chore: update all dependencies to latest and fix breaking changes (#457)

This commit is contained in:
2026-08-25 11:28:32 -04:00
committed by GitHub
parent 9b182ce834
commit d1cdd75e7e
219 changed files with 12663 additions and 10268 deletions
+7 -11
View File
@@ -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 }}
+5
View File
@@ -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/
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.19.0
+1 -10
View File
@@ -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",
+2 -2
View File
@@ -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"
},
+60 -22
View File
@@ -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<Data> {
"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<T>` 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
<ErrorBoundary fallback={<ErrorFallback />}>
<Suspense fallback={<MySkeleton />}>
<MyComponent />
</Suspense>
</ErrorBoundary>
</ErrorBoundary>;
// 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 <div>{data.value}</div>;
}
```
**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
+3
View File
@@ -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=
+1
View File
@@ -1,3 +1,4 @@
next-env.d.ts
.vercel
.env*.local
/.swc
@@ -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;
}
@@ -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;
}
+3
View File
@@ -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");
}
@@ -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;
}
+3
View File
@@ -1,3 +1,6 @@
// instant = false: empty @modal slot except during intercepting navigations.
export const instant = false;
export default function Default() {
return null;
}
-4
View File
@@ -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 <DomainReportClient domain={registrable} />;
}
+40 -57
View File
@@ -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 } },
);
}
}
+33 -45
View File
@@ -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,
-17
View File
@@ -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",
@@ -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",
},
);
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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,
+1 -3
View File
@@ -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}
</Providers>
<Analytics />
</body>
</html>
);
+2 -3
View File
@@ -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}
<VibrationProvider />
<HapticsProvider>{children}</HapticsProvider>
</MotionConfig>
</ProgressProvider>
</TooltipProvider>
+5 -1
View File
@@ -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(),
};
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
+15
View File
@@ -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,
}));
}
@@ -21,6 +21,7 @@ export const Conversation = ({ stickyInstance, className, ...props }: Conversati
scrollRef={scrollRef}
contentRef={contentRef}
role="log"
aria-relevant="additions"
{...props}
/>
);
@@ -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<HTMLAttributes<HTMLFormElement>, "onSubmit">
};
export const PromptInput = ({ className, onSubmit, children, ...props }: PromptInputProps) => {
const { trigger } = useHaptics();
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
try {
navigator.vibrate([50]);
} catch {}
void trigger("medium");
const form = event.currentTarget;
const formData = new FormData(form);
+22 -11
View File
@@ -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<number | null>(null);
const startTimeRef = useRef<number | null>(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<typeof CollapsibleTrigger> &
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <ShimmeringText text="Thinking…" />;
return <ShimmeringText text="Thinking…" startOnView={false} />;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
@@ -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}
@@ -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);
};
@@ -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<string | null>(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}</>;
}
+12 -1
View File
@@ -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<string | null>(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
+5 -4
View File
@@ -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",
});
}
};
@@ -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(<CalendarInstructions />, { 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();
});
});
+15 -5
View File
@@ -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<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" role="img" aria-label="Microsoft" {...props}>
<path d="M11.4 24H0V12.6h11.4zM24 24H12.6V12.6H24zM11.4 11.4H0V0h11.4zm12.6 0H12.6V0H24z" />
</svg>
);
}
/**
* 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<SVGSVGElement>) => (
<svg viewBox="0 0 24 24" fill="currentColor" role="img" aria-label="Microsoft" {...props}>
<path d="M11.4 24H0V12.6h11.4zM24 24H12.6V12.6H24zM11.4 11.4H0V0h11.4zm12.6 0H12.6V0H24z" />
</svg>
),
icon: OutlookIcon,
href: `https://outlook.office.com/calendar/0/addfromweb?url=${encodeURIComponent(feedUrl.replace("https://", "webcal://"))}`,
},
{
@@ -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 <DynamicChatClient suggestions={suggestions} />;
+293 -166
View File
@@ -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<ChatMode | null>(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 (
<>
<AnimatePresence>{!hideAiFeatures && <ChatFab onClick={handleChatClick} />}</AnimatePresence>
{chatHydrated &&
(mode === "local" ? (
<LocalChatSession
domain={domain}
suggestions={suggestions}
model={browserAI.model}
browserAI={browserAI}
isMobile={isMobile}
open={open}
onOpenChange={setOpen}
onSettingsClick={handleSettingsClick}
onActiveChange={handleActiveChange}
/>
) : (
<CloudChatSession
domain={domain}
suggestions={suggestions}
browserAI={browserAI}
isMobile={isMobile}
open={open}
onOpenChange={setOpen}
onSettingsClick={handleSettingsClick}
onActiveChange={handleActiveChange}
/>
))}
<ChatSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</>
);
}
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<string | null | undefined>(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 (
<>
<AnimatePresence>{!hideAiFeatures && <ChatFab onClick={handleChatClick} />}</AnimatePresence>
{isMobile ? (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>
<DrawerHeader className="flex flex-row items-center justify-between">
<DrawerTitle className="flex items-center gap-2">
<IconLego className="size-4" />
<span className="text-[15px] leading-none font-semibold tracking-tight">
{CHATBOT_NAME}
</span>
<BetaBadge />
</DrawerTitle>
<div className="flex items-center gap-2">
<ChatHeaderActions
messages={messages}
onClear={clearMessages}
onSettingsClick={handleSettingsClick}
onCloseClick={() => setOpen(false)}
/>
</div>
</DrawerHeader>
<ChatPanel {...chatClientProps} conversationClassName="px-4" inputClassName="p-4" />
</DrawerContent>
</Drawer>
) : (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="flex w-[420px] flex-col gap-0 p-0"
showCloseButton={false}
>
<SheetHeader className="flex shrink-0 flex-row items-center justify-between border-b bg-card/60 px-3.5 py-2">
<SheetTitle className="flex items-center gap-2">
<IconLego className="size-4" />
<span className="text-[15px] leading-none font-semibold tracking-tight">
{CHATBOT_NAME}
</span>
<BetaBadge />
</SheetTitle>
<div className="-mr-1.5 flex items-center gap-1.5">
<ChatHeaderActions
messages={messages}
onClear={clearMessages}
onSettingsClick={handleSettingsClick}
onCloseClick={() => setOpen(false)}
closeIcon={IconLayoutSidebarRightCollapse}
/>
</div>
</SheetHeader>
<ChatPanel {...chatClientProps} inputClassName="p-3" />
</SheetContent>
</Sheet>
)}
<ChatSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</>
<ChatShell
chat={{
messages: chat.messages,
sendMessage,
clearMessages,
status: chat.status,
error,
}}
domain={domain}
suggestions={suggestions}
browserAI={browserAI}
isMobile={isMobile}
open={open}
onOpenChange={onOpenChange}
onSettingsClick={onSettingsClick}
/>
);
}
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 (
<ChatShell
chat={{
messages: chat.messages,
sendMessage,
clearMessages,
status: chat.status,
error,
}}
domain={domain}
suggestions={suggestions}
browserAI={browserAI}
isMobile={isMobile}
open={open}
onOpenChange={onOpenChange}
onSettingsClick={onSettingsClick}
/>
);
}
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 = (
<ChatHeaderActions
messages={chat.messages}
onClear={chat.clearMessages}
onSettingsClick={onSettingsClick}
onCloseClick={() => onOpenChange(false)}
closeIcon={isMobile ? undefined : IconLayoutSidebarRightCollapse}
/>
);
const panel = (
<ChatPanel
{...chat}
domain={domain}
homeSuggestions={suggestions}
browserAI={browserAI}
conversationClassName={isMobile ? "px-4" : undefined}
inputClassName={isMobile ? "p-4" : "p-3"}
/>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent>
<DrawerHeader className="flex flex-row items-center justify-between">
<DrawerTitle className="flex items-center gap-2">
<IconLego className="size-4" />
<span className="text-[15px] leading-none font-semibold tracking-tight">
{CHATBOT_NAME}
</span>
<BetaBadge />
</DrawerTitle>
<div className="flex items-center gap-2">{headerActions}</div>
</DrawerHeader>
{panel}
</DrawerContent>
</Drawer>
);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-[420px] flex-col gap-0 p-0"
showCloseButton={false}
>
<SheetHeader className="flex shrink-0 flex-row items-center justify-between border-b bg-card/60 px-3.5 py-2">
<SheetTitle className="flex items-center gap-2">
<IconLego className="size-4" />
<span className="text-[15px] leading-none font-semibold tracking-tight">
{CHATBOT_NAME}
</span>
<BetaBadge />
</SheetTitle>
<div className="-mr-1.5 flex items-center gap-1.5">{headerActions}</div>
</SheetHeader>
{panel}
</SheetContent>
</Sheet>
);
}
@@ -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" });
}
};
@@ -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}
>
<IconDownload className="size-3.5" />
@@ -129,12 +125,15 @@ export function ChatModeSelector({ className, disabled }: ChatModeSelectorProps)
</DropdownMenuRadioItem>
}
/>
<ResponsiveTooltipContent className={cn(canUseLocal && "hidden")}>
<ResponsiveTooltipContent
className={cn(browserAI.status !== "unavailable" && "hidden")}
>
Requires latest{" "}
<a
href="https://developer.chrome.com/docs/ai/prompt-api"
target="_blank"
rel="noopener noreferrer"
className="font-medium"
>
Google Chrome
</a>{" "}
@@ -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
</a>{" "}
+100 -82
View File
@@ -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<string, number>();
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 (
<div
className="flex items-center gap-2 text-[13px] text-muted-foreground"
aria-live="polite"
aria-atomic="true"
>
<IconBrain className="size-3.5" aria-hidden />
<ShimmeringText text="Thinking…" startOnView={false} />
</div>
);
}
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({
<>
<Conversation
stickyInstance={stickyInstance}
aria-busy={status === "submitted" || status === "streaming"}
className={cn(
"min-h-0 flex-1 bg-popover/10 [&_[data-slot=scroll-area-content]]:flex [&_[data-slot=scroll-area-content]]:min-h-full [&_[data-slot=scroll-area-content]]:flex-col",
conversationClassName,
)}
>
<ConversationContent
className={cn(messages.length === 0 ? "items-center justify-center" : "gap-4 px-3 py-4")}
aria-live="polite"
className={cn(
messages.length === 0 && !showThinking
? "items-center justify-center"
: "gap-4 px-3 py-4",
)}
>
{messages.length === 0 ? (
{messages.length === 0 && !showThinking ? (
<ConversationEmptyState
icon={<IconMessages className="size-7" />}
title={`Ask me anything about ${domain ?? "domains"}!`}
@@ -138,19 +153,29 @@ export function ChatPanel({
/>
) : (
<>
{messages.map((message) => (
<Message key={message.id} from={message.role}>
<MessageContent>
{getMessagePartItems(message).map(({ key, part, position }) => {
if (part.type === "text") {
return <MessageResponse key={key}>{part.text}</MessageResponse>;
}
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 (
<Message key={message.id} from={message.role}>
<MessageContent>
{getMessagePartItems(message).map(({ key, part, position }) => {
if (part.type === "text") {
return <MessageResponse key={key}>{part.text}</MessageResponse>;
}
if (part.type === "reasoning") {
if (!showReasoning) {
return null;
}
const isStreaming =
status === "streaming" &&
position === message.parts.length - 1 &&
message.id === messages.at(-1)?.id;
return (
<Reasoning key={key} className="w-full" isStreaming={isStreaming}>
<ReasoningTrigger />
@@ -158,48 +183,38 @@ export function ChatPanel({
</Reasoning>
);
}
return isStreaming ? (
<div
key={key}
className="flex items-center gap-2 text-[13px] text-muted-foreground"
>
<IconBrain className="size-3.5" />
<ShimmeringText text="Thinking…" />
</div>
) : null;
}
if (part.type.startsWith("tool-") && showToolCalls) {
const toolPart = part as ToolUIPart;
return (
<Tool key={key}>
<ToolHeader
title={getToolStatusMessage(toolPart.type)}
type={toolPart.type}
state={toolPart.state}
/>
<ToolContent>
<ToolInput input={toolPart.input} />
{toolPart.state === "output-available" && (
<ToolOutput
output={toolPart.output}
errorText={toolPart.errorText}
/>
)}
</ToolContent>
</Tool>
);
}
return null;
})}
</MessageContent>
</Message>
))}
{/* Show loading indicator while waiting for response stream to begin */}
{status === "submitted" && (
<Message from="assistant">
if (isToolPart(part) && showToolCalls) {
const toolPart = part as ToolUIPart;
const statusType = getToolPartType(part) as ToolUIPart["type"];
return (
<Tool key={key}>
<ToolHeader
title={getDomainToolStatus(statusType)}
type={statusType}
state={toolPart.state}
/>
<ToolContent>
<ToolInput input={toolPart.input} />
{toolPart.state === "output-available" && (
<ToolOutput
output={toolPart.output}
errorText={toolPart.errorText}
/>
)}
</ToolContent>
</Tool>
);
}
return null;
})}
</MessageContent>
</Message>
);
})}
{showThinking && (
<Message key="thinking" from="assistant">
<MessageContent>
<ShimmeringText text="Thinking…" />
<ThinkingStatus />
</MessageContent>
</Message>
)}
@@ -250,7 +265,10 @@ export function ChatPanel({
<PromptInputFooter className="pr-1.5 pb-1.5 pl-3">
<PromptInputCharacterCount current={inputLength} max={MAX_MESSAGE_LENGTH} />
<div className="flex items-center gap-2">
<ChatModeSelector disabled={status === "submitted" || status === "streaming"} />
<ChatModeSelector
browserAI={browserAI}
disabled={status === "submitted" || status === "streaming"}
/>
<PromptInputSubmit disabled={inputLength === 0} status={error ? "error" : status} />
</div>
</PromptInputFooter>
-19
View File
@@ -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
+13 -21
View File
@@ -22,39 +22,29 @@ export function CookiePrompt({ consentRequired }: { consentRequired: boolean })
const [consent, setConsent, { isPersistent }] = useLocalStorageState<ConsentStatus>(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;
}
@@ -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<string, string>,
}));
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;
}) => (
<div>
<span data-testid="resume">{JSON.stringify(resumeDomain ?? null)}</span>
<span data-testid="prefill">{prefillDomain ?? ""}</span>
<button type="button" onClick={onSuccess}>
Finish
</button>
{onClose ? (
<button type="button" onClick={onClose}>
Close
</button>
) : null}
</div>
),
}));
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(<AddDomainPageClient prefillDomain="from-report.com" />);
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(<AddDomainPageClient />);
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(<AddDomainModalClient />);
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(<AddDomainModalClient />);
await user.click(screen.getByRole("button", { name: "Close" }));
expect(nav.back).toHaveBeenCalledOnce();
expect(nav.push).not.toHaveBeenCalled();
});
});
@@ -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();
});
});
@@ -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<ResumeDomainData | null>(() => {
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 (
<AddDomainContent
@@ -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";
import { Card } from "@domainstack/ui/card";
export function AddDomainPageClient({ prefillDomain }: { prefillDomain?: string }) {
@@ -28,29 +27,7 @@ export function AddDomainPageClient({ prefillDomain }: { prefillDomain?: string
router.push("/dashboard", { scroll: false });
};
const resumeDomain = useMemo<ResumeDomainData | null>(() => {
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 (
<Card className="w-full px-6">
@@ -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<typeof userEvent.setup>) {
render(
<ShareInstructionsDialog
domain={DOMAIN}
verificationToken={TOKEN}
trackedDomainId={TRACKED_ID}
/>,
);
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);
});
});
});
@@ -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]);
@@ -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" });
}
}}
>
@@ -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(
<AddDomainContent
onSuccess={options.onSuccess ?? addDomainActionSpies.onSuccess}
onClose={options.onClose ?? addDomainActionSpies.onClose}
resumeDomain={options.resumeDomain}
prefillDomain={options.prefillDomain}
/>,
);
}
@@ -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();
});
});
@@ -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}
>
<IconRefresh />
<span className="sr-only sm:not-sr-only sm:ml-2">Reactivate</span>
@@ -57,6 +57,7 @@ export function BulkActionsToolbar({ totalCount, className }: BulkActionsToolbar
{/* Left: Select all checkbox + count */}
<ResponsiveTooltip>
<ResponsiveTooltipTrigger
nativeButton={false}
render={
<Label className="flex cursor-pointer items-center gap-2.5">
<Checkbox
@@ -0,0 +1,44 @@
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 } 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(<CalendarFeedPopover />, { 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();
});
});
@@ -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(
<DashboardBannerDismissable
variant="success"
title="Welcome to Pro!"
description="Thanks for upgrading."
dismissible
onDismiss={onDismiss}
/>,
);
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();
});
});
@@ -22,7 +22,13 @@ export function DashboardBannerDismissable(props: React.ComponentProps<typeof Da
ease: "easeInOut",
}}
>
<DashboardBanner {...props} onDismiss={() => setIsDismissed(true)} />
<DashboardBanner
{...props}
onDismiss={() => {
setIsDismissed(true);
props.onDismiss?.();
}}
/>
</motion.div>
)}
</AnimatePresence>
@@ -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<Table<TrackedDomainWithDetails> | null>(null);
const [tableInstance, setTableInstance] = useState<DashboardTable | null>(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() {
)}
</DashboardProvider>
{/* Confirmation dialog for destructive actions */}
<AlertDialog
open={pendingAction !== null}
<DashboardConfirmDialog
pendingAction={pendingAction}
onOpenChange={(open) => {
if (!open) setPendingAction(null);
}}
>
{pendingAction && (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmDialogContent(pendingAction).title}</AlertDialogTitle>
<AlertDialogDescription>
{getConfirmDialogContent(pendingAction).description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
variant={getConfirmDialogContent(pendingAction).variant}
>
{getConfirmDialogContent(pendingAction).confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
)}
</AlertDialog>
onConfirm={handleConfirm}
/>
</div>
);
}
@@ -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<typeof userEvent.setup>, 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"]);
});
});
@@ -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 (
<AlertDialog open={pendingAction !== null} onOpenChange={onOpenChange}>
{pendingAction && (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmDialogContent(pendingAction).title}</AlertDialogTitle>
<AlertDialogDescription>
{getConfirmDialogContent(pendingAction).description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
variant={getConfirmDialogContent(pendingAction).variant}
>
{getConfirmDialogContent(pendingAction).confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
)}
</AlertDialog>
);
}
@@ -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<TrackedDomainWithDetails>) => 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
</Button>
) : (
<Button
size="lg"
render={
<Link href="/dashboard/add-domain" scroll={false}>
<IconPlus />
Add Your First Domain
</Link>
}
/>
<Link
href="/dashboard/add-domain"
scroll={false}
className={buttonVariants({ size: "lg" })}
>
<IconPlus />
Add Your First Domain
</Link>
)}
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<IconHourglass className="size-4" />
@@ -137,7 +144,7 @@ export function DashboardContent({
{viewMode === "table" ? (
<DashboardTable domains={domains} onTableReady={onTableReady} />
) : (
<DashboardGrid domains={domains} />
<DashboardGrid domains={domains} delays={delays} />
)}
</motion.div>
</AnimatePresence>
@@ -243,6 +243,7 @@ export const DashboardGridCard = memo(function DashboardGridCard({
{expirationDate ? (
<ResponsiveTooltip>
<ResponsiveTooltipTrigger
nativeButton={false}
render={
<span className="truncate">{format(expirationDate, "MMM d, yyyy")}</span>
}
@@ -305,6 +306,7 @@ export const DashboardGridCard = memo(function DashboardGridCard({
<>
<ResponsiveTooltip>
<ResponsiveTooltipTrigger
nativeButton={false}
render={
<span className="truncate">
{format(expirationDate, "MMM d, yyyy")}
@@ -442,7 +444,7 @@ function InfoRow({
(provider?.name ? (
tooltipData.shouldShowTooltip ? (
<ResponsiveTooltip open={tooltipData.isOpen} onOpenChange={tooltipData.setIsOpen}>
<ResponsiveTooltipTrigger render={providerContent} />
<ResponsiveTooltipTrigger nativeButton={false} render={providerContent} />
<ResponsiveTooltipContent>
<ProviderTooltipContent
providerId={tooltipData.providerId}
@@ -461,7 +463,7 @@ function InfoRow({
</ResponsiveTooltip>
) : isTruncated ? (
<ResponsiveTooltip>
<ResponsiveTooltipTrigger render={providerContent} />
<ResponsiveTooltipTrigger nativeButton={false} render={providerContent} />
<ResponsiveTooltipContent>{provider.name}</ResponsiveTooltipContent>
</ResponsiveTooltip>
) : (
@@ -1,5 +1,4 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { DashboardGridCard } from "@/components/dashboard/dashboard-grid-card";
import { GridUpgradeCard } from "@/components/dashboard/grid-upgrade-card";
@@ -7,29 +6,56 @@ import type { TrackedDomainWithDetails } from "@domainstack/types";
type DashboardGridProps = {
domains: TrackedDomainWithDetails[];
delays: Map<string, number>;
};
export function DashboardGrid({ domains }: DashboardGridProps) {
const shouldReduceMotion = useReducedMotion();
export function createInitialDelays(domains: TrackedDomainWithDetails[]) {
const delays = new Map<string, number>();
domains.forEach((domain, index) => {
delays.set(domain.id, Math.min(index * 0.05, 0.3));
});
delays.set("upgrade-cta", Math.min(domains.length * 0.05, 0.3));
return delays;
}
// Stagger on first mount only (keeps later add/remove snappy and avoids re-staggering on sort/filter).
const isFirstMountRef = useRef(true);
useEffect(() => {
isFirstMountRef.current = false;
}, []);
export function pruneDelays(delays: Map<string, number>, domains: TrackedDomainWithDetails[]) {
const visible = new Set(domains.map((domain) => domain.id));
visible.add("upgrade-cta");
let changed = false;
const next = new Map<string, number>();
for (const [id, delay] of delays) {
if (visible.has(id)) {
next.set(id, delay);
} else {
changed = true;
}
}
return changed ? next : delays;
}
export function DashboardGrid({ domains, delays }: DashboardGridProps) {
const shouldReduceMotion = useReducedMotion();
const ease = [0.22, 1, 0.36, 1] as const;
const duration = shouldReduceMotion ? 0.1 : 0.18;
const layoutTransition = { duration, ease } as const;
const getItemMotionProps = (index: number) => {
const delay = isFirstMountRef.current && !shouldReduceMotion ? Math.min(index * 0.05, 0.3) : 0;
const getItemMotionProps = (id: string) => {
const delay = shouldReduceMotion ? 0 : (delays.get(id) ?? 0);
return {
layout: shouldReduceMotion ? false : ("position" as const),
initial: { opacity: 0, y: shouldReduceMotion ? 0 : 10 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: shouldReduceMotion ? 0 : -10 },
exit: {
opacity: 0,
y: shouldReduceMotion ? 0 : -10,
transition: {
opacity: { duration, ease, delay: 0 },
y: { duration, ease, delay: 0 },
},
},
transition: {
// Stagger only the "enter" fade/slide; never delay layout reflow.
opacity: { duration, ease, delay },
@@ -42,14 +68,14 @@ export function DashboardGrid({ domains }: DashboardGridProps) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<AnimatePresence>
{domains.map((domain, index) => (
<motion.div key={domain.id} className="h-full" {...getItemMotionProps(index)}>
{domains.map((domain) => (
<motion.div key={domain.id} className="h-full" {...getItemMotionProps(domain.id)}>
<DashboardGridCard domain={domain} />
</motion.div>
))}
{/* Free-tier CTA: treated as just another (last) grid item */}
<motion.div key="upgrade-cta" className="h-full" {...getItemMotionProps(domains.length)}>
<motion.div key="upgrade-cta" className="h-full" {...getItemMotionProps("upgrade-cta")}>
<GridUpgradeCard />
</motion.div>
</AnimatePresence>
@@ -13,7 +13,7 @@ import { QuotaBar } from "@/components/dashboard/quota-bar";
import { useSubscription } from "@/hooks/use-subscription";
import type { DashboardViewModeOptions } from "@/lib/dashboard-utils";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { Button } from "@domainstack/ui/button";
import { Button, buttonVariants } from "@domainstack/ui/button";
import {
ResponsiveTooltip,
ResponsiveTooltipContent,
@@ -72,15 +72,10 @@ export function DashboardHeader({ userName }: DashboardHeaderProps) {
{/* Add Domain button - top-right on mobile, far right on desktop */}
<div className="lg:order-last">
{subscription?.canAddMore ? (
<Button
nativeButton={false}
render={
<Link href="/dashboard/add-domain" scroll={false}>
<IconPlus />
Add Domain
</Link>
}
/>
<Link href="/dashboard/add-domain" scroll={false} className={buttonVariants()}>
<IconPlus />
Add Domain
</Link>
) : (
<ResponsiveTooltip>
<ResponsiveTooltipTrigger
@@ -0,0 +1,208 @@
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 { SubscriptionEndingBanner } from "@/components/dashboard/subscription-ending-banner";
import { daysFromTestNow } from "@/components/dashboard/test-fixtures";
import {
mockSubscription,
renderDashboardShell,
resetDashboardTestState,
subscriptionActionSpies,
} from "@/components/dashboard/test-utils";
import { UpgradeBanner } from "@/components/dashboard/upgrade-banner";
import { render, screen, waitFor } from "@/mocks/react";
import { PLAN_QUOTAS } from "@domainstack/constants";
async function waitForCatalog() {
await waitFor(() => {
expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument();
});
}
describe("dashboard quota and banners", () => {
beforeEach(() => {
resetDashboardTestState();
});
afterEach(() => {
resetDashboardTestState();
vi.useRealTimers();
});
describe("header", () => {
it("shows the Pro badge, quota meter, and Add Domain link", async () => {
renderDashboardShell();
await waitForCatalog();
expect(screen.getByText("Pro")).toBeInTheDocument();
expect(screen.getByRole("meter", { name: "Domain usage" })).toHaveAttribute(
"aria-valuetext",
"4 of 100 domains used",
);
expect(screen.getByText("4/100")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Add Domain" })).toHaveAttribute(
"href",
"/dashboard/add-domain",
);
});
it("shows a Free badge", async () => {
mockSubscription.plan = "free";
mockSubscription.planQuota = PLAN_QUOTAS.free;
renderDashboardShell();
await waitForCatalog();
expect(screen.getByText("Free")).toBeInTheDocument();
expect(screen.queryByText("Pro")).not.toBeInTheDocument();
});
it("disables Add Domain at the Pro limit", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
mockSubscription.canAddMore = false;
renderDashboardShell();
await waitForCatalog();
const addDomain = screen.getByRole("button", { name: "Add Domain" });
expect(addDomain).toBeDisabled();
expect(screen.queryByRole("link", { name: "Add Domain" })).not.toBeInTheDocument();
await user.hover(addDomain);
expect(await screen.findByText("Domain limit reached")).toBeInTheDocument();
});
it("offers checkout from the at-limit tooltip on Free", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
mockSubscription.plan = "free";
mockSubscription.planQuota = PLAN_QUOTAS.free;
mockSubscription.canAddMore = false;
renderDashboardShell();
await waitForCatalog();
await user.hover(screen.getByRole("button", { name: "Add Domain" }));
const upgrade = await screen.findByRole("button", { name: /Upgrade to add more domains/ });
await user.click(upgrade);
expect(subscriptionActionSpies.handleCheckout).toHaveBeenCalledOnce();
});
it("shows access-until copy on a canceling Pro badge", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
mockSubscription.endsAt = daysFromTestNow(10);
renderDashboardShell();
await waitForCatalog();
await user.hover(screen.getByText("Pro"));
expect(await screen.findByText("Access until Sep 2, 2026")).toBeInTheDocument();
});
});
describe("UpgradeBanner", () => {
it("hides for Pro and for Free users under 80%", () => {
render(<UpgradeBanner />);
expect(screen.queryByText("Approaching Limit")).not.toBeInTheDocument();
expect(screen.queryByText("Domain Limit Reached")).not.toBeInTheDocument();
mockSubscription.plan = "free";
mockSubscription.planQuota = PLAN_QUOTAS.free;
mockSubscription.activeCount = 3;
mockSubscription.canAddMore = true;
render(<UpgradeBanner />);
expect(screen.queryByText("Approaching Limit")).not.toBeInTheDocument();
});
it("warns when Free is near the limit and can be dismissed", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
mockSubscription.plan = "free";
mockSubscription.planQuota = PLAN_QUOTAS.free;
mockSubscription.activeCount = 4;
mockSubscription.canAddMore = true;
render(<UpgradeBanner />);
expect(screen.getByText("Approaching Limit")).toBeInTheDocument();
expect(screen.getByText(/You're using 4 of 5 domain slots/)).toBeInTheDocument();
await user.hover(screen.getByText("Approaching Limit"));
await user.click(screen.getByRole("button", { name: "Dismiss" }));
await waitFor(() => {
expect(screen.queryByText("Approaching Limit")).not.toBeInTheDocument();
});
});
it("shows the at-limit banner and starts checkout", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
mockSubscription.plan = "free";
mockSubscription.planQuota = PLAN_QUOTAS.free;
mockSubscription.activeCount = 5;
mockSubscription.canAddMore = false;
render(<UpgradeBanner />);
expect(screen.getByText("Domain Limit Reached")).toBeInTheDocument();
expect(
screen.getByText(/You've reached your limit of 5 tracked domains/),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Upgrade" }));
expect(subscriptionActionSpies.handleCheckout).toHaveBeenCalledOnce();
});
});
describe("SubscriptionEndingBanner", () => {
it("hides without an end date or when already expired", () => {
render(<SubscriptionEndingBanner />);
expect(screen.queryByText("Your Pro subscription is ending")).not.toBeInTheDocument();
mockSubscription.endsAt = daysFromTestNow(-1);
render(<SubscriptionEndingBanner />);
expect(screen.queryByText(/Pro subscription ending/)).not.toBeInTheDocument();
});
it("shows resubscribe actions when Pro is ending later", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
mockSubscription.endsAt = daysFromTestNow(10);
render(<SubscriptionEndingBanner />);
expect(screen.getByText("Your Pro subscription is ending")).toBeInTheDocument();
expect(screen.getByText("September 2, 2026")).toBeInTheDocument();
expect(
screen.getByText(new RegExp(`free quota of ${PLAN_QUOTAS.free} domains`)),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Resubscribe" }));
expect(subscriptionActionSpies.handleCheckout).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "Manage" }));
expect(subscriptionActionSpies.handleCustomerPortal).toHaveBeenCalledOnce();
});
it("uses urgent copy when Pro ends within three days", () => {
mockSubscription.endsAt = daysFromTestNow(2);
render(<SubscriptionEndingBanner />);
expect(screen.getByText("Pro subscription ending in 2 days")).toBeInTheDocument();
});
});
});
@@ -0,0 +1,594 @@
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 { makeDashboardDomains, makePaginationDomains } from "@/components/dashboard/test-fixtures";
import {
createInitialDelays,
dashboardActionSpies,
pruneDelays,
renderDashboardShell,
resetDashboardTestState,
} from "@/components/dashboard/test-utils";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { screen, waitFor, within } from "@/mocks/react";
function domainNames() {
return screen
.queryAllByRole("link")
.map((el) => el.textContent?.replace(/\s+/g, " ").trim() ?? "")
.filter((name) => name.includes("."));
}
function getFilterTrigger(name: RegExp) {
return screen.getAllByRole("combobox", { name })[0]!;
}
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<typeof userEvent.setup>, name: string) {
await user.hover(domainCard(name));
await user.click(screen.getByRole("checkbox", { name: `Select ${name}` }));
}
describe("dashboard shell", () => {
beforeEach(() => {
resetDashboardTestState();
});
afterEach(() => {
resetDashboardTestState();
vi.useRealTimers();
});
describe("view toggle and empty states", () => {
it("renders the grid by default without a table", async () => {
renderDashboardShell();
await waitForCatalog();
expect(screen.getByRole("heading", { name: /Welcome back/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Grid view" })).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
expect(domainNames()).toEqual(
expect.arrayContaining(["alpha.com", "beta.io", "gamma.com", "pending.dev"]),
);
});
it("switches between grid and table", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await user.click(screen.getByRole("button", { name: "Table view" }));
await waitFor(() => {
expect(screen.getByRole("table")).toBeInTheDocument();
});
expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Grid view" }));
await waitFor(() => {
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument();
});
it("shows the first-time empty state", () => {
renderDashboardShell({ domains: [], totalDomains: 0 });
expect(screen.getByText("Start tracking your domains")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Add Your First Domain/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Grid view" })).not.toBeInTheDocument();
});
it("shows a no-matches empty state and restores cards after clearing filters", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await user.type(screen.getByRole("textbox", { name: "Search domains" }), "zzzz");
await waitFor(() => {
expect(screen.getByText("No domains match your filters")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Clear Filters" }));
await waitFor(() => {
expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument();
});
});
});
describe("grid", () => {
it("shows status badges and complete-verification on unverified cards", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
expect(screen.getAllByText("Verified").length).toBeGreaterThan(0);
expect(screen.getByText("Pending")).toBeInTheDocument();
expect(screen.getByText("Healthy")).toBeInTheDocument();
expect(screen.getAllByText("Needs Attention").length).toBeGreaterThan(0);
await user.click(screen.getByRole("button", { name: /Complete Verification/ }));
expect(dashboardActionSpies.onVerify).toHaveBeenCalledWith("domain-pending", null);
});
it("archives, mutes, and removes a verified card from the actions menu", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await user.click(within(domainCard("alpha.com")).getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Archive" }));
expect(dashboardActionSpies.onArchive).toHaveBeenCalledWith("domain-alpha", "alpha.com");
await user.click(within(domainCard("alpha.com")).getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Mute" }));
expect(dashboardActionSpies.onToggleMuted).toHaveBeenCalledWith("domain-alpha", true);
await user.click(within(domainCard("alpha.com")).getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Remove" }));
expect(dashboardActionSpies.onRemove).toHaveBeenCalledWith("domain-alpha", "alpha.com");
});
it("reorders cards from the sort dropdown", async () => {
const user = userEvent.setup();
const { urlUpdates } = renderDashboardShell();
await waitForCatalog();
await user.click(screen.getByRole("button", { name: /Sort:/ }));
await user.click(screen.getByRole("menuitemradio", { name: "Name (Z-A)" }));
await waitFor(() => {
expect(domainNames()).toEqual(["pending.dev", "gamma.com", "beta.io", "alpha.com"]);
});
expect(urlUpdates.some((url) => url.includes("sort=domainName.desc"))).toBe(true);
});
it("sorts by expiry from the dropdown and keeps unverified last", async () => {
const user = userEvent.setup();
const { urlUpdates } = renderDashboardShell();
await waitForCatalog();
await user.click(screen.getByRole("button", { name: /Sort:/ }));
await user.click(screen.getByRole("menuitemradio", { name: "Expiry (Soonest first)" }));
await waitFor(() => {
expect(domainNames()).toEqual(["gamma.com", "beta.io", "alpha.com", "pending.dev"]);
});
expect(urlUpdates.some((url) => url.includes("sort=expirationDate.asc"))).toBe(true);
});
it("selects a card and shows the bulk toolbar", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument();
await selectGridCard(user, "alpha.com");
const toolbar = await screen.findByRole("toolbar", { name: "Bulk actions" });
expect(within(toolbar).getByText("1 selected")).toBeInTheDocument();
});
});
describe("table", () => {
async function openTable(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "Table view" }));
await waitFor(() => {
expect(screen.getByRole("table")).toBeInTheDocument();
});
}
it("renders domain links and unverified continue/remove actions", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await openTable(user);
const table = screen.getByRole("table");
expect(within(table).getByRole("link", { name: "alpha.com" })).toBeInTheDocument();
await user.click(within(table).getByRole("button", { name: "Continue" }));
expect(dashboardActionSpies.onVerify).toHaveBeenCalledWith("domain-pending", null);
await user.click(within(table).getByRole("button", { name: "Remove" }));
expect(dashboardActionSpies.onRemove).toHaveBeenCalledWith("domain-pending", "pending.dev");
});
it("toggles sort from the domain header and keeps unverified last on expiry", async () => {
const user = userEvent.setup();
const { urlUpdates } = renderDashboardShell();
await waitForCatalog();
await openTable(user);
await user.click(screen.getByRole("button", { name: /^Domain$/ }));
await waitFor(() => {
expect(urlUpdates.some((url) => url.includes("sort=domainName.desc"))).toBe(true);
});
await user.click(screen.getByRole("button", { name: /^Expires$/ }));
await waitFor(() => {
const names = within(screen.getByRole("table"))
.getAllByRole("link")
.map((el) => el.textContent?.trim());
expect(names.at(-1)).toBe("pending.dev");
});
});
it("paginates and resets the page when page size changes", async () => {
const user = userEvent.setup();
const { urlUpdates } = renderDashboardShell({ domains: makePaginationDomains(12) });
await waitFor(() => {
expect(screen.getByRole("link", { name: "site00.com" })).toBeInTheDocument();
});
await openTable(user);
const table = screen.getByRole("table");
expect(within(table).getAllByRole("link")).toHaveLength(10);
expect(screen.getByText("1 of 2")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Go to next page" }));
await waitFor(() => {
expect(screen.getByText("2 of 2")).toBeInTheDocument();
expect(
within(screen.getByRole("table")).queryByRole("link", { name: "site00.com" }),
).not.toBeInTheDocument();
expect(within(screen.getByRole("table")).getAllByRole("link")).toHaveLength(2);
});
expect(urlUpdates.some((url) => /(?:^|[?&])page=2(?:&|$)/.test(url))).toBe(true);
const pageSize = screen.getByRole("combobox", { name: "Domains per page" });
await user.click(pageSize);
await user.click(await screen.findByRole("option", { name: "25" }));
await waitFor(() => {
expect(within(screen.getByRole("table")).getAllByRole("link")).toHaveLength(12);
});
expect(screen.getByText("1 of 1")).toBeInTheDocument();
});
it("returns to page 1 when filters change even if page 2 still has rows", async () => {
const user = userEvent.setup();
usePreferencesStore.setState({ viewMode: "table" });
renderDashboardShell({
domains: makePaginationDomains(12),
searchParams: "page=2",
});
await waitFor(() => {
expect(screen.getByText("2 of 2")).toBeInTheDocument();
expect(
within(screen.getByRole("table")).queryByRole("link", { name: "site00.com" }),
).not.toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Grid view" }));
// "s" still matches all 12 sites, so clamp alone would leave page 2 empty of site00.
await user.type(screen.getAllByRole("textbox", { name: "Search domains" })[0]!, "s");
await waitFor(() => {
expect(domainNames()).toEqual(expect.arrayContaining(["site00.com", "site11.com"]));
});
await user.click(screen.getByRole("button", { name: "Table view" }));
await waitFor(() => {
expect(
within(screen.getByRole("table")).getByRole("link", { name: "site00.com" }),
).toBeInTheDocument();
expect(screen.getByText("1 of 2")).toBeInTheDocument();
});
});
it("clamps an impossible deep-linked page to page 1", async () => {
usePreferencesStore.setState({ viewMode: "table" });
renderDashboardShell({
domains: makePaginationDomains(2),
searchParams: "page=2",
});
await waitFor(() => {
expect(screen.getByRole("table")).toBeInTheDocument();
expect(
within(screen.getByRole("table")).getByRole("link", { name: "site00.com" }),
).toBeInTheDocument();
expect(screen.getByText("1 of 1")).toBeInTheDocument();
});
});
it("keeps a deep-linked page when filters are not changed", async () => {
const user = userEvent.setup();
usePreferencesStore.setState({ viewMode: "table" });
renderDashboardShell({
domains: makePaginationDomains(12),
searchParams: "page=2",
});
await waitFor(() => {
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getByText("2 of 2")).toBeInTheDocument();
});
expect(
within(screen.getByRole("table")).queryByRole("link", { name: "site00.com" }),
).not.toBeInTheDocument();
expect(within(screen.getByRole("table")).getAllByRole("link")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "Go to previous page" }));
await waitFor(() => {
expect(
within(screen.getByRole("table")).getByRole("link", { name: "site00.com" }),
).toBeInTheDocument();
});
});
it("hides and restores a column from the column menu", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await openTable(user);
expect(screen.getByRole("button", { name: /^Registrar$/ })).toBeInTheDocument();
await user.click(screen.getAllByRole("button", { name: "Toggle columns" })[0]!);
await user.click(await screen.findByRole("menuitemcheckbox", { name: /Registrar/ }));
await waitFor(() => {
expect(screen.queryByRole("button", { name: /^Registrar$/ })).not.toBeInTheDocument();
});
await user.click(screen.getByRole("menuitem", { name: /Show all columns/ }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /^Registrar$/ })).toBeInTheDocument();
});
});
it("selects a row and shows the bulk toolbar", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await openTable(user);
await user.click(screen.getByRole("checkbox", { name: "Select alpha.com" }));
expect(await screen.findByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument();
});
});
describe("filters", () => {
it("filters by search and restores after clearing the chip", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await user.type(screen.getByRole("textbox", { name: "Search domains" }), "beta");
await waitFor(() => {
expect(domainNames()).toEqual(["beta.io"]);
});
expect(screen.getByText('"beta"')).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Remove search filter" }));
await waitFor(() => {
expect(domainNames()).toEqual(expect.arrayContaining(["alpha.com", "beta.io"]));
});
});
it("filters by health from the dropdown and supports clear all", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await user.click(getFilterTrigger(/^Health/));
await user.click(await screen.findByRole("option", { name: "Expiring Soon" }));
await waitFor(() => {
expect(domainNames()).toEqual(["beta.io"]);
});
expect(screen.getByRole("button", { name: "Remove health filter" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Clear all" }));
await waitFor(() => {
expect(domainNames()).toEqual(expect.arrayContaining(["alpha.com", "gamma.com"]));
});
});
it("filters by TLD and provider from initial search params", async () => {
renderDashboardShell({ searchParams: "tlds=io" });
await waitFor(() => {
expect(domainNames()).toEqual(["beta.io"]);
});
expect(screen.getByText(".io")).toBeInTheDocument();
});
it("filters by provider from initial search params", async () => {
renderDashboardShell({ searchParams: "providers=cloudflare" });
await waitFor(() => {
expect(domainNames().sort()).toEqual(["alpha.com", "beta.io"]);
});
});
it("applies pending and expiring filters from the health summary", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Filter by pending verification" }),
).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Filter by expiring domains" })).toHaveTextContent(
"1expiring soon",
);
await user.click(screen.getByRole("button", { name: "Filter by pending verification" }));
await waitFor(() => {
expect(domainNames()).toEqual(["pending.dev"]);
});
expect(screen.getByRole("button", { name: "Remove status filter" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Clear all" }));
await user.click(screen.getByRole("button", { name: "Filter by expiring domains" }));
await waitFor(() => {
expect(domainNames()).toEqual(["beta.io"]);
});
});
it("removes one chip without clearing the rest", async () => {
const user = userEvent.setup();
renderDashboardShell({ searchParams: "search=a&tlds=com" });
await waitFor(() => {
expect(domainNames().sort()).toEqual(["alpha.com", "gamma.com"]);
});
await user.click(screen.getByRole("button", { name: "Remove tld filter" }));
await waitFor(() => {
expect(domainNames()).toEqual(expect.arrayContaining(["alpha.com", "beta.io"]));
});
expect(screen.getByText('"a"')).toBeInTheDocument();
});
it("pins a domain from domainId search params", async () => {
renderDashboardShell({ searchParams: "domainId=domain-alpha" });
await waitFor(() => {
expect(domainNames()).toEqual(["alpha.com"]);
});
expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument();
expect(screen.getByText("Domain:")).toBeInTheDocument();
});
});
describe("bulk toolbar", () => {
it("archives, deletes, cancels, and select-alls visible ids", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await selectGridCard(user, "alpha.com");
await selectGridCard(user, "beta.io");
const toolbar = await screen.findByRole("toolbar", { name: "Bulk actions" });
expect(within(toolbar).getByText("2 selected")).toBeInTheDocument();
await user.click(within(toolbar).getByRole("button", { name: "Archive" }));
expect(dashboardActionSpies.onBulkArchive).toHaveBeenCalledWith([
"domain-alpha",
"domain-beta",
]);
await user.click(within(toolbar).getByRole("button", { name: "Delete" }));
expect(dashboardActionSpies.onBulkDelete).toHaveBeenCalledWith([
"domain-alpha",
"domain-beta",
]);
await user.click(within(toolbar).getByRole("button", { name: "Cancel selection" }));
await waitFor(() => {
expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument();
});
await selectGridCard(user, "alpha.com");
const toolbarAgain = await screen.findByRole("toolbar", { name: "Bulk actions" });
await user.click(within(toolbarAgain).getByRole("checkbox"));
await waitFor(() => {
expect(within(toolbarAgain).getByText("4 selected")).toBeInTheDocument();
});
});
it("selects only the filtered visible ids", async () => {
const user = userEvent.setup();
renderDashboardShell({ searchParams: "tlds=com" });
await waitFor(() => {
expect(domainNames().sort()).toEqual(["alpha.com", "gamma.com"]);
});
await selectGridCard(user, "alpha.com");
const toolbar = await screen.findByRole("toolbar", { name: "Bulk actions" });
await user.click(within(toolbar).getByRole("checkbox"));
await waitFor(() => {
expect(within(toolbar).getByText("2 selected")).toBeInTheDocument();
});
});
it("drops hidden domains from the selection when filters change", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await selectGridCard(user, "alpha.com");
expect(await screen.findByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument();
await user.type(screen.getByRole("textbox", { name: "Search domains" }), "beta");
await waitFor(() => {
expect(domainNames()).toEqual(["beta.io"]);
expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument();
});
});
it("clears selection on Escape", async () => {
const user = userEvent.setup();
renderDashboardShell();
await waitForCatalog();
await selectGridCard(user, "alpha.com");
expect(await screen.findByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument();
});
});
});
describe("stagger delays", () => {
it("creates and prunes first-paint delays", () => {
const catalog = makeDashboardDomains();
const delays = createInitialDelays(catalog);
expect(delays.get("domain-alpha")).toBe(0);
expect(delays.has("upgrade-cta")).toBe(true);
const pruned = pruneDelays(delays, [catalog[0]!]);
expect(pruned.has("domain-alpha")).toBe(true);
expect(pruned.has("domain-beta")).toBe(false);
expect(pruned.has("upgrade-cta")).toBe(true);
});
});
describe("preferences", () => {
it("opens in table view when the preference is already table", async () => {
usePreferencesStore.setState({ viewMode: "table" });
renderDashboardShell();
await waitFor(() => {
expect(screen.getByRole("table")).toBeInTheDocument();
});
});
});
});
@@ -1,6 +1,6 @@
import { IconEye, IconTableOptions } from "@tabler/icons-react";
import type { Table } from "@tanstack/react-table";
import type { DashboardTable } from "@/lib/dashboard-table-features";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { Button } from "@domainstack/ui/button";
import {
@@ -14,18 +14,19 @@ import {
import { ScrollArea } from "@domainstack/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@domainstack/ui/tooltip";
type DashboardTableColumnMenuProps<TData> = {
table: Table<TData>;
type DashboardTableColumnMenuProps = {
table: DashboardTable;
};
export function DashboardTableColumnMenu<TData>({ table }: DashboardTableColumnMenuProps<TData>) {
// Read visibility state directly from store (not stale table API)
export function DashboardTableColumnMenu({ table }: DashboardTableColumnMenuProps) {
// Controlled visibility lives in the preferences store. Reading it here
// (instead of `column.getIsVisible()`) keeps this nested menu correct under
// the compiler without a `table.Subscribe` boundary.
const columnVisibility = usePreferencesStore((s) => s.columnVisibility);
const setColumnVisibility = usePreferencesStore((s) => s.setColumnVisibility);
const allColumns = table.getAllColumns().filter((column) => column.getCanHide());
// Check visibility from our state, not the table API
const isColumnVisible = (columnId: string) => columnVisibility[columnId] !== false;
const hiddenCount = allColumns.filter((column) => !isColumnVisible(column.id)).length;
@@ -1,4 +1,3 @@
"use no memo"; // Disable React Compiler memoization - TanStack Table has issues with it
import {
IconArchive,
IconBell,
@@ -8,7 +7,7 @@ import {
IconExternalLink,
IconTrash,
} from "@tabler/icons-react";
import type { ColumnDef, RowData } from "@tanstack/react-table";
import type { ColumnDef, RowData, TableFeatures } from "@tanstack/react-table";
import { format } from "date-fns";
import Link from "next/link";
@@ -17,6 +16,8 @@ import { DomainStatusBadge } from "@/components/dashboard/domain-status-badge";
import { ProviderCell } from "@/components/dashboard/provider-cell";
import { ScreenshotPopover } from "@/components/domain/screenshot-popover";
import { Favicon } from "@/components/icons/favicon";
import { useIsDomainSelected, useToggleDomainSelection } from "@/hooks/use-dashboard-selection";
import type { DashboardTableFeatures } from "@/lib/dashboard-table-features";
import type { VerificationMethod } from "@domainstack/constants";
import type { TrackedDomainWithDetails } from "@domainstack/types";
import { Button } from "@domainstack/ui/button";
@@ -38,7 +39,7 @@ import { formatDateTimeUtc } from "@domainstack/utils";
// Define custom column meta for styling
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
interface ColumnMeta<TFeatures extends TableFeatures, TData extends RowData, TValue> {
className?: string;
}
}
@@ -80,9 +81,47 @@ export function createUnverifiedLastSorter(isDescFn: (columnId: string) => boole
};
}
type DomainSelectCellProps = {
domainId: string;
domainName: string;
};
/**
* Subscribes to selection itself so the compiler can memoize the table/row
* while this checkbox still updates. Selection is app state (Jotai), not
* TanStack row-selection, so `table.Subscribe` does not apply here.
*/
function DomainSelectCell({ domainId, domainName }: DomainSelectCellProps) {
const isSelected = useIsDomainSelected(domainId);
const toggle = useToggleDomainSelection();
return (
<div className="relative size-4">
{/* Favicon - hidden on hover, keyboard focus, or when selected */}
<Favicon
domain={domainName}
className={cn(
"absolute inset-0",
isSelected ? "hidden" : "group-focus-within:hidden group-hover:hidden",
)}
/>
{/* Checkbox stays mounted so it remains focusable when unselected */}
<Checkbox
checked={isSelected}
onCheckedChange={() => toggle(domainId)}
aria-label={`Select ${domainName}`}
className={cn(
"absolute inset-0",
isSelected
? "opacity-100"
: "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
)}
/>
</div>
);
}
export type ColumnCallbacks = {
selectedIdsRef: React.RefObject<Set<string>>;
onToggleSelect?: (id: string) => void;
onVerify: (id: string, verificationMethod: VerificationMethod | null) => void;
onRemove: (id: string, domainName: string) => void;
onArchive: (id: string, domainName: string) => void;
@@ -90,42 +129,19 @@ export type ColumnCallbacks = {
withUnverifiedLast: ReturnType<typeof createUnverifiedLastSorter>;
};
export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDomainWithDetails>[] {
const {
selectedIdsRef,
onToggleSelect,
onVerify,
onRemove,
onArchive,
onToggleMuted,
withUnverifiedLast,
} = callbacks;
export function createColumns(
callbacks: ColumnCallbacks,
): ColumnDef<DashboardTableFeatures, TrackedDomainWithDetails>[] {
const { onVerify, onRemove, onArchive, onToggleMuted, withUnverifiedLast } = callbacks;
return [
// Selection checkbox column
{
id: "select",
header: () => null, // No header checkbox here - it's in the bulk toolbar
cell: ({ row }) => {
// Read from ref to avoid columns recreation on selection change
const isSelected = selectedIdsRef.current?.has(row.original.id);
return (
<div className="relative size-4">
{/* Favicon - hidden on hover or when selected */}
<Favicon
domain={row.original.domainName}
className={cn("absolute inset-0", isSelected ? "hidden" : "group-hover:hidden")}
/>
{/* Checkbox - shown on hover or when selected */}
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect?.(row.original.id)}
aria-label={`Select ${row.original.domainName}`}
className={cn("absolute inset-0", isSelected ? "flex" : "hidden group-hover:flex")}
/>
</div>
);
},
cell: ({ row }) => (
<DomainSelectCell domainId={row.original.id} domainName={row.original.domainName} />
),
size: 40,
enableHiding: false, // Always show selection column
meta: {
@@ -174,7 +190,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
},
size: 100,
// Sort verified domains first (verified = -1, unverified = 1)
sortingFn: (rowA, rowB) =>
sortFn: (rowA, rowB) =>
rowA.original.verified === rowB.original.verified ? 0 : rowA.original.verified ? -1 : 1,
},
{
@@ -190,7 +206,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
size: 100,
// Sort by health status priority: critical (0) > warning (1) > healthy (2) > unknown (3)
// Within the same status, sort by expiration date for more granular ordering
sortingFn: withUnverifiedLast((a, b) => {
sortFn: withUnverifiedLast((a, b) => {
const now = new Date();
const getHealthPriority = (exp: Date | null, verified: boolean): number => {
if (!verified || !exp) return 3; // unknown
@@ -236,7 +252,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
);
},
size: 110,
sortingFn: withUnverifiedLast((a, b) => {
sortFn: withUnverifiedLast((a, b) => {
const aTime = a.expirationDate?.getTime() ?? 0;
const bTime = b.expirationDate?.getTime() ?? 0;
return aTime - bTime;
@@ -254,7 +270,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
/>
),
size: 128,
sortingFn: withUnverifiedLast((a, b) => {
sortFn: withUnverifiedLast((a, b) => {
const aName = a.registrar.name ?? "";
const bName = b.registrar.name ?? "";
return aName.localeCompare(bName);
@@ -272,7 +288,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
/>
),
size: 128,
sortingFn: withUnverifiedLast((a, b) => {
sortFn: withUnverifiedLast((a, b) => {
const aName = a.dns.name ?? "";
const bName = b.dns.name ?? "";
return aName.localeCompare(bName);
@@ -290,7 +306,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
/>
),
size: 128,
sortingFn: withUnverifiedLast((a, b) => {
sortFn: withUnverifiedLast((a, b) => {
const aName = a.hosting.name ?? "";
const bName = b.hosting.name ?? "";
return aName.localeCompare(bName);
@@ -308,7 +324,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
/>
),
size: 128,
sortingFn: withUnverifiedLast((a, b) => {
sortFn: withUnverifiedLast((a, b) => {
const aName = a.email.name ?? "";
const bName = b.email.name ?? "";
return aName.localeCompare(bName);
@@ -326,7 +342,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
/>
),
size: 128,
sortingFn: withUnverifiedLast((a, b) => {
sortFn: withUnverifiedLast((a, b) => {
const aName = a.ca.name ?? "";
const bName = b.ca.name ?? "";
return aName.localeCompare(bName);
@@ -355,7 +371,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
);
},
size: 110,
sortingFn: withUnverifiedLast((a, b) => {
sortFn: withUnverifiedLast((a, b) => {
const aTime = a.registrationDate?.getTime() ?? 0;
const bTime = b.registrationDate?.getTime() ?? 0;
return aTime - bTime;
@@ -381,8 +397,7 @@ export function createColumns(callbacks: ColumnCallbacks): ColumnDef<TrackedDoma
);
},
size: 110,
sortingFn: (rowA, rowB) =>
rowA.original.createdAt.getTime() - rowB.original.createdAt.getTime(),
sortFn: (rowA, rowB) => rowA.original.createdAt.getTime() - rowB.original.createdAt.getTime(),
},
{
id: "actions",
@@ -43,7 +43,10 @@ export function DashboardTablePagination({
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value) as DashboardPageSizeOptions)}
>
<SelectTrigger className="!h-8 cursor-pointer gap-1.5 px-2 text-xs">
<SelectTrigger
aria-label="Domains per page"
className="!h-8 cursor-pointer gap-1.5 px-2 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1,17 +1,8 @@
"use no memo"; // Disable React Compiler memoization - TanStack Table has issues with it
// See: https://github.com/TanStack/table/issues/5567
import type { SortingState } from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { flexRender, useTable } from "@tanstack/react-table";
import { AnimatePresence } from "motion/react";
import { parseAsString, useQueryState } from "nuqs";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo } from "react";
import {
createColumns,
@@ -23,7 +14,11 @@ import { UnverifiedTableRow } from "@/components/dashboard/unverified-table-row"
import { UpgradeRow } from "@/components/dashboard/upgrade-row";
import { VerifiedTableRow } from "@/components/dashboard/verified-table-row";
import { useDashboardActions, useDashboardPaginationContext } from "@/context/dashboard-context";
import { useDashboardSelection } from "@/hooks/use-dashboard-selection";
import {
dashboardTableFeatures,
type DashboardTable,
type DashboardTableFeatures,
} from "@/lib/dashboard-table-features";
import { DEFAULT_SORT, parseSortParam, serializeSortState } from "@/lib/dashboard-utils";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import type { TrackedDomainWithDetails } from "@domainstack/types";
@@ -32,20 +27,19 @@ import { cn } from "@domainstack/ui/utils";
type DashboardTableProps = {
domains: TrackedDomainWithDetails[];
onTableReady?: (table: ReturnType<typeof useReactTable<TrackedDomainWithDetails>>) => void;
onTableReady?: (table: DashboardTable) => void;
};
export function DashboardTable({ domains, onTableReady }: DashboardTableProps) {
// Get selection and actions from context
const { selectedIds, toggle } = useDashboardSelection();
const { onVerify, onRemove, onArchive, onToggleMuted } = useDashboardActions();
const { pageIndex, pageSize, setPageSize, setPageIndex, resetPage } =
useDashboardPaginationContext();
const pagination = { pageIndex, pageSize };
const pagination = useMemo(
(): PaginationState => ({ pageIndex, pageSize }),
[pageIndex, pageSize],
);
// Table sort state with URL persistence
const onSortChangeRef = useRef(resetPage);
onSortChangeRef.current = resetPage;
const [sortParam, setSortParam] = useQueryState(
"sort",
parseAsString.withDefault(DEFAULT_SORT).withOptions({
@@ -58,62 +52,64 @@ export function DashboardTable({ domains, onTableReady }: DashboardTableProps) {
(updater: SortingState | ((old: SortingState) => SortingState)) => {
const newSorting = typeof updater === "function" ? updater(sorting) : updater;
setSortParam(serializeSortState(newSorting));
onSortChangeRef.current?.();
resetPage();
},
[sorting, setSortParam],
[sorting, setSortParam, resetPage],
);
const columnVisibility = usePreferencesStore((s) => s.columnVisibility);
const setColumnVisibility = usePreferencesStore((s) => s.setColumnVisibility);
// Use refs to store current state so columns can be memoized
// without being recreated on every selection/sort change
const sortingRef = useRef(sorting);
sortingRef.current = sorting;
const selectedIdsRef = useRef(selectedIds);
selectedIdsRef.current = selectedIds;
// Create a stable sorting helper that reads from ref instead of closing over state
// This prevents columns from being recreated on every render
const withUnverifiedLast = useMemo(
() =>
createUnverifiedLastSorter((columnId) => {
const columnSort = sortingRef.current.find((s) => s.id === columnId);
const columnSort = sorting.find((s) => s.id === columnId);
return columnSort?.desc ?? false;
}),
[], // Empty deps - reads from ref, not state
[sorting],
);
const columns = useMemo(
() =>
createColumns({
selectedIdsRef,
onToggleSelect: toggle,
onVerify,
onRemove,
onArchive,
onToggleMuted,
withUnverifiedLast,
}),
// Note: selectedIds is accessed via ref (selectedIdsRef) to avoid recreating
// columns on every selection change. The table re-renders cells independently.
[toggle, onRemove, onArchive, onToggleMuted, onVerify, withUnverifiedLast],
[onRemove, onArchive, onToggleMuted, onVerify, withUnverifiedLast],
);
const table = useReactTable({
data: domains,
columns,
state: { sorting, pagination, columnVisibility },
onSortingChange: setSorting,
onPaginationChange: (updater) => {
const tableState = useMemo(
() => ({ sorting, pagination, columnVisibility }),
[sorting, pagination, columnVisibility],
);
const onPaginationChange = useCallback<OnChangeFn<PaginationState>>(
(updater) => {
const newPagination = typeof updater === "function" ? updater(pagination) : updater;
setPageIndex(newPagination.pageIndex);
},
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
[pagination, setPageIndex],
);
// v9 `useTable` returns a new wrapper whenever `tableOptions` identity changes.
// Inline options would make `onTableReady={setTableInstance}` loop the parent.
const tableOptions = useMemo(
() => ({
features: dashboardTableFeatures,
data: domains,
columns,
state: tableState,
onSortingChange: setSorting,
onPaginationChange,
onColumnVisibilityChange: setColumnVisibility,
}),
[domains, columns, tableState, setSorting, onPaginationChange, setColumnVisibility],
);
const table = useTable<DashboardTableFeatures, TrackedDomainWithDetails>(tableOptions);
// Expose table instance to parent for column visibility menu in filters bar
useEffect(() => {
@@ -152,10 +148,7 @@ export function DashboardTable({ domains, onTableReady }: DashboardTableProps) {
}
const canSort = header.column.getCanSort();
// Get sort state directly from our state instead of table API
// (header.column.getIsSorted() can return stale values)
const sortEntry = sorting.find((s) => s.id === header.column.id);
const isSorted = sortEntry ? (sortEntry.desc ? "desc" : "asc") : false;
const isSorted = header.column.getIsSorted();
const headerContent = header.isPlaceholder ? null : canSort ? (
<button
@@ -205,7 +198,6 @@ export function DashboardTable({ domains, onTableReady }: DashboardTableProps) {
<AnimatePresence initial={false}>
{table.getRowModel().rows.map((row) => {
const isUnverified = !row.original.verified;
const isSelected = selectedIds.has(row.original.id);
const cells = row.getVisibleCells();
if (isUnverified) {
@@ -215,7 +207,6 @@ export function DashboardTable({ domains, onTableReady }: DashboardTableProps) {
rowId={row.id}
cells={cells}
original={row.original}
isSelected={isSelected}
/>
);
}
@@ -225,7 +216,7 @@ export function DashboardTable({ domains, onTableReady }: DashboardTableProps) {
key={row.id}
rowId={row.id}
cells={cells}
isSelected={isSelected}
original={row.original}
/>
);
})}
@@ -238,7 +229,7 @@ export function DashboardTable({ domains, onTableReady }: DashboardTableProps) {
{/* Pagination controls - only show if there are domains */}
{domains.length > 0 && (
<DashboardTablePagination
pageIndex={table.getState().pagination.pageIndex}
pageIndex={table.state.pagination.pageIndex}
pageSize={pageSize}
pageCount={table.getPageCount()}
canPreviousPage={table.getCanPreviousPage()}
@@ -1,9 +1,9 @@
import { IconChevronDown, IconFilter } from "@tabler/icons-react";
import type { Table } from "@tanstack/react-table";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { DashboardTableColumnMenu } from "@/components/dashboard/dashboard-table-column-menu";
import type { DashboardTable } from "@/lib/dashboard-table-features";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { Badge } from "@domainstack/ui/badge";
import { Button } from "@domainstack/ui/button";
@@ -13,7 +13,7 @@ import { cn } from "@domainstack/ui/utils";
type MobileFiltersCollapsibleProps = {
hasActiveFilters: boolean;
activeFilterCount: number;
table?: Table<any> | null;
table?: DashboardTable | null;
children: React.ReactNode;
};
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
export function Favicon({ domain }: { domain: string }) {
return <span aria-hidden data-domain={domain} />;
}
export function ProviderLogo({
providerId,
}: {
providerId?: string | null;
providerName?: string | null;
}) {
if (!providerId) {
return null;
}
return <span aria-hidden data-provider-logo={providerId} />;
}
export function ScreenshotPopover({ children }: { children: ReactNode }) {
return children;
}
export function CalendarFeedPopover() {
return null;
}
export function useProviderTooltipData() {
return {
isOpen: false,
setIsOpen: () => undefined,
shouldShowTooltip: false,
isLoading: false,
};
}
@@ -0,0 +1,7 @@
export function ShareInstructionsDialog() {
return (
<button type="button" aria-label="Share instructions">
Share
</button>
);
}
@@ -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,
};
}
@@ -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> = {},
): 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> = {}): 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),
});
});
}
@@ -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<DashboardTable | null>(null);
const [pendingAction, setPendingAction] = useState<ConfirmAction | null>(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 (
<DashboardProvider
onVerify={dashboardActionSpies.onVerify}
onRemove={onRemove}
onArchive={onArchive}
onUnarchive={dashboardActionSpies.onUnarchive}
onToggleMuted={dashboardActionSpies.onToggleMuted}
onBulkArchive={onBulkArchive}
onBulkDelete={onBulkDelete}
isBulkArchiving={false}
isBulkDeleting={false}
filterHook={filterHook}
sortOption={sortOption}
setSortOption={setSortParam}
table={viewMode === "table" ? tableInstance : null}
setTable={setTableInstance}
paginationHook={paginationHook}
>
<div className="space-y-6">
<DashboardHeader userName={userName} />
{totalDomains > 0 && (
<div className="space-y-4">
<HealthSummary />
<DashboardFilters />
</div>
)}
<DashboardContent
domains={filteredDomains}
totalDomains={totalDomains}
onTableReady={setTableInstance}
/>
</div>
{confirmActions && pendingAction ? (
<DashboardConfirmDialog
pendingAction={pendingAction}
onOpenChange={(open) => {
if (!open) setPendingAction(null);
}}
onConfirm={handleConfirm}
/>
) : null}
</DashboardProvider>
);
}
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(
<NuqsTestingAdapter
searchParams={options.searchParams ?? ""}
hasMemory
onUrlUpdate={(event) => {
urlUpdates.push(event.queryString);
}}
>
<DashboardTestShell
domains={domains}
totalDomains={totalDomains}
userName={options.userName}
confirmActions={options.confirmActions}
/>
</NuqsTestingAdapter>,
);
return { ...view, domains, urlUpdates };
}
export function renderDashboardConfirmShell(options: RenderDashboardShellOptions = {}) {
return renderDashboardShell({ ...options, confirmActions: true });
}
export function renderArchivedList(domains: TrackedDomainWithDetails[]) {
mockSubscription.activeCount = 0;
return render(
<TooltipProvider>
<DashboardProvider
onVerify={dashboardActionSpies.onVerify}
onRemove={dashboardActionSpies.onRemove}
onArchive={dashboardActionSpies.onArchive}
onUnarchive={dashboardActionSpies.onUnarchive}
onToggleMuted={dashboardActionSpies.onToggleMuted}
onBulkArchive={dashboardActionSpies.onBulkArchive}
onBulkDelete={dashboardActionSpies.onBulkDelete}
isBulkArchiving={false}
isBulkDeleting={false}
filterHook={stubFilterHook()}
sortOption={DEFAULT_SORT}
setSortOption={vi.fn<(sort: SortOption) => void>()}
table={null}
setTable={vi.fn<(table: DashboardTable | null) => void>()}
paginationHook={stubPaginationHook()}
>
<ArchivedDomainsList domains={domains} />
</DashboardProvider>
</TooltipProvider>,
);
}
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);
@@ -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<TrackedDomainWithDetails, unknown>[];
cells: Cell<DashboardTableFeatures, TrackedDomainWithDetails, unknown>[];
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]));
@@ -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<TrackedDomainWithDetails, unknown>[];
isSelected: boolean;
cells: Cell<DashboardTableFeatures, TrackedDomainWithDetails, unknown>[];
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 (
<motion.tr
+5 -4
View File
@@ -3,12 +3,12 @@
import { IconDownload } from "@tabler/icons-react";
import { notifyManager, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { analytics } from "@/lib/analytics/client";
import { exportDomainData } from "@/lib/json-export";
import { useTRPC } from "@/lib/trpc/client";
import { analytics } from "@domainstack/analytics/client";
import { Button } from "@domainstack/ui/button";
import { toast } from "@domainstack/ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "@domainstack/ui/tooltip";
import { cn } from "@domainstack/ui/utils";
@@ -68,9 +68,10 @@ export function ExportButton({ domain, enabled = true }: { domain: string; enabl
exportDomainData(domain, exportData);
} catch (err) {
toast.error(`Failed to export ${domain}`, {
toast.add({
title: `Failed to export ${domain}`,
description: err instanceof Error ? err.message : "An error occurred while exporting",
position: "bottom-center",
type: "error",
});
}
}, [domain, queryClient, queryKeys]);
@@ -2,8 +2,8 @@ import { SiCloudflare } from "@icons-pack/react-simple-icons";
import { useSuspenseQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useAnalytics } from "@/lib/analytics/client";
import { useTRPC } from "@/lib/trpc/client";
import { useAnalytics } from "@domainstack/analytics/client";
import { Button } from "@domainstack/ui/button";
import { Skeleton } from "@domainstack/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@domainstack/ui/tooltip";
@@ -153,16 +153,18 @@ function HighlightedLine({ line, isJson }: { line: string; isJson: boolean }): R
}
// Attempt tokenization with graceful fallback to plain text
let tokens: Token[];
let tokens: Token[] | null = null;
try {
tokens = tokenizeLine(line);
// Sanity check: ensure tokens reconstruct the original line
const reconstructed = tokens.map((t) => 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<string, number>();
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]);
+11 -7
View File
@@ -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<string | null>(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<HTMLDivElement>(null);
const sectionIds = Object.keys(sections);
@@ -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,
@@ -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<HTMLElement>) => {
// 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);
}}
>
<PopoverTrigger
+45 -21
View File
@@ -4,11 +4,11 @@ import { IconCircleX, IconShieldExclamation } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { analytics } from "@/lib/analytics/client";
import { parseRetryAfterHeader } from "@/lib/ratelimit/client";
import { analytics } from "@domainstack/analytics/client";
import { Spinner } from "@domainstack/ui/spinner";
import { toast } from "@domainstack/ui/toast";
import { cn } from "@domainstack/ui/utils";
type ScreenshotStartResponse =
@@ -116,6 +116,8 @@ export function useScreenshot({
const queryClient = useQueryClient();
const [runId, setRunId] = useState<string | null>(null);
const [screenshotData, setScreenshotData] = useState<ScreenshotData | null>(null);
const [pollFailed, setPollFailed] = useState(false);
const [pollError, setPollError] = useState<Error | null>(null);
const hasStartedRef = useRef(false);
const startedForDomainRef = useRef<string | null>(null);
const [rateLimitedUntil, setRateLimitedUntil] = useState<number | null>(null);
@@ -124,6 +126,16 @@ export function useScreenshot({
const screenshotQueryKey = useMemo(() => ["screenshot", domain], [domain]);
const cachedData = queryClient.getQueryData<ScreenshotData>(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 &&
@@ -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<T>(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;
}
@@ -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<BlobParams[] | null>(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;
+9 -9
View File
@@ -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={<a ref={hrefScript} href="#" />}
onClick={(e) => {
e.preventDefault();
toast.info("Drag the button to your bookmarks bar to use it.", {
icon: <IconCornerLeftUp className="size-4" />,
position: "top-center",
});
}}
onClick={handleInspectDomainClick}
>
<IconWorld />
Inspect Domain
@@ -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;
+1 -3
View File
@@ -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("/");
},
},
@@ -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
<div className="flex gap-3">
{/* Icon */}
<Icon size="sm" variant={iconColor} className="rounded-full">
<IconComponent />
{createElement(IconComponent)}
</Icon>
{/* Content */}
@@ -16,7 +16,9 @@ export function NotificationEmptyState({ variant, onClosePopover }: Notification
return (
<div className="flex flex-col items-center justify-center p-10 text-center">
<Icon className="mb-4">{variant === "inbox" ? <IconConfetti /> : <IconArchive />}</Icon>
<p className="text-sm text-foreground/80">All caught up!</p>
<p className="text-sm text-foreground/80">
{variant === "inbox" ? "All caught up!" : "Nothing archived yet"}
</p>
<p className="mt-1 text-[13px] text-muted-foreground/80">
{variant === "inbox" ? "No unread notifications" : "Nothing to see here (yet…)"}
</p>
@@ -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<typeof createTestQueryClient>,
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(<NotificationsPopover />, { queryClient });
}
function setupUser() {
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
}
async function openInbox(user: ReturnType<typeof userEvent.setup>) {
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(<NotificationsPopover />, { 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(<NotificationsPopover />, { queryClient });
expect(await screen.findByRole("button", { name: "Notifications (100)" })).toBeInTheDocument();
await openInbox(user);
expect(within(screen.getByRole("tab", { name: /Inbox/ })).getByText("99+")).toBeInTheDocument();
});
});
@@ -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 (
<Popover
open={open}
onOpenChange={(nextOpen) => {
// 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={
<Link href="/settings/notifications">
@@ -199,6 +204,11 @@ export function NotificationsPopover() {
}
startTransition(() => setView(nextView));
// Reset scroll position when switching tabs
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = 0;
}
}}
>
<TabsList variant="line">
@@ -258,7 +268,7 @@ export function NotificationsPopover() {
loadMoreRef={loadMoreRef}
scrollAreaRef={scrollAreaRef}
onNotificationClick={handleNotificationClick}
onClosePopover={() => setOpen(false)}
onClosePopover={closePopover}
/>
</div>
</PopoverContent>
@@ -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> = {}): 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],
};
}
@@ -0,0 +1,23 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useWebHaptics } from "web-haptics/react";
type Haptics = ReturnType<typeof useWebHaptics>;
const HapticsContext = createContext<Haptics | null>(null);
export function HapticsProvider({ children }: { children: ReactNode }) {
const { trigger, cancel, isSupported } = useWebHaptics();
const value = useMemo(() => ({ trigger, cancel, isSupported }), [trigger, cancel, isSupported]);
return <HapticsContext.Provider value={value}>{children}</HapticsContext.Provider>;
}
export function useHaptics() {
const haptics = useContext(HapticsContext);
if (!haptics) {
throw new Error("useHaptics must be used within HapticsProvider");
}
return haptics;
}
@@ -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<typeof setTimeout>;
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<number> {
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;
}
@@ -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<HTMLDivElement>(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);
@@ -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(<SearchClient variant="lg" />);
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 () => {

Some files were not shown because too many files have changed in this diff Show More