25 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
7a96e7b038 sign cooies 2026-03-09 18:29:15 +01:00
Carl-Gerhard Lindesvärd
fc256124b5 comments 2026-03-09 17:21:27 +01:00
Carl-Gerhard Lindesvärd
df0258f532 comments 2026-03-09 14:20:15 +01:00
Carl-Gerhard Lindesvärd
0f9e5f6e93 fix timepicker 2026-03-09 13:48:02 +01:00
Carl-Gerhard Lindesvärd
c9cf7901ad wip 2026-03-09 12:30:28 +01:00
Carl-Gerhard Lindesvärd
2981638893 wip 2026-03-06 13:59:03 +01:00
Carl-Gerhard Lindesvärd
70ca44f039 chore(public): update @opennextjs/cloudflare #309 2026-03-06 13:13:59 +01:00
Carl-Gerhard Lindesvärd
00f6cd6f50 fix: importer 2.. 2026-03-03 23:54:53 +01:00
Carl-Gerhard Lindesvärd
227d629dc5 fix: pnpm lock 2026-03-03 23:15:34 +01:00
Carl-Gerhard Lindesvärd
f2e19093f0 fix: importer.. 2026-03-03 22:17:49 +01:00
Carl-Gerhard Lindesvärd
7f85b2ac0a fix: pagination bug #296 2026-03-03 12:53:11 +01:00
Carl-Gerhard Lindesvärd
38965387da chore: add create checkout link 2026-03-03 12:52:57 +01:00
Carl-Gerhard Lindesvärd
74bcb7ead2 fix(api): improve export api, properties to be a comma seperated list 2026-03-03 11:37:05 +01:00
Carl-Gerhard Lindesvärd
2377f95b86 feat(dashboard): allow create organizations 2026-03-03 11:11:59 +01:00
Carl-Gerhard Lindesvärd
de6ca96628 chore: update gitignore 2026-03-03 11:04:20 +01:00
Carl-Gerhard Lindesvärd
9e46099246 chore: add dpa, update terms and privacy 2026-03-03 10:59:45 +01:00
Carl-Gerhard Lindesvärd
83761638f2 fix: improve how previous state is shown for funnels 2026-03-02 15:28:28 +01:00
Carl-Gerhard Lindesvärd
885f7225db bump(sdk): 1.2.0 2026-03-02 13:43:32 +01:00
Carl-Gerhard Lindesvärd
553e4cf675 fix: ts issues 2026-03-02 13:18:34 +01:00
Carl-Gerhard Lindesvärd
f2c414b4b4 fix(sdk): add timestamp when queueing events 2026-03-02 13:16:55 +01:00
Carl-Gerhard Lindesvärd
043730444a feat: improve how disabled works for the SDKS (to improve consent management) 2026-03-02 11:00:20 +01:00
Carl-Gerhard Lindesvärd
8c377c2066 fix: default last/first seen broken when clickhouse defaults to 1970 2026-03-02 09:34:23 +01:00
Carl-Gerhard Lindesvärd
647ac2a4af fix: redo how the importer works 2026-03-01 21:59:12 +01:00
Carl-Gerhard Lindesvärd
6251d143d1 fix(dashboard): pagination and login 2026-03-01 13:33:55 +01:00
Carl-Gerhard Lindesvärd
b801d6a8ef fix: last auth provider cookie (wrong domain) 2026-02-27 23:41:38 +01:00
112 changed files with 8653 additions and 2182 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.secrets
packages/db/src/generated/prisma
packages/db/code-migrations/*.sql
**/.open-next
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
packages/sdk/profileId.txt

View File

@@ -1,20 +1,18 @@
import { parseQueryString } from '@/utils/parse-zod-query-string';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
import { DateTime } from '@openpanel/common';
import type { GetEventListOptions } from '@openpanel/db';
import {
ChartEngine,
ClientType,
db,
getEventList,
getEventsCountCached,
getEventsCount,
getSettingsForProject,
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zReport } from '@openpanel/validation';
import { omit } from 'ramda';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
import { parseQueryString } from '@/utils/parse-zod-query-string';
async function getProjectId(
request: FastifyRequest<{
@@ -22,8 +20,7 @@ async function getProjectId(
project_id?: string;
projectId?: string;
};
}>,
reply: FastifyReply,
}>
) {
let projectId = request.query.projectId || request.query.project_id;
@@ -75,8 +72,20 @@ const eventsScheme = z.object({
limit: z.coerce.number().optional().default(50),
includes: z
.preprocess(
(arg) => (typeof arg === 'string' ? [arg] : arg),
z.array(z.string()),
(arg) => {
if (arg == null) {
return undefined;
}
if (Array.isArray(arg)) {
return arg;
}
if (typeof arg === 'string') {
const parts = arg.split(',').map((s) => s.trim()).filter(Boolean);
return parts;
}
return arg;
},
z.array(z.string())
)
.optional(),
});
@@ -85,7 +94,7 @@ export async function events(
request: FastifyRequest<{
Querystring: z.infer<typeof eventsScheme>;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const query = eventsScheme.safeParse(request.query);
@@ -97,7 +106,7 @@ export async function events(
});
}
const projectId = await getProjectId(request, reply);
const projectId = await getProjectId(request);
const limit = query.data.limit;
const page = Math.max(query.data.page, 1);
const take = Math.max(Math.min(limit, 1000), 1);
@@ -118,20 +127,20 @@ export async function events(
meta: false,
...query.data.includes?.reduce(
(acc, key) => ({ ...acc, [key]: true }),
{},
{}
),
},
};
const [data, totalCount] = await Promise.all([
getEventList(options),
getEventsCountCached(omit(['cursor', 'take'], options)),
getEventsCount(options),
]);
reply.send({
meta: {
count: data.length,
totalCount: totalCount,
totalCount,
pages: Math.ceil(totalCount / options.take),
current: cursor + 1,
},
@@ -158,7 +167,7 @@ const chartSchemeFull = zReport
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
}),
})
)
.optional(),
// Backward compatibility - events will be migrated to series via preprocessing
@@ -169,7 +178,7 @@ const chartSchemeFull = zReport
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
}),
})
)
.optional(),
});
@@ -178,7 +187,7 @@ export async function charts(
request: FastifyRequest<{
Querystring: Record<string, string>;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const query = chartSchemeFull.safeParse(parseQueryString(request.query));
@@ -190,7 +199,7 @@ export async function charts(
});
}
const projectId = await getProjectId(request, reply);
const projectId = await getProjectId(request);
const { timezone } = await getSettingsForProject(projectId);
const { events, series, ...rest } = query.data;

View File

@@ -0,0 +1,167 @@
import { googleGsc } from '@openpanel/auth';
import { db, encrypt } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { LogError } from '@/utils/errors';
const OAUTH_SENSITIVE_KEYS = ['code', 'state'];
function sanitizeOAuthQuery(
query: Record<string, unknown> | null | undefined
): Record<string, string> {
if (!query || typeof query !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(query).map(([k, v]) => [
k,
OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v),
])
);
}
export async function gscGoogleCallback(
req: FastifyRequest,
reply: FastifyReply
) {
try {
const schema = z.object({
code: z.string(),
state: z.string(),
});
const query = schema.safeParse(req.query);
if (!query.success) {
throw new LogError(
'Invalid GSC callback query params',
sanitizeOAuthQuery(req.query as Record<string, unknown>)
);
}
const { code, state } = query.data;
const rawStoredState = req.cookies.gsc_oauth_state ?? null;
const rawCodeVerifier = req.cookies.gsc_code_verifier ?? null;
const rawProjectId = req.cookies.gsc_project_id ?? null;
const storedStateResult =
rawStoredState !== null ? req.unsignCookie(rawStoredState) : null;
const codeVerifierResult =
rawCodeVerifier !== null ? req.unsignCookie(rawCodeVerifier) : null;
const projectIdResult =
rawProjectId !== null ? req.unsignCookie(rawProjectId) : null;
if (
!(
storedStateResult?.value &&
codeVerifierResult?.value &&
projectIdResult?.value
)
) {
throw new LogError('Missing GSC OAuth cookies', {
storedState: !storedStateResult?.value,
codeVerifier: !codeVerifierResult?.value,
projectId: !projectIdResult?.value,
});
}
if (
!(
storedStateResult?.valid &&
codeVerifierResult?.valid &&
projectIdResult?.valid
)
) {
throw new LogError('Invalid GSC OAuth cookies', {
storedState: !storedStateResult?.value,
codeVerifier: !codeVerifierResult?.value,
projectId: !projectIdResult?.value,
});
}
const stateStr = storedStateResult?.value;
const codeVerifierStr = codeVerifierResult?.value;
const projectIdStr = projectIdResult?.value;
if (state !== stateStr) {
throw new LogError('GSC OAuth state mismatch', {
hasState: true,
hasStoredState: true,
stateMismatch: true,
});
}
const tokens = await googleGsc.validateAuthorizationCode(
code,
codeVerifierStr
);
const accessToken = tokens.accessToken();
const refreshToken = tokens.hasRefreshToken()
? tokens.refreshToken()
: null;
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
if (!refreshToken) {
throw new LogError('No refresh token returned from Google GSC OAuth');
}
const project = await db.project.findUnique({
where: { id: projectIdStr },
select: { id: true, organizationId: true },
});
if (!project) {
throw new LogError('Project not found for GSC connection', {
projectId: projectIdStr,
});
}
await db.gscConnection.upsert({
where: { projectId: projectIdStr },
create: {
projectId: projectIdStr,
accessToken: encrypt(accessToken),
refreshToken: encrypt(refreshToken),
accessTokenExpiresAt,
siteUrl: '',
},
update: {
accessToken: encrypt(accessToken),
refreshToken: encrypt(refreshToken),
accessTokenExpiresAt,
lastSyncStatus: null,
lastSyncError: null,
},
});
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
const dashboardUrl =
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`;
return reply.redirect(redirectUrl);
} catch (error) {
req.log.error(error);
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
return redirectWithError(reply, error);
}
}
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
);
url.pathname = '/login';
if (error instanceof LogError) {
url.searchParams.set('error', error.message);
} else {
url.searchParams.set('error', 'Failed to connect Google Search Console');
}
url.searchParams.set('correlationId', reply.request.id);
return reply.redirect(url.toString());
}

View File

@@ -5,6 +5,7 @@ import {
github,
google,
type OAuth2Tokens,
setLastAuthProviderCookie,
setSessionTokenCookie,
} from '@openpanel/auth';
import { type Account, connectUserToOrganization, db } from '@openpanel/db';
@@ -76,11 +77,10 @@ async function handleExistingUser({
sessionToken,
session.expiresAt
);
reply.setCookie('last-auth-provider', providerName, {
maxAge: 60 * 60 * 24 * 365,
path: '/',
sameSite: 'lax',
});
setLastAuthProviderCookie(
(...args) => reply.setCookie(...args),
providerName
);
return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
);
@@ -145,11 +145,10 @@ async function handleNewUser({
sessionToken,
session.expiresAt
);
reply.setCookie('last-auth-provider', providerName, {
maxAge: 60 * 60 * 24 * 365,
path: '/',
sameSite: 'lax',
});
setLastAuthProviderCookie(
(...args) => reply.setCookie(...args),
providerName
);
return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
);

View File

@@ -36,6 +36,7 @@ import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
@@ -194,6 +195,7 @@ const startServer = async () => {
instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(gscCallbackRouter, { prefix: '/gsc' });
instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' });
});

View File

@@ -0,0 +1,12 @@
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
import type { FastifyPluginCallback } from 'fastify';
const router: FastifyPluginCallback = async (fastify) => {
fastify.route({
method: 'GET',
url: '/callback',
handler: gscGoogleCallback,
});
};
export default router;

View File

@@ -0,0 +1,76 @@
---
title: Consent management
description: Queue all tracking until the user gives consent, then flush everything with a single call.
---
import { Callout } from 'fumadocs-ui/components/callout';
Some jurisdictions require explicit user consent before you can track events or record sessions. OpenPanel has built-in support for this: initialise with `disabled: true` and nothing is sent until you call `ready()`.
## How it works
When `disabled: true` is set, all calls to `track`, `identify`, `screenView`, and session replay chunks are held in an in-memory queue instead of being sent to the API. Once the user consents, call `ready()` and the entire queue is flushed immediately.
```ts
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
disabled: true, // nothing sent until ready() is called
});
// Later, when the user accepts your consent banner:
op.ready();
```
If the user declines, simply don't call `ready()`. The queue is discarded when the page unloads.
## With session replay
Session replay chunks are also queued while `disabled: true`. Once `ready()` is called, buffered replay chunks flush along with any queued events.
```ts
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
disabled: true,
trackScreenViews: true,
sessionReplay: { enabled: true },
});
// User accepts consent:
op.ready();
```
<Callout type="info">
The replay recorder starts as soon as the page loads (so no interactions are missed), but no data is sent until `ready()` is called.
</Callout>
## Waiting for a user profile
If you want to hold events until you know who the user is rather than waiting for explicit consent, use `waitForProfile` instead. Events are queued until `identify()` is called with a `profileId`.
```ts
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
waitForProfile: true,
});
// Events queue here...
op.track('page_view');
// Queue is flushed once a profileId is set:
op.identify({ profileId: 'user_123' });
```
If the user never authenticates, the queue is never flushed automatically — no events will be sent. To handle anonymous users or guest flows, call `ready()` explicitly when you know the user won't identify:
```ts
// User skipped login — flush queued events without a profileId
op.ready();
```
`ready()` always releases the queue regardless of whether `waitForProfile` or `disabled` is set.
## Related
- [Consent management guide](/guides/consent-management) — full walkthrough with a cookie banner example
- [Session replay](/docs/session-replay) — privacy controls for replay recordings
- [Identify users](/docs/get-started/identify-users) — link events to a user profile

View File

@@ -1,3 +1,3 @@
{
"pages": ["sdks", "how-it-works", "session-replay", "..."]
"pages": ["sdks", "how-it-works", "session-replay", "consent-management", "..."]
}

View File

@@ -67,8 +67,9 @@ With the npm package, the replay module is a dynamic import code-split by your b
| Option | Type | Default | Description |
|---|---|---|---|
| `enabled` | `boolean` | `false` | Enable session replay recording |
| `maskAllInputs` | `boolean` | `true` | Mask all input field values with `*` |
| `maskTextSelector` | `string` | `[data-openpanel-replay-mask]` | CSS selector for text elements to mask |
| `maskAllInputs` | `boolean` | `true` | Mask all input field values |
| `maskAllText` | `boolean` | `true` | Mask all text content in the recording |
| `unmaskTextSelector` | `string` | — | CSS selector for elements whose text should NOT be masked when `maskAllText` is true |
| `blockSelector` | `string` | `[data-openpanel-replay-block]` | CSS selector for elements to replace with a placeholder |
| `blockClass` | `string` | — | Class name that blocks elements from being recorded |
| `ignoreSelector` | `string` | — | CSS selector for elements excluded from interaction tracking |
@@ -79,40 +80,72 @@ With the npm package, the replay module is a dynamic import code-split by your b
## Privacy controls
Session replay captures user interactions. These options protect sensitive content before it ever leaves the browser.
Session replay captures user interactions. All text and inputs are masked by default — sensitive content is replaced with `***` before it ever leaves the browser.
### Masking inputs
### Text masking (default on)
All input fields are masked by default (`maskAllInputs: true`). Recorded values appear as `***` in the replay. Disable this only if you have a specific reason—form field contents are almost always personal data.
All text content is masked by default (`maskAllText: true`). This means visible page text, labels, and content are replaced with `***` in replays, in addition to input fields.
### Masking specific text
This is the safest default for GDPR compliance since replays cannot incidentally capture names, emails, or other personal data visible on the page.
Add `data-openpanel-replay-mask` to any element to replace its text with `***` in replays:
### Selectively unmasking text
```html
<p data-openpanel-replay-mask>Sensitive text here</p>
```
Or use a custom selector:
If your pages display non-sensitive content you want visible in replays, use `unmaskTextSelector` to opt specific elements out of masking:
```ts
sessionReplay: {
enabled: true,
maskTextSelector: '.pii, [data-sensitive]',
unmaskTextSelector: '[data-openpanel-unmask]',
}
```
```html
<h1 data-openpanel-unmask>Product Analytics</h1>
<p data-openpanel-unmask>Welcome to the dashboard</p>
<!-- This stays masked: -->
<p>John Doe · john@example.com</p>
```
You can also use any CSS selector to target elements by class, tag, or attribute:
```ts
sessionReplay: {
enabled: true,
unmaskTextSelector: '.replay-safe, nav, footer',
}
```
### Disabling full text masking
If you want to disable full text masking and return to selector-based masking, set `maskAllText: false`. In this mode only elements with `data-openpanel-replay-mask` are masked:
```ts
sessionReplay: {
enabled: true,
maskAllText: false,
}
```
```html
<p data-openpanel-replay-mask>This will be masked</p>
<p>This will be visible in replays</p>
```
<Callout type="warn">
Only disable `maskAllText` if you are confident your pages do not display personal data, or if you are masking all sensitive elements individually. You are responsible for ensuring your use of session replay complies with applicable privacy law.
</Callout>
### Blocking elements
Elements matched by `blockSelector` or `blockClass` are replaced with a same-size grey placeholder in the replay. The element and all its children are never recorded.
```html
<div data-openpanel-replay-block>
This section won't appear in replays
This section won't appear in replays at all
</div>
```
Or with a custom selector and class:
Or with a custom selector:
```ts
sessionReplay: {

View File

@@ -53,14 +53,32 @@ GET /export/events
| `end` | string | End date for the event range (ISO format) | `2024-04-18` |
| `page` | number | Page number for pagination (default: 1) | `2` |
| `limit` | number | Number of events per page (default: 50, max: 1000) | `100` |
| `includes` | string or string[] | Additional fields to include in the response | `profile` or `["profile","meta"]` |
| `includes` | string or string[] | Additional fields to include in the response. Pass multiple as comma-separated (`profile,meta`) or repeated params (`includes=profile&includes=meta`). | `profile` or `profile,meta` |
#### Include Options
The `includes` parameter allows you to fetch additional related data:
The `includes` parameter allows you to fetch additional related data. When using query parameters, you can pass multiple values in either of these ways:
- `profile`: Include user profile information
- `meta`: Include event metadata and configuration
- **Comma-separated**: `?includes=profile,meta` (include both profile and meta in the response)
- **Repeated parameter**: `?includes=profile&includes=meta` (same result; useful when building URLs programmatically)
Supported values (any of these can be combined; names match the response keys):
**Related data** (adds nested objects or extra lookups):
- `profile` — User profile for the event (id, email, firstName, lastName, etc.)
- `meta` — Event metadata from project config (name, description, conversion flag)
**Event fields** (optional columns; these are in addition to the default fields):
- `properties` — Custom event properties
- `region`, `longitude`, `latitude` — Extra geo (default already has `city`, `country`)
- `osVersion`, `browserVersion`, `device`, `brand`, `model` — Extra device (default already has `os`, `browser`)
- `origin`, `referrer`, `referrerName`, `referrerType` — Referrer/navigation
- `revenue` — Revenue amount
- `importedAt`, `sdkName`, `sdkVersion` — Import/SDK info
The response always includes: `id`, `name`, `deviceId`, `profileId`, `sessionId`, `projectId`, `createdAt`, `path`, `duration`, `city`, `country`, `os`, `browser`. Use `includes` to add any of the values above.
#### Example Request
@@ -129,12 +147,15 @@ Retrieve aggregated chart data for analytics and visualization. This endpoint pr
GET /export/charts
```
**Note:** The endpoint accepts either `series` or `events` for the event configuration; `series` is the preferred parameter name. Both use the same structure.
#### Query Parameters
| Parameter | Type | Description | Example |
|-----------|------|-------------|---------|
| `projectId` | string | The ID of the project to fetch chart data from | `abc123` |
| `events` | object[] | Array of event configurations to analyze | `[{"name":"screen_view","filters":[]}]` |
| `series` | object[] | Array of event/series configurations to analyze (preferred over `events`) | `[{"name":"screen_view","filters":[]}]` |
| `events` | object[] | Array of event configurations (deprecated in favor of `series`) | `[{"name":"screen_view","filters":[]}]` |
| `breakdowns` | object[] | Array of breakdown dimensions | `[{"name":"country"}]` |
| `interval` | string | Time interval for data points | `day` |
| `range` | string | Predefined date range | `7d` |
@@ -144,7 +165,7 @@ GET /export/charts
#### Event Configuration
Each event in the `events` array supports the following properties:
Each item in the `series` or `events` array supports the following properties:
| Property | Type | Description | Required | Default |
|----------|------|-------------|----------|---------|
@@ -228,11 +249,13 @@ Common breakdown dimensions include:
#### Example Request
```bash
curl 'https://api.openpanel.dev/export/charts?projectId=abc123&events=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \
curl 'https://api.openpanel.dev/export/charts?projectId=abc123&series=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
```
You can use `events` instead of `series` in the query for backward compatibility; both accept the same structure.
#### Example Advanced Request
```bash
@@ -241,7 +264,7 @@ curl 'https://api.openpanel.dev/export/charts' \
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \
-G \
--data-urlencode 'projectId=abc123' \
--data-urlencode 'events=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \
--data-urlencode 'series=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \
--data-urlencode 'breakdowns=[{"name":"country"}]' \
--data-urlencode 'interval=day' \
--data-urlencode 'range=30d'

View File

@@ -0,0 +1,233 @@
---
title: "Consent management with OpenPanel"
description: "Learn how to queue analytics events and session replays until the user gives consent, then flush everything at once with a single call."
difficulty: beginner
timeToComplete: 15
date: 2026-03-02
updated: 2026-03-02
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Initialise with disabled: true"
anchor: "disable"
- name: "Build a consent banner"
anchor: "banner"
- name: "Call ready() on consent"
anchor: "ready"
- name: "Handle decline"
anchor: "decline"
- name: "Persist consent across page loads"
anchor: "persist"
---
# Consent management with OpenPanel
Privacy regulations like GDPR and CCPA require that you obtain explicit user consent before tracking behaviour or recording sessions. This guide shows how to use OpenPanel's built-in queue to hold all tracking until the user makes a choice, then flush everything at once—or discard it silently on decline.
## Prerequisites
- OpenPanel installed via the `@openpanel/web` npm package or the script tag
- Your Client ID from the [OpenPanel dashboard](https://dashboard.openpanel.dev/onboarding)
## Initialise with `disabled: true` [#disable]
Pass `disabled: true` when creating the OpenPanel instance. All tracking calls (`track`, `identify`, `screenView`, session replay chunks) are held in an in-memory queue instead of being sent to the API.
### Script tag
```html title="index.html"
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
disabled: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
### NPM package
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
disabled: true,
});
```
From this point on, any `op.track(...)` calls elsewhere in your app are safely queued and not transmitted.
## Build a consent banner [#banner]
How you build the UI is up to you. The key is to call `op.ready()` when the user accepts, and do nothing (or call `op.clear()`) when they decline.
```tsx title="ConsentBanner.tsx"
import { op } from './op';
export function ConsentBanner() {
function handleAccept() {
localStorage.setItem('consent', 'granted');
op.ready(); // flushes the queue and enables all future tracking
hideBanner();
}
function handleDecline() {
localStorage.setItem('consent', 'denied');
hideBanner(); // queue is discarded on page unload
}
return (
<div role="dialog" aria-label="Cookie consent">
<p>
We use analytics to improve our product. Do you consent to anonymous
usage tracking?
</p>
<button type="button" onClick={handleAccept}>Accept</button>
<button type="button" onClick={handleDecline}>Decline</button>
</div>
);
}
```
## Call `ready()` on consent [#ready]
`op.ready()` does two things:
1. Clears the `disabled` flag so all future events are sent immediately
2. Flushes the entire queue — every event and session replay chunk buffered since page load is sent at once
This means you don't lose any events that happened before the user made their choice. The screen view for the page they landed on, any clicks they made while the banner was visible—all of it is captured and sent the moment they consent.
## Handle session replay [#replay]
If you have session replay enabled, the recorder starts capturing DOM changes as soon as the page loads (so no interactions are missed), but no data leaves the browser until `ready()` is called.
```ts title="op.ts"
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
disabled: true,
sessionReplay: {
enabled: true,
},
});
```
On `op.ready()`, buffered replay chunks flush along with the queued events. The full session from the start of the page load is preserved.
## Handle decline [#decline]
If the user declines, don't call `ready()`. The queue lives only in memory and is automatically discarded when the tab closes or the page navigates away. No data is ever sent.
If you want to be explicit, you can clear the queue immediately:
```ts
function handleDecline() {
localStorage.setItem('consent', 'denied');
// op stays disabled — nothing will be sent
// The in-memory queue will be garbage collected
}
```
## Persist consent across page loads [#persist]
The `disabled` flag resets on every page load. You need to check the stored consent choice on initialisation and skip `disabled: true` if consent was already granted.
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const hasConsent = localStorage.getItem('consent') === 'granted';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
disabled: !hasConsent,
});
```
And in your banner component, only show it when no choice has been stored:
```tsx title="ConsentBanner.tsx"
export function ConsentBanner() {
const stored = localStorage.getItem('consent');
if (stored) return null; // already decided, don't show
// ... render banner
}
```
## Full example
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const hasConsent = localStorage.getItem('consent') === 'granted';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
disabled: !hasConsent,
sessionReplay: {
enabled: true,
},
});
```
```tsx title="ConsentBanner.tsx"
import { op } from './op';
export function ConsentBanner() {
if (localStorage.getItem('consent')) return null;
return (
<div role="dialog" aria-label="Cookie consent">
<p>We use analytics to improve our product. Do you consent?</p>
<button
type="button"
onClick={() => {
localStorage.setItem('consent', 'granted');
op.ready();
}}
>
Accept
</button>
<button
type="button"
onClick={() => {
localStorage.setItem('consent', 'denied');
}}
>
Decline
</button>
</div>
);
}
```
## Related
- [Consent management docs](/docs/consent-management) — quick reference for `disabled` and `waitForProfile`
- [Session replay](/docs/session-replay) — privacy controls for what gets recorded
- [Identify users](/docs/get-started/identify-users) — link events to a user profile
<Faqs>
<FaqItem question="Are events lost if the user declines?">
No events are sent if the user declines and you never call `ready()`. The queue lives in memory and is discarded when the page unloads.
</FaqItem>
<FaqItem question="What happens to events tracked before the banner appears?">
They sit in the queue. If the user later accepts, they are all flushed. If the user declines, they are discarded.
</FaqItem>
<FaqItem question="Does session replay start before consent?">
The recorder starts capturing DOM changes immediately so the full session can be reconstructed, but nothing is transmitted until `ready()` is called.
</FaqItem>
<FaqItem question="Do I need to handle this differently for the script tag vs npm?">
No. The `disabled` option and `ready()` method work the same in both.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,132 @@
---
title: Data Processing Agreement
description: OpenPanel's Data Processing Agreement (DPA) under Art. 28 GDPR for cloud customers who use OpenPanel to collect analytics on their websites and applications.
---
_Last updated: March 3, 2026_
This Data Processing Agreement ("DPA") is incorporated into and forms part of the OpenPanel Terms of Service between OpenPanel AB ("OpenPanel", "we", "us") and the customer ("Controller", "you"). It applies where OpenPanel processes personal data on your behalf as part of the OpenPanel Cloud service.
## 1. Definitions
- **GDPR** means Regulation (EU) 2016/679 of the European Parliament and of the Council.
- **Controller** means you, the customer, who determines the purposes and means of processing.
- **Processor** means OpenPanel, who processes data on your behalf.
- **Personal Data**, **Processing**, **Data Subject**, and **Supervisory Authority** have the meanings given in the GDPR.
- **Sub-processor** means any third party engaged by OpenPanel to process Personal Data in connection with the service.
## 2. Our approach to privacy
OpenPanel is built to minimize personal data collection by design. We do not use cookies for analytics tracking. We do not store IP addresses. Instead, we generate a daily-rotating anonymous identifier using a one-way hash of the visitor's IP address, user agent, and project ID combined with a salt that is replaced every 24 hours. The raw IP address is discarded immediately and the identifier becomes irreversible once the salt is rotated.
The data we store per event is:
- Page URL and referrer
- Browser name and version
- Operating system name and version
- Device type, brand, and model
- City, country, and region (derived from IP at the time of the request; IP is then discarded)
- Custom event properties you choose to send
No persistent identifiers, no cookies, no cross-site tracking.
Because of this approach, the analytics data OpenPanel collects in standard website tracking mode does not constitute personal data under GDPR Art. 4(1). However, we provide this DPA for customers who require it for their own compliance documentation and records of processing activities.
**Session replay (optional feature)**
OpenPanel optionally supports session replay, which must be explicitly enabled by the Controller. When enabled, session replay records DOM snapshots and user interactions (mouse movements, clicks, scrolls) on the Controller's website using rrweb. This data is stored against the session identifier and may incidentally capture personal data visible in the page (for example, a logged-in user's name displayed in the UI). All text content and form inputs are masked by default. The Controller is responsible for ensuring their use of session replay complies with applicable privacy law, including providing appropriate notice to end users. Additional masking options are available via the SDK configuration.
## 3. Scope and roles
OpenPanel acts as a **Processor** when processing data on behalf of the Controller. You act as the **Controller** for the analytics data collected from visitors to your websites and applications.
## 4. Processor obligations
OpenPanel commits to the following:
- Process Personal Data only on your documented instructions and for no other purpose.
- Ensure that all personnel with access to Personal Data are bound by appropriate confidentiality obligations.
- Implement and maintain technical and organizational measures in accordance with Section 7 of this DPA.
- Not engage a Sub-processor without your prior general or specific written authorization and flow down equivalent data protection obligations to any Sub-processor.
- Assist you, where reasonably possible, in responding to Data Subject requests to exercise their rights under GDPR.
- Notify you without undue delay (and no later than 48 hours) upon becoming aware of a Personal Data breach.
- Make available all information necessary to demonstrate compliance with this DPA and cooperate with audits conducted by you or your designated auditor, subject to reasonable notice and confidentiality obligations.
- At your choice, delete or return all Personal Data upon termination of the service.
## 5. Your obligations as Controller
You confirm that:
- You have a lawful basis for the processing described in this DPA.
- You have provided appropriate privacy notices to your end users.
- You are responsible for the accuracy and lawfulness of the data you instruct OpenPanel to process.
## 6. Sub-processors
OpenPanel uses the following sub-processors to deliver the service. All sub-processors are located within the European Economic Area or provide adequate safeguards under GDPR Chapter V.
| Sub-processor | Purpose | Location |
|---|---|---|
| Hetzner Online GmbH | Cloud infrastructure and data storage | Germany (EU) |
| Cloudflare R2 | Backup storage | EU |
We will inform you of any intended changes to this list (additions or replacements) with reasonable notice, giving you the opportunity to object.
## 7. Technical and organizational measures
OpenPanel implements the following measures under GDPR Art. 32:
**Data minimization and anonymization**
- IP addresses are never stored. They are used only to derive geolocation and generate an anonymous daily identifier, then discarded.
- Daily-rotating cryptographic salts ensure visitor identifiers cannot be reversed or linked to individuals after 24 hours.
- No cookies or persistent cross-device identifiers are used.
**Access control**
- Dashboard access is protected by authentication and role-based access control.
- Production systems are accessible only to authorized personnel.
**Encryption and transport security**
- All data is transmitted over HTTPS (TLS).
**Infrastructure and availability**
- All data is hosted on Hetzner servers located in Germany within the EU.
- Regular backups are performed.
- No data leaves the EEA in the course of normal operations.
**Incident response**
- We maintain procedures for detecting, reporting, and investigating Personal Data breaches.
- In the event of a breach affecting your data, we will notify you within 48 hours of becoming aware.
**Open source**
- The OpenPanel codebase is publicly available on GitHub, allowing independent review of our data handling practices.
## 8. International data transfers
OpenPanel stores and processes all analytics data on Hetzner infrastructure located in Germany. No Personal Data is transferred to countries outside the EEA in the course of delivering the service.
## 9. Data retention and deletion
**Analytics events** are retained for as long as your account is active. We do not currently enforce a maximum retention period on analytics event data. If we introduce a retention limit in the future, we will notify all customers in advance.
**Session replays** are retained for 30 days and then permanently deleted.
You can delete individual projects, all associated data, or your entire account at any time from within the dashboard. Upon account termination we will delete your data within 30 days unless we are required by law to retain it longer.
## 10. Governing law
This DPA is governed by the laws of Sweden and is interpreted in accordance with the GDPR.
## 11. How to execute this DPA
Using OpenPanel Cloud constitutes acceptance of this DPA as part of our Terms of Service.
If your organization requires a signed copy for your records of processing activities, you can download a pre-signed version below. Fill in your company details and countersign — no need to send it back to us.
[Download pre-signed DPA](/dpa/download)
## Contact
For questions about this DPA or data protection at OpenPanel:
- Email: [hello@openpanel.dev](mailto:hello@openpanel.dev)
- Company: OpenPanel AB, Sankt Eriksgatan 100, 113 31 Stockholm, Sweden

View File

@@ -3,6 +3,8 @@ title: Privacy Policy
description: Our privacy policy outlines how we handle your data, including usage information and cookies, to provide and improve our services.
---
_Last updated: March 3, 2026_
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.
We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.
@@ -23,7 +25,7 @@ For the purposes of this Privacy Policy:
- **Application** refers to Openpanel, the software program provided by the Company.
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Coderax AB, Sankt Eriksgatan 100, 113 31, Stockholm.
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to OpenPanel AB, Sankt Eriksgatan 100, 113 31, Stockholm.
- **Cookies** are small files that are placed on Your computer, mobile device or any other device by a website, containing the details of Your browsing history on that website among its many uses.
@@ -43,65 +45,44 @@ For the purposes of this Privacy Policy:
- **You** means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
## What we do not collect
OpenPanel is built around the principle of collecting as little data as possible. When you use OpenPanel to track visitors on your websites and applications, the following is true:
- **No IP addresses are stored.** IP addresses are used transiently to derive city-level geolocation and to generate an anonymous visitor identifier. The raw IP address is discarded immediately after.
- **No cookies are used for analytics tracking.** We do not set any tracking cookies on your visitors' browsers.
- **No persistent cross-device identifiers.** Visitor identifiers are generated using a daily-rotating cryptographic hash of the IP address, user agent, and project ID. The hash cannot be reversed and becomes meaningless after 24 hours.
- **No behavioral profiling.** We do not build profiles of individual users or track visitors across different websites.
- **No data sold to third parties.** We will never sell, share, or transfer your data for advertising or any other commercial purpose.
## Collecting and Using Your Personal Data
### Types of Data Collected
#### Personal Data
#### Analytics data (visitor data on your websites)
While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:
When the OpenPanel tracking script is installed on a website, the following aggregated, anonymized data is collected per event:
- Email address
- First name and last name
- Usage Data
- Page URL and referrer
- Browser name and version
- Operating system name and version
- Device type, brand, and model
- City, country, and region (derived from IP at request time; IP then discarded)
- Custom event properties the website owner chooses to send
#### Usage Data
No IP addresses, no cookies, no names, no email addresses, and no persistent identifiers are stored.
Usage Data is collected automatically when using the Service.
#### Account data (OpenPanel dashboard users)
Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
When you create an OpenPanel account, we collect:
When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.
- Email address (required for login and transactional notifications)
- Name (optional, used for display purposes)
- Billing information (processed by our payment provider; we do not store full card details)
We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.
#### Dashboard session
#### Tracking Technologies and Cookies
We use Cookies and similar tracking technologies to track the activity on Our Service and store certain information. Tracking technologies used are beacons, tags, and scripts to collect and track information and to improve and analyze Our Service. The technologies We use may include:
- **Cookies or Browser Cookies.** A cookie is a small file placed on Your Device. You can instruct Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do not accept Cookies, You may not be able to use some parts of our Service. Unless you have adjusted Your browser setting so that it will refuse Cookies, our Service may use Cookies.
- **Web Beacons.** Certain sections of our Service and our emails may contain small electronic files known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that permit the Company, for example, to count users who have visited those pages or opened an email and for other related website statistics (for example, recording the popularity of a certain section and verifying system and server integrity).
Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies remain on Your personal computer or mobile device when You go offline, while Session Cookies are deleted as soon as You close Your web browser. You can learn more about cookies [here](https://www.termsfeed.com/blog/cookies/#What_Are_Cookies).
We use both Session and Persistent Cookies for the purposes set out below:
- **Necessary / Essential Cookies**
Type: Session Cookies
Administered by: Us
Purpose: These Cookies are essential to provide You with services available through the Website and to enable You to use some of its features. They help to authenticate users and prevent fraudulent use of user accounts. Without these Cookies, the services that You have asked for cannot be provided, and We only use these Cookies to provide You with those services.
- **Cookies Policy / Notice Acceptance Cookies**
Type: Persistent Cookies
Administered by: Us
Purpose: These Cookies identify if users have accepted the use of cookies on the Website.
- **Functionality Cookies**
Type: Persistent Cookies
Administered by: Us
Purpose: These Cookies allow us to remember choices You make when You use the Website, such as remembering your login details or language preference. The purpose of these Cookies is to provide You with a more personal experience and to avoid You having to re-enter your preferences every time You use the Website.
For more information about the cookies we use and your choices regarding cookies, please visit our Cookies Policy or the Cookies section of our Privacy Policy.
We use a single server-side session cookie to keep you logged in to the OpenPanel dashboard. This cookie is strictly necessary for authentication and is not used for tracking or analytics purposes. It is deleted when you log out or your session expires.
### Use of Your Personal Data
@@ -143,13 +124,11 @@ The Company will retain Your Personal Data only for as long as is necessary for
The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.
### Transfer of Your Personal Data
### Data storage and transfers
Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.
All analytics data is stored on Hetzner infrastructure located in Germany. All backups are stored on Cloudflare R2 within the EU. No analytics data is transferred outside the European Economic Area.
Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.
The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.
Account data (email, name, billing) is processed within the EU. Our payment processor and transactional email provider operate under EU data protection standards.
### Delete Your Personal Data
@@ -197,6 +176,10 @@ Our Service may contain links to other websites that are not operated by Us. If
We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.
## Data Processing Agreement
If you use OpenPanel Cloud to collect analytics on behalf of your own users, OpenPanel acts as a data processor and you act as the data controller. Our Data Processing Agreement (DPA) governs this relationship and forms part of our Terms of Service. You can read and download a signed copy at [openpanel.dev/dpa](/dpa).
## Changes to this Privacy Policy
We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.

View File

@@ -3,6 +3,8 @@ title: Terms of Service
description: Legal terms and conditions governing the use of Openpanel's services and website.
---
_Last updated: March 3, 2026_
Please read these terms and conditions carefully before using Our Service.
## Interpretation and Definitions
@@ -25,7 +27,7 @@ For the purposes of these Terms and Conditions:
- **Country** refers to: Sweden
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Coderax AB, Sankt Eriksgatan 100, 113 31, Stockholm.
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to OpenPanel AB, Sankt Eriksgatan 100, 113 31, Stockholm.
- **Device** means any device that can access the Service such as a computer, a cellphone or a digital tablet.
@@ -55,6 +57,16 @@ You represent that you are over the age of 18. The Company does not permit those
Your access to and use of the Service is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Company. Our Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your personal information when You use the Application or the Website and tells You about Your privacy rights and how the law protects You. Please read Our Privacy Policy carefully before using Our Service.
## Data Processing Agreement
Our Data Processing Agreement (DPA) under the European General Data Protection Regulation (GDPR) forms part of these Terms of Service. By using OpenPanel Cloud, you agree to the terms of the DPA. A copy is available at [openpanel.dev/dpa](/dpa).
## Your Data
You retain full ownership of all data you submit to the Service, including analytics data collected from your websites and applications. The Company claims no intellectual property rights over your data.
We will never sell, share, or transfer your data to third parties for advertising, marketing, or any commercial purpose. Your data is used solely to provide and improve the Service for you.
## Subscriptions
### Subscription period
@@ -157,14 +169,6 @@ If You have any concern or dispute about the Service, You agree to first try to
If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in which You are resident.
## United States Federal Government End Use Provisions
If You are a U.S. federal government end user, our Service is a "Commercial Item" as that term is defined at 48 C.F.R. §2.101.
## United States Legal Compliance
You represent and warrant that (i) You are not located in a country that is subject to the United States government embargo, or that has been designated by the United States government as a "terrorist supporting" country, and (ii) You are not listed on any United States government list of prohibited or restricted parties.
## Severability and Waiver
### Severability

View File

@@ -17,7 +17,7 @@
"dependencies": {
"@nivo/funnel": "^0.99.0",
"@number-flow/react": "0.5.10",
"@opennextjs/cloudflare": "^1.16.5",
"@opennextjs/cloudflare": "^1.17.1",
"@openpanel/common": "workspace:*",
"@openpanel/geo": "workspace:*",
"@openpanel/nextjs": "^1.2.0",
@@ -61,4 +61,4 @@
"typescript": "catalog:",
"wrangler": "^4.65.0"
}
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,13 +1,11 @@
'use client';
import { FeatureCardBackground } from '@/components/feature-card';
import { Section, SectionHeader, SectionLabel } from '@/components/section';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { QuoteIcon } from 'lucide-react';
import Image from 'next/image';
import { useState } from 'react';
import Markdown from 'react-markdown';
import { FeatureCardBackground } from '@/components/feature-card';
import { Section, SectionHeader, SectionLabel } from '@/components/section';
import { cn } from '@/lib/utils';
const images = [
{
@@ -65,55 +63,54 @@ const quotes: {
];
export function WhyOpenPanel() {
const [showMore, setShowMore] = useState(false);
return (
<Section className="container gap-16">
<SectionHeader label="Trusted by founders" title="Who uses OpenPanel?" />
<div className="col overflow-hidden">
<SectionLabel className="text-muted-foreground bg-background -mb-2 z-5 self-start pr-4">
<SectionLabel className="z-5 -mb-2 self-start bg-background pr-4 text-muted-foreground">
USED BY
</SectionLabel>
<div className="grid grid-cols-3 md:grid-cols-6 -mx-4 border-y py-4">
<div className="-mx-4 grid grid-cols-3 border-y py-4 md:grid-cols-6">
{images.map((image) => (
<div key={image.logo} className="px-4 border-r last:border-r-0 ">
<div className="border-r px-4 last:border-r-0" key={image.logo}>
<a
className={cn('group center-center relative aspect-square')}
href={image.url}
target="_blank"
rel="noopener noreferrer nofollow"
key={image.logo}
className={cn('relative group center-center aspect-square')}
rel="noopener noreferrer nofollow"
target="_blank"
title={image.name}
>
<FeatureCardBackground />
<Image
src={image.logo}
alt={image.name}
width={64}
height={64}
className={cn('size-16 object-contain dark:invert')}
height={64}
src={image.logo}
width={64}
/>
</a>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 -mx-4 border-y py-4">
{quotes.slice(0, showMore ? quotes.length : 2).map((quote) => (
<div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2">
{quotes.map((quote) => (
<figure
className="group px-4 py-4 md:odd:border-r"
key={quote.author}
className="px-4 py-4 md:odd:border-r group"
>
<QuoteIcon className="size-10 text-muted-foreground/50 stroke-1 mb-2 group-hover:text-foreground group-hover:rotate-6 transition-all" />
<blockquote className="text-xl prose">
<QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" />
<blockquote className="prose text-xl">
<Markdown>{quote.quote}</Markdown>
</blockquote>
<figcaption className="row justify-between text-muted-foreground text-sm mt-4">
<figcaption className="row mt-4 justify-between text-muted-foreground text-sm">
<span>{quote.author}</span>
{quote.site && (
<cite className="not-italic">
<a
href={quote.site}
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
{quote.site.replace('https://', '')}
</a>
@@ -123,14 +120,6 @@ export function WhyOpenPanel() {
</figure>
))}
</div>
<Button
onClick={() => setShowMore((p) => !p)}
type="button"
variant="outline"
className="self-end mt-4"
>
{showMore ? 'Show less' : 'View more reviews'}
</Button>
</div>
</Section>
);

View File

@@ -0,0 +1,498 @@
'use client';
import Image from 'next/image';
export default function DpaDownloadPage() {
return (
<div className="min-h-screen bg-white text-black">
{/* Print button - hidden when printing */}
<div className="sticky top-0 z-10 flex justify-end gap-3 border-gray-200 border-b bg-white px-8 py-3 print:hidden">
<button
className="rounded bg-black px-4 py-2 font-medium text-sm text-white hover:bg-gray-800"
onClick={() => window.print()}
type="button"
>
Download / Print PDF
</button>
</div>
<div className="mx-auto max-w-3xl px-8 py-12 print:py-0">
{/* Header */}
<div className="mb-10 border-gray-300 border-b pb-8">
<p className="mb-1 text-gray-500 text-xs uppercase tracking-widest">
OpenPanel AB
</p>
<h1 className="mb-2 font-bold text-3xl">Data Processing Agreement</h1>
<p className="text-gray-500 text-sm">
Version 1.0 &middot; Last updated: March 3, 2026
</p>
</div>
<p className="mb-8 text-gray-700 text-sm leading-relaxed">
This Data Processing Agreement ("DPA") is entered into between
OpenPanel AB ("OpenPanel", "Processor") and the customer identified in
the signature block below ("Controller"). It applies where OpenPanel
processes personal data on behalf of the Controller as part of the
OpenPanel Cloud service, and forms part of the OpenPanel Terms of
Service.
</p>
<Section number="1" title="Definitions">
<ul className="list-none space-y-2 text-gray-700 text-sm">
<li>
<strong>GDPR</strong> means Regulation (EU) 2016/679 of the
European Parliament and of the Council.
</li>
<li>
<strong>Controller</strong> means the customer, who determines the
purposes and means of processing.
</li>
<li>
<strong>Processor</strong> means OpenPanel, who processes data on
the Controller's behalf.
</li>
<li>
<strong>Personal Data</strong>, <strong>Processing</strong>,{' '}
<strong>Data Subject</strong>, and{' '}
<strong>Supervisory Authority</strong> have the meanings given in
the GDPR.
</li>
<li>
<strong>Sub-processor</strong> means any third party engaged by
OpenPanel to process Personal Data in connection with the service.
</li>
</ul>
</Section>
<Section number="2" title="Our approach to privacy">
<p className="mb-3 text-gray-700 text-sm leading-relaxed">
OpenPanel is built to minimize personal data collection by design.
We do not use cookies for analytics tracking. We do not store IP
addresses. Instead, we generate a daily-rotating anonymous
identifier using a one-way hash of the visitor's IP address, user
agent, and project ID combined with a salt that is replaced every 24
hours. The raw IP address is discarded immediately and the
identifier becomes irreversible once the salt is rotated.
</p>
<p className="mb-2 text-gray-700 text-sm">
The data we store per event is:
</p>
<ul className="mb-3 list-disc space-y-1 pl-5 text-gray-700 text-sm">
<li>Page URL and referrer</li>
<li>Browser name and version</li>
<li>Operating system name and version</li>
<li>Device type, brand, and model</li>
<li>
City, country, and region (derived from IP at the time of the
request; IP is then discarded)
</li>
<li>Custom event properties the Controller chooses to send</li>
</ul>
<p className="mb-3 text-gray-700 text-sm">
No persistent identifiers, no cookies, no cross-site tracking.
Because of this approach, the analytics data OpenPanel collects in
standard website tracking mode does not constitute personal data
under GDPR Art. 4(1). We provide this DPA for Controllers who
require it for their own compliance documentation and records of
processing activities.
</p>
<p className="mb-1 text-gray-700 text-sm font-semibold">
Session replay (optional feature)
</p>
<p className="text-gray-700 text-sm">
OpenPanel optionally supports session replay, which must be
explicitly enabled by the Controller. When enabled, session replay
records DOM snapshots and user interactions (mouse movements, clicks,
scrolls) using rrweb. All text content and form inputs are masked by
default. The Controller is responsible for ensuring their use of
session replay complies with applicable privacy law, including
providing appropriate notice to end users.
</p>
</Section>
<Section number="3" title="Scope and roles">
<p className="text-gray-700 text-sm leading-relaxed">
OpenPanel acts as a Processor when processing data on behalf of the
Controller. The Controller is responsible for the analytics data
collected from visitors to their websites and applications.
</p>
</Section>
<Section number="4" title="Processor obligations">
<p className="mb-2 text-gray-700 text-sm">
OpenPanel commits to the following:
</p>
<ul className="list-disc space-y-1 pl-5 text-gray-700 text-sm">
<li>
Process Personal Data only on the Controller's documented
instructions and for no other purpose.
</li>
<li>
Ensure that all personnel with access to Personal Data are bound
by appropriate confidentiality obligations.
</li>
<li>
Implement and maintain technical and organizational measures in
accordance with Section 7 of this DPA.
</li>
<li>
Not engage a Sub-processor without prior general or specific
written authorization and flow down equivalent data protection
obligations to any Sub-processor.
</li>
<li>
Assist the Controller, where reasonably possible, in responding to
Data Subject requests to exercise their rights under GDPR.
</li>
<li>
Notify the Controller without undue delay (and no later than 48
hours) upon becoming aware of a Personal Data breach.
</li>
<li>
Make available all information necessary to demonstrate compliance
with this DPA and cooperate with audits conducted by the
Controller or their designated auditor, subject to reasonable
notice and confidentiality obligations.
</li>
<li>
At the Controller's choice, delete or return all Personal Data
upon termination of the service.
</li>
</ul>
</Section>
<Section number="5" title="Controller obligations">
<p className="mb-2 text-gray-700 text-sm">
The Controller confirms that:
</p>
<ul className="list-disc space-y-1 pl-5 text-gray-700 text-sm">
<li>
They have a lawful basis for the processing described in this DPA.
</li>
<li>
They have provided appropriate privacy notices to their end users.
</li>
<li>
They are responsible for the accuracy and lawfulness of the data
they instruct OpenPanel to process.
</li>
</ul>
</Section>
<Section number="6" title="Sub-processors">
<p className="mb-3 text-gray-700 text-sm">
OpenPanel uses the following sub-processors to deliver the service:
</p>
<table className="mb-3 w-full border-collapse text-sm">
<thead>
<tr className="border border-gray-300 bg-gray-50">
<th className="border border-gray-300 px-3 py-2 text-left font-semibold">
Sub-processor
</th>
<th className="border border-gray-300 px-3 py-2 text-left font-semibold">
Purpose
</th>
<th className="border border-gray-300 px-3 py-2 text-left font-semibold">
Location
</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-300 px-3 py-2">
Hetzner Online GmbH
</td>
<td className="border border-gray-300 px-3 py-2">
Cloud infrastructure and data storage
</td>
<td className="border border-gray-300 px-3 py-2">
Germany (EU)
</td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2">
Cloudflare R2
</td>
<td className="border border-gray-300 px-3 py-2">
Backup storage
</td>
<td className="border border-gray-300 px-3 py-2">EU</td>
</tr>
</tbody>
</table>
<p className="text-gray-700 text-sm">
OpenPanel will inform the Controller of any intended changes to this
list with reasonable notice, giving the Controller the opportunity
to object.
</p>
</Section>
<Section number="7" title="Technical and organizational measures">
<div className="space-y-4 text-gray-700 text-sm">
<div>
<p className="mb-1 font-semibold">
Data minimization and anonymization
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
IP addresses are never stored. They are used only to derive
geolocation and generate an anonymous daily identifier, then
discarded.
</li>
<li>
Daily-rotating cryptographic salts ensure visitor identifiers
cannot be reversed or linked to individuals after 24 hours.
</li>
<li>
No cookies or persistent cross-device identifiers are used.
</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">Access control</p>
<ul className="list-disc space-y-1 pl-5">
<li>
Dashboard access is protected by authentication and role-based
access control.
</li>
<li>
Production systems are accessible only to authorized
personnel.
</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">
Encryption and transport security
</p>
<ul className="list-disc space-y-1 pl-5">
<li>All data is transmitted over HTTPS (TLS).</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">
Infrastructure and availability
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
All data is hosted on Hetzner servers located in Germany
within the EU.
</li>
<li>Regular backups are performed.</li>
<li>
No data leaves the EEA in the course of normal operations.
</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">Incident response</p>
<ul className="list-disc space-y-1 pl-5">
<li>
We maintain procedures for detecting, reporting, and
investigating Personal Data breaches.
</li>
<li>
In the event of a breach affecting the Controller's data, we
will notify them within 48 hours of becoming aware.
</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">Open source</p>
<ul className="list-disc space-y-1 pl-5">
<li>
The OpenPanel codebase is publicly available on GitHub,
allowing independent review of our data handling practices.
</li>
</ul>
</div>
</div>
</Section>
<Section number="8" title="International data transfers">
<p className="text-gray-700 text-sm leading-relaxed">
OpenPanel stores and processes all analytics data on Hetzner
infrastructure located in Germany. No Personal Data is transferred
to countries outside the EEA in the course of delivering the
service.
</p>
</Section>
<Section number="9" title="Data retention and deletion">
<ul className="list-disc space-y-1 pl-5 text-gray-700 text-sm">
<li>
<strong>Analytics events</strong> are retained for as long as the
Controller's account is active. No maximum retention period is
currently enforced. If a retention limit is introduced in the
future, all customers will be notified in advance.
</li>
<li>
<strong>Session replays</strong> are retained for 30 days and then
permanently deleted.
</li>
<li>
The Controller can delete individual projects, all associated data,
or their entire account at any time from within the dashboard. Upon
account termination, OpenPanel will delete the Controller's data
within 30 days unless required by law to retain it longer.
</li>
</ul>
</Section>
<Section number="10" title="Governing law">
<p className="text-gray-700 text-sm leading-relaxed">
This DPA is governed by the laws of Sweden and is interpreted in
accordance with the GDPR.
</p>
</Section>
{/* Exhibit A */}
<div className="mb-8 border-black border-t-2 pt-8">
<p className="mb-1 text-gray-500 text-xs uppercase tracking-widest">
Annex
</p>
<h2 className="mb-4 font-bold text-xl">
Exhibit A: Description of Processing
</h2>
<table className="w-full border-collapse text-sm">
<tbody>
<Row
label="Nature of processing"
value="Collection and storage of anonymized website analytics events (page views, custom events, session data). Optionally: session replay recording of DOM snapshots and user interactions."
/>
<Row
label="Purpose of processing"
value="To provide the Controller with website and product analytics via the OpenPanel Cloud dashboard. Session replay (if enabled) is used to allow the Controller to review user sessions for UX and debugging purposes."
/>
<Row
label="Duration of processing"
value="Analytics events: retained for the duration of the active account (no current maximum). Session replays: 30 days, then permanently deleted. All data deleted within 30 days of account termination."
/>
<Row
label="Categories of data subjects"
value="Visitors to the Controller's websites and applications"
/>
<Row
label="Categories of personal data"
value="Anonymized session identifiers (non-reversible after 24 hours), page URLs, referrers, browser type and version, operating system, device type, city-level geolocation (country, region, city). No IP addresses, no cookies, no names, no email addresses. If session replay is enabled: DOM snapshots and interaction recordings, which may incidentally contain personal data visible on the Controller's pages. All text content and form inputs are masked by default."
/>
<Row
label="Special categories of data"
value="None intended. The Controller is responsible for ensuring no special category data is captured via session replay."
/>
<Row
label="Sub-processors"
value="Hetzner Online GmbH (Germany) cloud infrastructure; Cloudflare R2 (EU) backup storage"
/>
</tbody>
</table>
</div>
{/* Signatures */}
<div className="border-black border-t-2 pt-8">
<p className="mb-1 text-gray-500 text-xs uppercase tracking-widest">
Execution
</p>
<h2 className="mb-6 font-bold text-xl">Signatures</h2>
<div className="grid grid-cols-2 gap-12">
{/* Processor - pre-signed */}
<div>
<div className="col h-32 gap-2">
<p className="font-semibold text-gray-500 text-xs uppercase tracking-widest">
Processor
</p>
<p className="font-semibold text-sm">OpenPanel AB</p>
<p className="text-gray-500 text-xs">
Sankt Eriksgatan 100, 113 31 Stockholm, Sweden
</p>
</div>
<SignatureLine
label="Signature"
value={
<Image
alt="Carl-Gerhard Lindesvärd signature"
className="relative top-4 h-16 w-auto object-contain object-left"
height={64}
src="/signature.png"
width={200}
/>
}
/>
<SignatureLine label="Name" value="Carl-Gerhard Lindesvärd" />
<SignatureLine label="Title" value="Founder" />
<SignatureLine label="Date" value="March 3, 2026" />
</div>
{/* Controller - blank */}
<div>
<div className="flex flex-col h-32 gap-2">
<p className="font-semibold text-gray-500 text-xs uppercase tracking-widest">
Controller
</p>
</div>
<SignatureLine label="Company" value="" />
<SignatureLine label="Signature" value="" />
<SignatureLine label="Name" value="" />
<SignatureLine label="Title" value="" />
<SignatureLine label="Date" value="" />
</div>
</div>
</div>
<div className="mt-12 border-gray-200 border-t pt-6 text-center text-gray-400 text-xs print:mt-4">
OpenPanel AB &middot; hello@openpanel.dev &middot; openpanel.dev/dpa
</div>
</div>
</div>
);
}
function Section({
number,
title,
children,
}: {
number: string;
title: string;
children: React.ReactNode;
}) {
return (
<div className="mb-8">
<h2 className="mb-3 font-bold text-base">
{number}. {title}
</h2>
{children}
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<tr className="border border-gray-300">
<td className="w-48 border border-gray-300 bg-gray-50 px-3 py-2 align-top font-semibold text-xs">
{label}
</td>
<td className="border border-gray-300 px-3 py-2 text-xs leading-relaxed">
{value}
</td>
</tr>
);
}
function SignatureLine({
label,
value,
}: {
label: string;
value: string | React.ReactNode;
}) {
return (
<div className="mb-3">
<p className="text-gray-500 text-xs">{label}</p>
<div className="mt-1 flex h-7 items-end border-gray-400 border-b font-mono">
{value}
</div>
</div>
);
}

View File

@@ -124,6 +124,7 @@ export async function Footer() {
<Link href="/sitemap.xml">Sitemap</Link>
<Link href="/privacy">Privacy Policy</Link>
<Link href="/terms">Terms of Service</Link>
<Link href="/dpa">DPA</Link>
<Link href="/cookies">Cookie Policy (just kidding)</Link>
</div>
</div>

View File

@@ -6,7 +6,7 @@
"dev": "pnpm with-env vite dev --port 3000",
"start_deprecated": "pnpm with-env node .output/server/index.mjs",
"preview": "vite preview",
"deploy": "npx wrangler deploy",
"deploy": "pnpm build && npx wrangler deploy",
"cf-typegen": "wrangler types",
"build": "pnpm with-env vite build",
"serve": "vite preview",

View File

@@ -1,24 +1,27 @@
import { pushModal } from '@/modals';
import type {
IReport,
IChartRange,
IChartType,
IInterval,
IReport,
} from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { ReportChart } from '../report-chart';
import { ReportChartType } from '../report/ReportChartType';
import { ReportInterval } from '../report/ReportInterval';
import { ReportChart } from '../report-chart';
import { TimeWindowPicker } from '../time-window-picker';
import { Button } from '../ui/button';
import { pushModal } from '@/modals';
export function ChatReport({
lazy,
...props
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
}: {
report: IReport & { startDate: string; endDate: string };
lazy: boolean;
}) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
props.report.chartType
);
const [startDate, setStartDate] = useState<string>(props.report.startDate);
const [endDate, setEndDate] = useState<string>(props.report.endDate);
@@ -35,47 +38,48 @@ export function ChatReport({
};
return (
<div className="card">
<div className="text-center text-sm font-mono font-medium pt-4">
<div className="pt-4 text-center font-medium font-mono text-sm">
{props.report.name}
</div>
<div className="p-4">
<ReportChart lazy={lazy} report={report} />
</div>
<div className="row justify-between gap-1 border-t border-border p-2">
<div className="row justify-between gap-1 border-border border-t p-2">
<div className="col md:row gap-1">
<TimeWindowPicker
className="min-w-0"
onChange={setRange}
value={report.range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={report.endDate}
onChange={setRange}
onEndDateChange={setEndDate}
onIntervalChange={setInterval}
onStartDateChange={setStartDate}
startDate={report.startDate}
value={report.range}
/>
<ReportInterval
chartType={chartType}
className="min-w-0"
interval={interval}
range={range}
chartType={chartType}
onChange={setInterval}
range={range}
/>
<ReportChartType
value={chartType}
onChange={(type) => {
setChartType(type);
}}
value={chartType}
/>
</div>
<Button
icon={SaveIcon}
variant="outline"
size="sm"
onClick={() => {
pushModal('SaveReport', {
report,
disableRedirect: true,
});
}}
size="sm"
variant="outline"
>
Save report
</Button>

View File

@@ -3,7 +3,7 @@ import { type VariantProps, cva } from 'class-variance-authority';
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
const deltaChipVariants = cva(
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
'flex items-center justify-center gap-1 rounded-full font-semibold',
{
variants: {
variant: {
@@ -12,9 +12,10 @@ const deltaChipVariants = cva(
default: 'bg-muted text-muted-foreground',
},
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
xs: 'px-1.5 py-0 leading-none text-[10px]',
sm: 'px-2 py-1 text-xs',
md: 'px-2 py-1 text-sm',
lg: 'px-2 py-1 text-base',
},
},
defaultVariants: {
@@ -30,6 +31,7 @@ type DeltaChipProps = VariantProps<typeof deltaChipVariants> & {
};
const iconVariants: Record<NonNullable<DeltaChipProps['size']>, number> = {
xs: 8,
sm: 12,
md: 16,
lg: 20,

View File

@@ -1,9 +1,9 @@
import { useAppContext } from '@/hooks/use-app-context';
import { cn } from '@/utils/cn';
import { MenuIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
import { LogoSquare } from './logo';
import { Button, LinkButton } from './ui/button';
import { Button } from './ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { cn } from '@/utils/cn';
export function LoginNavbar({ className }: { className?: string }) {
const { isSelfHosted } = useAppContext();
@@ -12,59 +12,61 @@ export function LoginNavbar({ className }: { className?: string }) {
return (
<div
className={cn(
'absolute top-0 left-0 w-full row justify-between items-center p-8 z-10',
className,
'row absolute top-0 left-0 z-10 w-full items-center justify-between p-8',
className
)}
>
<a href="https://openpanel.dev" className="row items-center gap-2">
<a className="row items-center gap-2" href="https://openpanel.dev">
<LogoSquare className="size-8 shrink-0" />
<span className="font-medium text-sm text-muted-foreground">
<span className="font-medium text-muted-foreground text-sm">
{isSelfHosted ? 'Self-hosted analytics' : 'OpenPanel.dev'}
</span>
</a>
<nav className="max-md:hidden">
<ul className="row gap-4 items-center [&>li>a]:text-sm [&>li>a]:text-muted-foreground [&>li>a]:hover:underline">
<li>
<a href="https://openpanel.dev">OpenPanel Cloud</a>
</li>
<li>
<a href="https://openpanel.dev/compare/mixpanel-alternative">
Mixpanel alternative
</a>
</li>
<li>
<a href="https://openpanel.dev/compare/posthog-alternative">
Posthog alternative
</a>
</li>
<li>
<a href="https://openpanel.dev/articles/open-source-web-analytics">
Open source analytics
</a>
</li>
</ul>
</nav>
<div className="md:hidden relative">
{isSelfHosted && (
<nav className="max-md:hidden">
<ul className="row items-center gap-4 [&>li>a]:text-muted-foreground [&>li>a]:text-sm [&>li>a]:hover:underline">
<li>
<a href="https://openpanel.dev">OpenPanel Cloud</a>
</li>
<li>
<a href="https://openpanel.dev/compare/mixpanel-alternative">
Mixpanel alternative
</a>
</li>
<li>
<a href="https://openpanel.dev/compare/posthog-alternative">
Posthog alternative
</a>
</li>
<li>
<a href="https://openpanel.dev/articles/open-source-web-analytics">
Open source analytics
</a>
</li>
</ul>
</nav>
)}
<div className="relative md:hidden">
<Button
onClick={() => setMobileMenuOpen((prev) => !prev)}
size="icon"
variant="ghost"
onClick={() => setMobileMenuOpen((prev) => !prev)}
>
{mobileMenuOpen ? <XIcon size={20} /> : <MenuIcon size={20} />}
</Button>
{mobileMenuOpen && (
<>
<button
type="button"
onClick={() => setMobileMenuOpen(false)}
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onClick={() => setMobileMenuOpen(false)}
type="button"
/>
<nav className="absolute right-0 top-full mt-2 z-50 bg-card border border-border rounded-md shadow-lg min-w-48 py-2">
<ul className="flex flex-col *:text-sm *:text-muted-foreground">
<nav className="absolute top-full right-0 z-50 mt-2 min-w-48 rounded-md border border-border bg-card py-2 shadow-lg">
<ul className="flex flex-col *:text-muted-foreground *:text-sm">
<li>
<a
href="https://openpanel.dev"
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
href="https://openpanel.dev"
onClick={() => setMobileMenuOpen(false)}
>
OpenPanel Cloud
@@ -72,8 +74,8 @@ export function LoginNavbar({ className }: { className?: string }) {
</li>
<li>
<a
href="https://openpanel.dev/compare/mixpanel-alternative"
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
href="https://openpanel.dev/compare/mixpanel-alternative"
onClick={() => setMobileMenuOpen(false)}
>
Posthog alternative
@@ -81,8 +83,8 @@ export function LoginNavbar({ className }: { className?: string }) {
</li>
<li>
<a
href="https://openpanel.dev/compare/mixpanel-alternative"
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
href="https://openpanel.dev/compare/mixpanel-alternative"
onClick={() => setMobileMenuOpen(false)}
>
Mixpanel alternative
@@ -90,8 +92,8 @@ export function LoginNavbar({ className }: { className?: string }) {
</li>
<li>
<a
href="https://openpanel.dev/articles/open-source-web-analytics"
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
href="https://openpanel.dev/articles/open-source-web-analytics"
onClick={() => setMobileMenuOpen(false)}
>
Open source analytics

View File

@@ -1,119 +1,102 @@
import { LogoSquare } from '@/components/logo';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { Link } from '@tanstack/react-router';
import { CodeIcon, CreditCardIcon, DollarSignIcon } from 'lucide-react';
import { SellingPoint } from './selling-points';
import Autoplay from 'embla-carousel-autoplay';
import { QuoteIcon } from 'lucide-react';
const onboardingSellingPoints = [
const testimonials = [
{
key: 'get-started',
render: () => (
<SellingPoint
bgImage="/img-6.webp"
title="Get started in minutes"
description={
<>
<p>
<DollarSignIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Free trial
</p>
<p>
<CreditCardIcon className="size-4 inline-block mr-1 relative -top-0.5" />
No credit card required
</p>
<p>
<CodeIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Add our tracking code and get insights in real-time.
</p>
</>
}
/>
),
key: 'thomas',
bgImage: '/img-1.webp',
quote:
"OpenPanel is BY FAR the best open-source analytics I've ever seen. Better UX/UI, many more features, and incredible support from the founder.",
author: 'Thomas Sanlis',
site: 'uneed.best',
},
{
key: 'welcome',
render: () => (
<SellingPoint
bgImage="/img-1.webp"
title="Best open-source alternative"
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring"
/>
),
key: 'julien',
bgImage: '/img-2.webp',
quote:
'After testing several product analytics tools, we chose OpenPanel and we are very satisfied. Profiles and Conversion Events are our favorite features.',
author: 'Julien Hany',
site: 'strackr.com',
},
{
key: 'selling-point-2',
render: () => (
<SellingPoint
bgImage="/img-2.webp"
title="Fast and reliable"
description="Never miss a beat with our real-time analytics"
/>
),
key: 'piotr',
bgImage: '/img-3.webp',
quote:
'The Overview tab is great — it has everything I need. The UI is beautiful, clean, modern, very pleasing to the eye.',
author: 'Piotr Kulpinski',
site: 'producthunt.com',
},
{
key: 'selling-point-3',
render: () => (
<SellingPoint
bgImage="/img-3.webp"
title="Easy to use"
description="Compared to other tools we have kept it simple"
/>
),
},
{
key: 'selling-point-4',
render: () => (
<SellingPoint
bgImage="/img-4.webp"
title="Privacy by default"
description="We have built our platform with privacy at its heart"
/>
),
},
{
key: 'selling-point-5',
render: () => (
<SellingPoint
bgImage="/img-5.webp"
title="Open source"
description="You can inspect the code and self-host if you choose"
/>
),
key: 'selfhost',
bgImage: '/img-4.webp',
quote:
"After paying a lot to PostHog for years, OpenPanel gives us the same — in many ways better — analytics while keeping full ownership of our data. We don't want to run any business without OpenPanel anymore.",
author: 'Self-hosting user',
site: undefined,
},
];
function TestimonialSlide({
bgImage,
quote,
author,
site,
}: {
bgImage: string;
quote: string;
author: string;
site?: string;
}) {
return (
<div className="relative flex flex-col justify-end h-full p-10 select-none">
<img
src={bgImage}
className="absolute inset-0 w-full h-full object-cover"
alt=""
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-black/10" />
<div className="relative z-10 flex flex-col gap-4">
<QuoteIcon className="size-10 text-white/40 stroke-1" />
<blockquote className="text-3xl font-medium text-white leading-relaxed">
{quote}
</blockquote>
<figcaption className="text-white/60 text-sm">
{author}
{site && <span className="ml-1 text-white/40">· {site}</span>}
</figcaption>
</div>
</div>
);
}
export function OnboardingLeftPanel() {
return (
<div className="sticky top-0 h-screen overflow-hidden">
{/* Carousel */}
<div className="flex items-center justify-center h-full mt-24">
<Carousel
className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
opts={{
loop: true,
align: 'center',
}}
opts={{ loop: true, align: 'center' }}
plugins={[Autoplay({ delay: 6000, stopOnInteraction: false })]}
>
<CarouselContent className="h-full">
{onboardingSellingPoints.map((point, index) => (
<CarouselItem
key={`onboarding-point-${point.key}`}
className="p-8 pb-32 pt-0"
>
{testimonials.map((t) => (
<CarouselItem key={t.key} className="p-8 pb-32 pt-0">
<div className="rounded-xl min-h-full h-full overflow-hidden bg-card border border-border shadow-lg">
{point.render()}
<TestimonialSlide
bgImage={t.bgImage}
quote={t.quote}
author={t.author}
site={t.site}
/>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-12 bottom-30 top-auto" />
<CarouselNext className="right-12 bottom-30 top-auto" />
</Carousel>
</div>
</div>

View File

@@ -1,23 +1,16 @@
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart, Bar, BarChart, Tooltip } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common';
import { useEffect, useRef, useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Bar, BarChart, Tooltip } from 'recharts';
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '../charts/chart-tooltip';
import {
PreviousDiffIndicatorPure,
getDiffIndicator,
PreviousDiffIndicatorPure,
} from '../report-chart/common/previous-diff-indicator';
import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip';
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { formatDate, timeAgo } from '@/utils/date';
interface MetricCardProps {
id: string;
@@ -78,6 +71,9 @@ export function OverviewMetricCard({
}
if (unit === 'timeAgo') {
if (!value) {
return <>{'N/A'}</>;
}
return <>{timeAgo(new Date(value))}</>;
}
@@ -103,7 +99,7 @@ export function OverviewMetricCard({
getPreviousMetric(current, previous)?.state,
'#6ee7b7', // green
'#fda4af', // red
'#93c5fd', // blue
'#93c5fd' // blue
);
const renderTooltip = () => {
@@ -115,7 +111,7 @@ export function OverviewMetricCard({
{renderValue(
data[currentIndex].current,
'ml-1 font-light text-xl',
false,
false
)}
</span>
</span>
@@ -132,60 +128,60 @@ export function OverviewMetricCard({
);
};
return (
<Tooltiper content={renderTooltip()} asChild sideOffset={-20}>
<Tooltiper asChild content={renderTooltip()} sideOffset={-20}>
<button
type="button"
className={cn(
'col-span-2 flex-1 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
active && 'bg-def-100',
active && 'bg-def-100'
)}
onClick={onClick}
type="button"
>
<div className={cn('group relative p-4')}>
<div
className={cn(
'absolute left-4 right-4 bottom-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
'absolute right-4 bottom-0 left-4 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100'
)}
>
<AutoSizer style={{ height: 20 }}>
{({ width }) => (
<BarChart
width={width}
height={20}
data={data}
style={{
background: 'transparent',
}}
height={20}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
onMouseMove={(event) => {
setCurrentIndex(event.activeTooltipIndex ?? null);
}}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
style={{
background: 'transparent',
}}
width={width}
>
<Tooltip content={() => null} cursor={false} />
<Bar
dataKey={'current'}
type="step"
fill={graphColors}
fillOpacity={1}
strokeWidth={0}
isAnimationActive={false}
strokeWidth={0}
type="step"
/>
</BarChart>
)}
</AutoSizer>
</div>
<OverviewMetricCardNumber
label={label}
value={renderValue(current, 'ml-1 font-light text-xl')}
enhancer={
<PreviousDiffIndicatorPure
className="text-sm"
size="sm"
inverted={inverted}
size="sm"
{...getPreviousMetric(current, previous)}
/>
}
isLoading={isLoading}
label={label}
value={renderValue(current, 'ml-1 font-light text-xl')}
/>
</div>
</button>
@@ -207,9 +203,9 @@ export function OverviewMetricCardNumber({
isLoading?: boolean;
}) {
return (
<div className={cn('min-w-0 col gap-2', className)}>
<div className={cn('col min-w-0 gap-2', className)}>
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
<span className="truncate font-medium text-muted-foreground text-sm leading-[1.1]">
{label}
</span>
</div>
@@ -219,11 +215,11 @@ export function OverviewMetricCardNumber({
<Skeleton className="h-6 w-12" />
</div>
) : (
<div className="truncate font-mono text-3xl leading-[1.1] font-bold w-full text-left">
<div className="w-full truncate text-left font-bold font-mono text-3xl leading-[1.1]">
{value}
</div>
)}
<div className="absolute right-0 top-0 bottom-0 center justify-center col pr-4">
<div className="center col absolute top-0 right-0 bottom-0 justify-center pr-4">
{enhancer}
</div>
</div>

View File

@@ -2,17 +2,25 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { TimeWindowPicker } from '@/components/time-window-picker';
export function OverviewRange() {
const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
useOverviewOptions();
const {
range,
setRange,
setStartDate,
setEndDate,
endDate,
startDate,
setInterval,
} = useOverviewOptions();
return (
<TimeWindowPicker
onChange={setRange}
value={range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={endDate}
onChange={setRange}
onEndDateChange={setEndDate}
onIntervalChange={setInterval}
onStartDateChange={setStartDate}
startDate={startDate}
value={range}
/>
);
}

View File

@@ -1,8 +1,11 @@
import { useAppParams } from '@/hooks/use-app-params';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { eventQueryFiltersParser } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { Widget, WidgetBody } from '../widget';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
@@ -23,7 +26,9 @@ export default function OverviewTopEvents({
shareId,
}: OverviewTopEventsProps) {
const { range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [filters] = useEventQueryFilters();
const { organizationId } = useAppParams();
const navigate = useNavigate();
const trpc = useTRPC();
const { data: conversions } = useQuery(
trpc.overview.topConversions.queryOptions({ projectId, shareId }),
@@ -162,11 +167,23 @@ export default function OverviewTopEvents({
<OverviewWidgetTableEvents
data={filteredData}
onItemClick={(name) => {
if (widget.meta?.type === 'linkOut') {
setFilter('properties.href', name);
} else {
setFilter('name', name);
}
const filterName =
widget.meta?.type === 'linkOut'
? 'properties.href'
: 'name';
const f = eventQueryFiltersParser.serialize([
{
id: filterName,
name: filterName,
operator: 'is',
value: [name],
},
]);
navigate({
to: '/$organizationId/$projectId/events/events',
params: { organizationId, projectId },
search: { f },
});
}}
/>
)}

View File

@@ -0,0 +1,143 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
import { Skeleton } from '@/components/skeleton';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
interface GscBreakdownTableProps {
projectId: string;
value: string;
type: 'page' | 'query';
}
export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableProps) {
const { range, startDate, endDate } = useOverviewOptions();
const trpc = useTRPC();
const dateInput = {
range,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
};
const pageQuery = useQuery(
trpc.gsc.getPageDetails.queryOptions(
{ projectId, page: value, ...dateInput },
{ enabled: type === 'page' },
),
);
const queryQuery = useQuery(
trpc.gsc.getQueryDetails.queryOptions(
{ projectId, query: value, ...dateInput },
{ enabled: type === 'query' },
),
);
const isLoading = type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
const breakdownRows: Record<string, string | number>[] =
type === 'page'
? ((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ?? []) as Record<string, string | number>[]
: ((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ?? []) as Record<string, string | number>[];
const breakdownKey = type === 'page' ? 'query' : 'page';
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
const pluralLabel = type === 'page' ? 'queries' : 'pages';
const maxClicks = Math.max(
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
1,
);
return (
<div className="card overflow-hidden">
<div className="border-b p-4">
<h3 className="font-medium text-sm">Top {pluralLabel}</h3>
</div>
{isLoading ? (
<OverviewWidgetTable
data={[1, 2, 3, 4, 5]}
keyExtractor={(i) => String(i)}
getColumnPercentage={() => 0}
columns={[
{ name: breakdownLabel, width: 'w-full', render: () => <Skeleton className="h-4 w-2/3" /> },
{ name: 'Clicks', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
{ name: 'Impr.', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
{ name: 'CTR', width: '60px', render: () => <Skeleton className="h-4 w-8" /> },
{ name: 'Pos.', width: '55px', render: () => <Skeleton className="h-4 w-8" /> },
]}
/>
) : (
<OverviewWidgetTable
data={breakdownRows}
keyExtractor={(item) => String(item[breakdownKey])}
getColumnPercentage={(item) => (item.clicks as number) / maxClicks}
columns={[
{
name: breakdownLabel,
width: 'w-full',
render(item) {
return (
<div className="min-w-0 overflow-hidden">
<span className="block truncate font-mono text-xs">
{String(item[breakdownKey])}
</span>
</div>
);
},
},
{
name: 'Clicks',
width: '70px',
getSortValue: (item) => item.clicks as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.clicks as number).toLocaleString()}
</span>
);
},
},
{
name: 'Impr.',
width: '70px',
getSortValue: (item) => item.impressions as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.impressions as number).toLocaleString()}
</span>
);
},
},
{
name: 'CTR',
width: '60px',
getSortValue: (item) => item.ctr as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{((item.ctr as number) * 100).toFixed(1)}%
</span>
);
},
},
{
name: 'Pos.',
width: '55px',
getSortValue: (item) => item.position as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.position as number).toFixed(1)}
</span>
);
},
},
]}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,226 @@
import type { IChartRange, IInterval } from '@openpanel/validation';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Pagination } from '@/components/pagination';
import { useAppContext } from '@/hooks/use-app-context';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
interface GscCannibalizationProps {
projectId: string;
range: IChartRange;
interval: IInterval;
startDate?: string;
endDate?: string;
}
export function GscCannibalization({
projectId,
range,
interval,
startDate,
endDate,
}: GscCannibalizationProps) {
const trpc = useTRPC();
const { apiUrl } = useAppContext();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [page, setPage] = useState(0);
const pageSize = 15;
const query = useQuery(
trpc.gsc.getCannibalization.queryOptions(
{
projectId,
range,
interval,
startDate,
endDate,
},
{ placeholderData: keepPreviousData }
)
);
const toggle = (q: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(q)) {
next.delete(q);
} else {
next.add(q);
}
return next;
});
};
const items = query.data ?? [];
const pageCount = Math.ceil(items.length / pageSize) || 1;
useEffect(() => {
setPage((p) => Math.max(0, Math.min(p, pageCount - 1)));
}, [items, pageSize, pageCount]);
const paginatedItems = useMemo(
() => items.slice(page * pageSize, (page + 1) * pageSize),
[items, page, pageSize]
);
const rangeStart = items.length ? page * pageSize + 1 : 0;
const rangeEnd = Math.min((page + 1) * pageSize, items.length);
if (!(query.isLoading || items.length)) {
return null;
}
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
{items.length > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
{items.length}
</span>
)}
</div>
{items.length > 0 && (
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-muted-foreground text-xs">
{items.length === 0
? '0 results'
: `${rangeStart}-${rangeEnd} of ${items.length}`}
</span>
<Pagination
canNextPage={page < pageCount - 1}
canPreviousPage={page > 0}
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
pageIndex={page}
previousPage={() => setPage((p) => Math.max(0, p - 1))}
/>
</div>
)}
</div>
<div className="divide-y">
{query.isLoading &&
[1, 2, 3].map((i) => (
<div className="space-y-2 p-4" key={i}>
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
</div>
))}
{paginatedItems.map((item) => {
const isOpen = expanded.has(item.query);
const avgCtr =
item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length;
return (
<div key={item.query}>
<button
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-muted/40"
onClick={() => toggle(item.query)}
type="button"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
'row shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs',
'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
)}
>
<AlertCircleIcon className="size-3" />
{item.pages.length} pages
</div>
<span className="truncate font-medium text-sm">
{item.query}
</span>
</div>
<div className="flex shrink-0 items-center gap-4">
<span className="whitespace-nowrap font-mono text-muted-foreground text-xs">
{item.totalImpressions.toLocaleString()} impr ·{' '}
{(avgCtr * 100).toFixed(1)}% avg CTR
</span>
<ChevronsUpDownIcon
className={cn(
'size-3.5 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)}
/>
</div>
</button>
{isOpen && (
<div className="border-t bg-muted/20 px-4 py-3">
<p className="mb-3 text-muted-foreground text-xs leading-normal">
These pages all rank for{' '}
<span className="font-medium text-foreground">
"{item.query}"
</span>
. Consider consolidating weaker pages into the top-ranking
one to concentrate link equity and avoid splitting clicks.
</p>
<div className="space-y-1.5">
{item.pages.map((page, idx) => {
// Strip hash fragments — GSC sometimes returns heading
// anchor URLs (e.g. /page#section) as separate entries
let cleanUrl = page.page;
let origin = '';
let path = page.page;
try {
const u = new URL(page.page);
u.hash = '';
cleanUrl = u.toString();
origin = u.origin;
path = u.pathname + u.search;
} catch {
cleanUrl = page.page.split('#')[0] ?? page.page;
}
const isWinner = idx === 0;
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60"
key={page.page}
onClick={() =>
pushModal('PageDetails', {
type: 'page',
projectId,
value: cleanUrl,
})
}
type="button"
>
<img
alt=""
className="size-3.5 shrink-0 rounded-sm"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display =
'none';
}}
src={`${apiUrl}/misc/favicon?url=${origin}`}
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{path || page.page}
</span>
{isWinner && (
<span className="shrink-0 rounded bg-emerald-100 px-1 py-0.5 font-medium text-emerald-700 text-xs dark:bg-emerald-900/30 dark:text-emerald-400">
#1
</span>
)}
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
pos {page.position.toFixed(1)} ·{' '}
{(page.ctr * 100).toFixed(1)}% CTR ·{' '}
{page.impressions.toLocaleString()} impr
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import { useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useTRPC } from '@/integrations/trpc/react';
import { getChartColor } from '@/utils/theme';
interface ChartData {
date: string;
clicks: number;
impressions: number;
}
const { TooltipProvider, Tooltip } = createChartTooltip<
ChartData,
{ formatDate: (date: Date | string) => string }
>(({ data, context }) => {
const item = data[0];
if (!item) {
return null;
}
return (
<>
<ChartTooltipHeader>
<div>{context.formatDate(item.date)}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span>
<span>{item.clicks.toLocaleString()}</span>
</div>
</ChartTooltipItem>
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Impressions</span>
<span>{item.impressions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
});
interface GscClicksChartProps {
projectId: string;
value: string;
type: 'page' | 'query';
}
export function GscClicksChart({
projectId,
value,
type,
}: GscClicksChartProps) {
const { range, startDate, endDate, interval } = useOverviewOptions();
const trpc = useTRPC();
const yAxisProps = useYAxisProps();
const formatDateShort = useFormatDateInterval({ interval, short: true });
const formatDateLong = useFormatDateInterval({ interval, short: false });
const dateInput = {
range,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
};
const pageQuery = useQuery(
trpc.gsc.getPageDetails.queryOptions(
{ projectId, page: value, ...dateInput },
{ enabled: type === 'page' }
)
);
const queryQuery = useQuery(
trpc.gsc.getQueryDetails.queryOptions(
{ projectId, query: value, ...dateInput },
{ enabled: type === 'query' }
)
);
const isLoading =
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
const timeseries =
(type === 'page'
? pageQuery.data?.timeseries
: queryQuery.data?.timeseries) ?? [];
const data: ChartData[] = timeseries.map((r) => ({
date: r.date,
clicks: r.clicks,
impressions: r.impressions,
}));
return (
<div className="card p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-sm">Clicks & Impressions</h3>
<div className="flex items-center gap-4 text-muted-foreground text-xs">
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(0) }}
/>
Clicks
</span>
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(1) }}
/>
Impressions
</span>
</div>
</div>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<TooltipProvider formatDate={formatDateLong}>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-clicks-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => formatDateShort(v)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<Tooltip />
<Line
dataKey="clicks"
dot={false}
filter="url(#gsc-clicks-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="impressions"
dot={false}
filter="url(#gsc-clicks-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}

View File

@@ -0,0 +1,228 @@
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { getChartColor } from '@/utils/theme';
// Industry average CTR by position (Google organic)
const BENCHMARK: Record<number, number> = {
1: 28.5,
2: 15.7,
3: 11.0,
4: 8.0,
5: 6.3,
6: 5.0,
7: 4.0,
8: 3.3,
9: 2.8,
10: 2.5,
11: 2.2,
12: 2.0,
13: 1.8,
14: 1.5,
15: 1.2,
16: 1.1,
17: 1.0,
18: 0.9,
19: 0.8,
20: 0.7,
};
interface PageEntry {
path: string;
ctr: number;
impressions: number;
}
interface ChartData {
position: number;
yourCtr: number | null;
benchmark: number;
pages: PageEntry[];
}
const { TooltipProvider, Tooltip } = createChartTooltip<
ChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) {
return null;
}
return (
<>
<ChartTooltipHeader>
<div>Position #{item.position}</div>
</ChartTooltipHeader>
{item.yourCtr != null && (
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Your avg CTR</span>
<span>{item.yourCtr.toFixed(1)}%</span>
</div>
</ChartTooltipItem>
)}
<ChartTooltipItem color={getChartColor(3)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Benchmark</span>
<span>{item.benchmark.toFixed(1)}%</span>
</div>
</ChartTooltipItem>
{item.pages.length > 0 && (
<div className="mt-1.5 border-t pt-1.5">
{item.pages.map((p) => (
<div
className="flex items-center justify-between gap-4 py-0.5"
key={p.path}
>
<span className="max-w-40 truncate font-mono text-muted-foreground text-xs">
{p.path}
</span>
<span className="shrink-0 font-mono text-xs tabular-nums">
{(p.ctr * 100).toFixed(1)}%
</span>
</div>
))}
</div>
)}
</>
);
});
interface GscCtrBenchmarkProps {
data: Array<{
page: string;
position: number;
ctr: number;
impressions: number;
}>;
isLoading: boolean;
}
export function GscCtrBenchmark({ data, isLoading }: GscCtrBenchmarkProps) {
const yAxisProps = useYAxisProps();
const grouped = new Map<number, { ctrSum: number; pages: PageEntry[] }>();
for (const d of data) {
const pos = Math.round(d.position);
if (pos < 1 || pos > 20 || d.impressions < 10) {
continue;
}
let path = d.page;
try {
path = new URL(d.page).pathname;
} catch {
// keep as-is
}
const entry = grouped.get(pos) ?? { ctrSum: 0, pages: [] };
entry.ctrSum += d.ctr * 100;
entry.pages.push({ path, ctr: d.ctr, impressions: d.impressions });
grouped.set(pos, entry);
}
const chartData: ChartData[] = Array.from({ length: 20 }, (_, i) => {
const pos = i + 1;
const entry = grouped.get(pos);
const pages = entry
? [...entry.pages].sort((a, b) => b.ctr - a.ctr).slice(0, 5)
: [];
return {
position: pos,
yourCtr: entry ? entry.ctrSum / entry.pages.length : null,
benchmark: BENCHMARK[pos] ?? 0,
pages,
};
});
const hasAnyData = chartData.some((d) => d.yourCtr != null);
return (
<div className="card p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-sm">CTR vs Position</h3>
<div className="flex items-center gap-4 text-muted-foreground text-xs">
{hasAnyData && (
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(0) }}
/>
Your CTR
</span>
)}
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full opacity-60"
style={{ backgroundColor: getChartColor(3) }}
/>
Benchmark
</span>
</div>
</div>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={chartData}>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="position"
domain={[1, 20]}
tickFormatter={(v: number) => `#${v}`}
ticks={[1, 5, 10, 15, 20]}
type="number"
/>
<YAxis
{...yAxisProps}
domain={[0, 'auto']}
tickFormatter={(v: number) => `${v}%`}
/>
<Tooltip />
<Line
activeDot={{ r: 4 }}
connectNulls={false}
dataKey="yourCtr"
dot={{ r: 3, fill: getChartColor(0) }}
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
/>
<Line
dataKey="benchmark"
dot={false}
isAnimationActive={false}
stroke={getChartColor(3)}
strokeDasharray="4 3"
strokeOpacity={0.6}
strokeWidth={1.5}
type="monotone"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}

View File

@@ -0,0 +1,129 @@
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { getChartColor } from '@/utils/theme';
interface ChartData {
date: string;
position: number;
}
const { TooltipProvider, Tooltip } = createChartTooltip<
ChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) return null;
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(2)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Avg Position</span>
<span>{item.position.toFixed(1)}</span>
</div>
</ChartTooltipItem>
</>
);
});
interface GscPositionChartProps {
data: Array<{ date: string; position: number }>;
isLoading: boolean;
}
export function GscPositionChart({ data, isLoading }: GscPositionChartProps) {
const yAxisProps = useYAxisProps();
const chartData: ChartData[] = data.map((r) => ({
date: r.date,
position: r.position,
}));
const positions = chartData.map((d) => d.position).filter((p) => p > 0);
const minPos = positions.length ? Math.max(1, Math.floor(Math.min(...positions)) - 2) : 1;
const maxPos = positions.length ? Math.ceil(Math.max(...positions)) + 2 : 20;
return (
<div className="card p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-sm">Avg Position</h3>
<span className="text-muted-foreground text-xs">Lower is better</span>
</div>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={chartData}>
<defs>
<filter
height="140%"
id="gsc-pos-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis
{...yAxisProps}
domain={[minPos, maxPos]}
reversed
tickFormatter={(v: number) => `#${v}`}
/>
<Tooltip />
<Line
dataKey="position"
dot={false}
filter="url(#gsc-pos-glow)"
isAnimationActive={false}
stroke={getChartColor(2)}
strokeWidth={2}
type="monotone"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { useTRPC } from '@/integrations/trpc/react';
import { getChartColor } from '@/utils/theme';
interface ChartData {
date: string;
pageviews: number;
sessions: number;
}
const { TooltipProvider, Tooltip } = createChartTooltip<
ChartData,
{ formatDate: (date: Date | string) => string }
>(({ data, context }) => {
const item = data[0];
if (!item) {
return null;
}
return (
<>
<ChartTooltipHeader>
<div>{context.formatDate(item.date)}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Views</span>
<span>{item.pageviews.toLocaleString()}</span>
</div>
</ChartTooltipItem>
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Sessions</span>
<span>{item.sessions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
});
interface PageViewsChartProps {
projectId: string;
origin: string;
path: string;
}
export function PageViewsChart({
projectId,
origin,
path,
}: PageViewsChartProps) {
const { range, interval } = useOverviewOptions();
const trpc = useTRPC();
const yAxisProps = useYAxisProps();
const formatDateShort = useFormatDateInterval({ interval, short: true });
const formatDateLong = useFormatDateInterval({ interval, short: false });
const query = useQuery(
trpc.event.pageTimeseries.queryOptions({
projectId,
range,
interval,
origin,
path,
})
);
const data: ChartData[] = (query.data ?? []).map((r) => ({
date: r.date,
pageviews: r.pageviews,
sessions: r.sessions,
}));
return (
<div className="card p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-sm">Views & Sessions</h3>
<div className="flex items-center gap-4 text-muted-foreground text-xs">
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(0) }}
/>
Views
</span>
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(1) }}
/>
Sessions
</span>
</div>
</div>
{query.isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<TooltipProvider formatDate={formatDateLong}>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="page-views-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => formatDateShort(v)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<Tooltip />
<Line
dataKey="pageviews"
dot={false}
filter="url(#page-views-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="sessions"
dot={false}
filter="url(#page-views-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}

View File

@@ -0,0 +1,332 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import {
AlertTriangleIcon,
EyeIcon,
MousePointerClickIcon,
TrendingUpIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Pagination } from '@/components/pagination';
import { useAppContext } from '@/hooks/use-app-context';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
type InsightType =
| 'low_ctr'
| 'near_page_one'
| 'invisible_clicks'
| 'high_bounce';
interface PageInsight {
page: string;
origin: string;
path: string;
type: InsightType;
impact: number;
headline: string;
suggestion: string;
metrics: string;
}
const INSIGHT_CONFIG: Record<
InsightType,
{ label: string; icon: React.ElementType; color: string; bg: string }
> = {
low_ctr: {
label: 'Low CTR',
icon: MousePointerClickIcon,
color: 'text-amber-600 dark:text-amber-400',
bg: 'bg-amber-100 dark:bg-amber-900/30',
},
near_page_one: {
label: 'Near page 1',
icon: TrendingUpIcon,
color: 'text-blue-600 dark:text-blue-400',
bg: 'bg-blue-100 dark:bg-blue-900/30',
},
invisible_clicks: {
label: 'Low visibility',
icon: EyeIcon,
color: 'text-violet-600 dark:text-violet-400',
bg: 'bg-violet-100 dark:bg-violet-900/30',
},
high_bounce: {
label: 'High bounce',
icon: AlertTriangleIcon,
color: 'text-red-600 dark:text-red-400',
bg: 'bg-red-100 dark:bg-red-900/30',
},
};
interface PagesInsightsProps {
projectId: string;
}
export function PagesInsights({ projectId }: PagesInsightsProps) {
const trpc = useTRPC();
const { range, interval, startDate, endDate } = useOverviewOptions();
const { apiUrl } = useAppContext();
const [page, setPage] = useState(0);
const pageSize = 8;
const dateInput = {
range,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
};
const gscPagesQuery = useQuery(
trpc.gsc.getPages.queryOptions(
{ projectId, ...dateInput, limit: 1000 },
{ placeholderData: keepPreviousData }
)
);
const analyticsQuery = useQuery(
trpc.event.pages.queryOptions(
{ projectId, cursor: 1, take: 1000, search: undefined, range, interval },
{ placeholderData: keepPreviousData }
)
);
const insights = useMemo<PageInsight[]>(() => {
const gscPages = gscPagesQuery.data ?? [];
const analyticsPages = analyticsQuery.data ?? [];
const analyticsMap = new Map(
analyticsPages.map((p) => [p.origin + p.path, p])
);
const results: PageInsight[] = [];
for (const gsc of gscPages) {
let origin = '';
let path = gsc.page;
try {
const url = new URL(gsc.page);
origin = url.origin;
path = url.pathname + url.search;
} catch {
// keep as-is
}
const analytics = analyticsMap.get(gsc.page);
// 1. Low CTR: ranking on page 1 but click rate is poor
if (gsc.position <= 10 && gsc.ctr < 0.04 && gsc.impressions >= 100) {
results.push({
page: gsc.page,
origin,
path,
type: 'low_ctr',
impact: gsc.impressions * (0.04 - gsc.ctr),
headline: `Ranking #${Math.round(gsc.position)} but only ${(gsc.ctr * 100).toFixed(1)}% CTR`,
suggestion:
'You are on page 1 but people rarely click. Rewrite your title tag and meta description to be more compelling and match search intent.',
metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${(gsc.ctr * 100).toFixed(1)}% CTR`,
});
}
// 2. Near page 1: just off the first page with decent visibility
if (gsc.position > 10 && gsc.position <= 20 && gsc.impressions >= 100) {
results.push({
page: gsc.page,
origin,
path,
type: 'near_page_one',
impact: gsc.impressions / gsc.position,
headline: `Position ${Math.round(gsc.position)} — one push from page 1`,
suggestion:
'A content refresh, more internal links, or a few backlinks could move this into the top 10 and dramatically increase clicks.',
metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks`,
});
}
// 3. Invisible clicks: high impressions but barely any clicks
if (gsc.impressions >= 500 && gsc.ctr < 0.01 && gsc.position > 10) {
results.push({
page: gsc.page,
origin,
path,
type: 'invisible_clicks',
impact: gsc.impressions,
headline: `${gsc.impressions.toLocaleString()} impressions but only ${gsc.clicks} clicks`,
suggestion:
'Google shows this page a lot, but it almost never gets clicked. Consider whether the page targets the right queries or if a different format (e.g. listicle, how-to) would perform better.',
metrics: `${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks · Pos ${Math.round(gsc.position)}`,
});
}
// 4. High bounce: good traffic but poor engagement (requires analytics match)
if (
analytics &&
analytics.bounce_rate >= 70 &&
analytics.sessions >= 20
) {
results.push({
page: gsc.page,
origin,
path,
type: 'high_bounce',
impact: analytics.sessions * (analytics.bounce_rate / 100),
headline: `${Math.round(analytics.bounce_rate)}% bounce rate on a page with ${analytics.sessions} sessions`,
suggestion:
'Visitors are leaving without engaging. Check if the page delivers on its title/meta promise, improve page speed, and make sure key content is above the fold.',
metrics: `${Math.round(analytics.bounce_rate)}% bounce · ${analytics.sessions} sessions · ${gsc.impressions.toLocaleString()} impr`,
});
}
}
// Also check analytics pages without GSC match for high bounce
for (const p of analyticsPages) {
const fullUrl = p.origin + p.path;
if (
!gscPagesQuery.data?.some((g) => g.page === fullUrl) &&
p.bounce_rate >= 75 &&
p.sessions >= 30
) {
results.push({
page: fullUrl,
origin: p.origin,
path: p.path,
type: 'high_bounce',
impact: p.sessions * (p.bounce_rate / 100),
headline: `${Math.round(p.bounce_rate)}% bounce rate with ${p.sessions} sessions`,
suggestion:
'High bounce rate with no search visibility. Review content quality and check if the page is indexed and targeting the right keywords.',
metrics: `${Math.round(p.bounce_rate)}% bounce · ${p.sessions} sessions`,
});
}
}
// Dedupe by (page, type), keep highest impact
const seen = new Set<string>();
const deduped = results.filter((r) => {
const key = `${r.page}::${r.type}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
return deduped.sort((a, b) => b.impact - a.impact);
}, [gscPagesQuery.data, analyticsQuery.data]);
const isLoading = gscPagesQuery.isLoading || analyticsQuery.isLoading;
const pageCount = Math.ceil(insights.length / pageSize) || 1;
const paginatedInsights = useMemo(
() => insights.slice(page * pageSize, (page + 1) * pageSize),
[insights, page, pageSize]
);
const rangeStart = insights.length ? page * pageSize + 1 : 0;
const rangeEnd = Math.min((page + 1) * pageSize, insights.length);
if (!isLoading && !insights.length) {
return null;
}
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">Opportunities</h3>
{insights.length > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
{insights.length}
</span>
)}
</div>
{insights.length > 0 && (
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-muted-foreground text-xs">
{insights.length === 0
? '0 results'
: `${rangeStart}-${rangeEnd} of ${insights.length}`}
</span>
<Pagination
canNextPage={page < pageCount - 1}
canPreviousPage={page > 0}
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
pageIndex={page}
previousPage={() => setPage((p) => Math.max(0, p - 1))}
/>
</div>
)}
</div>
<div className="divide-y">
{isLoading &&
[1, 2, 3, 4].map((i) => (
<div className="flex items-start gap-3 p-4" key={i}>
<div className="mt-0.5 h-7 w-20 animate-pulse rounded-md bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-full animate-pulse rounded bg-muted" />
</div>
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
))}
{paginatedInsights.map((insight, i) => {
const config = INSIGHT_CONFIG[insight.type];
const Icon = config.icon;
return (
<button
className="flex w-full items-start gap-3 p-4 text-left transition-colors hover:bg-muted/40"
key={`${insight.page}-${insight.type}-${i}`}
onClick={() =>
pushModal('PageDetails', {
type: 'page',
projectId,
value: insight.page,
})
}
type="button"
>
<div className="col min-w-0 flex-1 gap-2">
<div className="flex items-center gap-2">
<img
alt=""
className="size-3.5 shrink-0 rounded-sm"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
src={`${apiUrl}/misc/favicon?url=${insight.origin}`}
/>
<span className="truncate font-medium font-mono text-xs">
{insight.path || insight.page}
</span>
<span
className={cn(
'row shrink-0 items-center gap-1 rounded-md px-1 py-0.5 font-medium text-xs',
config.color,
config.bg
)}
>
<Icon className="size-3" />
{config.label}
</span>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
<span className="font-medium text-foreground">
{insight.headline}.
</span>{' '}
{insight.suggestion}
</p>
</div>
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
{insight.metrics}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useQuery } from '@tanstack/react-query';
import { Tooltiper } from '../ui/tooltip';
import { LazyComponent } from '@/components/lazy-component';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useTRPC } from '@/integrations/trpc/react';
interface SparklineBarsProps {
data: { date: string; pageviews: number }[];
}
const defaultGap = 1;
const height = 24;
const width = 100;
function getTrendDirection(data: { pageviews: number }[]): '↑' | '↓' | '→' {
const n = data.length;
if (n < 3) {
return '→';
}
const third = Math.max(1, Math.floor(n / 3));
const firstAvg =
data.slice(0, third).reduce((s, d) => s + d.pageviews, 0) / third;
const lastAvg =
data.slice(n - third).reduce((s, d) => s + d.pageviews, 0) / third;
const threshold = firstAvg * 0.05;
if (lastAvg - firstAvg > threshold) {
return '↑';
}
if (firstAvg - lastAvg > threshold) {
return '↓';
}
return '→';
}
function SparklineBars({ data }: SparklineBarsProps) {
if (!data.length) {
return <div style={{ height, width }} />;
}
const max = Math.max(...data.map((d) => d.pageviews), 1);
const total = data.length;
// Compute bar width to fit SVG width; reduce gap if needed so barW >= 1 when possible
let gap = defaultGap;
let barW = Math.floor((width - gap * (total - 1)) / total);
if (barW < 1 && total > 1) {
gap = 0;
barW = Math.floor((width - gap * (total - 1)) / total);
}
if (barW < 1) {
barW = 1;
}
const trend = getTrendDirection(data);
const trendColor =
trend === '↑'
? 'text-emerald-500'
: trend === '↓'
? 'text-red-500'
: 'text-muted-foreground';
return (
<div className="flex items-center gap-1.5">
<svg className="shrink-0" height={height} width={width}>
{data.map((d, i) => {
const barH = Math.max(
2,
Math.round((d.pageviews / max) * (height * 0.8))
);
return (
<rect
className="fill-chart-0"
height={barH}
key={d.date}
rx="1"
width={barW}
x={i * (barW + gap)}
y={height - barH}
/>
);
})}
</svg>
<Tooltiper
content={
trend === '↑'
? 'Upward trend'
: trend === '↓'
? 'Downward trend'
: 'Stable trend'
}
>
<span className={`shrink-0 font-medium text-xs ${trendColor}`}>
{trend}
</span>
</Tooltiper>
</div>
);
}
interface PageSparklineProps {
projectId: string;
origin: string;
path: string;
}
export function PageSparkline({ projectId, origin, path }: PageSparklineProps) {
const { range, interval } = useOverviewOptions();
const trpc = useTRPC();
const query = useQuery(
trpc.event.pageTimeseries.queryOptions({
projectId,
range,
interval,
origin,
path,
})
);
return (
<LazyComponent fallback={<div style={{ height, width }} />}>
<SparklineBars data={query.data ?? []} />
</LazyComponent>
);
}

View File

@@ -0,0 +1,206 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ExternalLinkIcon } from 'lucide-react';
import { useMemo } from 'react';
import { PageSparkline } from '@/components/pages/page-sparkline';
import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers';
import { useAppContext } from '@/hooks/use-app-context';
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import type { RouterOutputs } from '@/trpc/client';
export type PageRow = RouterOutputs['event']['pages'][number] & {
gsc?: { clicks: number; impressions: number; ctr: number; position: number };
};
export function useColumns({
projectId,
isGscConnected,
previousMap,
}: {
projectId: string;
isGscConnected: boolean;
previousMap?: Map<string, number>;
}): ColumnDef<PageRow>[] {
const number = useNumber();
const { apiUrl } = useAppContext();
return useMemo<ColumnDef<PageRow>[]>(() => {
const cols: ColumnDef<PageRow>[] = [
{
id: 'page',
accessorFn: (row) => `${row.origin}${row.path} ${row.title ?? ''}`,
header: createHeaderColumn('Page'),
size: 400,
meta: { bold: true },
cell: ({ row }) => {
const page = row.original;
return (
<div className="flex min-w-0 items-center gap-3">
<img
alt=""
className="size-4 shrink-0 rounded-sm"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
src={`${apiUrl}/misc/favicon?url=${page.origin}`}
/>
<div className="min-w-0">
{page.title && (
<div className="truncate font-medium text-sm leading-tight">
{page.title}
</div>
)}
<div className="flex min-w-0 items-center gap-1">
<span className="truncate font-mono text-muted-foreground text-xs">
{page.path}
</span>
<a
className="shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
href={page.origin + page.path}
onClick={(e) => e.stopPropagation()}
rel="noreferrer noopener"
target="_blank"
>
<ExternalLinkIcon className="size-3 text-muted-foreground" />
</a>
</div>
</div>
</div>
);
},
},
{
id: 'trend',
header: 'Trend',
enableSorting: false,
size: 96,
cell: ({ row }) => (
<PageSparkline
origin={row.original.origin}
path={row.original.path}
projectId={projectId}
/>
),
},
{
accessorKey: 'pageviews',
header: createHeaderColumn('Views'),
size: 80,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.pageviews)}
</span>
),
},
{
accessorKey: 'sessions',
header: createHeaderColumn('Sessions'),
size: 90,
cell: ({ row }) => {
const prev = previousMap?.get(
row.original.origin + row.original.path
);
if (prev == null) {
return <span className="text-muted-foreground"></span>;
}
if (prev === 0) {
return (
<div className="flex items-center gap-2">
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.sessions)}
</span>
<span className="text-muted-foreground">new</span>
</div>
);
}
const pct = ((row.original.sessions - prev) / prev) * 100;
const isPos = pct >= 0;
return (
<div className="flex items-center gap-2">
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.sessions)}
</span>
<span
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
>
{isPos ? '+' : ''}
{pct.toFixed(1)}%
</span>
</div>
);
},
},
{
accessorKey: 'bounce_rate',
header: createHeaderColumn('Bounce'),
size: 80,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">
{row.original.bounce_rate.toFixed(0)}%
</span>
),
},
{
accessorKey: 'avg_duration',
header: createHeaderColumn('Duration'),
size: 90,
cell: ({ row }) => (
<span className="whitespace-nowrap font-mono text-sm tabular-nums">
{fancyMinutes(row.original.avg_duration)}
</span>
),
},
];
if (isGscConnected) {
cols.push(
{
id: 'gsc_impressions',
accessorFn: (row) => row.gsc?.impressions ?? 0,
header: createHeaderColumn('Impr.'),
size: 80,
cell: ({ row }) =>
row.original.gsc ? (
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.gsc.impressions)}
</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'gsc_ctr',
accessorFn: (row) => row.gsc?.ctr ?? 0,
header: createHeaderColumn('CTR'),
size: 70,
cell: ({ row }) =>
row.original.gsc ? (
<span className="font-mono text-sm tabular-nums">
{(row.original.gsc.ctr * 100).toFixed(1)}%
</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'gsc_clicks',
accessorFn: (row) => row.gsc?.clicks ?? 0,
header: createHeaderColumn('Clicks'),
size: 80,
cell: ({ row }) =>
row.original.gsc ? (
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.gsc.clicks)}
</span>
) : (
<span className="text-muted-foreground"></span>
),
}
);
}
return cols;
}, [isGscConnected, number, apiUrl, projectId, previousMap]);
}

View File

@@ -0,0 +1,143 @@
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { DataTable } from '@/components/ui/data-table/data-table';
import {
AnimatedSearchInput,
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useTable } from '@/components/ui/data-table/use-table';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { type PageRow, useColumns } from './columns';
interface PagesTableProps {
projectId: string;
}
export function PagesTable({ projectId }: PagesTableProps) {
const trpc = useTRPC();
const { range, interval, startDate, endDate } = useOverviewOptions();
const { debouncedSearch, setSearch, search } = useSearchQueryState();
const pagesQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
search: debouncedSearch ?? undefined,
range,
interval,
},
{ placeholderData: keepPreviousData },
),
);
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions({ projectId }),
);
const isGscConnected = !!(connectionQuery.data?.siteUrl);
const gscPagesQuery = useQuery(
trpc.gsc.getPages.queryOptions(
{
projectId,
range,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
limit: 10_000,
},
{ enabled: isGscConnected },
),
);
const previousPagesQuery = useQuery(
trpc.event.previousPages.queryOptions(
{ projectId, range, interval },
{ placeholderData: keepPreviousData },
),
);
const previousMap = useMemo(() => {
const map = new Map<string, number>();
for (const p of previousPagesQuery.data ?? []) {
map.set(p.origin + p.path, p.sessions);
}
return map;
}, [previousPagesQuery.data]);
const gscMap = useMemo(() => {
const map = new Map<
string,
{ clicks: number; impressions: number; ctr: number; position: number }
>();
for (const row of gscPagesQuery.data ?? []) {
map.set(row.page, {
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
});
}
return map;
}, [gscPagesQuery.data]);
const rawData: PageRow[] = useMemo(() => {
return (pagesQuery.data ?? []).map((p) => ({
...p,
gsc: gscMap.get(p.origin + p.path),
}));
}, [pagesQuery.data, gscMap]);
const columns = useColumns({ projectId, isGscConnected, previousMap });
const { table } = useTable({
columns,
data: rawData,
loading: pagesQuery.isLoading,
pageSize: 50,
name: 'pages',
});
return (
<>
<DataTableToolbarContainer>
<AnimatedSearchInput
placeholder="Search pages"
value={search ?? ''}
onChange={setSearch}
/>
<div className="flex items-center gap-2">
<OverviewRange />
<OverviewInterval />
<DataTableViewOptions table={table} />
</div>
</DataTableToolbarContainer>
<DataTable
table={table}
loading={pagesQuery.isLoading}
empty={{
title: 'No pages',
description: debouncedSearch
? `No pages found matching "${debouncedSearch}"`
: 'Integrate our web SDK to your site to get pages here.',
}}
onRowClick={(row) => {
if (!isGscConnected) {
return;
}
const page = row.original;
pushModal('PageDetails', {
type: 'page',
projectId,
value: page.origin + page.path,
});
}}
/>
</>
);
}

View File

@@ -1,6 +1,5 @@
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import type { IProfileMetrics } from '@openpanel/db';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
type Props = {
data: IProfileMetrics;
@@ -102,7 +101,7 @@ const PROFILE_METRICS = [
export const ProfileMetrics = ({ data }: Props) => {
return (
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0">
<div className="relative col-span-6 -m-4 mt-0 mb-0 md:m-0">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
{PROFILE_METRICS.filter((metric) => {
if (metric.hideOnZero && data[metric.key] === 0) {
@@ -111,20 +110,20 @@ export const ProfileMetrics = ({ data }: Props) => {
return true;
}).map((metric) => (
<OverviewMetricCard
key={metric.key}
data={[]}
id={metric.key}
inverted={metric.inverted}
isLoading={false}
key={metric.key}
label={metric.title}
metric={{
current:
metric.unit === 'timeAgo'
? new Date(data[metric.key]).getTime()
metric.unit === 'timeAgo' && data[metric.key]
? new Date(data[metric.key]!).getTime()
: (data[metric.key] as number) || 0,
previous: null,
}}
unit={metric.unit}
data={[]}
inverted={metric.inverted}
isLoading={false}
/>
))}
</div>

View File

@@ -18,19 +18,22 @@ import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo } from 'react';
const PAGE_SIZE = 50;
type Props = {
query: UseQueryResult<RouterOutputs['profile']['list'], unknown>;
type: 'profiles' | 'power-users';
pageSize?: number;
};
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceProfile[];
export const ProfilesTable = memo(
({ type, query }: Props) => {
({ type, query, pageSize = PAGE_SIZE }: Props) => {
const { data, isLoading } = query;
const columns = useColumns(type);
const { setPage, state: pagination } = useDataTablePagination();
const { setPage, state: pagination } = useDataTablePagination(pageSize);
const {
columnVisibility,
setColumnVisibility,
@@ -83,7 +86,7 @@ export const ProfilesTable = memo(
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'type']),
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']),
);
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {

View File

@@ -1,3 +1,12 @@
import type { IServiceOrganization } from '@openpanel/db';
import { Link, useRouter } from '@tanstack/react-router';
import {
Building2Icon,
CheckIcon,
ChevronsUpDownIcon,
PlusIcon,
} from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -10,18 +19,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/use-app-params';
import { useRouter } from '@tanstack/react-router';
import { Link } from '@tanstack/react-router';
import {
Building2Icon,
CheckIcon,
ChevronsUpDownIcon,
PlusIcon,
} from 'lucide-react';
import { useState } from 'react';
import { pushModal } from '@/modals';
import type { IServiceOrganization } from '@openpanel/db';
interface ProjectSelectorProps {
projects: Array<{ id: string; name: string; organizationId: string }>;
@@ -69,16 +67,16 @@ export default function ProjectSelector({
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button
size={'sm'}
variant="outline"
role="combobox"
aria-expanded={open}
className="flex min-w-0 flex-1 items-center justify-start"
role="combobox"
size={'sm'}
variant="outline"
>
<Building2Icon size={16} className="shrink-0" />
<Building2Icon className="shrink-0" size={16} />
<span className="mx-2 truncate">
{projectId
? projects.find((p) => p.id === projectId)?.name
@@ -108,10 +106,10 @@ export default function ProjectSelector({
{projects.length > 10 && (
<DropdownMenuItem asChild>
<Link
to={'/$organizationId'}
params={{
organizationId,
}}
to={'/$organizationId'}
>
All projects
</Link>
@@ -148,11 +146,13 @@ export default function ProjectSelector({
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
New organization
<DropdownMenuShortcut>
<PlusIcon size={16} />
</DropdownMenuShortcut>
<DropdownMenuItem asChild>
<Link to={'/onboarding/project'}>
New organization
<DropdownMenuShortcut>
<PlusIcon size={16} />
</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</>

View File

@@ -97,7 +97,7 @@ interface PreviousDiffIndicatorPureProps {
diff?: number | null | undefined;
state?: string | null | undefined;
inverted?: boolean;
size?: 'sm' | 'lg' | 'md';
size?: 'xs' | 'sm' | 'lg' | 'md';
className?: string;
showPrevious?: boolean;
}

View File

@@ -144,6 +144,7 @@ const data = {
"dropbox": "https://www.dropbox.com",
"openai": "https://openai.com",
"chatgpt.com": "https://chatgpt.com",
"copilot.com": "https://www.copilot.com",
"mailchimp": "https://mailchimp.com",
"activecampaign": "https://www.activecampaign.com",
"customer.io": "https://customer.io",

View File

@@ -3,8 +3,10 @@ import { useNumber } from '@/hooks/use-numer-formatter';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { Tables } from './chart';
interface BreakdownListProps {
@@ -48,10 +50,9 @@ export function BreakdownList({
});
};
// Get the color index for a breakdown based on its position in the
// visible series list (so colors match the chart bars)
const getVisibleIndex = (id: string) => {
return visibleSeriesIds.indexOf(id);
// Get the stable color index for a breakdown (position in full list, matches chart)
const getStableColorIndex = (id: string) => {
return allBreakdowns.findIndex((b) => b.id === id);
};
if (allBreakdowns.length === 0) {
@@ -81,14 +82,12 @@ export function BreakdownList({
{allBreakdowns.map((item, index) => {
const isExpanded = expandedIds.has(item.id);
const isVisible = visibleSeriesIds.includes(item.id);
const visibleIndex = getVisibleIndex(item.id);
const stableColorIndex = getStableColorIndex(item.id);
const previousItem = previousData[index] ?? null;
const hasBreakdownName =
item.breakdowns && item.breakdowns.length > 0;
const color =
isVisible && visibleIndex !== -1
? getChartColor(visibleIndex)
: undefined;
stableColorIndex >= 0 ? getChartColor(stableColorIndex) : undefined;
return (
<div key={item.id} className="col">
@@ -107,7 +106,7 @@ export function BreakdownList({
className="shrink-0"
style={{
borderColor: color,
backgroundColor: isVisible ? color : 'transparent',
backgroundColor: isVisible && color ? color : 'transparent',
}}
/>
)}
@@ -141,6 +140,14 @@ export function BreakdownList({
'%',
)}
</div>
{previousItem && (
<PreviousDiffIndicatorPure
{...getPreviousMetric(
item.lastStep.percent,
previousItem.lastStep.percent,
)}
/>
)}
</div>
<div className="text-right row gap-2 items-center">
<div className="text-muted-foreground text-sm">
@@ -149,6 +156,14 @@ export function BreakdownList({
<div className="font-mono font-semibold text-sm">
{number.format(item.lastStep.count)}
</div>
{previousItem && (
<PreviousDiffIndicatorPure
{...getPreviousMetric(
item.lastStep.count,
previousItem.lastStep.count,
)}
/>
)}
</div>
</div>
</div>
@@ -160,6 +175,7 @@ export function BreakdownList({
current: item,
previous: previousItem,
}}
noTopBorderRadius
/>
)}
</div>

View File

@@ -1,20 +1,6 @@
import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
import { alphabetIds } from '@openpanel/constants';
import { createChartTooltip } from '@/components/charts/chart-tooltip';
import { BarShapeBlue, BarShapeProps } from '@/components/charts/common-bar';
import { Tooltiper } from '@/components/ui/tooltip';
import { WidgetTable } from '@/components/widget-table';
import { useNumber } from '@/hooks/use-numer-formatter';
import type { IVisibleFunnelBreakdowns } from '@/hooks/use-visible-funnel-breakdowns';
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
import { useCallback } from 'react';
import {
Bar,
@@ -31,12 +17,24 @@ import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
import { useReportChartContext } from '../context';
import { createChartTooltip } from '@/components/charts/chart-tooltip';
import { BarShapeProps } from '@/components/charts/common-bar';
import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { Tooltiper } from '@/components/ui/tooltip';
import { WidgetTable } from '@/components/widget-table';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
type Props = {
data: {
current: RouterOutputs['chart']['funnel']['current'][number];
previous: RouterOutputs['chart']['funnel']['current'][number] | null;
};
noTopBorderRadius?: boolean;
};
export const Metric = ({
@@ -50,20 +48,16 @@ export const Metric = ({
enhancer?: React.ReactNode;
className?: string;
}) => (
<div className={cn('gap-1 justify-between flex-1 col', className)}>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="row items-center gap-2 justify-between">
<div className={cn('col flex-1 justify-between gap-1', className)}>
<div className="text-muted-foreground text-sm">{label}</div>
<div className="row items-center justify-between gap-2">
<div className="font-mono font-semibold">{value}</div>
{enhancer && <div>{enhancer}</div>}
</div>
</div>
);
export function Summary({
data,
}: {
data: RouterOutputs['chart']['funnel'];
}) {
export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) {
const number = useNumber();
const highestConversion = data.current
.slice(0)
@@ -81,10 +75,10 @@ export function Summary({
<ChartName breakdowns={highestConversion.breakdowns ?? []} />
}
/>
<span className="text-xl font-semibold font-mono">
<span className="font-mono font-semibold text-xl">
{number.formatWithUnit(
highestConversion.lastStep.percent / 100,
'%',
'%'
)}
</span>
</div>
@@ -95,7 +89,7 @@ export function Summary({
label="Most conversions"
value={<ChartName breakdowns={highestCount.breakdowns ?? []} />}
/>
<span className="text-xl font-semibold font-mono">
<span className="font-mono font-semibold text-xl">
{number.format(highestCount.lastStep.count)}
</span>
</div>
@@ -107,7 +101,10 @@ export function Summary({
function ChartName({
breakdowns,
className,
}: { breakdowns: string[]; className?: string }) {
}: {
breakdowns: string[];
className?: string;
}) {
return (
<div className={cn('flex items-center gap-2 font-medium', className)}>
{breakdowns.map((name, index) => {
@@ -127,6 +124,7 @@ export function Tables({
current: { steps, mostDropoffsStep, lastStep, breakdowns },
previous: previousData,
},
noTopBorderRadius,
}: Props) {
const number = useNumber();
const hasHeader = breakdowns.length > 0;
@@ -145,11 +143,11 @@ export function Tables({
} = useReportChartContext();
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const funnelWindow = funnelOptions?.funnelWindow;
const funnelGroup = funnelOptions?.funnelGroup;
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
if (!projectId || !step.event.id) return;
if (!(projectId && step.event.id)) {
return;
}
// For funnels, we need to pass the step index so the modal can query
// users who completed at least that step in the funnel sequence
@@ -172,48 +170,56 @@ export function Tables({
});
};
return (
<div className={cn('col @container divide-y divide-border card')}>
<div
className={cn(
'col @container card divide-y divide-border',
noTopBorderRadius && 'rounded-t-none'
)}
>
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
<div className={cn('bg-def-100', !hasHeader && 'rounded-t-md')}>
<div className="col max-md:divide-y md:row md:items-center md:divide-x divide-border">
<div
className={cn(
'bg-def-100',
!hasHeader && 'rounded-t-md',
noTopBorderRadius && 'rounded-t-none'
)}
>
<div className="col md:row divide-border max-md:divide-y md:items-center md:divide-x">
<Metric
className="p-4 py-3"
label="Conversion"
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
enhancer={
previousData && (
<PreviousDiffIndicatorPure
{...getPreviousMetric(
lastStep?.percent,
previousData.lastStep?.percent,
previousData.lastStep?.percent
)}
/>
)
}
label="Conversion"
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
/>
<Metric
className="p-4 py-3"
label="Completed"
value={number.format(lastStep?.count)}
enhancer={
previousData && (
<PreviousDiffIndicatorPure
{...getPreviousMetric(
lastStep?.count,
previousData.lastStep?.count,
previousData.lastStep?.count
)}
/>
)
}
label="Completed"
value={number.format(lastStep?.count)}
/>
{!!mostDropoffsStep && (
<Metric
className="p-4 py-3"
label="Most dropoffs after"
value={mostDropoffsStep?.event?.displayName}
enhancer={
<Tooltiper
tooltipClassName="max-w-xs"
content={
<span>
<span className="font-semibold">
@@ -223,44 +229,26 @@ export function Tables({
conversion rate will likely increase.
</span>
}
tooltipClassName="max-w-xs"
>
<InfoIcon className="size-3" />
</Tooltiper>
}
label="Most dropoffs after"
value={mostDropoffsStep?.event?.displayName}
/>
)}
</div>
</div>
<div className="col divide-y divide-def-200">
<WidgetTable
data={steps}
keyExtractor={(item) => item.event.id!}
className={'text-sm @container'}
className={'@container text-sm'}
columnClassName="px-2 group/row items-center"
eachRow={(item, index) => {
return (
<div className="absolute inset-px !p-0">
<div
className={cn(
'h-full bg-def-300 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative',
item.isHighestDropoff && [
'bg-red-500/20',
'group-hover/row:bg-red-500/70',
],
index === steps.length - 1 && 'rounded-bl-sm',
)}
style={{
width: `${item.percent}%`,
}}
/>
</div>
);
}}
columns={[
{
name: 'Event',
render: (item, index) => (
<div className="row items-center gap-2 min-w-0 relative">
<div className="row relative min-w-0 items-center gap-2">
<ColorSquare color={getChartColor(index)}>
{alphabetIds[index]}
</ColorSquare>
@@ -295,17 +283,17 @@ export function Tables({
name: '',
render: (item) => (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
const stepIndex = steps.findIndex(
(s) => s.event.id === item.event.id,
(s) => s.event.id === item.event.id
);
handleInspectStep(item, stepIndex);
}}
size="sm"
title="View users who completed this step"
variant="ghost"
>
<UsersIcon size={16} />
</Button>
@@ -314,6 +302,27 @@ export function Tables({
width: '48px',
},
]}
data={steps}
eachRow={(item, index) => {
return (
<div className="!p-0 absolute inset-px">
<div
className={cn(
'relative h-full bg-def-300 transition-colors group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900',
item.isHighestDropoff && [
'bg-red-500/20',
'group-hover/row:bg-red-500/70',
],
index === steps.length - 1 && 'rounded-bl-sm'
)}
style={{
width: `${item.percent}%`,
}}
/>
</div>
);
}}
keyExtractor={(item) => item.event.id!}
/>
</div>
</div>
@@ -363,9 +372,11 @@ const useRechartData = ({
...visibleBreakdowns.reduce((acc, visibleItem, visibleIdx) => {
// Find the original index for this visible breakdown
const originalIndex = current.findIndex(
(item) => item.id === visibleItem.id,
(item) => item.id === visibleItem.id
);
if (originalIndex === -1) return acc;
if (originalIndex === -1) {
return acc;
}
const diff = previous?.[originalIndex];
return {
@@ -391,6 +402,47 @@ const useRechartData = ({
);
};
const StripedBarShape = (props: any) => {
const { x, y, width, height, fill, stroke, value } = props;
const patternId = `prev-stripes-${(fill || '').replace(/[^a-z0-9]/gi, '')}`;
return (
<g>
<defs>
<pattern
height="6"
id={patternId}
patternTransform="rotate(-45)"
patternUnits="userSpaceOnUse"
width="6"
>
<rect fill="transparent" height="6" width="6" />
<rect fill={fill} height="6" width="3" />
</pattern>
</defs>
<rect
fill={`url(#${patternId})`}
height={height}
rx={3}
width={width}
x={x}
y={y}
/>
{value > 0 && (
<rect
fill={stroke}
height={2}
opacity={0.6}
rx={2}
stroke="none"
width={width}
x={x}
y={y - 3}
/>
)}
</g>
);
};
export function Chart({
data,
visibleBreakdowns,
@@ -403,96 +455,162 @@ export function Chart({
const yAxisProps = useYAxisProps();
const hasBreakdowns = data.current.length > 1;
const hasVisibleBreakdowns = visibleBreakdowns.length > 1;
const hasPrevious =
data.previous !== null &&
data.previous !== undefined &&
data.previous.length > 0;
const showPreviousBars = hasPrevious && !hasBreakdowns;
const CustomLegend = useCallback(() => {
if (!hasVisibleBreakdowns) return null;
if (!hasVisibleBreakdowns) {
return null;
}
return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
{visibleBreakdowns.map((breakdown, idx) => (
<div
className="flex items-center gap-1"
key={breakdown.id}
style={{
color: getChartColor(idx),
}}
>
<SerieIcon name={breakdown.breakdowns ?? []} />
<SerieName
name={
breakdown.breakdowns && breakdown.breakdowns.length > 0
? breakdown.breakdowns
: ['Funnel']
}
className="font-semibold"
/>
</div>
))}
<div className="mt-4 -mb-2 flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs">
{visibleBreakdowns.map((breakdown, idx) => {
const stableIndex = data.current.findIndex((b) => b.id === breakdown.id);
const colorIndex = stableIndex >= 0 ? stableIndex : idx;
return (
<div
className="flex items-center gap-1.5 rounded px-2 py-1"
key={breakdown.id}
style={{
color: getChartColor(colorIndex),
}}
>
<SerieIcon name={breakdown.breakdowns ?? []} />
<SerieName
className="font-semibold"
name={
breakdown.breakdowns && breakdown.breakdowns.length > 0
? breakdown.breakdowns
: ['Funnel']
}
/>
</div>
);
})}
</div>
);
}, [visibleBreakdowns, hasVisibleBreakdowns]);
const PreviousLegend = useCallback(() => {
if (!showPreviousBars) {
return null;
}
return (
<div className="mt-4 -mb-2 flex flex-wrap justify-center gap-x-4 gap-y-1.5 text-xs">
<div className="flex items-center gap-1.5 rounded px-2 py-1">
<div
className="h-3 w-3 rounded-[2px]"
style={{
background: 'rgba(59, 121, 255, 0.3)',
borderTop: '2px solid rgba(59, 121, 255, 1)',
}}
/>
<span className="font-medium text-muted-foreground">Current</span>
</div>
<div className="flex items-center gap-1.5 rounded px-2 py-1">
<svg height="12" viewBox="0 0 12 12" width="12">
<defs>
<pattern
height="4"
id="legend-stripes"
patternTransform="rotate(-45)"
patternUnits="userSpaceOnUse"
width="4"
>
<rect fill="transparent" height="4" width="4" />
<rect fill="rgba(59, 121, 255, 0.3)" height="4" width="2" />
</pattern>
</defs>
<rect fill="url(#legend-stripes)" height="12" rx="2" width="12" />
</svg>
<span className="font-medium text-muted-foreground">Previous</span>
</div>
</div>
);
}, [showPreviousBars]);
return (
<TooltipProvider
data={data.current}
hasBreakdowns={hasBreakdowns}
hasPrevious={hasPrevious}
visibleBreakdownIds={new Set(visibleBreakdowns.map((b) => b.id))}
>
<div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
<div className="card aspect-video max-h-[250px] w-full p-4 pb-1">
<ResponsiveContainer>
<BarChart data={rechartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={true}
className="stroke-border"
horizontal={true}
strokeDasharray="3 3"
vertical={true}
/>
<XAxis
{...xAxisProps}
dataKey="id"
allowDuplicatedCategory={false}
type={'category'}
scale="auto"
dataKey="id"
domain={undefined}
interval="preserveStartEnd"
tickSize={0}
tickMargin={4}
scale="auto"
tickFormatter={(id) =>
data.current[0].steps.find((step) => step.event.id === id)
?.event.displayName ?? ''
}
tickMargin={4}
tickSize={0}
type={'category'}
/>
<YAxis {...yAxisProps} />
{hasBreakdowns ? (
visibleBreakdowns.map((item, breakdownIndex) => (
<Bar
key={`step:percent:${item.id}`}
dataKey={`step:percent:${breakdownIndex}`}
shape={<BarShapeProps />}
>
{rechartData.map((item, stepIndex) => (
<Cell
key={`${item.name}-${breakdownIndex}`}
fill={getChartTranslucentColor(breakdownIndex)}
stroke={getChartColor(breakdownIndex)}
/>
))}
</Bar>
))
) : (
<Bar
data={rechartData}
dataKey="step:percent:0"
shape={<BarShapeProps />}
>
{hasBreakdowns &&
visibleBreakdowns.map((item, breakdownIndex) => {
const stableIndex = data.current.findIndex(
(b) => b.id === item.id,
);
const colorIndex =
stableIndex >= 0 ? stableIndex : breakdownIndex;
return (
<Bar
dataKey={`step:percent:${breakdownIndex}`}
key={`step:percent:${item.id}`}
shape={<BarShapeProps />}
>
{rechartData.map((row, stepIndex) => (
<Cell
fill={getChartTranslucentColor(colorIndex)}
key={`${row.name}-${breakdownIndex}`}
stroke={getChartColor(colorIndex)}
/>
))}
</Bar>
);
})}
{!hasBreakdowns && (
<Bar dataKey="step:percent:0" shape={<BarShapeProps />}>
{rechartData.map((item, index) => (
<Cell
key={item.name}
fill={getChartTranslucentColor(index)}
key={item.name}
stroke={getChartColor(index)}
/>
))}
</Bar>
)}
{showPreviousBars && (
<Bar dataKey="prev_step:percent:0" shape={<StripedBarShape />}>
{rechartData.map((item, index) => (
<Cell
fill={getChartTranslucentColor(index)}
key={`prev-${item.name}`}
stroke={getChartColor(index)}
/>
))}
</Bar>
)}
{hasVisibleBreakdowns && <Legend content={<CustomLegend />} />}
{showPreviousBars && <Legend content={<PreviousLegend />} />}
<Tooltip />
</BarChart>
</ResponsiveContainer>
@@ -506,27 +624,85 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
{
data: RouterOutputs['chart']['funnel']['current'];
visibleBreakdownIds: Set<string>;
hasPrevious: boolean;
hasBreakdowns: boolean;
}
>(({ data: dataArray, context, ...props }) => {
const data = dataArray[0]!;
const data = dataArray[0];
const number = useNumber();
if (!data) {
return null;
}
const variants = Object.keys(data).filter((key) =>
key.startsWith('step:data:'),
key.startsWith('step:data:')
) as `step:data:${number}`[];
const index = context.data[0].steps.findIndex(
(step) => step.event.id === (data as any).id,
(step) => step.event.id === (data as any).id
);
// Filter variants to only show visible breakdowns
// The variant object contains the full breakdown item, so we can check its ID directly
const visibleVariants = variants.filter((key) => {
const variant = data[key];
if (!variant) return false;
if (!variant) {
return false;
}
// The variant is the breakdown item itself (with step added), so it has an id property
return context.visibleBreakdownIds.has(variant.id);
});
if (!context.hasBreakdowns && context.hasPrevious) {
const currentVariant = data['step:data:0'];
const previousVariant = data['prev_step:data:0'];
if (!currentVariant?.step) {
return null;
}
const metric = getPreviousMetric(
currentVariant.step.percent,
previousVariant?.step.percent
);
return (
<>
<div className="text-muted-foreground">{data.name}</div>
<div className="col gap-1.5">
<div className="flex justify-between gap-8 font-medium font-mono">
<span className="text-muted-foreground">Current</span>
<span>
{number.format(currentVariant.step.count)} (
{number.formatWithUnit(currentVariant.step.percent / 100, '%')})
</span>
</div>
{previousVariant?.step && (
<div className="flex justify-between gap-8 font-medium font-mono text-muted-foreground">
<span>Previous</span>
<span>
{number.format(previousVariant.step.count)} (
{number.formatWithUnit(previousVariant.step.percent / 100, '%')}
)
</span>
</div>
)}
{metric && metric.diff != null && (
<div className="mt-0.5 flex items-center justify-between gap-8 border-border border-t pt-1.5">
<span className="font-medium text-sm">
{metric.state === 'positive'
? 'Improvement'
: metric.state === 'negative'
? 'Decline'
: 'No change'}
</span>
<PreviousDiffIndicatorPure {...metric} size="xs" />
</div>
)}
</div>
</>
);
}
return (
<>
<div className="flex justify-between gap-8 text-muted-foreground">
@@ -538,30 +714,33 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
if (!variant?.step) {
return null;
}
// Find the original breakdown index for color
// Find the original breakdown index for color (matches chart bar order)
const originalBreakdownIndex = context.data.findIndex(
(b) => b.id === variant.id,
(b) => b.id === variant.id
);
let colorIndex = index;
if (visibleVariants.length > 1) {
colorIndex =
originalBreakdownIndex >= 0 ? originalBreakdownIndex : visibleIndex;
}
return (
<div className="row gap-2" key={key}>
<div
className="w-[3px] rounded-full"
className="w-[3px] rounded-full shrink-0"
style={{
background: getChartColor(
visibleVariants.length > 1 ? visibleIndex : index,
),
background: getChartColor(colorIndex),
}}
/>
<div className="col flex-1 gap-1">
<div className="col flex-1 gap-1 min-w-0">
<div className="flex items-center gap-1">
<ChartName breakdowns={variant.breakdowns ?? []} />
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="col gap-1">
<div className="flex items-center justify-between gap-4 font-mono font-medium">
<div className="col gap-0.5">
<span>
{number.formatWithUnit(variant.step.percent / 100, '%')}
</span>
<span className="text-muted-foreground">
<span className="text-muted-foreground text-xs">
({number.format(variant.step.count)})
</span>
</div>
@@ -569,8 +748,9 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
<PreviousDiffIndicatorPure
{...getPreviousMetric(
variant.step.percent,
prevVariant?.step.percent,
prevVariant?.step.percent
)}
size="xs"
/>
</div>
</div>

View File

@@ -1,4 +1,7 @@
import { ReportChart } from '@/components/report-chart';
import type { IServiceReport } from '@openpanel/db';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react';
import EditReportName from '../report/edit-report-name';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
@@ -14,18 +17,13 @@ import {
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { ReportChart } from '@/components/report-chart';
import { TimeWindowPicker } from '@/components/time-window-picker';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { bind } from 'bind-event-listener';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db';
import EditReportName from '../report/edit-report-name';
interface ReportEditorProps {
report: IServiceReport | null;
@@ -54,15 +52,15 @@ export default function ReportEditor({
return (
<Sheet>
<div>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center justify-between p-4">
<EditReportName />
{initialReport?.id && (
<Button
variant="outline"
icon={ShareIcon}
onClick={() =>
pushModal('ShareReportModal', { reportId: initialReport.id })
}
variant="outline"
>
Share
</Button>
@@ -71,9 +69,9 @@ export default function ReportEditor({
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
<SheetTrigger asChild>
<Button
className="self-start"
icon={GanttChartSquareIcon}
variant="cta"
className="self-start"
>
Pick events
</Button>
@@ -88,23 +86,26 @@ export default function ReportEditor({
/>
<TimeWindowPicker
className="min-w-0 flex-1"
endDate={report.endDate}
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
value={report.range}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
onEndDateChange={(date) => dispatch(changeEndDate(date))}
endDate={report.endDate}
onIntervalChange={(interval) =>
dispatch(changeInterval(interval))
}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
startDate={report.startDate}
value={report.range}
/>
<ReportInterval
chartType={report.chartType}
className="min-w-0 flex-1"
endDate={report.endDate}
interval={report.interval}
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
range={report.range}
chartType={report.chartType}
startDate={report.startDate}
endDate={report.endDate}
/>
<ReportLineType className="min-w-0 flex-1" />
</div>
@@ -114,7 +115,7 @@ export default function ReportEditor({
</div>
<div className="flex flex-col gap-4 p-4" id="report-editor">
{report.ready && (
<ReportChart report={{ ...report, projectId }} isEditMode />
<ReportChart isEditMode report={{ ...report, projectId }} />
)}
</div>
</div>

View File

@@ -14,9 +14,11 @@ import {
LayoutDashboardIcon,
LayoutPanelTopIcon,
PlusIcon,
SearchIcon,
SparklesIcon,
TrendingUpDownIcon,
UndoDotIcon,
UserCircleIcon,
UsersIcon,
WallpaperIcon,
} from 'lucide-react';
@@ -55,10 +57,11 @@ export default function SidebarProjectMenu({
label="Insights"
/>
<SidebarLink href={'/pages'} icon={LayersIcon} label="Pages" />
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
<SidebarLink href={'/realtime'} icon={Globe2Icon} label="Realtime" />
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
<SidebarLink href={'/profiles'} icon={UsersIcon} label="Profiles" />
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
Manage
</div>

View File

@@ -1,3 +1,9 @@
import { timeWindows } from '@openpanel/constants';
import type { IChartRange, IInterval } from '@openpanel/validation';
import { bind } from 'bind-event-listener';
import { endOfDay, format, startOfDay } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -11,24 +17,18 @@ import {
} from '@/components/ui/dropdown-menu';
import { pushModal, useOnPushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { bind } from 'bind-event-listener';
import { CalendarIcon } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import { shouldIgnoreKeypress } from '@/utils/should-ignore-keypress';
import { timeWindows } from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
import { endOfDay, format, startOfDay } from 'date-fns';
type Props = {
interface Props {
value: IChartRange;
onChange: (value: IChartRange) => void;
onStartDateChange: (date: string) => void;
onEndDateChange: (date: string) => void;
onIntervalChange: (interval: IInterval) => void;
endDate: string | null;
startDate: string | null;
className?: string;
};
}
export function TimeWindowPicker({
value,
onChange,
@@ -36,6 +36,7 @@ export function TimeWindowPicker({
onStartDateChange,
endDate,
onEndDateChange,
onIntervalChange,
className,
}: Props) {
const isDateRangerPickerOpen = useRef(false);
@@ -46,10 +47,11 @@ export function TimeWindowPicker({
const handleCustom = useCallback(() => {
pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => {
onChange: ({ startDate, endDate, interval }) => {
onStartDateChange(format(startOfDay(startDate), 'yyyy-MM-dd HH:mm:ss'));
onEndDateChange(format(endOfDay(endDate), 'yyyy-MM-dd HH:mm:ss'));
onChange('custom');
onIntervalChange(interval);
},
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
@@ -69,7 +71,7 @@ export function TimeWindowPicker({
}
const match = Object.values(timeWindows).find(
(tw) => event.key === tw.shortcut.toLowerCase(),
(tw) => event.key === tw.shortcut.toLowerCase()
);
if (match?.key === 'custom') {
handleCustom();
@@ -84,9 +86,9 @@ export function TimeWindowPicker({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={CalendarIcon}
className={cn('justify-start', className)}
icon={CalendarIcon}
variant="outline"
>
{timeWindow?.label}
</Button>

View File

@@ -9,7 +9,6 @@ import {
DayPicker,
getDefaultClassNames,
} from 'react-day-picker';
import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -29,99 +28,93 @@ function Calendar({
return (
<DayPicker
showOutsideDays={showOutsideDays}
captionLayout={captionLayout}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
'group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col sm:flex-row relative',
defaultClassNames.months,
'relative flex flex-col gap-4 sm:flex-row',
defaultClassNames.months
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav,
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous,
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next,
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_next
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption,
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns,
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm',
defaultClassNames.dropdowns
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root,
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
defaultClassNames.dropdown_root
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
defaultClassNames.dropdown,
'absolute inset-0 bg-popover opacity-0',
defaultClassNames.dropdown
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday,
'flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground',
defaultClassNames.weekday
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
'w-(--cell-size) select-none',
defaultClassNames.week_number_header
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number,
'select-none text-[0.8rem] text-muted-foreground',
defaultClassNames.week_number
),
day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
defaultClassNames.day,
'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
defaultClassNames.day
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start,
defaultClassNames.range_start
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
defaultClassNames.today
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
defaultClassNames.outside
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
defaultClassNames.disabled
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
@@ -130,9 +123,9 @@ function Calendar({
Root: ({ className, rootRef, ...props }) => {
return (
<div
className={cn(className)}
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
@@ -169,6 +162,12 @@ function Calendar({
},
...components,
}}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
showOutsideDays={showOutsideDays}
{...props}
/>
);
@@ -184,29 +183,31 @@ function CalendarDayButton({
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
if (modifiers.focused) {
ref.current?.focus();
}
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
className={cn(
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md data-[range-end=true]:bg-primary data-[range-middle=true]:bg-accent data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-accent-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className
)}
data-day={day.date.toLocaleDateString()}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
data-range-start={modifiers.range_start}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
ref={ref}
size="icon"
variant="ghost"
{...props}
/>
);

View File

@@ -22,6 +22,7 @@ export interface DataTableProps<TData> {
title: string;
description: string;
};
onRowClick?: (row: import('@tanstack/react-table').Row<TData>) => void;
}
declare module '@tanstack/react-table' {
@@ -35,6 +36,7 @@ export function DataTable<TData>({
table,
loading,
className,
onRowClick,
empty = {
title: 'No data',
description: 'We could not find any data here yet',
@@ -78,6 +80,8 @@ export function DataTable<TData>({
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer' : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell

View File

@@ -1,3 +1,5 @@
import { getISOWeek } from 'date-fns';
import type { IInterval } from '@openpanel/validation';
export function formatDateInterval(options: {
@@ -8,15 +10,19 @@ export function formatDateInterval(options: {
const { interval, date, short } = options;
try {
if (interval === 'hour' || interval === 'minute') {
if (short) {
return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
return new Intl.DateTimeFormat('en-GB', {
...(!short
? {
month: '2-digit',
day: '2-digit',
}
: {}),
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
@@ -25,6 +31,9 @@ export function formatDateInterval(options: {
}
if (interval === 'week') {
if (short) {
return `W${getISOWeek(date)}`;
}
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
@@ -33,6 +42,12 @@ export function formatDateInterval(options: {
}
if (interval === 'day') {
if (short) {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
}).format(date);
}
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
@@ -41,7 +56,7 @@ export function formatDateInterval(options: {
}
return date.toISOString();
} catch (e) {
} catch {
return '';
}
}

View File

@@ -1,20 +1,24 @@
import { getDefaultIntervalByDates } from '@openpanel/constants';
import type { IInterval } from '@openpanel/validation';
import { endOfDay, subMonths } from 'date-fns';
import { CheckIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
import { popModal } from '.';
import { ModalContent } from './Modal/Container';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { subMonths } from 'date-fns';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { formatDate } from '@/utils/date';
import { CheckIcon, XIcon } from 'lucide-react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type Props = {
onChange: (payload: { startDate: Date; endDate: Date }) => void;
interface Props {
onChange: (payload: {
startDate: Date;
endDate: Date;
interval: IInterval;
}) => void;
startDate?: Date;
endDate?: Date;
};
}
export default function DateRangerPicker({
onChange,
startDate: initialStartDate,
@@ -25,20 +29,20 @@ export default function DateRangerPicker({
const [endDate, setEndDate] = useState(initialEndDate);
return (
<ModalContent className="p-4 md:p-8 min-w-fit">
<ModalContent className="min-w-fit p-4 md:p-8">
<Calendar
captionLayout="dropdown"
initialFocus
mode="range"
className="mx-auto min-h-[310px] p-0 [&_table]:mx-auto [&_table]:w-auto"
defaultMonth={subMonths(
startDate ? new Date(startDate) : new Date(),
isBelowSm ? 0 : 1,
isBelowSm ? 0 : 1
)}
selected={{
from: startDate,
to: endDate,
hidden={{
after: endOfDay(new Date()),
}}
toDate={new Date()}
initialFocus
mode="range"
numberOfMonths={isBelowSm ? 1 : 2}
onSelect={(range) => {
if (range?.from) {
setStartDate(range.from);
@@ -47,33 +51,39 @@ export default function DateRangerPicker({
setEndDate(range.to);
}
}}
numberOfMonths={isBelowSm ? 1 : 2}
className="mx-auto min-h-[310px] [&_table]:mx-auto [&_table]:w-auto p-0"
selected={{
from: startDate,
to: endDate,
}}
/>
<div className="col flex-col-reverse md:row gap-2">
<div className="col md:row flex-col-reverse gap-2">
<Button
icon={XIcon}
onClick={() => popModal()}
type="button"
variant="outline"
onClick={() => popModal()}
icon={XIcon}
>
Cancel
</Button>
{startDate && endDate && (
<Button
type="button"
className="md:ml-auto"
icon={startDate && endDate ? CheckIcon : XIcon}
onClick={() => {
popModal();
if (startDate && endDate) {
onChange({
startDate: startDate,
endDate: endDate,
startDate,
endDate,
interval: getDefaultIntervalByDates(
startDate.toISOString(),
endDate.toISOString()
)!,
});
}
}}
icon={startDate && endDate ? CheckIcon : XIcon}
type="button"
>
{startDate && endDate
? `Select ${formatDate(startDate)} - ${formatDate(endDate)}`

View File

@@ -0,0 +1,440 @@
import type { IChartRange, IInterval } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useTRPC } from '@/integrations/trpc/react';
import { getChartColor } from '@/utils/theme';
interface GscChartData {
date: string;
clicks: number;
impressions: number;
}
interface GscViewsChartData {
date: string;
views: number;
}
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
GscChartData | GscViewsChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) {
return null;
}
if (!('date' in item)) {
return null;
}
if ('views' in item && item.views != null) {
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Views</span>
<span>{item.views.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
}
const clicks = 'clicks' in item ? item.clicks : undefined;
const impressions = 'impressions' in item ? item.impressions : undefined;
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
{clicks != null && (
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span>
<span>{clicks.toLocaleString()}</span>
</div>
</ChartTooltipItem>
)}
{impressions != null && (
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Impressions</span>
<span>{impressions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
)}
</>
);
});
type Props =
| {
type: 'page';
projectId: string;
value: string;
range: IChartRange;
interval: IInterval;
}
| {
type: 'query';
projectId: string;
value: string;
range: IChartRange;
interval: IInterval;
};
export default function GscDetails(props: Props) {
const { type, projectId, value, range, interval } = props;
const trpc = useTRPC();
const dateInput = {
range,
interval,
};
const pageQuery = useQuery(
trpc.gsc.getPageDetails.queryOptions(
{ projectId, page: value, ...dateInput },
{ enabled: type === 'page' }
)
);
const queryQuery = useQuery(
trpc.gsc.getQueryDetails.queryOptions(
{ projectId, query: value, ...dateInput },
{ enabled: type === 'query' }
)
);
const { origin: pageOrigin, path: pagePath } =
type === 'page'
? (() => {
try {
const url = new URL(value);
return { origin: url.origin, path: url.pathname + url.search };
} catch {
return {
origin: typeof window !== 'undefined' ? window.location.origin : '',
path: value,
};
}
})()
: { origin: '', path: '' };
const pageTimeseriesQuery = useQuery(
trpc.event.pageTimeseries.queryOptions(
{ projectId, ...dateInput, origin: pageOrigin, path: pagePath },
{ enabled: type === 'page' && !!pagePath }
)
);
const data = type === 'page' ? pageQuery.data : queryQuery.data;
const isLoading =
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
const timeseries = data?.timeseries ?? [];
const pageTimeseries = pageTimeseriesQuery.data ?? [];
const breakdownRows =
type === 'page'
? ((data as { queries?: unknown[] } | undefined)?.queries ?? [])
: ((data as { pages?: unknown[] } | undefined)?.pages ?? []);
const breakdownKey = type === 'page' ? 'query' : 'page';
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
const maxClicks = Math.max(
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
1
);
return (
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
{value}
</SheetTitle>
</SheetHeader>
<div className="col gap-6">
{type === 'page' && (
<div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Views & Sessions</h3>
{isLoading || pageTimeseriesQuery.isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<GscViewsChart
data={pageTimeseries.map((r) => ({
date: r.date,
views: r.pageviews,
}))}
/>
)}
</div>
)}
<div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<GscTimeseriesChart data={timeseries} />
)}
</div>
<div className="card overflow-hidden">
<div className="border-b p-4">
<h3 className="font-medium text-sm">
Top {breakdownLabel.toLowerCase()}s
</h3>
</div>
{isLoading ? (
<OverviewWidgetTable
columns={[
{
name: breakdownLabel,
width: 'w-full',
render: () => <Skeleton className="h-4 w-2/3" />,
},
{
name: 'Clicks',
width: '70px',
render: () => <Skeleton className="h-4 w-10" />,
},
{
name: 'Pos.',
width: '55px',
render: () => <Skeleton className="h-4 w-8" />,
},
]}
data={[1, 2, 3, 4, 5]}
getColumnPercentage={() => 0}
keyExtractor={(i) => String(i)}
/>
) : (
<OverviewWidgetTable
columns={[
{
name: breakdownLabel,
width: 'w-full',
render(item) {
return (
<div className="min-w-0 overflow-hidden">
<span className="block truncate font-mono text-xs">
{String(item[breakdownKey])}
</span>
</div>
);
},
},
{
name: 'Clicks',
width: '70px',
getSortValue: (item) => item.clicks as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.clicks as number).toLocaleString()}
</span>
);
},
},
{
name: 'Impr.',
width: '70px',
getSortValue: (item) => item.impressions as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.impressions as number).toLocaleString()}
</span>
);
},
},
{
name: 'CTR',
width: '60px',
getSortValue: (item) => item.ctr as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{((item.ctr as number) * 100).toFixed(1)}%
</span>
);
},
},
{
name: 'Pos.',
width: '55px',
getSortValue: (item) => item.position as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.position as number).toFixed(1)}
</span>
);
},
},
]}
data={breakdownRows as Record<string, string | number>[]}
getColumnPercentage={(item) =>
(item.clicks as number) / maxClicks
}
keyExtractor={(item) => String(item[breakdownKey])}
/>
)}
</div>
</div>
</SheetContent>
);
}
function GscViewsChart({
data,
}: {
data: Array<{ date: string; views: number }>;
}) {
const yAxisProps = useYAxisProps();
return (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-detail-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<GscTooltip />
<Line
dataKey="views"
dot={false}
filter="url(#gsc-detail-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
);
}
function GscTimeseriesChart({
data,
}: {
data: Array<{ date: string; clicks: number; impressions: number }>;
}) {
const yAxisProps = useYAxisProps();
return (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-detail-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<GscTooltip />
<Line
dataKey="clicks"
dot={false}
filter="url(#gsc-detail-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="impressions"
dot={false}
filter="url(#gsc-detail-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
);
}

View File

@@ -1,3 +1,4 @@
import PageDetails from './page-details';
import { createPushModal } from 'pushmodal';
import AddClient from './add-client';
import AddDashboard from './add-dashboard';
@@ -34,6 +35,7 @@ import OverviewTopPagesModal from '@/components/overview/overview-top-pages-moda
import { op } from '@/utils/op';
const modals = {
PageDetails,
OverviewTopPagesModal,
OverviewTopGenericModal,
RequestPasswordReset,

View File

@@ -0,0 +1,49 @@
import { GscBreakdownTable } from '@/components/page/gsc-breakdown-table';
import { GscClicksChart } from '@/components/page/gsc-clicks-chart';
import { PageViewsChart } from '@/components/page/page-views-chart';
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
type Props = {
type: 'page' | 'query';
projectId: string;
value: string;
};
export default function PageDetails({ type, projectId, value }: Props) {
return (
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
{value}
</SheetTitle>
</SheetHeader>
<div className="col gap-6">
{type === 'page' &&
(() => {
let origin: string;
let path: string;
try {
const url = new URL(value);
origin = url.origin;
path = url.pathname + url.search;
} catch {
// value is path-only (e.g. "/docs/foo")
origin =
typeof window !== 'undefined' ? window.location.origin : '';
path = value;
}
return (
<PageViewsChart
origin={origin}
path={path}
projectId={projectId}
/>
);
})()}
<GscClicksChart projectId={projectId} type={type} value={value} />
<GscBreakdownTable projectId={projectId} type={type} value={value} />
</div>
</SheetContent>
);
}

View File

@@ -42,6 +42,7 @@ import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo'
import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports'
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
@@ -71,6 +72,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from '.
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
import { Route as AppOrganizationIdProjectIdSettingsTabsGscRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.gsc'
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
@@ -312,6 +314,12 @@ const AppOrganizationIdProjectIdSessionsRoute =
path: '/sessions',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdSeoRoute =
AppOrganizationIdProjectIdSeoRouteImport.update({
id: '/seo',
path: '/seo',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdReportsRoute =
AppOrganizationIdProjectIdReportsRouteImport.update({
id: '/reports',
@@ -488,6 +496,12 @@ const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
path: '/imports',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsGscRoute =
AppOrganizationIdProjectIdSettingsTabsGscRouteImport.update({
id: '/gsc',
path: '/gsc',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
id: '/events',
@@ -606,6 +620,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
@@ -640,6 +655,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -677,6 +693,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
@@ -708,6 +725,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -747,6 +765,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
@@ -789,6 +808,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/_app/$organizationId/$projectId/settings/_tabs/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -830,6 +850,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references'
| '/$organizationId/$projectId/reports'
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
@@ -864,6 +885,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/gsc'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets'
@@ -901,6 +923,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references'
| '/$organizationId/$projectId/reports'
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
@@ -932,6 +955,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/gsc'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets'
@@ -970,6 +994,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/realtime'
| '/_app/$organizationId/$projectId/references'
| '/_app/$organizationId/$projectId/reports'
| '/_app/$organizationId/$projectId/seo'
| '/_app/$organizationId/$projectId/sessions'
| '/_app/$organizationId/integrations'
| '/_app/$organizationId/integrations/_tabs'
@@ -1012,6 +1037,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
| '/_app/$organizationId/$projectId/settings/_tabs/details'
| '/_app/$organizationId/$projectId/settings/_tabs/events'
| '/_app/$organizationId/$projectId/settings/_tabs/gsc'
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
@@ -1310,6 +1336,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/seo': {
id: '/_app/$organizationId/$projectId/seo'
path: '/seo'
fullPath: '/$organizationId/$projectId/seo'
preLoaderRoute: typeof AppOrganizationIdProjectIdSeoRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/reports': {
id: '/_app/$organizationId/$projectId/reports'
path: '/reports'
@@ -1520,6 +1553,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/gsc': {
id: '/_app/$organizationId/$projectId/settings/_tabs/gsc'
path: '/gsc'
fullPath: '/$organizationId/$projectId/settings/gsc'
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/events': {
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
path: '/events'
@@ -1785,6 +1825,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
AppOrganizationIdProjectIdSettingsTabsGscRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -1799,6 +1840,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
AppOrganizationIdProjectIdSettingsTabsGscRoute:
AppOrganizationIdProjectIdSettingsTabsGscRoute,
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
@@ -1837,6 +1880,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
AppOrganizationIdProjectIdSeoRoute: typeof AppOrganizationIdProjectIdSeoRoute
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
@@ -1862,6 +1906,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdReferencesRoute,
AppOrganizationIdProjectIdReportsRoute:
AppOrganizationIdProjectIdReportsRoute,
AppOrganizationIdProjectIdSeoRoute: AppOrganizationIdProjectIdSeoRoute,
AppOrganizationIdProjectIdSessionsRoute:
AppOrganizationIdProjectIdSessionsRoute,
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,

View File

@@ -1,349 +1,22 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { PagesTable } from '@/components/pages/table';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { FloatingPagination } from '@/components/pagination-floating';
import { ReportChart } from '@/components/report-chart';
import { Skeleton } from '@/components/skeleton';
import { Input } from '@/components/ui/input';
import { TableButtons } from '@/components/ui/table';
import { useAppContext } from '@/hooks/use-app-context';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import type { IChartRange, IInterval } from '@openpanel/validation';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsInteger, useQueryState } from 'nuqs';
import { memo, useEffect, useMemo, useState } from 'react';
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.PAGES),
},
],
};
},
head: () => ({
meta: [{ title: createProjectTitle(PAGE_TITLES.PAGES) }],
}),
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const take = 20;
const { range, interval } = useOverviewOptions();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(1),
);
const { debouncedSearch, setSearch, search } = useSearchQueryState();
// Track if we should use backend search (when client-side filtering finds nothing)
const [useBackendSearch, setUseBackendSearch] = useState(false);
// Reset to client-side filtering when search changes
useEffect(() => {
setUseBackendSearch(false);
setCursor(1);
}, [debouncedSearch, setCursor]);
// Query for all pages (without search) - used for client-side filtering
const allPagesQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
cursor: 1,
take: 1000,
search: undefined, // No search - get all pages
range,
interval,
},
{
placeholderData: keepPreviousData,
},
),
);
// Query for backend search (only when client-side filtering finds nothing)
const backendSearchQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
cursor: 1,
take: 1000,
search: debouncedSearch || undefined,
range,
interval,
},
{
placeholderData: keepPreviousData,
enabled: useBackendSearch && !!debouncedSearch,
},
),
);
// Client-side filtering: filter all pages by search query
const clientSideFiltered = useMemo(() => {
if (!debouncedSearch || useBackendSearch) {
return allPagesQuery.data ?? [];
}
const searchLower = debouncedSearch.toLowerCase();
return (allPagesQuery.data ?? []).filter(
(page) =>
page.path.toLowerCase().includes(searchLower) ||
page.origin.toLowerCase().includes(searchLower),
);
}, [allPagesQuery.data, debouncedSearch, useBackendSearch]);
// Check if client-side filtering found results
useEffect(() => {
if (
debouncedSearch &&
!useBackendSearch &&
allPagesQuery.isSuccess &&
clientSideFiltered.length === 0
) {
// No results from client-side filtering, switch to backend search
setUseBackendSearch(true);
}
}, [
debouncedSearch,
useBackendSearch,
allPagesQuery.isSuccess,
clientSideFiltered.length,
]);
// Determine which data source to use
const allData = useBackendSearch
? (backendSearchQuery.data ?? [])
: clientSideFiltered;
const isLoading = useBackendSearch
? backendSearchQuery.isLoading
: allPagesQuery.isLoading;
// Client-side pagination: slice the items based on cursor
const startIndex = (cursor - 1) * take;
const endIndex = startIndex + take;
const data = allData.slice(startIndex, endIndex);
const totalPages = Math.ceil(allData.length / take);
return (
<PageContainer>
<PageHeader
title="Pages"
description="Access all your pages here"
className="mb-8"
/>
<TableButtons>
<OverviewRange />
<OverviewInterval />
<Input
className="self-auto"
placeholder="Search path"
value={search ?? ''}
onChange={(e) => {
setSearch(e.target.value);
setCursor(1);
}}
/>
</TableButtons>
{data.length === 0 && !isLoading && (
<FullPageEmptyState
title="No pages"
description={
debouncedSearch
? `No pages found matching "${debouncedSearch}"`
: 'Integrate our web sdk to your site to get pages here.'
}
/>
)}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<PageCardSkeleton />
<PageCardSkeleton />
<PageCardSkeleton />
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.map((page) => {
return (
<PageCard
key={page.origin + page.path}
page={page}
range={range}
interval={interval}
projectId={projectId}
/>
);
})}
</div>
{allData.length !== 0 && (
<div className="p-4">
<FloatingPagination
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
canNextPage={cursor < totalPages}
canPreviousPage={cursor > 1}
pageIndex={cursor - 1}
nextPage={() => {
setCursor((p) => Math.min(p + 1, totalPages));
}}
previousPage={() => {
setCursor((p) => Math.max(p - 1, 1));
}}
/>
</div>
)}
<PageHeader title="Pages" description="Access all your pages here" className="mb-8" />
<PagesTable projectId={projectId} />
</PageContainer>
);
}
const PageCard = memo(
({
page,
range,
interval,
projectId,
}: {
page: RouterOutputs['event']['pages'][number];
range: IChartRange;
interval: IInterval;
projectId: string;
}) => {
const number = useNumber();
const { apiUrl } = useAppContext();
return (
<div className="card">
<div className="row gap-4 justify-between p-4 py-2 items-center">
<div className="row gap-2 items-center h-16">
<img
src={`${apiUrl}/misc/og?url=${page.origin}${page.path}`}
alt={page.title}
className="size-10 rounded-sm object-cover"
loading="lazy"
decoding="async"
/>
<div className="col min-w-0">
<div className="font-medium leading-[28px] truncate">
{page.title}
</div>
<a
target="_blank"
rel="noreferrer"
href={`${page.origin}${page.path}`}
className="text-muted-foreground font-mono truncate hover:underline"
>
{page.path}
</a>
</div>
</div>
</div>
<div className="row border-y">
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.formatWithUnit(page.avg_duration, 'min')}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
duration
</div>
</div>
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.formatWithUnit(page.bounce_rate / 100, '%')}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
bounce rate
</div>
</div>
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.format(page.sessions)}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
sessions
</div>
</div>
</div>
<ReportChart
options={{
hideXAxis: true,
hideYAxis: true,
aspectRatio: 0.15,
}}
report={{
breakdowns: [],
metric: 'sum',
range,
interval,
previous: true,
chartType: 'linear',
projectId,
series: [
{
type: 'event',
id: 'A',
name: 'screen_view',
segment: 'event',
filters: [
{
id: 'path',
name: 'path',
value: [page.path],
operator: 'is',
},
{
id: 'origin',
name: 'origin',
value: [page.origin],
operator: 'is',
},
],
},
],
}}
/>
</div>
);
},
);
const PageCardSkeleton = memo(() => {
return (
<div className="card">
<div className="row gap-4 justify-between p-4 py-2 items-center">
<div className="row gap-2 items-center h-16">
<Skeleton className="size-10 rounded-sm" />
<div className="col min-w-0">
<Skeleton className="h-3 w-32 mb-2" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
<div className="row border-y">
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-16 mb-1" />
<Skeleton className="h-4 w-12" />
</div>
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-12 mb-1" />
<Skeleton className="h-4 w-16" />
</div>
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-14 mb-1" />
<Skeleton className="h-4 w-14" />
</div>
</div>
<div className="p-4">
<Skeleton className="h-16 w-full rounded" />
</div>
</div>
);
});

View File

@@ -24,12 +24,12 @@ export const Route = createFileRoute(
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { page } = useDataTablePagination();
const { page } = useDataTablePagination(50);
const { debouncedSearch } = useSearchQueryState();
const query = useQuery(
trpc.profile.list.queryOptions(
{
cursor: (page - 1) * 50,
cursor: page - 1,
projectId,
take: 50,
search: debouncedSearch,

View File

@@ -31,7 +31,7 @@ function Component() {
const query = useQuery(
trpc.profile.list.queryOptions(
{
cursor: (page - 1) * 50,
cursor: page - 1,
projectId,
take: 50,
search: debouncedSearch,

View File

@@ -23,11 +23,11 @@ export const Route = createFileRoute(
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { page } = useDataTablePagination();
const { page } = useDataTablePagination(50);
const query = useQuery(
trpc.profile.powerUsers.queryOptions(
{
cursor: (page - 1) * 50,
cursor: page - 1,
projectId,
take: 50,
},

View File

@@ -0,0 +1,821 @@
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { GscCannibalization } from '@/components/page/gsc-cannibalization';
import { GscCtrBenchmark } from '@/components/page/gsc-ctr-benchmark';
import { GscPositionChart } from '@/components/page/gsc-position-chart';
import { PagesInsights } from '@/components/page/pages-insights';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Pagination } from '@/components/pagination';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Skeleton } from '@/components/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { getChartColor } from '@/utils/theme';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute('/_app/$organizationId/$projectId/seo')({
component: SeoPage,
head: () => ({
meta: [{ title: createProjectTitle('SEO') }],
}),
});
interface GscChartData {
date: string;
clicks: number;
impressions: number;
}
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
GscChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) {
return null;
}
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span>
<span>{item.clicks.toLocaleString()}</span>
</div>
</ChartTooltipItem>
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Impressions</span>
<span>{item.impressions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
});
function SeoPage() {
const { projectId, organizationId } = useAppParams();
const trpc = useTRPC();
const navigate = useNavigate();
const { range, startDate, endDate, interval } = useOverviewOptions();
const dateInput = {
range,
interval,
startDate,
endDate,
};
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions({ projectId })
);
const connection = connectionQuery.data;
const isConnected = connection?.siteUrl;
const overviewQuery = useQuery(
trpc.gsc.getOverview.queryOptions(
{ projectId, ...dateInput, interval: interval ?? 'day' },
{ enabled: !!isConnected }
)
);
const pagesQuery = useQuery(
trpc.gsc.getPages.queryOptions(
{ projectId, ...dateInput, limit: 50 },
{ enabled: !!isConnected }
)
);
const queriesQuery = useQuery(
trpc.gsc.getQueries.queryOptions(
{ projectId, ...dateInput, limit: 50 },
{ enabled: !!isConnected }
)
);
const searchEnginesQuery = useQuery(
trpc.gsc.getSearchEngines.queryOptions({ projectId, ...dateInput })
);
const aiEnginesQuery = useQuery(
trpc.gsc.getAiEngines.queryOptions({ projectId, ...dateInput })
);
const previousOverviewQuery = useQuery(
trpc.gsc.getPreviousOverview.queryOptions(
{ projectId, ...dateInput, interval: interval ?? 'day' },
{ enabled: !!isConnected }
)
);
const [pagesPage, setPagesPage] = useState(0);
const [queriesPage, setQueriesPage] = useState(0);
const pageSize = 15;
const [pagesSearch, setPagesSearch] = useState('');
const [queriesSearch, setQueriesSearch] = useState('');
const pages = pagesQuery.data ?? [];
const queries = queriesQuery.data ?? [];
const filteredPages = useMemo(() => {
if (!pagesSearch.trim()) {
return pages;
}
const q = pagesSearch.toLowerCase();
return pages.filter((row) => {
return String(row.page).toLowerCase().includes(q);
});
}, [pages, pagesSearch]);
const filteredQueries = useMemo(() => {
if (!queriesSearch.trim()) {
return queries;
}
const q = queriesSearch.toLowerCase();
return queries.filter((row) => {
return String(row.query).toLowerCase().includes(q);
});
}, [queries, queriesSearch]);
const paginatedPages = useMemo(
() => filteredPages.slice(pagesPage * pageSize, (pagesPage + 1) * pageSize),
[filteredPages, pagesPage, pageSize]
);
const paginatedQueries = useMemo(
() =>
filteredQueries.slice(
queriesPage * pageSize,
(queriesPage + 1) * pageSize
),
[filteredQueries, queriesPage, pageSize]
);
const pagesPageCount = Math.ceil(filteredPages.length / pageSize) || 1;
const queriesPageCount = Math.ceil(filteredQueries.length / pageSize) || 1;
if (connectionQuery.isLoading) {
return (
<PageContainer>
<PageHeader description="Google Search Console data" title="SEO" />
<div className="mt-8 space-y-4">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-96 w-full" />
</div>
</PageContainer>
);
}
if (!isConnected) {
return (
<FullPageEmptyState
className="pt-[20vh]"
description="Connect Google Search Console to track your search impressions, clicks, and keyword rankings."
icon={SearchIcon}
title="No SEO data yet"
>
<Button
onClick={() =>
navigate({
to: '/$organizationId/$projectId/settings/gsc',
params: { organizationId, projectId },
})
}
>
Connect Google Search Console
</Button>
</FullPageEmptyState>
);
}
const overview = overviewQuery.data ?? [];
const prevOverview = previousOverviewQuery.data ?? [];
const sumOverview = (rows: typeof overview) =>
rows.reduce(
(acc, row) => ({
clicks: acc.clicks + row.clicks,
impressions: acc.impressions + row.impressions,
ctr: acc.ctr + row.ctr,
position: acc.position + row.position,
}),
{ clicks: 0, impressions: 0, ctr: 0, position: 0 }
);
const totals = sumOverview(overview);
const prevTotals = sumOverview(prevOverview);
const n = Math.max(overview.length, 1);
const pn = Math.max(prevOverview.length, 1);
return (
<PageContainer>
<PageHeader
actions={
<>
<OverviewRange />
<OverviewInterval />
</>
}
description={`Search performance for ${connection.siteUrl}`}
title="SEO"
/>
<div className="mt-8 space-y-8">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
<div className="card col-span-1 grid grid-cols-2 overflow-hidden rounded-md lg:col-span-2">
<OverviewMetricCard
data={overview.map((r) => ({ current: r.clicks, date: r.date }))}
id="clicks"
isLoading={overviewQuery.isLoading}
label="Clicks"
metric={{ current: totals.clicks, previous: prevTotals.clicks }}
/>
<OverviewMetricCard
data={overview.map((r) => ({
current: r.impressions,
date: r.date,
}))}
id="impressions"
isLoading={overviewQuery.isLoading}
label="Impressions"
metric={{
current: totals.impressions,
previous: prevTotals.impressions,
}}
/>
<OverviewMetricCard
data={overview.map((r) => ({
current: r.ctr * 100,
date: r.date,
}))}
id="ctr"
isLoading={overviewQuery.isLoading}
label="Avg CTR"
metric={{
current: (totals.ctr / n) * 100,
previous: (prevTotals.ctr / pn) * 100,
}}
unit="%"
/>
<OverviewMetricCard
data={overview.map((r) => ({
current: r.position,
date: r.date,
}))}
id="position"
inverted
isLoading={overviewQuery.isLoading}
label="Avg Position"
metric={{
current: totals.position / n,
previous: prevTotals.position / pn,
}}
/>
</div>
<SearchEngines
engines={searchEnginesQuery.data?.engines ?? []}
isLoading={searchEnginesQuery.isLoading}
previousTotal={searchEnginesQuery.data?.previousTotal ?? 0}
total={searchEnginesQuery.data?.total ?? 0}
/>
<AiEngines
engines={aiEnginesQuery.data?.engines ?? []}
isLoading={aiEnginesQuery.isLoading}
previousTotal={aiEnginesQuery.data?.previousTotal ?? 0}
total={aiEnginesQuery.data?.total ?? 0}
/>
</div>
<GscChart data={overview} isLoading={overviewQuery.isLoading} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<GscPositionChart
data={overview}
isLoading={overviewQuery.isLoading}
/>
<GscCtrBenchmark
data={pagesQuery.data ?? []}
isLoading={pagesQuery.isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<GscTable
isLoading={pagesQuery.isLoading}
keyField="page"
keyLabel="Page"
maxClicks={Math.max(...paginatedPages.map((p) => p.clicks), 1)}
onNextPage={() =>
setPagesPage((p) => Math.min(pagesPageCount - 1, p + 1))
}
onPreviousPage={() => setPagesPage((p) => Math.max(0, p - 1))}
onRowClick={(value) =>
pushModal('PageDetails', { type: 'page', projectId, value })
}
onSearchChange={(v) => {
setPagesSearch(v);
setPagesPage(0);
}}
pageCount={pagesPageCount}
pageIndex={pagesPage}
pageSize={pageSize}
rows={paginatedPages}
searchPlaceholder="Search pages"
searchValue={pagesSearch}
title="Top pages"
totalCount={filteredPages.length}
/>
<GscTable
isLoading={queriesQuery.isLoading}
keyField="query"
keyLabel="Query"
maxClicks={Math.max(...paginatedQueries.map((q) => q.clicks), 1)}
onNextPage={() =>
setQueriesPage((p) => Math.min(queriesPageCount - 1, p + 1))
}
onPreviousPage={() => setQueriesPage((p) => Math.max(0, p - 1))}
onRowClick={(value) =>
pushModal('PageDetails', { type: 'query', projectId, value })
}
onSearchChange={(v) => {
setQueriesSearch(v);
setQueriesPage(0);
}}
pageCount={queriesPageCount}
pageIndex={queriesPage}
pageSize={pageSize}
rows={paginatedQueries}
searchPlaceholder="Search queries"
searchValue={queriesSearch}
title="Top queries"
totalCount={filteredQueries.length}
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<GscCannibalization
endDate={endDate ?? undefined}
interval={interval ?? 'day'}
projectId={projectId}
range={range}
startDate={startDate ?? undefined}
/>
<PagesInsights projectId={projectId} />
</div>
</div>
</PageContainer>
);
}
function TrafficSourceWidget({
title,
engines,
total,
previousTotal,
isLoading,
emptyMessage,
}: {
title: string;
engines: Array<{ name: string; sessions: number }>;
total: number;
previousTotal: number;
isLoading: boolean;
emptyMessage: string;
}) {
const displayed =
engines.length > 8
? [
...engines.slice(0, 7),
{
name: 'Others',
sessions: engines.slice(7).reduce((s, d) => s + d.sessions, 0),
},
]
: engines.slice(0, 8);
const max = displayed[0]?.sessions ?? 1;
const pctChange =
previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : null;
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b p-4">
<h3 className="font-medium text-sm">{title}</h3>
{!isLoading && total > 0 && (
<div className="flex items-center gap-2">
<span className="font-medium font-mono text-sm tabular-nums">
{total.toLocaleString()}
</span>
{pctChange !== null && (
<span
className={`font-mono text-xs tabular-nums ${pctChange >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
>
{pctChange >= 0 ? '+' : ''}
{pctChange.toFixed(1)}%
</span>
)}
</div>
)}
</div>
<div className="grid grid-cols-2">
{isLoading &&
[1, 2, 3, 4].map((i) => (
<div className="flex items-center gap-2.5 px-4 py-2.5" key={i}>
<div className="size-4 animate-pulse rounded-sm bg-muted" />
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
<div className="ml-auto h-3 w-8 animate-pulse rounded bg-muted" />
</div>
))}
{!isLoading && engines.length === 0 && (
<p className="col-span-2 px-4 py-6 text-center text-muted-foreground text-xs">
{emptyMessage}
</p>
)}
{!isLoading &&
displayed.map((engine) => {
const pct = total > 0 ? (engine.sessions / total) * 100 : 0;
const barPct = (engine.sessions / max) * 100;
return (
<div className="relative px-4 py-2.5" key={engine.name}>
<div
className="absolute inset-y-0 left-0 bg-muted/50"
style={{ width: `${barPct}%` }}
/>
<div className="relative flex items-center gap-2">
{engine.name !== 'Others' && (
<SerieIcon
className="size-3.5 shrink-0 rounded-sm"
name={engine.name}
/>
)}
<span className="min-w-0 flex-1 truncate text-xs capitalize">
{engine.name.replace(/\..+$/, '')}
</span>
<span className="shrink-0 font-mono text-xs tabular-nums">
{engine.sessions.toLocaleString()}
</span>
<span className="shrink-0 font-mono text-muted-foreground text-xs">
{pct.toFixed(0)}%
</span>
</div>
</div>
);
})}
</div>
</div>
);
}
function SearchEngines(props: {
engines: Array<{ name: string; sessions: number }>;
total: number;
previousTotal: number;
isLoading: boolean;
}) {
return (
<TrafficSourceWidget
{...props}
emptyMessage="No search traffic in this period"
title="Search engines"
/>
);
}
function AiEngines(props: {
engines: Array<{ name: string; sessions: number }>;
total: number;
previousTotal: number;
isLoading: boolean;
}) {
return (
<TrafficSourceWidget
{...props}
emptyMessage="No AI traffic in this period"
title="AI referrals"
/>
);
}
function GscChart({
data,
isLoading,
}: {
data: Array<{ date: string; clicks: number; impressions: number }>;
isLoading: boolean;
}) {
const color = getChartColor(0);
const yAxisProps = useYAxisProps();
return (
<div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<TooltipProvider>
<ResponsiveContainer height={200} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-line-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<GscTooltip />
<Line
dataKey="clicks"
dot={false}
filter="url(#gsc-line-glow)"
isAnimationActive={false}
stroke={color}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="impressions"
dot={false}
filter="url(#gsc-line-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}
interface GscTableRow {
clicks: number;
impressions: number;
ctr: number;
position: number;
[key: string]: string | number;
}
function GscTable({
title,
rows,
keyField,
keyLabel,
maxClicks,
isLoading,
onRowClick,
searchValue,
onSearchChange,
searchPlaceholder,
totalCount,
pageIndex,
pageSize,
pageCount,
onPreviousPage,
onNextPage,
}: {
title: string;
rows: GscTableRow[];
keyField: string;
keyLabel: string;
maxClicks: number;
isLoading: boolean;
onRowClick?: (value: string) => void;
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
totalCount?: number;
pageIndex?: number;
pageSize?: number;
pageCount?: number;
onPreviousPage?: () => void;
onNextPage?: () => void;
}) {
const showPagination =
totalCount != null &&
pageSize != null &&
pageCount != null &&
onPreviousPage != null &&
onNextPage != null &&
pageIndex != null;
const canPreviousPage = (pageIndex ?? 0) > 0;
const canNextPage = (pageIndex ?? 0) < (pageCount ?? 1) - 1;
const rangeStart = totalCount ? (pageIndex ?? 0) * (pageSize ?? 0) + 1 : 0;
const rangeEnd = Math.min(
(pageIndex ?? 0) * (pageSize ?? 0) + (pageSize ?? 0),
totalCount ?? 0
);
if (isLoading) {
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b p-4">
<h3 className="font-medium text-sm">{title}</h3>
</div>
<OverviewWidgetTable
columns={[
{
name: keyLabel,
width: 'w-full',
render: () => <Skeleton className="h-4 w-2/3" />,
},
{
name: 'Clicks',
width: '70px',
render: () => <Skeleton className="h-4 w-10" />,
},
{
name: 'Impr.',
width: '70px',
render: () => <Skeleton className="h-4 w-10" />,
},
{
name: 'CTR',
width: '60px',
render: () => <Skeleton className="h-4 w-8" />,
},
{
name: 'Pos.',
width: '55px',
render: () => <Skeleton className="h-4 w-8" />,
},
]}
data={[1, 2, 3, 4, 5]}
getColumnPercentage={() => 0}
keyExtractor={(i) => String(i)}
/>
</div>
);
}
return (
<div className="card">
<div className="border-b">
<div className="flex items-center justify-between px-4 py-3">
<h3 className="font-medium text-sm">{title}</h3>
{showPagination && (
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-muted-foreground text-xs">
{totalCount === 0
? '0 results'
: `${rangeStart}-${rangeEnd} of ${totalCount}`}
</span>
<Pagination
canNextPage={canNextPage}
canPreviousPage={canPreviousPage}
nextPage={onNextPage}
pageIndex={pageIndex}
previousPage={onPreviousPage}
/>
</div>
)}
</div>
{onSearchChange != null && (
<div className="relative border-t">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="rounded-none border-0 border-y bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder ?? 'Search'}
type="search"
value={searchValue ?? ''}
/>
</div>
)}
</div>
<OverviewWidgetTable
columns={[
{
name: keyLabel,
width: 'w-full',
render(item) {
return (
<div className="min-w-0 overflow-hidden">
<button
className="block w-full truncate text-left font-mono text-xs hover:underline"
onClick={() => onRowClick?.(String(item[keyField]))}
type="button"
>
{String(item[keyField])}
</button>
</div>
);
},
},
{
name: 'Clicks',
width: '70px',
getSortValue: (item) => item.clicks,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{item.clicks.toLocaleString()}
</span>
);
},
},
{
name: 'Impr.',
width: '70px',
getSortValue: (item) => item.impressions,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{item.impressions.toLocaleString()}
</span>
);
},
},
{
name: 'CTR',
width: '60px',
getSortValue: (item) => item.ctr,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.ctr * 100).toFixed(1)}%
</span>
);
},
},
{
name: 'Pos.',
width: '55px',
getSortValue: (item) => item.position,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{item.position.toFixed(1)}
</span>
);
},
},
]}
data={rows}
getColumnPercentage={(item) => item.clicks / maxClicks}
keyExtractor={(item) => String(item[keyField])}
/>
</div>
);
}

View File

@@ -0,0 +1,334 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { formatDistanceToNow } from 'date-fns';
import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { Skeleton } from '@/components/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/settings/_tabs/gsc'
)({
component: GscSettings,
});
function GscSettings() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [selectedSite, setSelectedSite] = useState('');
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions(
{ projectId },
{ refetchInterval: 5000 }
)
);
const sitesQuery = useQuery(
trpc.gsc.getSites.queryOptions(
{ projectId },
{ enabled: !!connectionQuery.data && !connectionQuery.data.siteUrl }
)
);
const initiateOAuth = useMutation(
trpc.gsc.initiateOAuth.mutationOptions({
onSuccess: (data) => {
window.location.href = data.url;
},
onError: () => {
toast.error('Failed to initiate Google Search Console connection');
},
})
);
const selectSite = useMutation(
trpc.gsc.selectSite.mutationOptions({
onSuccess: () => {
toast.success('Site connected', {
description: 'Backfill of 6 months of data has started.',
});
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
},
onError: () => {
toast.error('Failed to select site');
},
})
);
const disconnect = useMutation(
trpc.gsc.disconnect.mutationOptions({
onSuccess: () => {
toast.success('Disconnected from Google Search Console');
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
},
onError: () => {
toast.error('Failed to disconnect');
},
})
);
const connection = connectionQuery.data;
if (connectionQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
</div>
);
}
// Not connected at all
if (!connection) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Google Search Console</h3>
<p className="mt-1 text-muted-foreground text-sm">
Connect your Google Search Console property to import search
performance data.
</p>
</div>
<div className="flex flex-col gap-4 rounded-lg border p-6">
<p className="text-muted-foreground text-sm">
You will be redirected to Google to authorize access. Only read-only
access to Search Console data is requested.
</p>
<Button
className="w-fit"
disabled={initiateOAuth.isPending}
onClick={() => initiateOAuth.mutate({ projectId })}
>
{initiateOAuth.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Connect Google Search Console
</Button>
</div>
</div>
);
}
// Connected but no site selected yet
if (!connection.siteUrl) {
const sites = sitesQuery.data ?? [];
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Select a property</h3>
<p className="mt-1 text-muted-foreground text-sm">
Choose which Google Search Console property to connect to this
project.
</p>
</div>
<div className="space-y-4 rounded-lg border p-6">
{sitesQuery.isLoading ? (
<Skeleton className="h-10 w-full" />
) : sites.length === 0 ? (
<p className="text-muted-foreground text-sm">
No Search Console properties found for this Google account.
</p>
) : (
<>
<Select onValueChange={setSelectedSite} value={selectedSite}>
<SelectTrigger>
<SelectValue placeholder="Select a property..." />
</SelectTrigger>
<SelectContent>
{sites.map((site) => (
<SelectItem key={site.siteUrl} value={site.siteUrl}>
{site.siteUrl}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!selectedSite || selectSite.isPending}
onClick={() =>
selectSite.mutate({ projectId, siteUrl: selectedSite })
}
>
{selectSite.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Connect property
</Button>
</>
)}
</div>
<Button
onClick={() => disconnect.mutate({ projectId })}
size="sm"
variant="ghost"
>
Cancel
</Button>
</div>
);
}
// Token expired — show reconnect prompt
if (connection.lastSyncStatus === 'token_expired') {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Google Search Console</h3>
<p className="mt-1 text-muted-foreground text-sm">
Connected to Google Search Console.
</p>
</div>
<div className="flex flex-col gap-4 rounded-lg border border-destructive/50 bg-destructive/5 p-6">
<div className="flex items-center gap-2 text-destructive">
<XCircleIcon className="h-4 w-4" />
<span className="font-medium text-sm">Authorization expired</span>
</div>
<p className="text-muted-foreground text-sm">
Your Google Search Console authorization has expired or been
revoked. Please reconnect to continue syncing data.
</p>
{connection.lastSyncError && (
<p className="break-words font-mono text-muted-foreground text-xs">
{connection.lastSyncError}
</p>
)}
<Button
className="w-fit"
disabled={initiateOAuth.isPending}
onClick={() => initiateOAuth.mutate({ projectId })}
>
{initiateOAuth.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Reconnect Google Search Console
</Button>
</div>
<Button
disabled={disconnect.isPending}
onClick={() => disconnect.mutate({ projectId })}
size="sm"
variant="ghost"
>
{disconnect.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Disconnect
</Button>
</div>
);
}
// Fully connected
const syncStatusIcon =
connection.lastSyncStatus === 'success' ? (
<CheckCircleIcon className="h-4 w-4" />
) : connection.lastSyncStatus === 'error' ? (
<XCircleIcon className="h-4 w-4" />
) : null;
const syncStatusVariant =
connection.lastSyncStatus === 'success'
? 'success'
: connection.lastSyncStatus === 'error'
? 'destructive'
: 'secondary';
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Google Search Console</h3>
<p className="mt-1 text-muted-foreground text-sm">
Connected to Google Search Console.
</p>
</div>
<div className="divide-y rounded-lg border">
<div className="flex items-center justify-between p-4">
<div className="font-medium text-sm">Property</div>
<div className="font-mono text-muted-foreground text-sm">
{connection.siteUrl}
</div>
</div>
{connection.backfillStatus && (
<div className="flex items-center justify-between p-4">
<div className="font-medium text-sm">Backfill</div>
<Badge
className="capitalize"
variant={
connection.backfillStatus === 'completed'
? 'success'
: connection.backfillStatus === 'failed'
? 'destructive'
: connection.backfillStatus === 'running'
? 'default'
: 'secondary'
}
>
{connection.backfillStatus === 'running' && (
<Loader2Icon className="mr-1 h-3 w-3 animate-spin" />
)}
{connection.backfillStatus}
</Badge>
</div>
)}
{connection.lastSyncedAt && (
<div className="flex items-center justify-between p-4">
<div className="font-medium text-sm">Last synced</div>
<div className="flex items-center gap-2">
{connection.lastSyncStatus && (
<Badge
className="capitalize"
variant={syncStatusVariant as any}
>
{syncStatusIcon}
{connection.lastSyncStatus}
</Badge>
)}
<span className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
addSuffix: true,
})}
</span>
</div>
</div>
)}
{connection.lastSyncError && (
<div className="p-4">
<div className="font-medium text-destructive text-sm">
Last error
</div>
<div className="mt-1 break-words font-mono text-muted-foreground text-sm">
{connection.lastSyncError}
</div>
</div>
)}
</div>
<Button
disabled={disconnect.isPending}
onClick={() => disconnect.mutate({ projectId })}
size="sm"
variant="destructive"
>
{disconnect.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Disconnect
</Button>
</div>
);
}

View File

@@ -1,3 +1,15 @@
import { IMPORT_PROVIDERS } from '@openpanel/importer/providers';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { formatDistanceToNow } from 'date-fns';
import {
CheckCircleIcon,
Download,
InfoIcon,
Loader2Icon,
XCircleIcon,
} from 'lucide-react';
import { toast } from 'sonner';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import {
IntegrationCard,
@@ -19,21 +31,9 @@ import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { IMPORT_PROVIDERS } from '@openpanel/importer/providers';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { formatDistanceToNow } from 'date-fns';
import {
CheckCircleIcon,
Download,
InfoIcon,
Loader2Icon,
XCircleIcon,
} from 'lucide-react';
import { toast } from 'sonner';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/settings/_tabs/imports',
'/_app/$organizationId/$projectId/settings/_tabs/imports'
)({
component: ImportsSettings,
});
@@ -48,8 +48,8 @@ function ImportsSettings() {
{ projectId },
{
refetchInterval: 5000,
},
),
}
)
);
const imports = importsQuery.data ?? [];
@@ -61,7 +61,7 @@ function ImportsSettings() {
});
queryClient.invalidateQueries(trpc.import.list.pathFilter());
},
}),
})
);
const retryImport = useMutation(
@@ -72,11 +72,11 @@ function ImportsSettings() {
});
queryClient.invalidateQueries(trpc.import.list.pathFilter());
},
}),
})
);
const handleProviderSelect = (
provider: (typeof IMPORT_PROVIDERS)[number],
provider: (typeof IMPORT_PROVIDERS)[number]
) => {
pushModal('AddImport', {
provider: provider.id,
@@ -93,10 +93,10 @@ function ImportsSettings() {
failed: 'destructive',
};
const icons: Record<string, React.ReactNode> = {
pending: <Loader2Icon className="w-4 h-4 animate-spin" />,
processing: <Loader2Icon className="w-4 h-4 animate-spin" />,
completed: <CheckCircleIcon className="w-4 h-4" />,
failed: <XCircleIcon className="w-4 h-4" />,
pending: <Loader2Icon className="h-4 w-4 animate-spin" />,
processing: <Loader2Icon className="h-4 w-4 animate-spin" />,
completed: <CheckCircleIcon className="h-4 w-4" />,
failed: <XCircleIcon className="h-4 w-4" />,
};
if (status === 'failed') {
@@ -105,7 +105,7 @@ function ImportsSettings() {
content={errorMessage}
tooltipClassName="max-w-xs break-words"
>
<Badge variant={variants[status] || 'default'} className="capitalize">
<Badge className="capitalize" variant={variants[status] || 'default'}>
{icons[status] || null}
{status}
</Badge>
@@ -114,7 +114,7 @@ function ImportsSettings() {
}
return (
<Badge variant={variants[status] || 'default'} className="capitalize">
<Badge className="capitalize" variant={variants[status] || 'default'}>
{icons[status] || null}
{status}
</Badge>
@@ -124,26 +124,26 @@ function ImportsSettings() {
return (
<div className="space-y-8">
<div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{IMPORT_PROVIDERS.map((provider) => (
<IntegrationCard
key={provider.id}
description={provider.description}
icon={
<IntegrationCardLogoImage
src={provider.logo}
backgroundColor={provider.backgroundColor}
className="p-4"
src={provider.logo}
/>
}
key={provider.id}
name={provider.name}
description={provider.description}
>
<IntegrationCardFooter className="row justify-end">
<Button
variant="ghost"
onClick={() => handleProviderSelect(provider)}
variant="ghost"
>
<Download className="w-4 h-4 mr-2" />
<Download className="mr-2 h-4 w-4" />
Import Data
</Button>
</IntegrationCardFooter>
@@ -153,9 +153,9 @@ function ImportsSettings() {
</div>
<div>
<h3 className="text-lg font-medium mb-4">Import History</h3>
<h3 className="mb-4 font-medium text-lg">Import History</h3>
<div className="border rounded-lg">
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
@@ -172,8 +172,8 @@ function ImportsSettings() {
<TableRow>
<TableCell colSpan={6}>
<FullPageEmptyState
title="No imports yet"
description="Your import history will appear here."
title="No imports yet"
/>
</TableCell>
</TableRow>
@@ -196,7 +196,7 @@ function ImportsSettings() {
<TableCell>
<Skeleton className="h-4 w-3/5" />
</TableCell>
<TableCell className="text-right justify-end row">
<TableCell className="row justify-end text-right">
<Skeleton className="h-4 w-3/5" />
</TableCell>
</TableRow>
@@ -204,9 +204,9 @@ function ImportsSettings() {
{imports.map((imp) => (
<TableRow key={imp.id}>
<TableCell className="font-medium capitalize">
<div className="row gap-2 items-center">
<div className="row items-center gap-2">
<div>{imp.config.provider}</div>
<Badge variant="outline" className="uppercase">
<Badge className="uppercase" variant="outline">
{imp.config.type}
</Badge>
</div>
@@ -220,7 +220,7 @@ function ImportsSettings() {
<div className="space-y-1">
{getStatusBadge(imp.status, imp.errorMessage)}
{imp.statusMessage && (
<div className="text-xs text-muted-foreground truncate">
<div className="truncate text-muted-foreground text-xs">
{imp.statusMessage}
</div>
)}
@@ -237,13 +237,13 @@ function ImportsSettings() {
tooltipClassName="max-w-xs"
>
{imp.totalEvents.toLocaleString()}{' '}
<InfoIcon className="w-4 h-4 inline-block relative -top-px" />
<InfoIcon className="relative -top-px inline-block h-4 w-4" />
</Tooltiper>
</div>
{imp.status === 'processing' && (
<div className="w-full bg-secondary rounded-full h-1.5">
<div className="h-1.5 w-full rounded-full bg-secondary">
<div
className="bg-primary h-1.5 rounded-full transition-all"
className="h-1.5 rounded-full bg-primary transition-all"
style={{
width: `${Math.min(Math.round((imp.processedEvents / imp.totalEvents) * 100), 100)}%`,
}}
@@ -265,7 +265,7 @@ function ImportsSettings() {
<TableCell>
<Tooltiper
content={
<pre className="font-mono text-sm leading-normal whitespace-pre-wrap break-words">
<pre className="whitespace-pre-wrap break-words font-mono text-sm leading-normal">
{JSON.stringify(imp.config, null, 2)}
</pre>
}
@@ -274,20 +274,20 @@ function ImportsSettings() {
<Badge>Config</Badge>
</Tooltiper>
</TableCell>
<TableCell className="text-right space-x-2">
<TableCell className="space-x-2 text-right">
{imp.status === 'failed' && (
<Button
variant="outline"
size="sm"
onClick={() => retryImport.mutate({ id: imp.id })}
size="sm"
variant="outline"
>
Retry
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => deleteImport.mutate({ id: imp.id })}
size="sm"
variant="ghost"
>
Delete
</Button>

View File

@@ -45,6 +45,7 @@ function ProjectDashboard() {
{ id: 'tracking', label: 'Tracking script' },
{ id: 'widgets', label: 'Widgets' },
{ id: 'imports', label: 'Imports' },
{ id: 'gsc', label: 'Google Search' },
];
const handleTabChange = (tabId: string) => {

View File

@@ -1,6 +1,6 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { LoginLeftPanel } from '@/components/login-left-panel';
import { LoginNavbar } from '@/components/login-navbar';
import { OnboardingLeftPanel } from '@/components/onboarding-left-panel';
export const Route = createFileRoute('/_login')({
beforeLoad: async ({ context }) => {
@@ -16,7 +16,7 @@ function AuthLayout() {
<div className="relative grid min-h-screen md:grid-cols-2">
<LoginNavbar />
<div className="hidden md:block">
<LoginLeftPanel />
<OnboardingLeftPanel />
</div>
<div className="center-center mx-auto w-full max-w-md px-4">
<Outlet />

View File

@@ -57,11 +57,11 @@ function Component() {
<div className="col w-full gap-8 text-left">
<div>
<h1 className="mb-2 font-bold text-3xl text-foreground">
Create an account
Start tracking in minutes
</h1>
<p className="text-muted-foreground">
Let's start with creating your account. By creating an account you
accept the{' '}
Join 1,000+ projects already using OpenPanel. By creating an account
you accept the{' '}
<a
className="underline transition-colors hover:text-foreground"
href="https://openpanel.dev/terms"
@@ -111,6 +111,9 @@ function Component() {
<SignInGithub inviteId={inviteId} type="sign-up" />
<SignInGoogle inviteId={inviteId} type="sign-up" />
</div>
<p className="text-center text-muted-foreground text-xs">
No credit card required · Free 30-day trial · Cancel anytime
</p>
<Or className="my-6" />

View File

@@ -78,6 +78,11 @@ export async function bootCron() {
type: 'onboarding',
pattern: '0 * * * *',
},
{
name: 'gscSync',
type: 'gscSync',
pattern: '0 3 * * *',
},
];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {

View File

@@ -6,6 +6,7 @@ import {
type EventsQueuePayloadIncomingEvent,
cronQueue,
eventsGroupQueues,
gscQueue,
importQueue,
insightsQueue,
miscQueue,
@@ -20,6 +21,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
import { Worker as GroupWorker } from 'groupmq';
import { cronJob } from './jobs/cron';
import { gscJob } from './jobs/gsc';
import { incomingEvent } from './jobs/events.incoming-event';
import { importJob } from './jobs/import';
import { insightsProjectJob } from './jobs/insights';
@@ -59,6 +61,7 @@ function getEnabledQueues(): QueueName[] {
'misc',
'import',
'insights',
'gsc',
];
}
@@ -208,6 +211,17 @@ export async function bootWorkers() {
logger.info('Started worker for insights', { concurrency });
}
// Start gsc worker
if (enabledQueues.includes('gsc')) {
const concurrency = getConcurrencyFor('gsc', 5);
const gscWorker = new Worker(gscQueue.name, gscJob, {
...workerOptions,
concurrency,
});
workers.push(gscWorker);
logger.info('Started worker for gsc', { concurrency });
}
if (workers.length === 0) {
logger.warn(
'No workers started. Check ENABLED_QUEUES environment variable.',

View File

@@ -4,6 +4,7 @@ import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessio
import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects';
import { gscSyncAllJob } from './gsc';
import { onboardingJob } from './cron.onboarding';
import { ping } from './cron.ping';
import { salt } from './cron.salt';
@@ -41,5 +42,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'onboarding': {
return await onboardingJob(job);
}
case 'gscSync': {
return await gscSyncAllJob();
}
}
}

142
apps/worker/src/jobs/gsc.ts Normal file
View File

@@ -0,0 +1,142 @@
import { db, syncGscData } from '@openpanel/db';
import { gscQueue } from '@openpanel/queue';
import type { GscQueuePayload } from '@openpanel/queue';
import type { Job } from 'bullmq';
import { logger } from '../utils/logger';
const BACKFILL_MONTHS = 6;
const CHUNK_DAYS = 14;
export async function gscJob(job: Job<GscQueuePayload>) {
switch (job.data.type) {
case 'gscProjectSync':
return gscProjectSyncJob(job.data.payload.projectId);
case 'gscProjectBackfill':
return gscProjectBackfillJob(job.data.payload.projectId);
}
}
async function gscProjectSyncJob(projectId: string) {
const conn = await db.gscConnection.findUnique({ where: { projectId } });
if (!conn?.siteUrl) {
logger.warn('GSC sync skipped: no connection or siteUrl', { projectId });
return;
}
try {
// Sync rolling 3-day window (GSC data can arrive late)
const endDate = new Date();
endDate.setDate(endDate.getDate() - 1); // yesterday
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 2); // 3 days total
await syncGscData(projectId, startDate, endDate);
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncedAt: new Date(),
lastSyncStatus: 'success',
lastSyncError: null,
},
});
logger.info('GSC sync completed', { projectId });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncedAt: new Date(),
lastSyncStatus: 'error',
lastSyncError: message,
},
});
logger.error('GSC sync failed', { projectId, error });
throw error;
}
}
async function gscProjectBackfillJob(projectId: string) {
const conn = await db.gscConnection.findUnique({ where: { projectId } });
if (!conn?.siteUrl) {
logger.warn('GSC backfill skipped: no connection or siteUrl', { projectId });
return;
}
await db.gscConnection.update({
where: { projectId },
data: { backfillStatus: 'running' },
});
try {
const endDate = new Date();
endDate.setDate(endDate.getDate() - 1); // yesterday
const startDate = new Date(endDate);
startDate.setMonth(startDate.getMonth() - BACKFILL_MONTHS);
// Process in chunks to avoid timeouts and respect API limits
let chunkEnd = new Date(endDate);
while (chunkEnd > startDate) {
const chunkStart = new Date(chunkEnd);
chunkStart.setDate(chunkStart.getDate() - CHUNK_DAYS + 1);
if (chunkStart < startDate) {
chunkStart.setTime(startDate.getTime());
}
logger.info('GSC backfill chunk', {
projectId,
from: chunkStart.toISOString().slice(0, 10),
to: chunkEnd.toISOString().slice(0, 10),
});
await syncGscData(projectId, chunkStart, chunkEnd);
chunkEnd = new Date(chunkStart);
chunkEnd.setDate(chunkEnd.getDate() - 1);
}
await db.gscConnection.update({
where: { projectId },
data: {
backfillStatus: 'completed',
lastSyncedAt: new Date(),
lastSyncStatus: 'success',
lastSyncError: null,
},
});
logger.info('GSC backfill completed', { projectId });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await db.gscConnection.update({
where: { projectId },
data: {
backfillStatus: 'failed',
lastSyncStatus: 'error',
lastSyncError: message,
},
});
logger.error('GSC backfill failed', { projectId, error });
throw error;
}
}
export async function gscSyncAllJob() {
const connections = await db.gscConnection.findMany({
where: {
siteUrl: { not: '' },
},
select: { projectId: true },
});
logger.info('GSC nightly sync: enqueuing projects', {
count: connections.length,
});
for (const conn of connections) {
await gscQueue.add('gscProjectSync', {
type: 'gscProjectSync',
payload: { projectId: conn.projectId },
});
}
}

View File

@@ -1,17 +1,17 @@
import {
type IClickhouseEvent,
type ImportSteps,
type Prisma,
backfillSessionsToProduction,
cleanupSessionStartEndEvents,
cleanupStagingData,
createSessionsStartEndEvents,
db,
formatClickhouseDate,
generateSessionIds,
generateGapBasedSessionIds,
getImportDateBounds,
getImportProgress,
type IClickhouseEvent,
type IClickhouseProfile,
insertImportBatch,
markImportComplete,
insertProfilesBatch,
moveImportsToProduction,
type Prisma,
updateImportStatus,
} from '@openpanel/db';
import { MixpanelProvider, UmamiProvider } from '@openpanel/importer';
@@ -22,294 +22,261 @@ import { logger } from '../utils/logger';
const BATCH_SIZE = Number.parseInt(process.env.IMPORT_BATCH_SIZE || '5000', 10);
/**
* Yields control back to the event loop to prevent stalled jobs
*/
async function yieldToEventLoop(): Promise<void> {
function yieldToEventLoop(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 100);
});
}
const RESUMABLE_STEPS = ['creating_sessions', 'moving', 'backfilling_sessions'];
export async function importJob(job: Job<ImportQueuePayload>) {
const { importId } = job.data.payload;
const record = await db.$primary().import.findUniqueOrThrow({
where: { id: importId },
include: {
project: true,
},
include: { project: true },
});
const jobLogger = logger.child({
importId,
config: record.config,
});
type ValidStep = Exclude<ImportSteps, 'failed' | 'completed'>;
const steps: Record<ValidStep, number> = {
loading: 0,
generating_session_ids: 1,
creating_sessions: 2,
moving: 3,
backfilling_sessions: 4,
};
const jobLogger = logger.child({ importId, config: record.config });
jobLogger.info('Starting import job');
const providerInstance = createProvider(record, jobLogger);
const shouldGenerateSessionIds = providerInstance.shouldGenerateSessionIds();
try {
// Check if this is a resume operation
const isNewImport = record.currentStep === null;
const isRetry = record.currentStep !== null;
const canResume =
isRetry && RESUMABLE_STEPS.includes(record.currentStep as string);
if (isNewImport) {
await updateImportStatus(jobLogger, job, importId, {
step: 'loading',
});
} else {
jobLogger.info('Resuming import from previous state', {
currentStep: record.currentStep,
currentBatch: record.currentBatch,
});
}
// Try to get a precomputed total for better progress reporting
const totalEvents = await providerInstance
.getTotalEventsCount()
.catch(() => -1);
let processedEvents = record.processedEvents;
const resumeLoadingFrom =
(record.currentStep === 'loading' && record.currentBatch) || undefined;
const resumeGeneratingSessionIdsFrom =
(record.currentStep === 'generating_session_ids' &&
record.currentBatch) ||
undefined;
const resumeCreatingSessionsFrom =
(record.currentStep === 'creating_sessions' && record.currentBatch) ||
undefined;
const resumeMovingFrom =
(record.currentStep === 'moving' && record.currentBatch) || undefined;
const resumeBackfillingSessionsFrom =
(record.currentStep === 'backfilling_sessions' && record.currentBatch) ||
undefined;
// Example:
// shouldRunStep(0) // currStep = 2 (should not run)
// shouldRunStep(1) // currStep = 2 (should not run)
// shouldRunStep(2) // currStep = 2 (should run)
// shouldRunStep(3) // currStep = 2 (should run)
const shouldRunStep = (step: ValidStep) => {
if (isNewImport) {
return true;
// -------------------------------------------------------
// STAGING PHASE: clean slate on failure, run from scratch
// -------------------------------------------------------
if (!canResume) {
if (isRetry) {
jobLogger.info(
'Retry detected before resumable phase — cleaning staging data'
);
await cleanupStagingData(importId);
}
const stepToRunIndex = steps[step];
const currentStepIndex = steps[record.currentStep as ValidStep];
return stepToRunIndex >= currentStepIndex;
};
// Phase 1: Load events into staging
await updateImportStatus(jobLogger, job, importId, { step: 'loading' });
async function whileBounds(
from: string | undefined,
callback: (from: string, to: string) => Promise<void>,
) {
const bounds = await getImportDateBounds(importId, from);
if (bounds.min && bounds.max) {
const start = new Date(bounds.min);
const end = new Date(bounds.max);
let cursor = new Date(start);
while (cursor < end) {
const next = new Date(cursor);
next.setDate(next.getDate() + 1);
await callback(
formatClickhouseDate(cursor, true),
formatClickhouseDate(next, true),
);
cursor = next;
const totalEvents = await providerInstance
.getTotalEventsCount()
.catch(() => -1);
let processedEvents = 0;
const eventBatch: IClickhouseEvent[] = [];
// Yield control back to event loop after processing each day
await yieldToEventLoop();
}
}
}
// Phase 1: Fetch & Transform - Process events in batches
if (shouldRunStep('loading')) {
const eventBatch: any = [];
for await (const rawEvent of providerInstance.parseSource(
resumeLoadingFrom,
)) {
// Validate event
for await (const rawEvent of providerInstance.parseSource()) {
if (
!providerInstance.validate(
// @ts-expect-error
rawEvent,
// @ts-expect-error -- provider-specific raw type
rawEvent
)
) {
jobLogger.warn('Skipping invalid event', { rawEvent });
continue;
}
eventBatch.push(rawEvent);
const transformed: IClickhouseEvent = providerInstance.transformEvent(
// @ts-expect-error -- provider-specific raw type
rawEvent
);
// Session IDs for providers that need them (e.g. Mixpanel) are generated
// in generateGapBasedSessionIds after loading, using gap-based logic.
eventBatch.push(transformed);
// Process batch when it reaches the batch size
if (eventBatch.length >= BATCH_SIZE) {
jobLogger.info('Processing batch', { batchSize: eventBatch.length });
const transformedEvents: IClickhouseEvent[] = eventBatch.map(
(
// @ts-expect-error
event,
) => providerInstance!.transformEvent(event),
);
await insertImportBatch(transformedEvents, importId);
await insertImportBatch(eventBatch, importId);
processedEvents += eventBatch.length;
eventBatch.length = 0;
const createdAt = new Date(transformedEvents[0]?.created_at || '')
const batchDate = new Date(eventBatch[0]?.created_at || '')
.toISOString()
.split('T')[0];
await updateImportStatus(jobLogger, job, importId, {
step: 'loading',
batch: createdAt,
batch: batchDate,
totalEvents,
processedEvents,
});
// Yield control back to event loop after processing each batch
eventBatch.length = 0;
await yieldToEventLoop();
}
}
// Process remaining events in the last batch
if (eventBatch.length > 0) {
const transformedEvents = eventBatch.map(
(
// @ts-expect-error
event,
) => providerInstance!.transformEvent(event),
);
await insertImportBatch(transformedEvents, importId);
await insertImportBatch(eventBatch, importId);
processedEvents += eventBatch.length;
eventBatch.length = 0;
const createdAt = new Date(transformedEvents[0]?.created_at || '')
const batchDate = new Date(eventBatch[0]?.created_at || '')
.toISOString()
.split('T')[0];
await updateImportStatus(jobLogger, job, importId, {
step: 'loading',
batch: createdAt,
batch: batchDate,
totalEvents,
processedEvents,
});
eventBatch.length = 0;
}
// Yield control back to event loop after processing final batch
jobLogger.info('Loading complete', { processedEvents });
// Phase 1b: Load user profiles (Mixpanel only)
const profileBatchSize = 5000;
if (
'streamProfiles' in providerInstance &&
typeof (providerInstance as MixpanelProvider).streamProfiles ===
'function'
) {
await updateImportStatus(jobLogger, job, importId, {
step: 'loading_profiles',
});
const profileBatch: IClickhouseProfile[] = [];
let processedProfiles = 0;
for await (const rawProfile of (
providerInstance as MixpanelProvider
).streamProfiles()) {
const profile = (
providerInstance as MixpanelProvider
).transformProfile(rawProfile);
profileBatch.push(profile);
if (profileBatch.length >= profileBatchSize) {
await insertProfilesBatch(profileBatch, record.projectId);
processedProfiles += profileBatch.length;
await updateImportStatus(jobLogger, job, importId, {
step: 'loading_profiles',
processedProfiles,
});
profileBatch.length = 0;
await yieldToEventLoop();
}
}
if (profileBatch.length > 0) {
await insertProfilesBatch(profileBatch, record.projectId);
processedProfiles += profileBatch.length;
await updateImportStatus(jobLogger, job, importId, {
step: 'loading_profiles',
processedProfiles,
totalProfiles: processedProfiles,
});
}
jobLogger.info('Profile loading complete', { processedProfiles });
}
// Phase 2: Generate gap-based session IDs (Mixpanel etc.)
if (shouldGenerateSessionIds) {
await updateImportStatus(jobLogger, job, importId, {
step: 'generating_sessions',
});
await generateGapBasedSessionIds(importId);
await yieldToEventLoop();
jobLogger.info('Session ID generation complete');
}
}
// Phase 2: Generate session IDs if provider requires it
if (
shouldRunStep('generating_session_ids') &&
providerInstance.shouldGenerateSessionIds()
) {
await whileBounds(resumeGeneratingSessionIdsFrom, async (from) => {
console.log('Generating session IDs', { from });
await generateSessionIds(importId, from);
await updateImportStatus(jobLogger, job, importId, {
step: 'generating_session_ids',
batch: from,
});
// -------------------------------------------------------
// SESSION CREATION PHASE: resumable by cleaning session_start/end
// -------------------------------------------------------
const skipSessionCreation =
canResume && record.currentStep !== 'creating_sessions';
// Yield control back to event loop after processing each day
await yieldToEventLoop();
if (!skipSessionCreation) {
if (canResume && record.currentStep === 'creating_sessions') {
jobLogger.info(
'Retry at creating_sessions — cleaning existing session_start/end events'
);
await cleanupSessionStartEndEvents(importId);
}
await updateImportStatus(jobLogger, job, importId, {
step: 'creating_sessions',
batch: 'all sessions',
});
await createSessionsStartEndEvents(importId);
await yieldToEventLoop();
jobLogger.info('Session ID generation complete');
jobLogger.info('Session event creation complete');
}
// Phase 3-5: Process in daily batches for robustness
// -------------------------------------------------------
// PRODUCTION PHASE: resume-safe, track progress per batch
// -------------------------------------------------------
if (shouldRunStep('creating_sessions')) {
await whileBounds(resumeCreatingSessionsFrom, async (from) => {
await createSessionsStartEndEvents(importId, from);
await updateImportStatus(jobLogger, job, importId, {
step: 'creating_sessions',
batch: from,
});
// Phase 3: Move staging events to production (per-day)
const resumeMovingFrom =
canResume && record.currentStep === 'moving'
? (record.currentBatch ?? undefined)
: undefined;
// Yield control back to event loop after processing each day
await yieldToEventLoop();
});
}
// currentBatch is the last successfully completed day — resume from the next day to avoid re-inserting it
const moveFromDate = (() => {
if (!resumeMovingFrom) {
return undefined;
}
const next = new Date(`${resumeMovingFrom}T12:00:00Z`);
next.setUTCDate(next.getUTCDate() + 1);
return next.toISOString().split('T')[0]!;
})();
if (shouldRunStep('moving')) {
await whileBounds(resumeMovingFrom, async (from) => {
await moveImportsToProduction(importId, from);
const bounds = await getImportDateBounds(importId, moveFromDate);
if (bounds.min && bounds.max) {
const startDate = bounds.min.split(' ')[0]!;
const endDate = bounds.max.split(' ')[0]!;
const cursor = new Date(`${startDate}T12:00:00Z`);
const end = new Date(`${endDate}T12:00:00Z`);
while (cursor <= end) {
const dateStr = cursor.toISOString().split('T')[0]!;
await moveImportsToProduction(importId, dateStr);
await updateImportStatus(jobLogger, job, importId, {
step: 'moving',
batch: from,
batch: dateStr,
});
// Yield control back to event loop after processing each day
await yieldToEventLoop();
});
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
if (shouldRunStep('backfilling_sessions')) {
await whileBounds(resumeBackfillingSessionsFrom, async (from) => {
await backfillSessionsToProduction(importId, from);
await updateImportStatus(jobLogger, job, importId, {
step: 'backfilling_sessions',
batch: from,
});
jobLogger.info('Move to production complete');
// Yield control back to event loop after processing each day
await yieldToEventLoop();
});
}
await markImportComplete(importId);
// Phase 4: Backfill sessions table
await updateImportStatus(jobLogger, job, importId, {
step: 'completed',
step: 'backfilling_sessions',
batch: 'all sessions',
});
jobLogger.info('Import marked as complete');
await backfillSessionsToProduction(importId);
await yieldToEventLoop();
// Get final progress
const finalProgress = await getImportProgress(importId);
jobLogger.info('Session backfill complete');
jobLogger.info('Import job completed successfully', {
totalEvents: finalProgress.totalEvents,
insertedEvents: finalProgress.insertedEvents,
status: finalProgress.status,
});
// Done
await updateImportStatus(jobLogger, job, importId, { step: 'completed' });
jobLogger.info('Import completed');
return {
success: true,
totalEvents: finalProgress.totalEvents,
processedEvents: finalProgress.insertedEvents,
};
return { success: true };
} catch (error) {
jobLogger.error('Import job failed', { error });
// Mark import as failed
try {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await updateImportStatus(jobLogger, job, importId, {
step: 'failed',
errorMessage: errorMsg,
});
jobLogger.warn('Import marked as failed', { error: errorMsg });
} catch (markError) {
jobLogger.error('Failed to mark import as failed', { error, markError });
}
@@ -320,7 +287,7 @@ export async function importJob(job: Job<ImportQueuePayload>) {
function createProvider(
record: Prisma.ImportGetPayload<{ include: { project: true } }>,
jobLogger: ILogger,
jobLogger: ILogger
) {
const config = record.config;
switch (config.provider) {

View File

@@ -1,18 +0,0 @@
import { GitHub } from 'arctic';
export type { OAuth2Tokens } from 'arctic';
import * as Arctic from 'arctic';
export { Arctic };
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID ?? '',
process.env.GITHUB_CLIENT_SECRET ?? '',
process.env.GITHUB_REDIRECT_URI ?? '',
);
export const google = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GOOGLE_REDIRECT_URI ?? '',
);

View File

@@ -7,7 +7,17 @@ export function setSessionTokenCookie(
expiresAt: Date
): void {
setCookie('session', token, {
maxAge: Math.floor((expiresAt.getTime() - new Date().getTime()) / 1000),
maxAge: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
...COOKIE_OPTIONS,
});
}
export function setLastAuthProviderCookie(
setCookie: ISetCookie,
provider: string
): void {
setCookie('last-auth-provider', provider, {
maxAge: 60 * 60 * 24 * 365,
...COOKIE_OPTIONS,
});
}

View File

@@ -1,6 +1,7 @@
import { GitHub } from 'arctic';
export type { OAuth2Tokens } from 'arctic';
import * as Arctic from 'arctic';
export { Arctic };
@@ -8,11 +9,17 @@ export { Arctic };
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID ?? '',
process.env.GITHUB_CLIENT_SECRET ?? '',
process.env.GITHUB_REDIRECT_URI ?? '',
process.env.GITHUB_REDIRECT_URI ?? ''
);
export const google = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GOOGLE_REDIRECT_URI ?? '',
process.env.GOOGLE_REDIRECT_URI ?? ''
);
export const googleGsc = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GSC_GOOGLE_REDIRECT_URI ?? ''
);

View File

@@ -1,6 +1,5 @@
import { DateTime } from 'luxon';
export { DateTime };
// biome-ignore lint/performance/noBarrelFile: lazy
export { DateTime } from 'luxon';
export function getTime(date: string | number | Date) {
return new Date(date).getTime();

View File

@@ -0,0 +1,85 @@
import fs from 'node:fs';
import path from 'node:path';
import { createTable, runClickhouseMigrationCommands } from '../src/clickhouse/migration';
import { getIsCluster } from './helpers';
export async function up() {
const isClustered = getIsCluster();
const commonMetricColumns = [
'`clicks` UInt32 CODEC(Delta(4), LZ4)',
'`impressions` UInt32 CODEC(Delta(4), LZ4)',
'`ctr` Float32 CODEC(Gorilla, LZ4)',
'`position` Float32 CODEC(Gorilla, LZ4)',
'`synced_at` DateTime DEFAULT now() CODEC(Delta(4), LZ4)',
];
const sqls: string[] = [
// Daily totals — accurate overview numbers
...createTable({
name: 'gsc_daily',
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`date` Date CODEC(Delta(2), LZ4)',
...commonMetricColumns,
],
orderBy: ['project_id', 'date'],
partitionBy: 'toYYYYMM(date)',
engine: 'ReplacingMergeTree(synced_at)',
distributionHash: 'cityHash64(project_id)',
replicatedVersion: '1',
isClustered,
}),
// Per-page breakdown
...createTable({
name: 'gsc_pages_daily',
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`date` Date CODEC(Delta(2), LZ4)',
'`page` String CODEC(ZSTD(3))',
...commonMetricColumns,
],
orderBy: ['project_id', 'date', 'page'],
partitionBy: 'toYYYYMM(date)',
engine: 'ReplacingMergeTree(synced_at)',
distributionHash: 'cityHash64(project_id)',
replicatedVersion: '1',
isClustered,
}),
// Per-query breakdown
...createTable({
name: 'gsc_queries_daily',
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`date` Date CODEC(Delta(2), LZ4)',
'`query` String CODEC(ZSTD(3))',
...commonMetricColumns,
],
orderBy: ['project_id', 'date', 'query'],
partitionBy: 'toYYYYMM(date)',
engine: 'ReplacingMergeTree(synced_at)',
distributionHash: 'cityHash64(project_id)',
replicatedVersion: '1',
isClustered,
}),
];
fs.writeFileSync(
path.join(__filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
)
.join('\n\n---\n\n'),
);
if (!process.argv.includes('--dry')) {
await runClickhouseMigrationCommands(sqls);
}
}

View File

@@ -1,6 +1,5 @@
export * from './src/prisma-client';
export * from './src/clickhouse/client';
export * from './src/clickhouse/csv';
export * from './src/sql-builder';
export * from './src/services/chart.service';
export * from './src/engine';
@@ -32,3 +31,5 @@ export * from './src/services/overview.service';
export * from './src/services/pages.service';
export * from './src/services/insights';
export * from './src/session-context';
export * from './src/gsc';
export * from './src/encryption';

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "public"."gsc_connections" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"projectId" TEXT NOT NULL,
"siteUrl" TEXT NOT NULL DEFAULT '',
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"accessTokenExpiresAt" TIMESTAMP(3),
"lastSyncedAt" TIMESTAMP(3),
"lastSyncStatus" TEXT,
"lastSyncError" TEXT,
"backfillStatus" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gsc_connections_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "gsc_connections_projectId_key" ON "public"."gsc_connections"("projectId");
-- AddForeignKey
ALTER TABLE "public"."gsc_connections" ADD CONSTRAINT "gsc_connections_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -203,6 +203,7 @@ model Project {
notificationRules NotificationRule[]
notifications Notification[]
imports Import[]
gscConnection GscConnection?
// When deleteAt > now(), the project will be deleted
deleteAt DateTime?
@@ -612,6 +613,24 @@ model InsightEvent {
@@map("insight_events")
}
model GscConnection {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @unique
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
siteUrl String @default("")
accessToken String
refreshToken String
accessTokenExpiresAt DateTime?
lastSyncedAt DateTime?
lastSyncStatus String?
lastSyncError String?
backfillStatus String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("gsc_connections")
}
model EmailUnsubscribe {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
email String

View File

@@ -1,11 +1,9 @@
import { Readable } from 'node:stream';
import type { ClickHouseSettings, ResponseJSON } from '@clickhouse/client';
import { ClickHouseLogLevel, createClient } from '@clickhouse/client';
import sqlstring from 'sqlstring';
import type { NodeClickHouseClientConfigOptions } from '@clickhouse/client/dist/config';
import { createLogger } from '@openpanel/logger';
import type { IInterval } from '@openpanel/validation';
import sqlstring from 'sqlstring';
export { createClient };
@@ -60,6 +58,9 @@ export const TABLE_NAMES = {
sessions: 'sessions',
events_imports: 'events_imports',
session_replay_chunks: 'session_replay_chunks',
gsc_daily: 'gsc_daily',
gsc_pages_daily: 'gsc_pages_daily',
gsc_queries_daily: 'gsc_queries_daily',
};
/**
@@ -68,8 +69,11 @@ export const TABLE_NAMES = {
* Non-clustered mode = self-hosted environments
*/
export function isClickhouseClustered(): boolean {
if (process.env.CLICKHOUSE_CLUSTER === 'true' || process.env.CLICKHOUSE_CLUSTER === '1') {
return true
if (
process.env.CLICKHOUSE_CLUSTER === 'true' ||
process.env.CLICKHOUSE_CLUSTER === '1'
) {
return true;
}
return !(
@@ -97,21 +101,21 @@ function getClickhouseSettings(): ClickHouseSettings {
return {
distributed_product_mode: 'allow',
date_time_input_format: 'best_effort',
...(!process.env.CLICKHOUSE_SETTINGS_REMOVE_CONVERT_ANY_JOIN
? {
...(process.env.CLICKHOUSE_SETTINGS_REMOVE_CONVERT_ANY_JOIN
? {}
: {
query_plan_convert_any_join_to_semi_or_anti_join: 0,
}
: {}),
}),
...additionalSettings,
};
}
export const CLICKHOUSE_OPTIONS: NodeClickHouseClientConfigOptions = {
max_open_connections: 30,
request_timeout: 300000,
request_timeout: 300_000,
keep_alive: {
enabled: true,
idle_socket_ttl: 60000,
idle_socket_ttl: 60_000,
},
compression: {
request: true,
@@ -138,7 +142,7 @@ const cleanQuery = (query?: string) =>
export async function withRetry<T>(
operation: () => Promise<T>,
maxRetries = 3,
baseDelay = 500,
baseDelay = 500
): Promise<T> {
let lastError: Error | undefined;
@@ -162,7 +166,7 @@ export async function withRetry<T>(
`Attempt ${attempt + 1}/${maxRetries} failed, retrying in ${delay}ms`,
{
error: error.message,
},
}
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
@@ -213,7 +217,7 @@ export const ch = new Proxy(originalCh, {
export async function chQueryWithMeta<T extends Record<string, any>>(
query: string,
clickhouseSettings?: ClickHouseSettings,
clickhouseSettings?: ClickHouseSettings
): Promise<ResponseJSON<T>> {
const start = Date.now();
const res = await ch.query({
@@ -249,44 +253,16 @@ export async function chQueryWithMeta<T extends Record<string, any>>(
return response;
}
export async function chInsertCSV(tableName: string, rows: string[]) {
try {
const now = performance.now();
// Create a readable stream in binary mode for CSV (similar to EventBuffer)
const csvStream = Readable.from(rows.join('\n'), {
objectMode: false,
});
await ch.insert({
table: tableName,
values: csvStream,
format: 'CSV',
clickhouse_settings: {
format_csv_allow_double_quotes: 1,
format_csv_allow_single_quotes: 0,
},
});
logger.info('CSV Insert successful', {
elapsed: performance.now() - now,
rows: rows.length,
});
} catch (error) {
logger.error('CSV Insert failed:', error);
throw error;
}
}
export async function chQuery<T extends Record<string, any>>(
query: string,
clickhouseSettings?: ClickHouseSettings,
clickhouseSettings?: ClickHouseSettings
): Promise<T[]> {
return (await chQueryWithMeta<T>(query, clickhouseSettings)).data;
}
export function formatClickhouseDate(
date: Date | string,
skipTime = false,
skipTime = false
): string {
if (skipTime) {
return new Date(date).toISOString().split('T')[0]!;
@@ -317,3 +293,8 @@ export function toDate(str: string, interval?: IInterval) {
export function convertClickhouseDateToJs(date: string) {
return new Date(`${date.replace(' ', 'T')}Z`);
}
const ROLLUP_DATE_PREFIX = '1970-01-01';
export function isClickhouseDefaultMinDate(date: string): boolean {
return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31');
}

View File

@@ -1,53 +0,0 @@
// ClickHouse Map(String, String) format in CSV uses single quotes, not JSON double quotes
// Format: '{'key1':'value1','key2':'value2'}'
// Single quotes inside values must be escaped with backslash: \'
// We also need to escape newlines and control characters to prevent CSV parsing issues
const escapeMapValue = (str: string) => {
return str
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/'/g, "\\'") // Escape single quotes
.replace(/\n/g, '\\n') // Escape newlines
.replace(/\r/g, '\\r') // Escape carriage returns
.replace(/\t/g, '\\t') // Escape tabs
.replace(/\0/g, '\\0'); // Escape null bytes
};
export const csvEscapeJson = (
value: Record<string, unknown> | null | undefined,
): string => {
if (value == null) return '';
// Normalize to strings if your column is Map(String,String)
const normalized: Record<string, string> = Object.fromEntries(
Object.entries(value).map(([k, v]) => [
String(k),
v == null ? '' : String(v),
]),
);
// Empty object should return empty Map (without quotes, csvEscapeField will handle if needed)
if (Object.keys(normalized).length === 0) return '{}';
const pairs = Object.entries(normalized)
.map(([k, v]) => `'${escapeMapValue(k)}':'${escapeMapValue(v)}'`)
.join(',');
// Return Map format without outer quotes - csvEscapeField will handle CSV escaping
// This allows csvEscapeField to properly wrap/escape the entire field if it contains newlines/quotes
return csvEscapeField(`{${pairs}}`);
};
// Escape a CSV field - wrap in double quotes if it contains commas, quotes, or newlines
// Double quotes inside must be doubled (""), per CSV standard
export const csvEscapeField = (value: string | number): string => {
const str = String(value);
// If field contains commas, quotes, or newlines, it must be quoted
if (/[,"\n\r]/.test(str)) {
// Escape double quotes by doubling them
const escaped = str.replace(/"/g, '""');
return `"${escaped}"`;
}
return str;
};

View File

@@ -0,0 +1,44 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const ENCODING = 'base64';
function getKey(): Buffer {
const raw = process.env.ENCRYPTION_KEY;
if (!raw) {
throw new Error('ENCRYPTION_KEY environment variable is not set');
}
const buf = Buffer.from(raw, 'hex');
if (buf.length !== 32) {
throw new Error(
'ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'
);
}
return buf;
}
export function encrypt(plaintext: string): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
// Format: base64(iv + tag + ciphertext)
return Buffer.concat([iv, tag, encrypted]).toString(ENCODING);
}
export function decrypt(ciphertext: string): string {
const key = getKey();
const buf = Buffer.from(ciphertext, ENCODING);
const iv = buf.subarray(0, IV_LENGTH);
const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return decipher.update(encrypted) + decipher.final('utf8');
}

554
packages/db/src/gsc.ts Normal file
View File

@@ -0,0 +1,554 @@
import { cacheable } from '@openpanel/redis';
import { originalCh } from './clickhouse/client';
import { decrypt, encrypt } from './encryption';
import { db } from './prisma-client';
export interface GscSite {
siteUrl: string;
permissionLevel: string;
}
async function refreshGscToken(
refreshToken: string
): Promise<{ accessToken: string; expiresAt: Date }> {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID ?? '',
client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '',
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to refresh GSC token: ${text}`);
}
const data = (await res.json()) as {
access_token: string;
expires_in: number;
};
const expiresAt = new Date(Date.now() + data.expires_in * 1000);
return { accessToken: data.access_token, expiresAt };
}
export async function getGscAccessToken(projectId: string): Promise<string> {
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
if (
conn.accessTokenExpiresAt &&
conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000
) {
return decrypt(conn.accessToken);
}
try {
const { accessToken, expiresAt } = await refreshGscToken(
decrypt(conn.refreshToken)
);
await db.gscConnection.update({
where: { projectId },
data: { accessToken: encrypt(accessToken), accessTokenExpiresAt: expiresAt },
});
return accessToken;
} catch (error) {
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncStatus: 'token_expired',
lastSyncError:
error instanceof Error ? error.message : 'Failed to refresh token',
},
});
throw new Error(
'GSC token has expired or been revoked. Please reconnect Google Search Console.'
);
}
}
export async function listGscSites(projectId: string): Promise<GscSite[]> {
const accessToken = await getGscAccessToken(projectId);
const res = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to list GSC sites: ${text}`);
}
const data = (await res.json()) as {
siteEntry?: Array<{ siteUrl: string; permissionLevel: string }>;
};
return data.siteEntry ?? [];
}
interface GscApiRow {
keys: string[];
clicks: number;
impressions: number;
ctr: number;
position: number;
}
interface GscDimensionFilter {
dimension: string;
operator: string;
expression: string;
}
interface GscFilterGroup {
filters: GscDimensionFilter[];
}
async function queryGscSearchAnalytics(
accessToken: string,
siteUrl: string,
startDate: string,
endDate: string,
dimensions: string[],
dimensionFilterGroups?: GscFilterGroup[]
): Promise<GscApiRow[]> {
const encodedSiteUrl = encodeURIComponent(siteUrl);
const url = `https://www.googleapis.com/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`;
const allRows: GscApiRow[] = [];
let startRow = 0;
const rowLimit = 25000;
while (true) {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
startDate,
endDate,
dimensions,
rowLimit,
startRow,
dataState: 'all',
...(dimensionFilterGroups && { dimensionFilterGroups }),
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`GSC query failed for dimensions [${dimensions.join(',')}]: ${text}`);
}
const data = (await res.json()) as { rows?: GscApiRow[] };
const rows = data.rows ?? [];
allRows.push(...rows);
if (rows.length < rowLimit) break;
startRow += rowLimit;
}
return allRows;
}
function formatDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
function nowString(): string {
return new Date().toISOString().replace('T', ' ').replace('Z', '');
}
export async function syncGscData(
projectId: string,
startDate: Date,
endDate: Date
): Promise<void> {
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
if (!conn.siteUrl) {
throw new Error('No GSC site URL configured for this project');
}
const accessToken = await getGscAccessToken(projectId);
const start = formatDate(startDate);
const end = formatDate(endDate);
const syncedAt = nowString();
// 1. Daily totals — authoritative numbers for overview chart
const dailyRows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
start,
end,
['date']
);
if (dailyRows.length > 0) {
await originalCh.insert({
table: 'gsc_daily',
values: dailyRows.map((row) => ({
project_id: projectId,
date: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
synced_at: syncedAt,
})),
format: 'JSONEachRow',
});
}
// 2. Per-page breakdown
const pageRows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
start,
end,
['date', 'page']
);
if (pageRows.length > 0) {
await originalCh.insert({
table: 'gsc_pages_daily',
values: pageRows.map((row) => ({
project_id: projectId,
date: row.keys[0] ?? '',
page: row.keys[1] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
synced_at: syncedAt,
})),
format: 'JSONEachRow',
});
}
// 3. Per-query breakdown
const queryRows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
start,
end,
['date', 'query']
);
if (queryRows.length > 0) {
await originalCh.insert({
table: 'gsc_queries_daily',
values: queryRows.map((row) => ({
project_id: projectId,
date: row.keys[0] ?? '',
query: row.keys[1] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
synced_at: syncedAt,
})),
format: 'JSONEachRow',
});
}
}
export async function getGscOverview(
projectId: string,
startDate: string,
endDate: string,
interval: 'day' | 'week' | 'month' = 'day'
): Promise<
Array<{
date: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>
> {
const dateExpr =
interval === 'month'
? 'toStartOfMonth(date)'
: interval === 'week'
? 'toStartOfWeek(date)'
: 'date';
const result = await originalCh.query({
query: `
SELECT
${dateExpr} as date,
sum(clicks) as clicks,
sum(impressions) as impressions,
avg(ctr) as ctr,
avg(position) as position
FROM gsc_daily
FINAL
WHERE project_id = {projectId: String}
AND date >= {startDate: String}
AND date <= {endDate: String}
GROUP BY date
ORDER BY date ASC
`,
query_params: { projectId, startDate, endDate },
format: 'JSONEachRow',
});
return result.json();
}
export async function getGscPages(
projectId: string,
startDate: string,
endDate: string,
limit = 100
): Promise<
Array<{
page: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>
> {
const result = await originalCh.query({
query: `
SELECT
page,
sum(clicks) as clicks,
sum(impressions) as impressions,
avg(ctr) as ctr,
avg(position) as position
FROM gsc_pages_daily
FINAL
WHERE project_id = {projectId: String}
AND date >= {startDate: String}
AND date <= {endDate: String}
GROUP BY page
ORDER BY clicks DESC
LIMIT {limit: UInt32}
`,
query_params: { projectId, startDate, endDate, limit },
format: 'JSONEachRow',
});
return result.json();
}
export interface GscCannibalizedQuery {
query: string;
totalImpressions: number;
totalClicks: number;
pages: Array<{
page: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>;
}
export const getGscCannibalization = cacheable(
async (
projectId: string,
startDate: string,
endDate: string
): Promise<GscCannibalizedQuery[]> => {
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
const accessToken = await getGscAccessToken(projectId);
const rows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
startDate,
endDate,
['query', 'page']
);
const map = new Map<
string,
{
totalImpressions: number;
totalClicks: number;
pages: GscCannibalizedQuery['pages'];
}
>();
for (const row of rows) {
const query = row.keys[0] ?? '';
// Strip hash fragments — GSC records heading anchors (e.g. /page#section)
// as separate URLs but Google treats them as the same page
let page = row.keys[1] ?? '';
try {
const u = new URL(page);
u.hash = '';
page = u.toString();
} catch {
page = page.split('#')[0] ?? page;
}
const entry = map.get(query) ?? {
totalImpressions: 0,
totalClicks: 0,
pages: [],
};
entry.totalImpressions += row.impressions;
entry.totalClicks += row.clicks;
// Merge into existing page entry if already seen (from a different hash variant)
const existing = entry.pages.find((p) => p.page === page);
if (existing) {
const totalImpressions = existing.impressions + row.impressions;
if (totalImpressions > 0) {
existing.position =
(existing.position * existing.impressions + row.position * row.impressions) / totalImpressions;
}
existing.clicks += row.clicks;
existing.impressions += row.impressions;
existing.ctr =
existing.impressions > 0 ? existing.clicks / existing.impressions : 0;
} else {
entry.pages.push({
page,
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
});
}
map.set(query, entry);
}
return [...map.entries()]
.filter(([, v]) => v.pages.length >= 2 && v.totalImpressions >= 100)
.sort(([, a], [, b]) => b.totalImpressions - a.totalImpressions)
.slice(0, 50)
.map(([query, v]) => ({
query,
totalImpressions: v.totalImpressions,
totalClicks: v.totalClicks,
pages: v.pages.sort((a, b) =>
a.position !== b.position
? a.position - b.position
: b.impressions - a.impressions
),
}));
},
60 * 60 * 4
);
export async function getGscPageDetails(
projectId: string,
page: string,
startDate: string,
endDate: string
): Promise<{
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>;
}> {
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
const accessToken = await getGscAccessToken(projectId);
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'page', operator: 'equals', expression: page }] }];
const [timeseriesRows, queryRows] = await Promise.all([
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['query'], filterGroups),
]);
return {
timeseries: timeseriesRows.map((row) => ({
date: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
queries: queryRows.map((row) => ({
query: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
};
}
export async function getGscQueryDetails(
projectId: string,
query: string,
startDate: string,
endDate: string
): Promise<{
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
pages: Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>;
}> {
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
const accessToken = await getGscAccessToken(projectId);
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'query', operator: 'equals', expression: query }] }];
const [timeseriesRows, pageRows] = await Promise.all([
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['page'], filterGroups),
]);
return {
timeseries: timeseriesRows.map((row) => ({
date: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
pages: pageRows.map((row) => ({
page: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
};
}
export async function getGscQueries(
projectId: string,
startDate: string,
endDate: string,
limit = 100
): Promise<
Array<{
query: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>
> {
const result = await originalCh.query({
query: `
SELECT
query,
sum(clicks) as clicks,
sum(impressions) as impressions,
avg(ctr) as ctr,
avg(position) as position
FROM gsc_queries_daily
FINAL
WHERE project_id = {projectId: String}
AND date >= {startDate: String}
AND date <= {endDate: String}
GROUP BY query
ORDER BY clicks DESC
LIMIT {limit: UInt32}
`,
query_params: { projectId, startDate, endDate, limit },
format: 'JSONEachRow',
});
return result.json();
}

View File

@@ -477,7 +477,7 @@ export async function getEventList(options: GetEventListOptions) {
sb.where.cursor = `created_at < ${sqlstring.escape(formatClickhouseDate(cursor))}`;
}
if (!cursor) {
if (!cursor && !(startDate && endDate)) {
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
}
@@ -628,7 +628,7 @@ export async function getEventList(options: GetEventListOptions) {
}
}
sb.orderBy.created_at = 'created_at DESC';
sb.orderBy.created_at = 'created_at DESC, id ASC';
if (custom) {
custom(sb);
@@ -654,7 +654,6 @@ export async function getEventList(options: GetEventListOptions) {
return data;
}
export const getEventsCountCached = cacheable(getEventsCount, 60 * 10);
export async function getEventsCount({
projectId,
profileId,

View File

@@ -12,6 +12,17 @@ import {
} from './chart.service';
import { onlyReportEvents } from './reports.service';
/** Display label for null/empty breakdown values (e.g. property not set). */
export const EMPTY_BREAKDOWN_LABEL = 'Not set';
function normalizeBreakdownValue(value: unknown): string {
if (value == null || value === '') {
return EMPTY_BREAKDOWN_LABEL;
}
const s = String(value).trim();
return s === '' ? EMPTY_BREAKDOWN_LABEL : s;
}
export class FunnelService {
constructor(private client: typeof ch) {}
@@ -144,20 +155,24 @@ export class FunnelService {
];
}
// Group by breakdown values
// Group by breakdown values (normalize empty/null to "Not set")
const series = funnel.reduce(
(acc, f) => {
if (limit && Object.keys(acc).length >= limit) {
return acc;
}
const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|');
const key = breakdowns
.map((b, index) => normalizeBreakdownValue(f[`b_${index}`]))
.join('|');
if (!acc[key]) {
acc[key] = [];
}
acc[key]!.push({
id: key,
breakdowns: breakdowns.map((b, index) => f[`b_${index}`]),
breakdowns: breakdowns.map((b, index) =>
normalizeBreakdownValue(f[`b_${index}`]),
),
level: f.level,
count: f.count,
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
import { average, sum } from '@openpanel/common';
import { chartColors } from '@openpanel/constants';
import { getCache } from '@openpanel/redis';
import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation';
import { omit } from 'ramda';
import sqlstring from 'sqlstring';
import { z } from 'zod';
import {
TABLE_NAMES,
ch,
convertClickhouseDateToJs,
isClickhouseDefaultMinDate,
TABLE_NAMES,
} from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
import {
@@ -172,24 +171,12 @@ export type IGetMapDataInput = z.infer<typeof zGetMapDataInput> & {
export class OverviewService {
constructor(private client: typeof ch) {}
// Helper methods
private isRollupRow(date: string): boolean {
// The rollup row has date 1970-01-01 00:00:00 (epoch) from ClickHouse.
// After transform with `new Date().toISOString()`, this becomes an ISO string.
// Due to timezone handling in JavaScript's Date constructor (which interprets
// the input as local time), the UTC date might become:
// - 1969-12-31T... for positive UTC offsets (e.g., UTC+8)
// - 1970-01-01T... for UTC or negative offsets
// We check for both year prefixes to handle all server timezones.
return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31');
}
private getFillConfig(interval: string, startDate: string, endDate: string) {
const useDateOnly = ['month', 'week'].includes(interval);
return {
from: clix.toStartOf(
clix.datetime(startDate, useDateOnly ? 'toDate' : 'toDateTime'),
interval as any,
interval as any
),
to: clix.datetime(endDate, useDateOnly ? 'toDate' : 'toDateTime'),
step: clix.toInterval('1', interval as any),
@@ -234,12 +221,12 @@ export class OverviewService {
private mergeRevenueIntoSeries<T extends { date: string }>(
series: T[],
revenueData: { date: string; total_revenue: number }[],
revenueData: { date: string; total_revenue: number }[]
): (T & { total_revenue: number })[] {
const revenueByDate = new Map(
revenueData
.filter((r) => !this.isRollupRow(r.date))
.map((r) => [r.date, r.total_revenue]),
.filter((r) => !isClickhouseDefaultMinDate(r.date))
.map((r) => [r.date, r.total_revenue])
);
return series.map((row) => ({
...row,
@@ -248,10 +235,11 @@ export class OverviewService {
}
private getOverallRevenue(
revenueData: { date: string; total_revenue: number }[],
revenueData: { date: string; total_revenue: number }[]
): number {
return (
revenueData.find((r) => this.isRollupRow(r.date))?.total_revenue ?? 0
revenueData.find((r) => isClickhouseDefaultMinDate(r.date))
?.total_revenue ?? 0
);
}
@@ -263,7 +251,7 @@ export class OverviewService {
startDate: string;
endDate: string;
timezone: string;
},
}
): ReturnType<typeof clix> {
if (!this.isPageFilter(params.filters)) {
query.rawWhere(this.getRawWhereClause('sessions', params.filters));
@@ -276,7 +264,7 @@ export class OverviewService {
.where(
'id',
'IN',
clix.exp('(SELECT session_id FROM distinct_sessions)'),
clix.exp('(SELECT session_id FROM distinct_sessions)')
);
}
@@ -475,14 +463,14 @@ export class OverviewService {
clix(this.client, timezone)
.select(['bounce_rate'])
.from('session_agg')
.where('date', '=', rollupDate),
.where('date', '=', rollupDate)
)
.with(
'daily_session_stats',
clix(this.client, timezone)
.select(['date', 'bounce_rate'])
.from('session_agg')
.where('date', '!=', rollupDate),
.where('date', '!=', rollupDate)
)
.with('overall_unique_visitors', overallUniqueVisitorsQuery)
.select<{
@@ -512,7 +500,7 @@ export class OverviewService {
.from(`${TABLE_NAMES.events} AS e`)
.leftJoin(
'daily_session_stats AS dss',
`${clix.toStartOf('e.created_at', interval as any)} = dss.date`,
`${clix.toStartOf('e.created_at', interval as any)} = dss.date`
)
.where('e.project_id', '=', projectId)
.where('e.name', '=', 'screen_view')
@@ -551,7 +539,7 @@ export class OverviewService {
(item) =>
item.overall_bounce_rate !== null ||
item.overall_total_sessions !== null ||
item.overall_unique_visitors !== null,
item.overall_unique_visitors !== null
);
return {
@@ -560,11 +548,11 @@ export class OverviewService {
unique_visitors: anyRowWithData?.overall_unique_visitors ?? 0,
total_sessions: anyRowWithData?.overall_total_sessions ?? 0,
avg_session_duration: average(
mainRes.map((item) => item.avg_session_duration),
mainRes.map((item) => item.avg_session_duration)
),
total_screen_views: sum(mainRes.map((item) => item.total_screen_views)),
views_per_session: average(
mainRes.map((item) => item.views_per_session),
mainRes.map((item) => item.views_per_session)
),
total_revenue: overallRevenue,
},
@@ -591,7 +579,7 @@ export class OverviewService {
return item;
}
return item;
}),
})
);
return Object.values(where).join(' AND ');
@@ -879,7 +867,7 @@ export class OverviewService {
startDate,
endDate,
timezone,
},
}
);
const timeSeriesData = await mainTimeSeriesQuery.execute();
@@ -1066,7 +1054,7 @@ export class OverviewService {
.from('paths_deduped_cte')
.having('length(paths)', '>=', 2)
// ONLY sessions starting with top entry pages
.having('paths[1]', 'IN', topEntryPages),
.having('paths[1]', 'IN', topEntryPages)
)
.select<{
source: string;
@@ -1081,8 +1069,8 @@ export class OverviewService {
])
.from(
clix.exp(
'(SELECT arrayJoin(arrayMap(i -> (paths[i], paths[i + 1], i), range(1, length(paths)))) as pair FROM session_paths WHERE length(paths) >= 2)',
),
'(SELECT arrayJoin(arrayMap(i -> (paths[i], paths[i + 1], i), range(1, length(paths)))) as pair FROM session_paths WHERE length(paths) >= 2)'
)
)
.groupBy(['source', 'target', 'step'])
.orderBy('step', 'ASC')
@@ -1143,7 +1131,9 @@ export class OverviewService {
for (const t of fromSource) {
// Skip self-loops
if (t.source === t.target) continue;
if (t.source === t.target) {
continue;
}
const targetNodeId = getNodeId(t.target, step + 1);
@@ -1180,7 +1170,9 @@ export class OverviewService {
}
// Stop if no more nodes to process
if (activeNodes.size === 0) break;
if (activeNodes.size === 0) {
break;
}
}
// Step 5: Filter links by threshold (0.25% of total sessions)
@@ -1235,22 +1227,24 @@ export class OverviewService {
})
.sort((a, b) => {
// Sort by step first, then by value descending
if (a.step !== b.step) return a.step - b.step;
if (a.step !== b.step) {
return a.step - b.step;
}
return b.value - a.value;
});
// Sanity check: Ensure all link endpoints exist in nodes
const nodeIds = new Set(finalNodes.map((n) => n.id));
const invalidLinks = filteredLinks.filter(
(link) => !nodeIds.has(link.source) || !nodeIds.has(link.target),
(link) => !(nodeIds.has(link.source) && nodeIds.has(link.target))
);
if (invalidLinks.length > 0) {
console.warn(
`UserJourney: Found ${invalidLinks.length} links with missing nodes`,
`UserJourney: Found ${invalidLinks.length} links with missing nodes`
);
// Remove invalid links
const validLinks = filteredLinks.filter(
(link) => nodeIds.has(link.source) && nodeIds.has(link.target),
(link) => nodeIds.has(link.source) && nodeIds.has(link.target)
);
return {
nodes: finalNodes,
@@ -1260,7 +1254,9 @@ export class OverviewService {
// Sanity check: Ensure steps are monotonic (should always be true, but verify)
const stepsValid = finalNodes.every((node, idx, arr) => {
if (idx === 0) return true;
if (idx === 0) {
return true;
}
return node.step! >= arr[idx - 1]!.step!;
});
if (!stepsValid) {

View File

@@ -1,4 +1,5 @@
import { TABLE_NAMES, ch } from '../clickhouse/client';
import type { IInterval } from '@openpanel/validation';
import { ch, TABLE_NAMES } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
export interface IGetPagesInput {
@@ -7,6 +8,15 @@ export interface IGetPagesInput {
endDate: string;
timezone: string;
search?: string;
limit?: number;
}
export interface IPageTimeseriesRow {
origin: string;
path: string;
date: string;
pageviews: number;
sessions: number;
}
export interface ITopPage {
@@ -28,6 +38,7 @@ export class PagesService {
endDate,
timezone,
search,
limit,
}: IGetPagesInput): Promise<ITopPage[]> {
// CTE: Get titles from the last 30 days for faster retrieval
const titlesCte = clix(this.client, timezone)
@@ -72,7 +83,7 @@ export class PagesService {
.leftJoin(
sessionsSubquery,
'e.session_id = s.id AND e.project_id = s.project_id',
's',
's'
)
.leftJoin('page_titles pt', 'concat(e.origin, e.path) = pt.page_key')
.where('e.project_id', '=', projectId)
@@ -83,14 +94,69 @@ export class PagesService {
clix.datetime(endDate, 'toDateTime'),
])
.when(!!search, (q) => {
q.where('e.path', 'LIKE', `%${search}%`);
const term = `%${search}%`;
q.whereGroup()
.where('e.path', 'LIKE', term)
.orWhere('e.origin', 'LIKE', term)
.orWhere('pt.title', 'LIKE', term)
.end();
})
.groupBy(['e.origin', 'e.path', 'pt.title'])
.orderBy('sessions', 'DESC')
.limit(1000);
.orderBy('sessions', 'DESC');
if (limit !== undefined) {
query.limit(limit);
}
return query.execute();
}
async getPageTimeseries({
projectId,
startDate,
endDate,
timezone,
interval,
filterOrigin,
filterPath,
}: IGetPagesInput & {
interval: IInterval;
filterOrigin?: string;
filterPath?: string;
}): Promise<IPageTimeseriesRow[]> {
const dateExpr = clix.toStartOf('e.created_at', interval, timezone);
const useDateOnly = interval === 'month' || interval === 'week';
const fillFrom = clix.toStartOf(
clix.datetime(startDate, useDateOnly ? 'toDate' : 'toDateTime'),
interval
);
const fillTo = clix.datetime(
endDate,
useDateOnly ? 'toDate' : 'toDateTime'
);
const fillStep = clix.toInterval('1', interval);
return clix(this.client, timezone)
.select<IPageTimeseriesRow>([
'e.origin as origin',
'e.path as path',
`${dateExpr} AS date`,
'count() as pageviews',
'uniq(e.session_id) as sessions',
])
.from(`${TABLE_NAMES.events} e`, false)
.where('e.project_id', '=', projectId)
.where('e.name', '=', 'screen_view')
.where('e.path', '!=', '')
.where('e.created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.when(!!filterOrigin, (q) => q.where('e.origin', '=', filterOrigin!))
.when(!!filterPath, (q) => q.where('e.path', '=', filterPath!))
.groupBy(['e.origin', 'e.path', 'date'])
.orderBy('date', 'ASC')
.fill(fillFrom, fillTo, fillStep)
.execute();
}
}
export const pagesService = new PagesService(ch);

View File

@@ -1,23 +1,21 @@
import { omit, uniq } from 'ramda';
import sqlstring from 'sqlstring';
import { strip, toObject } from '@openpanel/common';
import { cacheable } from '@openpanel/redis';
import type { IChartEventFilter } from '@openpanel/validation';
import { uniq } from 'ramda';
import sqlstring from 'sqlstring';
import { profileBuffer } from '../buffers';
import {
TABLE_NAMES,
ch,
chQuery,
convertClickhouseDateToJs,
formatClickhouseDate,
isClickhouseDefaultMinDate,
TABLE_NAMES,
} from '../clickhouse/client';
import { createSqlBuilder } from '../sql-builder';
export type IProfileMetrics = {
lastSeen: Date;
firstSeen: Date;
export interface IProfileMetrics {
lastSeen: Date | null;
firstSeen: Date | null;
screenViews: number;
sessions: number;
durationAvg: number;
@@ -29,7 +27,7 @@ export type IProfileMetrics = {
conversionEvents: number;
avgTimeBetweenSessions: number;
revenue: number;
};
}
export function getProfileMetrics(profileId: string, projectId: string) {
return chQuery<
Omit<IProfileMetrics, 'lastSeen' | 'firstSeen'> & {
@@ -100,8 +98,12 @@ export function getProfileMetrics(profileId: string, projectId: string) {
.then((data) => {
return {
...data,
lastSeen: convertClickhouseDateToJs(data.lastSeen),
firstSeen: convertClickhouseDateToJs(data.firstSeen),
lastSeen: isClickhouseDefaultMinDate(data.lastSeen)
? null
: convertClickhouseDateToJs(data.lastSeen),
firstSeen: isClickhouseDefaultMinDate(data.firstSeen)
? null
: convertClickhouseDateToJs(data.firstSeen),
};
});
}
@@ -127,7 +129,7 @@ export async function getProfileById(id: string, projectId: string) {
last_value(is_external) as is_external,
last_value(properties) as properties,
last_value(created_at) as created_at
FROM ${TABLE_NAMES.profiles} FINAL WHERE id = ${sqlstring.escape(String(id))} AND project_id = ${sqlstring.escape(projectId)} GROUP BY id, project_id ORDER BY created_at DESC LIMIT 1`,
FROM ${TABLE_NAMES.profiles} FINAL WHERE id = ${sqlstring.escape(String(id))} AND project_id = ${sqlstring.escape(projectId)} GROUP BY id, project_id ORDER BY created_at DESC LIMIT 1`
);
if (!profile) {
@@ -169,7 +171,7 @@ export async function getProfiles(ids: string[], projectId: string) {
project_id = ${sqlstring.escape(projectId)} AND
id IN (${filteredIds.map((id) => sqlstring.escape(id)).join(',')})
GROUP BY id, project_id
`,
`
);
return data.map(transformProfile);
@@ -221,7 +223,7 @@ export async function getProfileListCount({
return data[0]?.count ?? 0;
}
export type IServiceProfile = {
export interface IServiceProfile {
id: string;
email: string;
avatar: string;
@@ -245,7 +247,7 @@ export type IServiceProfile = {
model?: string;
referrer?: string;
};
};
}
export interface IClickhouseProfile {
id: string;
@@ -289,7 +291,7 @@ export function transformProfile({
};
}
export async function upsertProfile(
export function upsertProfile(
{
id,
firstName,
@@ -300,7 +302,7 @@ export async function upsertProfile(
projectId,
isExternal,
}: IServiceUpsertProfile,
isFromEvent = false,
isFromEvent = false
) {
const profile: IClickhouseProfile = {
id,

View File

@@ -39,7 +39,7 @@ describe('mixpanel', () => {
const rawEvent = {
event: '$mp_web_page_view',
properties: {
time: 1746097970,
time: 1_746_097_970,
distinct_id: '$device:123',
$browser: 'Chrome',
$browser_version: 135,
@@ -53,7 +53,7 @@ describe('mixpanel', () => {
$insert_id: 'source_id',
$lib_version: '2.60.0',
$mp_api_endpoint: 'api-js.mixpanel.com',
$mp_api_timestamp_ms: 1746078175363,
$mp_api_timestamp_ms: 1_746_078_175_363,
$mp_autocapture: true,
$os: 'Android',
$referrer: 'https://google.com/',
@@ -71,7 +71,7 @@ describe('mixpanel', () => {
gclid: 'oqneoqow',
mp_country_code: 'IN',
mp_lib: 'web',
mp_processing_time_ms: 1746078175546,
mp_processing_time_ms: 1_746_078_175_546,
mp_sent_by_lib_version: '2.60.0',
utm_medium: 'cpc',
utm_source: 'google',
@@ -101,7 +101,7 @@ describe('mixpanel', () => {
__title:
'Landeed: Satbara Utara, 7/12 Extract, Property Card & Index 2',
},
created_at: '2025-05-01T11:12:50.000Z',
created_at: '2025-05-01 11:12:50',
country: 'IN',
city: 'Mumbai',
region: 'Maharashtra',
@@ -110,7 +110,7 @@ describe('mixpanel', () => {
os: 'Android',
os_version: undefined,
browser: 'Chrome',
browser_version: '',
browser_version: '135',
device: 'mobile',
brand: '',
model: '',
@@ -141,7 +141,7 @@ describe('mixpanel', () => {
const rawEvent = {
event: 'custom_event',
properties: {
time: 1746097970,
time: 1_746_097_970,
distinct_id: '$device:123',
$device_id: '123',
$user_id: 'user123',
@@ -192,7 +192,7 @@ describe('mixpanel', () => {
const rawEvent = {
event: 'ec_search_error',
properties: {
time: 1759947367,
time: 1_759_947_367,
distinct_id: '3385916',
$browser: 'Mobile Safari',
$browser_version: null,
@@ -207,7 +207,7 @@ describe('mixpanel', () => {
$insert_id: 'bclkaepeqcfuzt4v',
$lib_version: '2.60.0',
$mp_api_endpoint: 'api-js.mixpanel.com',
$mp_api_timestamp_ms: 1759927570699,
$mp_api_timestamp_ms: 1_759_927_570_699,
$os: 'iOS',
$region: 'Karnataka',
$screen_height: 852,
@@ -225,7 +225,7 @@ describe('mixpanel', () => {
language: 'english',
mp_country_code: 'IN',
mp_lib: 'web',
mp_processing_time_ms: 1759927592421,
mp_processing_time_ms: 1_759_927_592_421,
mp_sent_by_lib_version: '2.60.0',
os: 'web',
osVersion:
@@ -249,15 +249,15 @@ describe('mixpanel', () => {
expect(res.id.length).toBeGreaterThan(30);
expect(res.imported_at).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
);
expect(omit(['id', 'imported_at'], res)).toEqual({
brand: 'Apple',
browser: 'GSA',
browser_version: 'null',
browser_version: '388.0.811331708',
city: 'Bengaluru',
country: 'IN',
created_at: '2025-10-08T18:16:07.000Z',
created_at: '2025-10-08 18:16:07',
device: 'mobile',
device_id: '199b498af1036c-0e943279a1292e-5c0f4368-51bf4-199b498af1036c',
duration: 0,

View File

@@ -1,8 +1,13 @@
import { randomUUID } from 'node:crypto';
import { isSameDomain, parsePath, toDots } from '@openpanel/common';
import { type UserAgentInfo, parseUserAgent } from '@openpanel/common/server';
import { getReferrerWithQuery, parseReferrer } from '@openpanel/common/server';
import type { IClickhouseEvent } from '@openpanel/db';
import {
getReferrerWithQuery,
parseReferrer,
parseUserAgent,
type UserAgentInfo,
} from '@openpanel/common/server';
import { formatClickhouseDate, type IClickhouseEvent } from '@openpanel/db';
import type { IClickhouseProfile } from '@openpanel/db';
import type { ILogger } from '@openpanel/logger';
import type { IMixpanelImportConfig } from '@openpanel/validation';
import { z } from 'zod';
@@ -15,22 +20,88 @@ export const zMixpanelRawEvent = z.object({
export type MixpanelRawEvent = z.infer<typeof zMixpanelRawEvent>;
/** Engage API profile: https://docs.mixpanel.com/docs/export-methods#exporting-profiles */
export const zMixpanelRawProfile = z.object({
$distinct_id: z.union([z.string(), z.number()]),
$properties: z.record(z.unknown()).optional().default({}),
});
export type MixpanelRawProfile = z.infer<typeof zMixpanelRawProfile>;
class MixpanelRateLimitError extends Error {
readonly retryAfterMs?: number;
constructor(message: string, retryAfterMs?: number) {
super(message);
this.name = 'MixpanelRateLimitError';
this.retryAfterMs = retryAfterMs;
}
}
export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
provider = 'mixpanel';
version = '1.0.0';
private static readonly MAX_REQUESTS_PER_HOUR = 100;
private static readonly MIN_REQUEST_INTERVAL_MS = 334; // 3 QPS limit
private requestTimestamps: number[] = [];
private lastRequestTime = 0;
constructor(
private readonly projectId: string,
private readonly config: IMixpanelImportConfig,
private readonly logger?: ILogger,
private readonly logger?: ILogger
) {
super();
}
async getTotalEventsCount(): Promise<number> {
private async waitForRateLimit(): Promise<void> {
const now = Date.now();
const oneHourAgo = now - 60 * 60 * 1000;
// Prune timestamps older than 1 hour
this.requestTimestamps = this.requestTimestamps.filter(
(t) => t > oneHourAgo
);
// Enforce per-second limit (3 QPS → min 334ms gap)
const timeSinceLast = now - this.lastRequestTime;
if (timeSinceLast < MixpanelProvider.MIN_REQUEST_INTERVAL_MS) {
const delay = MixpanelProvider.MIN_REQUEST_INTERVAL_MS - timeSinceLast;
await new Promise((resolve) => setTimeout(resolve, delay));
}
// Enforce hourly limit
if (
this.requestTimestamps.length >= MixpanelProvider.MAX_REQUESTS_PER_HOUR
) {
const oldestInWindow = this.requestTimestamps[0]!;
const waitUntil = oldestInWindow + 60 * 60 * 1000;
const waitMs = waitUntil - Date.now() + 1000; // +1s buffer
if (waitMs > 0) {
this.logger?.info(
`Rate limit: ${this.requestTimestamps.length} requests in the last hour, waiting ${Math.ceil(waitMs / 1000)}s`,
{
requestsInWindow: this.requestTimestamps.length,
waitMs,
}
);
await new Promise((resolve) => setTimeout(resolve, waitMs));
// Prune again after waiting
this.requestTimestamps = this.requestTimestamps.filter(
(t) => t > Date.now() - 60 * 60 * 1000
);
}
}
this.lastRequestTime = Date.now();
this.requestTimestamps.push(Date.now());
}
getTotalEventsCount(): Promise<number> {
// Mixpanel sucks and dont provide a good way to extract total event count within a period
// jql would work but not accurate and will be deprecated end of 2025
return -1;
return Promise.resolve(-1);
}
/**
@@ -42,13 +113,13 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
}
async *parseSource(
overrideFrom?: string,
overrideFrom?: string
): AsyncGenerator<MixpanelRawEvent, void, unknown> {
yield* this.fetchEventsFromMixpanel(overrideFrom);
}
private async *fetchEventsFromMixpanel(
overrideFrom?: string,
overrideFrom?: string
): AsyncGenerator<MixpanelRawEvent, void, unknown> {
const { serviceAccount, serviceSecret, projectId, from, to } = this.config;
@@ -58,20 +129,24 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
for (const [chunkFrom, chunkTo] of dateChunks) {
let retries = 0;
const maxRetries = 3;
const maxRetries = 6;
while (retries <= maxRetries) {
try {
await this.waitForRateLimit();
yield* this.fetchEventsForDateRange(
serviceAccount,
serviceSecret,
projectId,
chunkFrom,
chunkTo,
chunkTo
);
break; // Success, move to next chunk
} catch (error) {
retries++;
const isRateLimit =
error instanceof MixpanelRateLimitError ||
(error instanceof Error && error.message.includes('429'));
const isLastRetry = retries > maxRetries;
this.logger?.warn('Failed to fetch events for date range', {
@@ -80,22 +155,31 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
attempt: retries,
maxRetries,
error: (error as Error).message,
isRateLimit,
willRetry: !isLastRetry,
});
if (isLastRetry) {
// Final attempt failed, re-throw
throw new Error(
`Failed to fetch Mixpanel events for ${chunkFrom} to ${chunkTo} after ${maxRetries} retries: ${(error as Error).message}`,
`Failed to fetch Mixpanel events for ${chunkFrom} to ${chunkTo} after ${maxRetries} retries: ${(error as Error).message}`
);
}
// Exponential backoff: wait before retrying
const delay = Math.min(1000 * 2 ** (retries - 1), 60_000); // Cap at 1 minute
let delay: number;
if (error instanceof MixpanelRateLimitError && error.retryAfterMs) {
delay = error.retryAfterMs;
} else if (isRateLimit) {
// 5min → 10min → 15min → 15min → 15min = 60min total
delay = Math.min(300_000 * 2 ** (retries - 1), 900_000);
} else {
delay = Math.min(1000 * 2 ** (retries - 1), 60_000);
}
this.logger?.info('Retrying after delay', {
delayMs: delay,
chunkFrom,
chunkTo,
isRateLimit,
});
await new Promise((resolve) => setTimeout(resolve, delay));
}
@@ -108,7 +192,7 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
serviceSecret: string,
projectId: string,
from: string,
to: string,
to: string
): AsyncGenerator<MixpanelRawEvent, void, unknown> {
const url = 'https://data.mixpanel.com/api/2.0/export';
@@ -134,9 +218,18 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
},
});
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const retryAfterMs = retryAfter ? Number(retryAfter) * 1000 : undefined;
throw new MixpanelRateLimitError(
'Mixpanel rate limit exceeded (429)',
retryAfterMs
);
}
if (!response.ok) {
throw new Error(
`Failed to fetch events from Mixpanel: ${response.status} ${response.statusText}`,
`Failed to fetch events from Mixpanel: ${response.status} ${response.statusText}`
);
}
@@ -153,7 +246,9 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
@@ -187,7 +282,7 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
{
line: buffer.substring(0, 100),
error,
},
}
);
}
}
@@ -196,6 +291,114 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
}
}
/**
* Stream user profiles from Mixpanel Engage API.
* Paginates with page/page_size (5k per page) and yields each profile.
*/
async *streamProfiles(): AsyncGenerator<MixpanelRawProfile, void, unknown> {
const { serviceAccount, serviceSecret, projectId } = this.config;
const pageSize = 5000;
let page = 0;
while (true) {
await this.waitForRateLimit();
const url = `https://mixpanel.com/api/query/engage?project_id=${encodeURIComponent(projectId)}`;
const body = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
this.logger?.info('Fetching profiles from Mixpanel Engage', {
page,
page_size: pageSize,
projectId,
});
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${serviceAccount}:${serviceSecret}`).toString('base64')}`,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString(),
});
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const retryAfterMs = retryAfter ? Number(retryAfter) * 1000 : undefined;
throw new MixpanelRateLimitError(
'Mixpanel rate limit exceeded (429)',
retryAfterMs
);
}
if (!response.ok) {
const text = await response.text();
throw new Error(
`Failed to fetch profiles from Mixpanel: ${response.status} ${response.statusText} - ${text}`
);
}
const data = (await response.json()) as {
results?: Array<{ $distinct_id: string | number; $properties?: Record<string, unknown> }>;
page?: number;
total?: number;
};
const results = data.results ?? [];
for (const row of results) {
const parsed = zMixpanelRawProfile.safeParse(row);
if (parsed.success) {
yield parsed.data;
} else {
this.logger?.warn('Skipping invalid Mixpanel profile', {
row: JSON.stringify(row).slice(0, 200),
});
}
}
if (results.length < pageSize) {
break;
}
page++;
}
}
/**
* Map Mixpanel Engage profile to OpenPanel IClickhouseProfile.
*/
transformProfile(raw: MixpanelRawProfile): IClickhouseProfile {
const parsed = zMixpanelRawProfile.parse(raw);
const props = (parsed.$properties || {}) as Record<string, unknown>;
const id = String(parsed.$distinct_id).replace(/^\$device:/, '');
const createdAt = props.$created
? formatClickhouseDate(new Date(String(props.$created)))
: formatClickhouseDate(new Date());
const properties: Record<string, string> = {};
const stripPrefix = /^\$/;
for (const [key, value] of Object.entries(props)) {
if (stripPrefix.test(key)) continue;
if (value == null) continue;
properties[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
}
return {
id,
project_id: this.projectId,
first_name: String(props.$first_name ?? ''),
last_name: String(props.$last_name ?? ''),
email: String(props.$email ?? ''),
avatar: String(props.$avatar ?? props.$image ?? ''),
properties,
created_at: createdAt,
is_external: true,
};
}
validate(rawEvent: MixpanelRawEvent): boolean {
const res = zMixpanelRawEvent.safeParse(rawEvent);
return res.success;
@@ -208,7 +411,7 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
const deviceId = props.$device_id;
const profileId = String(props.$user_id || props.distinct_id).replace(
/^\$device:/,
'',
''
);
// Build full URL from current_url and current_url_search (web only)
@@ -309,7 +512,7 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
project_id: projectId,
session_id: '', // Will be generated in SQL after import
properties: toDots(properties), // Flatten nested objects/arrays to Map(String, String)
created_at: new Date(props.time * 1000).toISOString(),
created_at: formatClickhouseDate(new Date(props.time * 1000)),
country,
city,
region,
@@ -318,10 +521,7 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
os: uaInfo.os || props.$os,
os_version: uaInfo.osVersion || props.$osVersion,
browser: uaInfo.browser || props.$browser,
browser_version:
uaInfo.browserVersion || props.$browserVersion
? String(props.$browser_version)
: '',
browser_version: uaInfo.browserVersion || String(props.$browser_version ?? ''),
device: this.getDeviceType(props.mp_lib, uaInfo, props),
brand: uaInfo.brand || '',
model: uaInfo.model || '',
@@ -338,14 +538,6 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
sdk_version: this.version,
};
// TODO: Remove this
// Temporary fix for a client
const isMightBeScreenView = this.getMightBeScreenView(rawEvent);
if (isMightBeScreenView && event.name === 'Loaded a Screen') {
event.name = 'screen_view';
event.path = isMightBeScreenView;
}
// TODO: Remove this
// This is a hack to get utm tags (not sure if this is just the testing project or all mixpanel projects)
if (props.utm_source && !properties.__query?.utm_source) {
@@ -371,13 +563,13 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
private getDeviceType(
mp_lib: string,
uaInfo: UserAgentInfo,
props: Record<string, any>,
props: Record<string, any>
) {
// Normalize lib/os/browser data
const lib = (mp_lib || '').toLowerCase();
const os = String(props.$os || uaInfo.os || '').toLowerCase();
const browser = String(
props.$browser || uaInfo.browser || '',
props.$browser || uaInfo.browser || ''
).toLowerCase();
const isTabletOs = os === 'ipados' || os === 'ipad os' || os === 'ipad';
@@ -431,11 +623,6 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
return !this.isWebEvent(mp_lib);
}
private getMightBeScreenView(rawEvent: MixpanelRawEvent) {
const props = rawEvent.properties as Record<string, any>;
return Object.keys(props).find((key) => key.match(/^[A-Z1-9_]+$/));
}
private parseServerDeviceInfo(props: Record<string, any>): UserAgentInfo {
// For mobile events, extract device information from Mixpanel properties
const os = props.$os || props.os || '';
@@ -446,19 +633,19 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
return {
isServer: true,
os: os,
osVersion: osVersion,
os,
osVersion,
browser: '',
browserVersion: '',
device: device,
brand: brand,
model: model,
device,
brand,
model,
};
}
private stripMixpanelProperties(
properties: Record<string, any>,
searchParams: Record<string, string>,
searchParams: Record<string, string>
): Record<string, any> {
const strip = [
'time',
@@ -472,8 +659,8 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
];
const filtered = Object.fromEntries(
Object.entries(properties).filter(
([key]) => !key.match(/^(\$|mp_|utm_)/) && !strip.includes(key),
),
([key]) => !(key.match(/^(\$|mp_|utm_)/) || strip.includes(key))
)
);
// Parse JSON strings back to objects/arrays so toDots() can flatten them

View File

@@ -2,10 +2,13 @@ import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createBrotliDecompress, createGunzip } from 'node:zlib';
import { isSameDomain, parsePath } from '@openpanel/common';
import { generateDeviceId } from '@openpanel/common/server';
import { getReferrerWithQuery, parseReferrer } from '@openpanel/common/server';
import type { IClickhouseEvent } from '@openpanel/db';
import { isSameDomain, parsePath, toDots } from '@openpanel/common';
import {
generateDeviceId,
getReferrerWithQuery,
parseReferrer,
} from '@openpanel/common/server';
import { formatClickhouseDate, type IClickhouseEvent } from '@openpanel/db';
import type { ILogger } from '@openpanel/logger';
import type { IUmamiImportConfig } from '@openpanel/validation';
import { parse } from 'csv-parse';
@@ -63,7 +66,7 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
constructor(
private readonly projectId: string,
private readonly config: IUmamiImportConfig,
private readonly logger?: ILogger,
private readonly logger?: ILogger
) {
super();
}
@@ -82,7 +85,7 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
signal?: AbortSignal;
maxBytes?: number;
maxRows?: number;
} = {},
} = {}
): AsyncGenerator<UmamiRawEvent, void, unknown> {
const { signal, maxBytes, maxRows } = opts;
const controller = new AbortController();
@@ -95,9 +98,9 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
}
const res = await fetch(url, { signal: controller.signal });
if (!res.ok || !res.body) {
if (!(res.ok && res.body)) {
throw new Error(
`Failed to fetch remote file: ${res.status} ${res.statusText}`,
`Failed to fetch remote file: ${res.status} ${res.statusText}`
);
}
@@ -108,15 +111,15 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
if (
contentType &&
!/text\/csv|text\/plain|application\/gzip|application\/octet-stream/i.test(
contentType,
contentType
)
) {
console.warn(`Warning: Content-Type is ${contentType}, expected CSV-ish`);
this.logger?.warn(`Warning: Content-Type is ${contentType}, expected CSV-ish`);
}
if (maxBytes && contentLen && contentLen > maxBytes) {
throw new Error(
`Remote file exceeds size limit (${contentLen} > ${maxBytes})`,
`Remote file exceeds size limit (${contentLen} > ${maxBytes})`
);
}
@@ -137,9 +140,7 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
if (seenBytes > maxBytes) {
controller.abort();
body.destroy(
new Error(
`Stream exceeded size limit (${seenBytes} > ${maxBytes})`,
),
new Error(`Stream exceeded size limit (${seenBytes} > ${maxBytes})`)
);
}
});
@@ -190,7 +191,7 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
throw new Error(
`Failed to parse remote file from ${url}: ${
err instanceof Error ? err.message : String(err)
}`,
}`
);
} finally {
controller.abort(); // ensure fetch stream is torn down
@@ -205,7 +206,7 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
transformEvent(_rawEvent: UmamiRawEvent): IClickhouseEvent {
const projectId =
this.config.projectMapper.find(
(mapper) => mapper.from === _rawEvent.website_id,
(mapper) => mapper.from === _rawEvent.website_id
)?.to || this.projectId;
const rawEvent = zUmamiRawEvent.parse(_rawEvent);
@@ -261,39 +262,50 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
}
// Add useful properties from Umami data
if (rawEvent.page_title) properties.__title = rawEvent.page_title;
if (rawEvent.screen) properties.__screen = rawEvent.screen;
if (rawEvent.language) properties.__language = rawEvent.language;
if (rawEvent.utm_source)
if (rawEvent.page_title) {
properties.__title = rawEvent.page_title;
}
if (rawEvent.screen) {
properties.__screen = rawEvent.screen;
}
if (rawEvent.language) {
properties.__language = rawEvent.language;
}
if (rawEvent.utm_source) {
properties = assocPath(
['__query', 'utm_source'],
rawEvent.utm_source,
properties,
properties
);
if (rawEvent.utm_medium)
}
if (rawEvent.utm_medium) {
properties = assocPath(
['__query', 'utm_medium'],
rawEvent.utm_medium,
properties,
properties
);
if (rawEvent.utm_campaign)
}
if (rawEvent.utm_campaign) {
properties = assocPath(
['__query', 'utm_campaign'],
rawEvent.utm_campaign,
properties,
properties
);
if (rawEvent.utm_content)
}
if (rawEvent.utm_content) {
properties = assocPath(
['__query', 'utm_content'],
rawEvent.utm_content,
properties,
properties
);
if (rawEvent.utm_term)
}
if (rawEvent.utm_term) {
properties = assocPath(
['__query', 'utm_term'],
rawEvent.utm_term,
properties,
properties
);
}
return {
id: rawEvent.event_id || randomUUID(),
@@ -302,8 +314,8 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
profile_id: profileId,
project_id: projectId,
session_id: rawEvent.session_id || '',
properties,
created_at: rawEvent.created_at.toISOString(),
properties: toDots(properties),
created_at: formatClickhouseDate(rawEvent.created_at),
country,
city,
region: this.mapRegion(region),
@@ -329,7 +341,7 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
}
mapRegion(region: string): string {
return region.replace(/^[A-Z]{2}\-/, '');
return region.replace(/^[A-Z]{2}-/, '');
}
mapDevice(device: string): string {

View File

@@ -0,0 +1,246 @@
import { db } from '@openpanel/db';
import { Polar } from '@polar-sh/sdk';
import inquirer from 'inquirer';
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
import { getSuccessUrl } from '..';
// Register the autocomplete prompt
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
interface Answers {
organizationId: string;
userId: string;
productId: string;
}
async function promptForInput(polar: Polar) {
// Get all organizations first
const organizations = await db.organization.findMany({
select: {
id: true,
name: true,
},
});
// Fetch all products from Polar
let products: any[] = [];
try {
const productsResponse = await polar.products.list({
limit: 100,
isArchived: false,
sorting: ['price_amount'],
});
products = productsResponse.result.items;
if (products.length === 0) {
console.warn('Warning: No products found in Polar');
}
} catch (error) {
console.error('Error fetching products from Polar:', error);
throw new Error('Failed to fetch products. Please check your API key and try again.');
}
const answers = await inquirer.prompt<Answers>([
{
type: 'autocomplete',
name: 'productId',
message: 'Select product:',
source: (answersSoFar: any, input = '') => {
return products
.filter(
(product) =>
product.name.toLowerCase().includes(input.toLowerCase()) ||
product.id.toLowerCase().includes(input.toLowerCase()),
)
.map((product) => {
const price = product.prices[0];
const priceDisplay = price
? `$${(price.priceAmount / 100).toFixed(2)}`
: 'Free';
return {
name: `${product.name} - ${priceDisplay} (${product.id})`,
value: product.id,
};
});
},
},
{
type: 'autocomplete',
name: 'organizationId',
message: 'Select organization:',
source: (answersSoFar: any, input = '') => {
return organizations
.filter(
(org) =>
org.name.toLowerCase().includes(input.toLowerCase()) ||
org.id.toLowerCase().includes(input.toLowerCase()),
)
.map((org) => ({
name: `${org.name} (${org.id})`,
value: org.id,
}));
},
},
{
type: 'autocomplete',
name: 'userId',
message: 'Select user:',
source: async (answersSoFar: Answers, input = '') => {
if (!answersSoFar.organizationId) {
return [];
}
try {
const org = await db.organization.findFirst({
where: {
id: answersSoFar.organizationId,
},
include: {
members: {
select: {
role: true,
user: true,
},
},
},
});
if (!org || !org.members || org.members.length === 0) {
return [{ name: 'No members found', value: '', disabled: true }];
}
return org.members
.filter(
(member) =>
member.user?.email
.toLowerCase()
.includes(input.toLowerCase()) ||
member.user?.firstName
?.toLowerCase()
.includes(input.toLowerCase()),
)
.map((member) => ({
name: `${
[member.user?.firstName, member.user?.lastName]
.filter(Boolean)
.join(' ') || 'No name'
} (${member.user?.email}) [${member.role}]`,
value: member.user?.id,
}));
} catch (error) {
console.error('Error fetching organization members:', error);
return [{ name: 'Error loading members', value: '', disabled: true }];
}
},
},
]);
return answers;
}
async function main() {
try {
console.log('Creating checkout link...');
// First, get environment and API key to initialize Polar client
const { isProduction, polarApiKey } = await inquirer.prompt([
{
type: 'list',
name: 'isProduction',
message: 'Is this for production?',
choices: [
{ name: 'Yes', value: true },
{ name: 'No', value: false },
],
default: true,
},
{
type: 'string',
name: 'polarApiKey',
message: 'Enter your Polar API key:',
validate: (input: string) => {
if (!input) return 'API key is required';
return true;
},
},
]);
const polar = new Polar({
accessToken: polarApiKey!,
server: isProduction ? 'production' : 'sandbox',
});
const input = await promptForInput(polar);
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
select: {
id: true,
name: true,
},
});
const user = await db.user.findUniqueOrThrow({
where: {
id: input.userId,
},
});
const product = await polar.products.get({ id: input.productId });
console.log('\nReview the following settings:');
console.table({
environment: isProduction ? 'Production' : 'Sandbox',
organization: organization.name,
product: product.name,
productId: product.id,
email: user.email,
name:
[user.firstName, user.lastName].filter(Boolean).join(' ') || 'No name',
});
const { confirmed } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmed',
message: 'Do you want to proceed?',
default: false,
},
]);
if (!confirmed) {
console.log('Operation canceled');
return;
}
const checkout = await polar.checkouts.create({
products: [input.productId],
successUrl: getSuccessUrl(
isProduction
? 'https://dashboard.openpanel.dev'
: 'http://localhost:3000',
organization.id,
),
customerEmail: user.email,
customerName: [user.firstName, user.lastName].filter(Boolean).join(' '),
metadata: {
organizationId: organization.id,
userId: user.id,
},
});
console.log('\nCheckout link created successfully!');
console.table({
url: checkout.url,
id: checkout.id,
});
} catch (error) {
console.error('Error creating checkout link:', error);
throw error;
}
}
main()
.catch(console.error)
.finally(() => db.$disconnect());

View File

@@ -126,6 +126,10 @@ export type CronQueuePayloadFlushReplay = {
type: 'flushReplay';
payload: undefined;
};
export type CronQueuePayloadGscSync = {
type: 'gscSync';
payload: undefined;
};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
@@ -136,7 +140,8 @@ export type CronQueuePayload =
| CronQueuePayloadPing
| CronQueuePayloadProject
| CronQueuePayloadInsightsDaily
| CronQueuePayloadOnboarding;
| CronQueuePayloadOnboarding
| CronQueuePayloadGscSync;
export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon';
@@ -268,3 +273,21 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
},
}
);
export type GscQueuePayloadSync = {
type: 'gscProjectSync';
payload: { projectId: string };
};
export type GscQueuePayloadBackfill = {
type: 'gscProjectBackfill';
payload: { projectId: string };
};
export type GscQueuePayload = GscQueuePayloadSync | GscQueuePayloadBackfill;
export const gscQueue = new Queue<GscQueuePayload>(getQueueName('gsc'), {
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 50,
removeOnFail: 100,
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "@openpanel/astro",
"version": "1.1.0-local",
"version": "1.2.0-local",
"config": {
"transformPackageJson": false,
"transformEnvs": true,
@@ -20,7 +20,7 @@
"astro-component"
],
"dependencies": {
"@openpanel/web": "workspace:1.1.0-local"
"@openpanel/web": "workspace:1.2.0-local"
},
"devDependencies": {
"astro": "^5.7.7"

View File

@@ -35,7 +35,7 @@ const methods: { name: OpenPanelMethodNames; value: unknown }[] = [
value: {
...options,
sdk: 'astro',
sdkVersion: '1.1.0',
sdkVersion: '1.2.0',
},
},
];

View File

@@ -1,6 +1,6 @@
{
"name": "@openpanel/express",
"version": "1.1.0-local",
"version": "1.2.0-local",
"module": "index.ts",
"config": {
"docPath": "apps/public/content/docs/(tracking)/sdks/express.mdx"
@@ -10,7 +10,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/sdk": "workspace:1.1.0-local",
"@openpanel/sdk": "workspace:1.2.0-local",
"@openpanel/common": "workspace:*"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openpanel/nextjs",
"version": "1.2.0-local",
"version": "1.3.0-local",
"module": "index.ts",
"config": {
"docPath": "apps/public/content/docs/(tracking)/sdks/nextjs.mdx"
@@ -10,7 +10,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/web": "workspace:1.1.0-local"
"@openpanel/web": "workspace:1.2.0-local"
},
"peerDependencies": {
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openpanel/nuxt",
"version": "0.1.0-local",
"version": "0.2.0-local",
"type": "module",
"main": "./dist/module.mjs",
"exports": {
@@ -24,7 +24,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/web": "workspace:1.1.0-local"
"@openpanel/web": "workspace:1.2.0-local"
},
"peerDependencies": {
"h3": "^1.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openpanel/react-native",
"version": "1.1.0-local",
"version": "1.2.0-local",
"module": "index.ts",
"config": {
"docPath": "apps/public/content/docs/(tracking)/sdks/react-native.mdx"
@@ -10,7 +10,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/sdk": "workspace:1.1.0-local"
"@openpanel/sdk": "workspace:1.2.0-local"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",

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