mirror of
https://github.com/jakejarvis/domainstack.io.git
synced 2026-09-11 05:05:34 -04:00
chore: update all dependencies to latest and fix breaking changes (#457)
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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
-10
@@ -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",
|
||||
|
||||
Vendored
+2
-2
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// instant = false: empty @modal slot except during intercepting navigations.
|
||||
export const instant = false;
|
||||
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>{" "}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user