Compare commits
25 Commits
self-hosti
...
feature/gs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a96e7b038 | ||
|
|
fc256124b5 | ||
|
|
df0258f532 | ||
|
|
0f9e5f6e93 | ||
|
|
c9cf7901ad | ||
|
|
2981638893 | ||
|
|
70ca44f039 | ||
|
|
00f6cd6f50 | ||
|
|
227d629dc5 | ||
|
|
f2e19093f0 | ||
|
|
7f85b2ac0a | ||
|
|
38965387da | ||
|
|
74bcb7ead2 | ||
|
|
2377f95b86 | ||
|
|
de6ca96628 | ||
|
|
9e46099246 | ||
|
|
83761638f2 | ||
|
|
885f7225db | ||
|
|
553e4cf675 | ||
|
|
f2c414b4b4 | ||
|
|
043730444a | ||
|
|
8c377c2066 | ||
|
|
647ac2a4af | ||
|
|
6251d143d1 | ||
|
|
b801d6a8ef |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal file
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal 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());
|
||||
}
|
||||
@@ -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!
|
||||
);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
12
apps/api/src/routes/gsc-callback.router.ts
Normal file
12
apps/api/src/routes/gsc-callback.router.ts
Normal 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;
|
||||
76
apps/public/content/docs/(tracking)/consent-management.mdx
Normal file
76
apps/public/content/docs/(tracking)/consent-management.mdx
Normal 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
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["sdks", "how-it-works", "session-replay", "..."]
|
||||
"pages": ["sdks", "how-it-works", "session-replay", "consent-management", "..."]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
233
apps/public/content/guides/consent-management.mdx
Normal file
233
apps/public/content/guides/consent-management.mdx
Normal 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>
|
||||
132
apps/public/content/pages/dpa.mdx
Normal file
132
apps/public/content/pages/dpa.mdx
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
BIN
apps/public/public/signature.png
Normal file
BIN
apps/public/public/signature.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -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>
|
||||
);
|
||||
|
||||
498
apps/public/src/app/dpa/download/page.tsx
Normal file
498
apps/public/src/app/dpa/download/page.tsx
Normal 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 · 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 · hello@openpanel.dev · 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
apps/start/src/components/page/gsc-cannibalization.tsx
Normal file
226
apps/start/src/components/page/gsc-cannibalization.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal file
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal file
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal file
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
apps/start/src/components/page/page-views-chart.tsx
Normal file
180
apps/start/src/components/page/page-views-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
332
apps/start/src/components/page/pages-insights.tsx
Normal file
332
apps/start/src/components/page/pages-insights.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/start/src/components/pages/page-sparkline.tsx
Normal file
122
apps/start/src/components/pages/page-sparkline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
apps/start/src/components/pages/table/columns.tsx
Normal file
206
apps/start/src/components/pages/table/columns.tsx
Normal 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]);
|
||||
}
|
||||
143
apps/start/src/components/pages/table/index.tsx
Normal file
143
apps/start/src/components/pages/table/index.tsx
Normal 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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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> }) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
440
apps/start/src/modals/gsc-details.tsx
Normal file
440
apps/start/src/modals/gsc-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
49
apps/start/src/modals/page-details.tsx
Normal file
49
apps/start/src/modals/page-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
142
apps/worker/src/jobs/gsc.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ?? '',
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ?? ''
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
85
packages/db/code-migrations/12-add-gsc.ts
Normal file
85
packages/db/code-migrations/12-add-gsc.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
44
packages/db/src/encryption.ts
Normal file
44
packages/db/src/encryption.ts
Normal 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
554
packages/db/src/gsc.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
246
packages/payments/scripts/create-checkout-link.ts
Normal file
246
packages/payments/scripts/create-checkout-link.ts
Normal 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());
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -35,7 +35,7 @@ const methods: { name: OpenPanelMethodNames; value: unknown }[] = [
|
||||
value: {
|
||||
...options,
|
||||
sdk: 'astro',
|
||||
sdkVersion: '1.1.0',
|
||||
sdkVersion: '1.2.0',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user