From d1cdd75e7e7ac3ce607635263e63e55504e8582c Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Tue, 25 Aug 2026 11:28:32 -0400 Subject: [PATCH] chore: update all dependencies to latest and fix breaking changes (#457) --- .github/workflows/ci.yml | 18 +- .gitignore | 5 + .nvmrc | 2 +- .oxlintrc.json | 11 +- .vscode/settings.json | 4 +- AGENTS.md | 82 +- apps/web/.env.example | 3 + apps/web/.gitignore | 1 + .../app/@modal/(.)settings/account/page.tsx | 3 + .../@modal/(.)settings/notifications/page.tsx | 3 + apps/web/app/@modal/(.)settings/page.tsx | 3 + .../@modal/(.)settings/subscription/page.tsx | 3 + apps/web/app/@modal/default.tsx | 3 + apps/web/app/[domain]/page.tsx | 4 - apps/web/app/api/chat/[runId]/stream/route.ts | 97 +- apps/web/app/api/chat/route.ts | 78 +- apps/web/app/api/screenshot/route.ts | 17 - .../app/api/transport/[transport]/route.ts | 9 +- apps/web/app/error.tsx | 2 +- apps/web/app/global-error.tsx | 2 +- apps/web/app/layout.tsx | 4 +- apps/web/app/providers.tsx | 5 +- apps/web/app/robots.ts | 6 +- apps/web/app/settings/(tabs)/account/page.tsx | 3 + .../settings/(tabs)/notifications/page.tsx | 3 + .../app/settings/(tabs)/subscription/page.tsx | 3 + apps/web/app/sitemap.ts | 15 + .../components/ai-elements/conversation.tsx | 1 + .../components/ai-elements/prompt-input.tsx | 7 +- apps/web/components/ai-elements/reasoning.tsx | 33 +- .../ai-elements/shimmering-text.tsx | 6 - .../web/components/ai-elements/suggestion.tsx | 7 +- .../components/analytics/posthog-identity.tsx | 46 +- apps/web/components/auth/login-content.tsx | 13 +- apps/web/components/auth/oauth-button.tsx | 9 +- .../components/calendar-instructions.test.tsx | 151 + apps/web/components/calendar-instructions.tsx | 20 +- apps/web/components/chat/chat-client-lazy.tsx | 6 +- apps/web/components/chat/chat-client.tsx | 459 +- .../components/chat/chat-header-actions.tsx | 4 +- .../components/chat/chat-mode-selector.tsx | 18 +- apps/web/components/chat/chat-panel.tsx | 182 +- apps/web/components/chat/utils.ts | 19 - apps/web/components/consent/cookie-prompt.tsx | 34 +- .../add-domain/add-domain-clients.test.tsx | 136 + .../add-domain/add-domain-content.test.tsx | 112 + .../add-domain/add-domain-modal-client.tsx | 27 +- .../add-domain/add-domain-page-client.tsx | 27 +- .../share-instructions-dialog.test.tsx | 143 + .../add-domain/share-instructions-dialog.tsx | 26 +- .../add-domain/step-verify-ownership.tsx | 8 +- .../dashboard/add-domain/test-utils.tsx | 46 + .../dashboard/archived-domains-list.test.tsx | 101 + .../dashboard/archived-domains-list.tsx | 1 + .../dashboard/bulk-actions-toolbar.tsx | 1 + .../dashboard/calendar-feed-popover.test.tsx | 44 + .../dashboard-banner-dismissable.test.tsx | 32 + .../dashboard-banner-dismissable.tsx | 8 +- .../components/dashboard/dashboard-client.tsx | 104 +- .../dashboard-confirm-dialog.test.tsx | 118 + .../dashboard/dashboard-confirm-dialog.tsx | 49 + .../dashboard/dashboard-content.tsx | 47 +- .../dashboard/dashboard-grid-card.tsx | 6 +- .../components/dashboard/dashboard-grid.tsx | 54 +- .../components/dashboard/dashboard-header.tsx | 15 +- .../dashboard/dashboard-quota.test.tsx | 208 + .../dashboard/dashboard-shell.test.tsx | 594 + .../dashboard/dashboard-table-column-menu.tsx | 13 +- .../dashboard/dashboard-table-columns.tsx | 107 +- .../dashboard/dashboard-table-pagination.tsx | 5 +- .../components/dashboard/dashboard-table.tsx | 103 +- .../dashboard/mobile-filters-collapsible.tsx | 4 +- apps/web/components/dashboard/mocks/leaf.tsx | 34 + .../dashboard/mocks/share-instructions.tsx | 7 + .../dashboard/mocks/subscription.ts | 36 + .../web/components/dashboard/test-fixtures.ts | 163 + apps/web/components/dashboard/test-utils.tsx | 346 + .../dashboard/unverified-table-row.tsx | 14 +- .../dashboard/verified-table-row.tsx | 10 +- apps/web/components/domain/export-button.tsx | 9 +- .../web/components/domain/registrar-links.tsx | 2 +- .../domain/registration/raw-data-dialog.tsx | 22 +- apps/web/components/domain/report-client.tsx | 18 +- .../domain/report-section-error-boundary.tsx | 2 +- .../components/domain/screenshot-popover.tsx | 10 +- apps/web/components/domain/screenshot.tsx | 66 +- .../components/domain/seo/robots-summary.tsx | 15 +- .../components/layout/animated-background.tsx | 14 +- apps/web/components/layout/app-footer.tsx | 18 +- .../components/layout/static-background.tsx | 9 +- apps/web/components/layout/user-menu.tsx | 4 +- .../notifications/notification-card.tsx | 6 +- .../notification-empty-state.tsx | 4 +- .../notifications-popover.test.tsx | 251 + .../notifications/notifications-popover.tsx | 50 +- .../components/notifications/test-fixtures.ts | 22 + .../components/providers/haptics-provider.tsx | 23 + .../providers/vibration-provider.tsx | 156 - .../search/home-search-suggestions-client.tsx | 12 +- .../components/search/search-client.test.tsx | 10 +- apps/web/components/search/search-client.tsx | 28 +- .../account/delete-account-dialog.tsx | 2 +- .../components/settings/settings-content.tsx | 14 +- .../settings/settings-error-boundary.tsx | 2 +- apps/web/components/ui/map.tsx | 74 +- apps/web/components/ui/sonner.tsx | 41 - apps/web/context/dashboard-context.tsx | 11 +- apps/web/hooks/use-auth-callback.ts | 8 +- apps/web/hooks/use-browser-ai.ts | 183 +- apps/web/hooks/use-calendar-feed.test.tsx | 186 + apps/web/hooks/use-calendar-feed.ts | 18 +- apps/web/hooks/use-chat-persistence.ts | 79 +- .../hooks/use-dashboard-mutations.test.tsx | 280 + apps/web/hooks/use-dashboard-mutations.ts | 61 +- apps/web/hooks/use-dashboard-pagination.ts | 77 +- apps/web/hooks/use-dashboard-selection.ts | 62 +- apps/web/hooks/use-domain-verification.ts | 28 +- apps/web/hooks/use-hydrated-now.test.tsx | 16 + apps/web/hooks/use-hydrated-now.ts | 29 +- apps/web/hooks/use-is-client.ts | 15 + apps/web/hooks/use-linked-accounts.ts | 13 +- apps/web/hooks/use-local-chat.ts | 8 +- .../web/hooks/use-notification-preferences.ts | 10 +- .../web/hooks/use-notifications-data.test.tsx | 204 + apps/web/hooks/use-notifications-data.ts | 55 +- apps/web/hooks/use-section-tracking.ts | 8 +- apps/web/hooks/use-subscription.ts | 8 +- apps/web/instrumentation-client.ts | 29 +- apps/web/instrumentation.ts | 72 +- apps/web/lib/add-domain-resume.test.ts | 56 + apps/web/lib/add-domain-resume.ts | 31 + apps/web/lib/analytics/client.ts | 73 + apps/web/lib/analytics/server.ts | 68 + apps/web/lib/analytics/types.ts | 9 + apps/web/lib/atoms/chat-atoms.ts | 72 +- apps/web/lib/chat/client-tools.ts | 209 +- apps/web/lib/chat/domain-tools.test.ts | 103 + apps/web/lib/chat/domain-tools.ts | 173 + apps/web/lib/chat/message-parts.test.ts | 120 + apps/web/lib/chat/message-parts.ts | 57 + apps/web/lib/chat/request-schema.test.ts | 278 + apps/web/lib/chat/request-schema.ts | 76 + apps/web/lib/dashboard-table-features.ts | 35 + apps/web/lib/dashboard-utils.test.ts | 399 + apps/web/lib/dashboard-utils.ts | 16 +- apps/web/lib/ratelimit/client.ts | 11 +- apps/web/lib/stores/chat-store.ts | 58 +- apps/web/lib/stores/preferences-store.ts | 21 +- .../web/lib/verification-instructions.test.ts | 70 + apps/web/mocks/next-image.test.ts | 23 - apps/web/mocks/next-link.ts | 27 + apps/web/mocks/trpc.ts | 528 + apps/web/next-env.d.ts | 1 + apps/web/next.config.ts | 10 +- apps/web/package.json | 111 +- apps/web/server/routers/domain.ts | 2 +- apps/web/server/routers/provider.ts | 2 +- apps/web/server/routers/tracking.ts | 26 +- apps/web/server/routers/user.ts | 2 +- apps/web/vercel.json | 5 +- apps/web/vitest.config.ts | 10 + apps/web/vitest.setup.browser.ts | 8 +- apps/web/vitest.setup.node.ts | 5 +- apps/web/workflows/chat/gateway.ts | 39 +- apps/web/workflows/chat/logging.ts | 205 - apps/web/workflows/chat/tools.ts | 274 +- apps/web/workflows/chat/workflow.ts | 89 +- package.json | 14 +- packages/analytics/.oxlintrc.json | 4 - packages/analytics/package.json | 32 - packages/analytics/src/client.ts | 121 - packages/analytics/src/server.ts | 154 - packages/analytics/src/types.ts | 21 - packages/analytics/tsconfig.json | 8 - packages/api/package.json | 5 +- packages/api/src/context.ts | 6 +- packages/api/src/middleware/logging.ts | 43 +- packages/auth/package.json | 6 +- packages/auth/src/analytics.ts | 26 + packages/auth/src/client.ts | 3 +- packages/auth/src/server.ts | 14 + packages/blob/package.json | 4 +- packages/constants/src/ai.ts | 26 + packages/db/package.json | 8 +- packages/db/src/client.ts | 18 +- packages/db/src/testing.ts | 4 +- packages/email/package.json | 8 +- packages/email/src/components/box.tsx | 2 +- packages/email/src/components/button.tsx | 2 +- packages/email/src/components/layout.tsx | 2 +- packages/email/src/components/shared.tsx | 2 +- packages/image/package.json | 2 +- packages/image/src/optimize.ts | 6 +- packages/logger/package.json | 10 +- packages/logger/src/index.ts | 65 +- packages/logger/src/otel.ts | 244 + packages/polar/package.json | 6 +- packages/polar/src/analytics.ts | 26 + packages/polar/src/emails.ts | 4 +- packages/polar/src/handlers.test.ts | 63 +- packages/polar/src/handlers.ts | 63 +- packages/polar/src/products.ts | 11 +- packages/redis/package.json | 2 +- packages/safe-fetch/package.json | 2 +- packages/screenshot/package.json | 8 +- packages/server/package.json | 12 +- packages/server/src/services/hosting.ts | 3 +- packages/server/src/tls/utils.ts | 2 +- packages/typescript-config/nextjs.json | 3 + packages/ui/package.json | 20 +- packages/ui/src/components/code-block.tsx | 67 +- packages/ui/src/components/copy-button.tsx | 14 +- packages/ui/src/components/multi-select.tsx | 11 +- packages/ui/src/components/slider.tsx | 6 +- packages/ui/src/components/stepper.tsx | 47 +- packages/ui/src/components/toast.tsx | 226 + pnpm-lock.yaml | 11610 ++++++---------- pnpm-workspace.yaml | 57 +- turbo.json | 9 +- 219 files changed, 12663 insertions(+), 10268 deletions(-) create mode 100644 apps/web/app/sitemap.ts create mode 100644 apps/web/components/calendar-instructions.test.tsx create mode 100644 apps/web/components/dashboard/add-domain/add-domain-clients.test.tsx create mode 100644 apps/web/components/dashboard/add-domain/add-domain-content.test.tsx create mode 100644 apps/web/components/dashboard/add-domain/share-instructions-dialog.test.tsx create mode 100644 apps/web/components/dashboard/add-domain/test-utils.tsx create mode 100644 apps/web/components/dashboard/archived-domains-list.test.tsx create mode 100644 apps/web/components/dashboard/calendar-feed-popover.test.tsx create mode 100644 apps/web/components/dashboard/dashboard-banner-dismissable.test.tsx create mode 100644 apps/web/components/dashboard/dashboard-confirm-dialog.test.tsx create mode 100644 apps/web/components/dashboard/dashboard-confirm-dialog.tsx create mode 100644 apps/web/components/dashboard/dashboard-quota.test.tsx create mode 100644 apps/web/components/dashboard/dashboard-shell.test.tsx create mode 100644 apps/web/components/dashboard/mocks/leaf.tsx create mode 100644 apps/web/components/dashboard/mocks/share-instructions.tsx create mode 100644 apps/web/components/dashboard/mocks/subscription.ts create mode 100644 apps/web/components/dashboard/test-fixtures.ts create mode 100644 apps/web/components/dashboard/test-utils.tsx create mode 100644 apps/web/components/notifications/notifications-popover.test.tsx create mode 100644 apps/web/components/notifications/test-fixtures.ts create mode 100644 apps/web/components/providers/haptics-provider.tsx delete mode 100644 apps/web/components/providers/vibration-provider.tsx delete mode 100644 apps/web/components/ui/sonner.tsx create mode 100644 apps/web/hooks/use-calendar-feed.test.tsx create mode 100644 apps/web/hooks/use-dashboard-mutations.test.tsx create mode 100644 apps/web/hooks/use-hydrated-now.test.tsx create mode 100644 apps/web/hooks/use-is-client.ts create mode 100644 apps/web/hooks/use-notifications-data.test.tsx create mode 100644 apps/web/lib/add-domain-resume.test.ts create mode 100644 apps/web/lib/add-domain-resume.ts create mode 100644 apps/web/lib/analytics/client.ts create mode 100644 apps/web/lib/analytics/server.ts create mode 100644 apps/web/lib/analytics/types.ts create mode 100644 apps/web/lib/chat/domain-tools.test.ts create mode 100644 apps/web/lib/chat/domain-tools.ts create mode 100644 apps/web/lib/chat/message-parts.test.ts create mode 100644 apps/web/lib/chat/message-parts.ts create mode 100644 apps/web/lib/chat/request-schema.test.ts create mode 100644 apps/web/lib/chat/request-schema.ts create mode 100644 apps/web/lib/dashboard-table-features.ts create mode 100644 apps/web/lib/dashboard-utils.test.ts create mode 100644 apps/web/lib/verification-instructions.test.ts delete mode 100644 apps/web/mocks/next-image.test.ts create mode 100644 apps/web/mocks/next-link.ts create mode 100644 apps/web/mocks/trpc.ts delete mode 100644 apps/web/workflows/chat/logging.ts delete mode 100644 packages/analytics/.oxlintrc.json delete mode 100644 packages/analytics/package.json delete mode 100644 packages/analytics/src/client.ts delete mode 100644 packages/analytics/src/server.ts delete mode 100644 packages/analytics/src/types.ts delete mode 100644 packages/analytics/tsconfig.json create mode 100644 packages/auth/src/analytics.ts create mode 100644 packages/logger/src/otel.ts create mode 100644 packages/polar/src/analytics.ts create mode 100644 packages/ui/src/components/toast.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dd522f8..0c502f36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,19 +20,15 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 + uses: pnpm/setup@v2 with: - node-version: "24" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + runtime: node@24 + cache: true - name: Install Playwright browsers run: pnpm --filter @domainstack/web exec playwright install chromium --with-deps @@ -50,6 +46,6 @@ jobs: run: pnpm test:coverage - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index c63799bd..5eaaa4e7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules coverage __screenshots__ test-results +.vitest-attachments # Turbo .turbo @@ -49,5 +50,9 @@ next-env.d.ts # SWC .swc +# Workflow SDK +.workflow-data +**/.well-known/workflow + # next-agents-md .next-docs/ diff --git a/.nvmrc b/.nvmrc index eefb690f..039ec409 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.15.0 \ No newline at end of file +24.19.0 \ No newline at end of file diff --git a/.oxlintrc.json b/.oxlintrc.json index 0bf3425c..6f49b0a7 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,15 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": [ - "oxc", - "eslint", - "typescript", - "react", - "nextjs", - "import", - "unicorn", - "vitest" - ], + "plugins": ["oxc", "eslint", "typescript", "react", "nextjs", "import", "unicorn", "vitest"], "categories": { "correctness": "error", "suspicious": "warn", diff --git a/.vscode/settings.json b/.vscode/settings.json index 1dfffe34..216ec6bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,12 @@ { - "typescript.tsdk": "node_modules/typescript/lib", + "js/ts.experimental.useTsgo": true, + "js/ts.tsdk.path": "node_modules/typescript", "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true, "editor.formatOnPaste": true, "editor.codeActionsOnSave": { "source.fixAll.oxlint": "explicit" }, - "emmet.showExpandedAbbreviation": "never", "[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, diff --git a/AGENTS.md b/AGENTS.md index 1d16b3c2..c806cf04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,28 +7,32 @@ 1. `pnpm lint` — Must pass with zero warnings 2. `pnpm fmt:check` — Must pass with zero warnings 3. `pnpm check-types` — Must pass with zero warnings -3. `pnpm test` — Must pass with zero warnings +4. `pnpm test` — Must pass with zero warnings Do not proceed with commits until all four checks are clean. ## Commands ### Development + - `pnpm dev` — Start Next.js dev server at http://localhost:3000 - `pnpm build` — Compile production bundle - `pnpm check-types` — Run `tsc --noEmit` for type diagnostics ### Linting & Formatting + - `pnpm lint` — Run oxlint lint - `pnpm fmt` — Apply oxfmt formatting ### Testing + - `pnpm test` — Run all tests once - `pnpm test path/to/file.test.ts` — Run a single test file - `pnpm test -t "test name"` — Run tests matching a pattern - `pnpm test:coverage` — Run tests with coverage report ### Database + - `pnpm db:generate` — Generate Drizzle migrations - `pnpm db:push` — Push schema to database - `pnpm db:migrate` — Apply migrations @@ -37,29 +41,34 @@ Do not proceed with commits until all four checks are clean. ## Code Style ### General + - TypeScript only, `strict` enabled - 2-space indentation (oxfmt enforces) - Prefer small, pure modules - Node.js >= 24 required ### Naming Conventions + - **Files/folders:** kebab-case (`user-settings.ts`) - **React components:** PascalCase exports (`UserSettings`) - **Helpers/hooks:** camelCase named exports (`useUserSettings`) ### Imports + - Use `@/...` path aliases for app-specific imports - Import shared UI components from `@domainstack/ui/*` (e.g., `@domainstack/ui/button`) - oxfmt auto-organizes imports on save - Client components must start with `"use client"` ### Types + - Shared domain types in `@domainstack/types` package - Enum const arrays (primitives) in `@domainstack/constants` (Drizzle pgEnums derive from these) - Do NOT use Zod for simple enums or internal database types - Import types from `@domainstack/types` ### Tailwind Classes + - oxfmt enforces sorted Tailwind classes via `useSortedClasses` rule - Use `cn()` from `@domainstack/ui/utils` for conditional classes @@ -218,25 +227,27 @@ Concise rules for building accessible, fast, delightful UIs. Use MUST/SHOULD/NEV ## Error Handling ### Workflow Steps + Use `lib/workflow/errors.ts` utilities for proper error classification: + ```typescript import { classifyFetchError, withFetchErrorHandling } from "@/lib/workflow"; async function fetchDataStep(domain: string): Promise { "use step"; - return await withFetchErrorHandling( - () => fetchData(domain), - { context: `fetching ${domain}` } - ); + return await withFetchErrorHandling(() => fetchData(domain), { context: `fetching ${domain}` }); } ``` Error classification: + - **FatalError** (don't retry): DNS errors, TLS errors, invalid URLs, blocked hosts - **RetryableError** (retry with backoff): Timeouts, network errors, server errors ### Custom Error Classes + Create domain-specific errors with typed codes: + ```typescript export class SafeFetchError extends Error { constructor( @@ -250,14 +261,18 @@ export class SafeFetchError extends Error { ``` ### tRPC Errors + Use `TRPCError` with appropriate codes: + ```typescript throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" }); throw new TRPCError({ code: "NOT_FOUND", message: "Domain not found" }); ``` ### Rate Limiting + Use Upstash Redis for rate limiting via the `withRateLimit` middleware: + ```typescript import { publicProcedure, withRateLimit } from "@/trpc/init"; @@ -280,6 +295,7 @@ export const myRouter = createTRPCRouter({ ## Logging Server-side only using Pino (object-first API): + ```typescript import { createLogger } from "@domainstack/logger"; const logger = createLogger({ source: "dns" }); @@ -293,17 +309,20 @@ Client-side: Use `analytics.trackException(error, context)` for errors. ## Testing Patterns ### File Organization + - Node tests: `**/*.test.ts` (run in Node environment) - Browser tests: `**/*.test.tsx` (run in Playwright browser) - Tests live next to the code they test ### Mocking + - Analytics and logger are globally mocked in `vitest.setup.node.ts` - Use `vi.hoisted` for ESM module mocks - Use PGlite (`@/lib/db/pglite`) for isolated database testing - Mock `@vercel/blob` for storage tests ### Example Test + ```typescript import { describe, expect, it, vi } from "vitest"; @@ -359,17 +378,20 @@ All commands run from the **monorepo root** via Turborepo. ### Package Imports **Constants** (`@domainstack/constants`): + ```typescript // Pure constants - no runtime dependencies import { DNS_RECORD_TYPES, PLANS, REPOSITORY_SLUG } from "@domainstack/constants"; ``` **Types** (`@domainstack/types`): + ```typescript import type { DnsRecord, RegistrationResponse, Certificate } from "@domainstack/types"; ``` **UI Components** (`@domainstack/ui`): + ```typescript import { Button } from "@domainstack/ui/button"; import { Card, CardHeader, CardContent } from "@domainstack/ui/card"; @@ -377,13 +399,20 @@ import { cn } from "@domainstack/ui/utils"; import { useMediaQuery } from "@domainstack/ui/hooks"; ``` -**App-specific wrappers** (in `apps/web/components/ui/`): -- `sonner.tsx` — Configures toast notifications with theme support +**Toasts** (`@domainstack/ui/toast`): + +```typescript +import { toast, Toaster } from "@domainstack/ui/toast"; + +toast.add({ title: "Domain archived", type: "success" }); +``` ## Key Patterns ### SWR Caching + Repository functions return `CacheResult` with staleness metadata: + ```typescript const { data, stale } = await getRegistration("example.com"); if (stale) { @@ -392,15 +421,16 @@ if (stale) { ``` ### Workflow Concurrency + Use deduplication for concurrent requests: + ```typescript import { startWithDeduplication, getDeduplicationKey } from "@/lib/workflow"; import { start } from "workflow/api"; const key = getDeduplicationKey("registration", domain); -const { result, deduplicated, source } = await startWithDeduplication( - key, - () => start(registrationWorkflow, [{ domain }]), +const { result, deduplicated, source } = await startWithDeduplication(key, () => + start(registrationWorkflow, [{ domain }]), ); // result: T - the workflow return value // deduplicated: boolean - true if attached to existing run @@ -408,6 +438,7 @@ const { result, deduplicated, source } = await startWithDeduplication( ``` ### Protected tRPC Procedures + ```typescript import { protectedProcedure } from "@/trpc/init"; @@ -419,6 +450,7 @@ export const myRouter = createTRPCRouter({ ``` ### Optimistic Updates (TanStack Query) + ```typescript const mutation = useMutation({ ...trpc.tracking.removeDomain.mutationOptions(), @@ -441,53 +473,54 @@ Use `useSuspenseQuery` for declarative data fetching with React Suspense boundar Exemplar: `components/domain/report-client.tsx` **When to use Suspense:** + - Simple read-only queries without `enabled` flag - Components that render data immediately (no conditional logic) - Parallel independent data sections that can load separately **When NOT to use Suspense:** + - Queries with `enabled` option (conditional fetching) - Hooks with mutations and optimistic updates (e.g., `useTrackedDomains`) - Lazy-loaded data (hover triggers, infinite scroll) - Polling-based queries **Pattern:** + ```tsx // Parent wraps with boundaries }> }> - +; // Component uses useSuspenseQuery - data is guaranteed non-null function MyComponent() { - const { data } = useSuspenseQuery( - trpc.myRouter.myQuery.queryOptions() - ); + const { data } = useSuspenseQuery(trpc.myRouter.myQuery.queryOptions()); return
{data.value}
; } ``` **Parallel queries:** + ```tsx function MyComponent() { const [query1, query2] = useSuspenseQueries({ - queries: [ - trpc.router1.query1.queryOptions(), - trpc.router2.query2.queryOptions(), - ], + queries: [trpc.router1.query1.queryOptions(), trpc.router2.query2.queryOptions()], }); // Both are guaranteed to have data } ``` **Error boundaries:** + - Use `SectionErrorBoundary` for domain report sections - Use `SettingsErrorBoundary` for settings panels - Create context-specific boundaries with `CreateIssueButton` for error reporting **Skeleton requirements:** + - MUST mirror final content layout to prevent CLS - Export skeleton components for reuse (e.g., `CalendarInstructionsSkeleton`) @@ -496,24 +529,29 @@ function MyComponent() { The AI chat assistant (`components/chat/`) provides natural language domain lookups using Vercel's Workflow SDK. ### Architecture -- **Client**: `useDomainChat` hook with session persistence via localStorage -- **API**: `POST /api/chat` starts workflow, returns streaming response -- **Workflow**: `workflows/chat/workflow.ts` uses `DurableAgent` for durable tool execution + +- **Client**: `useChat` + `WorkflowChatTransport` (`@ai-sdk/workflow`) with Zustand session persistence +- **API**: `POST /api/chat` starts workflow, returns streaming response; `GET /api/chat/:runId/stream` reconnects +- **Workflow**: `workflows/chat/workflow.ts` uses `WorkflowAgent` from `@ai-sdk/workflow` for durable tool execution - **Tools**: `workflows/chat/tools.ts` defines domain lookup tools (WHOIS, DNS, SSL, etc.) -### Constants (`lib/constants/ai.ts`) +### Constants (`packages/constants/src/ai.ts`) + All chat limits are centralized for client/server consistency. ### Rate Limits + Differentiated by auth status and endpoint. ### Security Layers + 1. **Rate limiting**: Per-user/IP via Upstash Redis 2. **Input validation**: Zod schema validates message structure and length 3. **Conversation truncation**: Only last N messages sent to model 4. **System prompt defense**: Refuses off-topic questions, ignores override attempts ### Adding New Tools + 1. Define tool in `workflows/chat/tools.ts` using `createDomainToolset()` 2. Add human-readable title in `components/chat/utils.ts` (`TOOL_TITLES`) 3. Tools call tRPC procedures which have their own rate limits diff --git a/apps/web/.env.example b/apps/web/.env.example index e172ebaf..460bc2c1 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -4,6 +4,9 @@ NEXT_PUBLIC_POSTHOG_KEY= POSTHOG_API_KEY= POSTHOG_ENV_ID= +# Optional: override Pino log level (default: debug in development, info in production, warn in tests) +LOG_LEVEL= + # Postgres connection string (with credentials) DATABASE_URL= diff --git a/apps/web/.gitignore b/apps/web/.gitignore index a051b7fb..95c4d87f 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1,3 +1,4 @@ next-env.d.ts .vercel .env*.local +/.swc diff --git a/apps/web/app/@modal/(.)settings/account/page.tsx b/apps/web/app/@modal/(.)settings/account/page.tsx index 097c7f7e..456db9c5 100644 --- a/apps/web/app/@modal/(.)settings/account/page.tsx +++ b/apps/web/app/@modal/(.)settings/account/page.tsx @@ -1,3 +1,6 @@ +// instant = false: SettingsModalLayout owns the UI; this page is a route shell. +export const instant = false; + export default function InterceptedSettingsAccountPage() { return null; } diff --git a/apps/web/app/@modal/(.)settings/notifications/page.tsx b/apps/web/app/@modal/(.)settings/notifications/page.tsx index 872c34a4..fdf8a771 100644 --- a/apps/web/app/@modal/(.)settings/notifications/page.tsx +++ b/apps/web/app/@modal/(.)settings/notifications/page.tsx @@ -1,3 +1,6 @@ +// instant = false: SettingsModalLayout owns the UI; this page is a route shell. +export const instant = false; + export default function InterceptedSettingsNotificationsPage() { return null; } diff --git a/apps/web/app/@modal/(.)settings/page.tsx b/apps/web/app/@modal/(.)settings/page.tsx index 157b6a18..4edac5ad 100644 --- a/apps/web/app/@modal/(.)settings/page.tsx +++ b/apps/web/app/@modal/(.)settings/page.tsx @@ -1,5 +1,8 @@ import { redirect } from "next/navigation"; +// instant = false: SettingsModalLayout owns the UI; this page is a route shell. +export const instant = false; + export default function InterceptedSettingsIndexPage() { redirect("/settings/subscription"); } diff --git a/apps/web/app/@modal/(.)settings/subscription/page.tsx b/apps/web/app/@modal/(.)settings/subscription/page.tsx index 7e496084..837dd835 100644 --- a/apps/web/app/@modal/(.)settings/subscription/page.tsx +++ b/apps/web/app/@modal/(.)settings/subscription/page.tsx @@ -1,3 +1,6 @@ +// instant = false: SettingsModalLayout owns the UI; this page is a route shell. +export const instant = false; + export default function InterceptedSettingsSubscriptionPage() { return null; } diff --git a/apps/web/app/@modal/default.tsx b/apps/web/app/@modal/default.tsx index 6ddf1b76..e08c3a20 100644 --- a/apps/web/app/@modal/default.tsx +++ b/apps/web/app/@modal/default.tsx @@ -1,3 +1,6 @@ +// instant = false: empty @modal slot except during intercepting navigations. +export const instant = false; + export default function Default() { return null; } diff --git a/apps/web/app/[domain]/page.tsx b/apps/web/app/[domain]/page.tsx index edcb1d6c..5a28c9e1 100644 --- a/apps/web/app/[domain]/page.tsx +++ b/apps/web/app/[domain]/page.tsx @@ -3,7 +3,6 @@ import { notFound, redirect } from "next/navigation"; import { DomainReportClient } from "@/components/domain/report-client"; import { toRegistrableDomain } from "@/lib/normalize-domain"; -import { analytics } from "@domainstack/analytics/server"; export async function generateMetadata({ params, @@ -63,8 +62,5 @@ export default async function DomainPage({ params }: { params: Promise<{ domain: redirect(`/${encodeURIComponent(registrable)}`); } - // Track server-side page view - analytics.track("report_viewed", { domain: registrable }); - return ; } diff --git a/apps/web/app/api/chat/[runId]/stream/route.ts b/apps/web/app/api/chat/[runId]/stream/route.ts index e7d9e01a..2f0c1122 100644 --- a/apps/web/app/api/chat/[runId]/stream/route.ts +++ b/apps/web/app/api/chat/[runId]/stream/route.ts @@ -8,9 +8,11 @@ * network issues or Vercel Function timeouts. */ +import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow"; import { createUIMessageStreamResponse } from "ai"; import { type NextRequest, NextResponse } from "next/server"; import { getRun } from "workflow/api"; +import { RunExpiredError, WorkflowRunNotFoundError, WorkflowWorldError } from "workflow/errors"; import { checkRateLimit } from "@/lib/ratelimit/api"; import { auth } from "@domainstack/auth/server"; @@ -19,27 +21,18 @@ import { createLogger } from "@domainstack/logger"; const logger = createLogger({ source: "api/chat/stream" }); -/** - * GET /api/chat/:runId/stream - * - * Reconnect to an existing chat workflow stream. - * Supports startIndex query param to resume from a specific chunk. - */ export async function GET( request: NextRequest, { params }: { params: Promise<{ runId: string }> }, ) { - // Check authentication status for differentiated rate limits let isAuthenticated = false; try { const session = await auth.api.getSession({ headers: request.headers }); isAuthenticated = !!session?.user?.id; } catch (err) { - // Auth error - treat as anonymous, but log for debugging logger.debug({ err }, "auth session check failed, treating as anonymous"); } - // Apply rate limits based on auth status const rateLimitConfig = isAuthenticated ? RATE_LIMIT_AUTHENTICATED.stream : RATE_LIMIT_ANONYMOUS.stream; @@ -54,75 +47,65 @@ export async function GET( } const { runId } = await params; - const startIndexParam = request.nextUrl.searchParams.get("startIndex"); - const parsedIndex = startIndexParam ? Number.parseInt(startIndexParam, 10) : 0; - // Validate startIndex is a non-negative integer, default to 0 if invalid - const startIndex = Number.isNaN(parsedIndex) || parsedIndex < 0 ? 0 : parsedIndex; + const rawStartIndex = request.nextUrl.searchParams.get("startIndex") ?? "0"; + const startIndex = /^\d+$/.test(rawStartIndex) ? Number(rawStartIndex) : Number.NaN; + if (!Number.isSafeInteger(startIndex)) { + return NextResponse.json( + { error: "startIndex must be a non-negative safe integer" }, + { status: 400, headers: { ...rateLimit.headers } }, + ); + } try { const run = getRun(runId); - - // Check if run exists by checking status const status = await run.status; if (status === "failed") { logger.error({ runId }, "chat workflow failed"); return NextResponse.json( { error: "Workflow failed" }, - { - status: 500, - headers: { ...rateLimit.headers }, - }, + { status: 500, headers: { ...rateLimit.headers } }, ); } - // Get readable stream from the specified index - const readable = run.getReadable({ startIndex }); + const readable = run + .getReadable({ startIndex: 0 }) + .pipeThrough(createModelCallToUIChunkTransform({ uiStartIndex: startIndex })); - // Return streaming response using AI SDK's createUIMessageStreamResponse - // This properly serializes UIMessageChunk objects for HTTP streaming return createUIMessageStreamResponse({ stream: readable, - headers: { ...rateLimit.headers }, + headers: { + "x-workflow-run-id": runId, + ...rateLimit.headers, + }, }); } catch (err) { - // Provide more specific error messages based on error type - const error = err instanceof Error ? err : new Error(String(err)); - let errorMessage = "Chat session not found or expired"; - let statusCode = 404; - - // Check for workflow run no longer available (400 means run completed/expired) - // This is expected when the client tries to reconnect after the workflow finished - if (error.message.includes("400") || error.message.includes("Bad Request")) { - errorMessage = "Chat session completed or expired."; - statusCode = 410; // Gone - resource no longer available - } else if (error.message.includes("timeout")) { - errorMessage = "Connection timed out. Please try again."; - statusCode = 408; - } else if (error.message.includes("network")) { - errorMessage = "Network error. Please check your connection."; - statusCode = 502; - } else if (!error.message.includes("not found") && !error.message.includes("expired")) { - // Unexpected error - use 500 instead of misleading 404 - errorMessage = "An unexpected error occurred. Please try again."; - statusCode = 500; + // Completed runs are reported as a 400 WorkflowWorldError. + if (WorkflowWorldError.is(err) && err.status === 400) { + logger.debug({ runId }, "chat stream reconnection to completed workflow"); + return NextResponse.json( + { error: "Chat session completed or expired." }, + { status: 410, headers: { ...rateLimit.headers } }, + ); } - // Log at appropriate severity: error for 500s, warn/debug for expected errors - if (statusCode === 500) { - logger.error({ err, runId, statusCode }, "unexpected error reconnecting to chat stream"); - } else if (statusCode === 410) { - // 410 Gone is expected when reconnecting to a completed workflow - logger.debug({ runId, statusCode }, "chat stream reconnection to completed workflow"); - } else { - logger.warn({ err, runId, statusCode }, "failed to reconnect to chat stream"); + // Missing or expired runs should not be treated as unexpected 500s. + if ( + WorkflowRunNotFoundError.is(err) || + RunExpiredError.is(err) || + (err instanceof Error && err.name === "StreamExpiredError") || + (WorkflowWorldError.is(err) && (err.status === 404 || err.status === 410)) + ) { + logger.debug({ runId }, "chat stream reconnection to unavailable workflow"); + return NextResponse.json( + { error: "Chat session completed or expired." }, + { status: 404, headers: { ...rateLimit.headers } }, + ); } + logger.error({ err, runId }, "unexpected error reconnecting to chat stream"); return NextResponse.json( - { error: errorMessage }, - { - status: statusCode, - headers: { ...rateLimit.headers }, - }, + { error: "An unexpected error occurred. Please try again." }, + { status: 500, headers: { ...rateLimit.headers } }, ); } } diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index f8e90908..bc506b2a 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -3,7 +3,7 @@ * * POST /api/chat - Start a chat workflow and stream the response * - * Uses the Workflow SDK's DurableAgent for: + * Uses WorkflowAgent for: * - Durable tool execution with automatic retries * - Streaming responses via getWritable()/getReadable() * - Resumable streams for client reconnection after timeouts @@ -15,18 +15,19 @@ * - Conversation history truncation */ +import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow"; import { ipAddress } from "@vercel/functions"; import { createUIMessageStreamResponse, type UIMessage } from "ai"; import { NextResponse } from "next/server"; import { start } from "workflow/api"; -import { z } from "zod"; +import { chatRequestSchema } from "@/lib/chat/request-schema"; import { checkRateLimit } from "@/lib/ratelimit/api"; import { chatWorkflow } from "@/workflows/chat"; import { auth } from "@domainstack/auth/server"; import { + MAX_CHAT_REQUEST_BYTES, MAX_CONVERSATION_MESSAGES, - MAX_MESSAGE_LENGTH, RATE_LIMIT_ANONYMOUS, RATE_LIMIT_AUTHENTICATED, } from "@domainstack/constants"; @@ -34,45 +35,6 @@ import { createLogger } from "@domainstack/logger"; const logger = createLogger({ source: "api/chat" }); -/** - * Zod schema for chat request validation. - * - * Validates: - * - Message array exists and isn't too long - * - Each message has required fields - * - Text content doesn't exceed max length - * - Domain is a reasonable string if provided - */ -const chatRequestSchema = z.object({ - messages: z - .array( - z - .object({ - id: z.string(), - role: z.enum(["user", "assistant"]), - parts: z.array( - z.union([ - z.object({ - type: z.literal("text"), - text: z.string().max(MAX_MESSAGE_LENGTH, { - message: `Message text exceeds ${MAX_MESSAGE_LENGTH} characters`, - }), - }), - // Allow other part types (tool calls, etc.) to pass through - z.object({ type: z.string() }).passthrough(), - ]), - ), - }) - // Allow additional fields from UIMessage (metadata, createdAt, etc.) - .passthrough(), - ) - .min(1, { message: "At least one message is required" }) - .max(MAX_CONVERSATION_MESSAGES * 2, { - message: `Too many messages (max ${MAX_CONVERSATION_MESSAGES * 2})`, - }), - domain: z.string().max(253, { message: "Domain name too long" }).optional(), -}); - /** * POST /api/chat * @@ -102,10 +64,36 @@ export async function POST(request: Request) { return rateLimit.error; } + const contentLength = Number(request.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_CHAT_REQUEST_BYTES) { + return NextResponse.json( + { error: "Request body too large" }, + { status: 413, headers: { ...rateLimit.headers } }, + ); + } + // Parse and validate request body + let rawBody: string; + try { + rawBody = await request.text(); + } catch (err) { + logger.warn({ err }, "failed to read chat request body"); + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400, headers: { ...rateLimit.headers } }, + ); + } + + if (new TextEncoder().encode(rawBody).byteLength > MAX_CHAT_REQUEST_BYTES) { + return NextResponse.json( + { error: "Request body too large" }, + { status: 413, headers: { ...rateLimit.headers } }, + ); + } + let body: unknown; try { - body = await request.json(); + body = JSON.parse(rawBody); } catch (err) { logger.warn({ err }, "invalid JSON in chat request body"); return NextResponse.json( @@ -139,9 +127,9 @@ export async function POST(request: Request) { try { const run = await start(chatWorkflow, [{ messages, domain, ip, userId }]); - // Return streaming response + // Convert raw ModelCallStreamPart chunks to UI message chunks for the client return createUIMessageStreamResponse({ - stream: run.readable, + stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()), headers: { "x-workflow-run-id": run.runId, ...rateLimit.headers, diff --git a/apps/web/app/api/screenshot/route.ts b/apps/web/app/api/screenshot/route.ts index bdbd8909..3af8bbc1 100644 --- a/apps/web/app/api/screenshot/route.ts +++ b/apps/web/app/api/screenshot/route.ts @@ -4,7 +4,6 @@ import { getRun, start } from "workflow/api"; import { checkRateLimit } from "@/lib/ratelimit/api"; import { type ScreenshotWorkflowResult, screenshotWorkflow } from "@/workflows/screenshot"; -import { analytics } from "@domainstack/analytics/server"; import { getDomainById, getScreenshotByDomainId, isDomainBlocked } from "@domainstack/db/queries"; import { createLogger } from "@domainstack/logger"; @@ -110,10 +109,6 @@ export async function POST( } } - analytics.track("screenshot_api_cache_hit", { - domain: domain.name, - }); - return NextResponse.json( { status: "completed", @@ -133,11 +128,6 @@ export async function POST( "screenshot workflow started", ); - analytics.track("screenshot_api_workflow_started", { - domain: domain.name, - runId: run.runId, - }); - return NextResponse.json( { status: "running", @@ -188,11 +178,6 @@ export async function GET( if (status === "completed") { const result = (await run.returnValue) as ScreenshotWorkflowResult; - analytics.track("screenshot_api_workflow_completed", { - runId, - success: result.success, - }); - return NextResponse.json( { status: "completed", @@ -206,8 +191,6 @@ export async function GET( } if (status === "failed") { - analytics.track("screenshot_api_workflow_failed", { runId }); - return NextResponse.json( { status: "failed", diff --git a/apps/web/app/api/transport/[transport]/route.ts b/apps/web/app/api/transport/[transport]/route.ts index 0ba94365..2874f789 100644 --- a/apps/web/app/api/transport/[transport]/route.ts +++ b/apps/web/app/api/transport/[transport]/route.ts @@ -6,6 +6,10 @@ import { checkRateLimit } from "@/lib/ratelimit/api"; import { createCaller } from "@/server/routers/_app"; import type { Context } from "@/trpc/init"; +// mcp-handler v2 no longer accepts `maxDuration`/`basePath` as handler options - +// route timeout is now controlled via the standard Next.js route segment config. +export const maxDuration = 800; + /** * Domain input schema for MCP tools. * Uses simple string validation - normalization happens in tRPC layer. @@ -290,11 +294,6 @@ function createMcpHandlerWithContext(request: Request) { capabilities: { tools: {}, }, - }, - { - redisUrl: process.env.REDIS_URL, - basePath: "/api/transport", - maxDuration: 800, verboseLogs: process.env.NODE_ENV === "development", }, ); diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 1be7b4ce..dcf594eb 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -4,7 +4,7 @@ import { IconRefresh } from "@tabler/icons-react"; import { useEffect } from "react"; import { CreateIssueButton } from "@/components/create-issue-button"; -import { analytics } from "@domainstack/analytics/client"; +import { analytics } from "@/lib/analytics/client"; import { Button } from "@domainstack/ui/button"; export default function RootError(props: { diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx index 4b54dfd1..ef064c4b 100644 --- a/apps/web/app/global-error.tsx +++ b/apps/web/app/global-error.tsx @@ -3,7 +3,7 @@ import NextError from "next/error"; import { useEffect } from "react"; -import { analytics } from "@domainstack/analytics/client"; +import { analytics } from "@/lib/analytics/client"; export default function GlobalError({ error, diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 4574eb5c..6dcc9b8b 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,3 @@ -import { Analytics } from "@vercel/analytics/next"; import { GeistMono } from "geist/font/mono"; import { GeistSans } from "geist/font/sans"; import type { Metadata, Viewport } from "next"; @@ -9,7 +8,7 @@ import { ChatServer } from "@/components/chat/chat-server"; import { CookiePromptGeofenced } from "@/components/consent/cookie-prompt-geofenced"; import { AppFooter } from "@/components/layout/app-footer"; import { AppHeader } from "@/components/layout/app-header"; -import { Toaster } from "@/components/ui/sonner"; +import { Toaster } from "@domainstack/ui/toast"; import "./globals.css"; @@ -87,7 +86,6 @@ export default function RootLayout({ {modal} - ); diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index c880a512..32d3930c 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -5,7 +5,7 @@ import { MotionConfig } from "motion/react"; import { ThemeProvider } from "next-themes"; import { PostHogIdentityProvider } from "@/components/analytics/posthog-identity"; -import { VibrationProvider } from "@/components/providers/vibration-provider"; +import { HapticsProvider } from "@/components/providers/haptics-provider"; import { TRPCProvider } from "@/trpc/client"; import { TooltipProvider } from "@domainstack/ui/tooltip"; @@ -29,8 +29,7 @@ export function Providers({ children }: { children: React.ReactNode }) { ease: [0.22, 1, 0.36, 1] as const, }} > - {children} - + {children} diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts index c241917e..20b2f226 100644 --- a/apps/web/app/robots.ts +++ b/apps/web/app/robots.ts @@ -5,8 +5,12 @@ export default function robots(): MetadataRoute.Robots { rules: [ { userAgent: "*", - allow: ["/api/og"], + allow: ["/", "/api/og"], }, ], + sitemap: new URL( + "/sitemap.xml", + process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000", + ).toString(), }; } diff --git a/apps/web/app/settings/(tabs)/account/page.tsx b/apps/web/app/settings/(tabs)/account/page.tsx index c2182af3..144c4d8b 100644 --- a/apps/web/app/settings/(tabs)/account/page.tsx +++ b/apps/web/app/settings/(tabs)/account/page.tsx @@ -1,3 +1,6 @@ +// instant = false: SettingsTabsLayout owns the UI; this page is a route shell. +export const instant = false; + export default function SettingsAccountPage() { return null; } diff --git a/apps/web/app/settings/(tabs)/notifications/page.tsx b/apps/web/app/settings/(tabs)/notifications/page.tsx index 1e55bde0..9bab37d0 100644 --- a/apps/web/app/settings/(tabs)/notifications/page.tsx +++ b/apps/web/app/settings/(tabs)/notifications/page.tsx @@ -1,3 +1,6 @@ +// instant = false: SettingsTabsLayout owns the UI; this page is a route shell. +export const instant = false; + export default function SettingsNotificationsPage() { return null; } diff --git a/apps/web/app/settings/(tabs)/subscription/page.tsx b/apps/web/app/settings/(tabs)/subscription/page.tsx index b7c65d17..0179e000 100644 --- a/apps/web/app/settings/(tabs)/subscription/page.tsx +++ b/apps/web/app/settings/(tabs)/subscription/page.tsx @@ -1,3 +1,6 @@ +// instant = false: SettingsTabsLayout owns the UI; this page is a route shell. +export const instant = false; + export default function SettingsSubscriptionPage() { return null; } diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts new file mode 100644 index 00000000..9149e694 --- /dev/null +++ b/apps/web/app/sitemap.ts @@ -0,0 +1,15 @@ +import type { MetadataRoute } from "next"; + +/** + * Public marketing pages that should be discovered by search engines. + * Auth, dashboard, settings, and per-domain reports are intentionally omitted. + */ +const MARKETING_PATHS = ["/", "/help", "/mcp", "/privacy", "/terms"] as const; + +export default function sitemap(): MetadataRoute.Sitemap { + return MARKETING_PATHS.map((path) => ({ + url: new URL(path, process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000").toString(), + changeFrequency: path === "/" ? "weekly" : "monthly", + priority: path === "/" ? 1 : 0.6, + })); +} diff --git a/apps/web/components/ai-elements/conversation.tsx b/apps/web/components/ai-elements/conversation.tsx index 1f4e1c30..0a0696c2 100644 --- a/apps/web/components/ai-elements/conversation.tsx +++ b/apps/web/components/ai-elements/conversation.tsx @@ -21,6 +21,7 @@ export const Conversation = ({ stickyInstance, className, ...props }: Conversati scrollRef={scrollRef} contentRef={contentRef} role="log" + aria-relevant="additions" {...props} /> ); diff --git a/apps/web/components/ai-elements/prompt-input.tsx b/apps/web/components/ai-elements/prompt-input.tsx index 5c005738..726b45ec 100644 --- a/apps/web/components/ai-elements/prompt-input.tsx +++ b/apps/web/components/ai-elements/prompt-input.tsx @@ -11,6 +11,7 @@ import { useState, } from "react"; +import { useHaptics } from "@/components/providers/haptics-provider"; import { InputGroup, InputGroupAddon, @@ -36,12 +37,12 @@ export type PromptInputProps = Omit, "onSubmit"> }; export const PromptInput = ({ className, onSubmit, children, ...props }: PromptInputProps) => { + const { trigger } = useHaptics(); + const handleSubmit: FormEventHandler = (event) => { event.preventDefault(); - try { - navigator.vibrate([50]); - } catch {} + void trigger("medium"); const form = event.currentTarget; const formData = new FormData(form); diff --git a/apps/web/components/ai-elements/reasoning.tsx b/apps/web/components/ai-elements/reasoning.tsx index d5817cc2..5e455dd2 100644 --- a/apps/web/components/ai-elements/reasoning.tsx +++ b/apps/web/components/ai-elements/reasoning.tsx @@ -2,7 +2,16 @@ import { IconBrain, IconChevronDown } from "@tabler/icons-react"; import type { ComponentProps, ReactNode } from "react"; -import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Streamdown } from "streamdown"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@domainstack/ui/collapsible"; @@ -87,19 +96,21 @@ export const Reasoning = memo( }); const [hasAutoClosed, setHasAutoClosed] = useState(false); - const [startTime, setStartTime] = useState(null); + const startTimeRef = useRef(null); - // Track duration when streaming starts and ends + // Track duration when streaming starts and ends. Wall-clock timing has to + // live in an effect; start time is stored in a ref so we only setState once + // streaming finishes. useEffect(() => { if (isStreaming) { - if (startTime === null) { - setStartTime(Date.now()); - } - } else if (startTime !== null) { - setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S)); - setStartTime(null); + startTimeRef.current ??= Date.now(); + return; } - }, [isStreaming, startTime, setDuration]); + if (startTimeRef.current !== null) { + setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S)); + startTimeRef.current = null; + } + }, [isStreaming, setDuration]); // Auto-open when streaming starts, auto-close when streaming ends (once only) useEffect(() => { @@ -143,7 +154,7 @@ export type ReasoningTriggerProps = ComponentProps & const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => { if (isStreaming || duration === 0) { - return ; + return ; } if (duration === undefined) { return

Thought for a few seconds

; diff --git a/apps/web/components/ai-elements/shimmering-text.tsx b/apps/web/components/ai-elements/shimmering-text.tsx index 0fc9fa25..18f625e3 100644 --- a/apps/web/components/ai-elements/shimmering-text.tsx +++ b/apps/web/components/ai-elements/shimmering-text.tsx @@ -76,13 +76,11 @@ export function ShimmeringText({ } initial={{ backgroundPosition: "100% center", - opacity: 0, }} animate={ shouldAnimate ? { backgroundPosition: "0% center", - opacity: 1, } : {} } @@ -94,10 +92,6 @@ export function ShimmeringText({ repeatDelay, ease: "linear", }, - opacity: { - duration: 0.3, - delay, - }, }} > {text} diff --git a/apps/web/components/ai-elements/suggestion.tsx b/apps/web/components/ai-elements/suggestion.tsx index 69e7755e..0a951bed 100644 --- a/apps/web/components/ai-elements/suggestion.tsx +++ b/apps/web/components/ai-elements/suggestion.tsx @@ -2,6 +2,7 @@ import type { ComponentProps } from "react"; +import { useHaptics } from "@/components/providers/haptics-provider"; import { Button } from "@domainstack/ui/button"; import { ScrollArea } from "@domainstack/ui/scroll-area"; import { cn } from "@domainstack/ui/utils"; @@ -28,10 +29,10 @@ export const Suggestion = ({ children, ...props }: SuggestionProps) => { + const { trigger } = useHaptics(); + const handleClick = () => { - try { - navigator.vibrate([50]); - } catch {} + void trigger("light"); onClick?.(suggestion); }; diff --git a/apps/web/components/analytics/posthog-identity.tsx b/apps/web/components/analytics/posthog-identity.tsx index e0653e32..06e909a7 100644 --- a/apps/web/components/analytics/posthog-identity.tsx +++ b/apps/web/components/analytics/posthog-identity.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; -import { analytics } from "@domainstack/analytics/client"; +import { analytics } from "@/lib/analytics/client"; import { useSession } from "@domainstack/auth/client"; /** @@ -15,30 +15,33 @@ import { useSession } from "@domainstack/auth/client"; export function PostHogIdentityProvider({ children }: { children: React.ReactNode }) { const { data: session } = useSession(); const previousUserIdRef = useRef(null); + const userId = session?.user?.id; useEffect(() => { - const currentUserId = session?.user?.id ?? null; + const currentUserId = userId ?? null; const previousUserId = previousUserIdRef.current; - // User logged in or session hydrated with user + // User logged in, session hydrated, or account switched. if (currentUserId && currentUserId !== previousUserId) { - // Only identify if not already identified with this user - if (!analytics.isIdentified()) { - const user = session?.user; - if (user) { - analytics.identify( - user.id, - // $set properties (can change) - { - email: user.email, - name: user.name, - }, - // $set_once properties (immutable) - { - createdAt: user.createdAt ? new Date(user.createdAt).toISOString() : undefined, - }, - ); - } + // A direct account switch must not retain the previous person's identity. + if (previousUserId) { + analytics.reset(); + } + + const user = session?.user; + if (user) { + analytics.identify( + user.id, + // $set properties (can change) + { + email: user.email, + name: user.name, + }, + // $set_once properties (immutable) + { + createdAt: user.createdAt ? new Date(user.createdAt).toISOString() : undefined, + }, + ); } } @@ -47,9 +50,8 @@ export function PostHogIdentityProvider({ children }: { children: React.ReactNod analytics.reset(); } - // Update ref for next comparison previousUserIdRef.current = currentUserId; - }, [session]); + }, [session?.user, userId]); return <>{children}; } diff --git a/apps/web/components/auth/login-content.tsx b/apps/web/components/auth/login-content.tsx index fdb76caf..cf7f95fd 100644 --- a/apps/web/components/auth/login-content.tsx +++ b/apps/web/components/auth/login-content.tsx @@ -2,12 +2,14 @@ import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { OAuthButton } from "@/components/auth/oauth-button"; import { Logo } from "@/components/logo"; import { useAuthCallback } from "@/hooks/use-auth-callback"; +import { analytics } from "@/lib/analytics/client"; import { getEnabledProviders } from "@/lib/oauth"; +import { useSession } from "@domainstack/auth/client"; import { Icon } from "@domainstack/ui/icon"; import { cn } from "@domainstack/ui/utils"; @@ -24,10 +26,19 @@ export function LoginContent({ className, onNavigate, callbackURL }: LoginConten const [loadingProvider, setLoadingProvider] = useState(null); const pathname = usePathname(); const searchParams = useSearchParams(); + const { data: session, isPending } = useSession(); // Handle auth callback errors (e.g., OAuth failures redirect here with ?error=...) useAuthCallback(); + useEffect(() => { + if (isPending || session?.user) { + return; + } + + analytics.track("signup_pageview", { pathname }); + }, [isPending, pathname, session?.user]); + // Use provided callback URL, or auto-detect current page // After OAuth completes, better-auth redirects to this URL // Special cases: homepage (/) and /login page redirect to /dashboard diff --git a/apps/web/components/auth/oauth-button.tsx b/apps/web/components/auth/oauth-button.tsx index 3d751225..1f2ab7c8 100644 --- a/apps/web/components/auth/oauth-button.tsx +++ b/apps/web/components/auth/oauth-button.tsx @@ -1,12 +1,11 @@ "use client"; -import { toast } from "sonner"; - +import { useAnalytics } from "@/lib/analytics/client"; import type { OAuthProvider } from "@/lib/oauth"; -import { useAnalytics } from "@domainstack/analytics/client"; import { signIn } from "@domainstack/auth/client"; import { Button } from "@domainstack/ui/button"; import { Spinner } from "@domainstack/ui/spinner"; +import { toast } from "@domainstack/ui/toast"; import { cn } from "@domainstack/ui/utils"; interface OAuthButtonProps { @@ -78,8 +77,10 @@ export function OAuthButton({ provider: provider.id, action: "sign_in", }); - toast.error(`Failed to sign in with ${provider.name}.`, { + toast.add({ + title: `Failed to sign in with ${provider.name}.`, description: "Please try again or choose a different provider.", + type: "error", }); } }; diff --git a/apps/web/components/calendar-instructions.test.tsx b/apps/web/components/calendar-instructions.test.tsx new file mode 100644 index 00000000..a7f83b84 --- /dev/null +++ b/apps/web/components/calendar-instructions.test.tsx @@ -0,0 +1,151 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/trpc/client", async () => { + const { useTRPC } = await import("@/mocks/trpc"); + return { useTRPC }; +}); +vi.mock("@domainstack/ui/toast", () => ({ + toast: { + add: vi.fn<(options?: { title?: string; description?: string; type?: string }) => void>(), + }, +})); + +import { createTestQueryClient, render, screen, waitFor, within } from "@/mocks/react"; +import { + CALENDAR_FEED_QUERY_KEY, + CALENDAR_FEED_ROTATED_URL, + CALENDAR_FEED_URL, + type CalendarFeedData, + deleteCalendarFeedMutation, + enableCalendarFeedMutation, + resetTrpcMocks, + rotateCalendarFeedTokenMutation, + setCalendarFeedState, +} from "@/mocks/trpc"; + +import { CalendarInstructions } from "./calendar-instructions"; + +const enabledFeed: CalendarFeedData = { + enabled: true, + feedUrl: CALENDAR_FEED_URL, + lastAccessedAt: null, +}; + +function renderInstructions(feed: CalendarFeedData = { enabled: false }) { + const queryClient = createTestQueryClient(); + setCalendarFeedState(feed); + queryClient.setQueryData(CALENDAR_FEED_QUERY_KEY, feed); + return render(, { queryClient }); +} + +describe("CalendarInstructions", () => { + beforeEach(() => { + resetTrpcMocks(); + }); + + afterEach(() => { + resetTrpcMocks(); + }); + + it("enables the feed from the empty state", async () => { + const user = userEvent.setup(); + renderInstructions(); + + await user.click(screen.getByRole("button", { name: "Enable" })); + + await waitFor(() => { + expect(enableCalendarFeedMutation).toHaveBeenCalledOnce(); + }); + expect(await screen.findByText("Treat this URL like a password!")).toBeInTheDocument(); + expect(screen.getByText(CALENDAR_FEED_URL)).toBeInTheDocument(); + }); + + it("shows the feed URL and last-accessed copy when enabled", () => { + renderInstructions({ + ...enabledFeed, + lastAccessedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + }); + + expect(screen.getByText("Treat this URL like a password!")).toBeInTheDocument(); + expect(screen.getByText(CALENDAR_FEED_URL)).toBeInTheDocument(); + expect(screen.getByText(/Last accessed/)).toBeInTheDocument(); + expect(screen.queryByText("Not accessed yet.")).not.toBeInTheDocument(); + }); + + it("says the feed has not been accessed yet", () => { + renderInstructions(enabledFeed); + + expect(screen.getByText("Not accessed yet.")).toBeInTheDocument(); + }); + + it("opens calendar apps from the Open In menu", async () => { + const user = userEvent.setup(); + renderInstructions(enabledFeed); + + await user.click(screen.getByRole("button", { name: /Open In/ })); + await screen.findByRole("menu"); + + const webcal = CALENDAR_FEED_URL.replace("https://", "webcal://"); + expect(document.querySelector('a[href*="calendar.google.com"]')).toHaveAttribute( + "href", + `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(webcal)}`, + ); + expect(document.querySelector(`a[href="${webcal}"]`)).toBeInTheDocument(); + expect(document.querySelector('a[href*="outlook.office.com"]')).toHaveAttribute( + "href", + `https://outlook.office.com/calendar/0/addfromweb?url=${encodeURIComponent(webcal)}`, + ); + expect(document.querySelector('a[href*="proton.me/support"]')).toHaveAttribute( + "href", + "https://proton.me/support/subscribe-to-external-calendar#subscribe-external-link", + ); + expect(document.querySelector('a[href^="https://chatgpt.com/"]')).toBeInTheDocument(); + }); + + it("regenerates the URL after confirming", async () => { + const user = userEvent.setup(); + renderInstructions(enabledFeed); + + await user.click(screen.getByRole("button", { name: "Regenerate URL" })); + expect(screen.getByRole("heading", { name: "Regenerate Calendar URL?" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.queryByRole("heading", { name: "Regenerate Calendar URL?" }), + ).not.toBeInTheDocument(); + expect(rotateCalendarFeedTokenMutation).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Regenerate URL" })); + await user.click(screen.getByRole("button", { name: "Regenerate" })); + + await waitFor(() => { + expect(rotateCalendarFeedTokenMutation).toHaveBeenCalledOnce(); + }); + expect(await screen.findByText(CALENDAR_FEED_ROTATED_URL)).toBeInTheDocument(); + }); + + it("disables the feed after confirming", async () => { + const user = userEvent.setup(); + renderInstructions(enabledFeed); + + await user.click(screen.getByRole("button", { name: "Disable" })); + expect(screen.getByRole("heading", { name: "Disable Calendar Feed?" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.queryByRole("heading", { name: "Disable Calendar Feed?" }), + ).not.toBeInTheDocument(); + expect(deleteCalendarFeedMutation).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Disable" })); + await user.click( + within(screen.getByRole("alertdialog")).getByRole("button", { name: "Disable" }), + ); + + await waitFor(() => { + expect(deleteCalendarFeedMutation).toHaveBeenCalledOnce(); + }); + expect(await screen.findByRole("button", { name: "Enable" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/calendar-instructions.tsx b/apps/web/components/calendar-instructions.tsx index b854366d..37f1a2f7 100644 --- a/apps/web/components/calendar-instructions.tsx +++ b/apps/web/components/calendar-instructions.tsx @@ -36,6 +36,20 @@ import { Skeleton } from "@domainstack/ui/skeleton"; import { Spinner } from "@domainstack/ui/spinner"; import { cn } from "@domainstack/ui/utils"; +/** + * Outlook doesn't have an icon in @icons-pack/react-simple-icons, so we draw + * its Microsoft logo by hand. Hoisted to module scope (rather than defined + * inline in `getIntegrations`) so its component identity is stable across + * renders. + */ +function OutlookIcon(props: React.SVGProps) { + return ( + + + + ); +} + /** * Skeleton for calendar instructions. * Exported for use as Suspense fallback in parent components. @@ -89,11 +103,7 @@ export function CalendarInstructions({ className }: { className?: string }) { { id: "outlook", label: "Outlook", - icon: (props: React.SVGProps) => ( - - - - ), + icon: OutlookIcon, href: `https://outlook.office.com/calendar/0/addfromweb?url=${encodeURIComponent(feedUrl.replace("https://", "webcal://"))}`, }, { diff --git a/apps/web/components/chat/chat-client-lazy.tsx b/apps/web/components/chat/chat-client-lazy.tsx index a5bbed90..95b652f5 100644 --- a/apps/web/components/chat/chat-client-lazy.tsx +++ b/apps/web/components/chat/chat-client-lazy.tsx @@ -33,9 +33,9 @@ export function ChatClientLazy({ suggestions }: { suggestions?: string[] }) { // Once loaded, stay loaded (keeps settings dialog working when user disables AI) const shouldLoad = hydrated && !hideAiFeatures; - useEffect(() => { - if (shouldLoad) setHasLoaded(true); - }, [shouldLoad]); + if (shouldLoad && !hasLoaded) { + setHasLoaded(true); + } if (!hasLoaded) return null; return ; diff --git a/apps/web/components/chat/chat-client.tsx b/apps/web/components/chat/chat-client.tsx index 0e678554..15cf71da 100644 --- a/apps/web/components/chat/chat-client.tsx +++ b/apps/web/components/chat/chat-client.tsx @@ -1,25 +1,27 @@ "use client"; import { useChat } from "@ai-sdk/react"; +import { WorkflowChatTransport } from "@ai-sdk/workflow"; import { IconLayoutSidebarRightCollapse, IconLego } from "@tabler/icons-react"; -import { WorkflowChatTransport } from "@workflow/ai"; -import { useAtom, useSetAtom } from "jotai"; +import type { UIMessage } from "ai"; +import { useAtom } from "jotai"; import { AnimatePresence } from "motion/react"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BetaBadge } from "@/components/beta-badge"; -import { useBrowserAI } from "@/hooks/use-browser-ai"; +import { useHaptics } from "@/components/providers/haptics-provider"; +import { type UseBrowserAIResult, useBrowserAI } from "@/hooks/use-browser-ai"; import { useChatPersistence } from "@/hooks/use-chat-persistence"; import { useLocalChat } from "@/hooks/use-local-chat"; import { useIsMobile } from "@/hooks/use-mobile"; -import { chatOpenAtom, serverSuggestionsAtom } from "@/lib/atoms/chat-atoms"; +import { analytics } from "@/lib/analytics/client"; +import { chatOpenAtom } from "@/lib/atoms/chat-atoms"; import { buildClientSystemPrompt } from "@/lib/chat/client-prompt"; import { createClientDomainTools } from "@/lib/chat/client-tools"; -import { useChatStore } from "@/lib/stores/chat-store"; +import { useChatHydrated, useChatStore } from "@/lib/stores/chat-store"; import { usePreferencesStore } from "@/lib/stores/preferences-store"; import { useTRPCClient } from "@/lib/trpc/client"; -import { analytics } from "@domainstack/analytics/client"; import { CHATBOT_NAME } from "@domainstack/constants"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@domainstack/ui/drawer"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@domainstack/ui/sheet"; @@ -34,46 +36,139 @@ interface ChatClientProps { suggestions?: string[]; } -export function ChatClient({ suggestions = [] }: ChatClientProps) { +const EMPTY_SUGGESTIONS: string[] = []; + +type ChatMode = "cloud" | "local"; + +interface ChatController { + messages: UIMessage[]; + sendMessage: (params: { text: string }) => void; + clearMessages: () => void; + status: "submitted" | "streaming" | "ready" | "error"; + error: string | null; +} + +export function ChatClient({ suggestions = EMPTY_SUGGESTIONS }: ChatClientProps) { const [open, setOpen] = useAtom(chatOpenAtom); const [settingsOpen, setSettingsOpen] = useState(false); const params = useParams<{ domain?: string }>(); const isMobile = useIsMobile(); const hideAiFeatures = usePreferencesStore((s) => s.hideAiFeatures); const aiMode = usePreferencesStore((s) => s.aiMode); + const browserAI = useBrowserAI(); + const chatHydrated = useChatHydrated(); + const storedMessageCount = useChatStore((s) => s.messages.length); + const { trigger } = useHaptics(); const domain = params.domain ? decodeURIComponent(params.domain) : undefined; + + const wantsLocal = (aiMode === "local" || aiMode === "auto") && browserAI.status === "ready"; + const preferredMode: ChatMode = + chatHydrated && storedMessageCount > 0 ? "cloud" : wantsLocal ? "local" : "cloud"; + + const [lockedMode, setLockedMode] = useState(null); + const mode = lockedMode ?? preferredMode; + + const handleActiveChange = useCallback( + (active: boolean) => { + setLockedMode((prev) => { + if (active) return prev ?? preferredMode; + return null; + }); + }, + [preferredMode], + ); + + const handleChatClick = () => { + void trigger("medium"); + setOpen(!open); + }; + + const handleSettingsClick = () => { + setOpen(false); + setSettingsOpen(true); + }; + + if (hideAiFeatures && !settingsOpen) { + return null; + } + + return ( + <> + {!hideAiFeatures && } + + {chatHydrated && + (mode === "local" ? ( + + ) : ( + + ))} + + + + ); +} + +interface ChatSessionProps { + domain?: string; + suggestions: string[]; + browserAI: UseBrowserAIResult; + isMobile: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onSettingsClick: () => void; + onActiveChange: (active: boolean) => void; +} + +function CloudChatSession({ + domain, + suggestions, + browserAI, + isMobile, + open, + onOpenChange, + onSettingsClick, + onActiveChange, +}: ChatSessionProps) { const domainRef = useRef(domain); useEffect(() => { domainRef.current = domain; }); - // Browser AI detection and local chat setup - const browserAI = useBrowserAI(); - const trpcClient = useTRPCClient(); - - // Client-side tools and prompt for local chat - const clientTools = useMemo(() => createClientDomainTools(trpcClient), [trpcClient]); - const systemPrompt = useMemo(() => buildClientSystemPrompt(domain), [domain]); - const runId = useChatStore((s) => s.runId); const runIdRef = useRef(runId); useEffect(() => { runIdRef.current = runId; }); + // Capture initial runId for resume — must stay stable so AI SDK does not + // restart resumption when onChatEnd later clears the live run ID. + const [initialRunId] = useState(runId); const setRunId = useChatStore((s) => s.setRunId); - const setMessages = useChatStore((s) => s.setMessages); + const setStoredMessages = useChatStore((s) => s.setMessages); const clearSession = useChatStore((s) => s.clearSession); - // Capture initial runId for resume prop - must be stable to avoid AI SDK errors - // when runId changes mid-session (e.g., onChatEnd clearing it) - const initialRunIdRef = useRef(undefined); - if (initialRunIdRef.current === undefined) { - initialRunIdRef.current = runId; - } - const transport = useMemo( () => + // oxlint-disable-next-line react/refs -- transport callbacks read latest domain/runId from refs after render new WorkflowChatTransport({ api: "/api/chat", prepareSendMessagesRequest: ({ messages }) => ({ @@ -90,7 +185,7 @@ export function ChatClient({ suggestions = [] }: ChatClientProps) { }; }, onChatSendMessage: (response, options) => { - setMessages(options.messages); + setStoredMessages(options.messages); const workflowRunId = response.headers.get("x-workflow-run-id"); if (workflowRunId) { setRunId(workflowRunId); @@ -100,68 +195,27 @@ export function ChatClient({ suggestions = [] }: ChatClientProps) { setRunId(null); }, }), - [setMessages, setRunId], + [setStoredMessages, setRunId], ); - // Cloud chat (via Vercel Workflow) - // Use stable initialRunIdRef for resume to avoid AI SDK errors when runId changes - const cloudChat = useChat({ + const chat = useChat({ transport, - resume: !!initialRunIdRef.current, + resume: !!initialRunId, onError: (error) => { analytics.trackException(error, { context: "chat-send", domain }); }, }); - // Local chat (browser-based AI) - declared before effectiveMode to check for active - // local messages and prevent race conditions with cloud history hydration. - // The hook handles null model gracefully (sendMessage becomes a no-op) - const localChat = useLocalChat({ - model: browserAI.model, - tools: clientTools, - systemPrompt, - onError: (error) => { - analytics.trackException(error, { context: "local-chat-send", domain }); - }, - }); - - // Determine effective mode based on preference and browser AI availability. - // IMPORTANT: Once a conversation is in progress in either mode, we lock to that mode - // to prevent message loss when: - // 1. Browser AI becomes ready mid-cloud-conversation - // 2. Cloud history hydrates mid-local-conversation (the fix for the race condition) - const effectiveMode = useMemo((): "cloud" | "local" => { - // If there's an active local conversation, stay in local mode to avoid losing messages. - // This prevents the race condition where async cloud history hydration from localStorage - // would override an in-progress local chat session. - if (localChat.messages.length > 0) return "local"; - // If there's an active cloud conversation, stay in cloud mode to avoid losing messages - if (cloudChat.messages.length > 0) return "cloud"; - // No active conversation - use preference-based mode selection - if (aiMode === "local" && browserAI.status === "ready") return "local"; - if (aiMode === "auto" && browserAI.status === "ready") return "local"; - return "cloud"; - }, [aiMode, browserAI.status, cloudChat.messages.length, localChat.messages.length]); - - // Select the active chat based on effective mode - const chat = effectiveMode === "local" ? localChat : cloudChat; - - // Handle message persistence (restore from store, persist to store, clear runId on completion) - // Only persist cloud chat messages (local chat doesn't have resumable workflows) useChatPersistence({ - messages: cloudChat.messages, - status: cloudChat.status, - setMessages: cloudChat.setMessages, + messages: chat.messages, + status: chat.status, + setMessages: chat.setMessages, }); - // Ref for clearMessages callback to avoid dependency on chat.setMessages - const chatSetMessagesRef = useRef(chat.setMessages); - chatSetMessagesRef.current = chat.setMessages; - const clearMessages = useCallback(() => { - chatSetMessagesRef.current([]); + chat.setMessages([]); clearSession(); - }, [clearSession]); + }, [chat, clearSession]); const sendMessage = useCallback( (msgParams: { text: string }) => { @@ -172,103 +226,176 @@ export function ChatClient({ suggestions = [] }: ChatClientProps) { [chat], ); - const { messages, status } = chat; - - // Don't show errors while streaming - if messages are coming through, the chat is working. - // The WorkflowChatTransport may report errors from reconnection attempts that don't affect - // the actual message stream (e.g., trying to reconnect after the workflow already completed). const error = - status === "streaming" ? null : chat.error ? getUserFriendlyError(chat.error) : null; + chat.status === "streaming" ? null : chat.error ? getUserFriendlyError(chat.error) : null; - // Hydrate server suggestions into atom - const setServerSuggestions = useSetAtom(serverSuggestionsAtom); useEffect(() => { - setServerSuggestions(suggestions); - }, [suggestions, setServerSuggestions]); - - const chatClientProps = { - messages, - sendMessage, - clearMessages, - status, - domain, - error, - }; - - if (hideAiFeatures && !settingsOpen) { - return null; - } - - const handleChatClick = () => { - try { - navigator.vibrate([50]); - } catch {} - setOpen(!open); - }; - - const handleSettingsClick = () => { - setOpen(false); - setSettingsOpen(true); - }; + onActiveChange(chat.messages.length > 0); + }, [chat.messages.length, onActiveChange]); return ( - <> - {!hideAiFeatures && } - - {isMobile ? ( - - - - - - - {CHATBOT_NAME} - - - -
- setOpen(false)} - /> -
-
- -
-
- ) : ( - - - - - - - {CHATBOT_NAME} - - - -
- setOpen(false)} - closeIcon={IconLayoutSidebarRightCollapse} - /> -
-
- -
-
- )} - - - + + ); +} + +function LocalChatSession({ + domain, + suggestions, + model, + browserAI, + isMobile, + open, + onOpenChange, + onSettingsClick, + onActiveChange, +}: ChatSessionProps & { model: UseBrowserAIResult["model"] }) { + const trpcClient = useTRPCClient(); + const clientTools = useMemo(() => createClientDomainTools(trpcClient), [trpcClient]); + const systemPrompt = useMemo(() => buildClientSystemPrompt(domain), [domain]); + + const chat = useLocalChat({ + model, + tools: clientTools, + systemPrompt, + onError: (error) => { + analytics.trackException(error, { context: "local-chat-send", domain }); + }, + }); + + const clearMessages = useCallback(() => { + chat.setMessages([]); + }, [chat]); + + const sendMessage = useCallback( + (msgParams: { text: string }) => { + const text = msgParams.text.trim(); + if (!text) return; + chat.sendMessage({ text }); + }, + [chat], + ); + + const error = + chat.status === "streaming" ? null : chat.error ? getUserFriendlyError(chat.error) : null; + + useEffect(() => { + onActiveChange(chat.messages.length > 0); + }, [chat.messages.length, onActiveChange]); + + return ( + + ); +} + +function ChatShell({ + chat, + domain, + suggestions, + browserAI, + isMobile, + open, + onOpenChange, + onSettingsClick, +}: { + chat: ChatController; + domain?: string; + suggestions: string[]; + browserAI: UseBrowserAIResult; + isMobile: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onSettingsClick: () => void; +}) { + const headerActions = ( + onOpenChange(false)} + closeIcon={isMobile ? undefined : IconLayoutSidebarRightCollapse} + /> + ); + + const panel = ( + + ); + + if (isMobile) { + return ( + + + + + + + {CHATBOT_NAME} + + + +
{headerActions}
+
+ {panel} +
+
+ ); + } + + return ( + + + + + + + {CHATBOT_NAME} + + + +
{headerActions}
+
+ {panel} +
+
); } diff --git a/apps/web/components/chat/chat-header-actions.tsx b/apps/web/components/chat/chat-header-actions.tsx index 24cdfa1e..c624c86c 100644 --- a/apps/web/components/chat/chat-header-actions.tsx +++ b/apps/web/components/chat/chat-header-actions.tsx @@ -10,9 +10,9 @@ import { } from "@tabler/icons-react"; import type { UIMessage } from "ai"; import { useState } from "react"; -import { toast } from "sonner"; import { Button } from "@domainstack/ui/button"; +import { toast } from "@domainstack/ui/toast"; import { Tooltip, TooltipContent, TooltipTrigger } from "@domainstack/ui/tooltip"; import { formatMessagesAsMarkdown } from "./utils"; @@ -31,7 +31,7 @@ function CopyConversationButton({ messages }: { messages: UIMessage[] }) { setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { - toast.error("Failed to copy conversation"); + toast.add({ title: "Failed to copy conversation", type: "error" }); } }; diff --git a/apps/web/components/chat/chat-mode-selector.tsx b/apps/web/components/chat/chat-mode-selector.tsx index ff1b6aa6..7af57ba4 100644 --- a/apps/web/components/chat/chat-mode-selector.tsx +++ b/apps/web/components/chat/chat-mode-selector.tsx @@ -8,7 +8,7 @@ import { IconDownload, } from "@tabler/icons-react"; -import { type BrowserAIStatus, useBrowserAI } from "@/hooks/use-browser-ai"; +import { type BrowserAIStatus, type UseBrowserAIResult } from "@/hooks/use-browser-ai"; import { type AiModePreference, usePreferencesStore } from "@/lib/stores/preferences-store"; import { Button } from "@domainstack/ui/button"; import { @@ -29,22 +29,18 @@ import { cn } from "@domainstack/ui/utils"; interface ChatModeSelectorProps { className?: string; - /** Disable the selector (e.g., while chat is active) */ disabled?: boolean; + browserAI: UseBrowserAIResult; } function getStatusLabel(status: BrowserAIStatus, downloadProgress?: number): string { switch (status) { - case "unavailable": - return "Not supported"; case "checking": return "Checking…"; case "downloadable": return "Download required"; case "downloading": return `Downloading… ${Math.round((downloadProgress ?? 0) * 100)}%`; - case "ready": - return "Ready"; case "error": return "Error"; default: @@ -52,10 +48,9 @@ function getStatusLabel(status: BrowserAIStatus, downloadProgress?: number): str } } -export function ChatModeSelector({ className, disabled }: ChatModeSelectorProps) { +export function ChatModeSelector({ className, disabled, browserAI }: ChatModeSelectorProps) { const aiMode = usePreferencesStore((s) => s.aiMode); const setAiMode = usePreferencesStore((s) => s.setAiMode); - const browserAI = useBrowserAI(); const canUseLocal = browserAI.status === "ready" || browserAI.status === "downloadable"; const isDownloading = browserAI.status === "downloading"; @@ -121,6 +116,7 @@ export function ChatModeSelector({ className, disabled }: ChatModeSelectorProps) variant="ghost" size="sm" className="ml-auto h-6 px-2" + aria-label="Download on-device model" onClick={handleDownloadClick} > @@ -129,12 +125,15 @@ export function ChatModeSelector({ className, disabled }: ChatModeSelectorProps) } /> - + Requires latest{" "} Google Chrome {" "} @@ -143,6 +142,7 @@ export function ChatModeSelector({ className, disabled }: ChatModeSelectorProps) href="https://learn.microsoft.com/en-us/microsoft-edge/web-platform/prompt-api" target="_blank" rel="noopener noreferrer" + className="font-medium" > Microsoft Edge {" "} diff --git a/apps/web/components/chat/chat-panel.tsx b/apps/web/components/chat/chat-panel.tsx index 8618a06f..e4f0375c 100644 --- a/apps/web/components/chat/chat-panel.tsx +++ b/apps/web/components/chat/chat-panel.tsx @@ -2,7 +2,6 @@ import { IconAlertCircle, IconBrain, IconMessages, IconX } from "@tabler/icons-react"; import type { ChatStatus, ToolUIPart, UIMessage } from "ai"; -import { useAtomValue } from "jotai"; import { useCallback, useState } from "react"; import { useStickToBottom } from "use-stick-to-bottom"; @@ -30,30 +29,43 @@ import { ToolInput, ToolOutput, } from "@/components/ai-elements/tool"; -import { chatSuggestionsAtom } from "@/lib/atoms/chat-atoms"; +import { type UseBrowserAIResult } from "@/hooks/use-browser-ai"; +import { getDomainToolStatus, getToolPartType } from "@/lib/chat/domain-tools"; +import { + getMessagePartItems, + hasVisibleAssistantParts, + isToolPart, + shouldShowThinkingStatus, +} from "@/lib/chat/message-parts"; import { usePreferencesStore } from "@/lib/stores/preferences-store"; import { MAX_MESSAGE_LENGTH } from "@domainstack/constants"; import { Button } from "@domainstack/ui/button"; import { cn } from "@domainstack/ui/utils"; import { ChatModeSelector } from "./chat-mode-selector"; -import { getToolStatusMessage } from "./utils"; -function getMessagePartItems(message: UIMessage) { - const seen = new Map(); +const EMPTY_SUGGESTIONS: string[] = []; - return message.parts.map((part, position) => { - const baseKey = - part.type === "text" || part.type === "reasoning" ? `${part.type}-${part.text}` : part.type; - const duplicateCount = seen.get(baseKey) ?? 0; - seen.set(baseKey, duplicateCount + 1); +function ThinkingStatus() { + return ( +
+ + +
+ ); +} - return { - key: `${message.id}-${baseKey}-${duplicateCount}`, - part, - position, - }; - }); +function getReportSuggestions(domain: string): string[] { + return [ + `When does ${domain} expire?`, + `Is ${domain} missing any important security headers?`, + `Which email provider does ${domain} use?`, + `Is ${domain}'s SSL certificate valid?`, + ]; } interface ChatPanelProps { @@ -64,11 +76,9 @@ interface ChatPanelProps { domain?: string; error?: string | null; onClearError?: () => void; - /** Size variant for icon in empty state */ - iconSize?: "sm" | "lg"; - /** Additional class for the conversation container */ + homeSuggestions?: string[]; + browserAI: UseBrowserAIResult; conversationClassName?: string; - /** Additional class for the input container */ inputClassName?: string; } @@ -80,20 +90,21 @@ export function ChatPanel({ domain, error, onClearError, + homeSuggestions = EMPTY_SUGGESTIONS, + browserAI, conversationClassName, inputClassName, }: ChatPanelProps) { const [inputLength, setInputLength] = useState(0); const showToolCalls = usePreferencesStore((s) => s.showToolCalls); const showReasoning = usePreferencesStore((s) => s.showReasoning); + const visibility = { showReasoning, showToolCalls }; + const showThinking = shouldShowThinkingStatus(status, messages, visibility); - // Prepare to share scroll state between the different components const stickyInstance = useStickToBottom(); const placeholder = domain ? `Ask about ${domain}\u2026` : "Ask about a domain\u2026"; - - // Get suggestions from atom (context-aware or server-generated fallback) - const suggestions = useAtomValue(chatSuggestionsAtom); + const suggestions = domain ? getReportSuggestions(domain) : homeSuggestions; const { scrollToBottom } = stickyInstance; const handleScrollToBottom = useCallback(() => { @@ -121,16 +132,20 @@ export function ChatPanel({ <> - {messages.length === 0 ? ( + {messages.length === 0 && !showThinking ? ( } title={`Ask me anything about ${domain ?? "domains"}!`} @@ -138,19 +153,29 @@ export function ChatPanel({ /> ) : ( <> - {messages.map((message) => ( - - - {getMessagePartItems(message).map(({ key, part, position }) => { - if (part.type === "text") { - return {part.text}; - } - if (part.type === "reasoning") { - const isStreaming = - status === "streaming" && - position === message.parts.length - 1 && - message.id === messages.at(-1)?.id; - if (showReasoning) { + {messages.map((message) => { + if ( + message.role === "assistant" && + !hasVisibleAssistantParts(message, visibility) + ) { + return null; + } + + return ( + + + {getMessagePartItems(message).map(({ key, part, position }) => { + if (part.type === "text") { + return {part.text}; + } + if (part.type === "reasoning") { + if (!showReasoning) { + return null; + } + const isStreaming = + status === "streaming" && + position === message.parts.length - 1 && + message.id === messages.at(-1)?.id; return ( @@ -158,48 +183,38 @@ export function ChatPanel({ ); } - - return isStreaming ? ( -
- - -
- ) : null; - } - if (part.type.startsWith("tool-") && showToolCalls) { - const toolPart = part as ToolUIPart; - return ( - - - - - {toolPart.state === "output-available" && ( - - )} - - - ); - } - return null; - })} -
-
- ))} - {/* Show loading indicator while waiting for response stream to begin */} - {status === "submitted" && ( - + if (isToolPart(part) && showToolCalls) { + const toolPart = part as ToolUIPart; + const statusType = getToolPartType(part) as ToolUIPart["type"]; + return ( + + + + + {toolPart.state === "output-available" && ( + + )} + + + ); + } + return null; + })} +
+
+ ); + })} + {showThinking && ( + - + )} @@ -250,7 +265,10 @@ export function ChatPanel({
- +
diff --git a/apps/web/components/chat/utils.ts b/apps/web/components/chat/utils.ts index 6ab17273..9f871317 100644 --- a/apps/web/components/chat/utils.ts +++ b/apps/web/components/chat/utils.ts @@ -1,24 +1,5 @@ import type { UIMessage } from "@ai-sdk/react"; -/** Map tool names to human-readable status messages */ -const TOOL_STATUS_MESSAGES = { - get_registration: "Looking up WHOIS data", - get_dns_records: "Fetching DNS records", - get_hosting: "Detecting hosting provider", - get_certificates: "Checking SSL certificate", - get_headers: "Analyzing HTTP headers", - get_seo: "Fetching SEO metadata", -} as const; - -/** Known tool names from the chat workflow */ -export type ToolName = keyof typeof TOOL_STATUS_MESSAGES; - -/** Get human-readable status message for a tool type */ -export function getToolStatusMessage(type: string): string { - const toolName = type.replace(/^tool-/, ""); - return TOOL_STATUS_MESSAGES[toolName as ToolName] ?? toolName; -} - /** Format messages as markdown for clipboard copy */ export function formatMessagesAsMarkdown(messages: UIMessage[]): string { return messages diff --git a/apps/web/components/consent/cookie-prompt.tsx b/apps/web/components/consent/cookie-prompt.tsx index e48d70b1..7b8576a4 100644 --- a/apps/web/components/consent/cookie-prompt.tsx +++ b/apps/web/components/consent/cookie-prompt.tsx @@ -22,39 +22,29 @@ export function CookiePrompt({ consentRequired }: { consentRequired: boolean }) const [consent, setConsent, { isPersistent }] = useLocalStorageState(CONSENT_KEY, { defaultValue: "pending", }); - const [show, setShow] = useState(false); const [isExiting, setIsExiting] = useState(false); useEffect(() => { - // Wait for localStorage to be available + if (isPersistent && consent === "pending" && !consentRequired) { + setConsent("accepted"); + } + }, [isPersistent, consent, consentRequired, setConsent]); + + useEffect(() => { if (!isPersistent) return; - if (consent !== "pending") { - // User has already made a choice - re-apply PostHog state - // in case it was reset (cleared cookies, new session, etc.) - if (consent === "accepted") { - posthogClient.opt_in_capturing(); - } else { - posthogClient.opt_out_capturing(); - } - setShow(false); - } else if (!consentRequired) { - // Non-EU user with no stored consent - auto-accept silently - setConsent("accepted"); + if (consent === "accepted") { posthogClient.opt_in_capturing(); - setShow(false); - } else { - // EU user needs to make a choice - show banner - setShow(true); + } else if (consent === "declined") { + posthogClient.opt_out_capturing(); } - }, [consent, consentRequired, isPersistent, setConsent]); + }, [consent, isPersistent]); const handleHide = (consentStatus: ConsentStatus) => { setIsExiting(true); // Wait for exit animation to complete before actually hiding setTimeout(() => { setConsent(consentStatus); - setShow(false); setIsExiting(false); }, 200); // Match animation duration }; @@ -69,7 +59,9 @@ export function CookiePrompt({ consentRequired }: { consentRequired: boolean }) handleHide("declined"); }; - if (!show || consent !== "pending") { + const show = isPersistent && consent === "pending" && consentRequired; + + if (!show) { return null; } diff --git a/apps/web/components/dashboard/add-domain/add-domain-clients.test.tsx b/apps/web/components/dashboard/add-domain/add-domain-clients.test.tsx new file mode 100644 index 00000000..6fb245d0 --- /dev/null +++ b/apps/web/components/dashboard/add-domain/add-domain-clients.test.tsx @@ -0,0 +1,136 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const nav = vi.hoisted(() => ({ + push: vi.fn<(href: string, opts?: { scroll?: boolean }) => void>(), + back: vi.fn<() => void>(), +})); + +const search = vi.hoisted(() => ({ + params: {} as Record, +})); + +vi.mock("@/hooks/use-router", () => ({ + useRouter: () => ({ push: nav.push, back: nav.back }), +})); +vi.mock("next/navigation", () => ({ + useSearchParams: () => ({ + get: (key: string) => search.params[key] ?? null, + }), +})); +vi.mock("@/lib/trpc/client", async () => { + const { useTRPC } = await import("@/mocks/trpc"); + return { useTRPC }; +}); +vi.mock("@/components/dashboard/add-domain/add-domain-content", () => ({ + AddDomainContent: ({ + onSuccess, + onClose, + resumeDomain, + prefillDomain, + }: { + onSuccess: () => void; + onClose?: () => void; + resumeDomain?: { id: string; domainName: string; verificationMethod: string | null } | null; + prefillDomain?: string; + }) => ( +
+ {JSON.stringify(resumeDomain ?? null)} + {prefillDomain ?? ""} + + {onClose ? ( + + ) : null} +
+ ), +})); + +import { AddDomainModalClient } from "@/components/dashboard/add-domain/add-domain-modal-client"; +import { AddDomainPageClient } from "@/components/dashboard/add-domain/add-domain-page-client"; +import { render, screen } from "@/mocks/react"; +import { DOMAINS_QUERY_KEY, SUBSCRIPTION_QUERY_KEY } from "@/mocks/trpc"; + +describe("AddDomainPageClient", () => { + beforeEach(() => { + search.params = {}; + nav.push.mockClear(); + nav.back.mockClear(); + }); + + afterEach(() => { + search.params = {}; + }); + + it("parses resume params and returns to the dashboard after success", async () => { + const user = userEvent.setup(); + search.params = { + resume: "true", + id: "domain-pending", + domain: "pending.dev", + method: "dns_txt", + }; + const { queryClient } = render(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + + expect(JSON.parse(screen.getByTestId("resume").textContent ?? "null")).toEqual({ + id: "domain-pending", + domainName: "pending.dev", + verificationToken: "", + verificationMethod: "dns_txt", + }); + expect(screen.getByTestId("prefill")).toHaveTextContent("from-report.com"); + expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Finish" })); + + expect(invalidate).toHaveBeenCalledWith({ queryKey: DOMAINS_QUERY_KEY }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: SUBSCRIPTION_QUERY_KEY }); + expect(nav.push).toHaveBeenCalledWith("/dashboard", { scroll: false }); + expect(nav.back).not.toHaveBeenCalled(); + }); + + it("starts a fresh add when resume params are incomplete", () => { + search.params = { resume: "true", domain: "pending.dev" }; + render(); + expect(screen.getByTestId("resume")).toHaveTextContent("null"); + }); +}); + +describe("AddDomainModalClient", () => { + beforeEach(() => { + search.params = {}; + nav.push.mockClear(); + nav.back.mockClear(); + }); + + afterEach(() => { + search.params = {}; + }); + + it("goes back after success and invalidates lists", async () => { + const user = userEvent.setup(); + const { queryClient } = render(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + + await user.click(screen.getByRole("button", { name: "Finish" })); + + expect(invalidate).toHaveBeenCalledWith({ queryKey: DOMAINS_QUERY_KEY }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: SUBSCRIPTION_QUERY_KEY }); + expect(nav.back).toHaveBeenCalledOnce(); + expect(nav.push).not.toHaveBeenCalled(); + }); + + it("goes back when the modal is closed", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Close" })); + + expect(nav.back).toHaveBeenCalledOnce(); + expect(nav.push).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/dashboard/add-domain/add-domain-content.test.tsx b/apps/web/components/dashboard/add-domain/add-domain-content.test.tsx new file mode 100644 index 00000000..45c39dab --- /dev/null +++ b/apps/web/components/dashboard/add-domain/add-domain-content.test.tsx @@ -0,0 +1,112 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/use-subscription", async () => { + const { useSubscription } = await import("../mocks/subscription"); + return { useSubscription }; +}); +vi.mock("@/lib/trpc/client", async () => { + const { useTRPC } = await import("@/mocks/trpc"); + return { useTRPC }; +}); +vi.mock("@/components/dashboard/add-domain/share-instructions-dialog", async () => { + const { ShareInstructionsDialog } = await import("../mocks/share-instructions"); + return { ShareInstructionsDialog }; +}); +vi.mock("@domainstack/ui/toast", () => ({ + toast: { + add: vi.fn<(options?: { title?: string; description?: string; type?: string }) => void>(), + }, +})); + +import { makeResumeDomain } from "@/components/dashboard/test-fixtures"; +import { screen, waitFor } from "@/mocks/react"; + +import { + addDomainActionSpies, + addDomainMutation, + mockSubscription, + renderAddDomainContent, + resetAddDomainTestState, + verifyDomainMutation, +} from "./test-utils"; + +async function waitForStep2() { + await waitFor(() => { + expect(screen.getByRole("button", { name: "Check Now" })).toBeInTheDocument(); + }); +} + +describe("AddDomainContent", () => { + beforeEach(() => { + resetAddDomainTestState(); + }); + + afterEach(() => { + resetAddDomainTestState(); + }); + + it("adds a domain, shows DNS instructions, and calls onSuccess after verify", async () => { + const user = userEvent.setup(); + renderAddDomainContent(); + + expect(screen.getByRole("heading", { name: "Add Domain" })).toBeInTheDocument(); + + await user.type(screen.getByLabelText("Domain name"), "newdomain.com"); + await user.click(screen.getByRole("button", { name: "Continue" })); + + await waitForStep2(); + expect(addDomainMutation.mock.calls[0]?.[0]).toEqual({ domain: "newdomain.com" }); + expect(screen.getByText("Recommended: Add a DNS record")).toBeInTheDocument(); + expect(screen.getByText("domainstack-verify=token-new")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Check Now" })); + + await waitFor(() => { + expect(addDomainActionSpies.onSuccess).toHaveBeenCalledOnce(); + }); + expect(verifyDomainMutation.mock.calls[0]?.[0]).toEqual({ trackedDomainId: "domain-new" }); + expect(screen.getByRole("heading", { name: "Domain verified!" })).toBeInTheDocument(); + expect(screen.getByText("newdomain.com")).toBeInTheDocument(); + }); + + it("shows the quota gate when the user cannot add more domains", () => { + mockSubscription.canAddMore = false; + mockSubscription.planQuota = 5; + + renderAddDomainContent(); + + expect(screen.getByRole("heading", { name: "Domain Limit Reached" })).toBeInTheDocument(); + expect(screen.getByText(/You've reached your limit of 5 tracked domains/)).toBeInTheDocument(); + expect(screen.queryByLabelText("Domain name")).not.toBeInTheDocument(); + }); + + it("resumes verification on step 2 for a pending domain", async () => { + renderAddDomainContent({ resumeDomain: makeResumeDomain() }); + + await waitForStep2(); + expect(screen.getByRole("heading", { name: "Complete Verification" })).toBeInTheDocument(); + expect(screen.getByText("Verify ownership of pending.dev")).toBeInTheDocument(); + expect(screen.getByText("Recommended: Add a DNS record")).toBeInTheDocument(); + expect(screen.getByText("domainstack-verify=token-pending")).toBeInTheDocument(); + expect(addDomainMutation).not.toHaveBeenCalled(); + }); + + it("stays on step 2 and shows troubleshooting when verification fails", async () => { + const user = userEvent.setup(); + verifyDomainMutation.mockResolvedValueOnce({ verified: false, method: null }); + renderAddDomainContent({ resumeDomain: makeResumeDomain() }); + await waitForStep2(); + + await user.click(screen.getByRole("button", { name: "Check Now" })); + + await waitFor(() => { + expect(screen.getByText("Verification Failed")).toBeInTheDocument(); + }); + expect(screen.getByText("DNS Record Troubleshooting")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Check Again" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Complete Verification" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Domain verified!" })).not.toBeInTheDocument(); + expect(addDomainActionSpies.onSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/dashboard/add-domain/add-domain-modal-client.tsx b/apps/web/components/dashboard/add-domain/add-domain-modal-client.tsx index 5cd4bfa8..2425f8a9 100644 --- a/apps/web/components/dashboard/add-domain/add-domain-modal-client.tsx +++ b/apps/web/components/dashboard/add-domain/add-domain-modal-client.tsx @@ -6,9 +6,8 @@ import { useMemo } from "react"; import { AddDomainContent } from "@/components/dashboard/add-domain/add-domain-content"; import { useRouter } from "@/hooks/use-router"; +import { parseResumeDomain } from "@/lib/add-domain-resume"; import { useTRPC } from "@/lib/trpc/client"; -import { isValidVerificationMethod } from "@/lib/verification-instructions"; -import type { ResumeDomainData } from "@domainstack/types"; export function AddDomainModalClient({ prefillDomain }: { prefillDomain?: string }) { const router = useRouter(); @@ -31,29 +30,7 @@ export function AddDomainModalClient({ prefillDomain }: { prefillDomain?: string router.back(); }; - const resumeDomain = useMemo(() => { - const isResume = searchParams.get("resume") === "true"; - const id = searchParams.get("id"); - const domain = searchParams.get("domain"); - const methodParam = searchParams.get("method"); - - // Validate verification method at runtime without Zod - const method = isValidVerificationMethod(methodParam) ? methodParam : null; - - if (isResume && id) { - return { - id, - // Optional: fallback to empty string if not in params, - // will be populated by useDomainVerification fetching verification data - domainName: domain ?? "", - // Token is not needed here as it will be fetched from the server - verificationToken: "", - verificationMethod: method, - }; - } - - return null; - }, [searchParams]); + const resumeDomain = useMemo(() => parseResumeDomain(searchParams), [searchParams]); return ( (() => { - const isResume = searchParams.get("resume") === "true"; - const id = searchParams.get("id"); - const domain = searchParams.get("domain"); - const methodParam = searchParams.get("method"); - - // Validate verification method at runtime without Zod - const method = isValidVerificationMethod(methodParam) ? methodParam : null; - - if (isResume && id) { - return { - id, - // Optional: fallback to empty string if not in params, - // will be populated by useDomainVerification fetching verification data - domainName: domain ?? "", - // Token is not needed here as it will be fetched from the server - verificationToken: "", - verificationMethod: method, - }; - } - - return null; - }, [searchParams]); + const resumeDomain = useMemo(() => parseResumeDomain(searchParams), [searchParams]); return ( diff --git a/apps/web/components/dashboard/add-domain/share-instructions-dialog.test.tsx b/apps/web/components/dashboard/add-domain/share-instructions-dialog.test.tsx new file mode 100644 index 00000000..f3c89e15 --- /dev/null +++ b/apps/web/components/dashboard/add-domain/share-instructions-dialog.test.tsx @@ -0,0 +1,143 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { toast } from "@domainstack/ui/toast"; + +vi.mock("@/lib/trpc/client", async () => { + const { useTRPC } = await import("@/mocks/trpc"); + return { useTRPC }; +}); +vi.mock("@domainstack/ui/toast", () => ({ + toast: { + add: vi.fn<(options?: { title?: string; description?: string; type?: string }) => void>(), + }, +})); + +import { ShareInstructionsDialog } from "@/components/dashboard/add-domain/share-instructions-dialog"; +import { render, screen, waitFor } from "@/mocks/react"; +import { resetTrpcMocks, sendVerificationInstructionsMutation } from "@/mocks/trpc"; + +const DOMAIN = "pending.dev"; +const TOKEN = "token-pending"; +const TRACKED_ID = "domain-pending"; + +async function openShareDialog(user: ReturnType) { + render( + , + ); + await user.click(screen.getByRole("button", { name: "Share" })); + expect( + await screen.findByRole("heading", { name: "Share Verification Instructions" }), + ).toBeInTheDocument(); +} + +describe("ShareInstructionsDialog", () => { + beforeEach(() => { + resetTrpcMocks(); + vi.mocked(toast.add).mockClear(); + }); + + afterEach(() => { + resetTrpcMocks(); + }); + + it("opens the three share options", async () => { + const user = userEvent.setup(); + await openShareDialog(user); + + expect(screen.getByText("Copy to clipboard")).toBeInTheDocument(); + expect(screen.getByText("Download as file")).toBeInTheDocument(); + expect(screen.getByText("Send via email")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy to clipboard" })).toBeInTheDocument(); + expect(screen.getByPlaceholderText(`admin@${DOMAIN}`)).toBeInTheDocument(); + }); + + it("downloads instructions as a text file", async () => { + const user = userEvent.setup(); + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:test"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + + await openShareDialog(user); + await user.click(screen.getByRole("button", { name: "Download instructions" })); + + expect(createObjectURL).toHaveBeenCalledOnce(); + expect(click).toHaveBeenCalledOnce(); + expect(toast.add).toHaveBeenCalledWith({ + title: "Instructions downloaded!", + description: "Send this file to your domain admin.", + type: "success", + }); + + createObjectURL.mockRestore(); + revokeObjectURL.mockRestore(); + click.mockRestore(); + }); + + it("keeps Send disabled until the email is valid", async () => { + const user = userEvent.setup(); + await openShareDialog(user); + + const send = screen.getByRole("button", { name: "Send email" }); + expect(send).toBeDisabled(); + + await user.type(screen.getByLabelText("Email address"), "not-an-email"); + expect(send).toBeDisabled(); + + await user.clear(screen.getByLabelText("Email address")); + await user.type(screen.getByLabelText("Email address"), "admin@pending.dev"); + expect(send).toBeEnabled(); + }); + + it("sends instructions to a trimmed email address", async () => { + const user = userEvent.setup(); + await openShareDialog(user); + + await user.type(screen.getByLabelText("Email address"), " admin@pending.dev "); + await user.click(screen.getByRole("button", { name: "Send email" })); + + await waitFor(() => { + expect(sendVerificationInstructionsMutation.mock.calls[0]?.[0]).toEqual({ + trackedDomainId: TRACKED_ID, + recipientEmail: "admin@pending.dev", + }); + }); + expect(toast.add).toHaveBeenCalledWith({ + title: "Instructions sent!", + description: "Email sent to admin@pending.dev", + type: "success", + }); + }); + + it("toasts an error when sending fails so the user can retry", async () => { + const user = userEvent.setup(); + sendVerificationInstructionsMutation.mockRejectedValueOnce(new Error("nope")); + await openShareDialog(user); + + await user.type(screen.getByLabelText("Email address"), "admin@pending.dev"); + await user.click(screen.getByRole("button", { name: "Send email" })); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ + title: "Failed to send email", + description: "Please try again or use another method.", + type: "error", + }); + }); + + const email = screen.getByLabelText("Email address"); + expect(email).toHaveValue("admin@pending.dev"); + expect(screen.getByRole("button", { name: "Send email" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Send email" })); + + await waitFor(() => { + expect(sendVerificationInstructionsMutation).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/apps/web/components/dashboard/add-domain/share-instructions-dialog.tsx b/apps/web/components/dashboard/add-domain/share-instructions-dialog.tsx index 1cecae65..3c7e4bf7 100644 --- a/apps/web/components/dashboard/add-domain/share-instructions-dialog.tsx +++ b/apps/web/components/dashboard/add-domain/share-instructions-dialog.tsx @@ -9,7 +9,6 @@ import { } from "@tabler/icons-react"; import { useMutation } from "@tanstack/react-query"; import { useCallback, useEffect, useReducer, useRef } from "react"; -import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; import { buildVerificationInstructions } from "@/lib/verification-instructions"; @@ -42,6 +41,7 @@ import { ItemTitle, } from "@domainstack/ui/item"; import { Spinner } from "@domainstack/ui/spinner"; +import { toast } from "@domainstack/ui/toast"; // ============================================================================ // Types @@ -80,6 +80,7 @@ type ShareDialogAction = | { type: "COPY_RESET" } | { type: "EMAIL_SENDING" } | { type: "EMAIL_SENT" } + | { type: "EMAIL_ERROR" } | { type: "EMAIL_RESET" }; const initialState: ShareDialogState = { @@ -113,6 +114,9 @@ function shareDialogReducer(state: ShareDialogState, action: ShareDialogAction): case "EMAIL_SENT": return { ...state, emailStatus: "sent" }; + case "EMAIL_ERROR": + return { ...state, emailStatus: "idle" }; + case "EMAIL_RESET": return { ...state, emailStatus: "idle", email: "" }; @@ -233,8 +237,10 @@ export function ShareInstructionsDialog({ }, onSuccess: () => { dispatch({ type: "EMAIL_SENT" }); - toast.success("Instructions sent!", { - description: `Email sent to ${state.email}`, + toast.add({ + title: "Instructions sent!", + description: `Email sent to ${state.email.trim()}`, + type: "success", }); // Reset after a delay if (timeoutRef.current) { @@ -245,10 +251,12 @@ export function ShareInstructionsDialog({ }, 3000); }, onError: () => { - // Reset to idle on error so user can retry - dispatch({ type: "EMAIL_RESET" }); - toast.error("Failed to send email", { + // Keep the typed email so the user can retry without re-entering it + dispatch({ type: "EMAIL_ERROR" }); + toast.add({ + title: "Failed to send email", description: "Please try again or use another method.", + type: "error", }); }, }); @@ -256,11 +264,13 @@ export function ShareInstructionsDialog({ const handleDownload = useCallback(() => { const result = downloadInstructionsFile(domain, verificationToken); if (result.success) { - toast.success("Instructions downloaded!", { + toast.add({ + title: "Instructions downloaded!", description: "Send this file to your domain admin.", + type: "success", }); } else { - toast.error("Failed to download file"); + toast.add({ title: "Failed to download file", type: "error" }); } }, [domain, verificationToken]); diff --git a/apps/web/components/dashboard/add-domain/step-verify-ownership.tsx b/apps/web/components/dashboard/add-domain/step-verify-ownership.tsx index 638d5937..0cdd95af 100644 --- a/apps/web/components/dashboard/add-domain/step-verify-ownership.tsx +++ b/apps/web/components/dashboard/add-domain/step-verify-ownership.tsx @@ -1,5 +1,4 @@ import { IconDownload, IconInfoCircle } from "@tabler/icons-react"; -import { toast } from "sonner"; import { VerificationFailed } from "@/components/dashboard/add-domain/verification-failed"; import { buildVerificationInstructions } from "@/lib/verification-instructions"; @@ -14,6 +13,7 @@ import { } from "@domainstack/ui/responsive-tooltip"; import { Separator } from "@domainstack/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@domainstack/ui/tabs"; +import { toast } from "@domainstack/ui/toast"; type StepVerifyOwnershipProps = { domain: string; @@ -175,11 +175,13 @@ export function StepVerifyOwnership({ instructions.html_file.fileContent, ); if (result.success) { - toast.success("File downloaded!", { + toast.add({ + title: "File downloaded!", description: "Upload the file to your website at the path shown.", + type: "success", }); } else { - toast.error("Failed to download file"); + toast.add({ title: "Failed to download file", type: "error" }); } }} > diff --git a/apps/web/components/dashboard/add-domain/test-utils.tsx b/apps/web/components/dashboard/add-domain/test-utils.tsx new file mode 100644 index 00000000..9d29967b --- /dev/null +++ b/apps/web/components/dashboard/add-domain/test-utils.tsx @@ -0,0 +1,46 @@ +import { vi } from "vitest"; + +import { AddDomainContent } from "@/components/dashboard/add-domain/add-domain-content"; +import { mockSubscription } from "@/components/dashboard/mocks/subscription"; +import { render } from "@/mocks/react"; +import { resetTrpcMocks } from "@/mocks/trpc"; +import type { ResumeDomainData } from "@domainstack/types"; + +export { mockSubscription } from "@/components/dashboard/mocks/subscription"; +export { addDomainMutation, getVerificationDataQuery, verifyDomainMutation } from "@/mocks/trpc"; + +export const addDomainActionSpies = { + onSuccess: vi.fn<() => void>(), + onClose: vi.fn<() => void>(), +}; + +export function resetAddDomainTestState() { + mockSubscription.plan = "pro"; + mockSubscription.planQuota = 100; + mockSubscription.endsAt = null; + mockSubscription.activeCount = 4; + mockSubscription.archivedCount = 0; + mockSubscription.canAddMore = true; + for (const spy of Object.values(addDomainActionSpies)) { + spy.mockClear(); + } + resetTrpcMocks(); +} + +export type RenderAddDomainContentOptions = { + resumeDomain?: ResumeDomainData | null; + prefillDomain?: string; + onSuccess?: () => void; + onClose?: () => void; +}; + +export function renderAddDomainContent(options: RenderAddDomainContentOptions = {}) { + return render( + , + ); +} diff --git a/apps/web/components/dashboard/archived-domains-list.test.tsx b/apps/web/components/dashboard/archived-domains-list.test.tsx new file mode 100644 index 00000000..9e592c56 --- /dev/null +++ b/apps/web/components/dashboard/archived-domains-list.test.tsx @@ -0,0 +1,101 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/use-subscription", async () => { + const { useSubscription } = await import("./mocks/subscription"); + return { useSubscription }; +}); +vi.mock("@/components/icons/favicon", async () => { + const { Favicon } = await import("./mocks/leaf"); + return { Favicon }; +}); +vi.mock("@/components/icons/provider-logo", async () => { + const { ProviderLogo } = await import("./mocks/leaf"); + return { ProviderLogo }; +}); +vi.mock("@/components/domain/screenshot-popover", async () => { + const { ScreenshotPopover } = await import("./mocks/leaf"); + return { ScreenshotPopover }; +}); +vi.mock("@/components/dashboard/calendar-feed-popover", async () => { + const { CalendarFeedPopover } = await import("./mocks/leaf"); + return { CalendarFeedPopover }; +}); +vi.mock("@/hooks/use-provider-tooltip-data", async () => { + const { useProviderTooltipData } = await import("./mocks/leaf"); + return { useProviderTooltipData }; +}); + +import { DASHBOARD_TEST_NOW, makeTrackedDomain } from "@/components/dashboard/test-fixtures"; +import { + dashboardActionSpies, + mockSubscription, + renderArchivedList, + resetDashboardTestState, +} from "@/components/dashboard/test-utils"; +import { screen } from "@/mocks/react"; +import { PLAN_QUOTAS } from "@domainstack/constants"; + +const archived = makeTrackedDomain({ + id: "domain-archived", + domainName: "archived.com", + archivedAt: DASHBOARD_TEST_NOW, +}); + +describe("ArchivedDomainsList", () => { + beforeEach(() => { + resetDashboardTestState(); + }); + + afterEach(() => { + resetDashboardTestState(); + vi.useRealTimers(); + }); + + it("shows an empty state", () => { + renderArchivedList([]); + expect(screen.getByText("No archived domains")).toBeInTheDocument(); + }); + + it("reactivates and deletes an archived domain", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderArchivedList([archived]); + + expect(screen.getByText("archived.com")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Reactivate/ })); + expect(dashboardActionSpies.onUnarchive).toHaveBeenCalledWith("domain-archived"); + + await user.click(screen.getByRole("button", { name: "Delete" })); + expect(dashboardActionSpies.onRemove).toHaveBeenCalledWith("domain-archived", "archived.com"); + }); + + it("blocks reactivate and shows an upgrade banner on Free at the limit", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSubscription.plan = "free"; + mockSubscription.planQuota = PLAN_QUOTAS.free; + mockSubscription.canAddMore = false; + renderArchivedList([archived]); + + expect(screen.getByText("Upgrade to Reactivate")).toBeInTheDocument(); + expect(screen.getByText(/You've reached your domain tracking limit/)).toBeInTheDocument(); + + const reactivate = screen.getByRole("button", { name: /Reactivate/ }); + expect(reactivate).toBeDisabled(); + await user.click(reactivate); + expect(dashboardActionSpies.onUnarchive).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Delete" })); + expect(dashboardActionSpies.onRemove).toHaveBeenCalledWith("domain-archived", "archived.com"); + }); + + it("keeps reactivate disabled for Pro at the limit without the upgrade banner", async () => { + mockSubscription.plan = "pro"; + mockSubscription.planQuota = PLAN_QUOTAS.pro; + mockSubscription.canAddMore = false; + renderArchivedList([archived]); + + expect(screen.queryByText("Upgrade to Reactivate")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Reactivate/ })).toBeDisabled(); + }); +}); diff --git a/apps/web/components/dashboard/archived-domains-list.tsx b/apps/web/components/dashboard/archived-domains-list.tsx index 48e46c11..f0597fa1 100644 --- a/apps/web/components/dashboard/archived-domains-list.tsx +++ b/apps/web/components/dashboard/archived-domains-list.tsx @@ -90,6 +90,7 @@ export function ArchivedDomainsList({ domains }: ArchivedDomainsListProps) { size="sm" onClick={() => onUnarchive(domain.id)} disabled={!subscription?.canAddMore} + className={!subscription?.canAddMore ? "pointer-events-none" : undefined} > Reactivate diff --git a/apps/web/components/dashboard/bulk-actions-toolbar.tsx b/apps/web/components/dashboard/bulk-actions-toolbar.tsx index 12975afc..004e6b47 100644 --- a/apps/web/components/dashboard/bulk-actions-toolbar.tsx +++ b/apps/web/components/dashboard/bulk-actions-toolbar.tsx @@ -57,6 +57,7 @@ export function BulkActionsToolbar({ totalCount, className }: BulkActionsToolbar {/* Left: Select all checkbox + count */} { + const { useTRPC } = await import("@/mocks/trpc"); + return { useTRPC }; +}); +vi.mock("@domainstack/ui/toast", () => ({ + toast: { + add: vi.fn<(options?: { title?: string; description?: string; type?: string }) => void>(), + }, +})); + +import { createTestQueryClient, render, screen } from "@/mocks/react"; +import { CALENDAR_FEED_QUERY_KEY, resetTrpcMocks, setCalendarFeedState } from "@/mocks/trpc"; + +import { CalendarFeedPopover } from "./calendar-feed-popover"; + +describe("CalendarFeedPopover", () => { + beforeEach(() => { + resetTrpcMocks(); + }); + + afterEach(() => { + resetTrpcMocks(); + }); + + it("opens the calendar feed instructions", async () => { + const user = userEvent.setup(); + const queryClient = createTestQueryClient(); + setCalendarFeedState({ enabled: false }); + queryClient.setQueryData(CALENDAR_FEED_QUERY_KEY, { enabled: false }); + + render(, { queryClient }); + + await user.click(screen.getByRole("button", { name: "Subscribe" })); + + expect(screen.getByRole("heading", { name: "Calendar Feed" })).toBeInTheDocument(); + expect( + screen.getByText("Subscribe to domain expiration dates in your favorite calendar app"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/dashboard/dashboard-banner-dismissable.test.tsx b/apps/web/components/dashboard/dashboard-banner-dismissable.test.tsx new file mode 100644 index 00000000..f96f3a11 --- /dev/null +++ b/apps/web/components/dashboard/dashboard-banner-dismissable.test.tsx @@ -0,0 +1,32 @@ +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { DashboardBannerDismissable } from "@/components/dashboard/dashboard-banner-dismissable"; +import { render, screen, waitFor } from "@/mocks/react"; + +describe("DashboardBannerDismissable", () => { + it("forwards onDismiss when the banner is dismissed", async () => { + const user = userEvent.setup(); + const onDismiss = vi.fn<() => void>(); + + render( + , + ); + + const banner = screen.getByText("Welcome to Pro!").closest("[data-slot=card]"); + expect(banner).toBeTruthy(); + await user.hover(banner!); + await user.click(screen.getByRole("button", { name: "Dismiss" })); + + await waitFor(() => { + expect(screen.queryByText("Welcome to Pro!")).not.toBeInTheDocument(); + }); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/dashboard/dashboard-banner-dismissable.tsx b/apps/web/components/dashboard/dashboard-banner-dismissable.tsx index 15181608..47a72d5c 100644 --- a/apps/web/components/dashboard/dashboard-banner-dismissable.tsx +++ b/apps/web/components/dashboard/dashboard-banner-dismissable.tsx @@ -22,7 +22,13 @@ export function DashboardBannerDismissable(props: React.ComponentProps - setIsDismissed(true)} /> + { + setIsDismissed(true); + props.onDismiss?.(); + }} + /> )} diff --git a/apps/web/components/dashboard/dashboard-client.tsx b/apps/web/components/dashboard/dashboard-client.tsx index c45f1730..4dacdf04 100644 --- a/apps/web/components/dashboard/dashboard-client.tsx +++ b/apps/web/components/dashboard/dashboard-client.tsx @@ -2,14 +2,13 @@ import { IconArchive, IconArrowLeft, IconHeartHandshake } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; -import type { Table } from "@tanstack/react-table"; import { useSearchParams } from "next/navigation"; import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs"; import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; import { ArchivedDomainsList } from "@/components/dashboard/archived-domains-list"; import { DashboardBannerDismissable } from "@/components/dashboard/dashboard-banner-dismissable"; +import { DashboardConfirmDialog } from "@/components/dashboard/dashboard-confirm-dialog"; import { DashboardContent } from "@/components/dashboard/dashboard-content"; import { DashboardError } from "@/components/dashboard/dashboard-error"; import { DashboardFilters } from "@/components/dashboard/dashboard-filters"; @@ -21,14 +20,18 @@ import { UpgradeBanner } from "@/components/dashboard/upgrade-banner"; import { DashboardProvider } from "@/context/dashboard-context"; import { useDashboardFilters } from "@/hooks/use-dashboard-filters"; import { useDashboardMutations } from "@/hooks/use-dashboard-mutations"; -import { useDashboardPagination } from "@/hooks/use-dashboard-pagination"; +import { + getDashboardFilterSignature, + useDashboardPagination, + useSyncDashboardPage, +} from "@/hooks/use-dashboard-pagination"; import { useDashboardSelection, useSyncVisibleDomainIds } from "@/hooks/use-dashboard-selection"; import { useRouter } from "@/hooks/use-router"; import { useSubscription } from "@/hooks/use-subscription"; +import type { DashboardTable } from "@/lib/dashboard-table-features"; import { type ConfirmAction, DEFAULT_SORT, - getConfirmDialogContent, SORT_OPTIONS, type SortOption, sortDomains, @@ -37,17 +40,6 @@ import { usePreferencesStore } from "@/lib/stores/preferences-store"; import { useTRPC } from "@/lib/trpc/client"; import { useSession } from "@domainstack/auth/client"; import type { VerificationMethod } from "@domainstack/constants"; -import type { TrackedDomainWithDetails } from "@domainstack/types"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@domainstack/ui/alert-dialog"; import { Button } from "@domainstack/ui/button"; export function DashboardClient() { @@ -89,7 +81,7 @@ export function DashboardClient() { actions: { setPageIndex, setPageSize, resetPage }, } = useDashboardPagination(); - const [tableInstance, setTableInstance] = useState | null>(null); + const [tableInstance, setTableInstance] = useState(null); // Tracked domains query const domainsQuery = useQuery(trpc.tracking.listDomains.queryOptions({ includeArchived: true })); @@ -120,6 +112,14 @@ export function DashboardClient() { // Filtered domain IDs for selection - sync to Jotai atom const filteredDomainIds = useMemo(() => filteredDomains.map((d) => d.id), [filteredDomains]); useSyncVisibleDomainIds(filteredDomainIds); + useSyncDashboardPage({ + itemCount: filteredDomains.length, + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + filterSignature: getDashboardFilterSignature(filterHook.state), + resetPage, + enabled: allDomains !== undefined, + }); // Selection state from Jotai const { clearSelection } = useDashboardSelection(); @@ -127,17 +127,8 @@ export function DashboardClient() { const doBulkArchive = useCallback( async (domainIds: string[]) => { try { - const result = await mutations.bulkArchive(domainIds); + await mutations.bulkArchive(domainIds); clearSelection(); - if (result.failedCount === 0) { - toast.success( - `Archived ${result.successCount} domain${result.successCount === 1 ? "" : "s"}`, - ); - } else { - toast.warning( - `Archived ${result.successCount} of ${domainIds.length} domains (${result.failedCount} failed)`, - ); - } } catch { // Error handled in mutation onError } @@ -148,17 +139,8 @@ export function DashboardClient() { const doBulkDelete = useCallback( async (domainIds: string[]) => { try { - const result = await mutations.bulkDelete(domainIds); + await mutations.bulkDelete(domainIds); clearSelection(); - if (result.failedCount === 0) { - toast.success( - `Deleted ${result.successCount} domain${result.successCount === 1 ? "" : "s"}`, - ); - } else { - toast.warning( - `Deleted ${result.successCount} of ${domainIds.length} domains (${result.failedCount} failed)`, - ); - } } catch { // Error handled in mutation onError } @@ -188,17 +170,19 @@ export function DashboardClient() { // Handle ?upgraded=true query param (after nuqs adapter) const searchParams = useSearchParams(); + const upgradedParam = searchParams?.get("upgraded") === "true"; + if (upgradedParam && !showUpgradedBanner) { + setShowUpgradedBanner(true); + } useEffect(() => { - if (searchParams?.get("upgraded") === "true") { - setShowUpgradedBanner(true); - // Clear only the `upgraded` param while preserving others (e.g., filters) - const params = new URLSearchParams(searchParams.toString()); - params.delete("upgraded"); - const newSearch = params.toString(); - const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ""); - router.replace(newUrl, { scroll: false }); - } - }, [router, searchParams]); + if (!upgradedParam || !searchParams) return; + // Clear only the `upgraded` param while preserving others (e.g., filters) + const params = new URLSearchParams(searchParams.toString()); + params.delete("upgraded"); + const newSearch = params.toString(); + const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ""); + router.replace(newUrl, { scroll: false }); + }, [upgradedParam, router, searchParams]); const handleAddDomain = useCallback(() => { router.push("/dashboard/add-domain", { scroll: false }); @@ -388,33 +372,13 @@ export function DashboardClient() { )} - {/* Confirmation dialog for destructive actions */} - { if (!open) setPendingAction(null); }} - > - {pendingAction && ( - - - {getConfirmDialogContent(pendingAction).title} - - {getConfirmDialogContent(pendingAction).description} - - - - Cancel - - {getConfirmDialogContent(pendingAction).confirmLabel} - - - - )} - + onConfirm={handleConfirm} + /> ); } diff --git a/apps/web/components/dashboard/dashboard-confirm-dialog.test.tsx b/apps/web/components/dashboard/dashboard-confirm-dialog.test.tsx new file mode 100644 index 00000000..ceab6709 --- /dev/null +++ b/apps/web/components/dashboard/dashboard-confirm-dialog.test.tsx @@ -0,0 +1,118 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/use-subscription", async () => { + const { useSubscription } = await import("./mocks/subscription"); + return { useSubscription }; +}); +vi.mock("@/components/icons/favicon", async () => { + const { Favicon } = await import("./mocks/leaf"); + return { Favicon }; +}); +vi.mock("@/components/icons/provider-logo", async () => { + const { ProviderLogo } = await import("./mocks/leaf"); + return { ProviderLogo }; +}); +vi.mock("@/components/domain/screenshot-popover", async () => { + const { ScreenshotPopover } = await import("./mocks/leaf"); + return { ScreenshotPopover }; +}); +vi.mock("@/components/dashboard/calendar-feed-popover", async () => { + const { CalendarFeedPopover } = await import("./mocks/leaf"); + return { CalendarFeedPopover }; +}); +vi.mock("@/hooks/use-provider-tooltip-data", async () => { + const { useProviderTooltipData } = await import("./mocks/leaf"); + return { useProviderTooltipData }; +}); + +import { + dashboardActionSpies, + renderDashboardConfirmShell, + resetDashboardTestState, +} from "@/components/dashboard/test-utils"; +import { screen, waitFor, within } from "@/mocks/react"; + +async function waitForCatalog() { + await waitFor(() => { + expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); + }); +} + +function domainCard(name: string) { + const card = screen.getByRole("link", { name }).closest(".group"); + expect(card).not.toBeNull(); + return card as HTMLElement; +} + +async function selectGridCard(user: ReturnType, name: string) { + await user.hover(domainCard(name)); + await user.click(screen.getByRole("checkbox", { name: `Select ${name}` })); +} + +describe("dashboard confirm dialog", () => { + beforeEach(() => { + resetDashboardTestState(); + }); + + afterEach(() => { + resetDashboardTestState(); + vi.useRealTimers(); + }); + + it("archives a card after confirming the dialog", async () => { + const user = userEvent.setup(); + renderDashboardConfirmShell(); + await waitForCatalog(); + + const card = domainCard("alpha.com"); + await user.hover(card); + await user.click(within(card).getByRole("button", { name: "Actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Archive" })); + + const dialog = await screen.findByRole("alertdialog"); + expect(within(dialog).getByRole("heading", { name: "Archive domain?" })).toBeInTheDocument(); + expect(dashboardActionSpies.onArchive).not.toHaveBeenCalled(); + + await user.click(within(dialog).getByRole("button", { name: "Archive" })); + expect(dashboardActionSpies.onArchive).toHaveBeenCalledWith("domain-alpha", "alpha.com"); + }); + + it("does not archive when the dialog is cancelled", async () => { + const user = userEvent.setup(); + renderDashboardConfirmShell(); + await waitForCatalog(); + + const card = domainCard("alpha.com"); + await user.hover(card); + await user.click(within(card).getByRole("button", { name: "Actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Archive" })); + + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + + await waitFor(() => { + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }); + expect(dashboardActionSpies.onArchive).not.toHaveBeenCalled(); + }); + + it("bulk-deletes after confirming the dialog", async () => { + const user = userEvent.setup(); + renderDashboardConfirmShell(); + await waitForCatalog(); + + await selectGridCard(user, "alpha.com"); + await selectGridCard(user, "beta.io"); + + const toolbar = await screen.findByRole("toolbar", { name: "Bulk actions" }); + await user.click(within(toolbar).getByRole("button", { name: "Delete" })); + + const dialog = await screen.findByRole("alertdialog"); + expect(within(dialog).getByRole("heading", { name: "Delete 2 domains?" })).toBeInTheDocument(); + expect(dashboardActionSpies.onBulkDelete).not.toHaveBeenCalled(); + + await user.click(within(dialog).getByRole("button", { name: "Delete All" })); + expect(dashboardActionSpies.onBulkDelete).toHaveBeenCalledWith(["domain-alpha", "domain-beta"]); + }); +}); diff --git a/apps/web/components/dashboard/dashboard-confirm-dialog.tsx b/apps/web/components/dashboard/dashboard-confirm-dialog.tsx new file mode 100644 index 00000000..3784c4ad --- /dev/null +++ b/apps/web/components/dashboard/dashboard-confirm-dialog.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { type ConfirmAction, getConfirmDialogContent } from "@/lib/dashboard-utils"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@domainstack/ui/alert-dialog"; + +type DashboardConfirmDialogProps = { + pendingAction: ConfirmAction | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}; + +export function DashboardConfirmDialog({ + pendingAction, + onOpenChange, + onConfirm, +}: DashboardConfirmDialogProps) { + return ( + + {pendingAction && ( + + + {getConfirmDialogContent(pendingAction).title} + + {getConfirmDialogContent(pendingAction).description} + + + + Cancel + + {getConfirmDialogContent(pendingAction).confirmLabel} + + + + )} + + ); +} diff --git a/apps/web/components/dashboard/dashboard-content.tsx b/apps/web/components/dashboard/dashboard-content.tsx index 319706fb..a5a12ddc 100644 --- a/apps/web/components/dashboard/dashboard-content.tsx +++ b/apps/web/components/dashboard/dashboard-content.tsx @@ -1,16 +1,21 @@ import { IconFilterX, IconHourglass, IconPlus, IconWorld } from "@tabler/icons-react"; -import type { Table } from "@tanstack/react-table"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { BulkActionsToolbar } from "@/components/dashboard/bulk-actions-toolbar"; -import { DashboardGrid } from "@/components/dashboard/dashboard-grid"; +import { + createInitialDelays, + DashboardGrid, + pruneDelays, +} from "@/components/dashboard/dashboard-grid"; import { DashboardTable } from "@/components/dashboard/dashboard-table"; import { useDashboardFiltersContext } from "@/context/dashboard-context"; +import { useIsClient } from "@/hooks/use-is-client"; +import type { DashboardTable as DashboardTableInstance } from "@/lib/dashboard-table-features"; import { usePreferencesStore } from "@/lib/stores/preferences-store"; import type { TrackedDomainWithDetails } from "@domainstack/types"; -import { Button } from "@domainstack/ui/button"; +import { Button, buttonVariants } from "@domainstack/ui/button"; import { Empty, EmptyContent, @@ -25,7 +30,7 @@ type DashboardContentProps = { totalDomains: number; // Total before filtering onAddDomain?: () => void; // Table instance callback (table view only) - onTableReady?: (table: Table) => void; + onTableReady?: (table: DashboardTableInstance) => void; }; export function DashboardContent({ @@ -36,14 +41,17 @@ export function DashboardContent({ }: DashboardContentProps) { const { hasActiveFilters, clearFilters } = useDashboardFiltersContext(); const viewMode = usePreferencesStore((s) => s.viewMode); - const [hasHydrated, setHasHydrated] = useState(false); - // Avoid animating the initial view swap during hydration when localStorage preferences reconcile. + const hasHydrated = useIsClient(); const shouldReduceMotion = useReducedMotion(); - useEffect(() => { - setHasHydrated(true); - }, []); + // Keep first-paint delays here so a zero-result filter (which unmounts the + // grid) does not recreate them via createInitialDelays on remount. + const [initialDelays, setInitialDelays] = useState(() => createInitialDelays(domains)); + const delays = pruneDelays(initialDelays, domains); + if (delays !== initialDelays) { + setInitialDelays(delays); + } // Empty state: No domains match filters if (domains.length === 0 && hasActiveFilters) { @@ -98,15 +106,14 @@ export function DashboardContent({ Add Your First Domain ) : ( - + ); +} diff --git a/apps/web/components/dashboard/mocks/subscription.ts b/apps/web/components/dashboard/mocks/subscription.ts new file mode 100644 index 00000000..eb98b76f --- /dev/null +++ b/apps/web/components/dashboard/mocks/subscription.ts @@ -0,0 +1,36 @@ +import { vi } from "vitest"; + +export const mockSubscription = { + plan: "pro" as "free" | "pro", + planQuota: 100, + endsAt: null as Date | null, + activeCount: 4, + archivedCount: 0, + canAddMore: true, +}; + +export const subscriptionActionSpies = { + handleCheckout: vi.fn<() => void>(), + handleCustomerPortal: vi.fn<() => void>(), +}; + +export function resetSubscriptionActionSpies() { + for (const spy of Object.values(subscriptionActionSpies)) { + spy.mockClear(); + } +} + +export function useSubscription() { + return { + subscription: mockSubscription, + isPro: mockSubscription.plan === "pro", + isSubscriptionLoading: false, + isSubscriptionError: false, + refetchSubscription: () => undefined, + invalidateSubscription: () => undefined, + handleCheckout: subscriptionActionSpies.handleCheckout, + isCheckoutLoading: false, + handleCustomerPortal: subscriptionActionSpies.handleCustomerPortal, + isCustomerPortalLoading: false, + }; +} diff --git a/apps/web/components/dashboard/test-fixtures.ts b/apps/web/components/dashboard/test-fixtures.ts new file mode 100644 index 00000000..4bb5d95e --- /dev/null +++ b/apps/web/components/dashboard/test-fixtures.ts @@ -0,0 +1,163 @@ +import { EXPIRING_SOON_DAYS } from "@domainstack/constants"; +import type { ProviderInfo, ResumeDomainData, TrackedDomainWithDetails } from "@domainstack/types"; + +/** Stable clock for health/expiry fixtures. Keep in sync with `vi.setSystemTime` in tests. */ +export const DASHBOARD_TEST_NOW = new Date("2026-08-23T12:00:00.000Z"); + +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +export function daysFromTestNow(days: number): Date { + return new Date(DASHBOARD_TEST_NOW.getTime() + days * MS_PER_DAY); +} + +export const EMPTY_PROVIDER: ProviderInfo = { id: null, name: null, domain: null }; + +export function makeProvider( + id: string, + name: string, + domain: string | null = `${id}.com`, +): ProviderInfo { + return { id, name, domain }; +} + +function cloneProvider(provider: ProviderInfo): ProviderInfo { + return { + ...provider, + records: provider.records?.map((record) => ({ ...record })), + rdapServers: provider.rdapServers ? [...provider.rdapServers] : provider.rdapServers, + registrantInfo: provider.registrantInfo + ? { + ...provider.registrantInfo, + contacts: provider.registrantInfo.contacts?.map((c) => ({ ...c })) ?? null, + } + : provider.registrantInfo, + certificateExpiryDate: provider.certificateExpiryDate + ? new Date(provider.certificateExpiryDate.getTime()) + : provider.certificateExpiryDate, + }; +} + +function cloneDate(date: Date | null | undefined): Date | null { + return date ? new Date(date.getTime()) : null; +} + +const CLOUDFLARE = makeProvider("cloudflare", "Cloudflare", "cloudflare.com"); +const NAMECHEAP = makeProvider("namecheap", "Namecheap", "namecheap.com"); +const VERCEL = makeProvider("vercel", "Vercel", "vercel.com"); + +export function makeTrackedDomain( + overrides: Partial = {}, +): TrackedDomainWithDetails { + const domainName = overrides.domainName ?? "alpha.com"; + const tld = overrides.tld ?? domainName.split(".").at(-1) ?? "com"; + const id = overrides.id ?? `domain-${domainName.replaceAll(".", "-")}`; + + const domain = { + id, + userId: "user-test", + domainId: overrides.domainId ?? `dns-${id}`, + domainName, + tld, + verified: true, + verificationMethod: "dns_txt" as TrackedDomainWithDetails["verificationMethod"], + verificationToken: "token", + verificationStatus: "verified" as TrackedDomainWithDetails["verificationStatus"], + verificationFailedAt: null as Date | null, + lastVerifiedAt: cloneDate(DASHBOARD_TEST_NOW), + muted: false, + createdAt: cloneDate(DASHBOARD_TEST_NOW)!, + verifiedAt: cloneDate(DASHBOARD_TEST_NOW), + archivedAt: null as Date | null, + expirationDate: daysFromTestNow(200), + registrationDate: daysFromTestNow(-365), + registrar: cloneProvider(CLOUDFLARE), + dns: cloneProvider(EMPTY_PROVIDER), + hosting: cloneProvider(EMPTY_PROVIDER), + email: cloneProvider(EMPTY_PROVIDER), + ca: cloneProvider(EMPTY_PROVIDER), + ...overrides, + }; + + return { + ...domain, + verificationFailedAt: cloneDate(domain.verificationFailedAt), + lastVerifiedAt: cloneDate(domain.lastVerifiedAt), + createdAt: cloneDate(domain.createdAt)!, + verifiedAt: cloneDate(domain.verifiedAt), + archivedAt: cloneDate(domain.archivedAt), + expirationDate: cloneDate(domain.expirationDate), + registrationDate: cloneDate(domain.registrationDate), + registrar: cloneProvider(domain.registrar), + dns: cloneProvider(domain.dns), + hosting: cloneProvider(domain.hosting), + email: cloneProvider(domain.email), + ca: cloneProvider(domain.ca), + }; +} + +/** + * Default catalog: healthy .com / expiring .io / expired .com / unverified .dev. + */ +export function makeDashboardDomains(): TrackedDomainWithDetails[] { + return [ + makeTrackedDomain({ + id: "domain-alpha", + domainName: "alpha.com", + tld: "com", + expirationDate: daysFromTestNow(200), + registrar: CLOUDFLARE, + }), + makeTrackedDomain({ + id: "domain-beta", + domainName: "beta.io", + tld: "io", + expirationDate: daysFromTestNow(Math.floor(EXPIRING_SOON_DAYS / 2)), + registrar: NAMECHEAP, + dns: CLOUDFLARE, + }), + makeTrackedDomain({ + id: "domain-gamma", + domainName: "gamma.com", + tld: "com", + expirationDate: daysFromTestNow(-10), + registrar: EMPTY_PROVIDER, + hosting: VERCEL, + }), + makeTrackedDomain({ + id: "domain-pending", + domainName: "pending.dev", + tld: "dev", + verified: false, + verificationMethod: null, + verificationStatus: "unverified", + lastVerifiedAt: null, + verifiedAt: null, + expirationDate: null, + registrationDate: null, + registrar: EMPTY_PROVIDER, + dns: EMPTY_PROVIDER, + hosting: EMPTY_PROVIDER, + }), + ]; +} + +export function makeResumeDomain(overrides: Partial = {}): ResumeDomainData { + return { + id: overrides.id ?? "domain-pending", + domainName: overrides.domainName ?? "pending.dev", + verificationToken: overrides.verificationToken ?? "token-pending", + verificationMethod: overrides.verificationMethod ?? "dns_txt", + }; +} + +export function makePaginationDomains(count = 12): TrackedDomainWithDetails[] { + return Array.from({ length: count }, (_, index) => { + const n = String(index).padStart(2, "0"); + return makeTrackedDomain({ + id: `page-${n}`, + domainName: `site${n}.com`, + tld: "com", + expirationDate: daysFromTestNow(200), + }); + }); +} diff --git a/apps/web/components/dashboard/test-utils.tsx b/apps/web/components/dashboard/test-utils.tsx new file mode 100644 index 00000000..31b15ee7 --- /dev/null +++ b/apps/web/components/dashboard/test-utils.tsx @@ -0,0 +1,346 @@ +import { parseAsString, useQueryState } from "nuqs"; +import { NuqsTestingAdapter } from "nuqs/adapters/testing"; +import { useCallback, useMemo, useState } from "react"; +import { vi } from "vitest"; + +import { ArchivedDomainsList } from "@/components/dashboard/archived-domains-list"; +import { DashboardConfirmDialog } from "@/components/dashboard/dashboard-confirm-dialog"; +import { DashboardContent } from "@/components/dashboard/dashboard-content"; +import { DashboardFilters } from "@/components/dashboard/dashboard-filters"; +import { DashboardHeader } from "@/components/dashboard/dashboard-header"; +import { HealthSummary } from "@/components/dashboard/health-summary"; +import { + mockSubscription, + resetSubscriptionActionSpies, +} from "@/components/dashboard/mocks/subscription"; +import { DashboardProvider } from "@/context/dashboard-context"; +import { useDashboardFilters } from "@/hooks/use-dashboard-filters"; +import { + getDashboardFilterSignature, + useDashboardPagination, + useSyncDashboardPage, +} from "@/hooks/use-dashboard-pagination"; +import { useSyncVisibleDomainIds } from "@/hooks/use-dashboard-selection"; +import { resetHydratedNow } from "@/hooks/use-hydrated-now"; +import type { DashboardTable } from "@/lib/dashboard-table-features"; +import { + type ConfirmAction, + DEFAULT_SORT, + SORT_OPTIONS, + type SortOption, + sortDomains, +} from "@/lib/dashboard-utils"; +import { usePreferencesStore } from "@/lib/stores/preferences-store"; +import { render } from "@/mocks/react"; +import type { TrackedDomainWithDetails } from "@domainstack/types"; +import { TooltipProvider } from "@domainstack/ui/tooltip"; + +import { DASHBOARD_TEST_NOW, makeDashboardDomains } from "./test-fixtures"; + +export { createInitialDelays, pruneDelays } from "@/components/dashboard/dashboard-grid"; +export { + mockSubscription, + subscriptionActionSpies, +} from "@/components/dashboard/mocks/subscription"; + +export const dashboardActionSpies = { + onVerify: vi.fn<(id: string, method: string | null) => void>(), + onRemove: vi.fn<(id: string, domainName: string) => void>(), + onArchive: vi.fn<(id: string, domainName: string) => void>(), + onUnarchive: vi.fn<(id: string) => void>(), + onToggleMuted: vi.fn<(id: string, muted: boolean) => void>(), + onBulkArchive: vi.fn<(domainIds: string[]) => void>(), + onBulkDelete: vi.fn<(domainIds: string[]) => void>(), +}; + +const emptyProviders = { + registrar: [], + dns: [], + hosting: [], + email: [], + ca: [], +}; + +function stubFilterHook() { + return { + state: { + search: "", + status: [], + health: [], + tlds: [], + providers: [], + domainId: null, + filteredDomainName: null, + availableTlds: [], + availableProviders: emptyProviders, + hasActiveFilters: false, + stats: { expiringSoon: 0, pendingVerification: 0 }, + }, + actions: { + setSearch: vi.fn<(value: string) => void>(), + setStatus: vi.fn<(values: ("verified" | "pending")[]) => void>(), + setHealth: vi.fn<(values: ("healthy" | "expiring" | "expired")[]) => void>(), + setTlds: vi.fn<(values: string[]) => void>(), + setProviders: vi.fn<(values: string[]) => void>(), + clearFilters: vi.fn<() => void>(), + applyHealthFilter: vi.fn<(filter: "healthy" | "expiring" | "expired" | "pending") => void>(), + clearDomainId: vi.fn<() => void>(), + }, + }; +} + +function stubPaginationHook() { + return { + state: { pageIndex: 0, pageSize: 10 as const }, + actions: { + setPageIndex: vi.fn<(pageIndex: number) => void>(), + setPageSize: vi.fn<(pageSize: 10 | 25 | 50 | 100) => void>(), + resetPage: vi.fn<() => void>(), + }, + }; +} + +export function resetDashboardTestState() { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(DASHBOARD_TEST_NOW); + resetHydratedNow(DASHBOARD_TEST_NOW); + localStorage.clear(); + usePreferencesStore.setState({ + viewMode: "grid", + pageSize: 10, + columnVisibility: {}, + showToolCalls: true, + showReasoning: false, + hideAiFeatures: false, + aiMode: "cloud", + }); + mockSubscription.plan = "pro"; + mockSubscription.planQuota = 100; + mockSubscription.endsAt = null; + mockSubscription.activeCount = 4; + mockSubscription.archivedCount = 0; + mockSubscription.canAddMore = true; + for (const spy of Object.values(dashboardActionSpies)) { + spy.mockClear(); + } + resetSubscriptionActionSpies(); +} + +type DashboardTestShellProps = { + domains: TrackedDomainWithDetails[]; + totalDomains: number; + userName?: string; + confirmActions?: boolean; +}; + +function DashboardTestShell({ + domains, + totalDomains, + userName = "Test User", + confirmActions = false, +}: DashboardTestShellProps) { + const viewMode = usePreferencesStore((s) => s.viewMode); + const [sortParam, setSortParam] = useQueryState( + "sort", + parseAsString.withDefault(DEFAULT_SORT).withOptions({ + shallow: true, + clearOnDefault: true, + }), + ); + const sortOption = SORT_OPTIONS.some((opt) => opt.value === sortParam) + ? (sortParam as SortOption) + : DEFAULT_SORT; + + const paginationHook = useDashboardPagination(); + const filterHook = useDashboardFilters(domains); + const { filteredDomains: filteredUnsorted } = filterHook.state; + + const filteredDomains = useMemo( + () => (viewMode === "grid" ? sortDomains(filteredUnsorted, sortOption) : filteredUnsorted), + [filteredUnsorted, sortOption, viewMode], + ); + + const filteredDomainIds = useMemo(() => filteredDomains.map((d) => d.id), [filteredDomains]); + useSyncVisibleDomainIds(filteredDomainIds); + useSyncDashboardPage({ + itemCount: filteredDomains.length, + pageIndex: paginationHook.state.pageIndex, + pageSize: paginationHook.state.pageSize, + filterSignature: getDashboardFilterSignature(filterHook.state), + resetPage: paginationHook.actions.resetPage, + }); + + const [tableInstance, setTableInstance] = useState(null); + const [pendingAction, setPendingAction] = useState(null); + + const requestRemove = useCallback((id: string, domainName: string) => { + setPendingAction({ type: "remove", domainId: id, domainName }); + }, []); + const requestArchive = useCallback((id: string, domainName: string) => { + setPendingAction({ type: "archive", domainId: id, domainName }); + }, []); + const requestBulkArchive = useCallback((domainIds: string[]) => { + setPendingAction({ type: "bulk-archive", domainIds, count: domainIds.length }); + }, []); + const requestBulkDelete = useCallback((domainIds: string[]) => { + setPendingAction({ type: "bulk-delete", domainIds, count: domainIds.length }); + }, []); + + const onRemove = confirmActions ? requestRemove : dashboardActionSpies.onRemove; + const onArchive = confirmActions ? requestArchive : dashboardActionSpies.onArchive; + const onBulkArchive = confirmActions ? requestBulkArchive : dashboardActionSpies.onBulkArchive; + const onBulkDelete = confirmActions ? requestBulkDelete : dashboardActionSpies.onBulkDelete; + + const handleConfirm = () => { + if (!pendingAction) return; + if (pendingAction.type === "remove") { + dashboardActionSpies.onRemove(pendingAction.domainId, pendingAction.domainName); + } else if (pendingAction.type === "archive") { + dashboardActionSpies.onArchive(pendingAction.domainId, pendingAction.domainName); + } else if (pendingAction.type === "bulk-archive") { + dashboardActionSpies.onBulkArchive(pendingAction.domainIds); + } else if (pendingAction.type === "bulk-delete") { + dashboardActionSpies.onBulkDelete(pendingAction.domainIds); + } + setPendingAction(null); + }; + + return ( + +
+ + {totalDomains > 0 && ( +
+ + +
+ )} + +
+ {confirmActions && pendingAction ? ( + { + if (!open) setPendingAction(null); + }} + onConfirm={handleConfirm} + /> + ) : null} +
+ ); +} + +export type RenderDashboardShellOptions = { + domains?: TrackedDomainWithDetails[]; + totalDomains?: number; + searchParams?: string; + userName?: string; + confirmActions?: boolean; +}; + +export function renderDashboardShell(options: RenderDashboardShellOptions = {}) { + const domains = options.domains ?? makeDashboardDomains(); + const totalDomains = options.totalDomains ?? domains.length; + mockSubscription.activeCount = totalDomains; + + const urlUpdates: string[] = []; + + const view = render( + { + urlUpdates.push(event.queryString); + }} + > + + , + ); + + return { ...view, domains, urlUpdates }; +} + +export function renderDashboardConfirmShell(options: RenderDashboardShellOptions = {}) { + return renderDashboardShell({ ...options, confirmActions: true }); +} + +export function renderArchivedList(domains: TrackedDomainWithDetails[]) { + mockSubscription.activeCount = 0; + return render( + + void>()} + table={null} + setTable={vi.fn<(table: DashboardTable | null) => void>()} + paginationHook={stubPaginationHook()} + > + + + , + ); +} + +const nativeMatchMedia = window.matchMedia.bind(window); +window.matchMedia = (query: string) => { + const forcedMatch = + query.includes("prefers-reduced-motion") || + query === "(hover: hover)" || + query === "(pointer: fine)"; + const forcedMiss = query === "(pointer: coarse)"; + + if (forcedMatch || forcedMiss) { + return { + matches: forcedMatch, + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + }; + } + return nativeMatchMedia(query); +}; + +vi.useFakeTimers({ toFake: ["Date"] }); +vi.setSystemTime(DASHBOARD_TEST_NOW); +resetHydratedNow(DASHBOARD_TEST_NOW); diff --git a/apps/web/components/dashboard/unverified-table-row.tsx b/apps/web/components/dashboard/unverified-table-row.tsx index d8ce6c07..b2b1f4db 100644 --- a/apps/web/components/dashboard/unverified-table-row.tsx +++ b/apps/web/components/dashboard/unverified-table-row.tsx @@ -1,27 +1,23 @@ -"use no memo"; // Disable React Compiler memoization - TanStack Table has issues with it import { type Cell, flexRender } from "@tanstack/react-table"; import { motion, useReducedMotion } from "motion/react"; import { useDashboardActions } from "@/context/dashboard-context"; +import { useIsDomainSelected } from "@/hooks/use-dashboard-selection"; +import type { DashboardTableFeatures } from "@/lib/dashboard-table-features"; import type { TrackedDomainWithDetails } from "@domainstack/types"; import { Button } from "@domainstack/ui/button"; import { cn } from "@domainstack/ui/utils"; type UnverifiedTableRowProps = { rowId: string; - cells: Cell[]; + cells: Cell[]; original: TrackedDomainWithDetails; - isSelected: boolean; }; -export function UnverifiedTableRow({ - rowId, - cells, - original, - isSelected, -}: UnverifiedTableRowProps) { +export function UnverifiedTableRow({ rowId, cells, original }: UnverifiedTableRowProps) { const { onVerify, onRemove } = useDashboardActions(); const shouldReduceMotion = useReducedMotion(); + const isSelected = useIsDomainSelected(original.id); // Find cells by column ID for maintainability const cellMap = new Map(cells.map((cell) => [cell.column.id, cell])); diff --git a/apps/web/components/dashboard/verified-table-row.tsx b/apps/web/components/dashboard/verified-table-row.tsx index dc78069c..9640bb2c 100644 --- a/apps/web/components/dashboard/verified-table-row.tsx +++ b/apps/web/components/dashboard/verified-table-row.tsx @@ -1,18 +1,20 @@ -"use no memo"; // Disable React Compiler memoization - TanStack Table has issues with it import { type Cell, flexRender } from "@tanstack/react-table"; import { motion, useReducedMotion } from "motion/react"; +import { useIsDomainSelected } from "@/hooks/use-dashboard-selection"; +import type { DashboardTableFeatures } from "@/lib/dashboard-table-features"; import type { TrackedDomainWithDetails } from "@domainstack/types"; import { cn } from "@domainstack/ui/utils"; type VerifiedTableRowProps = { rowId: string; - cells: Cell[]; - isSelected: boolean; + cells: Cell[]; + original: TrackedDomainWithDetails; }; -export function VerifiedTableRow({ rowId, cells, isSelected }: VerifiedTableRowProps) { +export function VerifiedTableRow({ rowId, cells, original }: VerifiedTableRowProps) { const shouldReduceMotion = useReducedMotion(); + const isSelected = useIsDomainSelected(original.id); return ( t.value).join(""); - if (reconstructed !== line) { - return <>{line}; + const result = tokenizeLine(line); + const reconstructed = result.map((t) => t.value).join(""); + if (reconstructed === line) { + tokens = result; } } catch { - // Tokenization failed - fall back to plain text + tokens = null; + } + + if (!tokens) { return <>{line}; } @@ -214,17 +216,15 @@ export function RawDataDialog({ domain, format, data, serverName, serverUrl }: R const lines = useMemo(() => formattedData?.trim().split("\n") ?? [], [formattedData]); const lineItems = useMemo(() => { const seen = new Map(); - let lineNumber = 0; - return lines.map((line) => { - lineNumber += 1; + return lines.map((line, index) => { const duplicateCount = seen.get(line) ?? 0; seen.set(line, duplicateCount + 1); return { key: `${line || "empty-line"}-${duplicateCount}`, line, - lineNumber, + lineNumber: index + 1, }; }); }, [lines]); diff --git a/apps/web/components/domain/report-client.tsx b/apps/web/components/domain/report-client.tsx index 48adada5..79d14cf1 100644 --- a/apps/web/components/domain/report-client.tsx +++ b/apps/web/components/domain/report-client.tsx @@ -8,7 +8,6 @@ import { useQueryClient, useSuspenseQuery, } from "@tanstack/react-query"; -import { useSetAtom } from "jotai"; import { Suspense, useEffect, useRef, useState } from "react"; import { CreateIssueButton } from "@/components/create-issue-button"; @@ -31,7 +30,7 @@ import { SeoSectionSkeleton } from "@/components/domain/seo/seo-section-skeleton import { DomainUnregisteredCard } from "@/components/domain/unregistered-card"; import { useIsMobile } from "@/hooks/use-mobile"; import { useSectionTracking } from "@/hooks/use-section-tracking"; -import { chatContextAtom } from "@/lib/atoms/chat-atoms"; +import { analytics } from "@/lib/analytics/client"; import { HEADER_HEIGHT, SCROLL_PADDING, SECTION_NAV_HEIGHT } from "@/lib/constants/layout"; import { sections } from "@/lib/constants/sections"; import { useSearchHistoryStore } from "@/lib/stores/search-history-store"; @@ -170,12 +169,17 @@ export function DomainReportClient({ domain }: { domain: string }) { } }, [isRegistered, domain, addDomainToHistory]); - // Set chat context for domain-specific suggestions - const setChatContext = useSetAtom(chatContextAtom); + const viewedDomainRef = useRef(null); useEffect(() => { - setChatContext({ type: "report", domain }); - return () => setChatContext({ type: "home" }); - }, [domain, setChatContext]); + if (!isRegistered) { + return; + } + if (viewedDomainRef.current === domain) { + return; + } + viewedDomainRef.current = domain; + analytics.track("report_viewed", { domain }); + }, [domain, isRegistered]); const headerRef = useRef(null); const sectionIds = Object.keys(sections); diff --git a/apps/web/components/domain/report-section-error-boundary.tsx b/apps/web/components/domain/report-section-error-boundary.tsx index 90615bd9..82e301eb 100644 --- a/apps/web/components/domain/report-section-error-boundary.tsx +++ b/apps/web/components/domain/report-section-error-boundary.tsx @@ -5,7 +5,7 @@ import { useQueryErrorResetBoundary } from "@tanstack/react-query"; import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; import { CreateIssueButton } from "@/components/create-issue-button"; -import { analytics } from "@domainstack/analytics/client"; +import { analytics } from "@/lib/analytics/client"; import { Button } from "@domainstack/ui/button"; import { Empty, diff --git a/apps/web/components/domain/screenshot-popover.tsx b/apps/web/components/domain/screenshot-popover.tsx index 2bb40347..aac8e89e 100644 --- a/apps/web/components/domain/screenshot-popover.tsx +++ b/apps/web/components/domain/screenshot-popover.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Screenshot, useScreenshot } from "@/components/domain/screenshot"; import { usePointerCapability } from "@domainstack/ui/hooks"; @@ -30,13 +30,6 @@ export function ScreenshotPopover({ // Hook lives here (not in PopoverContent) so it stays mounted and keeps polling const screenshot = useScreenshot({ domain, domainId, enabled: hasOpened }); - // Reset tap count when popover closes - useEffect(() => { - if (!open) { - setTapCount(0); - } - }, [open]); - const handleInteraction = (e: React.MouseEvent) => { // On touch devices, implement two-tap behavior if (isTouchDevice && tapCount === 0) { @@ -55,6 +48,7 @@ export function ScreenshotPopover({ onOpenChange={(v) => { setOpen(v); if (v) setHasOpened(true); + else setTapCount(0); }} > (null); const [screenshotData, setScreenshotData] = useState(null); + const [pollFailed, setPollFailed] = useState(false); + const [pollError, setPollError] = useState(null); const hasStartedRef = useRef(false); const startedForDomainRef = useRef(null); const [rateLimitedUntil, setRateLimitedUntil] = useState(null); @@ -124,6 +126,16 @@ export function useScreenshot({ const screenshotQueryKey = useMemo(() => ["screenshot", domain], [domain]); const cachedData = queryClient.getQueryData(screenshotQueryKey); + const [trackedDomain, setTrackedDomain] = useState(domain); + if (domain !== trackedDomain) { + setTrackedDomain(domain); + setScreenshotData(null); + setRunId(null); + setRateLimitedUntil(null); + setPollFailed(false); + setPollError(null); + } + const startScreenshot = useCallback(async (id: string) => { const response = await fetch("/api/screenshot", { method: "POST", @@ -156,8 +168,10 @@ export function useScreenshot({ } else if (data.status === "rate_limited") { const retryAt = Date.now() + data.retryAfter * 1000; setRateLimitedUntil(retryAt); - toast.error("Too many requests", { + toast.add({ + title: "Too many requests", description: `Please wait ${data.retryAfter} second${data.retryAfter !== 1 ? "s" : ""} before trying again.`, + type: "error", }); analytics.track("screenshot_rate_limited", { domain, @@ -206,22 +220,34 @@ export function useScreenshot({ }, }); - // Handle polling completion + // Handle polling completion after commit so we don't setState during render. + // Persist terminal results before clearing runId — disabling the status query + // would otherwise drop completed data and failed/error state. useEffect(() => { - if (!statusQuery.data || statusQuery.data.status === "running") return; + const data = statusQuery.data; + if (!data || data.status === "running") return; - if (statusQuery.data.status === "completed") { - setScreenshotData(statusQuery.data.data); - queryClient.setQueryData(screenshotQueryKey, statusQuery.data.data); - analytics.track("screenshot_loaded_from_api", { domain }); - setRunId(null); - } else if (statusQuery.data.status === "rate_limited") { - toast.error("Too many requests", { - description: `Polling paused. Retrying in ${statusQuery.data.retryAfter} seconds.`, + if (data.status === "rate_limited") { + toast.add({ + title: "Too many requests", + description: `Polling paused. Retrying in ${data.retryAfter} seconds.`, + type: "error", }); - } else { - setRunId(null); + return; } + + if (data.status === "completed") { + // oxlint-disable-next-line react/set-state-in-effect + setScreenshotData(data.data); + queryClient.setQueryData(screenshotQueryKey, data.data); + analytics.track("screenshot_loaded_from_api", { domain }); + } else if (data.status === "failed") { + setPollFailed(true); + } else if (data.status === "error") { + setPollError(new Error(data.error)); + } + + setRunId(null); }, [statusQuery.data, queryClient, screenshotQueryKey, domain]); // Cleanup retry timeout on unmount @@ -239,9 +265,6 @@ export function useScreenshot({ if (startedForDomainRef.current !== domain) { hasStartedRef.current = false; startedForDomainRef.current = domain; - setScreenshotData(null); - setRunId(null); - setRateLimitedUntil(null); if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; @@ -261,9 +284,10 @@ export function useScreenshot({ }, [domain, enabled, domainId, cachedData, screenshotData, startMutation, rateLimitedUntil]); // Derive return values - const finalData = screenshotData ?? cachedData ?? null; - const error = startMutation.error ?? statusQuery.error ?? null; - const hasFailed = statusQuery.data?.status === "failed"; + const polledData = statusQuery.data?.status === "completed" ? statusQuery.data.data : undefined; + const finalData = screenshotData ?? polledData ?? cachedData ?? null; + const error = startMutation.error ?? statusQuery.error ?? pollError ?? null; + const hasFailed = pollFailed || statusQuery.data?.status === "failed"; const isLoading = !finalData && !error && diff --git a/apps/web/components/domain/seo/robots-summary.tsx b/apps/web/components/domain/seo/robots-summary.tsx index eb87044d..6bab1dc3 100644 --- a/apps/web/components/domain/seo/robots-summary.tsx +++ b/apps/web/components/domain/seo/robots-summary.tsx @@ -13,7 +13,7 @@ import { IconX, } from "@tabler/icons-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { useCallback, useMemo, useState, useTransition } from "react"; import { PillCount } from "@/components/domain/pill-count"; import type { SeoResponse } from "@domainstack/types"; @@ -65,15 +65,16 @@ function useProgressiveReveal(items: T[], initialVisible: number) { const [visible, setVisible] = useState(initialVisible); const total = items.length; const more = total - visible; - const prevVisibleRef = useRef(visible); - const prev = Math.min(prevVisibleRef.current, visible); + const [prevVisible, setPrevVisible] = useState(initialVisible); + const [seenVisible, setSeenVisible] = useState(initialVisible); + if (visible !== seenVisible) { + setPrevVisible(seenVisible); + setSeenVisible(visible); + } + const prev = Math.min(prevVisible, visible, total); const existing = items.slice(0, prev); const added = items.slice(prev, Math.min(visible, total)); - useEffect(() => { - prevVisibleRef.current = Math.min(visible, items.length); - }, [visible, items]); - return { existing, added, more, total, visible, setVisible } as const; } diff --git a/apps/web/components/layout/animated-background.tsx b/apps/web/components/layout/animated-background.tsx index e29e6a9e..dd40ee26 100644 --- a/apps/web/components/layout/animated-background.tsx +++ b/apps/web/components/layout/animated-background.tsx @@ -7,10 +7,11 @@ import { type Transition, useReducedMotion, } from "motion/react"; -import { useEffect, useId, useState } from "react"; +import { useId, useState } from "react"; import { createPortal } from "react-dom"; import { StaticBackground } from "@/components/layout/static-background"; +import { useIsClient } from "@/hooks/use-is-client"; /** * Animated gradient background with organic, drifting motion. @@ -21,15 +22,12 @@ export function AnimatedBackground() { const shouldReduceMotion = useReducedMotion(); const baseId = useId(); + const mounted = useIsClient(); const [blobParams, setBlobParams] = useState(null); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - // Generate randomness only after hydration to keep SSR/prerender deterministic. - // This avoids Next.js' prerender hydration safeguards around Math.random(). + // Generate randomness only after hydration to keep SSR/prerender deterministic. + if (mounted && blobParams === null) { setBlobParams(generateBlobParams(createClientRand(), baseId)); - setMounted(true); - }, [baseId]); + } if (!mounted) return null; diff --git a/apps/web/components/layout/app-footer.tsx b/apps/web/components/layout/app-footer.tsx index 870b6ec1..40ae2bd9 100644 --- a/apps/web/components/layout/app-footer.tsx +++ b/apps/web/components/layout/app-footer.tsx @@ -6,7 +6,6 @@ import { IconBookmarks, IconBrandApple, IconCookie, - IconCornerLeftUp, IconExternalLink, IconGavel, IconHeart, @@ -17,7 +16,6 @@ import { import * as motion from "motion/react-client"; import Link from "next/link"; import { useState } from "react"; -import { toast } from "sonner"; import { BetaBadge } from "@/components/beta-badge"; import { APPLE_SHORTCUT_ID } from "@domainstack/constants"; @@ -29,6 +27,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@domainstack/ui/dropdown-menu"; +import { toast } from "@domainstack/ui/toast"; export function AppFooter() { const [isBookmarkletsOpen, setIsBookmarkletsOpen] = useState(false); @@ -40,6 +39,13 @@ export function AppFooter() { if (!element) return; element.href = `javascript:(function(){var t=window.open("${process.env.NEXT_PUBLIC_BASE_URL}/"+location.hostname,"_blank");t.focus()})();`; }; + const handleInspectDomainClick = (e: React.MouseEvent) => { + e.preventDefault(); + toast.add({ + title: "Drag the button to your bookmarks bar to use it.", + type: "info", + }); + }; return ( <> @@ -156,13 +162,7 @@ export function AppFooter() { size="lg" nativeButton={false} render={} - onClick={(e) => { - e.preventDefault(); - toast.info("Drag the button to your bookmarks bar to use it.", { - icon: , - position: "top-center", - }); - }} + onClick={handleInspectDomainClick} > Inspect Domain diff --git a/apps/web/components/layout/static-background.tsx b/apps/web/components/layout/static-background.tsx index f93998a2..392b6c16 100644 --- a/apps/web/components/layout/static-background.tsx +++ b/apps/web/components/layout/static-background.tsx @@ -1,14 +1,11 @@ "use client"; -import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; -export function StaticBackground() { - const [mounted, setMounted] = useState(false); +import { useIsClient } from "@/hooks/use-is-client"; - useEffect(() => { - setMounted(true); - }, []); +export function StaticBackground() { + const mounted = useIsClient(); if (!mounted) return null; diff --git a/apps/web/components/layout/user-menu.tsx b/apps/web/components/layout/user-menu.tsx index 19952046..5f496545 100644 --- a/apps/web/components/layout/user-menu.tsx +++ b/apps/web/components/layout/user-menu.tsx @@ -12,7 +12,7 @@ import Link from "next/link"; import { useRouter } from "@/hooks/use-router"; import { useTheme } from "@/hooks/use-theme"; -import { useAnalytics } from "@domainstack/analytics/client"; +import { useAnalytics } from "@/lib/analytics/client"; import { signOut, useSession } from "@domainstack/auth/client"; import { Avatar, AvatarFallback, AvatarImage } from "@domainstack/ui/avatar"; import { Button } from "@domainstack/ui/button"; @@ -59,8 +59,6 @@ export function UserMenu() { await signOut({ fetchOptions: { onSuccess: () => { - // Reset PostHog identity to prevent event crossover between users - analytics.reset(); router.push("/"); }, }, diff --git a/apps/web/components/notifications/notification-card.tsx b/apps/web/components/notifications/notification-card.tsx index 003d6a03..9f7d4dd3 100644 --- a/apps/web/components/notifications/notification-card.tsx +++ b/apps/web/components/notifications/notification-card.tsx @@ -2,7 +2,7 @@ import { formatDistanceToNow } from "date-fns"; import Link from "next/link"; -import { useMemo } from "react"; +import { createElement } from "react"; import { getNotificationIcon, @@ -20,7 +20,7 @@ interface NotificationCardProps { } export function NotificationCard({ notification, onClick }: NotificationCardProps) { - const IconComponent = useMemo(() => getNotificationIcon(notification.type), [notification.type]); + const IconComponent = getNotificationIcon(notification.type); const severity = getNotificationSeverity(notification.type); const iconColor = getSeverityIconColor(severity, !!notification.readAt); const isUnread = !notification.readAt; @@ -43,7 +43,7 @@ export function NotificationCard({ notification, onClick }: NotificationCardProp
{/* Icon */} - + {createElement(IconComponent)} {/* Content */} diff --git a/apps/web/components/notifications/notification-empty-state.tsx b/apps/web/components/notifications/notification-empty-state.tsx index e0ed119c..d851736c 100644 --- a/apps/web/components/notifications/notification-empty-state.tsx +++ b/apps/web/components/notifications/notification-empty-state.tsx @@ -16,7 +16,9 @@ export function NotificationEmptyState({ variant, onClosePopover }: Notification return (
{variant === "inbox" ? : } -

All caught up!

+

+ {variant === "inbox" ? "All caught up!" : "Nothing archived yet"} +

{variant === "inbox" ? "No unread notifications" : "Nothing to see here (yet…)"}

diff --git a/apps/web/components/notifications/notifications-popover.test.tsx b/apps/web/components/notifications/notifications-popover.test.tsx new file mode 100644 index 00000000..56bf634c --- /dev/null +++ b/apps/web/components/notifications/notifications-popover.test.tsx @@ -0,0 +1,251 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const nav = vi.hoisted(() => ({ + push: vi.fn<(href: string) => void>(), +})); + +vi.mock("@/lib/trpc/client", async () => { + const { useTRPC } = await import("@/mocks/trpc"); + return { useTRPC }; +}); +vi.mock("@/hooks/use-router", () => ({ + useRouter: () => ({ push: nav.push }), +})); +vi.mock("@domainstack/ui/toast", () => ({ + toast: { + add: vi.fn<(options?: { title?: string; description?: string; type?: string }) => void>(), + }, +})); + +import { NotificationsPopover } from "@/components/notifications/notifications-popover"; +import { + makeNotification, + makeNotificationsInfiniteData, +} from "@/components/notifications/test-fixtures"; +import { createTestQueryClient, render, screen, waitFor, within } from "@/mocks/react"; +import { + listNotificationsQuery, + markAllReadMutation, + markReadMutation, + NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY, + notificationsListQueryKey, + resetTrpcMocks, + setNotificationsState, + unreadCountQuery, +} from "@/mocks/trpc"; +import type { NotificationData } from "@domainstack/types"; + +const unreadAlpha = makeNotification({ id: "notif-alpha" }); +const unreadGeneric = makeNotification({ + id: "notif-generic", + trackedDomainId: null, + type: "provider_change", + title: "DNS provider changed", + message: "A tracked domain changed DNS providers.", +}); +const archivedGamma = makeNotification({ + id: "notif-gamma", + title: "gamma.com expired", + message: "gamma.com expired 10 days ago.", + trackedDomainId: "domain-gamma", + readAt: new Date("2026-08-22T12:00:00.000Z"), +}); + +function seedNotifications( + queryClient: ReturnType, + items: NotificationData[], +) { + setNotificationsState(items); + const unread = items.filter((item) => item.readAt === null); + const read = items.filter((item) => item.readAt !== null); + queryClient.setQueryData(NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY, unread.length); + queryClient.setQueryData( + notificationsListQueryKey("unread"), + makeNotificationsInfiniteData(unread), + ); + queryClient.setQueryData(notificationsListQueryKey("read"), makeNotificationsInfiniteData(read)); +} + +function renderPopover(items: NotificationData[] = [unreadAlpha, unreadGeneric, archivedGamma]) { + const queryClient = createTestQueryClient(); + seedNotifications(queryClient, items); + return render(, { queryClient }); +} + +function setupUser() { + return userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); +} + +async function openInbox(user: ReturnType) { + await user.click(screen.getByRole("button", { name: /Notifications/ })); + expect(await screen.findByRole("heading", { name: "Notifications" })).toBeInTheDocument(); +} + +describe("NotificationsPopover", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-08-24T12:00:00.000Z")); + resetTrpcMocks(); + nav.push.mockClear(); + }); + + afterEach(() => { + resetTrpcMocks(); + vi.useRealTimers(); + }); + + it("shows a badge on the bell when there are unread notifications", async () => { + renderPopover([unreadAlpha]); + + const bell = await screen.findByRole("button", { name: "Notifications (1)" }); + expect(bell.querySelector(".bg-destructive")).not.toBeNull(); + }); + + it("hides the badge when there are no unread notifications", async () => { + renderPopover([]); + + const bell = await screen.findByRole("button", { name: "Notifications" }); + expect(bell.querySelector(".bg-destructive")).toBeNull(); + }); + + it("opens the inbox with unread copy and a relative timestamp", async () => { + const user = setupUser(); + renderPopover([unreadAlpha]); + await openInbox(user); + + expect(screen.getByText("alpha.com expires in 7 days")).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Unread" })).toBeInTheDocument(); + expect(screen.getByText("1 day ago")).toBeInTheDocument(); + }); + + it("shows distinct empty copy for inbox and archive", async () => { + const user = setupUser(); + renderPopover([]); + await openInbox(user); + + expect(screen.getByText("All caught up!")).toBeInTheDocument(); + expect(screen.getByText("No unread notifications")).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: /Archive/ })); + expect(await screen.findByText("Nothing archived yet")).toBeInTheDocument(); + expect(screen.getByText("Nothing to see here (yet…)")).toBeInTheDocument(); + expect(screen.queryByText("All caught up!")).not.toBeInTheDocument(); + }); + + it("shows an error when the list fails to load", async () => { + const user = setupUser(); + listNotificationsQuery.mockRejectedValue(new Error("nope")); + const queryClient = createTestQueryClient(); + setNotificationsState([unreadAlpha]); + queryClient.setQueryData(NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY, 1); + render(, { queryClient }); + + await openInbox(user); + + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load notifications"); + }); + + it("deep-links domain notifications and falls back to the dashboard", async () => { + const user = setupUser(); + renderPopover([unreadAlpha, unreadGeneric]); + await openInbox(user); + + expect(screen.getByRole("link", { name: /alpha.com expires in 7 days/ })).toHaveAttribute( + "href", + "/dashboard?domainId=domain-alpha", + ); + expect(screen.getByRole("link", { name: /DNS provider changed/ })).toHaveAttribute( + "href", + "/dashboard", + ); + }); + + it("marks only the clicked notification as read", async () => { + const user = setupUser(); + renderPopover([unreadAlpha, unreadGeneric]); + await openInbox(user); + + const notificationLink = screen.getByRole("link", { name: /alpha.com expires in 7 days/ }); + notificationLink.addEventListener("click", (event) => event.preventDefault(), true); + await user.click(notificationLink); + + await waitFor(() => { + expect(markReadMutation.mock.calls[0]?.[0]).toEqual({ id: "notif-alpha" }); + }); + expect(markAllReadMutation).not.toHaveBeenCalled(); + }); + + it("clears all unread notifications from Inbox", async () => { + const user = setupUser(); + renderPopover([unreadAlpha, unreadGeneric]); + await openInbox(user); + + await user.click(screen.getByRole("button", { name: "Clear all notifications" })); + + await waitFor(() => { + expect(markAllReadMutation).toHaveBeenCalledOnce(); + }); + expect(await screen.findByText("All caught up!")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument(); + }); + + it("marks remaining unread as read when switching to Archive", async () => { + const user = setupUser(); + renderPopover([unreadAlpha, archivedGamma]); + await openInbox(user); + + await user.click(screen.getByRole("tab", { name: /Archive/ })); + + await waitFor(() => { + expect(markAllReadMutation).toHaveBeenCalledOnce(); + }); + expect(await screen.findByText("alpha.com expires in 7 days")).toBeInTheDocument(); + expect(screen.getByText("gamma.com expired")).toBeInTheDocument(); + }); + + it("marks remaining unread as read when closing Inbox", async () => { + const user = setupUser(); + renderPopover([unreadAlpha]); + await openInbox(user); + + await user.click(screen.getByRole("button", { name: /Notifications/ })); + + await waitFor(() => { + expect(markAllReadMutation).toHaveBeenCalledOnce(); + }); + expect(screen.queryByRole("heading", { name: "Notifications" })).not.toBeInTheDocument(); + }); + + it("closes and navigates to settings", async () => { + const user = setupUser(); + renderPopover([unreadAlpha]); + await openInbox(user); + + await user.click(screen.getByRole("button", { name: "Notification settings" })); + + expect(nav.push).toHaveBeenCalledWith("/settings/notifications"); + await waitFor(() => { + expect(markAllReadMutation).toHaveBeenCalledOnce(); + }); + expect(screen.queryByRole("heading", { name: "Notifications" })).not.toBeInTheDocument(); + }); + + it("caps the inbox badge at 99+", async () => { + const user = setupUser(); + unreadCountQuery.mockResolvedValue(100); + const queryClient = createTestQueryClient(); + setNotificationsState([unreadAlpha]); + queryClient.setQueryData(NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY, 100); + queryClient.setQueryData( + notificationsListQueryKey("unread"), + makeNotificationsInfiniteData([unreadAlpha]), + ); + queryClient.setQueryData(notificationsListQueryKey("read"), makeNotificationsInfiniteData([])); + render(, { queryClient }); + + expect(await screen.findByRole("button", { name: "Notifications (100)" })).toBeInTheDocument(); + await openInbox(user); + expect(within(screen.getByRole("tab", { name: /Inbox/ })).getByText("99+")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/notifications/notifications-popover.tsx b/apps/web/components/notifications/notifications-popover.tsx index 7097d879..139aa6b9 100644 --- a/apps/web/components/notifications/notifications-popover.tsx +++ b/apps/web/components/notifications/notifications-popover.tsx @@ -20,6 +20,7 @@ export function NotificationsPopover() { const [open, setOpen] = useState(false); const [, startTransition] = useTransition(); const autoMarkedThisOpenRef = useRef(false); + const skipAutoMarkOnCloseRef = useRef(false); // Map view to filter parameter const filter = view === "inbox" ? "unread" : "read"; @@ -32,8 +33,8 @@ export function NotificationsPopover() { hasNextPage, isFetchingNextPage, isError: isNotificationsError, - markRead, markAllRead, + markRead, fetchNextPage, getLatestUnreadCount, } = useNotificationsData({ filter, enabled: open }); @@ -42,6 +43,7 @@ export function NotificationsPopover() { useEffect(() => { if (open) { autoMarkedThisOpenRef.current = false; + skipAutoMarkOnCloseRef.current = false; } }, [open]); @@ -62,17 +64,23 @@ export function NotificationsPopover() { }); }; + const closePopover = () => { + maybeAutoMarkAllRead(); + setOpen(false); + }; + + const handleNotificationClick = (notification: NotificationData) => { + if (!notification.readAt) { + markRead.mutate({ id: notification.id }); + } + skipAutoMarkOnCloseRef.current = true; + setOpen(false); + }; + // Note: Refetch on popover open is handled automatically by TanStack Query // since staleTime: 0 ensures fresh data on each mount/query key change. // The infinite query refetches when `filter` changes (via query key). - // Reset scroll position when switching tabs - useEffect(() => { - if (scrollAreaRef.current) { - scrollAreaRef.current.scrollTop = 0; - } - }, [view]); - // Infinite scroll observer - uses scrollAreaRef as root to observe within the scroll container useEffect(() => { const scrollContainer = scrollAreaRef.current; @@ -98,21 +106,18 @@ export function NotificationsPopover() { return () => observer.disconnect(); }, [fetchNextPage, hasNextPage, isFetchingNextPage, open]); - const handleNotificationClick = (notification: NotificationData) => { - setOpen(false); - // Only mark as read if not already read - if (!notification.readAt) { - markRead.mutate({ id: notification.id }); - } - }; - return ( { - // When closing from Inbox with unread notifications, mark them all as read. + // When closing from Inbox with unread notifications, mark them all as read + // unless this close came from clicking a single notification. if (!nextOpen) { - maybeAutoMarkAllRead(); + if (skipAutoMarkOnCloseRef.current) { + skipAutoMarkOnCloseRef.current = false; + } else { + maybeAutoMarkAllRead(); + } } setOpen(nextOpen); }} @@ -171,7 +176,7 @@ export function NotificationsPopover() { onClick={(e) => { e.preventDefault(); router.push("/settings/notifications"); - setOpen(false); + closePopover(); }} render={ @@ -199,6 +204,11 @@ export function NotificationsPopover() { } startTransition(() => setView(nextView)); + + // Reset scroll position when switching tabs + if (scrollAreaRef.current) { + scrollAreaRef.current.scrollTop = 0; + } }} > @@ -258,7 +268,7 @@ export function NotificationsPopover() { loadMoreRef={loadMoreRef} scrollAreaRef={scrollAreaRef} onNotificationClick={handleNotificationClick} - onClosePopover={() => setOpen(false)} + onClosePopover={closePopover} />
diff --git a/apps/web/components/notifications/test-fixtures.ts b/apps/web/components/notifications/test-fixtures.ts new file mode 100644 index 00000000..b8161023 --- /dev/null +++ b/apps/web/components/notifications/test-fixtures.ts @@ -0,0 +1,22 @@ +import { DASHBOARD_TEST_NOW } from "@/components/dashboard/test-fixtures"; +import type { NotificationData } from "@domainstack/types"; + +export function makeNotification(overrides: Partial = {}): NotificationData { + return { + id: "notif-1", + trackedDomainId: "domain-alpha", + type: "domain_expiry_7d", + title: "alpha.com expires in 7 days", + message: "Renew alpha.com to keep it from expiring.", + sentAt: DASHBOARD_TEST_NOW, + readAt: null, + ...overrides, + }; +} + +export function makeNotificationsInfiniteData(items: NotificationData[]) { + return { + pages: [{ items, nextCursor: undefined as string | undefined }], + pageParams: [undefined as string | undefined], + }; +} diff --git a/apps/web/components/providers/haptics-provider.tsx b/apps/web/components/providers/haptics-provider.tsx new file mode 100644 index 00000000..0aa5e7b8 --- /dev/null +++ b/apps/web/components/providers/haptics-provider.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { useWebHaptics } from "web-haptics/react"; + +type Haptics = ReturnType; + +const HapticsContext = createContext(null); + +export function HapticsProvider({ children }: { children: ReactNode }) { + const { trigger, cancel, isSupported } = useWebHaptics(); + const value = useMemo(() => ({ trigger, cancel, isSupported }), [trigger, cancel, isSupported]); + + return {children}; +} + +export function useHaptics() { + const haptics = useContext(HapticsContext); + if (!haptics) { + throw new Error("useHaptics must be used within HapticsProvider"); + } + return haptics; +} diff --git a/apps/web/components/providers/vibration-provider.tsx b/apps/web/components/providers/vibration-provider.tsx deleted file mode 100644 index 3bacf9c0..00000000 --- a/apps/web/components/providers/vibration-provider.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -export function VibrationProvider() { - useEffect(() => { - // Detect Safari version - const ua = navigator.userAgent; - let version = null; - if (ua.indexOf("Safari") !== -1 && ua.indexOf("Chrome") === -1) { - const match = ua.match(/Version\/(\d+(\.\d+)?)/); - if (match?.[1]) { - version = parseFloat(match[1]); - } - } - - // Determine support level - const support = - !navigator.vibrate && version - ? version >= 18.4 - ? "granted" - : version >= 18 - ? "full" - : null - : null; - - if (!support) { - return; - } - - // State - let label: HTMLLabelElement; - let checkbox: HTMLInputElement; - let timeout: ReturnType; - let lastTouch: number | null = null; - let state: [number, number[]] = [Date.now(), []]; - - // Adjust pattern based on elapsed time - function adjustPattern(elapsed: number, pattern: number[]): number[] { - const result: number[] = []; - let remaining = elapsed; - - for (let i = 0; i < pattern.length; i++) { - const duration = pattern[i]; - if (remaining > 0) { - const diff = duration - remaining; - if (diff > 0) { - if (!result.length && i % 2) { - result.push(0); - } - result.push(diff); - remaining = 0; - } else { - remaining = Math.abs(diff); - } - } else { - if (!result.length && i % 2) { - result.push(0); - } - result.push(duration); - } - } - return result; - } - - // Sleep with drift correction - async function sleep(ms: number): Promise { - const start = Date.now(); - return new Promise((resolve) => { - clearTimeout(timeout); - timeout = setTimeout(() => resolve(ms - (Date.now() - start)), ms); - }); - } - - // Process vibration queue - async function process() { - lastTouch = Date.now(); - let drift = 0; - - for (;;) { - const [timestamp, pattern] = state; - const adjusted = adjustPattern(Date.now() - timestamp, pattern); - state = [Date.now(), adjusted]; - - const [vibrateDuration, ...rest] = adjusted; - - if (vibrateDuration == null) { - // Pattern exhausted - keep polling for more vibrations - // "full" mode (Safari 18-18.4): poll forever while in user interaction - // "granted" mode (Safari 18.4+): poll for up to 1 second - const wait = - support === "full" - ? Infinity - : lastTouch - ? Math.max(0, 1000 - (Date.now() - lastTouch)) - : 0; - if (!wait) { - return; - } - await sleep(1); - continue; - } - - const shouldVibrate = vibrateDuration > 0; - const delay = (shouldVibrate ? 26.26 : (rest[0] ?? 0)) + drift; - - if (shouldVibrate) { - label.click(); - } - drift = await sleep(delay); - } - } - - // Handle user interactions - function onInteraction(e: Event) { - if (e.target !== label && e.target !== checkbox) { - void process(); - } - } - - // Polyfill navigator.vibrate - navigator.vibrate = (pattern) => { - const p = typeof pattern === "number" ? [pattern] : [...pattern]; - if (!p.length || p.some((n) => typeof n !== "number")) { - return false; - } - state = [Date.now(), p]; - return true; - }; - - // Create hidden checkbox - label = document.createElement("label"); - label.ariaHidden = "true"; - label.style.display = "none"; - - checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.setAttribute("switch", ""); - label.appendChild(checkbox); - - // Attach event listeners - window.addEventListener("click", onInteraction, { passive: true }); - window.addEventListener("touchend", onInteraction, { passive: true }); - window.addEventListener("keyup", onInteraction, { passive: true }); - window.addEventListener("keypress", onInteraction, { passive: true }); - - // Mount - if (document.head) { - document.head.appendChild(label); - } else { - setTimeout(() => document.head.appendChild(label), 0); - } - }, []); - - return null; -} diff --git a/apps/web/components/search/home-search-suggestions-client.tsx b/apps/web/components/search/home-search-suggestions-client.tsx index a47868c5..db5fd5b8 100644 --- a/apps/web/components/search/home-search-suggestions-client.tsx +++ b/apps/web/components/search/home-search-suggestions-client.tsx @@ -3,12 +3,13 @@ import { IconX } from "@tabler/icons-react"; import { useSetAtom } from "jotai"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { Favicon } from "@/components/icons/favicon"; +import { useIsClient } from "@/hooks/use-is-client"; +import { useAnalytics } from "@/lib/analytics/client"; import { pendingDomainAtom } from "@/lib/atoms/search-atoms"; import { useSearchHistoryStore } from "@/lib/stores/search-history-store"; -import { useAnalytics } from "@domainstack/analytics/client"; import { MAX_HISTORY_ITEMS } from "@domainstack/constants"; import { Button } from "@domainstack/ui/button"; import { ScrollArea } from "@domainstack/ui/scroll-area"; @@ -31,11 +32,8 @@ export function HomeSearchSuggestionsClient({ const setPendingDomain = useSetAtom(pendingDomainAtom); const scrollContainerRef = useRef(null); - // Track hydration state for consistent rendering - const [isHistoryLoaded, setIsHistoryLoaded] = useState(false); - useEffect(() => { - setIsHistoryLoaded(true); - }, []); + // Wait until after hydration so persisted search history does not mismatch SSR. + const isHistoryLoaded = useIsClient(); const history = useSearchHistoryStore((s) => s.history); const clearHistory = useSearchHistoryStore((s) => s.clearHistory); diff --git a/apps/web/components/search/search-client.test.tsx b/apps/web/components/search/search-client.test.tsx index bbca7bbb..d0041228 100644 --- a/apps/web/components/search/search-client.test.tsx +++ b/apps/web/components/search/search-client.test.tsx @@ -56,7 +56,9 @@ vi.mock("next/navigation", () => ({ useParams: () => ({}), })); -vi.mock("sonner", () => ({ toast: { error: vi.fn<(message?: string) => void>() } })); +vi.mock("@domainstack/ui/toast", () => ({ + toast: { add: vi.fn<(options?: { title?: string; type?: string }) => void>() }, +})); describe("DomainSearch (form variant)", () => { beforeEach(() => { @@ -78,13 +80,11 @@ describe("DomainSearch (form variant)", () => { }); it("shows error toast for invalid domain", async () => { - const { toast } = (await import("sonner")) as unknown as { - toast: { error: (msg: string) => void }; - }; + const { toast } = await import("@domainstack/ui/toast"); render(); const input = screen.getByLabelText(/Search any domain/i); await userEvent.type(input, "not a domain{Enter}"); - expect(toast.error).toHaveBeenCalled(); + expect(toast.add).toHaveBeenCalled(); }); it("handles pending domain from store (suggestion click)", async () => { diff --git a/apps/web/components/search/search-client.tsx b/apps/web/components/search/search-client.tsx index f7a7887a..9b247640 100644 --- a/apps/web/components/search/search-client.tsx +++ b/apps/web/components/search/search-client.tsx @@ -1,16 +1,16 @@ "use client"; -import { IconArrowRight, IconCircleX, IconSearch } from "@tabler/icons-react"; +import { IconArrowRight, IconSearch } from "@tabler/icons-react"; import { useAtom } from "jotai"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import { toast } from "sonner"; +import { useIsClient } from "@/hooks/use-is-client"; import { useIsMobile } from "@/hooks/use-mobile"; import { useRouter } from "@/hooks/use-router"; +import { analytics } from "@/lib/analytics/client"; import { pendingDomainAtom } from "@/lib/atoms/search-atoms"; -import { analytics } from "@domainstack/analytics/client"; import { Field, FieldLabel } from "@domainstack/ui/field"; import { Form } from "@domainstack/ui/form"; import { @@ -21,6 +21,7 @@ import { } from "@domainstack/ui/input-group"; import { Kbd } from "@domainstack/ui/kbd"; import { Spinner } from "@domainstack/ui/spinner"; +import { toast } from "@domainstack/ui/toast"; import { cn } from "@domainstack/ui/utils"; import { isValidDomain, normalizeDomainInput } from "@domainstack/utils/domain/client"; @@ -63,19 +64,17 @@ export function SearchClient({ // Input state const [value, setValue] = useState(derivedInitial); + const [prevDerivedInitial, setPrevDerivedInitial] = useState(derivedInitial); const [loading, setLoading] = useState(false); - const [mounted, setMounted] = useState(false); + const mounted = useIsClient(); const [isFocused, setIsFocused] = useState(false); const inputRef = useRef(null); - // Sync value when route/initial changes - useEffect(() => { + if (derivedInitial !== prevDerivedInitial) { + setPrevDerivedInitial(derivedInitial); setValue(derivedInitial); setLoading(false); - }, [derivedInitial]); - - // Mount effect for hydration - useEffect(() => setMounted(true), []); + } // Keyboard shortcut (⌘/Ctrl+K) useHotkeys("mod+k", (e) => { @@ -107,9 +106,11 @@ export function SearchClient({ }); // Handle pending domain from suggestion clicks (variant="lg" only) + if (variant === "lg" && pendingDomain && value !== pendingDomain) { + setValue(pendingDomain); + } useEffect(() => { if (variant === "lg" && pendingDomain) { - setValue(pendingDomain); navigateRef.current(pendingDomain); setPendingDomain(null); } @@ -177,10 +178,7 @@ export function SearchClient({ if (!isValidDomain(normalized)) { analytics.track("search_invalid_input", { input: value }); - toast.error("Please enter a valid domain.", { - icon: , - position: "bottom-center", - }); + toast.add({ title: "Please enter a valid domain.", type: "error" }); inputRef.current?.focus(); return; } diff --git a/apps/web/components/settings/account/delete-account-dialog.tsx b/apps/web/components/settings/account/delete-account-dialog.tsx index 50644260..097f378d 100644 --- a/apps/web/components/settings/account/delete-account-dialog.tsx +++ b/apps/web/components/settings/account/delete-account-dialog.tsx @@ -1,7 +1,7 @@ import { IconAlertTriangle, IconCheck, IconHeartBroken } from "@tabler/icons-react"; import { useCallback, useReducer } from "react"; -import { useAnalytics } from "@domainstack/analytics/client"; +import { useAnalytics } from "@/lib/analytics/client"; import { deleteUser } from "@domainstack/auth/client"; import { AlertDialog, diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx index 70bcf890..bf65d27f 100644 --- a/apps/web/components/settings/settings-content.tsx +++ b/apps/web/components/settings/settings-content.tsx @@ -105,15 +105,13 @@ export function SettingsTabsRouter({ const activeTab = navigationMode === "page" ? pageTab : segmentTab; + // Look up after commit: a render-time getElementById sees the previous tree, + // so a same-commit mount (client navigation into the modal) would miss the + // target and stick with the inline fallback forever. const [tabsListPortalTarget, setTabsListPortalTarget] = useState(null); - - useEffect(() => { - if (!tabsListPortalId) { - setTabsListPortalTarget(null); - return; - } - - setTabsListPortalTarget(document.getElementById(tabsListPortalId)); + useLayoutEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- sync portal target from committed DOM + setTabsListPortalTarget(tabsListPortalId ? document.getElementById(tabsListPortalId) : null); }, [tabsListPortalId]); const scrollPanelsToTop = useCallback((_tab: SettingsTabValue) => { diff --git a/apps/web/components/settings/settings-error-boundary.tsx b/apps/web/components/settings/settings-error-boundary.tsx index 73fea7f1..3763ce49 100644 --- a/apps/web/components/settings/settings-error-boundary.tsx +++ b/apps/web/components/settings/settings-error-boundary.tsx @@ -5,7 +5,7 @@ import { useQueryErrorResetBoundary } from "@tanstack/react-query"; import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; import { CreateIssueButton } from "@/components/create-issue-button"; -import { analytics } from "@domainstack/analytics/client"; +import { analytics } from "@/lib/analytics/client"; import { Button } from "@domainstack/ui/button"; import { CardDescription, CardHeader, CardTitle } from "@domainstack/ui/card"; diff --git a/apps/web/components/ui/map.tsx b/apps/web/components/ui/map.tsx index bcba4099..e65bbf80 100644 --- a/apps/web/components/ui/map.tsx +++ b/apps/web/components/ui/map.tsx @@ -8,7 +8,19 @@ import { IconPlus, IconX, } from "@tabler/icons-react"; -import MapLibreGL, { type MarkerOptions, type PopupOptions } from "maplibre-gl"; +import { + GeoJSONSource, + Map as MapLibreMap, + Marker, + Popup, + getVersion, + getWorkerUrl, + setWorkerUrl, + type MapOptions, + type MarkerOptions, + type PopupOptions, + type StyleSpecification, +} from "maplibre-gl"; import { createContext, useCallback, @@ -20,17 +32,22 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { toast } from "sonner"; +import { useIsClient } from "@/hooks/use-is-client"; import { useTheme } from "@/hooks/use-theme"; -import { analytics } from "@domainstack/analytics/client"; +import { analytics } from "@/lib/analytics/client"; import { Spinner } from "@domainstack/ui/spinner"; +import { toast } from "@domainstack/ui/toast"; import { cn } from "@domainstack/ui/utils"; import "maplibre-gl/dist/maplibre-gl.css"; +if (typeof window !== "undefined" && !getWorkerUrl()) { + setWorkerUrl(`https://unpkg.com/maplibre-gl@${getVersion()}/dist/maplibre-gl-worker.mjs`); +} + type MapContextValue = { - map: MapLibreGL.Map | null; + map: MapLibreMap | null; isLoaded: boolean; }; @@ -49,7 +66,7 @@ const defaultStyles = { light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", }; -type MapStyleOption = string | MapLibreGL.StyleSpecification; +type MapStyleOption = string | StyleSpecification; type MapInstanceProps = { children?: React.ReactNode; @@ -59,7 +76,7 @@ type MapInstanceProps = { dark?: MapStyleOption; }; className?: string; -} & Omit; +} & Omit; const DefaultLoader = () => (
@@ -72,8 +89,8 @@ const DefaultLoader = () => ( function MapInstance({ children, styles, className, ...props }: MapInstanceProps) { const containerRef = useRef(null); - const mapRef = useRef(null); - const [isMounted, setIsMounted] = useState(false); + const mapRef = useRef(null); + const isMounted = useIsClient(); const [isLoaded, setIsLoaded] = useState(false); const [isStyleLoaded, setIsStyleLoaded] = useState(false); const { theme: resolvedTheme } = useTheme(); @@ -91,14 +108,10 @@ function MapInstance({ children, styles, className, ...props }: MapInstanceProps initialMapStyleRef.current = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; } - useEffect(() => { - setIsMounted(true); - }, []); - useEffect(() => { if (!isMounted || !containerRef.current) return; - const mapInstance = new MapLibreGL.Map({ + const mapInstance = new MapLibreMap({ container: containerRef.current, style: initialMapStyleRef.current ?? defaultStyles.light, renderWorldCopies: false, @@ -135,6 +148,7 @@ function MapInstance({ children, styles, className, ...props }: MapInstanceProps const isLoading = !isMounted || !isLoaded || !isStyleLoaded; const contextValue = useMemo( () => ({ + // oxlint-disable-next-line react/refs map: mapRef.current, isLoaded: isMounted && isLoaded && isStyleLoaded, }), @@ -153,9 +167,9 @@ function MapInstance({ children, styles, className, ...props }: MapInstanceProps } type MarkerContextValue = { - markerRef: React.RefObject; + markerRef: React.RefObject; markerElementRef: React.RefObject; - map: MapLibreGL.Map | null; + map: MapLibreMap | null; isReady: boolean; }; @@ -195,7 +209,7 @@ function MapMarker({ ...markerOptions }: MapMarkerProps) { const { map, isLoaded } = useMap(); - const markerRef = useRef(null); + const markerRef = useRef(null); const markerElementRef = useRef(null); const [isReady, setIsReady] = useState(false); const markerOptionsRef = useRef(markerOptions); @@ -211,6 +225,7 @@ function MapMarker({ onDrag, onDragEnd, }); + // oxlint-disable-next-line react/refs markerStateRef.current = { longitude, latitude, @@ -230,7 +245,7 @@ function MapMarker({ markerElementRef.current = container; const currentState = markerStateRef.current; - const marker = new MapLibreGL.Marker({ + const marker = new Marker({ ...initialMarkerOptionsRef.current, element: container, draggable: currentState.draggable, @@ -265,6 +280,7 @@ function MapMarker({ marker.on("drag", handleDrag); marker.on("dragend", handleDragEnd); + // oxlint-disable-next-line react/set-state-in-effect setIsReady(true); return () => { @@ -327,10 +343,12 @@ type MapMarkerContentProps = { function MapMarkerContent({ children, className }: MapMarkerContentProps) { const { markerElementRef, isReady } = useMarkerContext(); + // oxlint-disable-next-line react/refs if (!isReady || !markerElementRef.current) return null; return createPortal(
{children || }
, + // oxlint-disable-next-line react/refs markerElementRef.current, ); } @@ -358,7 +376,7 @@ function MapMarkerPopup({ }: MapMarkerPopupProps) { const { markerRef, isReady } = useMarkerContext(); const containerRef = useRef(null); - const popupRef = useRef(null); + const popupRef = useRef(null); const [mounted, setMounted] = useState(false); const popupOptionsRef = useRef(popupOptions); const initialPopupOptionsRef = useRef(popupOptions); @@ -369,7 +387,7 @@ function MapMarkerPopup({ const container = document.createElement("div"); containerRef.current = container; - const popup = new MapLibreGL.Popup({ + const popup = new Popup({ offset: 16, ...initialPopupOptionsRef.current, closeButton: false, @@ -405,6 +423,7 @@ function MapMarkerPopup({ const handleClose = () => popupRef.current?.remove(); + // oxlint-disable-next-line react/refs if (!mounted || !containerRef.current) return null; return createPortal( @@ -427,6 +446,7 @@ function MapMarkerPopup({ )} {children}
, + // oxlint-disable-next-line react/refs containerRef.current, ); } @@ -439,7 +459,7 @@ type MapMarkerTooltipProps = { function MapMarkerTooltip({ children, className, ...popupOptions }: MapMarkerTooltipProps) { const { markerRef, markerElementRef, map, isReady } = useMarkerContext(); const containerRef = useRef(null); - const popupRef = useRef(null); + const popupRef = useRef(null); const [mounted, setMounted] = useState(false); const popupOptionsRef = useRef(popupOptions); const initialPopupOptionsRef = useRef(popupOptions); @@ -450,7 +470,7 @@ function MapMarkerTooltip({ children, className, ...popupOptions }: MapMarkerToo const container = document.createElement("div"); containerRef.current = container; - const popup = new MapLibreGL.Popup({ + const popup = new Popup({ offset: 16, ...initialPopupOptionsRef.current, closeOnClick: true, @@ -497,6 +517,7 @@ function MapMarkerTooltip({ children, className, ...popupOptions }: MapMarkerToo popupOptionsRef.current = popupOptions; }, [popupOptions]); + // oxlint-disable-next-line react/refs if (!mounted || !containerRef.current) return null; return createPortal( @@ -508,6 +529,7 @@ function MapMarkerTooltip({ children, className, ...popupOptions }: MapMarkerToo > {children}
, + // oxlint-disable-next-line react/refs containerRef.current, ); } @@ -641,7 +663,7 @@ function MapControls({ 3: "Location request timed out. Please try again.", }; const message = messages[error.code] ?? "Unable to get your location"; - toast.error(message); + toast.add({ title: message, type: "error" }); // Track non-permission errors (permission denied is expected user behavior) if (error.code !== 1) { @@ -773,10 +795,11 @@ function MapPopup({ ...popupOptions }: MapPopupProps) { const { map } = useMap(); - const popupRef = useRef(null); + const popupRef = useRef(null); const popupOptionsRef = useRef(popupOptions); const initialPopupOptionsRef = useRef(popupOptions); const popupStateRef = useRef({ longitude, latitude, onClose }); + // oxlint-disable-next-line react/refs popupStateRef.current = { longitude, latitude, onClose }; const container = useMemo(() => document.createElement("div"), []); @@ -785,7 +808,7 @@ function MapPopup({ if (!map) return; const currentState = popupStateRef.current; - const popup = new MapLibreGL.Popup({ + const popup = new Popup({ offset: 16, ...initialPopupOptionsRef.current, closeButton: false, @@ -877,6 +900,7 @@ function MapRoute({ const sourceId = `route-source-${id}`; const layerId = `route-layer-${id}`; const paintRef = useRef({ color, width, opacity, dashArray }); + // oxlint-disable-next-line react/refs paintRef.current = { color, width, opacity, dashArray }; // Add source and layer on mount @@ -920,7 +944,7 @@ function MapRoute({ useEffect(() => { if (!isLoaded || !map || coordinates.length < 2) return; - const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + const source = map.getSource(sourceId) as GeoJSONSource; if (source) { source.setData({ type: "Feature", diff --git a/apps/web/components/ui/sonner.tsx b/apps/web/components/ui/sonner.tsx deleted file mode 100644 index 3bc7edbc..00000000 --- a/apps/web/components/ui/sonner.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { - IconAlertTriangle, - IconCircleCheck, - IconCircleX, - IconInfoCircle, -} from "@tabler/icons-react"; -import { Toaster as Sonner, type ToasterProps } from "sonner"; - -import { useTheme } from "@/hooks/use-theme"; -import { Spinner } from "@domainstack/ui/spinner"; - -const Toaster = ({ ...props }: ToasterProps) => { - const { theme } = useTheme(); - - return ( - , - info: , - warning: , - error: , - loading: , - }} - style={ - { - "--normal-bg": "var(--popover)", - "--normal-text": "var(--popover-foreground)", - "--normal-border": "var(--border)", - "--border-radius": "var(--radius)", - } as React.CSSProperties - } - {...props} - /> - ); -}; - -export { Toaster }; diff --git a/apps/web/context/dashboard-context.tsx b/apps/web/context/dashboard-context.tsx index 97f6e80b..36cd8d92 100644 --- a/apps/web/context/dashboard-context.tsx +++ b/apps/web/context/dashboard-context.tsx @@ -1,8 +1,8 @@ "use client"; -import type { Table } from "@tanstack/react-table"; import { createContext, useContext, useMemo } from "react"; +import type { DashboardTable } from "@/lib/dashboard-table-features"; import type { AvailableProvidersByCategory, DashboardPageSizeOptions, @@ -11,7 +11,6 @@ import type { StatusFilter, } from "@/lib/dashboard-utils"; import type { VerificationMethod } from "@domainstack/constants"; -import type { TrackedDomainWithDetails } from "@domainstack/types"; // Re-export types so consumers can import from context export type { SortOption } from "@/lib/dashboard-utils"; @@ -50,7 +49,7 @@ export interface FilterState { // Sort (grid view only) sortOption: SortOption; // Table instance (for column visibility) - table: Table | null; + table: DashboardTable | null; } export interface FilterActions { @@ -63,7 +62,7 @@ export interface FilterActions { applyHealthFilter: (filter: HealthFilter | "pending") => void; clearDomainId: () => void; setSortOption: (sort: SortOption) => void; - setTable: (table: Table | null) => void; + setTable: (table: DashboardTable | null) => void; } export interface PaginationState { @@ -116,8 +115,8 @@ interface DashboardProviderProps { sortOption: SortOption; setSortOption: (sort: SortOption) => void; /** Table instance (table view only) */ - table: Table | null; - setTable: (table: Table | null) => void; + table: DashboardTable | null; + setTable: (table: DashboardTable | null) => void; /** Pagination hook result - passed directly from useDashboardPagination */ paginationHook: { state: PaginationState; diff --git a/apps/web/hooks/use-auth-callback.ts b/apps/web/hooks/use-auth-callback.ts index 523fe398..57a1e77b 100644 --- a/apps/web/hooks/use-auth-callback.ts +++ b/apps/web/hooks/use-auth-callback.ts @@ -2,11 +2,11 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useRef } from "react"; -import { toast } from "sonner"; import { useRouter } from "@/hooks/use-router"; -import { analytics } from "@domainstack/analytics/client"; +import { analytics } from "@/lib/analytics/client"; import { getAuthErrorMessage, isAccountLinkingError } from "@domainstack/auth"; +import { toast } from "@domainstack/ui/toast"; /** * Hook to handle auth callback error query parameters. @@ -53,8 +53,10 @@ export function useAuthCallback() { // Title based on error type const title = isLinkError ? "Failed to link account" : "Sign in failed"; - toast.error(title, { + toast.add({ + title, description: errorMessage, + type: "error", }); // Clear error param from URL while preserving others diff --git a/apps/web/hooks/use-browser-ai.ts b/apps/web/hooks/use-browser-ai.ts index 71cd61c0..7beeef94 100644 --- a/apps/web/hooks/use-browser-ai.ts +++ b/apps/web/hooks/use-browser-ai.ts @@ -1,7 +1,45 @@ "use client"; -import { browserAI, doesBrowserSupportBrowserAI } from "@browser-ai/core"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { browserAI } from "@browser-ai/core"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; + +const NOOP_SUBSCRIBE = () => () => {}; +const AVAILABILITY_TIMEOUT_MS = 2_000; + +type BrowserAIModel = ReturnType; + +/** One Prompt API instance + availability probe per tab, shared across open/close. */ +let sharedModel: BrowserAIModel | null = null; +let sharedAvailability: Promise | null = null; + +function getLanguageModelGlobal(): { availability?: unknown } | undefined { + try { + return (globalThis as { LanguageModel?: { availability?: unknown } }).LanguageModel; + } catch { + return undefined; + } +} + +/** True only when the Prompt API is actually callable — not a bundler stub. */ +function browserSupportsBuiltInAI(): boolean { + return typeof getLanguageModelGlobal()?.availability === "function"; +} + +function getSharedModel(): BrowserAIModel { + sharedModel ??= browserAI(); + return sharedModel; +} + +function getSharedAvailability(): Promise { + sharedAvailability ??= getSharedModel() + .availability() + .catch((err: unknown) => { + // Allow a later open to retry after a failed probe + sharedAvailability = null; + throw err; + }); + return sharedAvailability; +} /** * Browser AI availability status. @@ -28,15 +66,22 @@ export interface UseBrowserAIResult { /** Error message if status is "error" */ error: string | null; /** The browser AI model instance (only available when status is "ready") */ - model: ReturnType | null; + model: BrowserAIModel | null; /** Manually trigger model download/initialization */ initialize: () => Promise; } +export interface UseBrowserAIOptions { + /** When false, skip the Prompt API availability probe. */ + enabled?: boolean; +} + /** * Hook to detect and manage browser AI availability. * - * Automatically checks browser support and model availability on mount. + * Browser support is detected synchronously. Model availability is probed once + * when the hook is enabled (by default, on mount) and reused for the rest of + * the tab. Closing the chat does not cancel or restart the probe. * When the model is downloadable, call `initialize()` to start the download. * * @example @@ -60,82 +105,106 @@ export interface UseBrowserAIResult { * } * ``` */ -export function useBrowserAI(): UseBrowserAIResult { - const [status, setStatus] = useState("checking"); +export function useBrowserAI({ enabled = true }: UseBrowserAIOptions = {}): UseBrowserAIResult { + // Synchronous client snapshot so unsupported browsers never sit on "checking" + // waiting for an effect. Server snapshot is false to avoid hydration mismatch. + const supported = useSyncExternalStore(NOOP_SUBSCRIBE, browserSupportsBuiltInAI, () => false); + const [modelStatus, setModelStatus] = useState("checking"); + const status: BrowserAIStatus = supported ? modelStatus : "unavailable"; const [downloadProgress, setDownloadProgress] = useState(0); const [error, setError] = useState(null); - const [model, setModel] = useState | null>(null); + const [model, setModel] = useState(null); // Track if we've already initialized to prevent double-init const initializingRef = useRef(false); - const modelInstanceRef = useRef | null>(null); + const modelInstanceRef = useRef(null); // Track mounted state to prevent state updates after unmount const isMountedRef = useRef(true); + // Start at most one waiter per hook instance; the underlying probe is shared + const probeStartedRef = useRef(false); - // Check browser support and model availability on mount useEffect(() => { isMountedRef.current = true; - - async function checkAvailability() { - // First check if browser supports the API at all - if (!doesBrowserSupportBrowserAI()) { - if (isMountedRef.current) setStatus("unavailable"); - return; - } - - try { - // Create the model instance to check availability - const instance = browserAI(); - modelInstanceRef.current = instance; - - const availability = await instance.availability(); - - if (!isMountedRef.current) return; - - switch (availability) { - case "unavailable": - setStatus("unavailable"); - break; - case "downloadable": - setStatus("downloadable"); - break; - case "downloading": - setStatus("downloading"); - break; - case "available": - setModel(instance); - setStatus("ready"); - break; - default: - // Handle any future states gracefully - setStatus("unavailable"); - } - } catch (err) { - if (!isMountedRef.current) return; - setError(err instanceof Error ? err.message : "Failed to check AI availability"); - setStatus("error"); - } - } - - void checkAvailability(); - return () => { isMountedRef.current = false; }; }, []); + // Probe on mount so local/auto is resolved before the panel opens. Gating on + // the open state started a cloud session first, then swapped to local. + useEffect(() => { + if (!enabled || !supported || probeStartedRef.current) return; + probeStartedRef.current = true; + + const instance = getSharedModel(); + modelInstanceRef.current = instance; + + const applyAvailability = (availability: string) => { + if (!isMountedRef.current) return; + + switch (availability) { + case "unavailable": + setModelStatus("unavailable"); + break; + case "downloadable": + setModelStatus("downloadable"); + break; + case "downloading": + setModelStatus("downloading"); + break; + case "available": + setModel(instance); + setModelStatus("ready"); + break; + default: + setModelStatus("unavailable"); + } + }; + + const applyProbeError = (err: unknown) => { + probeStartedRef.current = false; + if (!isMountedRef.current) return; + setError(err instanceof Error ? err.message : "Failed to check AI availability"); + setModelStatus("error"); + }; + + const probe = getSharedAvailability(); + + void (async () => { + try { + const result = await Promise.race([ + probe.then((availability) => ({ timedOut: false as const, availability })), + new Promise<{ timedOut: true }>((resolve) => { + setTimeout(() => resolve({ timedOut: true }), AVAILABILITY_TIMEOUT_MS); + }), + ]); + + if (result.timedOut) { + // Don't sit on "Checking…" if the Prompt API never answers. + if (isMountedRef.current) setModelStatus("unavailable"); + void probe.then(applyAvailability).catch(applyProbeError); + return; + } + + applyAvailability(result.availability); + } catch (err) { + applyProbeError(err); + } + })(); + }, [enabled, supported]); + // Initialize (download) the model const initialize = useCallback(async () => { if (initializingRef.current) return; if (status !== "downloadable" && status !== "error") return; initializingRef.current = true; - setStatus("downloading"); + setModelStatus("downloading"); setDownloadProgress(0); setError(null); try { - const instance = modelInstanceRef.current ?? browserAI(); + const instance = modelInstanceRef.current ?? getSharedModel(); modelInstanceRef.current = instance; // Create session with progress tracking @@ -145,12 +214,12 @@ export function useBrowserAI(): UseBrowserAIResult { if (isMountedRef.current) { setModel(instance); - setStatus("ready"); + setModelStatus("ready"); } } catch (err) { if (isMountedRef.current) { setError(err instanceof Error ? err.message : "Failed to initialize AI model"); - setStatus("error"); + setModelStatus("error"); } } finally { initializingRef.current = false; diff --git a/apps/web/hooks/use-calendar-feed.test.tsx b/apps/web/hooks/use-calendar-feed.test.tsx new file mode 100644 index 00000000..e9f2f465 --- /dev/null +++ b/apps/web/hooks/use-calendar-feed.test.tsx @@ -0,0 +1,186 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { toast } from "@domainstack/ui/toast"; + +vi.mock("@/lib/trpc/client", async () => { + const { useTRPC } = await import("@/mocks/trpc"); + return { useTRPC }; +}); +vi.mock("@domainstack/ui/toast", () => ({ + toast: { + add: vi.fn<(options?: { title?: string; description?: string; type?: string }) => void>(), + }, +})); + +import { createTestQueryClient, renderHook, waitFor } from "@/mocks/react"; +import { + CALENDAR_FEED_QUERY_KEY, + CALENDAR_FEED_ROTATED_URL, + CALENDAR_FEED_URL, + type CalendarFeedData, + deleteCalendarFeedMutation, + disableCalendarFeedMutation, + enableCalendarFeedMutation, + resetTrpcMocks, + rotateCalendarFeedTokenMutation, + setCalendarFeedState, +} from "@/mocks/trpc"; + +import { useCalendarFeed } from "./use-calendar-feed"; + +const enabledFeed: CalendarFeedData = { + enabled: true, + feedUrl: CALENDAR_FEED_URL, + lastAccessedAt: null, +}; + +function getFeed(queryClient: ReturnType) { + return queryClient.getQueryData(CALENDAR_FEED_QUERY_KEY); +} + +function renderCalendarFeed(feed: CalendarFeedData = { enabled: false }) { + const queryClient = createTestQueryClient(); + setCalendarFeedState(feed); + queryClient.setQueryData(CALENDAR_FEED_QUERY_KEY, feed); + + const view = renderHook(() => useCalendarFeed(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + return { ...view, queryClient }; +} + +describe("useCalendarFeed", () => { + beforeEach(() => { + resetTrpcMocks(); + vi.mocked(toast.add).mockClear(); + }); + + afterEach(() => { + resetTrpcMocks(); + }); + + it("enables the feed and writes the new URL into cache", async () => { + const { result, queryClient } = renderCalendarFeed(); + + result.current.enable(); + + await waitFor(() => { + expect(getFeed(queryClient)).toEqual({ + enabled: true, + feedUrl: CALENDAR_FEED_URL, + lastAccessedAt: null, + }); + }); + expect(result.current.isEnabled).toBe(true); + expect(toast.add).toHaveBeenCalledWith({ title: "Calendar feed enabled", type: "success" }); + expect(enableCalendarFeedMutation).toHaveBeenCalledOnce(); + }); + + it("toasts when enable fails", async () => { + enableCalendarFeedMutation.mockRejectedValueOnce(new Error("nope")); + const { result } = renderCalendarFeed(); + + result.current.enable(); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ + title: "Failed to enable calendar feed", + type: "error", + }); + }); + expect(result.current.isEnabled).toBe(false); + }); + + it("disables the feed optimistically", async () => { + const { result, queryClient } = renderCalendarFeed(enabledFeed); + + result.current.disable(); + + await waitFor(() => { + expect(getFeed(queryClient)?.enabled).toBe(false); + }); + expect(result.current.isEnabled).toBe(false); + expect(toast.add).toHaveBeenCalledWith({ title: "Calendar feed disabled", type: "success" }); + expect(disableCalendarFeedMutation).toHaveBeenCalledOnce(); + }); + + it("rolls back and toasts when disable fails", async () => { + disableCalendarFeedMutation.mockRejectedValueOnce(new Error("nope")); + const { result, queryClient } = renderCalendarFeed(enabledFeed); + + result.current.disable(); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ + title: "Failed to disable calendar feed", + type: "error", + }); + }); + expect(getFeed(queryClient)).toEqual(enabledFeed); + expect(result.current.isEnabled).toBe(true); + }); + + it("rotates the token and invalidates to the new URL", async () => { + const { result, queryClient } = renderCalendarFeed(enabledFeed); + + result.current.rotate.mutate(); + + await waitFor(() => { + expect(getFeed(queryClient)).toEqual({ + enabled: true, + feedUrl: CALENDAR_FEED_ROTATED_URL, + lastAccessedAt: null, + }); + }); + expect(toast.add).toHaveBeenCalledWith({ + title: "Calendar feed URL regenerated", + type: "success", + }); + expect(rotateCalendarFeedTokenMutation).toHaveBeenCalledOnce(); + }); + + it("toasts when rotate fails", async () => { + rotateCalendarFeedTokenMutation.mockRejectedValueOnce(new Error("nope")); + const { result, queryClient } = renderCalendarFeed(enabledFeed); + + result.current.rotate.mutate(); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ title: "Failed to regenerate URL", type: "error" }); + }); + expect(getFeed(queryClient)).toEqual(enabledFeed); + }); + + it("deletes the feed optimistically", async () => { + const { result, queryClient } = renderCalendarFeed(enabledFeed); + + result.current.deleteFeed.mutate(); + + await waitFor(() => { + expect(getFeed(queryClient)).toEqual({ enabled: false }); + }); + expect(result.current.isEnabled).toBe(false); + expect(toast.add).toHaveBeenCalledWith({ title: "Calendar feed disabled", type: "success" }); + expect(deleteCalendarFeedMutation).toHaveBeenCalledOnce(); + }); + + it("rolls back and toasts when delete fails", async () => { + deleteCalendarFeedMutation.mockRejectedValueOnce(new Error("nope")); + const { result, queryClient } = renderCalendarFeed(enabledFeed); + + result.current.deleteFeed.mutate(); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ + title: "Failed to disable calendar feed", + type: "error", + }); + }); + expect(getFeed(queryClient)).toEqual(enabledFeed); + expect(result.current.isEnabled).toBe(true); + }); +}); diff --git a/apps/web/hooks/use-calendar-feed.ts b/apps/web/hooks/use-calendar-feed.ts index cd63f665..f69fc8cc 100644 --- a/apps/web/hooks/use-calendar-feed.ts +++ b/apps/web/hooks/use-calendar-feed.ts @@ -1,9 +1,9 @@ "use client"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; +import { toast } from "@domainstack/ui/toast"; type CalendarFeedData = | { enabled: false } @@ -65,10 +65,10 @@ export function useCalendarFeed(): UseCalendarFeedReturn { feedUrl: data.feedUrl, lastAccessedAt: null, }); - toast.success("Calendar feed enabled"); + toast.add({ title: "Calendar feed enabled", type: "success" }); }, onError: () => { - toast.error("Failed to enable calendar feed"); + toast.add({ title: "Failed to enable calendar feed", type: "error" }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: feedQueryKey }); @@ -88,10 +88,10 @@ export function useCalendarFeed(): UseCalendarFeedReturn { if (context?.previous) { queryClient.setQueryData(feedQueryKey, context.previous); } - toast.error("Failed to disable calendar feed"); + toast.add({ title: "Failed to disable calendar feed", type: "error" }); }, onSuccess: () => { - toast.success("Calendar feed disabled"); + toast.add({ title: "Calendar feed disabled", type: "success" }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: feedQueryKey }); @@ -102,10 +102,10 @@ export function useCalendarFeed(): UseCalendarFeedReturn { const rotateMutation = useMutation({ ...trpc.user.rotateCalendarFeedToken.mutationOptions(), onSuccess: () => { - toast.success("Calendar feed URL regenerated"); + toast.add({ title: "Calendar feed URL regenerated", type: "success" }); }, onError: () => { - toast.error("Failed to regenerate URL"); + toast.add({ title: "Failed to regenerate URL", type: "error" }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: feedQueryKey }); @@ -125,10 +125,10 @@ export function useCalendarFeed(): UseCalendarFeedReturn { if (context?.previous) { queryClient.setQueryData(feedQueryKey, context.previous); } - toast.error("Failed to disable calendar feed"); + toast.add({ title: "Failed to disable calendar feed", type: "error" }); }, onSuccess: () => { - toast.success("Calendar feed disabled"); + toast.add({ title: "Calendar feed disabled", type: "success" }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: feedQueryKey }); diff --git a/apps/web/hooks/use-chat-persistence.ts b/apps/web/hooks/use-chat-persistence.ts index 390d586f..b4be6af5 100644 --- a/apps/web/hooks/use-chat-persistence.ts +++ b/apps/web/hooks/use-chat-persistence.ts @@ -1,86 +1,53 @@ -import type { ChatRequestOptions, UIMessage } from "ai"; +import type { ChatStatus, UIMessage } from "ai"; import { useEffect, useRef } from "react"; import { useChatHydrated, useChatStore } from "@/lib/stores/chat-store"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type ChatStatus = "submitted" | "streaming" | "ready" | "error"; - interface UseChatPersistenceOptions { - /** Current messages from useChat */ messages: UIMessage[]; - /** Current status from useChat */ status: ChatStatus; - /** Function to set messages in useChat (for restoration) */ - setMessages: ( - messages: UIMessage[] | ((messages: UIMessage[]) => UIMessage[]), - chatRequestOptions?: ChatRequestOptions, - ) => void; + setMessages: (messages: UIMessage[]) => void; } -// --------------------------------------------------------------------------- -// Hook -// --------------------------------------------------------------------------- - /** - * Manages chat message persistence between the useChat hook and Zustand store. - * - * Handles: - * - Restoring messages from store on mount (once, after hydration) - * - Persisting messages to store when they change - * - Clearing runId when chat transitions from streaming to ready - * - * Uses refs internally to avoid effect dependency issues with callbacks. + * Restores cloud chat messages from the Zustand store after hydration, + * persists subsequent changes, and clears the runId when a stream + * finishes or errors (backup for onChatEnd). */ export function useChatPersistence({ messages, status, setMessages, }: UseChatPersistenceOptions): void { - // Store access const hydrated = useChatHydrated(); const runId = useChatStore((s) => s.runId); const storedMessages = useChatStore((s) => s.messages); const setRunId = useChatStore((s) => s.setRunId); const storeSetMessages = useChatStore((s) => s.setMessages); - // Ref to access setMessages without it being a dependency const setMessagesRef = useRef(setMessages); - setMessagesRef.current = setMessages; - - // --------------------------------------------------------------------------- - // Restore messages from store once after hydration - // --------------------------------------------------------------------------- + useEffect(() => { + setMessagesRef.current = setMessages; + }); const hasRestored = useRef(false); useEffect(() => { - // Wait for store to hydrate from localStorage before restoring - if (!hydrated) return; - if (hasRestored.current) return; + if (!hydrated || hasRestored.current) return; hasRestored.current = true; if (storedMessages.length > 0) { setMessagesRef.current(storedMessages); } - // Clear any persisted runId - it's from a previous session and the workflow - // has likely completed. Trying to resume a completed workflow causes errors. - // In-session stream interruptions (network drops) still work since runId is in memory. - if (runId) { - setRunId(null); - } - }, [hydrated, storedMessages, runId, setRunId]); - - // --------------------------------------------------------------------------- - // Persist messages to store - // --------------------------------------------------------------------------- + }, [hydrated, storedMessages]); const isInitialized = useRef(false); useEffect(() => { if (!isInitialized.current) { if (messages.length > 0) { isInitialized.current = true; + // Transport can fail before onChatSendMessage writes the store. + if (status === "error") { + storeSetMessages(messages); + } } return; } @@ -89,25 +56,13 @@ export function useChatPersistence({ } }, [messages, status, storeSetMessages]); - // --------------------------------------------------------------------------- - // Clear runId when chat completes successfully - // --------------------------------------------------------------------------- - - // The WorkflowChatTransport's onChatEnd callback should clear runId when a finish chunk - // is received, but sometimes the finish chunk is not received (e.g., during tool execution - // when the workflow suspends). This effect handles that case by detecting when the chat - // transitions from streaming to ready with assistant messages, indicating completion. const prevStatusRef = useRef(status); useEffect(() => { const wasStreaming = prevStatusRef.current === "streaming"; - const isNowReady = status === "ready"; - const hasAssistantMessage = messages.some((m) => m.role === "assistant"); + prevStatusRef.current = status; - if (wasStreaming && isNowReady && hasAssistantMessage && runId) { - // Chat completed but onChatEnd wasn't called - clear runId to prevent stale reconnection attempts + if (wasStreaming && (status === "ready" || status === "error") && runId) { setRunId(null); } - - prevStatusRef.current = status; - }, [status, messages, runId, setRunId]); + }, [status, runId, setRunId]); } diff --git a/apps/web/hooks/use-dashboard-mutations.test.tsx b/apps/web/hooks/use-dashboard-mutations.test.tsx new file mode 100644 index 00000000..320a3155 --- /dev/null +++ b/apps/web/hooks/use-dashboard-mutations.test.tsx @@ -0,0 +1,280 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { toast } from "@domainstack/ui/toast"; + +vi.mock("@/lib/trpc/client", async () => { + const { useTRPC } = await import("@/mocks/trpc"); + return { useTRPC }; +}); +vi.mock("@domainstack/ui/toast", () => ({ + toast: { + add: vi.fn<(options?: { title?: string; description?: string; type?: string }) => void>(), + }, +})); + +import { + DASHBOARD_TEST_NOW, + makeDashboardDomains, + makeTrackedDomain, +} from "@/components/dashboard/test-fixtures"; +import { createTestQueryClient, renderHook, waitFor } from "@/mocks/react"; +import { + bulkArchiveDomainsMutation, + bulkRemoveDomainsMutation, + DOMAINS_QUERY_KEY, + removeDomainMutation, + resetTrpcMocks, + SUBSCRIPTION_QUERY_KEY, +} from "@/mocks/trpc"; +import type { TrackedDomainWithDetails } from "@domainstack/types"; + +import { useDashboardMutations } from "./use-dashboard-mutations"; + +type SubscriptionCache = { + plan: "free" | "pro"; + planQuota: number; + endsAt: Date | null; + activeCount: number; + archivedCount: number; + canAddMore: boolean; +}; + +const archivedDomain = makeTrackedDomain({ + id: "domain-archived", + domainName: "archived.com", + archivedAt: DASHBOARD_TEST_NOW, +}); + +function defaultSubscription(overrides: Partial = {}): SubscriptionCache { + return { + plan: "pro", + planQuota: 100, + endsAt: null, + activeCount: 4, + archivedCount: 1, + canAddMore: true, + ...overrides, + }; +} + +function getDomains(queryClient: ReturnType) { + return queryClient.getQueryData(DOMAINS_QUERY_KEY) ?? []; +} + +function getSubscription(queryClient: ReturnType) { + return queryClient.getQueryData(SUBSCRIPTION_QUERY_KEY); +} + +function renderDashboardMutations(options?: { + domains?: TrackedDomainWithDetails[]; + subscription?: SubscriptionCache; +}) { + const queryClient = createTestQueryClient(); + const domains = options?.domains ?? [...makeDashboardDomains(), archivedDomain]; + const subscription = options?.subscription ?? defaultSubscription(); + + queryClient.setQueryData(DOMAINS_QUERY_KEY, domains); + queryClient.setQueryData(SUBSCRIPTION_QUERY_KEY, subscription); + + const view = renderHook(() => useDashboardMutations(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + return { ...view, queryClient, domains }; +} + +describe("useDashboardMutations", () => { + beforeEach(() => { + resetTrpcMocks(); + vi.mocked(toast.add).mockClear(); + }); + + afterEach(() => { + resetTrpcMocks(); + }); + + it("removes a domain and decrements active count", async () => { + const { result, queryClient } = renderDashboardMutations({ + subscription: defaultSubscription({ planQuota: 4, canAddMore: false }), + }); + + result.current.remove("domain-alpha"); + + await waitFor(() => { + expect(getDomains(queryClient).map((d) => d.id)).not.toContain("domain-alpha"); + }); + expect(getSubscription(queryClient)).toMatchObject({ + activeCount: 3, + archivedCount: 1, + canAddMore: true, + }); + expect(toast.add).toHaveBeenCalledWith({ title: "Domain removed", type: "success" }); + expect(removeDomainMutation.mock.calls[0]?.[0]).toEqual({ trackedDomainId: "domain-alpha" }); + }); + + it("archives a domain and moves it from active to archived counts", async () => { + const { result, queryClient } = renderDashboardMutations(); + + result.current.archive("domain-alpha"); + + await waitFor(() => { + expect( + getDomains(queryClient).find((d) => d.id === "domain-alpha")?.archivedAt, + ).toBeInstanceOf(Date); + }); + expect(getSubscription(queryClient)).toMatchObject({ + activeCount: 3, + archivedCount: 2, + canAddMore: true, + }); + expect(toast.add).toHaveBeenCalledWith({ title: "Domain archived", type: "success" }); + }); + + it("unarchives a domain and reverses the counts", async () => { + const { result, queryClient } = renderDashboardMutations(); + + result.current.unarchive("domain-archived"); + + await waitFor(() => { + expect( + getDomains(queryClient).find((d) => d.id === "domain-archived")?.archivedAt, + ).toBeNull(); + }); + expect(getSubscription(queryClient)).toMatchObject({ + activeCount: 5, + archivedCount: 0, + canAddMore: true, + }); + expect(toast.add).toHaveBeenCalledWith({ title: "Domain reactivated", type: "success" }); + }); + + it("mutes a domain without touching subscription cache", async () => { + const { result, queryClient } = renderDashboardMutations(); + const subscriptionBefore = getSubscription(queryClient); + + result.current.setMuted("domain-alpha", true); + + await waitFor(() => { + expect(getDomains(queryClient).find((d) => d.id === "domain-alpha")?.muted).toBe(true); + }); + expect(getSubscription(queryClient)).toEqual(subscriptionBefore); + expect(toast.add).toHaveBeenCalledWith({ title: "Domain muted", type: "success" }); + + result.current.setMuted("domain-alpha", false); + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ title: "Domain unmuted", type: "success" }); + }); + }); + + it("bulk-archives only non-archived ids when counting subscription changes", async () => { + const { result, queryClient } = renderDashboardMutations(); + + await result.current.bulkArchive(["domain-alpha", "domain-archived"]); + + const domains = getDomains(queryClient); + expect(domains.find((d) => d.id === "domain-alpha")?.archivedAt).toBeInstanceOf(Date); + expect(domains.find((d) => d.id === "domain-archived")?.archivedAt).toEqual(DASHBOARD_TEST_NOW); + expect(getSubscription(queryClient)).toMatchObject({ + activeCount: 3, + archivedCount: 2, + }); + expect(bulkArchiveDomainsMutation.mock.calls[0]?.[0]).toEqual({ + trackedDomainIds: ["domain-alpha", "domain-archived"], + }); + expect(toast.add).toHaveBeenCalledWith({ title: "Archived 2 domains", type: "success" }); + }); + + it("toasts requested count when some ids were already archived", async () => { + bulkArchiveDomainsMutation.mockResolvedValueOnce({ successCount: 1, failedCount: 0 }); + const { result } = renderDashboardMutations(); + + await result.current.bulkArchive(["domain-alpha", "domain-archived"]); + + expect(toast.add).toHaveBeenCalledWith({ title: "Archived 2 domains", type: "success" }); + }); + + it("toasts a warning when bulk archive only partially succeeds", async () => { + bulkArchiveDomainsMutation.mockResolvedValueOnce({ successCount: 1, failedCount: 1 }); + const { result } = renderDashboardMutations(); + + await result.current.bulkArchive(["domain-alpha", "domain-beta"]); + + expect(toast.add).toHaveBeenCalledWith({ + title: "Archived 1 of 2 domains (1 failed)", + type: "warning", + }); + expect(toast.add).not.toHaveBeenCalledWith(expect.objectContaining({ type: "success" })); + }); + + it("toasts a singular success when one domain is archived", async () => { + const { result } = renderDashboardMutations(); + + await result.current.bulkArchive(["domain-alpha"]); + + expect(toast.add).toHaveBeenCalledWith({ title: "Archived 1 domain", type: "success" }); + }); + + it("bulk-deletes ids and decrements active count only for non-archived domains", async () => { + const { result, queryClient } = renderDashboardMutations(); + + await result.current.bulkDelete(["domain-alpha", "domain-archived"]); + + const ids = getDomains(queryClient).map((d) => d.id); + expect(ids).not.toContain("domain-alpha"); + expect(ids).not.toContain("domain-archived"); + expect(getSubscription(queryClient)).toMatchObject({ + activeCount: 3, + archivedCount: 1, + }); + expect(bulkRemoveDomainsMutation.mock.calls[0]?.[0]).toEqual({ + trackedDomainIds: ["domain-alpha", "domain-archived"], + }); + expect(toast.add).toHaveBeenCalledWith({ title: "Deleted 2 domains", type: "success" }); + }); + + it("toasts a warning when bulk delete only partially succeeds", async () => { + bulkRemoveDomainsMutation.mockResolvedValueOnce({ successCount: 1, failedCount: 1 }); + const { result } = renderDashboardMutations(); + + await result.current.bulkDelete(["domain-alpha", "domain-beta"]); + + expect(toast.add).toHaveBeenCalledWith({ + title: "Deleted 1 of 2 domains (1 failed)", + type: "warning", + }); + expect(toast.add).not.toHaveBeenCalledWith(expect.objectContaining({ type: "success" })); + }); + + it("rolls back domains and subscription when remove fails", async () => { + removeDomainMutation.mockRejectedValueOnce(new Error("nope")); + const { result, queryClient } = renderDashboardMutations(); + const domainsBefore = getDomains(queryClient); + const subscriptionBefore = getSubscription(queryClient); + + result.current.remove("domain-alpha"); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ title: "Failed to remove domain", type: "error" }); + }); + expect(getDomains(queryClient)).toEqual(domainsBefore); + expect(getSubscription(queryClient)).toEqual(subscriptionBefore); + }); + + it("rolls back and toasts when bulk archive fails", async () => { + bulkArchiveDomainsMutation.mockRejectedValueOnce(new Error("nope")); + const { result, queryClient } = renderDashboardMutations(); + const domainsBefore = getDomains(queryClient); + const subscriptionBefore = getSubscription(queryClient); + + await expect(result.current.bulkArchive(["domain-alpha"])).rejects.toThrow("nope"); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ title: "Failed to archive domains", type: "error" }); + }); + expect(getDomains(queryClient)).toEqual(domainsBefore); + expect(getSubscription(queryClient)).toEqual(subscriptionBefore); + }); +}); diff --git a/apps/web/hooks/use-dashboard-mutations.ts b/apps/web/hooks/use-dashboard-mutations.ts index 17b4bb84..154f55f4 100644 --- a/apps/web/hooks/use-dashboard-mutations.ts +++ b/apps/web/hooks/use-dashboard-mutations.ts @@ -1,9 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; -import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; import type { TrackedDomainWithDetails } from "@domainstack/types"; +import { toast } from "@domainstack/ui/toast"; // --------------------------------------------------------------------------- // Types @@ -30,6 +30,25 @@ interface MutationContext { previousSubscription: SubscriptionData | undefined; } +function toastBulkResult( + verb: "Archived" | "Deleted", + result: BulkMutationResult, + requestedCount: number, +) { + if (result.failedCount === 0) { + toast.add({ + title: `${verb} ${requestedCount} domain${requestedCount === 1 ? "" : "s"}`, + type: "success", + }); + return; + } + + toast.add({ + title: `${verb} ${result.successCount} of ${requestedCount} domains (${result.failedCount} failed)`, + type: "warning", + }); +} + interface UseDashboardMutationsReturn { // Single-item mutations remove: (trackedDomainId: string) => void; @@ -122,9 +141,9 @@ export function useDashboardMutations(): UseDashboardMutationsReturn { if (context?.previousSubscription) { queryClient.setQueryData(subscriptionQueryKey, context.previousSubscription); } - toast.error("Failed to remove domain"); + toast.add({ title: "Failed to remove domain", type: "error" }); }, - onSuccess: () => toast.success("Domain removed"), + onSuccess: () => toast.add({ title: "Domain removed", type: "success" }), onSettled: invalidateDomainQueries, }); @@ -169,9 +188,9 @@ export function useDashboardMutations(): UseDashboardMutationsReturn { if (context?.previousSubscription) { queryClient.setQueryData(subscriptionQueryKey, context.previousSubscription); } - toast.error("Failed to archive domain"); + toast.add({ title: "Failed to archive domain", type: "error" }); }, - onSuccess: () => toast.success("Domain archived"), + onSuccess: () => toast.add({ title: "Domain archived", type: "success" }), onSettled: invalidateDomainQueries, }); @@ -216,9 +235,12 @@ export function useDashboardMutations(): UseDashboardMutationsReturn { if (context?.previousSubscription) { queryClient.setQueryData(subscriptionQueryKey, context.previousSubscription); } - toast.error(err instanceof Error ? err.message : "Failed to reactivate domain"); + toast.add({ + title: err instanceof Error ? err.message : "Failed to reactivate domain", + type: "error", + }); }, - onSuccess: () => toast.success("Domain reactivated"), + onSuccess: () => toast.add({ title: "Domain reactivated", type: "success" }), onSettled: invalidateDomainQueries, }); @@ -245,9 +267,10 @@ export function useDashboardMutations(): UseDashboardMutationsReturn { if (context?.previousDomains) { rollbackDomains(context.previousDomains); } - toast.error("Failed to update notification settings"); + toast.add({ title: "Failed to update notification settings", type: "error" }); }, - onSuccess: (_data, { muted }) => toast.success(muted ? "Domain muted" : "Domain unmuted"), + onSuccess: (_data, { muted }) => + toast.add({ title: muted ? "Domain muted" : "Domain unmuted", type: "success" }), onSettled: () => void queryClient.invalidateQueries({ queryKey: domainsQueryKey }), }); @@ -276,7 +299,7 @@ export function useDashboardMutations(): UseDashboardMutationsReturn { } queryClient.setQueriesData({ queryKey: domainsQueryKey }, (old: DomainsData) => - old?.map((d) => (idsSet.has(d.id) ? { ...d, archivedAt: new Date() } : d)), + old?.map((d) => (idsSet.has(d.id) && !d.archivedAt ? { ...d, archivedAt: new Date() } : d)), ); queryClient.setQueryData(subscriptionQueryKey, (old) => { if (!old) return old; @@ -301,7 +324,7 @@ export function useDashboardMutations(): UseDashboardMutationsReturn { if (context?.previousSubscription) { queryClient.setQueryData(subscriptionQueryKey, context.previousSubscription); } - toast.error("Failed to archive domains"); + toast.add({ title: "Failed to archive domains", type: "error" }); }, onSettled: invalidateDomainQueries, }); @@ -355,7 +378,7 @@ export function useDashboardMutations(): UseDashboardMutationsReturn { if (context?.previousSubscription) { queryClient.setQueryData(subscriptionQueryKey, context.previousSubscription); } - toast.error("Failed to delete domains"); + toast.add({ title: "Failed to delete domains", type: "error" }); }, onSettled: invalidateDomainQueries, }); @@ -393,14 +416,20 @@ export function useDashboardMutations(): UseDashboardMutationsReturn { ); const bulkArchive = useCallback( - async (trackedDomainIds: string[]): Promise => - bulkArchiveMutation.mutateAsync({ trackedDomainIds }), + async (trackedDomainIds: string[]): Promise => { + const result = await bulkArchiveMutation.mutateAsync({ trackedDomainIds }); + toastBulkResult("Archived", result, trackedDomainIds.length); + return result; + }, [bulkArchiveMutation], ); const bulkDelete = useCallback( - async (trackedDomainIds: string[]): Promise => - bulkDeleteMutation.mutateAsync({ trackedDomainIds }), + async (trackedDomainIds: string[]): Promise => { + const result = await bulkDeleteMutation.mutateAsync({ trackedDomainIds }); + toastBulkResult("Deleted", result, trackedDomainIds.length); + return result; + }, [bulkDeleteMutation], ); diff --git a/apps/web/hooks/use-dashboard-pagination.ts b/apps/web/hooks/use-dashboard-pagination.ts index ce943080..1338bdbf 100644 --- a/apps/web/hooks/use-dashboard-pagination.ts +++ b/apps/web/hooks/use-dashboard-pagination.ts @@ -1,7 +1,7 @@ import { parseAsInteger, useQueryState } from "nuqs"; -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; -import type { DashboardPageSizeOptions } from "@/lib/dashboard-utils"; +import { type DashboardPageSizeOptions, isPagePastEnd } from "@/lib/dashboard-utils"; import { usePreferencesStore } from "@/lib/stores/preferences-store"; // --------------------------------------------------------------------------- @@ -93,3 +93,76 @@ export function useDashboardPagination(): UseDashboardPaginationReturn { }, }; } + +export type DashboardFilterSignatureInput = { + search: string; + status: readonly string[]; + health: readonly string[]; + tlds: readonly string[]; + providers: readonly string[]; + domainId: string | null; +}; + +/** Stable key for filter URL state so pagination can reset after the user changes filters. */ +export function getDashboardFilterSignature(filters: DashboardFilterSignatureInput): string { + return JSON.stringify([ + filters.search, + filters.status, + filters.health, + filters.tlds, + filters.providers, + filters.domainId, + ]); +} + +type SyncDashboardPageOptions = { + itemCount: number; + pageIndex: number; + pageSize: number; + filterSignature: string; + resetPage: () => void; + /** When false, skip clamping (e.g. while listDomains is still loading). */ + enabled?: boolean; +}; + +/** + * Keep the page in sync with filters: + * - After the user changes filters, return to page 1 (skip the first paint so deep links stay). + * - If the current page is past the last result, clamp to page 1. + */ +export function useSyncDashboardPage({ + itemCount, + pageIndex, + pageSize, + filterSignature, + resetPage, + enabled = true, +}: SyncDashboardPageOptions): void { + const previousSignature = useRef(null); + + useEffect(() => { + if (!enabled) { + return; + } + if (previousSignature.current === null) { + previousSignature.current = filterSignature; + return; + } + if (previousSignature.current === filterSignature) { + return; + } + previousSignature.current = filterSignature; + if (pageIndex > 0) { + resetPage(); + } + }, [enabled, filterSignature, pageIndex, resetPage]); + + useEffect(() => { + if (!enabled) { + return; + } + if (isPagePastEnd(itemCount, pageIndex, pageSize)) { + resetPage(); + } + }, [enabled, itemCount, pageIndex, pageSize, resetPage]); +} diff --git a/apps/web/hooks/use-dashboard-selection.ts b/apps/web/hooks/use-dashboard-selection.ts index 2ccb337b..24a55ba7 100644 --- a/apps/web/hooks/use-dashboard-selection.ts +++ b/apps/web/hooks/use-dashboard-selection.ts @@ -1,5 +1,5 @@ -import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { useCallback, useEffect, useRef } from "react"; +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo } from "react"; import { hasSelectionAtom, @@ -36,6 +36,41 @@ export type UseDashboardSelectionReturn = DashboardSelectionState & DashboardSel // Hook // --------------------------------------------------------------------------- +/** + * Whether a single domain is selected. Subscribe in the cell/row that renders + * the checkbox or selection highlight so the table owner does not have to. + */ +export function useIsDomainSelected(id: string): boolean { + // Per-hook derived atom so unused IDs are GC'd with the row. atomFamily + // would retain one atom per ID for the lifetime of the module. + const isSelectedAtom = useMemo(() => atom((get) => get(selectedDomainIdsAtom).has(id)), [id]); + return useAtomValue(isSelectedAtom); +} + +/** + * Toggle selection without subscribing to the selected-id set. Safe to call + * from memoized table cells; does not re-render the component on other + * selection changes. + */ +export function useToggleDomainSelection(): (id: string) => void { + const setSelectedIds = useSetAtom(selectedDomainIdsAtom); + + return useCallback( + (id: string) => { + setSelectedIds((prev: Set) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, + [setSelectedIds], + ); +} + /** * Manages dashboard domain selection state via Jotai atoms. * @@ -52,6 +87,7 @@ export function useDashboardSelection(): UseDashboardSelectionReturn { const hasSelection = useAtomValue(hasSelectionAtom); const isAllSelected = useAtomValue(isAllSelectedAtom); const isPartiallySelected = useAtomValue(isPartiallySelectedAtom); + const toggle = useToggleDomainSelection(); // --------------------------------------------------------------------------- // Clear stale selections when visible domain IDs change @@ -74,42 +110,24 @@ export function useDashboardSelection(): UseDashboardSelectionReturn { // Escape key clears selection // --------------------------------------------------------------------------- - const selectedCountRef = useRef(selectedIds.size); - selectedCountRef.current = selectedIds.size; - const clearSelection = useCallback(() => { setSelectedIds(new Set()); }, [setSelectedIds]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && selectedCountRef.current > 0) { + if (e.key === "Escape" && selectedCount > 0) { clearSelection(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [clearSelection]); + }, [selectedCount, clearSelection]); // --------------------------------------------------------------------------- // Selection Actions // --------------------------------------------------------------------------- - const toggle = useCallback( - (id: string) => { - setSelectedIds((prev: Set) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, - [setSelectedIds], - ); - const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]); const selectAll = useCallback(() => { diff --git a/apps/web/hooks/use-domain-verification.ts b/apps/web/hooks/use-domain-verification.ts index 35da6956..a05a707d 100644 --- a/apps/web/hooks/use-domain-verification.ts +++ b/apps/web/hooks/use-domain-verification.ts @@ -1,12 +1,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; -import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; import { createInitialState, verificationReducer } from "@/lib/verification-state"; -import { analytics } from "@domainstack/analytics/client"; import type { VerificationMethod } from "@domainstack/constants"; import type { ResumeDomainData } from "@domainstack/types"; +import { toast } from "@domainstack/ui/toast"; import { isValidDomain, normalizeDomainInput } from "@domainstack/utils/domain/client"; // ============================================================================ @@ -47,7 +46,9 @@ export function useDomainVerification({ // Store callback in ref to avoid re-creating handleVerify when onSuccess changes const onSuccessRef = useRef(onSuccess); - onSuccessRef.current = onSuccess; + useEffect(() => { + onSuccessRef.current = onSuccess; + }); // ============================================================================ // Mutations & Queries @@ -203,9 +204,11 @@ export function useDomainVerification({ }); if (result.resumed) { - toast.info("Resuming verification", { + toast.add({ + title: "Resuming verification", description: "You previously started tracking this domain. Your verification token is unchanged.", + type: "info", }); } } catch (err) { @@ -228,34 +231,25 @@ export function useDomainVerification({ if (result.verified) { dispatch({ type: "VERIFICATION_SUCCEEDED", method: result.method }); - toast.success("Domain verified successfully!"); - analytics.track("domain_verification_succeeded", { - domain: state.domain, - method: result.method, - }); + toast.add({ title: "Domain verified successfully!", type: "success" }); onSuccessRef.current(); } else { dispatch({ type: "VERIFICATION_FAILED" }); - analytics.track("domain_verification_failed", { - domain: state.domain, - }); } } catch { dispatch({ type: "VERIFICATION_FAILED", error: "Something went wrong. Please try again.", }); - analytics.track("domain_verification_failed", { - domain: state.domain, - error: "exception", - }); } }, [state, verifyDomainMutation]); const handleReturnLater = useCallback(() => { - toast.info("Domain saved", { + toast.add({ + title: "Domain saved", description: "We'll automatically verify your domain once the changes have propagated. Check back later!", + type: "info", }); handleOpenChange(false); }, [handleOpenChange]); diff --git a/apps/web/hooks/use-hydrated-now.test.tsx b/apps/web/hooks/use-hydrated-now.test.tsx new file mode 100644 index 00000000..befbb9eb --- /dev/null +++ b/apps/web/hooks/use-hydrated-now.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from "vitest"; + +import { renderHook } from "@/mocks/react"; + +describe("useHydratedNow", () => { + it("keeps a pre-init resetHydratedNow(null) after the initializer microtask", async () => { + vi.resetModules(); + const { resetHydratedNow, useHydratedNow } = await import("./use-hydrated-now"); + + resetHydratedNow(null); + await Promise.resolve(); + + const { result } = renderHook(() => useHydratedNow()); + expect(result.current).toBeNull(); + }); +}); diff --git a/apps/web/hooks/use-hydrated-now.ts b/apps/web/hooks/use-hydrated-now.ts index 6edc1b93..821e0fd6 100644 --- a/apps/web/hooks/use-hydrated-now.ts +++ b/apps/web/hooks/use-hydrated-now.ts @@ -9,6 +9,7 @@ import { useSyncExternalStore } from "react"; */ let hydratedNow: Date | null = null; const listeners = new Set<() => void>(); +let cancelPendingInitializer: (() => void) | null = null; function subscribe(callback: () => void): () => void { listeners.add(callback); @@ -27,14 +28,20 @@ function getServerSnapshot(): Date | null { // Initialize on first client-side access if (typeof window !== "undefined" && hydratedNow === null) { + let cancelled = false; + cancelPendingInitializer = () => { + cancelled = true; + }; // Use microtask to ensure this runs after initial render queueMicrotask(() => { - if (hydratedNow === null) { - hydratedNow = new Date(); - // Notify all subscribers - for (const listener of listeners) { - listener(); - } + cancelPendingInitializer = null; + if (cancelled || hydratedNow !== null) { + return; + } + hydratedNow = new Date(); + // Notify all subscribers + for (const listener of listeners) { + listener(); } }); } @@ -52,3 +59,13 @@ if (typeof window !== "undefined" && hydratedNow === null) { export function useHydratedNow(): Date | null { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } + +/** Test-only: pin or clear the shared clock so suites do not leak wall time. */ +export function resetHydratedNow(date: Date | null = new Date()): void { + cancelPendingInitializer?.(); + cancelPendingInitializer = null; + hydratedNow = date ? new Date(date.getTime()) : null; + for (const listener of listeners) { + listener(); + } +} diff --git a/apps/web/hooks/use-is-client.ts b/apps/web/hooks/use-is-client.ts new file mode 100644 index 00000000..b514880c --- /dev/null +++ b/apps/web/hooks/use-is-client.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +const subscribe = () => () => {}; +const getSnapshot = () => true; +const getServerSnapshot = () => false; + +/** + * True after hydration. Uses `useSyncExternalStore` so the client/server + * snapshot switch is a store update, not `setState` inside an effect. + */ +export function useIsClient(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/apps/web/hooks/use-linked-accounts.ts b/apps/web/hooks/use-linked-accounts.ts index 3c2fe39a..bfc775fd 100644 --- a/apps/web/hooks/use-linked-accounts.ts +++ b/apps/web/hooks/use-linked-accounts.ts @@ -1,12 +1,12 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; +import { analytics } from "@/lib/analytics/client"; import { getEnabledProviders, type OAuthProvider } from "@/lib/oauth"; import { useTRPC } from "@/lib/trpc/client"; -import { analytics } from "@domainstack/analytics/client"; import { linkSocial, unlinkAccount } from "@domainstack/auth/client"; +import { toast } from "@domainstack/ui/toast"; export interface UseLinkedAccountsReturn { /** List of linked accounts */ @@ -83,11 +83,14 @@ export function useLinkedAccounts(): UseLinkedAccountsReturn { provider: providerId, action: "unlink_account", }); - toast.error("Failed to unlink account. Please try again."); + toast.add({ title: "Failed to unlink account. Please try again.", type: "error" }); }, onSuccess: (_data, providerId) => { const provider = enabledProviders.find((p) => p.id === providerId); - toast.success(`${provider?.name ?? "Account"} unlinked successfully`); + toast.add({ + title: `${provider?.name ?? "Account"} unlinked successfully`, + type: "success", + }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: linkedAccountsQueryKey }); @@ -107,7 +110,7 @@ export function useLinkedAccounts(): UseLinkedAccountsReturn { provider: provider.id, action: "link_account", }); - toast.error(`Failed to link ${provider.name}. Please try again.`); + toast.add({ title: `Failed to link ${provider.name}. Please try again.`, type: "error" }); throw err; // Re-throw so caller can handle loading state } }; diff --git a/apps/web/hooks/use-local-chat.ts b/apps/web/hooks/use-local-chat.ts index 16d60ef1..4ab74364 100644 --- a/apps/web/hooks/use-local-chat.ts +++ b/apps/web/hooks/use-local-chat.ts @@ -5,7 +5,7 @@ import { convertToModelMessages, generateId, readUIMessageStream, - stepCountIs, + isStepCount, streamText, type ToolSet, type UIMessage, @@ -124,14 +124,14 @@ export function useLocalChat({ const modelMessages = await convertToModelMessages(updatedMessages); // Run the model with tool calling - // stopWhen: stepCountIs(3) enables multi-step tool execution - without it, + // stopWhen: isStepCount(3) enables multi-step tool execution - without it, // the model stops after generating a tool call without executing it const result = streamText({ model, - system: systemPrompt, + instructions: systemPrompt, messages: modelMessages, tools, - stopWhen: stepCountIs(3), + stopWhen: isStepCount(3), abortSignal: abortControllerRef.current.signal, }); diff --git a/apps/web/hooks/use-notification-preferences.ts b/apps/web/hooks/use-notification-preferences.ts index 8938724b..b6f92b5f 100644 --- a/apps/web/hooks/use-notification-preferences.ts +++ b/apps/web/hooks/use-notification-preferences.ts @@ -1,11 +1,11 @@ "use client"; import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; import type { NotificationCategory } from "@domainstack/constants"; import type { TrackedDomainWithDetails, UserNotificationPreferences } from "@domainstack/types"; +import { toast } from "@domainstack/ui/toast"; const DEFAULT_PREFERENCES: UserNotificationPreferences = { domainExpiry: { inApp: true, email: true }, @@ -78,10 +78,10 @@ export function useNotificationPreferences(): UseNotificationPreferencesReturn { context.previousPrefs, ); } - toast.error("Failed to update settings"); + toast.add({ title: "Failed to update settings", type: "error" }); }, onSuccess: () => { - toast.success("Global settings updated"); + toast.add({ title: "Global settings updated", type: "success" }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: globalPrefsQueryKey }); @@ -111,10 +111,10 @@ export function useNotificationPreferences(): UseNotificationPreferencesReturn { queryClient.setQueryData(key, data); } } - toast.error("Failed to update settings"); + toast.add({ title: "Failed to update settings", type: "error" }); }, onSuccess: (_data, variables) => { - toast.success(variables.muted ? "Domain muted" : "Domain unmuted"); + toast.add({ title: variables.muted ? "Domain muted" : "Domain unmuted", type: "success" }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: domainsQueryKey }); diff --git a/apps/web/hooks/use-notifications-data.test.tsx b/apps/web/hooks/use-notifications-data.test.tsx new file mode 100644 index 00000000..5d147dae --- /dev/null +++ b/apps/web/hooks/use-notifications-data.test.tsx @@ -0,0 +1,204 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { toast } from "@domainstack/ui/toast"; + +vi.mock("@/lib/trpc/client", async () => { + const { useTRPC } = await import("@/mocks/trpc"); + return { useTRPC }; +}); +vi.mock("@domainstack/ui/toast", () => ({ + toast: { + add: vi.fn<(options?: { title?: string; description?: string; type?: string }) => void>(), + }, +})); + +import { + makeNotification, + makeNotificationsInfiniteData, +} from "@/components/notifications/test-fixtures"; +import { createTestQueryClient, renderHook, waitFor } from "@/mocks/react"; +import { + listNotificationsQuery, + markAllReadMutation, + markReadMutation, + NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY, + notificationsListQueryKey, + resetTrpcMocks, + setNotificationsState, +} from "@/mocks/trpc"; +import type { NotificationData } from "@domainstack/types"; + +import { useNotificationsData } from "./use-notifications-data"; + +const unreadAlpha = makeNotification({ id: "notif-alpha" }); +const unreadBeta = makeNotification({ + id: "notif-beta", + trackedDomainId: "domain-beta", + title: "beta.io expires in 7 days", + message: "Renew beta.io to keep it from expiring.", +}); +const archivedGamma = makeNotification({ + id: "notif-gamma", + trackedDomainId: "domain-gamma", + title: "gamma.com expired", + message: "gamma.com expired 10 days ago.", + readAt: new Date("2026-08-22T12:00:00.000Z"), +}); + +function pageItems( + queryClient: ReturnType, + filter: "unread" | "read", +) { + const data = queryClient.getQueryData<{ pages: { items: NotificationData[] }[] }>( + notificationsListQueryKey(filter), + ); + return data?.pages.flatMap((page) => page.items) ?? []; +} + +function renderNotificationsData(options?: { + items?: NotificationData[]; + filter?: "unread" | "read"; + enabled?: boolean; + seedReadList?: boolean; +}) { + const items = options?.items ?? [unreadAlpha, unreadBeta, archivedGamma]; + const queryClient = createTestQueryClient(); + setNotificationsState(items); + + queryClient.setQueryData( + NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY, + items.filter((item) => item.readAt === null).length, + ); + queryClient.setQueryData( + notificationsListQueryKey("unread"), + makeNotificationsInfiniteData(items.filter((item) => item.readAt === null)), + ); + if (options?.seedReadList !== false) { + queryClient.setQueryData( + notificationsListQueryKey("read"), + makeNotificationsInfiniteData(items.filter((item) => item.readAt !== null)), + ); + } + + const view = renderHook( + () => + useNotificationsData({ + filter: options?.filter ?? "unread", + enabled: options?.enabled ?? true, + }), + { + wrapper: ({ children }) => ( + {children} + ), + }, + ); + + return { ...view, queryClient }; +} + +describe("useNotificationsData", () => { + beforeEach(() => { + resetTrpcMocks(); + vi.mocked(toast.add).mockClear(); + }); + + afterEach(() => { + resetTrpcMocks(); + }); + + it("does not fetch the list when the popover is closed", async () => { + const { result } = renderNotificationsData({ enabled: false, items: [unreadAlpha] }); + + await waitFor(() => { + expect(result.current.count).toBe(1); + }); + expect(listNotificationsQuery).not.toHaveBeenCalled(); + }); + + it("fetches the read list via listNotificationsQuery", async () => { + const { result } = renderNotificationsData({ + filter: "read", + seedReadList: false, + }); + + await waitFor(() => { + expect(result.current.notifications.map((item) => item.id)).toContain("notif-gamma"); + }); + expect(listNotificationsQuery).toHaveBeenCalledWith( + expect.objectContaining({ filter: "read" }), + ); + }); + + it("markRead moves the item from inbox to archive and decrements the count", async () => { + const { result, queryClient } = renderNotificationsData(); + + result.current.markRead.mutate({ id: "notif-alpha" }); + + await waitFor(() => { + expect(pageItems(queryClient, "unread").map((item) => item.id)).toEqual(["notif-beta"]); + }); + const archived = pageItems(queryClient, "read"); + expect(archived.map((item) => item.id)).toEqual(["notif-alpha", "notif-gamma"]); + expect(archived[0]?.readAt).toBeInstanceOf(Date); + expect(queryClient.getQueryData(NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY)).toBe(1); + expect(markReadMutation.mock.calls[0]?.[0]).toEqual({ id: "notif-alpha" }); + }); + + it("rolls back and toasts when markRead fails", async () => { + markReadMutation.mockRejectedValueOnce(new Error("nope")); + const { result, queryClient } = renderNotificationsData(); + + result.current.markRead.mutate({ id: "notif-alpha" }); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ + title: "Failed to mark notification as read", + type: "error", + }); + }); + expect(pageItems(queryClient, "unread").map((item) => item.id)).toEqual([ + "notif-alpha", + "notif-beta", + ]); + expect(pageItems(queryClient, "read").map((item) => item.id)).toEqual(["notif-gamma"]); + expect(queryClient.getQueryData(NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY)).toBe(2); + }); + + it("markAllRead clears inbox and prepends those items onto archive", async () => { + const { result, queryClient } = renderNotificationsData(); + + result.current.markAllRead.mutate(); + + await waitFor(() => { + expect(pageItems(queryClient, "unread")).toEqual([]); + }); + expect(pageItems(queryClient, "read").map((item) => item.id)).toEqual([ + "notif-alpha", + "notif-beta", + "notif-gamma", + ]); + expect(queryClient.getQueryData(NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY)).toBe(0); + expect(markAllReadMutation).toHaveBeenCalledOnce(); + }); + + it("rolls back and toasts when markAllRead fails", async () => { + markAllReadMutation.mockRejectedValueOnce(new Error("nope")); + const { result, queryClient } = renderNotificationsData(); + + result.current.markAllRead.mutate(); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith({ + title: "Failed to mark notifications as read", + type: "error", + }); + }); + expect(pageItems(queryClient, "unread").map((item) => item.id)).toEqual([ + "notif-alpha", + "notif-beta", + ]); + expect(pageItems(queryClient, "read").map((item) => item.id)).toEqual(["notif-gamma"]); + expect(queryClient.getQueryData(NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY)).toBe(2); + }); +}); diff --git a/apps/web/hooks/use-notifications-data.ts b/apps/web/hooks/use-notifications-data.ts index 4d0e6fc2..0bdc313a 100644 --- a/apps/web/hooks/use-notifications-data.ts +++ b/apps/web/hooks/use-notifications-data.ts @@ -1,9 +1,9 @@ "use client"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; +import { toast } from "@domainstack/ui/toast"; const PAGE_SIZE = 20; @@ -55,9 +55,9 @@ export function useNotificationsData({ filter, enabled }: UseNotificationsDataOp const previousInbox = queryClient.getQueryData(inboxListQueryKey); const previousArchive = queryClient.getQueryData(archiveListQueryKey); - const wasInInbox = previousInbox?.pages?.some((page) => page.items.some((n) => n.id === id)); + const moved = previousInbox?.pages?.flatMap((page) => page.items).find((n) => n.id === id); - if (wasInInbox) { + if (moved) { queryClient.setQueryData(countQueryKey, (old: number | undefined) => typeof old === "number" ? Math.max(0, old - 1) : old, ); @@ -74,15 +74,24 @@ export function useNotificationsData({ filter, enabled }: UseNotificationsDataOp }; }); + const now = new Date(); + const archivedItem = moved + ? moved.readAt + ? moved + : Object.assign({}, moved, { readAt: now }) + : undefined; queryClient.setQueryData(archiveListQueryKey, (old) => { - if (!old?.pages) return old; - const now = new Date(); + if (!archivedItem) return old; + if (!old?.pages?.length) { + return { + pages: [{ items: [archivedItem], nextCursor: undefined }], + pageParams: [null], + }; + } + const [first, ...rest] = old.pages; return { ...old, - pages: old.pages.map((page) => ({ - ...page, - items: page.items.map((n) => (n.id === id ? { ...n, readAt: n.readAt ?? now } : n)), - })), + pages: [{ ...first, items: [archivedItem, ...first.items] }, ...rest], }; }); @@ -98,7 +107,7 @@ export function useNotificationsData({ filter, enabled }: UseNotificationsDataOp if (context?.previousArchive) { queryClient.setQueryData(archiveListQueryKey, context.previousArchive); } - toast.error("Failed to mark notification as read"); + toast.add({ title: "Failed to mark notification as read", type: "error" }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: inboxListQueryKey }); @@ -121,6 +130,12 @@ export function useNotificationsData({ filter, enabled }: UseNotificationsDataOp queryClient.setQueryData(countQueryKey, 0); + const now = new Date(); + const inboxItems = previousInbox?.pages?.flatMap((page) => page.items) ?? []; + const moved = inboxItems.map((item) => + item.readAt ? item : Object.assign({}, item, { readAt: now }), + ); + queryClient.setQueryData(inboxListQueryKey, (old) => { if (!old?.pages) return old; return { @@ -133,18 +148,18 @@ export function useNotificationsData({ filter, enabled }: UseNotificationsDataOp }; }); - const now = new Date(); queryClient.setQueryData(archiveListQueryKey, (old) => { - if (!old?.pages) return old; + if (moved.length === 0) return old; + if (!old?.pages?.length) { + return { + pages: [{ items: moved, nextCursor: undefined }], + pageParams: [null], + }; + } + const [first, ...rest] = old.pages; return { ...old, - pages: old.pages.map((page) => ({ - ...page, - items: page.items.map((n) => ({ - ...n, - readAt: n.readAt ?? now, - })), - })), + pages: [{ ...first, items: [...moved, ...first.items] }, ...rest], }; }); @@ -160,7 +175,7 @@ export function useNotificationsData({ filter, enabled }: UseNotificationsDataOp if (context?.previousArchive) { queryClient.setQueryData(archiveListQueryKey, context.previousArchive); } - toast.error("Failed to mark notifications as read"); + toast.add({ title: "Failed to mark notifications as read", type: "error" }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: inboxListQueryKey }); diff --git a/apps/web/hooks/use-section-tracking.ts b/apps/web/hooks/use-section-tracking.ts index ea8b64bd..b2121639 100644 --- a/apps/web/hooks/use-section-tracking.ts +++ b/apps/web/hooks/use-section-tracking.ts @@ -41,10 +41,6 @@ export function useSectionTracking({ const programmaticTargetIdRef = useRef(null); const programmaticLockUntilRef = useRef(0); - // Memoize scrollMarginPx in ref to allow updates without effect re-run - const scrollMarginRef = useRef(scrollMarginPx); - scrollMarginRef.current = scrollMarginPx; - // Section tracking effect useEffect(() => { if (sectionIds.length === 0) return; @@ -52,7 +48,7 @@ export function useSectionTracking({ let rafId: number | null = null; const updateActiveSection = () => { - const scrollMargin = scrollMarginRef.current; + const scrollMargin = scrollMarginPx; const targetId = programmaticTargetIdRef.current; // Check if we're in a programmatic scroll @@ -114,7 +110,7 @@ export function useSectionTracking({ window.cancelAnimationFrame(rafId); } }; - }, [sectionIds]); + }, [sectionIds, scrollMarginPx]); // Scroll to section with programmatic tracking const scrollToSection = useCallback((id: string) => { diff --git a/apps/web/hooks/use-subscription.ts b/apps/web/hooks/use-subscription.ts index 91d14d81..c0d834d9 100644 --- a/apps/web/hooks/use-subscription.ts +++ b/apps/web/hooks/use-subscription.ts @@ -3,13 +3,13 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { inferRouterOutputs } from "@trpc/server"; import { useCallback, useState } from "react"; -import { toast } from "sonner"; +import { analytics } from "@/lib/analytics/client"; import { useTRPC } from "@/lib/trpc/client"; import type { AppRouter } from "@/server/routers/_app"; -import { analytics } from "@domainstack/analytics/client"; import { checkoutEmbed, customer } from "@domainstack/auth/client"; import { PRO_TIER_INFO } from "@domainstack/polar/products"; +import { toast } from "@domainstack/ui/toast"; /** * Subscription data shape inferred from user.getSubscription procedure. @@ -129,7 +129,7 @@ export function useSubscription(options: UseSubscriptionOptions = {}): UseSubscr analytics.trackException(err instanceof Error ? err : new Error(String(err)), { action: "upgrade_checkout", }); - toast.error("Failed to open checkout. Please try again."); + toast.add({ title: "Failed to open checkout. Please try again.", type: "error" }); } finally { setCheckoutLoading(false); } @@ -146,7 +146,7 @@ export function useSubscription(options: UseSubscriptionOptions = {}): UseSubscr analytics.trackException(err instanceof Error ? err : new Error(String(err)), { action: "open_customer_portal", }); - toast.error("Failed to open customer portal. Please try again."); + toast.add({ title: "Failed to open customer portal. Please try again.", type: "error" }); } finally { setCustomerPortalLoading(false); } diff --git a/apps/web/instrumentation-client.ts b/apps/web/instrumentation-client.ts index d9937c62..54eb65ad 100644 --- a/apps/web/instrumentation-client.ts +++ b/apps/web/instrumentation-client.ts @@ -1,12 +1,21 @@ import posthogClient from "posthog-js"; -// PostHog -posthogClient.init(process.env.NEXT_PUBLIC_POSTHOG_KEY as string, { - api_host: "/_proxy/ingest", - ui_host: "https://us.posthog.com", - defaults: "2025-05-24", - capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this - debug: process.env.NODE_ENV === "development", - // GDPR: Don't set cookies until user consents - switches to cookieless tracking mode on reject - cookieless_mode: "on_reject", -}); +const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; + +if (!posthogKey) { + if (process.env.NODE_ENV === "development") { + console.warn( + "NEXT_PUBLIC_POSTHOG_KEY variable required by PostHog is missing or un-configured, this causes events to be silently missed. Configure NEXT_PUBLIC_POSTHOG_KEY to enable analytics.", + ); + } +} else { + posthogClient.init(posthogKey, { + api_host: "/_proxy/ingest", + tracing_headers: [window.location.hostname], + defaults: "2026-05-30", + capture_exceptions: true, + debug: process.env.NODE_ENV === "development", + // GDPR: don't set cookies until the user consents; cookieless until then + cookieless_mode: "on_reject", + }); +} diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts index f21df6fb..da004685 100644 --- a/apps/web/instrumentation.ts +++ b/apps/web/instrumentation.ts @@ -1,45 +1,57 @@ -import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino"; -import { registerOTel } from "@vercel/otel"; import type { Instrumentation } from "next"; export async function register() { - registerOTel({ - serviceName: "domainstack", - instrumentations: [ - "auto", // Keep Vercel's default fetch instrumentation - new PinoInstrumentation(), - ], - }); - - // Initialize Vercel Workflow world for durable backend operations // Only runs in Node.js runtime (not Edge) if (process.env.NEXT_RUNTIME !== "edge") { + // Flush buffered OTLP logs at request boundaries. Registering here means + // every request scope (route handlers, Server Components, Server Actions) + // is covered without each one opting in. + const { after } = await import("next/server"); + const { setFlushScheduler } = await import("@domainstack/logger"); + setFlushScheduler((task) => after(task)); + + // Initialize Vercel Workflow world for durable backend operations const { getWorld } = await import("workflow/runtime"); - await getWorld().start?.(); + const world = await getWorld(); + await world.start?.(); } } /** * Handle uncaught errors in Next.js requests. - * Logs errors with full context for debugging. */ export const onRequestError: Instrumentation.onRequestError = async (error, request) => { - // Only track errors in Node.js runtime (not Edge) - if (process.env.NEXT_RUNTIME === "nodejs") { - try { - // Use logger for structured error logging - const { logger } = await import("@domainstack/logger"); - logger.error( - { - err: error, - source: "instrumentation", - path: request.path, - method: request.method, - }, - "request error", - ); - } catch { - // Graceful degradation - don't throw to avoid breaking the request - } + if (process.env.NEXT_RUNTIME !== "nodejs") { + return; + } + + try { + const { flushLogs, logger } = await import("@domainstack/logger"); + logger.error( + { + err: error, + source: "instrumentation", + path: request.path, + method: request.method, + }, + "request error", + ); + await flushLogs(); + } catch { + // Don't throw from instrumentation + } + + try { + const { captureException } = await import("@/lib/analytics/server"); + const captured = + error instanceof Error + ? error + : new Error(typeof error === "string" ? error : "Request error"); + await captureException(captured, undefined, { + path: request.path, + method: request.method, + }); + } catch { + // Analytics must never break the request } }; diff --git a/apps/web/lib/add-domain-resume.test.ts b/apps/web/lib/add-domain-resume.test.ts new file mode 100644 index 00000000..50b7396a --- /dev/null +++ b/apps/web/lib/add-domain-resume.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { parseResumeDomain } from "@/lib/add-domain-resume"; + +function params(entries: Record) { + return new URLSearchParams(entries); +} + +describe("parseResumeDomain", () => { + it("returns null when resume is missing, not true, or search params are absent", () => { + expect(parseResumeDomain(params({ id: "domain-1" }))).toBeNull(); + expect(parseResumeDomain(params({ resume: "1", id: "domain-1" }))).toBeNull(); + expect(parseResumeDomain(params({}))).toBeNull(); + expect(parseResumeDomain(null)).toBeNull(); + expect(parseResumeDomain(undefined)).toBeNull(); + }); + + it("returns null when resume is true but id is missing", () => { + expect(parseResumeDomain(params({ resume: "true", domain: "pending.dev" }))).toBeNull(); + }); + + it("builds resume data from id, optional domain, and method", () => { + expect( + parseResumeDomain( + params({ + resume: "true", + id: "domain-pending", + domain: "pending.dev", + method: "dns_txt", + }), + ), + ).toEqual({ + id: "domain-pending", + domainName: "pending.dev", + verificationToken: "", + verificationMethod: "dns_txt", + }); + }); + + it("falls back to an empty domain name and drops invalid methods", () => { + expect(parseResumeDomain(params({ resume: "true", id: "domain-pending" }))).toEqual({ + id: "domain-pending", + domainName: "", + verificationToken: "", + verificationMethod: null, + }); + expect( + parseResumeDomain(params({ resume: "true", id: "domain-pending", method: "nope" })), + ).toEqual({ + id: "domain-pending", + domainName: "", + verificationToken: "", + verificationMethod: null, + }); + }); +}); diff --git a/apps/web/lib/add-domain-resume.ts b/apps/web/lib/add-domain-resume.ts new file mode 100644 index 00000000..f607e254 --- /dev/null +++ b/apps/web/lib/add-domain-resume.ts @@ -0,0 +1,31 @@ +import { isValidVerificationMethod } from "@/lib/verification-instructions"; +import type { ResumeDomainData } from "@domainstack/types"; + +/** + * Parse `?resume=true&id=…` search params into resume data for the add-domain flow. + * Invalid or missing verification methods are dropped rather than rejected. + */ +export function parseResumeDomain( + searchParams: Pick | null | undefined, +): ResumeDomainData | null { + if (!searchParams) { + return null; + } + + const isResume = searchParams.get("resume") === "true"; + const id = searchParams.get("id"); + const domain = searchParams.get("domain"); + const methodParam = searchParams.get("method"); + const method = isValidVerificationMethod(methodParam) ? methodParam : null; + + if (isResume && id) { + return { + id, + domainName: domain ?? "", + verificationToken: "", + verificationMethod: method, + }; + } + + return null; +} diff --git a/apps/web/lib/analytics/client.ts b/apps/web/lib/analytics/client.ts new file mode 100644 index 00000000..423b4dc8 --- /dev/null +++ b/apps/web/lib/analytics/client.ts @@ -0,0 +1,73 @@ +"use client"; + +import posthogClient from "posthog-js"; +import { useMemo } from "react"; + +import type { IdentifyProperties, IdentifySetOnceProperties } from "./types"; + +export type { IdentifyProperties, IdentifySetOnceProperties }; + +function track(event: string, properties?: Record) { + try { + posthogClient.capture(event, properties); + } catch { + // no-op + } +} + +function trackException(error: Error, properties?: Record) { + try { + posthogClient.captureException(error, properties); + } catch { + // no-op + } +} + +function identify( + userId: string, + properties?: IdentifyProperties, + setOnceProperties?: IdentifySetOnceProperties, +) { + try { + posthogClient.identify(userId, properties, setOnceProperties); + } catch { + // no-op + } +} + +function reset() { + try { + posthogClient.reset(); + } catch { + // no-op + } +} + +function setPersonProperties(properties: IdentifyProperties) { + try { + posthogClient.setPersonProperties(properties); + } catch { + // no-op + } +} + +export const analytics = { + track, + trackException, + identify, + reset, + setPersonProperties, +}; + +export function useAnalytics() { + return useMemo( + () => ({ + track, + trackException, + identify, + reset, + setPersonProperties, + }), + [], + ); +} diff --git a/apps/web/lib/analytics/server.ts b/apps/web/lib/analytics/server.ts new file mode 100644 index 00000000..93ac8ab9 --- /dev/null +++ b/apps/web/lib/analytics/server.ts @@ -0,0 +1,68 @@ +import "server-only"; +import { after } from "next/server"; +import { PostHog } from "posthog-node"; + +import type { IdentifyProperties, IdentifySetOnceProperties } from "./types"; + +const client = process.env.NEXT_PUBLIC_POSTHOG_KEY + ? new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com", + flushAt: 1, + flushInterval: 0, + }) + : null; + +export async function captureException( + error: Error, + userId?: string, + properties?: Record, +) { + if (!client) { + return; + } + + client.captureException(error, userId, properties); + await client.flush(); +} + +export const analytics = { + identify: ( + userId: string, + properties?: IdentifyProperties, + setOnceProperties?: IdentifySetOnceProperties, + ) => { + if (!client) { + return; + } + + const posthog = client; + after(() => + posthog.identifyImmediate({ + distinctId: userId, + properties: { + $set: properties, + $set_once: setOnceProperties, + }, + }), + ); + }, + + track: (event: string, properties: Record, userId: string) => { + if (!client) { + return; + } + + const posthog = client; + after(() => + posthog.captureImmediate({ + event, + distinctId: userId, + properties, + }), + ); + }, + + trackException: (error: Error, properties?: Record, userId?: string) => { + after(() => captureException(error, userId, properties)); + }, +}; diff --git a/apps/web/lib/analytics/types.ts b/apps/web/lib/analytics/types.ts new file mode 100644 index 00000000..fd6ec201 --- /dev/null +++ b/apps/web/lib/analytics/types.ts @@ -0,0 +1,9 @@ +export interface IdentifyProperties { + email?: string; + name?: string; + tier?: string; +} + +export interface IdentifySetOnceProperties { + createdAt?: string; +} diff --git a/apps/web/lib/atoms/chat-atoms.ts b/apps/web/lib/atoms/chat-atoms.ts index 1fabebf2..dbfd0c7c 100644 --- a/apps/web/lib/atoms/chat-atoms.ts +++ b/apps/web/lib/atoms/chat-atoms.ts @@ -2,78 +2,8 @@ import { atom } from "jotai"; -// --------------------------------------------------------------------------- -// Chat UI State -// --------------------------------------------------------------------------- - /** * Whether the chat panel is open. - * Global atom so any component can programmatically open the chat - * (e.g., "Ask AI about this domain" buttons). + * Global so any component can programmatically open the chat. */ export const chatOpenAtom = atom(false); - -// --------------------------------------------------------------------------- -// Chat Context -// --------------------------------------------------------------------------- - -/** - * Context types for the chat assistant. - * Each context type provides relevant information for generating suggestions - * and giving the AI appropriate context about what the user is looking at. - */ -export type ChatContext = { type: "home" } | { type: "report"; domain: string }; -// TODO: | { type: "dashboard"; trackedDomains: string[]; filters?: string } - -/** - * Current chat context atom. - * Set by page components to inform the chat about what's relevant. - * - * Usage: - * ```tsx - * // In report page - * const setContext = useSetAtom(chatContextAtom); - * useEffect(() => { - * setContext({ type: "report", domain: "example.com" }); - * return () => setContext({ type: "home" }); - * }, [domain]); - * ``` - */ -export const chatContextAtom = atom({ type: "home" }); - -// --------------------------------------------------------------------------- -// Suggestions -// --------------------------------------------------------------------------- - -/** - * Server-generated suggestions atom. - * Hydrated by ChatTriggerClient with randomized suggestions from the server. - * Used as fallback when no context-specific suggestions are available. - */ -export const serverSuggestionsAtom = atom([]); - -/** - * Derived atom that computes chat suggestions based on current context. - * Falls back to server-generated suggestions for "home" context. - * - * Usage: - * ```tsx - * const suggestions = useAtomValue(chatSuggestionsAtom); - * ``` - */ -export const chatSuggestionsAtom = atom((get) => { - const context = get(chatContextAtom); - const serverSuggestions = get(serverSuggestionsAtom); - - switch (context.type) { - case "report": - return [ - `When does ${context.domain} expire?`, - `Is ${context.domain} missing any important security headers?`, - `Which email provider does ${context.domain} use?`, - `Is ${context.domain}'s SSL certificate valid?`, - ]; - default: - return serverSuggestions; - } -}); diff --git a/apps/web/lib/chat/client-tools.ts b/apps/web/lib/chat/client-tools.ts index 425e4056..506f5a11 100644 --- a/apps/web/lib/chat/client-tools.ts +++ b/apps/web/lib/chat/client-tools.ts @@ -1,182 +1,63 @@ /** * Client-side domain lookup tools for browser-based AI chat. * - * These tools mirror the server-side tools in workflows/chat/tools.ts - * but call tRPC procedures directly from the browser instead of using - * durable workflow steps. + * Mirrors the server workflow tools, but calls tRPC from the browser + * instead of durable workflow steps. */ import type { TRPCClient } from "@trpc/client"; -import { tool } from "ai"; -import { z } from "zod"; +import { tool, type Tool } from "ai"; +import { analytics } from "@/lib/analytics/client"; import type { AppRouter } from "@/server/routers/_app"; -/** - * Schema for domain tool inputs. - * Using .describe() to help LLMs understand the expected format. - */ -const domainSchema = z.object({ - domain: z - .string() - .min(1, "Domain is required") - .describe( - "The root domain name to look up (e.g., 'example.com', 'example.org'). Must be a root domain, NOT a subdomain - WHOIS lookups don't work for subdomains like 'www.example.com' or 'api.example.com'. Do not include protocol (http/https).", - ), -}); - -/** - * Map specific error patterns to user-friendly messages. - */ -function getUserFriendlyError(err: unknown, domain: string): string { - const rawMessage = err instanceof Error ? err.message : "Unknown error"; - const lowerMessage = rawMessage.toLowerCase(); - - if (lowerMessage.includes("timeout") || lowerMessage.includes("timed out")) { - return "Request timed out. The domain may be slow to respond."; - } - if (lowerMessage.includes("rate limit") || lowerMessage.includes("429")) { - return "Rate limit exceeded. Please wait a moment and try again."; - } - if ( - lowerMessage.includes("not found") || - lowerMessage.includes("enotfound") || - lowerMessage.includes("dns") - ) { - return `Could not resolve ${domain}. The domain may not exist or DNS may be misconfigured.`; - } - if ( - lowerMessage.includes("certificate") || - lowerMessage.includes("ssl") || - lowerMessage.includes("tls") - ) { - return `Could not establish secure connection to ${domain}.`; - } - if (lowerMessage.includes("refused") || lowerMessage.includes("unreachable")) { - return `Could not connect to ${domain}. The server may be down.`; - } - if (lowerMessage.includes("unauthorized") || lowerMessage.includes("401")) { - return "Authentication required. Please sign in and try again."; - } - - return "Unable to fetch data. Please try again."; -} +import { + DOMAIN_TOOL_DEFS, + domainToolInputSchema, + getDomainToolErrorMessage, + type DomainToolInput, + type DomainToolResult, +} from "./domain-tools"; type TRPCClientType = TRPCClient; -/** - * Creates client-side domain intelligence tools that call tRPC procedures. - * - * Unlike the server-side tools, these execute directly in the browser - * without durable workflow steps. Rate limiting and caching are still - * handled by the tRPC procedures on the server. - * - * @param trpc - The tRPC client instance from useTRPCClient - */ -export function createClientDomainTools(trpc: TRPCClientType) { - return { - get_registration: tool({ - description: - "Get WHOIS/RDAP registration data for a domain including registrar, creation date, expiration date, nameservers, and registrant information. Use this tool when users ask about domain ownership, registration, expiry, or who owns a domain.", - inputSchema: domainSchema, - execute: async ({ domain }: { domain: string }) => { - try { - const result = await trpc.domain.getRegistration.query({ domain }); - if (!result.success) { - return { error: result.error }; - } - return result.data; - } catch (err) { - return { error: getUserFriendlyError(err, domain) }; - } - }, - }), +type ClientDomainToolSet = { + [Def in (typeof DOMAIN_TOOL_DEFS)[number] as Def["name"]]: Tool< + DomainToolInput, + DomainToolResult + >; +}; - get_dns_records: tool({ - description: - "Get DNS records for a domain including A, AAAA, CNAME, MX, TXT, NS, and SOA records. Use this tool when users ask about DNS configuration, IP addresses, mail servers, or nameservers.", - inputSchema: domainSchema, - execute: async ({ domain }: { domain: string }) => { - try { - const result = await trpc.domain.getDnsRecords.query({ domain }); - if (!result.success) { - return { error: result.error }; - } - return result.data; - } catch (err) { - return { error: getUserFriendlyError(err, domain) }; +function makeClientDomainTool( + trpc: TRPCClientType, + def: TDef, +) { + return tool({ + description: def.description, + inputSchema: domainToolInputSchema, + execute: async ({ domain }: DomainToolInput) => { + try { + const result = await trpc.domain[def.procedure].query({ domain }); + if (!result.success) { + return { error: result.error }; } - }, - }), + return result.data; + } catch (err) { + analytics.trackException(err instanceof Error ? err : new Error(String(err)), { + context: "client-domain-tool", + tool: def.name, + domain, + }); + return { error: getDomainToolErrorMessage(err) }; + } + }, + }); +} - get_hosting: tool({ - description: - "Detect hosting, DNS, CDN, and email providers for a domain by analyzing DNS records and HTTP headers. Use this tool when users ask where a site is hosted, what CDN they use, or who provides their email.", - inputSchema: domainSchema, - execute: async ({ domain }: { domain: string }) => { - try { - const result = await trpc.domain.getHosting.query({ domain }); - if (!result.success) { - return { error: result.error }; - } - return result.data; - } catch (err) { - return { error: getUserFriendlyError(err, domain) }; - } - }, - }), - - get_certificates: tool({ - description: - "Get SSL/TLS certificate information for a domain including issuer, validity dates, and certificate chain. Use this tool when users ask about HTTPS, SSL certificates, security, or certificate expiry.", - inputSchema: domainSchema, - execute: async ({ domain }: { domain: string }) => { - try { - const result = await trpc.domain.getCertificates.query({ domain }); - if (!result.success) { - return { error: result.error }; - } - return result.data; - } catch (err) { - return { error: getUserFriendlyError(err, domain) }; - } - }, - }), - - get_headers: tool({ - description: - "Get HTTP response headers for a domain including security headers, caching headers, and server information. Use this tool when users ask about security headers, server software, caching, or HTTP configuration.", - inputSchema: domainSchema, - execute: async ({ domain }: { domain: string }) => { - try { - const result = await trpc.domain.getHeaders.query({ domain }); - if (!result.success) { - return { error: result.error }; - } - return result.data; - } catch (err) { - return { error: getUserFriendlyError(err, domain) }; - } - }, - }), - - get_seo: tool({ - description: - "Get SEO metadata for a domain including title, description, Open Graph tags, Twitter cards, and robots.txt rules. Use this tool when users ask about SEO, meta tags, social sharing, or how a site appears in search.", - inputSchema: domainSchema, - execute: async ({ domain }: { domain: string }) => { - try { - const result = await trpc.domain.getSeo.query({ domain }); - if (!result.success) { - return { error: result.error }; - } - return result.data; - } catch (err) { - return { error: getUserFriendlyError(err, domain) }; - } - }, - }), - }; +export function createClientDomainTools(trpc: TRPCClientType): ClientDomainToolSet { + return Object.fromEntries( + DOMAIN_TOOL_DEFS.map((def) => [def.name, makeClientDomainTool(trpc, def)]), + ) as ClientDomainToolSet; } export type ClientDomainTools = ReturnType; diff --git a/apps/web/lib/chat/domain-tools.test.ts b/apps/web/lib/chat/domain-tools.test.ts new file mode 100644 index 00000000..8e1b4169 --- /dev/null +++ b/apps/web/lib/chat/domain-tools.test.ts @@ -0,0 +1,103 @@ +/* @vitest-environment node */ +import { describe, expect, it } from "vitest"; + +import type { RegistrationResponse } from "@domainstack/types"; + +import { + getDomainToolErrorMessage, + getDomainToolStatus, + getToolPartType, + getTrpcErrorCode, + type DomainToolResult, +} from "./domain-tools"; + +type Equals = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type AssertTrue = T; + +type RegistrationSuccess = Exclude, { error: string }>; +type DnsSuccess = Exclude, { error: string }>; + +// Fails if DomainToolResult collapses to `{ error: string }` or includes `data: null`. +type _KeepsRegistrationData = AssertTrue>; +type _ToolsKeepDistinctData = AssertTrue< + Equals extends true ? false : true +>; + +describe("getToolPartType", () => { + it("keeps static tool types unchanged", () => { + expect(getToolPartType({ type: "tool-get_registration" })).toBe("tool-get_registration"); + }); + + it("uses toolName for dynamic-tool parts", () => { + expect(getToolPartType({ type: "dynamic-tool", toolName: "web_search" })).toBe( + "tool-web_search", + ); + }); + + it("falls back to the part type when dynamic-tool has no name", () => { + expect(getToolPartType({ type: "dynamic-tool" })).toBe("dynamic-tool"); + expect(getToolPartType({ type: "dynamic-tool", toolName: "" })).toBe("dynamic-tool"); + }); +}); + +describe("getDomainToolStatus", () => { + it("strips the tool- prefix and returns the domain tool label", () => { + expect(getDomainToolStatus("tool-get_registration")).toBe("Looking up WHOIS data"); + }); + + it("returns a readable label for web_search", () => { + expect(getDomainToolStatus("tool-web_search")).toBe("Searching the web"); + }); + + it("falls back to the stripped name for unknown tools", () => { + expect(getDomainToolStatus("tool-unknown_lookup")).toBe("unknown_lookup"); + }); + + it("labels a dynamic-tool part from its toolName", () => { + expect( + getDomainToolStatus(getToolPartType({ type: "dynamic-tool", toolName: "web_search" })), + ).toBe("Searching the web"); + }); +}); + +describe("getTrpcErrorCode", () => { + it("returns a direct tRPC code", () => { + expect(getTrpcErrorCode({ code: "BAD_REQUEST" })).toBe("BAD_REQUEST"); + }); + + it("returns a nested data.code tRPC code", () => { + expect(getTrpcErrorCode({ data: { code: "TOO_MANY_REQUESTS" } })).toBe("TOO_MANY_REQUESTS"); + }); + + it("returns undefined for non-object inputs", () => { + expect(getTrpcErrorCode("BAD_REQUEST")).toBeUndefined(); + expect(getTrpcErrorCode(null)).toBeUndefined(); + }); + + it("ignores generic string codes that are not tRPC codes", () => { + expect(getTrpcErrorCode({ code: "ENOTFOUND" })).toBeUndefined(); + }); +}); + +describe("getDomainToolErrorMessage", () => { + it("returns the TOO_MANY_REQUESTS message", () => { + expect(getDomainToolErrorMessage({ code: "TOO_MANY_REQUESTS" })).toBe( + "Rate limit exceeded. Please wait a moment and try again.", + ); + }); + + it("returns the BAD_REQUEST message", () => { + expect(getDomainToolErrorMessage({ data: { code: "BAD_REQUEST" } })).toBe( + "Please provide a valid root domain (e.g., example.com).", + ); + }); + + it("returns the default message", () => { + expect(getDomainToolErrorMessage({ code: "INTERNAL_SERVER_ERROR" })).toBe( + "Unable to fetch data. Please try again.", + ); + expect(getDomainToolErrorMessage(new Error("boom"))).toBe( + "Unable to fetch data. Please try again.", + ); + }); +}); diff --git a/apps/web/lib/chat/domain-tools.ts b/apps/web/lib/chat/domain-tools.ts new file mode 100644 index 00000000..1ea4889f --- /dev/null +++ b/apps/web/lib/chat/domain-tools.ts @@ -0,0 +1,173 @@ +import type { inferRouterOutputs } from "@trpc/server"; +import { z } from "zod"; + +import type { AppRouter } from "@/server/routers/_app"; + +/** + * Shared domain-tool definitions used by the cloud workflow and browser chat. + */ +export const DOMAIN_TOOL_DEFS = [ + { + name: "get_registration", + procedure: "getRegistration", + status: "Looking up WHOIS data", + description: + "Get WHOIS/RDAP registration data for a domain including registrar, creation date, expiration date, nameservers, and registrant information. Use this tool when users ask about domain ownership, registration, expiry, or who owns a domain.", + }, + { + name: "get_dns_records", + procedure: "getDnsRecords", + status: "Fetching DNS records", + description: + "Get DNS records for a domain including A, AAAA, CNAME, MX, TXT, NS, and SOA records. Use this tool when users ask about DNS configuration, IP addresses, mail servers, or nameservers.", + }, + { + name: "get_hosting", + procedure: "getHosting", + status: "Detecting hosting provider", + description: + "Detect hosting, DNS, CDN, and email providers for a domain by analyzing DNS records and HTTP headers. Use this tool when users ask where a site is hosted, what CDN they use, or who provides their email.", + }, + { + name: "get_certificates", + procedure: "getCertificates", + status: "Checking SSL certificate", + description: + "Get SSL/TLS certificate information for a domain including issuer, validity dates, and certificate chain. Use this tool when users ask about HTTPS, SSL certificates, security, or certificate expiry.", + }, + { + name: "get_headers", + procedure: "getHeaders", + status: "Analyzing HTTP headers", + description: + "Get HTTP response headers for a domain including security headers, caching headers, and server information. Use this tool when users ask about security headers, server software, caching, or HTTP configuration.", + }, + { + name: "get_seo", + procedure: "getSeo", + status: "Fetching SEO metadata", + description: + "Get SEO metadata for a domain including title, description, Open Graph tags, Twitter cards, and robots.txt rules. Use this tool when users ask about SEO, meta tags, social sharing, or how a site appears in search.", + }, +] as const; + +export type DomainToolName = (typeof DOMAIN_TOOL_DEFS)[number]["name"]; +export type DomainToolProcedure = (typeof DOMAIN_TOOL_DEFS)[number]["procedure"]; + +type DomainOutputs = inferRouterOutputs["domain"]; + +/** + * inferRouterOutputs widens `success: true | false` to `boolean`, so a + * `success: true` check cannot pick the success branch. Infer from the + * shared `data` property and drop the failure branch's `null`. + */ +type ExtractSuccessData = T extends { data: infer D } ? NonNullable : never; + +export type DomainToolResult

= + | ExtractSuccessData + | { error: string }; + +export const domainToolInputSchema = z.object({ + domain: z + .string() + .min(1, "Domain is required") + .describe( + "The root domain name to look up (e.g., 'example.com', 'example.org'). Must be a root domain, NOT a subdomain - WHOIS lookups don't work for subdomains like 'www.example.com' or 'api.example.com'. Do not include protocol (http/https).", + ), +}); + +const DOMAIN_TOOL_STATUS = Object.fromEntries( + DOMAIN_TOOL_DEFS.map((def) => [def.name, def.status]), +) as Record; + +const EXTRA_TOOL_STATUS: Record = { + web_search: "Searching the web", +}; + +const TRPC_ERROR_CODES = new Set([ + "PARSE_ERROR", + "BAD_REQUEST", + "INTERNAL_SERVER_ERROR", + "NOT_IMPLEMENTED", + "BAD_GATEWAY", + "SERVICE_UNAVAILABLE", + "GATEWAY_TIMEOUT", + "UNAUTHORIZED", + "PAYMENT_REQUIRED", + "FORBIDDEN", + "NOT_FOUND", + "METHOD_NOT_SUPPORTED", + "TIMEOUT", + "CONFLICT", + "PRECONDITION_FAILED", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "UNPROCESSABLE_CONTENT", + "PRECONDITION_REQUIRED", + "TOO_MANY_REQUESTS", + "CLIENT_CLOSED_REQUEST", +]); + +export type DomainToolInput = z.infer; + +/** + * Normalize a UI tool part to the `tool-${name}` key used by status labels. + * Static tools already use that type; AI SDK dynamic-tool parts store the + * name on `toolName` instead. + */ +export function getToolPartType(part: { type: string; toolName?: unknown }): string { + if ( + part.type === "dynamic-tool" && + typeof part.toolName === "string" && + part.toolName.length > 0 + ) { + return `tool-${part.toolName}`; + } + return part.type; +} + +export function getDomainToolStatus(type: string): string { + const toolName = type.replace(/^tool-/, ""); + return DOMAIN_TOOL_STATUS[toolName as DomainToolName] ?? EXTRA_TOOL_STATUS[toolName] ?? toolName; +} + +function asTrpcErrorCode(code: unknown): string | undefined { + return typeof code === "string" && TRPC_ERROR_CODES.has(code) ? code : undefined; +} + +export function getTrpcErrorCode(err: unknown): string | undefined { + if (typeof err !== "object" || err === null) { + return undefined; + } + + if ("code" in err) { + const code = asTrpcErrorCode(err.code); + if (code) { + return code; + } + } + + if ("data" in err && typeof err.data === "object" && err.data !== null && "code" in err.data) { + return asTrpcErrorCode(err.data.code); + } + + return undefined; +} + +export function getDomainToolErrorMessage(err: unknown): string { + const code = getTrpcErrorCode(err); + if (code === "TOO_MANY_REQUESTS") { + return "Rate limit exceeded. Please wait a moment and try again."; + } + if (code === "BAD_REQUEST") { + return "Please provide a valid root domain (e.g., example.com)."; + } + return "Unable to fetch data. Please try again."; +} + +export function createDomainToolsContext(context: T): Record { + return Object.fromEntries(DOMAIN_TOOL_DEFS.map((def) => [def.name, context])) as Record< + DomainToolName, + T + >; +} diff --git a/apps/web/lib/chat/message-parts.test.ts b/apps/web/lib/chat/message-parts.test.ts new file mode 100644 index 00000000..d465a0f9 --- /dev/null +++ b/apps/web/lib/chat/message-parts.test.ts @@ -0,0 +1,120 @@ +/* @vitest-environment node */ +import type { UIMessage } from "ai"; +import { describe, expect, it } from "vitest"; + +import { + getMessagePartKey, + hasVisibleAssistantParts, + shouldShowThinkingStatus, +} from "./message-parts"; + +function assistantMessage(parts: UIMessage["parts"], id = "assistant-1"): UIMessage { + return { id, role: "assistant", parts }; +} + +function userMessage(text: string, id = "user-1"): UIMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} + +describe("getMessagePartKey", () => { + it("keeps text and reasoning keys stable as their text grows", () => { + expect(getMessagePartKey("m1", { type: "text", text: "Hel" }, 0)).toBe("m1-text-0"); + expect(getMessagePartKey("m1", { type: "text", text: "Hello" }, 0)).toBe("m1-text-0"); + expect(getMessagePartKey("m1", { type: "reasoning", text: "Hmm" }, 1)).toBe("m1-reasoning-1"); + expect(getMessagePartKey("m1", { type: "reasoning", text: "Hmm…" }, 1)).toBe("m1-reasoning-1"); + }); + + it("keys tool parts by toolCallId so inserts do not remount them", () => { + const toolPart = { + type: "tool-get_registration" as const, + toolCallId: "call-1", + state: "input-available" as const, + input: { domain: "example.com" }, + }; + + expect(getMessagePartKey("m1", toolPart, 0)).toBe("m1-call-1"); + expect(getMessagePartKey("m1", toolPart, 2)).toBe("m1-call-1"); + }); +}); + +describe("hasVisibleAssistantParts", () => { + const hidden = { showReasoning: false, showToolCalls: false }; + const visible = { showReasoning: true, showToolCalls: true }; + + it("treats non-empty text as visible", () => { + expect(hasVisibleAssistantParts(assistantMessage([{ type: "text", text: "Hi" }]), hidden)).toBe( + true, + ); + expect(hasVisibleAssistantParts(assistantMessage([{ type: "text", text: " " }]), hidden)).toBe( + false, + ); + }); + + it("hides reasoning and tools when those preferences are off", () => { + expect( + hasVisibleAssistantParts( + assistantMessage([ + { type: "reasoning", text: "planning" }, + { + type: "tool-get_registration", + toolCallId: "call-1", + state: "input-available", + input: {}, + }, + ]), + hidden, + ), + ).toBe(false); + }); + + it("shows reasoning and tools when those preferences are on", () => { + expect( + hasVisibleAssistantParts( + assistantMessage([{ type: "reasoning", text: "planning" }]), + visible, + ), + ).toBe(true); + expect( + hasVisibleAssistantParts( + assistantMessage([ + { + type: "tool-get_registration", + toolCallId: "call-1", + state: "input-available", + input: {}, + }, + ]), + visible, + ), + ).toBe(true); + }); +}); + +describe("shouldShowThinkingStatus", () => { + const options = { showReasoning: false, showToolCalls: true }; + + it("shows thinking while submitted or streaming before visible output", () => { + expect(shouldShowThinkingStatus("submitted", [userMessage("hi")], options)).toBe(true); + expect( + shouldShowThinkingStatus( + "streaming", + [userMessage("hi"), assistantMessage([{ type: "reasoning", text: "…" }])], + options, + ), + ).toBe(true); + }); + + it("hides thinking once visible assistant output exists", () => { + expect( + shouldShowThinkingStatus( + "streaming", + [userMessage("hi"), assistantMessage([{ type: "text", text: "Hello" }])], + options, + ), + ).toBe(false); + }); + + it("stays hidden when idle", () => { + expect(shouldShowThinkingStatus("ready", [userMessage("hi")], options)).toBe(false); + }); +}); diff --git a/apps/web/lib/chat/message-parts.ts b/apps/web/lib/chat/message-parts.ts new file mode 100644 index 00000000..3b71bbd2 --- /dev/null +++ b/apps/web/lib/chat/message-parts.ts @@ -0,0 +1,57 @@ +import type { ChatStatus, UIMessage } from "ai"; + +type MessagePart = UIMessage["parts"][number]; + +export function isToolPart(part: MessagePart): boolean { + return part.type.startsWith("tool-") || part.type === "dynamic-tool"; +} + +export function getMessagePartKey(messageId: string, part: MessagePart, position: number): string { + if (isToolPart(part) && "toolCallId" in part && typeof part.toolCallId === "string") { + return `${messageId}-${part.toolCallId}`; + } + return `${messageId}-${part.type}-${position}`; +} + +export function getMessagePartItems(message: UIMessage) { + return message.parts.map((part, position) => ({ + key: getMessagePartKey(message.id, part, position), + part, + position, + })); +} + +export function hasVisibleAssistantParts( + message: UIMessage, + options: { showReasoning: boolean; showToolCalls: boolean }, +): boolean { + return message.parts.some((part) => { + if (part.type === "text") { + return "text" in part && typeof part.text === "string" && part.text.trim().length > 0; + } + if (part.type === "reasoning") { + return options.showReasoning; + } + if (isToolPart(part)) { + return options.showToolCalls; + } + return false; + }); +} + +export function shouldShowThinkingStatus( + status: ChatStatus, + messages: UIMessage[], + options: { showReasoning: boolean; showToolCalls: boolean }, +): boolean { + if (status !== "submitted" && status !== "streaming") { + return false; + } + + const lastMessage = messages.at(-1); + if (!lastMessage || lastMessage.role !== "assistant") { + return true; + } + + return !hasVisibleAssistantParts(lastMessage, options); +} diff --git a/apps/web/lib/chat/request-schema.test.ts b/apps/web/lib/chat/request-schema.test.ts new file mode 100644 index 00000000..bd7dacf9 --- /dev/null +++ b/apps/web/lib/chat/request-schema.test.ts @@ -0,0 +1,278 @@ +/* @vitest-environment node */ +import { describe, expect, it } from "vitest"; + +import { + MAX_ASSISTANT_PART_CHARS, + MAX_ASSISTANT_PARTS, + MAX_ASSISTANT_TEXT_LENGTH, + MAX_CONVERSATION_MESSAGES, + MAX_MESSAGE_LENGTH, +} from "@domainstack/constants"; + +import { chatRequestSchema } from "./request-schema"; + +function userMessage(text: string, id = "user-1") { + return { + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], + }; +} + +describe("chatRequestSchema", () => { + it("accepts a user message at the character limit", () => { + const result = chatRequestSchema.safeParse({ + messages: [userMessage("x".repeat(MAX_MESSAGE_LENGTH))], + }); + + expect(result.success).toBe(true); + }); + + it("rejects a user text part over the character limit", () => { + const result = chatRequestSchema.safeParse({ + messages: [userMessage("x".repeat(MAX_MESSAGE_LENGTH + 1))], + }); + + expect(result.success).toBe(false); + if (result.success) return; + + expect( + result.error.issues.some((issue) => issue.message.includes(String(MAX_MESSAGE_LENGTH))), + ).toBe(true); + }); + + it("does not let oversized user text fall through a non-text part schema", () => { + const result = chatRequestSchema.safeParse({ + messages: [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "x".repeat(MAX_MESSAGE_LENGTH + 1) }], + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("rejects user parts that are not text", () => { + const result = chatRequestSchema.safeParse({ + messages: [ + { + id: "user-1", + role: "user", + parts: [{ type: "file", text: "x".repeat(MAX_MESSAGE_LENGTH + 1) }], + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("accepts assistant text longer than the user character limit", () => { + const result = chatRequestSchema.safeParse({ + messages: [ + userMessage("what is the registrar?"), + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "x".repeat(MAX_MESSAGE_LENGTH + 200) }], + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it("accepts assistant tool parts", () => { + const result = chatRequestSchema.safeParse({ + messages: [ + userMessage("look up example.com"), + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-get_registration", + toolCallId: "call-1", + state: "output-available", + input: { domain: "example.com" }, + output: { registrar: "Example Registrar" }, + }, + { type: "text", text: "Example.com is registered." }, + ], + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it("rejects an empty messages array", () => { + const result = chatRequestSchema.safeParse({ messages: [] }); + + expect(result.success).toBe(false); + }); + + it("rejects more messages than the conversation cap", () => { + const messages = Array.from({ length: MAX_CONVERSATION_MESSAGES * 2 + 1 }, (_, i) => + userMessage(`message ${i}`, `user-${i}`), + ); + + const result = chatRequestSchema.safeParse({ messages }); + + expect(result.success).toBe(false); + }); + + it("keeps extra fields on messages and assistant parts", () => { + const result = chatRequestSchema.safeParse({ + messages: [ + { + id: "user-1", + role: "user", + createdAt: "2026-08-23T00:00:00.000Z", + parts: [{ type: "text", text: "hello" }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-get_registration", + toolCallId: "call-1", + state: "output-available", + }, + ], + }, + ], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.messages[0]).toMatchObject({ + createdAt: "2026-08-23T00:00:00.000Z", + }); + expect(result.data.messages[1]?.parts[0]).toMatchObject({ + toolCallId: "call-1", + state: "output-available", + }); + }); + + it("rejects an oversized assistant part type", () => { + const result = chatRequestSchema.safeParse({ + messages: [ + userMessage("hello"), + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "x".repeat(65) }], + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("rejects assistant text over the assistant character limit", () => { + const result = chatRequestSchema.safeParse({ + messages: [ + userMessage("what is the registrar?"), + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "x".repeat(MAX_ASSISTANT_TEXT_LENGTH + 1) }], + }, + ], + }); + + expect(result.success).toBe(false); + if (result.success) return; + + expect( + result.error.issues.some((issue) => + issue.message.includes(String(MAX_ASSISTANT_TEXT_LENGTH)), + ), + ).toBe(true); + }); + + it("rejects an assistant part whose serialized size exceeds the cap", () => { + const result = chatRequestSchema.safeParse({ + messages: [ + userMessage("look up example.com"), + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-get_registration", + output: "x".repeat(MAX_ASSISTANT_PART_CHARS), + }, + ], + }, + ], + }); + + expect(result.success).toBe(false); + if (result.success) return; + + expect( + result.error.issues.some((issue) => issue.message.includes(String(MAX_ASSISTANT_PART_CHARS))), + ).toBe(true); + }); + + it("rejects too many assistant parts", () => { + const result = chatRequestSchema.safeParse({ + messages: [ + userMessage("hello"), + { + id: "assistant-1", + role: "assistant", + parts: Array.from({ length: MAX_ASSISTANT_PARTS + 1 }, (_, i) => ({ + type: "text", + text: `part ${i}`, + })), + }, + ], + }); + + expect(result.success).toBe(false); + if (result.success) return; + + expect( + result.error.issues.some((issue) => issue.message.includes(String(MAX_ASSISTANT_PARTS))), + ).toBe(true); + }); + + it("rejects an assistant part that cannot be serialized", () => { + const part: Record = { type: "text" }; + part.self = part; + + const result = chatRequestSchema.safeParse({ + messages: [ + userMessage("hello"), + { + id: "assistant-1", + role: "assistant", + parts: [part], + }, + ], + }); + + expect(result.success).toBe(false); + if (result.success) return; + + expect( + result.error.issues.some((issue) => issue.message.includes("could not be serialized")), + ).toBe(true); + }); + + it("rejects a domain longer than 253 characters", () => { + const result = chatRequestSchema.safeParse({ + messages: [userMessage("hello")], + domain: "a".repeat(254), + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/apps/web/lib/chat/request-schema.ts b/apps/web/lib/chat/request-schema.ts new file mode 100644 index 00000000..f603ed3c --- /dev/null +++ b/apps/web/lib/chat/request-schema.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { + MAX_ASSISTANT_PART_CHARS, + MAX_ASSISTANT_PARTS, + MAX_ASSISTANT_TEXT_LENGTH, + MAX_CONVERSATION_MESSAGES, + MAX_MESSAGE_LENGTH, +} from "@domainstack/constants"; + +const userTextPartSchema = z.object({ + type: z.literal("text"), + text: z.string().max(MAX_MESSAGE_LENGTH, { + message: `Message text exceeds ${MAX_MESSAGE_LENGTH} characters`, + }), +}); + +const userMessageSchema = z.looseObject({ + id: z.string(), + role: z.literal("user"), + parts: z.array(userTextPartSchema), +}); + +const assistantPartSchema = z + .looseObject({ + type: z.string().max(64, { message: "Assistant part type is too long" }), + }) + .superRefine((part, ctx) => { + if (typeof part.text === "string" && part.text.length > MAX_ASSISTANT_TEXT_LENGTH) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Assistant text exceeds ${MAX_ASSISTANT_TEXT_LENGTH} characters`, + path: ["text"], + }); + } + + try { + if (JSON.stringify(part).length > MAX_ASSISTANT_PART_CHARS) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Assistant part exceeds ${MAX_ASSISTANT_PART_CHARS} characters`, + }); + } + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Assistant part could not be serialized", + }); + } + }); + +const assistantMessageSchema = z.looseObject({ + id: z.string(), + role: z.literal("assistant"), + parts: z.array(assistantPartSchema).max(MAX_ASSISTANT_PARTS, { + message: `Too many assistant parts (max ${MAX_ASSISTANT_PARTS})`, + }), +}); + +/** + * Zod schema for chat request validation. + * + * User text is capped at MAX_MESSAGE_LENGTH. Assistant parts are passed + * through so conversation history (model replies, tool calls) is not + * rejected, but each part is size-capped so client-controlled history + * cannot inflate context or cost without bound. + */ +export const chatRequestSchema = z.object({ + messages: z + .array(z.discriminatedUnion("role", [userMessageSchema, assistantMessageSchema])) + .min(1, { message: "At least one message is required" }) + .max(MAX_CONVERSATION_MESSAGES * 2, { + message: `Too many messages (max ${MAX_CONVERSATION_MESSAGES * 2})`, + }), + domain: z.string().max(253, { message: "Domain name too long" }).optional(), +}); diff --git a/apps/web/lib/dashboard-table-features.ts b/apps/web/lib/dashboard-table-features.ts new file mode 100644 index 00000000..e1b20091 --- /dev/null +++ b/apps/web/lib/dashboard-table-features.ts @@ -0,0 +1,35 @@ +import { + columnSizingFeature, + columnVisibilityFeature, + createPaginatedRowModel, + createSortedRowModel, + rowPaginationFeature, + rowSortingFeature, + type Table, + tableFeatures, +} from "@tanstack/react-table"; + +import type { TrackedDomainWithDetails } from "@domainstack/types"; + +/** + * TanStack Table v9 requires explicitly registering the features (and their + * row models) a table uses. The dashboard table only needs sorting, + * pagination, column visibility, and column sizing - selection is handled + * outside the table via `useDashboardSelection`, and there's no filtering, + * grouping, expansion, or pinning. + * + * @see https://tanstack.com/table/latest/docs/framework/react/guide/migrating + */ +export const dashboardTableFeatures = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + columnVisibilityFeature, + columnSizingFeature, + sortedRowModel: createSortedRowModel(), + paginatedRowModel: createPaginatedRowModel(), +}); + +export type DashboardTableFeatures = typeof dashboardTableFeatures; + +/** Convenience alias for the dashboard's table instance type. */ +export type DashboardTable = Table; diff --git a/apps/web/lib/dashboard-utils.test.ts b/apps/web/lib/dashboard-utils.test.ts new file mode 100644 index 00000000..3a502a80 --- /dev/null +++ b/apps/web/lib/dashboard-utils.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it } from "vitest"; + +import { + DASHBOARD_TEST_NOW, + daysFromTestNow, + EMPTY_PROVIDER, + makeDashboardDomains, + makeProvider, + makeTrackedDomain, +} from "@/components/dashboard/test-fixtures"; +import { getDashboardFilterSignature } from "@/hooks/use-dashboard-pagination"; +import { + computeHealthStats, + DEFAULT_SORT, + extractAvailableProviders, + extractAvailableTlds, + filterDomains, + getConfirmDialogContent, + getValidProviderIds, + type HealthFilter, + isPagePastEnd, + parseSortParam, + serializeSortState, + sortDomains, + type StatusFilter, + validateHealthFilters, + validateStatusFilters, +} from "@/lib/dashboard-utils"; + +const domains = makeDashboardDomains(); +const availableProviders = extractAvailableProviders(domains); +const validProviderIds = getValidProviderIds(availableProviders); + +function names(list: ReturnType) { + return list.map((d) => d.domainName); +} + +describe("parseSortParam / serializeSortState", () => { + it("parses columnId.direction into TanStack sorting state", () => { + expect(parseSortParam("expirationDate.desc")).toEqual([{ id: "expirationDate", desc: true }]); + expect(parseSortParam("domainName.asc")).toEqual([{ id: "domainName", desc: false }]); + }); + + it("falls back to domainName asc for invalid values", () => { + expect(parseSortParam("")).toEqual([{ id: "domainName", desc: false }]); + expect(parseSortParam("nope")).toEqual([{ id: "domainName", desc: false }]); + expect(parseSortParam("domainName.sideways")).toEqual([{ id: "domainName", desc: false }]); + }); + + it("round-trips through serializeSortState", () => { + const parsed = parseSortParam("createdAt.desc"); + expect(serializeSortState(parsed)).toBe("createdAt.desc"); + }); + + it("serializes empty sorting to DEFAULT_SORT", () => { + expect(serializeSortState([])).toBe(DEFAULT_SORT); + }); +}); + +describe("sortDomains", () => { + it("sorts by name A-Z and Z-A without pushing unverified last", () => { + expect(names(sortDomains(domains, "domainName.asc"))).toEqual([ + "alpha.com", + "beta.io", + "gamma.com", + "pending.dev", + ]); + expect(names(sortDomains(domains, "domainName.desc"))).toEqual([ + "pending.dev", + "gamma.com", + "beta.io", + "alpha.com", + ]); + }); + + it("sorts by expiry and keeps unverified last", () => { + expect(names(sortDomains(domains, "expirationDate.asc"))).toEqual([ + "gamma.com", + "beta.io", + "alpha.com", + "pending.dev", + ]); + }); + + it("sorts by recently added", () => { + const older = makeTrackedDomain({ + id: "old", + domainName: "old.com", + createdAt: daysFromTestNow(-10), + }); + const newer = makeTrackedDomain({ + id: "new", + domainName: "new.com", + createdAt: daysFromTestNow(-1), + }); + expect(names(sortDomains([older, newer], "createdAt.desc"))).toEqual(["new.com", "old.com"]); + }); +}); + +describe("filterDomains", () => { + const emptyCriteria = { + search: "", + domainId: null, + status: [] as StatusFilter[], + health: [] as HealthFilter[], + tlds: [] as string[], + providers: [] as string[], + }; + + it("filters by search substring case-insensitively", () => { + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, search: "BETA" }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["beta.io"]); + }); + + it("ANDs search with TLD and health", () => { + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, search: "a", tlds: ["com"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["alpha.com", "gamma.com"]); + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, tlds: ["com"], health: ["expired"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["gamma.com"]); + }); + + it("filters by verification status", () => { + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, status: ["pending"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["pending.dev"]); + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, status: ["verified"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["alpha.com", "beta.io", "gamma.com"]); + }); + + it("filters by health", () => { + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, health: ["healthy"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["alpha.com"]); + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, health: ["expiring"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["beta.io"]); + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, health: ["expired"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["gamma.com"]); + }); + + it("filters by TLD", () => { + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, tlds: ["io"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["beta.io"]); + }); + + it("filters by provider across categories and excludes unverified", () => { + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, providers: ["cloudflare"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["alpha.com", "beta.io"]); + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, providers: ["vercel"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["gamma.com"]); + }); + + it("pins a single domain by id", () => { + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, domainId: "domain-gamma" }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["gamma.com"]); + }); + + it("ignores unknown provider ids for verified domains and still excludes unverified", () => { + expect( + names( + filterDomains( + domains, + { ...emptyCriteria, providers: ["not-a-provider"] }, + validProviderIds, + DASHBOARD_TEST_NOW, + ), + ), + ).toEqual(["alpha.com", "beta.io", "gamma.com"]); + }); +}); + +describe("extractAvailableTlds / extractAvailableProviders", () => { + it("extracts sorted unique TLDs including unverified", () => { + expect(extractAvailableTlds(domains)).toEqual(["com", "dev", "io"]); + }); + + it("skips unverified and archived domains when collecting providers", () => { + const archived = makeTrackedDomain({ + id: "archived", + domainName: "archived.net", + tld: "net", + archivedAt: DASHBOARD_TEST_NOW, + registrar: makeProvider("godaddy", "GoDaddy", "godaddy.com"), + }); + const providers = extractAvailableProviders([...domains, archived]); + expect(providers.registrar.map((p) => p.id)).toEqual(["cloudflare", "namecheap"]); + expect(providers.dns.map((p) => p.id)).toEqual(["cloudflare"]); + expect(providers.hosting.map((p) => p.id)).toEqual(["vercel"]); + }); +}); + +describe("computeHealthStats", () => { + it("counts expiring-soon and pending verification, not expired", () => { + expect(computeHealthStats(domains, DASHBOARD_TEST_NOW)).toEqual({ + expiringSoon: 1, + pendingVerification: 1, + }); + }); +}); + +describe("validateStatusFilters / validateHealthFilters", () => { + it("drops unknown URL values", () => { + expect(validateStatusFilters(["verified", "nope", "pending"])).toEqual(["verified", "pending"]); + expect(validateHealthFilters(["healthy", "boom", "expired"])).toEqual(["healthy", "expired"]); + }); +}); + +describe("isPagePastEnd", () => { + it("is false for a valid page and true when the page is empty", () => { + expect(isPagePastEnd(12, 1, 10)).toBe(false); + expect(isPagePastEnd(2, 1, 10)).toBe(true); + expect(isPagePastEnd(0, 0, 10)).toBe(false); + // Loading fallback (0 results) looks like page 2 is past the end — callers must wait for data. + expect(isPagePastEnd(0, 1, 10)).toBe(true); + }); +}); + +describe("makeDashboardDomains", () => { + it("clones providers and dates so mutations stay isolated", () => { + const [first] = makeDashboardDomains(); + first.registrar.id = "mutated"; + first.registrar.name = "Mutated"; + first.createdAt.setUTCFullYear(1999); + first.expirationDate?.setUTCFullYear(1999); + + const [next] = makeDashboardDomains(); + expect(next.registrar.id).toBe("cloudflare"); + expect(next.registrar.name).toBe("Cloudflare"); + expect(next.createdAt.getUTCFullYear()).toBe(2026); + expect(next.expirationDate?.getUTCFullYear()).toBe(2027); + expect(DASHBOARD_TEST_NOW.getUTCFullYear()).toBe(2026); + }); +}); + +describe("makeTrackedDomain", () => { + it("preserves extra provider fields and isolates them across clones", () => { + const source = makeProvider("cloudflare", "Cloudflare", "cloudflare.com"); + source.whoisServer = "whois.cloudflare.com"; + source.records = [{ type: "A", name: "alpha.com", value: "1.2.3.4" }]; + + const first = makeTrackedDomain({ registrar: source }); + expect(first.registrar.whoisServer).toBe("whois.cloudflare.com"); + expect(first.registrar.records).toEqual([{ type: "A", name: "alpha.com", value: "1.2.3.4" }]); + + first.registrar.whoisServer = "mutated"; + first.registrar.records![0].value = "9.9.9.9"; + + const next = makeTrackedDomain({ registrar: source }); + expect(next.registrar.whoisServer).toBe("whois.cloudflare.com"); + expect(next.registrar.records?.[0].value).toBe("1.2.3.4"); + }); +}); + +describe("getDashboardFilterSignature", () => { + it("changes when a filter value changes", () => { + const base = { + search: "", + status: [] as string[], + health: [] as string[], + tlds: [] as string[], + providers: [] as string[], + domainId: null, + }; + expect(getDashboardFilterSignature({ ...base, search: "s" })).not.toBe( + getDashboardFilterSignature(base), + ); + }); +}); + +describe("getConfirmDialogContent", () => { + it("returns copy and variants for single and bulk actions", () => { + expect( + getConfirmDialogContent({ type: "remove", domainId: "1", domainName: "alpha.com" }), + ).toEqual({ + title: "Remove domain?", + description: "Are you sure you want to stop tracking alpha.com?", + confirmLabel: "Remove", + variant: "destructive", + }); + expect( + getConfirmDialogContent({ type: "archive", domainId: "1", domainName: "alpha.com" }), + ).toMatchObject({ + title: "Archive domain?", + confirmLabel: "Archive", + variant: "default", + }); + expect( + getConfirmDialogContent({ type: "bulk-archive", domainIds: ["1", "2"], count: 2 }), + ).toMatchObject({ + title: "Archive 2 domains?", + confirmLabel: "Archive All", + }); + expect(getConfirmDialogContent({ type: "bulk-delete", domainIds: ["1"], count: 1 })).toEqual({ + title: "Delete 1 domain?", + description: "Are you sure you want to stop tracking 1 domain?", + confirmLabel: "Delete All", + variant: "destructive", + }); + }); +}); + +describe("empty provider fixture", () => { + it("has null identity fields", () => { + expect(EMPTY_PROVIDER).toEqual({ id: null, name: null, domain: null }); + }); +}); diff --git a/apps/web/lib/dashboard-utils.ts b/apps/web/lib/dashboard-utils.ts index 8fd9f53a..ff395d39 100644 --- a/apps/web/lib/dashboard-utils.ts +++ b/apps/web/lib/dashboard-utils.ts @@ -55,6 +55,12 @@ export type DashboardViewModeOptions = (typeof DASHBOARD_VIEW_MODE_OPTIONS)[numb export const DASHBOARD_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const; export type DashboardPageSizeOptions = (typeof DASHBOARD_PAGE_SIZE_OPTIONS)[number]; +/** True when the current page is past the last page of `itemCount` rows. */ +export function isPagePastEnd(itemCount: number, pageIndex: number, pageSize: number): boolean { + const pageCount = Math.max(1, Math.ceil(itemCount / pageSize)); + return pageIndex >= pageCount; +} + export const DASHBOARD_PREFERENCES_DEFAULT: { viewMode: DashboardViewModeOptions; pageSize: DashboardPageSizeOptions; @@ -313,7 +319,8 @@ export function getValidProviderIds(availableProviders: AvailableProvidersByCate } /** - * Compute health stats for domains (expiring soon + pending verification counts) + * Compute health stats for the dashboard badges. + * `expiringSoon` matches the Expiring Soon filter (not expired). */ export function computeHealthStats(domains: TrackedDomainWithDetails[], now: Date) { let expiringSoon = 0; @@ -324,8 +331,7 @@ export function computeHealthStats(domains: TrackedDomainWithDetails[], now: Dat pendingVerification++; continue; } - const healthStatus = getHealthStatus(domain.expirationDate, domain.verified, now); - if (healthStatus === "expiring" || healthStatus === "expired") { + if (getHealthStatus(domain.expirationDate, domain.verified, now) === "expiring") { expiringSoon++; } } @@ -446,14 +452,14 @@ export function getConfirmDialogContent(action: ConfirmAction) { }; case "bulk-archive": return { - title: `Archive ${action.count} domains?`, + title: `Archive ${action.count} domain${action.count === 1 ? "" : "s"}?`, description: `Are you sure you want to archive ${action.count} domain${action.count === 1 ? "" : "s"}? You can reactivate them later from the Archived section.`, confirmLabel: "Archive All", variant: "default" as const, }; case "bulk-delete": return { - title: `Delete ${action.count} domains?`, + title: `Delete ${action.count} domain${action.count === 1 ? "" : "s"}?`, description: `Are you sure you want to stop tracking ${action.count} domain${action.count === 1 ? "" : "s"}?`, confirmLabel: "Delete All", variant: "destructive" as const, diff --git a/apps/web/lib/ratelimit/client.ts b/apps/web/lib/ratelimit/client.ts index 7000504c..faf36ee2 100644 --- a/apps/web/lib/ratelimit/client.ts +++ b/apps/web/lib/ratelimit/client.ts @@ -1,6 +1,4 @@ -import { IconGauge } from "@tabler/icons-react"; -import { createElement } from "react"; -import { toast } from "sonner"; +import { toast } from "@domainstack/ui/toast"; /** * Rate limit error details extracted from tRPC or API errors. @@ -103,7 +101,7 @@ function extractRateLimitError(error: unknown): RateLimitError | null { * ```ts * onError: (error) => { * if (!showRateLimitToast(error)) { - * toast.error("Something went wrong"); + * toast.add({ title: "Something went wrong", type: "error" }); * } * } * ``` @@ -114,12 +112,13 @@ export function showRateLimitError(error: unknown): boolean { const { retryAfter } = rateLimitError; - toast.error("Too many requests", { + toast.add({ + title: "Too many requests", description: retryAfter > 0 ? `Please wait ${retryAfter} second${retryAfter !== 1 ? "s" : ""} before trying again.` : "Please wait a moment before trying again.", - icon: createElement(IconGauge, { className: "size-4" }), + type: "error", }); return true; diff --git a/apps/web/lib/stores/chat-store.ts b/apps/web/lib/stores/chat-store.ts index 03c93574..752a13c6 100644 --- a/apps/web/lib/stores/chat-store.ts +++ b/apps/web/lib/stores/chat-store.ts @@ -1,16 +1,14 @@ "use client"; import type { UIMessage } from "ai"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- +import { MAX_CONVERSATION_MESSAGES } from "@domainstack/constants"; interface ChatState { - // Session state (persisted) + /** Live workflow run ID; persisted so a reload can resume an in-flight stream. */ runId: string | null; messages: UIMessage[]; } @@ -23,19 +21,8 @@ interface ChatActions { type ChatStore = ChatState & ChatActions; -// --------------------------------------------------------------------------- -// Store -// --------------------------------------------------------------------------- - /** - * Chat store for session persistence (runId, messages). - * UI state (open/settings dialogs) is local useState in ChatTriggerClient. - * - * Usage: - * ```tsx - * const messages = useChatStore((s) => s.messages); - * const setMessages = useChatStore((s) => s.setMessages); - * ``` + * Chat store for session persistence (messages and in-flight stream resume). */ const chatStore = create()( persist( @@ -49,33 +36,34 @@ const chatStore = create()( }), { name: "chat", - version: 1, + version: 2, partialize: (state) => ({ runId: state.runId, - messages: state.messages, + messages: state.messages.slice(-MAX_CONVERSATION_MESSAGES), }), + migrate: (persisted) => { + const state = persisted as Partial; + return { + runId: null, + messages: state.messages ?? [], + }; + }, }, ), ); export const useChatStore = chatStore; +const subscribeChatHydration = chatStore.persist.onFinishHydration; +const getChatHydrationSnapshot = () => chatStore.persist.hasHydrated(); +const getChatHydrationServerSnapshot = () => false; + /** * Returns true once the chat store has hydrated from localStorage. - * Use this to prevent restoring messages before the store has loaded persisted data. - * - * @see https://zustand.docs.pmnd.rs/integrations/persisting-store-data#how-can-i-check-if-my-store-has-been-hydrated */ -export const useChatHydrated = () => { - const [hydrated, setHydrated] = useState(false); - - useEffect(() => { - const unsubscribe = chatStore.persist.onFinishHydration(() => setHydrated(true)); - - setHydrated(chatStore.persist.hasHydrated()); - - return () => unsubscribe(); - }, []); - - return hydrated; -}; +export const useChatHydrated = () => + useSyncExternalStore( + subscribeChatHydration, + getChatHydrationSnapshot, + getChatHydrationServerSnapshot, + ); diff --git a/apps/web/lib/stores/preferences-store.ts b/apps/web/lib/stores/preferences-store.ts index 84ba331e..ee4a635a 100644 --- a/apps/web/lib/stores/preferences-store.ts +++ b/apps/web/lib/stores/preferences-store.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -151,16 +151,9 @@ export const usePreferencesStore = preferencesStore; * * @see https://zustand.docs.pmnd.rs/integrations/persisting-store-data#how-can-i-check-if-my-store-has-been-hydrated */ -export const usePreferencesHydrated = () => { - const [hydrated, setHydrated] = useState(false); - - useEffect(() => { - const unsubscribe = preferencesStore.persist.onFinishHydration(() => setHydrated(true)); - - setHydrated(preferencesStore.persist.hasHydrated()); - - return () => unsubscribe(); - }, []); - - return hydrated; -}; +export const usePreferencesHydrated = () => + useSyncExternalStore( + (onStoreChange) => preferencesStore.persist.onFinishHydration(onStoreChange), + () => preferencesStore.persist.hasHydrated(), + () => false, + ); diff --git a/apps/web/lib/verification-instructions.test.ts b/apps/web/lib/verification-instructions.test.ts new file mode 100644 index 00000000..e2288d3f --- /dev/null +++ b/apps/web/lib/verification-instructions.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + DNS_VERIFICATION_PREFIX, + DNS_VERIFICATION_TTL, + DNS_VERIFICATION_TTL_LABEL, + HTML_FILE_CONTENT_PREFIX, + HTML_FILE_DIR, + META_TAG_NAME, +} from "@domainstack/constants"; + +import { + buildVerificationInstructions, + isValidVerificationMethod, +} from "./verification-instructions"; + +const domain = "example.com"; +const token = "abc123"; + +describe("buildVerificationInstructions", () => { + const instructions = buildVerificationInstructions(domain, token); + + it("builds DNS TXT instructions", () => { + expect(instructions.dns_txt).toEqual({ + title: "Recommended: Add a DNS record", + description: + "Add the following TXT record to your domain's DNS root. Changes may take a few minutes to propagate, but this is the most reliable method.", + hostname: domain, + recordType: "TXT", + value: `${DNS_VERIFICATION_PREFIX}${token}`, + suggestedTTL: DNS_VERIFICATION_TTL, + suggestedTTLLabel: DNS_VERIFICATION_TTL_LABEL, + }); + }); + + it("builds HTML file instructions", () => { + expect(instructions.html_file).toEqual({ + title: "Upload an HTML file", + description: + "Create a file at the following path with the contents shown below. The file must remain publicly accessible.", + hostname: domain, + fullPath: `${HTML_FILE_DIR}/${token}.html`, + filename: `${token}.html`, + fileContent: `${HTML_FILE_CONTENT_PREFIX}${token}`, + }); + }); + + it("builds meta tag instructions", () => { + expect(instructions.meta_tag).toEqual({ + title: "Add a meta tag", + description: "Add the following meta tag to the section of your homepage.", + metaTag: ``, + }); + }); +}); + +describe("isValidVerificationMethod", () => { + it("accepts known methods", () => { + expect(isValidVerificationMethod("dns_txt")).toBe(true); + expect(isValidVerificationMethod("html_file")).toBe(true); + expect(isValidVerificationMethod("meta_tag")).toBe(true); + }); + + it("rejects unknown values", () => { + expect(isValidVerificationMethod("nope")).toBe(false); + expect(isValidVerificationMethod("")).toBe(false); + expect(isValidVerificationMethod(null)).toBe(false); + expect(isValidVerificationMethod(1)).toBe(false); + }); +}); diff --git a/apps/web/mocks/next-image.test.ts b/apps/web/mocks/next-image.test.ts deleted file mode 100644 index 2b0fbabd..00000000 --- a/apps/web/mocks/next-image.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getImageProps } from "./next-image"; - -describe("next/image mock", () => { - it("returns image props from direct getImageProps options", () => { - const { props } = getImageProps({ - src: "/api/avatar/user-1", - alt: "User avatar", - width: 32, - height: 32, - priority: true, - unoptimized: true, - }); - - expect(props).toEqual({ - src: "/api/avatar/user-1", - alt: "User avatar", - width: 32, - height: 32, - }); - }); -}); diff --git a/apps/web/mocks/next-link.ts b/apps/web/mocks/next-link.ts new file mode 100644 index 00000000..8fc89ca1 --- /dev/null +++ b/apps/web/mocks/next-link.ts @@ -0,0 +1,27 @@ +import { type ComponentProps, createElement, type ReactNode } from "react"; + +type NextLinkMockProps = { + href: string | { pathname?: string }; + children?: ReactNode; + prefetch?: unknown; + scroll?: unknown; + replace?: unknown; + shallow?: unknown; + locale?: unknown; + passHref?: unknown; +} & Omit, "href">; + +export default function NextLinkMock({ + href, + children, + prefetch: _prefetch, + scroll: _scroll, + replace: _replace, + shallow: _shallow, + locale: _locale, + passHref: _passHref, + ...props +}: NextLinkMockProps) { + const resolvedHref = typeof href === "string" ? href : (href.pathname ?? "#"); + return createElement("a", { href: resolvedHref, ...props }, children); +} diff --git a/apps/web/mocks/trpc.ts b/apps/web/mocks/trpc.ts new file mode 100644 index 00000000..e7f4f961 --- /dev/null +++ b/apps/web/mocks/trpc.ts @@ -0,0 +1,528 @@ +import { vi } from "vitest"; + +import type { VerificationMethod } from "@domainstack/constants"; +import type { NotificationData, TrackedDomainWithDetails } from "@domainstack/types"; + +type AddDomainInput = { domain: string }; +type AddDomainResult = { + id: string; + domain: string; + verificationToken: string; + resumed: boolean; +}; + +type VerifyDomainInput = { trackedDomainId: string; method?: VerificationMethod }; +type VerifyDomainResult = { verified: boolean; method: VerificationMethod | null }; + +type GetVerificationDataInput = { trackedDomainId: string }; +type GetVerificationDataResult = { + domain: string; + verificationToken: string; + verificationMethod: VerificationMethod | null; +}; + +type TrackedDomainIdInput = { trackedDomainId: string }; +type BulkDomainIdsInput = { trackedDomainIds: string[] }; +type BulkMutationResult = { successCount: number; failedCount: number }; +type SetMutedInput = { trackedDomainId: string; muted: boolean }; +type SendVerificationInstructionsInput = { trackedDomainId: string; recipientEmail: string }; + +export const DOMAINS_QUERY_KEY = ["tracking", "listDomains"] as const; +export const SUBSCRIPTION_QUERY_KEY = ["user", "getSubscription"] as const; + +type ListDomainsInput = { includeArchived?: boolean } | undefined; + +function listDomainsQueryKey(input?: ListDomainsInput) { + return input === undefined ? DOMAINS_QUERY_KEY : ([...DOMAINS_QUERY_KEY, input] as const); +} + +let domainsState: TrackedDomainWithDetails[] = []; + +export function setDomainsState(items: TrackedDomainWithDetails[]) { + domainsState = items.map((item) => ({ ...item })); +} + +export function getDomainsState() { + return domainsState; +} + +function defaultListDomains(input?: ListDomainsInput): Promise { + const includeArchived = input?.includeArchived ?? false; + if (includeArchived) { + return Promise.resolve(domainsState); + } + return Promise.resolve(domainsState.filter((item) => item.archivedAt == null)); +} + +export const listDomainsQuery = + vi.fn<(input?: ListDomainsInput) => Promise>(defaultListDomains); + +export type SubscriptionData = { + plan: "free" | "pro"; + planQuota: number; + endsAt: Date | null; + activeCount: number; + archivedCount: number; + canAddMore: boolean; +}; + +const DEFAULT_SUBSCRIPTION: SubscriptionData = { + plan: "pro", + planQuota: 100, + endsAt: null, + activeCount: 0, + archivedCount: 0, + canAddMore: true, +}; + +let subscriptionState: SubscriptionData = { ...DEFAULT_SUBSCRIPTION }; + +export function setSubscriptionState(data: SubscriptionData) { + subscriptionState = { ...data }; +} + +export function getSubscriptionState() { + return subscriptionState; +} + +export const getSubscriptionQuery = vi.fn<() => Promise>( + async () => subscriptionState, +); + +export const addDomainMutation = vi.fn<(input: AddDomainInput) => Promise>( + async ({ domain }) => ({ + id: "domain-new", + domain, + verificationToken: "token-new", + resumed: false, + }), +); + +export const verifyDomainMutation = vi.fn< + (input: VerifyDomainInput) => Promise +>(async () => ({ + verified: true, + method: "dns_txt", +})); + +export const getVerificationDataQuery = vi.fn< + (input: GetVerificationDataInput) => Promise +>(async () => ({ + domain: "pending.dev", + verificationToken: "token-pending", + verificationMethod: "dns_txt", +})); + +export const removeDomainMutation = vi.fn<(input: TrackedDomainIdInput) => Promise<{ ok: true }>>( + async () => ({ ok: true }), +); + +export const archiveDomainMutation = vi.fn<(input: TrackedDomainIdInput) => Promise<{ ok: true }>>( + async () => ({ ok: true }), +); + +export const unarchiveDomainMutation = vi.fn< + (input: TrackedDomainIdInput) => Promise<{ ok: true }> +>(async () => ({ ok: true })); + +export const bulkArchiveDomainsMutation = vi.fn< + (input: BulkDomainIdsInput) => Promise +>(async ({ trackedDomainIds }) => ({ + successCount: trackedDomainIds.length, + failedCount: 0, +})); + +export const bulkRemoveDomainsMutation = vi.fn< + (input: BulkDomainIdsInput) => Promise +>(async ({ trackedDomainIds }) => ({ + successCount: trackedDomainIds.length, + failedCount: 0, +})); + +export const setDomainMutedMutation = vi.fn<(input: SetMutedInput) => Promise<{ ok: true }>>( + async () => ({ ok: true }), +); + +export const sendVerificationInstructionsMutation = vi.fn< + (input: SendVerificationInstructionsInput) => Promise<{ sent: boolean }> +>(async () => ({ sent: true })); + +export const CALENDAR_FEED_QUERY_KEY = ["user", "getCalendarFeed"] as const; +export const CALENDAR_FEED_URL = "https://cal.example.test/feed/token.ics"; +export const CALENDAR_FEED_ROTATED_URL = "https://cal.example.test/feed/rotated.ics"; + +export type CalendarFeedData = + | { enabled: false } + | { enabled: true; feedUrl: string; lastAccessedAt: Date | null } + | { enabled: false; feedUrl: string; lastAccessedAt: Date | null }; + +let calendarFeedState: CalendarFeedData = { enabled: false }; + +export function setCalendarFeedState(data: CalendarFeedData) { + calendarFeedState = data; +} + +export function getCalendarFeedState() { + return calendarFeedState; +} + +export const getCalendarFeedQuery = vi.fn<() => Promise>( + async () => calendarFeedState, +); + +export const enableCalendarFeedMutation = vi.fn< + () => Promise<{ feedUrl: string; createdAt: Date }> +>(async () => { + const feedUrl = CALENDAR_FEED_URL; + calendarFeedState = { enabled: true, feedUrl, lastAccessedAt: null }; + return { feedUrl, createdAt: new Date() }; +}); + +export const disableCalendarFeedMutation = vi.fn<() => Promise<{ success: true }>>(async () => { + if ("feedUrl" in calendarFeedState) { + calendarFeedState = { ...calendarFeedState, enabled: false }; + } else { + calendarFeedState = { enabled: false }; + } + return { success: true }; +}); + +export const rotateCalendarFeedTokenMutation = vi.fn< + () => Promise<{ feedUrl: string; rotatedAt: Date }> +>(async () => { + const feedUrl = CALENDAR_FEED_ROTATED_URL; + calendarFeedState = { + enabled: true, + feedUrl, + lastAccessedAt: "lastAccessedAt" in calendarFeedState ? calendarFeedState.lastAccessedAt : null, + }; + return { feedUrl, rotatedAt: new Date() }; +}); + +export const deleteCalendarFeedMutation = vi.fn<() => Promise<{ success: true }>>(async () => { + calendarFeedState = { enabled: false }; + return { success: true }; +}); + +export const NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY = ["notifications", "unreadCount"] as const; +export const NOTIFICATIONS_PAGE_SIZE = 20; + +type NotificationFilter = "unread" | "read" | "all"; +type NotificationsListInput = { + limit?: number; + cursor?: string; + filter: NotificationFilter; +}; +type NotificationsListResult = { items: NotificationData[]; nextCursor?: string }; + +export function notificationsListQueryKey( + filter: NotificationFilter, + limit = NOTIFICATIONS_PAGE_SIZE, +) { + return ["notifications", "list", { limit, filter }] as const; +} + +let notificationsState: NotificationData[] = []; + +export function setNotificationsState(items: NotificationData[]) { + notificationsState = items.map((item) => ({ ...item })); +} + +export function getNotificationsState() { + return notificationsState; +} + +function markNotificationRead(item: NotificationData, now: Date): NotificationData { + return item.readAt ? item : Object.assign({}, item, { readAt: now }); +} + +function filteredNotifications(filter: NotificationFilter) { + if (filter === "unread") { + return notificationsState.filter((item) => item.readAt === null); + } + if (filter === "read") { + return notificationsState.filter((item) => item.readAt !== null); + } + return notificationsState; +} + +async function defaultListNotifications( + input: NotificationsListInput, +): Promise { + const limit = input.limit ?? NOTIFICATIONS_PAGE_SIZE; + const items = filteredNotifications(input.filter); + let start = 0; + if (input.cursor) { + const cursorIndex = items.findIndex((item) => item.id === input.cursor); + start = cursorIndex >= 0 ? cursorIndex + 1 : 0; + } + const page = items.slice(start, start + limit + 1); + let nextCursor: string | undefined; + if (page.length > limit) { + nextCursor = page.pop()?.id; + } + return { items: page, nextCursor }; +} + +export const listNotificationsQuery = + vi.fn<(input: NotificationsListInput) => Promise>( + defaultListNotifications, + ); + +export const unreadCountQuery = vi.fn<() => Promise>( + async () => filteredNotifications("unread").length, +); + +export const markReadMutation = vi.fn<(input: { id: string }) => Promise<{ success: true }>>( + async ({ id }) => { + const now = new Date(); + notificationsState = notificationsState.map((item) => + item.id === id ? markNotificationRead(item, now) : item, + ); + return { success: true }; + }, +); + +export const markAllReadMutation = vi.fn<() => Promise<{ count: number }>>(async () => { + const now = new Date(); + const unreadCount = filteredNotifications("unread").length; + notificationsState = notificationsState.map((item) => markNotificationRead(item, now)); + return { count: unreadCount }; +}); + +export function resetTrpcMocks() { + domainsState = []; + listDomainsQuery.mockReset(); + listDomainsQuery.mockImplementation(defaultListDomains); + + subscriptionState = { ...DEFAULT_SUBSCRIPTION }; + getSubscriptionQuery.mockReset(); + getSubscriptionQuery.mockImplementation(async () => subscriptionState); + + addDomainMutation.mockReset(); + addDomainMutation.mockImplementation(async ({ domain }) => ({ + id: "domain-new", + domain, + verificationToken: "token-new", + resumed: false, + })); + + verifyDomainMutation.mockReset(); + verifyDomainMutation.mockImplementation(async () => ({ + verified: true, + method: "dns_txt", + })); + + getVerificationDataQuery.mockReset(); + getVerificationDataQuery.mockImplementation(async () => ({ + domain: "pending.dev", + verificationToken: "token-pending", + verificationMethod: "dns_txt", + })); + + removeDomainMutation.mockReset(); + removeDomainMutation.mockImplementation(async () => ({ ok: true })); + + archiveDomainMutation.mockReset(); + archiveDomainMutation.mockImplementation(async () => ({ ok: true })); + + unarchiveDomainMutation.mockReset(); + unarchiveDomainMutation.mockImplementation(async () => ({ ok: true })); + + bulkArchiveDomainsMutation.mockReset(); + bulkArchiveDomainsMutation.mockImplementation(async ({ trackedDomainIds }) => ({ + successCount: trackedDomainIds.length, + failedCount: 0, + })); + + bulkRemoveDomainsMutation.mockReset(); + bulkRemoveDomainsMutation.mockImplementation(async ({ trackedDomainIds }) => ({ + successCount: trackedDomainIds.length, + failedCount: 0, + })); + + setDomainMutedMutation.mockReset(); + setDomainMutedMutation.mockImplementation(async () => ({ ok: true })); + + sendVerificationInstructionsMutation.mockReset(); + sendVerificationInstructionsMutation.mockImplementation(async () => ({ sent: true })); + + calendarFeedState = { enabled: false }; + getCalendarFeedQuery.mockReset(); + getCalendarFeedQuery.mockImplementation(async () => calendarFeedState); + + enableCalendarFeedMutation.mockReset(); + enableCalendarFeedMutation.mockImplementation(async () => { + const feedUrl = CALENDAR_FEED_URL; + calendarFeedState = { enabled: true, feedUrl, lastAccessedAt: null }; + return { feedUrl, createdAt: new Date() }; + }); + + disableCalendarFeedMutation.mockReset(); + disableCalendarFeedMutation.mockImplementation(async () => { + if ("feedUrl" in calendarFeedState) { + calendarFeedState = { ...calendarFeedState, enabled: false }; + } else { + calendarFeedState = { enabled: false }; + } + return { success: true }; + }); + + rotateCalendarFeedTokenMutation.mockReset(); + rotateCalendarFeedTokenMutation.mockImplementation(async () => { + const feedUrl = CALENDAR_FEED_ROTATED_URL; + calendarFeedState = { + enabled: true, + feedUrl, + lastAccessedAt: + "lastAccessedAt" in calendarFeedState ? calendarFeedState.lastAccessedAt : null, + }; + return { feedUrl, rotatedAt: new Date() }; + }); + + deleteCalendarFeedMutation.mockReset(); + deleteCalendarFeedMutation.mockImplementation(async () => { + calendarFeedState = { enabled: false }; + return { success: true }; + }); + + notificationsState = []; + listNotificationsQuery.mockReset(); + listNotificationsQuery.mockImplementation(defaultListNotifications); + + unreadCountQuery.mockReset(); + unreadCountQuery.mockImplementation(async () => filteredNotifications("unread").length); + + markReadMutation.mockReset(); + markReadMutation.mockImplementation(async ({ id }) => { + const now = new Date(); + notificationsState = notificationsState.map((item) => + item.id === id ? markNotificationRead(item, now) : item, + ); + return { success: true }; + }); + + markAllReadMutation.mockReset(); + markAllReadMutation.mockImplementation(async () => { + const now = new Date(); + const unreadCount = filteredNotifications("unread").length; + notificationsState = notificationsState.map((item) => markNotificationRead(item, now)); + return { count: unreadCount }; + }); +} + +export function useTRPC() { + return { + tracking: { + addDomain: { + mutationOptions: () => ({ mutationFn: addDomainMutation }), + }, + verifyDomain: { + mutationOptions: () => ({ mutationFn: verifyDomainMutation }), + }, + getVerificationData: { + queryOptions: (input: GetVerificationDataInput) => ({ + queryKey: ["tracking", "getVerificationData", input] as const, + queryFn: () => getVerificationDataQuery(input), + }), + }, + listDomains: { + queryKey: listDomainsQueryKey, + queryOptions: (input?: ListDomainsInput) => ({ + queryKey: listDomainsQueryKey(input), + queryFn: () => listDomainsQuery(input), + }), + }, + removeDomain: { + mutationOptions: () => ({ mutationFn: removeDomainMutation }), + }, + archiveDomain: { + mutationOptions: () => ({ mutationFn: archiveDomainMutation }), + }, + unarchiveDomain: { + mutationOptions: () => ({ mutationFn: unarchiveDomainMutation }), + }, + bulkArchiveDomains: { + mutationOptions: () => ({ mutationFn: bulkArchiveDomainsMutation }), + }, + bulkRemoveDomains: { + mutationOptions: () => ({ mutationFn: bulkRemoveDomainsMutation }), + }, + sendVerificationInstructions: { + mutationOptions: () => ({ + mutationFn: sendVerificationInstructionsMutation, + }), + }, + }, + user: { + getSubscription: { + queryKey: () => SUBSCRIPTION_QUERY_KEY, + queryOptions: () => ({ + queryKey: SUBSCRIPTION_QUERY_KEY, + queryFn: () => getSubscriptionQuery(), + }), + }, + setDomainMuted: { + mutationOptions: () => ({ mutationFn: setDomainMutedMutation }), + }, + getCalendarFeed: { + queryKey: () => CALENDAR_FEED_QUERY_KEY, + queryOptions: () => ({ + queryKey: CALENDAR_FEED_QUERY_KEY, + queryFn: () => getCalendarFeedQuery(), + }), + }, + enableCalendarFeed: { + mutationOptions: () => ({ mutationFn: enableCalendarFeedMutation }), + }, + disableCalendarFeed: { + mutationOptions: () => ({ mutationFn: disableCalendarFeedMutation }), + }, + rotateCalendarFeedToken: { + mutationOptions: () => ({ mutationFn: rotateCalendarFeedTokenMutation }), + }, + deleteCalendarFeed: { + mutationOptions: () => ({ mutationFn: deleteCalendarFeedMutation }), + }, + }, + notifications: { + list: { + infiniteQueryOptions: ( + input: NotificationsListInput, + opts?: { + getNextPageParam?: (lastPage: NotificationsListResult) => string | undefined; + refetchOnWindowFocus?: boolean; + staleTime?: number; + enabled?: boolean; + }, + ) => ({ + queryKey: notificationsListQueryKey(input.filter, input.limit ?? NOTIFICATIONS_PAGE_SIZE), + queryFn: ({ pageParam }: { pageParam?: unknown }) => + listNotificationsQuery({ + ...input, + cursor: typeof pageParam === "string" ? pageParam : undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: + opts?.getNextPageParam ?? ((lastPage: NotificationsListResult) => lastPage.nextCursor), + refetchOnWindowFocus: opts?.refetchOnWindowFocus, + staleTime: opts?.staleTime, + enabled: opts?.enabled, + }), + }, + unreadCount: { + queryKey: () => NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY, + queryOptions: () => ({ + queryKey: NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY, + queryFn: () => unreadCountQuery(), + }), + }, + markRead: { + mutationOptions: () => ({ mutationFn: markReadMutation }), + }, + markAllRead: { + mutationOptions: () => ({ mutationFn: markAllReadMutation }), + }, + }, + }; +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7..ce4e94a6 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 3c6a097e..ff1633f1 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -13,11 +13,15 @@ let nextConfig: NextConfig = { }, productionBrowserSourceMaps: true, serverExternalPackages: [ + "@opentelemetry/api-logs", + "@opentelemetry/exporter-logs-otlp-http", + "@opentelemetry/resources", + "@opentelemetry/sdk-logs", // https://github.com/resend/react-email/issues/2426 "prettier", ], outputFileTracingIncludes: { - "/.well-known/workflow/v1/step": ["../../node_modules/@sparticuz/chromium/bin/**"], + "/.well-known/workflow/v1/flow": ["../../node_modules/@sparticuz/chromium/bin/**"], }, logging: { incomingRequests: { @@ -45,6 +49,10 @@ let nextConfig: NextConfig = { source: "/_proxy/ingest/static/:path*", destination: "https://us-assets.i.posthog.com/static/:path*", }, + { + source: "/_proxy/ingest/array/:path*", + destination: "https://us-assets.i.posthog.com/array/:path*", + }, { source: "/_proxy/ingest/:path*", destination: "https://us.i.posthog.com/:path*", diff --git a/apps/web/package.json b/apps/web/package.json index 647e13e2..b5f90c5c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,17 +14,15 @@ "fmt": "oxfmt --config ../../.oxfmtrc.json", "fmt:check": "oxfmt --check --config ../../.oxfmtrc.json", "test": "vitest run", - "test:coverage": "vitest run --coverage", - "workflow": "workflow inspect runs --web" + "test:coverage": "vitest run --coverage" }, "dependencies": { - "@ai-sdk/gateway": "^3.0.104", - "@ai-sdk/openai": "^3.0.53", - "@ai-sdk/provider": "^3.0.8", - "@ai-sdk/react": "^3.0.170", + "@ai-sdk/gateway": "^4.0.62", + "@ai-sdk/openai": "^4.0.46", + "@ai-sdk/react": "^4.0.80", + "@ai-sdk/workflow": "^2.0.7", "@bprogress/next": "^3.2.12", - "@browser-ai/core": "^2.1.12", - "@domainstack/analytics": "workspace:*", + "@browser-ai/core": "^3.0.2", "@domainstack/api": "workspace:*", "@domainstack/auth": "workspace:*", "@domainstack/constants": "workspace:*", @@ -40,74 +38,69 @@ "@domainstack/types": "workspace:*", "@domainstack/ui": "workspace:*", "@domainstack/utils": "workspace:*", - "@icons-pack/react-simple-icons": "^13.13.0", - "@modelcontextprotocol/sdk": "1.26.0", - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/instrumentation-pino": "^0.61.0", - "@posthog/ai": "^7.16.10", - "@posthog/nextjs-config": "^1.9.11", + "@icons-pack/react-simple-icons": "^13.15.1", + "@modelcontextprotocol/server": "^2.0.0", + "@posthog/nextjs-config": "^1.9.70", "@tabler/icons-react": "catalog:", - "@tanstack/react-query": "^5.100.5", - "@tanstack/react-query-devtools": "^5.100.5", - "@tanstack/react-query-next-experimental": "^5.100.5", - "@tanstack/react-table": "^8.21.3", - "@trpc/client": "^11.16.0", - "@trpc/server": "^11.16.0", - "@trpc/tanstack-react-query": "^11.16.0", - "@vercel/analytics": "^2.0.1", - "@vercel/functions": "^3.4.4", - "@vercel/otel": "^2.1.2", - "@workflow/ai": "4.1.2", - "ai": "^6.0.168", - "date-fns": "^4.1.0", - "geist": "^1.7.0", - "jotai": "^2.19.1", - "lru-cache": "^11.3.5", - "maplibre-gl": "^5.24.0", - "mcp-handler": "^1.1.0", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-devtools": "^5.101.4", + "@tanstack/react-query-next-experimental": "^5.101.4", + "@tanstack/react-table": "^9.1.2", + "@trpc/client": "^11.18.0", + "@trpc/server": "^11.18.0", + "@trpc/tanstack-react-query": "^11.18.0", + "@vercel/functions": "^3.9.5", + "ai": "^7.0.77", + "date-fns": "^4.4.0", + "geist": "^1.7.2", + "jotai": "^2.20.2", + "lru-cache": "^11.5.2", + "maplibre-gl": "^6.5.0", + "mcp-handler": "^2.1.1", "motion": "catalog:", "ms": "3.0.0-canary.202508261828", - "next": "16.2.6", + "next": "16.3.2", "next-themes": "^0.4.6", - "nuqs": "^2.8.9", - "posthog-js": "^1.372.1", - "react": "19.2.6", - "react-dom": "19.2.6", - "react-error-boundary": "^6.1.1", - "react-hotkeys-hook": "^5.2.4", + "nuqs": "^2.10.0", + "posthog-js": "^1.418.10", + "posthog-node": "^5.50.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-error-boundary": "^6.1.3", + "react-hotkeys-hook": "^5.3.3", "server-only": "0.0.1", - "sonner": "catalog:", "streamdown": "^2.5.0", "superjson": "^2.2.6", - "ts-ics": "^2.4.4", - "use-local-storage-state": "^19.5.0", - "use-stick-to-bottom": "^1.1.3", - "workflow": "4.2.4", - "ws": "^8.20.0", + "ts-ics": "^2.4.6", + "use-local-storage-state": "^20.0.0", + "use-stick-to-bottom": "^1.1.6", + "web-haptics": "^0.0.6", + "workflow": "5.0.0-beta.43", + "ws": "^8.21.3", "zod": "catalog:", - "zustand": "^5.0.12" + "zustand": "^5.0.15" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", - "@tailwindcss/postcss": "^4.2.4", - "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/postcss": "^4.3.3", + "@tailwindcss/typography": "^0.5.20", "@testing-library/dom": "10.4.1", - "@testing-library/jest-dom": "6.9.1", + "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.2", - "@testing-library/user-event": "14.6.1", + "@testing-library/user-event": "14.6.6", "@types/node": "catalog:", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", "@types/ws": "^8.18.1", - "@vitejs/plugin-react": "^6.0.1", - "@vitest/browser": "^4.1.5", - "@vitest/browser-playwright": "^4.1.5", - "@vitest/coverage-v8": "^4.1.5", + "@vitejs/plugin-react": "^6.1.0", + "@vitest/browser": "^4.1.11", + "@vitest/browser-playwright": "^4.1.11", + "@vitest/coverage-v8": "^4.1.11", "babel-plugin-react-compiler": "1.0.0", "bufferutil": "^4.1.0", - "msw": "^2.13.6", - "playwright": "^1.59.1", - "tailwindcss": "^4.2.4", + "msw": "^2.15.0", + "playwright": "^1.62.1", + "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", "vitest": "catalog:", "vitest-browser-react": "^2.2.0" diff --git a/apps/web/server/routers/domain.ts b/apps/web/server/routers/domain.ts index e2435da0..aea4bccc 100644 --- a/apps/web/server/routers/domain.ts +++ b/apps/web/server/routers/domain.ts @@ -19,7 +19,7 @@ import { fetchSeo, } from "@domainstack/server"; -const logger = createLogger({ source: "domain-router" }); +const logger = createLogger({ source: "routers/domain" }); const DomainInputSchema = z.object({ domain: z.string().min(1) }).transform(({ domain }) => { const registrable = toRegistrableDomain(domain); diff --git a/apps/web/server/routers/provider.ts b/apps/web/server/routers/provider.ts index 8f7085e2..422cc377 100644 --- a/apps/web/server/routers/provider.ts +++ b/apps/web/server/routers/provider.ts @@ -5,7 +5,7 @@ import { getProviderById, getProviderLogo } from "@domainstack/db/queries"; import { createLogger } from "@domainstack/logger"; import { fetchProviderLogo } from "@domainstack/server"; -const logger = createLogger({ source: "provider-router" }); +const logger = createLogger({ source: "routers/provider" }); export const providerRouter = createTRPCRouter({ /** diff --git a/apps/web/server/routers/tracking.ts b/apps/web/server/routers/tracking.ts index f99a1c02..5c2ef96d 100644 --- a/apps/web/server/routers/tracking.ts +++ b/apps/web/server/routers/tracking.ts @@ -2,9 +2,9 @@ import { TRPCError } from "@trpc/server"; import { start } from "workflow/api"; import { z } from "zod"; +import { analytics } from "@/lib/analytics/server"; import { autoVerifyWorkflow } from "@/workflows/auto-verify"; import { initializeSnapshotWorkflow } from "@/workflows/initialize-snapshot"; -import { analytics } from "@domainstack/analytics/server"; import { VERIFICATION_METHODS } from "@domainstack/constants"; import { archiveTrackedDomain, @@ -121,6 +121,8 @@ export const trackingRouter = createTRPCRouter({ }); } + analytics.track("domain_added", { domain, resumed: true }, ctx.user.id); + // If unverified, return the existing record so user can resume verification return { id: existing.id, @@ -156,6 +158,8 @@ export const trackingRouter = createTRPCRouter({ // "already_exists" - race condition where another request created it first const raceExisting = await findTrackedDomain(ctx.user.id, domainRecord.id); if (raceExisting) { + analytics.track("domain_added", { domain, resumed: true }, ctx.user.id); + return { id: raceExisting.id, domain, @@ -261,23 +265,19 @@ export const trackingRouter = createTRPCRouter({ }, `workflow failed: ${errorMessage}`, ); - analytics.track( - "workflow_failed", - { - workflow: "initialize-snapshot-trigger", - classification: "fatal", - error: errorMessage, - trackedDomainId: updated.id, - domainId: updated.domainId, - trigger: "manual_verification", - }, - "system", - ); }); + analytics.track( + "domain_verification_succeeded", + { method: result.data.method }, + ctx.user.id, + ); + return { verified: true, method: result.data.method }; } + analytics.track("domain_verification_failed", { reason: "not_verified" }, ctx.user.id); + return { verified: false, method: null, diff --git a/apps/web/server/routers/user.ts b/apps/web/server/routers/user.ts index 6b8ad60e..ac7170c9 100644 --- a/apps/web/server/routers/user.ts +++ b/apps/web/server/routers/user.ts @@ -1,8 +1,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { analytics } from "@/lib/analytics/server"; import { createTRPCRouter, protectedProcedure } from "@/trpc/init"; -import { analytics } from "@domainstack/analytics/server"; import { countTrackedDomainsByStatus, deleteCalendarFeed, diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 353984ec..097ae995 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -9,7 +9,10 @@ "app/api/cron/**/*.ts": { "maxDuration": 800 }, - "app/.well-known/workflow/v1/step/route.js": { + "app/api/chat/**/*.ts": { + "supportsCancellation": true + }, + "app/.well-known/workflow/v1/flow/route.js": { "maxDuration": 800 } }, diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 01e6863f..487affdd 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -9,8 +9,13 @@ export default defineConfig({ resolve: { alias: { "next/image": fileURLToPath(new URL("./mocks/next-image.ts", import.meta.url)), + "next/link": fileURLToPath(new URL("./mocks/next-link.ts", import.meta.url)), }, tsconfigPaths: true, + dedupe: ["react", "react-dom", "nuqs"], + }, + optimizeDeps: { + include: ["nuqs", "nuqs/adapters/testing"], }, define: { "process.env.NEXT_PUBLIC_BASE_URL": JSON.stringify("https://test.domainstack.io"), @@ -57,6 +62,11 @@ export default defineConfig({ }, }, setupFiles: ["./vitest.setup.browser.ts"], + server: { + deps: { + inline: ["nuqs"], + }, + }, }, }, ], diff --git a/apps/web/vitest.setup.browser.ts b/apps/web/vitest.setup.browser.ts index 62c543b1..3e6b0888 100644 --- a/apps/web/vitest.setup.browser.ts +++ b/apps/web/vitest.setup.browser.ts @@ -13,14 +13,20 @@ globalThis.fetch = vi.fn(() => { throw new Error("Network requests are not allowed in tests. Please mock fetch."); }); -vi.mock("@domainstack/analytics/client", () => ({ +vi.mock("@/lib/analytics/client", () => ({ analytics: { track: vi.fn<(...args: unknown[]) => void>(), trackException: vi.fn<(...args: unknown[]) => void>(), + identify: vi.fn<(...args: unknown[]) => void>(), + reset: vi.fn<(...args: unknown[]) => void>(), + setPersonProperties: vi.fn<(...args: unknown[]) => void>(), }, useAnalytics: () => ({ track: vi.fn<(...args: unknown[]) => void>(), trackException: vi.fn<(...args: unknown[]) => void>(), + identify: vi.fn<(...args: unknown[]) => void>(), + reset: vi.fn<(...args: unknown[]) => void>(), + setPersonProperties: vi.fn<(...args: unknown[]) => void>(), }), })); diff --git a/apps/web/vitest.setup.node.ts b/apps/web/vitest.setup.node.ts index c32759fa..9c1fe3ba 100644 --- a/apps/web/vitest.setup.node.ts +++ b/apps/web/vitest.setup.node.ts @@ -8,11 +8,12 @@ afterEach(() => server.resetHandlers()); afterAll(() => server.close()); // Global mocks for analytics to avoid network/log noise in tests -vi.mock("@domainstack/analytics/server", () => ({ +vi.mock("@/lib/analytics/server", () => ({ analytics: { track: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), trackException: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), }, + captureException: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), })); // Mock logger to avoid noise in tests @@ -37,6 +38,8 @@ vi.mock("@domainstack/logger", () => ({ createLogger: vi.fn<(...args: unknown[]) => ReturnType>(() => createMockLogger(), ), + flushLogs: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), + setFlushScheduler: vi.fn<(...args: unknown[]) => void>(), })); // Mock Next.js after() to execute callbacks immediately in tests diff --git a/apps/web/workflows/chat/gateway.ts b/apps/web/workflows/chat/gateway.ts index dafe504e..ad9deec7 100644 --- a/apps/web/workflows/chat/gateway.ts +++ b/apps/web/workflows/chat/gateway.ts @@ -1,14 +1,16 @@ -import type { CompatibleLanguageModel } from "@workflow/ai/agent"; -import { getStepMetadata, getWorkflowMetadata } from "workflow"; +import type { LanguageModel } from "ai"; /** - * Create the AI model instance and wrap with PostHog tracing. - * Runs as a step to keep Node.js modules out of workflow sandbox. + * Resolve the AI Gateway model ID from Edge Config. + * Runs as a step to keep Node.js modules out of the workflow sandbox. + * + * WorkflowAgent serializes `model` across step boundaries, so this returns a + * gateway LanguageModel instance (plain provider config). Correlation IDs go + * on `runtimeContext` / telemetry. */ -export async function getModelStep(): Promise { +export async function getModelStep(): Promise { "use step"; - // Create the AI model instance const { createGateway } = await import("@ai-sdk/gateway"); const gateway = createGateway({ headers: { @@ -18,31 +20,8 @@ export async function getModelStep(): Promise { }, }); - // Get the AI model ID from Edge Config, fallback to constants string const { getAiChatModel } = await import("@domainstack/server/edge-config"); const { DEFAULT_CHAT_MODEL } = await import("@domainstack/constants"); const modelId = await getAiChatModel(); - const model = gateway(modelId || DEFAULT_CHAT_MODEL); - - // Wrap with PostHog tracing if client available - const { getServerPosthog } = await import("@domainstack/analytics/server"); - const phClient = getServerPosthog(); - if (phClient) { - // Get workflow run ID and step ID for PostHog trace correlation - const { workflowRunId } = getWorkflowMetadata(); - const { stepId } = getStepMetadata(); - - const { withTracing } = await import("@posthog/ai"); - return withTracing(model, phClient, { - // TODO: it seems we can't choose what arguments DurableAgent passes in (if any), will revisit this later - // posthogDistinctId: userId || ip || undefined, - posthogTraceId: workflowRunId, - posthogProperties: { - workflow_run_id: workflowRunId, - step_id: stepId, - }, - }) as CompatibleLanguageModel; - } - - return model as CompatibleLanguageModel; + return gateway(modelId || DEFAULT_CHAT_MODEL); } diff --git a/apps/web/workflows/chat/logging.ts b/apps/web/workflows/chat/logging.ts deleted file mode 100644 index 4332c361..00000000 --- a/apps/web/workflows/chat/logging.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Logging utilities for the chat workflow. - * - * Provides serialization helpers, error extraction, and workflow step functions - * for structured logging of chat events. - */ - -import { InvalidToolInputError, NoSuchToolError, ToolCallRepairError } from "ai"; -import { getStepMetadata, getWorkflowMetadata } from "workflow"; - -// ============================================================================ -// Types -// ============================================================================ - -export type SerializedError = { - name?: string; - message?: string; - stack?: string; - cause?: SerializedError | string | null; -}; - -export type ToolErrorDetails = { - type: "no_such_tool" | "invalid_tool_input" | "tool_call_repair" | "tool_execution"; - toolName?: string; - toolInput?: string; - availableTools?: string[]; - originalError?: { - type: "no_such_tool" | "invalid_tool_input"; - toolName?: string; - toolInput?: string; - availableTools?: string[]; - }; -}; - -export type StuckToolPart = { - toolType: string; - toolCallId?: string; - input?: unknown; - errorText?: string; -}; - -export type ToolStepStats = { - totalSteps: number; - toolCalls: number; - toolResults: number; -}; - -export type ToolCallSummary = { - toolName?: string; - toolCallId?: string; -}; - -export type ToolResultSummary = { - toolName?: string; - toolCallId?: string; - isError?: boolean; -}; - -// ============================================================================ -// Serialization Helpers -// ============================================================================ - -export function serializeError(error: unknown): SerializedError { - if (error instanceof Error) { - const { name, message, stack, cause: rawCause } = error; - let cause: SerializedError | string | null | undefined; - if (rawCause instanceof Error) { - cause = serializeError(rawCause); - } else if (typeof rawCause === "string") { - cause = rawCause; - } else if (rawCause === null) { - cause = null; - } - return { name, message, stack, cause }; - } - return { message: String(error) }; -} - -export function getToolErrorDetails(error: unknown): ToolErrorDetails | undefined { - if (NoSuchToolError.isInstance(error)) { - return { - type: "no_such_tool", - toolName: error.toolName, - availableTools: error.availableTools, - }; - } - - if (InvalidToolInputError.isInstance(error)) { - return { - type: "invalid_tool_input", - toolName: error.toolName, - toolInput: error.toolInput, - }; - } - - if (ToolCallRepairError.isInstance(error)) { - const origErr = error.originalError; - return { - type: "tool_call_repair", - originalError: NoSuchToolError.isInstance(origErr) - ? { - type: "no_such_tool", - toolName: origErr.toolName, - availableTools: origErr.availableTools, - } - : { - type: "invalid_tool_input", - toolName: origErr.toolName, - toolInput: origErr.toolInput, - }, - }; - } - - return; -} - -export function summarizeToolCalls(toolCalls: unknown[]): ToolCallSummary[] { - return toolCalls.map((call) => { - if (!call || typeof call !== "object") { - return {}; - } - - const toolName = - "toolName" in call && typeof call.toolName === "string" - ? call.toolName - : "name" in call && typeof call.name === "string" - ? call.name - : undefined; - - const toolCallId = - "toolCallId" in call && typeof call.toolCallId === "string" - ? call.toolCallId - : "id" in call && typeof call.id === "string" - ? call.id - : undefined; - - return { toolName, toolCallId }; - }); -} - -export function summarizeToolResults(toolResults: unknown[]): ToolResultSummary[] { - return toolResults.map((result) => { - if (!result || typeof result !== "object") { - return {}; - } - - const toolName = - "toolName" in result && typeof result.toolName === "string" - ? result.toolName - : "name" in result && typeof result.name === "string" - ? result.name - : undefined; - - const toolCallId = - "toolCallId" in result && typeof result.toolCallId === "string" - ? result.toolCallId - : "id" in result && typeof result.id === "string" - ? result.id - : undefined; - - const isError = - "isError" in result && typeof result.isError === "boolean" ? result.isError : undefined; - - return { toolName, toolCallId, isError }; - }); -} - -// ============================================================================ -// Workflow Step Functions -// ============================================================================ - -export async function logChatStreamErrorStep(payload: { - event: "chat_stream_error"; - domain?: string; - userId?: string | null; - error: SerializedError; - tool?: ToolErrorDetails; -}) { - "use step"; - const { createLogger } = await import("@domainstack/logger"); - const logger = createLogger({ source: "chat/workflow" }); - const { workflowRunId } = getWorkflowMetadata(); - const { stepId } = getStepMetadata(); - logger.error({ ...payload, workflowRunId, stepId }, "chat stream error"); -} - -export async function logChatStepFinishStep(payload: { - event: "chat_step_finish"; - domain?: string; - userId?: string | null; - finishReason?: string; - usage?: { - inputTokens?: number; - outputTokens?: number; - }; - toolCalls: ToolCallSummary[]; - toolResults: ToolResultSummary[]; -}) { - "use step"; - const { createLogger } = await import("@domainstack/logger"); - const logger = createLogger({ source: "chat/workflow" }); - const { workflowRunId } = getWorkflowMetadata(); - const { stepId } = getStepMetadata(); - logger.info({ ...payload, workflowRunId, stepId }, "chat step finished"); -} diff --git a/apps/web/workflows/chat/tools.ts b/apps/web/workflows/chat/tools.ts index ec47a09a..bc942316 100644 --- a/apps/web/workflows/chat/tools.ts +++ b/apps/web/workflows/chat/tools.ts @@ -1,246 +1,84 @@ /** * Domain lookup tools for the chat workflow. * - * Each tool wraps a durable step function. The Workflow SDK provides - * automatic retries for failed steps by default. - * - * Node.js modules (like tRPC, database) are imported INSIDE step functions + * Each tool runs the shared `domainLookupStep`. The Workflow SDK retries + * failed steps by default. Node.js modules are imported inside the step * to keep them out of the workflow sandbox. */ -import { tool } from "ai"; -import { cache } from "react"; +import { tool, type Tool } from "ai"; import { z } from "zod"; -/** - * Schema for domain tool inputs. - * Using .describe() to help LLMs understand the expected format. - * See: https://ai-sdk.dev/docs/ai-sdk-core/prompt-engineering#tool--structured-data-schemas - */ -const domainSchema = z.object({ - domain: z - .string() - .min(1, "Domain is required") - .describe( - "The root domain name to look up (e.g., 'example.com', 'example.org'). Must be a root domain, NOT a subdomain - WHOIS lookups don't work for subdomains like 'www.example.com' or 'api.example.com'. Do not include protocol (http/https).", - ), -}); +import { + createDomainToolsContext, + DOMAIN_TOOL_DEFS, + domainToolInputSchema, + getDomainToolErrorMessage, + getTrpcErrorCode, + type DomainToolInput, + type DomainToolProcedure, + type DomainToolResult, +} from "@/lib/chat/domain-tools"; -/** - * Check if an error is an expected network/domain error vs an unexpected bug. - * Expected errors: network issues, DNS failures, timeouts, rate limits, etc. - * Unexpected errors: import failures, type errors, programming bugs - */ -function isExpectedError(message: string): boolean { - const lowerMessage = message.toLowerCase(); - const expectedPatterns = [ - "timeout", - "timed out", - "rate limit", - "not found", - "enotfound", - "dns", - "certificate", - "ssl", - "tls", - "refused", - "unreachable", - "network", - "econnreset", - "econnrefused", - "socket", - "fetch failed", - ]; - return expectedPatterns.some((pattern) => lowerMessage.includes(pattern)); -} - -/** - * Format an error for tool response and log it. - */ -async function handleToolError(err: unknown, domain: string, toolName: string) { - // Import logger inside to keep Node.js modules out of workflow sandbox - const { createLogger } = await import("@domainstack/logger"); - const logger = createLogger({ source: "chat/tools" }); - - // Sanitize error messages to avoid leaking internal details - const rawMessage = err instanceof Error ? err.message : "Unknown error"; - const lowerMessage = rawMessage.toLowerCase(); - - // Log at appropriate level: warn for expected errors, error for unexpected - const isExpected = isExpectedError(rawMessage); - if (isExpected) { - logger.warn({ err, domain, tool: toolName }, "tool step failed (expected)"); - } else { - logger.error({ err, domain, tool: toolName }, "tool step failed (unexpected)"); - } - - let userMessage = "Unable to fetch data. Please try again."; - - // Map specific error patterns to user-friendly messages - if (lowerMessage.includes("timeout") || lowerMessage.includes("timed out")) { - userMessage = "Request timed out. The domain may be slow to respond."; - } else if (lowerMessage.includes("rate limit")) { - userMessage = "Rate limit exceeded. Please wait a moment and try again."; - } else if ( - lowerMessage.includes("not found") || - lowerMessage.includes("enotfound") || - lowerMessage.includes("dns") - ) { - userMessage = `Could not resolve ${domain}. The domain may not exist or DNS may be misconfigured.`; - } else if ( - lowerMessage.includes("certificate") || - lowerMessage.includes("ssl") || - lowerMessage.includes("tls") - ) { - userMessage = `Could not establish secure connection to ${domain}.`; - } else if (lowerMessage.includes("refused") || lowerMessage.includes("unreachable")) { - userMessage = `Could not connect to ${domain}. The server may be down.`; - } - - return { error: userMessage }; -} - -/** - * Context passed to tool steps. - * Must be serializable - no functions or complex objects. - */ export interface ToolContext { ip: string | null; } -// Step functions for each domain lookup -// tRPC caller is created INSIDE each step to avoid workflow sandbox issues +const toolContextSchema = z.object({ + ip: z.string().nullable(), +}); -async function getRegistrationStep(domain: string, ctx: ToolContext) { +type DomainToolSet = { + [Def in (typeof DOMAIN_TOOL_DEFS)[number] as Def["name"]]: Tool< + DomainToolInput, + DomainToolResult, + ToolContext + >; +}; + +async function domainLookupStep(procedure: DomainToolProcedure, domain: string, ctx: ToolContext) { "use step"; try { const { createCaller } = await import("@/server/routers/_app"); const trpc = createCaller({ req: undefined, ip: ctx.ip, session: null }); - const result = await trpc.domain.getRegistration({ domain }); + const result = await trpc.domain[procedure]({ domain }); + if (!result.success) { + return { error: result.error }; + } return result.data; } catch (err) { - return handleToolError(err, domain, "get_registration"); - } -} - -async function getDnsRecordsStep(domain: string, ctx: ToolContext) { - "use step"; - try { - const { createCaller } = await import("@/server/routers/_app"); - const trpc = createCaller({ req: undefined, ip: ctx.ip, session: null }); - const result = await trpc.domain.getDnsRecords({ domain }); - return result.data; - } catch (err) { - return handleToolError(err, domain, "get_dns_records"); - } -} - -async function getHostingStep(domain: string, ctx: ToolContext) { - "use step"; - try { - const { createCaller } = await import("@/server/routers/_app"); - const trpc = createCaller({ req: undefined, ip: ctx.ip, session: null }); - const result = await trpc.domain.getHosting({ domain }); - return result.data; - } catch (err) { - return handleToolError(err, domain, "get_hosting"); - } -} - -async function getCertificatesStep(domain: string, ctx: ToolContext) { - "use step"; - try { - const { createCaller } = await import("@/server/routers/_app"); - const trpc = createCaller({ req: undefined, ip: ctx.ip, session: null }); - const result = await trpc.domain.getCertificates({ domain }); - return result.data; - } catch (err) { - return handleToolError(err, domain, "get_certificates"); - } -} - -async function getHeadersStep(domain: string, ctx: ToolContext) { - "use step"; - try { - const { createCaller } = await import("@/server/routers/_app"); - const trpc = createCaller({ req: undefined, ip: ctx.ip, session: null }); - const result = await trpc.domain.getHeaders({ domain }); - return result.data; - } catch (err) { - return handleToolError(err, domain, "get_headers"); - } -} - -async function getSeoStep(domain: string, ctx: ToolContext) { - "use step"; - try { - const { createCaller } = await import("@/server/routers/_app"); - const trpc = createCaller({ req: undefined, ip: ctx.ip, session: null }); - const result = await trpc.domain.getSeo({ domain }); - return result.data; - } catch (err) { - return handleToolError(err, domain, "get_seo"); + const { createLogger } = await import("@domainstack/logger"); + const logger = createLogger({ source: "chat/tools" }); + // Domain lookups return `{ success: false }` instead of throwing. + // Throws here are tRPC validation/rate-limit errors, or unexpected bugs. + const trpcCode = getTrpcErrorCode(err); + if (trpcCode && trpcCode !== "INTERNAL_SERVER_ERROR") { + logger.warn({ err, domain, procedure, code: trpcCode }, "tool step failed (expected)"); + } else { + logger.error({ err, domain, procedure }, "tool step failed (unexpected)"); + } + return { error: getDomainToolErrorMessage(err) }; } } /** * Creates domain intelligence tools bound to a tool context. * Each tool executes as a durable workflow step with automatic retries. - * - * Tool configuration follows AI SDK best practices: - * - strict: true - ensures valid tool calls when supported by provider - * - Descriptive descriptions - help LLM understand when/how to use each tool - * - Schema with .describe() - helps LLM understand expected input format - * - * See: https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#strict-mode */ -export const createDomainToolset = cache(() => ({ - get_registration: tool({ - description: - "Get WHOIS/RDAP registration data for a domain including registrar, creation date, expiration date, nameservers, and registrant information. Use this tool when users ask about domain ownership, registration, expiry, or who owns a domain.", - inputSchema: domainSchema, +function makeDomainTool(def: (typeof DOMAIN_TOOL_DEFS)[number]) { + return tool({ + description: def.description, + inputSchema: domainToolInputSchema, + contextSchema: toolContextSchema, strict: true, - execute: async ({ domain }, { experimental_context }) => - getRegistrationStep(domain, experimental_context as ToolContext), - }), - get_dns_records: tool({ - description: - "Get DNS records for a domain including A, AAAA, CNAME, MX, TXT, NS, and SOA records. Use this tool when users ask about DNS configuration, IP addresses, mail servers, or nameservers.", - inputSchema: domainSchema, - strict: true, - execute: async ({ domain }, { experimental_context }) => - getDnsRecordsStep(domain, experimental_context as ToolContext), - }), - get_hosting: tool({ - description: - "Detect hosting, DNS, CDN, and email providers for a domain by analyzing DNS records and HTTP headers. Use this tool when users ask where a site is hosted, what CDN they use, or who provides their email.", - inputSchema: domainSchema, - strict: true, - execute: async ({ domain }, { experimental_context }) => - getHostingStep(domain, experimental_context as ToolContext), - }), - get_certificates: tool({ - description: - "Get SSL/TLS certificate information for a domain including issuer, validity dates, and certificate chain. Use this tool when users ask about HTTPS, SSL certificates, security, or certificate expiry.", - inputSchema: domainSchema, - strict: true, - execute: async ({ domain }, { experimental_context }) => - getCertificatesStep(domain, experimental_context as ToolContext), - }), - get_headers: tool({ - description: - "Get HTTP response headers for a domain including security headers, caching headers, and server information. Use this tool when users ask about security headers, server software, caching, or HTTP configuration.", - inputSchema: domainSchema, - strict: true, - execute: async ({ domain }, { experimental_context }) => - getHeadersStep(domain, experimental_context as ToolContext), - }), - get_seo: tool({ - description: - "Get SEO metadata for a domain including title, description, Open Graph tags, Twitter cards, and robots.txt rules. Use this tool when users ask about SEO, meta tags, social sharing, or how a site appears in search.", - inputSchema: domainSchema, - strict: true, - execute: async ({ domain }, { experimental_context }) => - getSeoStep(domain, experimental_context as ToolContext), - }), -})); + execute: async ({ domain }, { context }) => domainLookupStep(def.procedure, domain, context), + }); +} + +export function createDomainToolset(): DomainToolSet { + return Object.fromEntries( + DOMAIN_TOOL_DEFS.map((def) => [def.name, makeDomainTool(def)]), + ) as DomainToolSet; +} + +export { createDomainToolsContext }; diff --git a/apps/web/workflows/chat/workflow.ts b/apps/web/workflows/chat/workflow.ts index 8a4d0927..25f00947 100644 --- a/apps/web/workflows/chat/workflow.ts +++ b/apps/web/workflows/chat/workflow.ts @@ -1,5 +1,5 @@ /** - * Chat workflow using DurableAgent for domain intelligence queries. + * Chat workflow using WorkflowAgent for domain intelligence queries. * * Features: * - Durable tool execution with automatic retries @@ -12,23 +12,15 @@ import type { GatewayProviderOptions } from "@ai-sdk/gateway"; import { type OpenAIResponsesProviderOptions, openai } from "@ai-sdk/openai"; -import { DurableAgent } from "@workflow/ai/agent"; -import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai"; -import { getWritable } from "workflow"; +import { type ModelCallStreamPart, WorkflowAgent } from "@ai-sdk/workflow"; +import { convertToModelMessages, isStepCount, type Tool, type UIMessage } from "ai"; +import { getWorkflowMetadata, getWritable } from "workflow"; import { MAX_OUTPUT_TOKENS, MAX_TOOL_STEPS } from "@domainstack/constants"; import { getModelStep } from "./gateway"; -import { - getToolErrorDetails, - logChatStepFinishStep, - logChatStreamErrorStep, - serializeError, - summarizeToolCalls, - summarizeToolResults, -} from "./logging"; import { buildSystemPromptStep } from "./prompt"; -import { createDomainToolset } from "./tools"; +import { createDomainToolset, createDomainToolsContext } from "./tools"; export interface ChatWorkflowInput { messages: UIMessage[]; @@ -40,7 +32,7 @@ export interface ChatWorkflowInput { } /** - * Chat workflow that uses DurableAgent for streaming responses. + * Chat workflow that uses WorkflowAgent for streaming responses. * Single-turn pattern: client owns conversation history. */ export async function chatWorkflow(input: ChatWorkflowInput) { @@ -48,23 +40,21 @@ export async function chatWorkflow(input: ChatWorkflowInput) { const { messages, domain, ip, userId } = input; - // Convert UI messages to model messages const modelMessages = await convertToModelMessages(messages); - - // Compile system prompt const systemPrompt = await buildSystemPromptStep(domain); + const model = await getModelStep(); + const domainTools = createDomainToolset(); + const { workflowRunId } = getWorkflowMetadata(); - // Create agent with domain tools - // Per AI SDK best practices: use temperature: 0 for deterministic tool calls - const agent = new DurableAgent({ - model: getModelStep, + const agent = new WorkflowAgent({ + model, tools: { - ...createDomainToolset(), + ...domainTools, web_search: openai.tools.webSearch({ searchContextSize: "low", - }), + }) as Tool, }, - system: systemPrompt, + instructions: systemPrompt, // Temperature 0 ensures consistent tool calling behavior across models // See: https://ai-sdk.dev/docs/ai-sdk-core/prompt-engineering#temperature-settings temperature: 0, @@ -77,55 +67,26 @@ export async function chatWorkflow(input: ChatWorkflowInput) { reasoningSummary: "auto", } satisfies OpenAIResponsesProviderOptions, }, - experimental_telemetry: { - isEnabled: true, + telemetry: { functionId: "chatWorkflow", - metadata: { - domain, - userId, + includeRuntimeContext: { + userId: true, + ip: true, + domain: true, + workflowRunId: true, }, }, + runtimeContext: { userId, ip, domain, workflowRunId }, + toolsContext: createDomainToolsContext({ ip }), }); - // Stream response to workflow output - // Errors will propagate to the stream and trigger onError on the client - const writable = getWritable(); + const writable = getWritable(); const result = await agent.stream({ messages: modelMessages, writable, - maxSteps: MAX_TOOL_STEPS, + stopWhen: isStepCount(MAX_TOOL_STEPS), maxOutputTokens: MAX_OUTPUT_TOKENS, - collectUIMessages: true, - experimental_context: { - userId, - ip, - }, - onStepFinish: async (step) => { - const toolCalls = Array.isArray(step.toolCalls) ? step.toolCalls : []; - const toolResults = Array.isArray(step.toolResults) ? step.toolResults : []; - - await logChatStepFinishStep({ - event: "chat_step_finish", - domain, - userId, - finishReason: step.finishReason, - usage: step.usage, - toolCalls: summarizeToolCalls(toolCalls), - toolResults: summarizeToolResults(toolResults), - }); - }, - onError: async ({ error }) => { - const errorDetails = serializeError(error); - const toolDetails = getToolErrorDetails(error); - await logChatStreamErrorStep({ - event: "chat_stream_error", - domain, - userId, - error: errorDetails, - tool: toolDetails, - }); - }, }); - return result; + return { messages: result.messages }; } diff --git a/package.json b/package.json index a4cc8f02..02926841 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,14 @@ "name": "domainstack", "version": "0.0.0-beta.1", "private": true, - "license": "MIT", "homepage": "https://domainstack.io", - "repository": "jakejarvis/domainstack.io", + "license": "MIT", "author": { "name": "Jake Jarvis", "email": "jake@jarv.is", "url": "https://github.com/jakejarvis" }, + "repository": "jakejarvis/domainstack.io", "type": "module", "scripts": { "dev": "turbo run dev", @@ -29,14 +29,14 @@ "clean": "shx rm -rf .turbo \"apps/*/.turbo\" \"apps/*/node_modules\" \"apps/*/*.tsbuildinfo\" apps/web/.next \"packages/*/.turbo\" \"packages/*/node_modules\" \"packages/*/*.tsbuildinfo\" node_modules pnpm-lock.yaml" }, "devDependencies": { - "oxfmt": "^0.46.0", - "oxlint": "^1.61.0", + "oxfmt": "^0.64.0", + "oxlint": "^1.79.0", "shx": "^0.4.0", - "turbo": "^2.9.6", - "typescript": "6.0.3" + "turbo": "^2.10.11", + "typescript": "7.0.2" }, "engines": { "node": ">=24.x" }, - "packageManager": "pnpm@10.33.2" + "packageManager": "pnpm@11.23.0" } diff --git a/packages/analytics/.oxlintrc.json b/packages/analytics/.oxlintrc.json deleted file mode 100644 index d38bbcf5..00000000 --- a/packages/analytics/.oxlintrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "../../node_modules/oxlint/configuration_schema.json", - "extends": ["../../.oxlintrc.json"] -} diff --git a/packages/analytics/package.json b/packages/analytics/package.json deleted file mode 100644 index 31d19950..00000000 --- a/packages/analytics/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@domainstack/analytics", - "version": "0.0.0-beta.1", - "private": true, - "type": "module", - "sideEffects": false, - "exports": { - "./client": "./src/client.ts", - "./server": "./src/server.ts" - }, - "scripts": { - "check-types": "tsc --noEmit", - "lint": "oxlint", - "lint:fix": "oxlint --fix", - "fmt": "oxfmt --config ../../.oxfmtrc.json", - "fmt:check": "oxfmt --check --config ../../.oxfmtrc.json" - }, - "dependencies": { - "posthog-js": "^1.372.1", - "posthog-node": "^5.30.4", - "uuid": "^14.0.0" - }, - "devDependencies": { - "@domainstack/typescript-config": "workspace:*", - "@types/node": "catalog:", - "@types/react": "19.2.14" - }, - "peerDependencies": { - "next": ">=16", - "react": ">=18" - } -} diff --git a/packages/analytics/src/client.ts b/packages/analytics/src/client.ts deleted file mode 100644 index f3b3cdc0..00000000 --- a/packages/analytics/src/client.ts +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import posthogClient from "posthog-js"; -import { useMemo } from "react"; - -import type { IdentifyProperties, IdentifySetOnceProperties } from "./types"; - -/** - * Track identification state locally to avoid relying on PostHog's private API. - * This is set when identify() is called and cleared when reset() is called. - */ -let identifiedUserId: string | null = null; - -function track(event: string, properties?: Record) { - try { - posthogClient.capture(event, properties); - } catch { - // no-op - } -} - -function trackException(error: Error, properties?: Record) { - try { - posthogClient.captureException(error, properties); - } catch { - // no-op - } -} - -/** - * Identify a user with PostHog. - * Should be called when a user logs in or on session hydration. - * - * @param userId - The user's unique ID (from better-auth) - * @param properties - Properties to set/update on the user ($set) - * @param setOnceProperties - Properties to set only once ($set_once) - */ -function identify( - userId: string, - properties?: IdentifyProperties, - setOnceProperties?: IdentifySetOnceProperties, -) { - try { - posthogClient.identify(userId, properties, setOnceProperties); - identifiedUserId = userId; - } catch { - // no-op - } -} - -/** - * Reset the current user's identity. - * Should be called when a user logs out to prevent event crossover. - */ -function reset() { - try { - posthogClient.reset(); - identifiedUserId = null; - } catch { - // no-op - } -} - -/** - * Check if the current user has been identified. - * Use this to prevent duplicate identify calls. - */ -function isIdentified(): boolean { - return identifiedUserId !== null; -} - -/** - * Get the current user's distinct ID. - */ -function getDistinctId(): string | undefined { - try { - return posthogClient.get_distinct_id(); - } catch { - return; - } -} - -/** - * Analytics tracking utility for non-React contexts. - * Use this in hooks or other non-component code. - */ -export const analytics = { - track, - trackException, - identify, - reset, - isIdentified, - getDistinctId, -}; - -/** - * Analytics tracking hook for React components. - * Use this in components for tracking user interactions. - * - * @example - * ```tsx - * const analytics = useAnalytics(); - * analytics.track("button_clicked", { button: "export" }); - * analytics.trackException(error, { context: "export" }); - * analytics.identify(userId, { email, name }); - * analytics.reset(); // on logout - * ``` - */ -export function useAnalytics() { - return useMemo( - () => ({ - track, - trackException, - identify, - reset, - isIdentified, - getDistinctId, - }), - [], - ); -} diff --git a/packages/analytics/src/server.ts b/packages/analytics/src/server.ts deleted file mode 100644 index a9983327..00000000 --- a/packages/analytics/src/server.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { cookies } from "next/headers"; -import { after } from "next/server"; -import { PostHog } from "posthog-node"; -import { cache } from "react"; -import { v4 as uuidv4 } from "uuid"; - -import type { IdentifyProperties, IdentifySetOnceProperties } from "./types"; - -// PostHog clients maintain background flushers; keep a single shared instance -// per runtime to avoid reopening sockets for every event. We deliberately avoid -// calling client.shutdown() after each capture so the client stays usable. -let sharedClient: PostHog | null = null; - -export function getServerPosthog(): PostHog | null { - if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) { - return null; - } - - if (!sharedClient) { - sharedClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, { - host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com", - flushAt: 1, - flushInterval: 0, - }); - } - - return sharedClient; -} - -const getDistinctId = cache(async (): Promise => { - let distinctId: string | undefined; - - try { - const cookieStore = await cookies(); - const phCookie = cookieStore.get(`ph_${process.env.NEXT_PUBLIC_POSTHOG_KEY}_posthog`); - if (phCookie?.value) { - try { - const parsed = JSON.parse(decodeURIComponent(phCookie.value)); - if (parsed && typeof parsed.distinct_id === "string") { - distinctId = parsed.distinct_id; - } - } catch {} - } - } catch (err) { - // cookies() throws when called outside request scope (e.g., during prerender) - // Log unexpected errors that don't match the known pattern - const isExpectedError = err instanceof Error && err.message.includes("outside a request scope"); - if (!isExpectedError) { - // Use console.warn to avoid circular dependency with logger - console.warn("unexpected error accessing cookies", err); - } - // Fall through to generate a UUID - } - - // fallback to distinct uuid - if (!distinctId) { - distinctId = uuidv4(); - } - - return distinctId; -}); - -/** - * Analytics tracking utility for server-side contexts. - * Use this in server components, API routes, and server actions. - * - * Note: These functions are fire-and-forget. They return immediately - * and perform tracking in the background via after() (when available). - * - * Implementation: To comply with Next.js restrictions on using cookies() - * inside after(), we call getDistinctId() outside to start the promise. - * Since getDistinctId() is wrapped in cache(), this triggers cookies() - * during the request phase, and we just await the cached result inside after(). - */ -export const analytics = { - /** - * Identify a user with PostHog server-side. - * Use this to ensure user properties are set even when client-side identify - * might not have run yet (e.g., in API routes, server actions). - * - * @param userId - The user's unique ID (from better-auth) - * @param properties - Properties to set/update on the user ($set) - * @param setOnceProperties - Properties to set only once ($set_once) - */ - identify: ( - userId: string, - properties?: IdentifyProperties, - setOnceProperties?: IdentifySetOnceProperties, - ) => { - const doIdentify = async () => { - const client = getServerPosthog(); - if (!client) { - return; - } - - await client.identifyImmediate({ - distinctId: userId, - properties: { - $set: properties, - $set_once: setOnceProperties, - }, - }); - }; - - after(() => doIdentify()); - }, - - track: (event: string, properties: Record, distinctId?: string) => { - // Start getDistinctId() promise outside of after() to trigger cookies() - // during the request phase (not inside the after callback) - const distinctIdPromise = distinctId ? Promise.resolve(distinctId) : getDistinctId(); - - const doTrack = async () => { - const client = getServerPosthog(); - if (!client) { - return; - } - - // Await the promise that was started outside of after() - const resolvedDistinctId = (await distinctIdPromise) || "server"; - - await client.captureImmediate({ - event, - distinctId: resolvedDistinctId, - properties, - }); - }; - - after(() => doTrack()); - }, - - /** - * @internal Use logger.error() instead, which automatically tracks exceptions. - */ - trackException: (error: Error, properties: Record, distinctId?: string) => { - // Start getDistinctId() promise outside of after() to trigger cookies() - // during the request phase (not inside the after callback) - const distinctIdPromise = distinctId ? Promise.resolve(distinctId) : getDistinctId(); - - const doTrack = async () => { - const client = getServerPosthog(); - if (!client) { - return; - } - - // Await the promise that was started outside of after() - const resolvedDistinctId = (await distinctIdPromise) || "server"; - - client.captureException(error, resolvedDistinctId, properties); - }; - - after(() => doTrack()); - }, -}; diff --git a/packages/analytics/src/types.ts b/packages/analytics/src/types.ts deleted file mode 100644 index f929123c..00000000 --- a/packages/analytics/src/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Shared types for PostHog analytics (client and server). - */ - -/** - * User properties that can be updated on each identify call ($set). - * These properties will be overwritten on subsequent identify calls. - */ -export interface IdentifyProperties { - email?: string; - name?: string; - tier?: string; -} - -/** - * User properties that should only be set once ($set_once). - * These properties will not be overwritten on subsequent identify calls. - */ -export interface IdentifySetOnceProperties { - createdAt?: string; -} diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json deleted file mode 100644 index c849685b..00000000 --- a/packages/analytics/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@domainstack/typescript-config/base.json", - "compilerOptions": { - "declaration": true, - "declarationMap": true - }, - "include": ["src/**/*"] -} diff --git a/packages/api/package.json b/packages/api/package.json index 9587a653..3e75821a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -16,13 +16,12 @@ "fmt:check": "oxfmt --check --config ../../.oxfmtrc.json" }, "dependencies": { - "@domainstack/analytics": "workspace:*", "@domainstack/auth": "workspace:*", "@domainstack/db": "workspace:*", "@domainstack/logger": "workspace:*", "@domainstack/redis": "workspace:*", - "@trpc/server": "^11.16.0", - "@vercel/functions": "^3.4.4", + "@trpc/server": "^11.18.0", + "@vercel/functions": "^3.9.5", "superjson": "^2.2.6" }, "devDependencies": { diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 3a2308a6..032df8aa 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -1,5 +1,9 @@ import { ipAddress } from "@vercel/functions"; +import { createLogger } from "@domainstack/logger"; + +const logger = createLogger({ source: "trpc/context" }); + export type Session = { user: { id: string; name: string; email: string }; }; @@ -47,7 +51,7 @@ export async function createContext(opts: CreateContextOptions = {}): Promise { +export const withLogging = t.middleware(async ({ path, type, ctx, next }) => { const start = performance.now(); - - const procedureLogger = createLogger({ source: "trpc", path, type }); - const result = await next(); const durationMs = Math.round(performance.now() - start); + const outcome = result.ok ? "ok" : "error"; + const posthogDistinctId = ctx.session?.user.id; - // Log errors from the result (tRPC middleware pattern) - if (!result.ok) { - procedureLogger.error({ err: result.error, durationMs }, "procedure error"); - } + const fields: Record = { + path, + type, + durationMs, + outcome, + ...(posthogDistinctId ? { posthogDistinctId } : {}), + }; - // Track slow requests (>5s threshold) in PostHog - if (durationMs > 5000) { - procedureLogger.info({ durationMs }, "slow request"); - - try { - const { analytics } = await import("@domainstack/analytics/server"); - void analytics.track("trpc_slow_request", { - path, - type, - durationMs, - }); - } catch { - // Analytics import failed - don't crash the request - } + if (result.ok) { + logger.info(fields, "procedure completed"); + } else { + logger.error({ ...fields, err: result.error }, "procedure completed"); } return result; diff --git a/packages/auth/package.json b/packages/auth/package.json index 9b00849f..9083ef85 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -22,13 +22,13 @@ "fmt:check": "oxfmt --check --config ../../.oxfmtrc.json" }, "dependencies": { - "@better-auth/infra": "^0.2.5", + "@better-auth/infra": "^0.3.7", "@domainstack/db": "workspace:*", "@domainstack/email": "workspace:*", "@domainstack/logger": "workspace:*", "@domainstack/polar": "workspace:*", "@domainstack/redis": "workspace:*", - "better-auth": "1.6.9" + "better-auth": "~1.6.30" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", @@ -36,6 +36,6 @@ }, "peerDependencies": { "next": ">=16", - "react": ">=18" + "react": ">=19" } } diff --git a/packages/auth/src/analytics.ts b/packages/auth/src/analytics.ts new file mode 100644 index 00000000..cab16900 --- /dev/null +++ b/packages/auth/src/analytics.ts @@ -0,0 +1,26 @@ +/** + * Fire-and-forget PostHog capture for auth lifecycle hooks. + * Never throws — account creation must not fail because analytics is down. + */ +export const analytics = { + track: (event: string, properties: Record, userId: string) => { + const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; + if (!apiKey) { + return; + } + + const host = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com"; + void fetch(`${host}/i/v0/e/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + api_key: apiKey, + event, + distinct_id: userId, + properties, + }), + }).catch(() => { + // no-op + }); + }, +}; diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index 11d22534..bca4767a 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -1,4 +1,3 @@ -import { sentinelClient } from "@better-auth/infra/client"; import { createAuthClient } from "better-auth/react"; import { polarClient } from "@domainstack/polar/better-auth/client"; @@ -9,7 +8,7 @@ import { polarClient } from "@domainstack/polar/better-auth/client"; */ const client = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_BASE_URL, - plugins: [polarClient(), sentinelClient()], + plugins: [polarClient()], }); export const authClient: ReturnType = client; diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 04369af7..cf0e65de 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -12,6 +12,7 @@ import DeleteAccountVerifyEmail from "@domainstack/email/templates/delete-accoun import { createLogger } from "@domainstack/logger"; import { getProductsForCheckout, + handleOrderPaid, handleSubscriptionActive, handleSubscriptionCanceled, handleSubscriptionCreated, @@ -22,6 +23,7 @@ import { checkout, polar, portal, webhooks } from "@domainstack/polar/better-aut import { Polar } from "@domainstack/polar/sdk"; import { getRedis } from "@domainstack/redis"; +import { analytics } from "./analytics"; import { buildOAuthProviders, validateOAuthCredentialPair } from "./providers"; import { createRedisStorage } from "./storage"; @@ -134,6 +136,17 @@ export const auth = betterAuth({ // Create Resend contact for marketing communications void addContact(user.email, user.name); + + analytics.track( + "signed_up", + { + $set: { email: user.email, name: user.name }, + $set_once: { + createdAt: user.createdAt ? new Date(user.createdAt).toISOString() : undefined, + }, + }, + user.id, + ); }, }, }, @@ -229,6 +242,7 @@ export const auth = betterAuth({ onSubscriptionCanceled: handleSubscriptionCanceled, onSubscriptionRevoked: handleSubscriptionRevoked, onSubscriptionUncanceled: handleSubscriptionUncanceled, + onOrderPaid: handleOrderPaid, }), ], }), diff --git a/packages/blob/package.json b/packages/blob/package.json index 3f8bf772..550c1eb6 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -16,8 +16,8 @@ }, "dependencies": { "@domainstack/logger": "workspace:*", - "@vercel/blob": "^2.3.3", - "file-type": "^22.0.1" + "@vercel/blob": "^2.8.0", + "file-type": "^22.0.2" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", diff --git a/packages/constants/src/ai.ts b/packages/constants/src/ai.ts index 13344d52..74ae2a8f 100644 --- a/packages/constants/src/ai.ts +++ b/packages/constants/src/ai.ts @@ -46,6 +46,32 @@ export const MAX_CONVERSATION_MESSAGES = 10; */ export const MAX_OUTPUT_TOKENS = 4096; +/** + * Maximum characters in a single assistant text/reasoning part. + * Larger than MAX_MESSAGE_LENGTH because model output can reach + * MAX_OUTPUT_TOKENS tokens. 8 chars/token leaves headroom for + * punctuation-heavy or non-English replies. + */ +export const MAX_ASSISTANT_TEXT_LENGTH = MAX_OUTPUT_TOKENS * 8; + +/** + * Maximum JSON-serialized size of a single assistant part. + * Bounds tool input/output and extra fields on loose parts. + */ +export const MAX_ASSISTANT_PART_CHARS = 64_000; + +/** + * Maximum number of parts on a single assistant message. + */ +export const MAX_ASSISTANT_PARTS = 32; + +/** + * Maximum chat request body size in bytes. + * Enforced before JSON.parse so oversized payloads never reach + * the schema or the model. + */ +export const MAX_CHAT_REQUEST_BYTES = 512_000; + /** * Maximum number of tool calls (steps) per request. * Prevents infinite loops and controls execution time. diff --git a/packages/db/package.json b/packages/db/package.json index c2a57ef0..428f7635 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -29,14 +29,14 @@ "@domainstack/types": "workspace:*", "@domainstack/utils": "workspace:*", "drizzle-orm": "^0.45.2", - "pg": "^8.20.0" + "pg": "^8.23.0" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", - "@electric-sql/pglite": "^0.4.4", + "@electric-sql/pglite": "^0.5.6", "@types/node": "catalog:", - "@types/pg": "^8.20.0", - "@vercel/functions": "^3.4.4", + "@types/pg": "^8.23.1", + "@vercel/functions": "^3.9.5", "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", "vitest": "catalog:" diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index f29e3c20..9384fe05 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -7,26 +7,26 @@ import * as schema from "./schema"; export type Database = NodePgDatabase; // Lazy-initialized singleton to allow tests to mock before first access -let _db: Database | null = null; -let _pool: Pool | null = null; +let dbInstance: Database | null = null; +let poolInstance: Pool | null = null; function getDb(): Database { - if (_db) return _db; + if (dbInstance) return dbInstance; const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error("DATABASE_URL is not set"); } - _pool = new Pool({ connectionString }); - _db = drizzle(_pool, { schema }); + poolInstance = new Pool({ connectionString }); + dbInstance = drizzle(poolInstance, { schema }); // Attach to Vercel's pool management if available // This ensures idle connections are properly released before fluid compute functions suspend // https://vercel.com/guides/connection-pooling-with-functions - void attachPoolIfVercel(_pool); + void attachPoolIfVercel(poolInstance); - return _db; + return dbInstance; } async function attachPoolIfVercel(pool: Pool) { @@ -48,6 +48,6 @@ export const db: Database = new Proxy({} as Database, { /** * For testing: allows replacing the db singleton with a test database. */ -export function __setTestDb(testDb: Database | null): void { - _db = testDb; +export function setTestDb(testDb: Database | null): void { + dbInstance = testDb; } diff --git a/packages/db/src/testing.ts b/packages/db/src/testing.ts index bb443ecd..d6a56e71 100644 --- a/packages/db/src/testing.ts +++ b/packages/db/src/testing.ts @@ -2,7 +2,7 @@ import { PGlite } from "@electric-sql/pglite"; import type { PgliteDatabase } from "drizzle-orm/pglite"; import { drizzle } from "drizzle-orm/pglite"; -import { __setTestDb, type Database } from "./client"; +import { setTestDb, type Database } from "./client"; import * as schema from "./schema"; // Dynamic import via require pattern is recommended in community examples @@ -44,7 +44,7 @@ export async function makePGliteDb(): Promise { } // Set the test db as the singleton so repos use it - __setTestDb(cached.db as any as Database); + setTestDb(cached.db as any as Database); return cached; } diff --git a/packages/email/package.json b/packages/email/package.json index fd4751b5..e52bb6e7 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -18,15 +18,15 @@ }, "dependencies": { "@domainstack/constants": "workspace:*", - "@react-email/components": "^1.0.12", - "resend": "^6.12.2" + "react-email": "^6.9.2", + "resend": "^6.22.0" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", "@types/node": "catalog:", - "@types/react": "19.2.14" + "@types/react": "19.2.18" }, "peerDependencies": { - "react": ">=18" + "react": ">=19" } } diff --git a/packages/email/src/components/box.tsx b/packages/email/src/components/box.tsx index 57729971..22c3ac10 100644 --- a/packages/email/src/components/box.tsx +++ b/packages/email/src/components/box.tsx @@ -1,4 +1,4 @@ -import { Section, Text } from "@react-email/components"; +import { Section, Text } from "react-email"; type EmailBoxProps = { variant: "info" | "warning" | "success" | "tip" | "danger"; diff --git a/packages/email/src/components/button.tsx b/packages/email/src/components/button.tsx index c60a7f08..7c5901a2 100644 --- a/packages/email/src/components/button.tsx +++ b/packages/email/src/components/button.tsx @@ -1,4 +1,4 @@ -import { Button, Section } from "@react-email/components"; +import { Button, Section } from "react-email"; type EmailButtonProps = { href: string; diff --git a/packages/email/src/components/layout.tsx b/packages/email/src/components/layout.tsx index f5807a67..b84a4fa5 100644 --- a/packages/email/src/components/layout.tsx +++ b/packages/email/src/components/layout.tsx @@ -1,4 +1,4 @@ -import { Body, Container, Head, Html, Img, Preview } from "@react-email/components"; +import { Body, Container, Head, Html, Img, Preview } from "react-email"; import { RESEND_LOGO_CONTENT_ID } from "@domainstack/constants"; diff --git a/packages/email/src/components/shared.tsx b/packages/email/src/components/shared.tsx index f8c26254..c0d431b0 100644 --- a/packages/email/src/components/shared.tsx +++ b/packages/email/src/components/shared.tsx @@ -1,4 +1,4 @@ -import { Heading, Hr, Link, Section, Text } from "@react-email/components"; +import { Heading, Hr, Link, Section, Text } from "react-email"; type EmailHeadingProps = { children: React.ReactNode; diff --git a/packages/image/package.json b/packages/image/package.json index 98441d87..75c4d39d 100644 --- a/packages/image/package.json +++ b/packages/image/package.json @@ -18,7 +18,7 @@ "@domainstack/blob": "workspace:*", "@domainstack/logger": "workspace:*", "decode-ico": "^0.4.1", - "sharp": "^0.34.5" + "sharp": "^0.35.3" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", diff --git a/packages/image/src/optimize.ts b/packages/image/src/optimize.ts index ce77e16f..51127ef9 100644 --- a/packages/image/src/optimize.ts +++ b/packages/image/src/optimize.ts @@ -1,4 +1,4 @@ -import sharp from "sharp"; +import sharp, { type ResizeOptions, type SharpOptions } from "sharp"; import { createLogger } from "@domainstack/logger"; @@ -33,13 +33,13 @@ export interface OptimizeImageOptions { * @default "cover" * @see https://sharp.pixelplumbing.com/api-resize/#resize */ - fit?: sharp.ResizeOptions["fit"]; + fit?: ResizeOptions["fit"]; /** * Sharp instance options * * @see https://sharp.pixelplumbing.com/api-constructor/#new */ - sharpOptions?: sharp.SharpOptions; + sharpOptions?: SharpOptions; } /** diff --git a/packages/logger/package.json b/packages/logger/package.json index 27e6d013..a32b8edb 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -15,11 +15,15 @@ "fmt:check": "oxfmt --check --config ../../.oxfmtrc.json" }, "dependencies": { - "pino": "^10.3.1" + "@opentelemetry/api-logs": "^0.221.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.221.0", + "@opentelemetry/resources": "^2.10.0", + "@opentelemetry/sdk-logs": "^0.221.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", - "@types/node": "catalog:", - "pino-pretty": "^13.1.3" + "@types/node": "catalog:" } } diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index c36dc7ab..d7343c88 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -1,4 +1,9 @@ import pino from "pino"; +import pretty from "pino-pretty"; + +import { emitToPostHog } from "./otel"; + +export { type FlushScheduler, flushLogs, setFlushScheduler } from "./otel"; const isDev = process.env.NODE_ENV === "development"; const isTest = process.env.NODE_ENV === "test"; @@ -7,20 +12,32 @@ const isTest = process.env.NODE_ENV === "test"; const levels = pino.levels.values; /** - * Creates a destination stream that routes logs to the appropriate console method. + * Creates a destination stream that routes logs to the appropriate console + * method (or pino-pretty in development) and forwards each record to PostHog. * - * This is safer than using process.stdout/stderr directly in serverless environments - * like Vercel, as console methods are guaranteed to work and Vercel properly interprets - * them for log level coloring. + * Parsing once serves both console routing and OTLP export. pino-pretty is a + * stream (not a worker transport) so HMR does not leak listener handles. + * + * Console methods are safer than process.stdout/stderr in serverless + * environments like Vercel, which interpret them for log level coloring. */ -function createConsoleDestination(): pino.DestinationStream { +function createDestination(): pino.DestinationStream { + const prettyStream = isDev ? pretty({ colorize: true }) : null; + return { write(msg: string): void { // Remove trailing newline for cleaner console output const trimmed = msg.trimEnd(); try { - const parsed = JSON.parse(trimmed) as { level?: string | number }; + const parsed = JSON.parse(trimmed) as Record; + emitToPostHog(parsed); + + if (prettyStream) { + prettyStream.write(msg); + return; + } + const level = typeof parsed.level === "string" ? levels[parsed.level as keyof typeof levels] @@ -62,34 +79,6 @@ const baseOptions: pino.LoggerOptions = { }, }; -/** - * Creates the Pino logger instance. - * Extracted to a function to support the global singleton pattern. - */ -function createPinoLogger(): pino.Logger { - return isDev - ? // Development: pretty printing to stdout (sync to avoid worker issues) - pino({ - ...baseOptions, - transport: { - target: "pino-pretty", - options: { colorize: true, sync: true }, - }, - }) - : // Production: route logs to console methods for proper Vercel log coloring - // console.error -> red, console.warn -> yellow, console.log -> default - pino(baseOptions, createConsoleDestination()); -} - -/** - * Global singleton to prevent multiple logger instances during Next.js HMR. - * In development, module re-evaluation would create new pino-pretty transports, - * causing "MaxListenersExceededWarning" from leaked socket listeners. - */ -const globalForLogger = globalThis as unknown as { - __pino_logger?: pino.Logger; -}; - /** * Server-side Pino logger. * @@ -99,7 +88,7 @@ const globalForLogger = globalThis as unknown as { * - Standard error serialization * - Pretty printing in development only * - Uses console methods for safe Vercel log level translation - * - Global singleton prevents HMR-related memory leaks + * - Forwards records to PostHog via OTLP when enabled * * @example * ```typescript @@ -109,11 +98,7 @@ const globalForLogger = globalThis as unknown as { * logger.error({ err: error, table: "users" }, "Database connection failed"); * ``` */ -if (!globalForLogger.__pino_logger) { - globalForLogger.__pino_logger = createPinoLogger(); -} - -export const logger: pino.Logger = globalForLogger.__pino_logger; +export const logger: pino.Logger = pino(baseOptions, createDestination()); /** * Create a child logger with a specific context prefix. diff --git a/packages/logger/src/otel.ts b/packages/logger/src/otel.ts new file mode 100644 index 00000000..948880a5 --- /dev/null +++ b/packages/logger/src/otel.ts @@ -0,0 +1,244 @@ +import { SeverityNumber } from "@opentelemetry/api-logs"; +import type { LogRecord } from "@opentelemetry/api-logs"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; + +const RESERVED_KEYS = new Set(["level", "time", "msg"]); + +const PINO_LABEL_TO_SEVERITY: Record = { + trace: SeverityNumber.TRACE, + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR, + fatal: SeverityNumber.FATAL, +}; + +const PINO_NUMERIC_TO_SEVERITY: Record = { + 10: SeverityNumber.TRACE, + 20: SeverityNumber.DEBUG, + 30: SeverityNumber.INFO, + 40: SeverityNumber.WARN, + 50: SeverityNumber.ERROR, + 60: SeverityNumber.FATAL, +}; + +let provider: LoggerProvider | undefined; + +/** + * Schedules work to run after the current request completes. Hosts provide + * their own primitive (Next.js supplies `after()`) so this package stays + * framework-agnostic. + */ +export type FlushScheduler = (task: () => Promise) => void; + +let scheduleFlush: FlushScheduler | undefined; +let flushScheduled = false; + +/** + * Register the scheduler used to flush buffered records at request boundaries. + * Call once during server startup; until then, records are only exported when + * the batch processor's own timer fires or `flushLogs` is called directly. + */ +export function setFlushScheduler(scheduler: FlushScheduler): void { + scheduleFlush = scheduler; +} + +/** + * Queue a flush for the end of the current request, at most one at a time. + * + * The flag resets when the task starts rather than when it finishes, so + * records emitted while an export is in flight schedule a fresh flush instead + * of being stranded in the buffer. + */ +function ensureFlushScheduled(): void { + if (!scheduleFlush || flushScheduled) { + return; + } + + flushScheduled = true; + try { + scheduleFlush(async () => { + flushScheduled = false; + await flushLogs(); + }); + } catch { + // No active request scope (workflow step, script, module init) + flushScheduled = false; + } +} + +function isExportEnabled(): boolean { + if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) { + return false; + } + return process.env.NODE_ENV === "production"; +} + +function toSeverity(level: unknown): SeverityNumber { + if (typeof level === "string") { + return PINO_LABEL_TO_SEVERITY[level] ?? SeverityNumber.UNSPECIFIED; + } + if (typeof level === "number") { + return PINO_NUMERIC_TO_SEVERITY[level] ?? SeverityNumber.UNSPECIFIED; + } + return SeverityNumber.UNSPECIFIED; +} + +function toTimestamp(time: unknown): number | undefined { + if (typeof time === "number" && Number.isFinite(time)) { + return time; + } + if (typeof time === "string") { + const parsed = Date.parse(time); + return Number.isNaN(parsed) ? undefined : parsed; + } + return undefined; +} + +function isScalar(value: unknown): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function toAttributeValue(value: unknown): string | number | boolean { + if (isScalar(value)) { + return value; + } + if (typeof value === "bigint") { + return value.toString(); + } + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +function toAttributes(record: Record): Record { + const attributes: Record = {}; + + for (const [key, value] of Object.entries(record)) { + if (RESERVED_KEYS.has(key) || value === null || value === undefined) { + continue; + } + + if (isScalar(value)) { + attributes[key] = value; + continue; + } + + if (typeof value === "object" && !Array.isArray(value)) { + for (const [nestedKey, nestedValue] of Object.entries(value as Record)) { + if (nestedValue === null || nestedValue === undefined) { + continue; + } + attributes[`${key}.${nestedKey}`] = toAttributeValue(nestedValue); + } + continue; + } + + attributes[key] = toAttributeValue(value); + } + + return attributes; +} + +/** + * Maps a parsed Pino JSON record to an OpenTelemetry log record. + */ +export function toLogRecord(record: Record): LogRecord { + const timestamp = toTimestamp(record.time); + const body = + typeof record.msg === "string" ? record.msg : record.msg != null ? String(record.msg) : ""; + + return { + body, + severityNumber: toSeverity(record.level), + ...(timestamp !== undefined ? { timestamp } : {}), + attributes: toAttributes(record), + }; +} + +function getProvider(): LoggerProvider | undefined { + if (provider) { + return provider; + } + if (!isExportEnabled()) { + return undefined; + } + + const key = process.env.NEXT_PUBLIC_POSTHOG_KEY; + if (!key) { + return undefined; + } + + const host = (process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com").replace( + /\/$/, + "", + ); + + const resourceAttributes: Record = { + "service.name": "domainstack-web", + }; + if (process.env.VERCEL_ENV) { + resourceAttributes["deployment.environment"] = process.env.VERCEL_ENV; + } + if (process.env.VERCEL_GIT_COMMIT_SHA) { + resourceAttributes["service.version"] = process.env.VERCEL_GIT_COMMIT_SHA; + } + + provider = new LoggerProvider({ + resource: resourceFromAttributes(resourceAttributes), + processors: [ + new BatchLogRecordProcessor({ + exporter: new OTLPLogExporter({ + url: `${host}/i/v1/logs`, + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + }, + }), + }), + ], + }); + + return provider; +} + +/** + * Convert a parsed Pino record and emit it to PostHog via OTLP. + * No-ops when export is disabled (tests, local dev). + */ +export function emitToPostHog(record: Record): void { + if (!isExportEnabled()) { + return; + } + + try { + const current = getProvider(); + if (!current) { + return; + } + current.getLogger("domainstack").emit(toLogRecord(record)); + ensureFlushScheduled(); + } catch { + // Logging must never throw + } +} + +/** + * Flush buffered OTLP log records. Normally driven by the registered + * `FlushScheduler`; call directly from contexts that have no request scope + * so serverless functions do not freeze before the batch export completes. + */ +export async function flushLogs(): Promise { + if (!provider) { + return; + } + try { + await provider.forceFlush(); + } catch { + // Flush must never throw at request boundaries + } +} diff --git a/packages/polar/package.json b/packages/polar/package.json index 326f709c..c03dc793 100644 --- a/packages/polar/package.json +++ b/packages/polar/package.json @@ -26,9 +26,9 @@ "@domainstack/db": "workspace:*", "@domainstack/email": "workspace:*", "@domainstack/logger": "workspace:*", - "@polar-sh/better-auth": "^1.8.3", - "@polar-sh/sdk": "^0.46.7", - "date-fns": "^4.1.0" + "@polar-sh/better-auth": "^1.8.4", + "@polar-sh/sdk": "^0.47.1", + "date-fns": "^4.4.0" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", diff --git a/packages/polar/src/analytics.ts b/packages/polar/src/analytics.ts new file mode 100644 index 00000000..a6f467e5 --- /dev/null +++ b/packages/polar/src/analytics.ts @@ -0,0 +1,26 @@ +/** + * Fire-and-forget PostHog capture for Polar webhook handlers. + * Never throws — billing side effects must not fail because analytics is down. + */ +export const analytics = { + track: (event: string, properties: Record, userId: string) => { + const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; + if (!apiKey) { + return; + } + + const host = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com"; + void fetch(`${host}/i/v0/e/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + api_key: apiKey, + event, + distinct_id: userId, + properties, + }), + }).catch(() => { + // no-op + }); + }, +}; diff --git a/packages/polar/src/emails.ts b/packages/polar/src/emails.ts index 63762e59..198fc777 100644 --- a/packages/polar/src/emails.ts +++ b/packages/polar/src/emails.ts @@ -5,7 +5,9 @@ import { sendEmail } from "@domainstack/email"; import ProUpgradeSuccessEmail from "@domainstack/email/templates/pro-upgrade-success"; import SubscriptionCancelingEmail from "@domainstack/email/templates/subscription-canceling"; import SubscriptionExpiredEmail from "@domainstack/email/templates/subscription-expired"; -import { logger } from "@domainstack/logger"; +import { createLogger } from "@domainstack/logger"; + +const logger = createLogger({ source: "polar/emails" }); const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://domainstack.io"; diff --git a/packages/polar/src/handlers.test.ts b/packages/polar/src/handlers.test.ts index 3dbc4b5e..c7be39a0 100644 --- a/packages/polar/src/handlers.test.ts +++ b/packages/polar/src/handlers.test.ts @@ -20,32 +20,39 @@ type SubscriptionUncanceledPayload = Parameters< >[0]; // Hoist mock functions so they're available to vi.mock factory -const { updateUserTier, setSubscriptionEndsAt, clearSubscriptionEndsAt, createMockLogger } = - vi.hoisted(() => { - type MockLogger = Record< - "log" | "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "child", - ReturnType - >; +const { + updateUserTier, + setSubscriptionEndsAt, + clearSubscriptionEndsAt, + createMockLogger, + analyticsTrack, +} = vi.hoisted(() => { + type MockLogger = Record< + "log" | "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "child", + ReturnType + >; - const buildMockLogger = (): MockLogger => ({ - log: vi.fn<(...args: unknown[]) => void>(), - trace: vi.fn<(...args: unknown[]) => void>(), - debug: vi.fn<(...args: unknown[]) => void>(), - info: vi.fn<(...args: unknown[]) => void>(), - warn: vi.fn<(...args: unknown[]) => void>(), - error: vi.fn<(...args: unknown[]) => void>(), - fatal: vi.fn<(...args: unknown[]) => void>(), - child: vi.fn<(...args: unknown[]) => MockLogger>(() => buildMockLogger()), - }); - - return { - updateUserTier: vi.fn<(userId: string, tier: "free" | "pro") => Promise>(), - setSubscriptionEndsAt: vi.fn<(userId: string, endsAt: Date) => Promise>(), - clearSubscriptionEndsAt: vi.fn<(userId: string) => Promise>(), - createMockLogger: buildMockLogger, - }; + const buildMockLogger = (): MockLogger => ({ + log: vi.fn<(...args: unknown[]) => void>(), + trace: vi.fn<(...args: unknown[]) => void>(), + debug: vi.fn<(...args: unknown[]) => void>(), + info: vi.fn<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), + error: vi.fn<(...args: unknown[]) => void>(), + fatal: vi.fn<(...args: unknown[]) => void>(), + child: vi.fn<(...args: unknown[]) => MockLogger>(() => buildMockLogger()), }); + return { + updateUserTier: vi.fn<(userId: string, tier: "free" | "pro") => Promise>(), + setSubscriptionEndsAt: vi.fn<(userId: string, endsAt: Date) => Promise>(), + clearSubscriptionEndsAt: vi.fn<(userId: string) => Promise>(), + createMockLogger: buildMockLogger, + analyticsTrack: + vi.fn<(event: string, properties: Record, userId: string) => void>(), + }; +}); + // Mock the dependencies - export functions directly (not via repo objects) vi.mock("@domainstack/db/queries", () => ({ updateUserTier, @@ -66,6 +73,14 @@ vi.mock("./downgrade", () => ({ vi.mock("./products", () => ({ getTierForProductId: vi.fn<(productId: string) => "pro" | null>(), + getProductByProductId: + vi.fn<(productId: string) => { interval: "month" | "year"; amount: number } | null>(), +})); + +vi.mock("./analytics", () => ({ + analytics: { + track: analyticsTrack, + }, })); vi.mock("./emails", () => ({ @@ -119,6 +134,8 @@ function createSubscriptionData(overrides: { cancelAtPeriodEnd: overrides.cancelAtPeriodEnd ?? false, currentPeriodEnd: overrides.currentPeriodEnd ?? null, canceledAt: overrides.canceledAt ?? null, + amount: 200, + currency: "usd", }; } diff --git a/packages/polar/src/handlers.ts b/packages/polar/src/handlers.ts index 7dfdf8cf..4d778e33 100644 --- a/packages/polar/src/handlers.ts +++ b/packages/polar/src/handlers.ts @@ -5,15 +5,18 @@ import { setSubscriptionEndsAt, updateUserTier, } from "@domainstack/db/queries"; -import { logger } from "@domainstack/logger"; +import { createLogger } from "@domainstack/logger"; +import { analytics } from "./analytics"; import { handleDowngrade } from "./downgrade"; import { sendProUpgradeEmail, sendSubscriptionCancelingEmail, sendSubscriptionExpiredEmail, } from "./emails"; -import { getTierForProductId } from "./products"; +import { getProductByProductId, getTierForProductId } from "./products"; + +const logger = createLogger({ source: "polar/webhooks" }); // Extract payload types from WebhooksOptions type SubscriptionCreatedPayload = Parameters< @@ -31,6 +34,7 @@ type SubscriptionRevokedPayload = Parameters< type SubscriptionUncanceledPayload = Parameters< NonNullable >[0]; +type OrderPaidPayload = Parameters>[0]; /** * Handle subscription.created webhook. @@ -98,6 +102,21 @@ export async function handleSubscriptionActive(payload: SubscriptionActivePayloa } catch (err) { logger.error({ err, userId }, "Failed to send pro upgrade email"); } + + const product = getProductByProductId(data.product.id); + analytics.track( + "subscription_started", + { + subscription_id: data.id, + product_id: data.product.id, + product: data.product.name, + interval: product?.interval, + amount: data.amount, + currency: data.currency, + tier, + }, + userId, + ); } /** @@ -206,3 +225,43 @@ export async function handleSubscriptionUncanceled( // Clear the subscription end date since they're no longer canceling await clearSubscriptionEndsAt(userId); } + +/** + * Handle order.paid webhook. + * Fires for the first payment and every renewal. Used as the customer analytics payment event. + */ +export async function handleOrderPaid(payload: OrderPaidPayload): Promise { + const { data } = payload; + const userId = data.customer.externalId; + + logger.info( + { + orderId: data.id, + userId, + productId: data.productId, + totalAmount: data.totalAmount, + currency: data.currency, + billingReason: data.billingReason, + }, + "Order paid", + ); + + if (!userId) { + logger.warn({ orderId: data.id }, "No externalId on customer, skipping payment analytics"); + return; + } + + analytics.track( + "payment_succeeded", + { + revenue: data.totalAmount, + currency: data.currency.toUpperCase(), + product: data.product?.name, + product_id: data.productId, + subscription_id: data.subscriptionId, + order_id: data.id, + billing_reason: data.billingReason, + }, + userId, + ); +} diff --git a/packages/polar/src/products.ts b/packages/polar/src/products.ts index c4e074b5..5552fc12 100644 --- a/packages/polar/src/products.ts +++ b/packages/polar/src/products.ts @@ -73,13 +73,20 @@ export function getProductsForCheckout() { })); } +/** + * Find Polar product config by Polar product ID. + * Used by webhooks for tier assignment and analytics properties. + */ +export function getProductByProductId(productId: string) { + return Object.values(POLAR_PRODUCTS).find((p) => p.productId === productId) ?? null; +} + /** * Find the tier associated with a Polar product ID. * Used by webhooks to determine which tier to assign. */ export function getTierForProductId(productId: string): "pro" | null { - const product = Object.values(POLAR_PRODUCTS).find((p) => p.productId === productId); - return product?.tier ?? null; + return getProductByProductId(productId)?.tier ?? null; } /** diff --git a/packages/redis/package.json b/packages/redis/package.json index 8199e594..6f25f799 100644 --- a/packages/redis/package.json +++ b/packages/redis/package.json @@ -18,7 +18,7 @@ "dependencies": { "@domainstack/logger": "workspace:*", "@upstash/ratelimit": "^2.0.8", - "@upstash/redis": "^1.37.0" + "@upstash/redis": "^1.38.2" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", diff --git a/packages/safe-fetch/package.json b/packages/safe-fetch/package.json index 979fee6d..9df050f1 100644 --- a/packages/safe-fetch/package.json +++ b/packages/safe-fetch/package.json @@ -19,7 +19,7 @@ "@domainstack/constants": "workspace:*", "@domainstack/logger": "workspace:*", "@domainstack/utils": "workspace:*", - "ipaddr.js": "^2.3.0" + "ipaddr.js": "^2.5.0" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", diff --git a/packages/screenshot/package.json b/packages/screenshot/package.json index 9f232dc1..4714602d 100644 --- a/packages/screenshot/package.json +++ b/packages/screenshot/package.json @@ -16,13 +16,13 @@ }, "dependencies": { "@domainstack/logger": "workspace:*", - "@ghostery/adblocker-puppeteer": "^2.14.1", - "@sparticuz/chromium": "143.0.4", - "puppeteer-core": "24.35.0" + "@ghostery/adblocker-puppeteer": "2.18.2", + "@sparticuz/chromium": "149.0.0", + "puppeteer-core": "25.1.0" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", "@types/node": "catalog:", - "puppeteer": "24.35.0" + "puppeteer": "25.1.0" } } diff --git a/packages/server/package.json b/packages/server/package.json index 5e4088ba..407afc0e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -34,22 +34,22 @@ "@domainstack/types": "workspace:*", "@domainstack/utils": "workspace:*", "@readme/http-status-codes": "^9.0.6", - "@vercel/edge-config": "^1.4.3", + "@vercel/edge-config": "^1.5.1", "cheerio": "^1.2.0", - "ipaddr.js": "^2.3.0", - "lru-cache": "^11.3.5", + "ipaddr.js": "^2.5.0", + "lru-cache": "^11.5.2", "rdapper": "^0.13.0" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", "@types/node": "catalog:", - "@types/react": "19.2.14", - "msw": "^2.13.6", + "@types/react": "19.2.18", + "msw": "^2.15.0", "vitest": "catalog:", "zod": "catalog:" }, "peerDependencies": { - "react": ">=18", + "react": ">=19", "zod": "^4" } } diff --git a/packages/server/src/services/hosting.ts b/packages/server/src/services/hosting.ts index 1e2386bf..bd432256 100644 --- a/packages/server/src/services/hosting.ts +++ b/packages/server/src/services/hosting.ts @@ -110,7 +110,7 @@ export async function fetchHosting(domain: string): Promise { // Internal: GeoIP Lookup // ============================================================================ -const geoIpLogger = createLogger({ source: "hosting-geoip" }); +const logger = createLogger({ source: "geoip" }); /** Raw iplocate.io API response - cached in Redis */ interface IplocateApiResponse { @@ -148,7 +148,6 @@ function transformApiResponse(data: IplocateApiResponse): GeoIpData { * This ensures cached data remains valid if transformation logic changes. */ async function lookupGeoIp(ip: string): Promise { - const logger = geoIpLogger; const redis = getRedis(); const cacheKey = `geoip:${ip}`; diff --git a/packages/server/src/tls/utils.ts b/packages/server/src/tls/utils.ts index 90d783c7..b61a9b2c 100644 --- a/packages/server/src/tls/utils.ts +++ b/packages/server/src/tls/utils.ts @@ -39,7 +39,7 @@ export function parseAltNames(subjectAltName: string | undefined): string[] { return [kind, value] as const; }) .filter(([kind, value]) => !!value && (kind === "DNS" || kind === "IP ADDRESS")) - .map(([_, value]) => value); + .map(([, value]) => value); } /** diff --git a/packages/typescript-config/nextjs.json b/packages/typescript-config/nextjs.json index 23ba712a..230273d6 100644 --- a/packages/typescript-config/nextjs.json +++ b/packages/typescript-config/nextjs.json @@ -6,6 +6,9 @@ "plugins": [ { "name": "next" + }, + { + "name": "workflow" } ] } diff --git a/packages/ui/package.json b/packages/ui/package.json index bf8b1486..496f4990 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,27 +17,25 @@ "fmt:check": "oxfmt --check --config ../../.oxfmtrc.json" }, "dependencies": { - "@base-ui/react": "^1.4.1", + "@base-ui/react": "^1.7.0", "clipboard-copy": "^4.0.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "cva": "1.0.0-beta.4", - "media-chrome": "^4.19.0", - "tailwind-merge": "^3.5.0" + "media-chrome": "^4.19.2", + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", "@tabler/icons-react": "catalog:", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "motion": "catalog:", - "sonner": "catalog:" + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "motion": "catalog:" }, "peerDependencies": { "@tabler/icons-react": "^3", - "motion": "^12", - "react": ">=18", - "react-dom": ">=18", - "sonner": "^2" + "motion": "^13", + "react": ">=19", + "react-dom": ">=19" } } diff --git a/packages/ui/src/components/code-block.tsx b/packages/ui/src/components/code-block.tsx index 23d1b83f..a4abc20e 100644 --- a/packages/ui/src/components/code-block.tsx +++ b/packages/ui/src/components/code-block.tsx @@ -1,6 +1,6 @@ "use client"; -import { type CSSProperties, type ReactNode, useCallback, useRef } from "react"; +import { type CSSProperties, type ReactNode, type RefObject, useCallback, useRef } from "react"; import { cn } from "../utils"; import { Card, CardContent, CardHeader, CardTitle } from "./card"; @@ -17,6 +17,37 @@ type CodeBlockProps = { title?: string; }; +function CodeBlockPre({ + children, + className, + preRef, + style, + tabIndex, +}: { + children: ReactNode; + className?: string; + preRef: RefObject; + style?: CSSProperties; + tabIndex?: number; +}) { + return ( + +

code]:grid",
+          className,
+        )}
+        ref={preRef}
+        style={style}
+        tabIndex={tabIndex}
+      >
+        {children}
+      
+ + ); +} + export const CodeBlock = ({ children, className, @@ -30,31 +61,12 @@ export const CodeBlock = ({ // Read the text content when copy is triggered, not at render time const getValue = useCallback(() => ref.current?.innerText ?? "", []); - const CodeBlockComponent = useCallback( - (props: { className?: string }) => ( - -
code]:grid",
-            className,
-            props.className,
-          )}
-          ref={ref}
-          style={style}
-          tabIndex={tabIndex}
-        >
-          {children}
-        
-
- ), - [children, style, tabIndex, className], - ); - if (!title) { return (
- + + {children} + - + + {children} + ); diff --git a/packages/ui/src/components/copy-button.tsx b/packages/ui/src/components/copy-button.tsx index 0e5c70f3..f02af723 100644 --- a/packages/ui/src/components/copy-button.tsx +++ b/packages/ui/src/components/copy-button.tsx @@ -1,13 +1,13 @@ "use client"; -import { IconCheck, IconCircleX, IconClipboardCheck, IconCopy } from "@tabler/icons-react"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; import clipboardCopy from "clipboard-copy"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; import { cn, type VariantProps } from "../utils"; import { Button, type buttonVariants } from "./button"; +import { toast } from "./toast"; type CopyButtonProps = { value: string | (() => string); @@ -50,10 +50,7 @@ export function CopyButton({ const textToCopy = typeof value === "function" ? value() : value; await clipboardCopy(textToCopy); - toast.success("Copied!", { - icon: , - position: "bottom-center", - }); + toast.add({ title: "Copied!", type: "success" }); // Start reset timer after successful copy timeoutRef.current = setTimeout(() => { @@ -63,10 +60,7 @@ export function CopyButton({ } catch { // Revert optimistic update on failure setCopied(false); - toast.error("Failed to copy", { - icon: , - position: "bottom-center", - }); + toast.add({ title: "Failed to copy", type: "error" }); } }, [copied, value]); diff --git a/packages/ui/src/components/multi-select.tsx b/packages/ui/src/components/multi-select.tsx index cbbbc1d8..89ccd0b1 100644 --- a/packages/ui/src/components/multi-select.tsx +++ b/packages/ui/src/components/multi-select.tsx @@ -166,9 +166,11 @@ export function MultiSelect({ filter={searchable ? filterOption : null} > 0 ? `${label}, ${selected.length} selected` : label} render={ } /> diff --git a/packages/ui/src/components/slider.tsx b/packages/ui/src/components/slider.tsx index daa7040f..e4439906 100644 --- a/packages/ui/src/components/slider.tsx +++ b/packages/ui/src/components/slider.tsx @@ -11,7 +11,7 @@ function Slider({ max = 100, ...props }: SliderPrimitive.Root.Props) { - const _values = useMemo( + const thumbValues = useMemo( () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]), [value, defaultValue, min, max], ); @@ -41,12 +41,12 @@ function Slider({ data-slot="slider-range" className="rounded-full bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full" /> - {Array.from({ length: _values.length }, (_, index) => ( + {Array.from({ length: thumbValues.length }, (_, index) => ( ))} diff --git a/packages/ui/src/components/stepper.tsx b/packages/ui/src/components/stepper.tsx index 1394b25d..6bea7166 100644 --- a/packages/ui/src/components/stepper.tsx +++ b/packages/ui/src/components/stepper.tsx @@ -49,6 +49,26 @@ interface StepItemContextValue { const StepperContext = createContext(undefined); const StepItemContext = createContext(undefined); +const EMPTY_INDICATORS: StepIndicators = {}; + +function countStepperItems(node: React.ReactNode): number { + let count = 0; + Children.forEach(node, (child) => { + if (isValidElement(child)) { + if ((child.type as { displayName?: string }).displayName === "StepperItem") { + count++; + } + // Recurse into children (e.g., StepperNav contains StepperItems) + if (child.props && typeof child.props === "object") { + const childProps = child.props as { children?: React.ReactNode }; + if (childProps.children) { + count += countStepperItems(childProps.children); + } + } + } + }); + return count; +} function useStepper() { const ctx = useContext(StepperContext); @@ -77,7 +97,7 @@ function Stepper({ orientation = "horizontal", className, children, - indicators = {}, + indicators = EMPTY_INDICATORS, ...rootProps }: StepperProps) { const [activeStep, setActiveStep] = useState(defaultValue); @@ -130,26 +150,6 @@ function Stepper({ [focusTrigger, triggerNodes.length], ); - // Recursively count StepperItem components in the tree - const countStepperItems = useCallback((node: React.ReactNode): number => { - let count = 0; - Children.forEach(node, (child) => { - if (isValidElement(child)) { - if ((child.type as { displayName?: string }).displayName === "StepperItem") { - count++; - } - // Recurse into children (e.g., StepperNav contains StepperItems) - if (child.props && typeof child.props === "object") { - const childProps = child.props as { children?: React.ReactNode }; - if (childProps.children) { - count += countStepperItems(childProps.children); - } - } - } - }); - return count; - }, []); - const contextValue = useMemo( () => ({ activeStep: currentStep, @@ -176,7 +176,6 @@ function Stepper({ focusFirst, focusLast, triggerNodes, - countStepperItems, indicators, ], ); @@ -292,9 +291,9 @@ function StepperTrigger({ render, className, children, tabIndex, ...props }: Ste }; }, [registerTrigger]); - const myIdx = triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current); - const handleKeyDown = (e: React.KeyboardEvent) => { + const myIdx = triggerNodes.findIndex((n: HTMLButtonElement) => n === e.currentTarget); + switch (e.key) { case "ArrowRight": case "ArrowDown": diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx new file mode 100644 index 00000000..69cdf706 --- /dev/null +++ b/packages/ui/src/components/toast.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { Toast as ToastPrimitive } from "@base-ui/react/toast"; +import { + IconAlertTriangle, + IconCircleCheck, + IconCircleX, + IconInfoCircle, + IconX, +} from "@tabler/icons-react"; +import type { ReactNode } from "react"; + +import { cn } from "../utils"; +import { Button } from "./button"; +import { Spinner } from "./spinner"; + +const toast = ToastPrimitive.createToastManager(); + +function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) { + return ; +} + +function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) { + return ; +} + +function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) { + return ( + + ); +} + +function Toast({ className, ...props }: ToastPrimitive.Root.Props) { + return ( + + ); +} + +function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) { + return ( + + ); +} + +function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) { + return ( + + ); +} + +function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) { + return ( + + ); +} + +const toastActionRender =