45 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
7a76b968ba try simplified event buffer with duration calculation on the fly instead 2026-03-16 09:15:34 +01:00
Carl-Gerhard Lindesvärd
9c3c1458bb wip: try groupmq 2 2026-03-13 06:43:09 +01:00
Carl-Gerhard Lindesvärd
a672b73947 wip 2026-03-11 23:38:22 +01:00
Carl-Gerhard Lindesvärd
bc08566cd4 fix 2026-03-11 21:33:08 +01:00
Carl-Gerhard Lindesvärd
bf39804767 remove unused file 2026-03-11 21:33:08 +01:00
Carl-Gerhard Lindesvärd
664f1abe0a perf: optimize event buffer 2026-03-11 21:33:08 +01:00
Carl-Gerhard Lindesvärd
8afcf55154 fix: how we fetch profiles in the buffer 2026-03-11 21:33:08 +01:00
Carl-Gerhard Lindesvärd
4736f8509d fix: healthz readiness should only fail if redis fails 2026-03-11 13:53:11 +01:00
Carl-Gerhard Lindesvärd
05cf6bb39f fix: add search for Opportunities and Cannibalization 2026-03-11 11:30:19 +01:00
Carl-Gerhard Lindesvärd
6e1daf2c76 fix: ensure we have envs for gsc sync 2026-03-11 09:50:12 +01:00
Carl-Gerhard Lindesvärd
f2aa0273e6 debug gsc sync 2026-03-11 08:20:04 +01:00
Carl-Gerhard Lindesvärd
1b898660ad fix: improve landing page 2026-03-10 22:30:31 +01:00
Carl-Gerhard Lindesvärd
9836f75e17 fix: add gsc worker to bullboard 2026-03-09 21:42:20 +01:00
Carl-Gerhard Lindesvärd
271d189ed0 feat: added google search console 2026-03-09 20:47:02 +01:00
Carl-Gerhard Lindesvärd
70ca44f039 chore(public): update @opennextjs/cloudflare #309 2026-03-06 13:13:59 +01:00
Carl-Gerhard Lindesvärd
00f6cd6f50 fix: importer 2.. 2026-03-03 23:54:53 +01:00
Carl-Gerhard Lindesvärd
227d629dc5 fix: pnpm lock 2026-03-03 23:15:34 +01:00
Carl-Gerhard Lindesvärd
f2e19093f0 fix: importer.. 2026-03-03 22:17:49 +01:00
Carl-Gerhard Lindesvärd
7f85b2ac0a fix: pagination bug #296 2026-03-03 12:53:11 +01:00
Carl-Gerhard Lindesvärd
38965387da chore: add create checkout link 2026-03-03 12:52:57 +01:00
Carl-Gerhard Lindesvärd
74bcb7ead2 fix(api): improve export api, properties to be a comma seperated list 2026-03-03 11:37:05 +01:00
Carl-Gerhard Lindesvärd
2377f95b86 feat(dashboard): allow create organizations 2026-03-03 11:11:59 +01:00
Carl-Gerhard Lindesvärd
de6ca96628 chore: update gitignore 2026-03-03 11:04:20 +01:00
Carl-Gerhard Lindesvärd
9e46099246 chore: add dpa, update terms and privacy 2026-03-03 10:59:45 +01:00
Carl-Gerhard Lindesvärd
83761638f2 fix: improve how previous state is shown for funnels 2026-03-02 15:28:28 +01:00
Carl-Gerhard Lindesvärd
885f7225db bump(sdk): 1.2.0 2026-03-02 13:43:32 +01:00
Carl-Gerhard Lindesvärd
553e4cf675 fix: ts issues 2026-03-02 13:18:34 +01:00
Carl-Gerhard Lindesvärd
f2c414b4b4 fix(sdk): add timestamp when queueing events 2026-03-02 13:16:55 +01:00
Carl-Gerhard Lindesvärd
043730444a feat: improve how disabled works for the SDKS (to improve consent management) 2026-03-02 11:00:20 +01:00
Carl-Gerhard Lindesvärd
8c377c2066 fix: default last/first seen broken when clickhouse defaults to 1970 2026-03-02 09:34:23 +01:00
Carl-Gerhard Lindesvärd
647ac2a4af fix: redo how the importer works 2026-03-01 21:59:12 +01:00
Carl-Gerhard Lindesvärd
6251d143d1 fix(dashboard): pagination and login 2026-03-01 13:33:55 +01:00
Carl-Gerhard Lindesvärd
b801d6a8ef fix: last auth provider cookie (wrong domain) 2026-02-27 23:41:38 +01:00
Carl-Gerhard Lindesvärd
1272466235 feat: add tracking code on project settings 2026-02-27 23:27:13 +01:00
Carl-Gerhard Lindesvärd
2501ee1eef chore: remove unused var 2026-02-27 23:25:45 +01:00
Carl-Gerhard Lindesvärd
10da7d3a1d fix: improve onboarding 2026-02-27 22:45:21 +01:00
Carl-Gerhard Lindesvärd
b0aa7f4196 fix: reduce noise for api errors 2026-02-27 20:20:16 +01:00
Carl-Gerhard Lindesvärd
f4602f8e56 fix: add session end event for notification funnel 2026-02-27 18:37:37 +01:00
Carl-Gerhard Lindesvärd
efb50fafdb docs: add dashboard guides 2026-02-27 13:47:59 +01:00
Carl-Gerhard Lindesvärd
cd112237e9 docs: session replay 2026-02-27 11:22:12 +01:00
Carl-Gerhard Lindesvärd
9c6c7bb037 fix: funnel notifications 2026-02-27 10:24:45 +01:00
Carl-Gerhard Lindesvärd
928c44ef6a fix: duplicate session start (race condition) + remove old device id handling 2026-02-27 09:56:51 +01:00
Carl-Gerhard Lindesvärd
a42adcdbfb fix: broken add notifications rule 2026-02-27 09:37:43 +01:00
Carl-Gerhard Lindesvärd
8b18b86deb fix: invalidate queries better 2026-02-27 09:37:29 +01:00
Carl-Gerhard Lindesvärd
8db5905fb5 public: sitemap 2026-02-26 21:59:16 +01:00
216 changed files with 12588 additions and 5282 deletions

1
.gitignore vendored
View File

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

View File

@@ -28,6 +28,7 @@ Openpanel is an open-source web and product analytics platform that combines the
## ✨ Features ## ✨ Features
- **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history - **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history
- **🎬 Session Replay**: Record and replay user sessions with privacy controls built in
- **📊 Real-time Dashboards**: Live data updates and interactive charts - **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns - **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts - **🔔 Smart Notifications**: Event and funnel-based alerts
@@ -48,6 +49,7 @@ Openpanel is an open-source web and product analytics platform that combines the
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ | | 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** | | 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ | | 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ | | 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ | | 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ | | 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
@@ -59,6 +61,7 @@ Openpanel is an open-source web and product analytics platform that combines the
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access. > ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers. > ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
> ✅*** Plausible has simple goals > ✅*** Plausible has simple goals
> ✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
## Stack ## Stack

View File

@@ -30,7 +30,6 @@
"@openpanel/logger": "workspace:*", "@openpanel/logger": "workspace:*",
"@openpanel/payments": "workspace:*", "@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*", "@openpanel/queue": "workspace:*",
"groupmq": "catalog:",
"@openpanel/redis": "workspace:*", "@openpanel/redis": "workspace:*",
"@openpanel/trpc": "workspace:*", "@openpanel/trpc": "workspace:*",
"@openpanel/validation": "workspace:*", "@openpanel/validation": "workspace:*",
@@ -40,6 +39,7 @@
"fastify": "^5.6.1", "fastify": "^5.6.1",
"fastify-metrics": "^12.1.0", "fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0", "fastify-raw-body": "^5.0.0",
"groupmq": "catalog:",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"sharp": "^0.33.5", "sharp": "^0.33.5",

View File

@@ -1,4 +1,4 @@
import { cacheable, cacheableLru } from '@openpanel/redis'; import { cacheable } from '@openpanel/redis';
import bots from './bots'; import bots from './bots';
// Pre-compile regex patterns at module load time // Pre-compile regex patterns at module load time
@@ -15,7 +15,7 @@ const compiledBots = bots.map((bot) => {
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot); const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
const includesBots = compiledBots.filter((bot) => 'includes' in bot); const includesBots = compiledBots.filter((bot) => 'includes' in bot);
export const isBot = cacheableLru( export const isBot = cacheable(
'is-bot', 'is-bot',
(ua: string) => { (ua: string) => {
// Check simple string patterns first (fast) // Check simple string patterns first (fast)
@@ -40,8 +40,5 @@ export const isBot = cacheableLru(
return null; return null;
}, },
{ 60 * 5
maxSize: 1000,
ttl: 60 * 5,
},
); );

View File

@@ -61,8 +61,6 @@ export async function postEvent(
}, },
uaInfo, uaInfo,
geo, geo,
currentDeviceId: '',
previousDeviceId: '',
deviceId, deviceId,
sessionId: sessionId ?? '', sessionId: sessionId ?? '',
}, },

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { isShuttingDown } from '@/utils/graceful-shutdown';
import { chQuery, db } from '@openpanel/db'; import { chQuery, db } from '@openpanel/db';
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { isShuttingDown } from '@/utils/graceful-shutdown';
// For docker compose healthcheck // For docker compose healthcheck
export async function healthcheck( export async function healthcheck(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
try { try {
const redisRes = await getRedisCache().ping(); const redisRes = await getRedisCache().ping();
@@ -21,6 +21,7 @@ export async function healthcheck(
ch: chRes && chRes.length > 0, ch: chRes && chRes.length > 0,
}); });
} catch (error) { } catch (error) {
request.log.warn('healthcheck failed', { error });
return reply.status(503).send({ return reply.status(503).send({
ready: false, ready: false,
reason: 'dependencies not ready', reason: 'dependencies not ready',
@@ -41,18 +42,22 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) {
// Perform lightweight dependency checks for readiness // Perform lightweight dependency checks for readiness
const redisRes = await getRedisCache().ping(); const redisRes = await getRedisCache().ping();
const dbRes = await db.project.findFirst(); const dbRes = await db.$executeRaw`SELECT 1`;
const chRes = await chQuery('SELECT 1'); const chRes = await chQuery('SELECT 1');
const isReady = redisRes && dbRes && chRes; const isReady = redisRes;
if (!isReady) { if (!isReady) {
return reply.status(503).send({ const res = {
ready: false,
reason: 'dependencies not ready',
redis: redisRes === 'PONG', redis: redisRes === 'PONG',
db: !!dbRes, db: !!dbRes,
ch: chRes && chRes.length > 0, ch: chRes && chRes.length > 0,
};
request.log.warn('dependencies not ready', res);
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
...res,
}); });
} }

View File

@@ -1,12 +1,5 @@
import type { FastifyRequest } from 'fastify';
import superjson from 'superjson';
import type { WebSocket } from '@fastify/websocket'; import type { WebSocket } from '@fastify/websocket';
import { import { eventBuffer } from '@openpanel/db';
eventBuffer,
getProfileById,
transformMinimalEvent,
} from '@openpanel/db';
import { setSuperJson } from '@openpanel/json'; import { setSuperJson } from '@openpanel/json';
import { import {
psubscribeToPublishedEvent, psubscribeToPublishedEvent,
@@ -14,10 +7,7 @@ import {
} from '@openpanel/redis'; } from '@openpanel/redis';
import { getProjectAccess } from '@openpanel/trpc'; import { getProjectAccess } from '@openpanel/trpc';
import { getOrganizationAccess } from '@openpanel/trpc/src/access'; import { getOrganizationAccess } from '@openpanel/trpc/src/access';
import type { FastifyRequest } from 'fastify';
export function getLiveEventInfo(key: string) {
return key.split(':').slice(2) as [string, string];
}
export function wsVisitors( export function wsVisitors(
socket: WebSocket, socket: WebSocket,
@@ -25,27 +15,38 @@ export function wsVisitors(
Params: { Params: {
projectId: string; projectId: string;
}; };
}>, }>
) { ) {
const { params } = req; const { params } = req;
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => { const sendCount = () => {
if (event?.projectId === params.projectId) { eventBuffer
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => { .getActiveVisitorCount(params.projectId)
.then((count) => {
socket.send(String(count)); socket.send(String(count));
})
.catch(() => {
socket.send('0');
}); });
};
const unsubscribe = subscribeToPublishedEvent(
'events',
'batch',
({ projectId }) => {
if (projectId === params.projectId) {
sendCount();
} }
}); }
);
const punsubscribe = psubscribeToPublishedEvent( const punsubscribe = psubscribeToPublishedEvent(
'__keyevent@0__:expired', '__keyevent@0__:expired',
(key) => { (key) => {
const [projectId] = getLiveEventInfo(key); const [, , projectId] = key.split(':');
if (projectId && projectId === params.projectId) { if (projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => { sendCount();
socket.send(String(count)); }
});
} }
},
); );
socket.on('close', () => { socket.on('close', () => {
@@ -62,18 +63,10 @@ export async function wsProjectEvents(
}; };
Querystring: { Querystring: {
token?: string; token?: string;
type?: 'saved' | 'received';
}; };
}>, }>
) { ) {
const { params, query } = req; const { params } = req;
const type = query.type || 'saved';
if (!['saved', 'received'].includes(type)) {
socket.send('Invalid type');
socket.close();
return;
}
const userId = req.session?.userId; const userId = req.session?.userId;
if (!userId) { if (!userId) {
@@ -87,24 +80,20 @@ export async function wsProjectEvents(
projectId: params.projectId, projectId: params.projectId,
}); });
if (!access) {
socket.send('No access');
socket.close();
return;
}
const unsubscribe = subscribeToPublishedEvent( const unsubscribe = subscribeToPublishedEvent(
'events', 'events',
type, 'batch',
async (event) => { ({ projectId, count }) => {
if (event.projectId === params.projectId) { if (projectId === params.projectId) {
const profile = await getProfileById(event.profileId, event.projectId); socket.send(setSuperJson({ count }));
socket.send(
superjson.stringify(
access
? {
...event,
profile,
} }
: transformMinimalEvent(event),
),
);
} }
},
); );
socket.on('close', () => unsubscribe()); socket.on('close', () => unsubscribe());
@@ -116,7 +105,7 @@ export async function wsProjectNotifications(
Params: { Params: {
projectId: string; projectId: string;
}; };
}>, }>
) { ) {
const { params } = req; const { params } = req;
const userId = req.session?.userId; const userId = req.session?.userId;
@@ -143,9 +132,9 @@ export async function wsProjectNotifications(
'created', 'created',
(notification) => { (notification) => {
if (notification.projectId === params.projectId) { if (notification.projectId === params.projectId) {
socket.send(superjson.stringify(notification)); socket.send(setSuperJson(notification));
}
} }
},
); );
socket.on('close', () => unsubscribe()); socket.on('close', () => unsubscribe());
@@ -157,7 +146,7 @@ export async function wsOrganizationEvents(
Params: { Params: {
organizationId: string; organizationId: string;
}; };
}>, }>
) { ) {
const { params } = req; const { params } = req;
const userId = req.session?.userId; const userId = req.session?.userId;
@@ -184,7 +173,7 @@ export async function wsOrganizationEvents(
'subscription_updated', 'subscription_updated',
(message) => { (message) => {
socket.send(setSuperJson(message)); socket.send(setSuperJson(message));
}, }
); );
socket.on('close', () => unsubscribe()); socket.on('close', () => unsubscribe());

View File

@@ -1,5 +1,4 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { HttpError } from '@/utils/errors';
import { stripTrailingSlash } from '@openpanel/common'; import { stripTrailingSlash } from '@openpanel/common';
import { hashPassword } from '@openpanel/common/server'; import { hashPassword } from '@openpanel/common/server';
import { import {
@@ -10,6 +9,7 @@ import {
} from '@openpanel/db'; } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { HttpError } from '@/utils/errors';
// Validation schemas // Validation schemas
const zCreateProject = z.object({ const zCreateProject = z.object({
@@ -57,7 +57,7 @@ const zUpdateReference = z.object({
// Projects CRUD // Projects CRUD
export async function listProjects( export async function listProjects(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
const projects = await db.project.findMany({ const projects = await db.project.findMany({
where: { where: {
@@ -74,7 +74,7 @@ export async function listProjects(
export async function getProject( export async function getProject(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const project = await db.project.findFirst({ const project = await db.project.findFirst({
where: { where: {
@@ -92,7 +92,7 @@ export async function getProject(
export async function createProject( export async function createProject(
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>, request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zCreateProject.safeParse(request.body); const parsed = zCreateProject.safeParse(request.body);
@@ -139,12 +139,9 @@ export async function createProject(
}, },
}); });
// Clear cache
await Promise.all([ await Promise.all([
getProjectByIdCached.clear(project.id), getProjectByIdCached.clear(project.id),
project.clients.map((client) => { ...project.clients.map((client) => getClientByIdCached.clear(client.id)),
getClientByIdCached.clear(client.id);
}),
]); ]);
reply.send({ reply.send({
@@ -165,7 +162,7 @@ export async function updateProject(
Params: { id: string }; Params: { id: string };
Body: z.infer<typeof zUpdateProject>; Body: z.infer<typeof zUpdateProject>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zUpdateProject.safeParse(request.body); const parsed = zUpdateProject.safeParse(request.body);
@@ -223,12 +220,9 @@ export async function updateProject(
data: updateData, data: updateData,
}); });
// Clear cache
await Promise.all([ await Promise.all([
getProjectByIdCached.clear(project.id), getProjectByIdCached.clear(project.id),
existing.clients.map((client) => { ...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
getClientByIdCached.clear(client.id);
}),
]); ]);
reply.send({ data: project }); reply.send({ data: project });
@@ -236,7 +230,7 @@ export async function updateProject(
export async function deleteProject( export async function deleteProject(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const project = await db.project.findFirst({ const project = await db.project.findFirst({
where: { where: {
@@ -266,7 +260,7 @@ export async function deleteProject(
// Clients CRUD // Clients CRUD
export async function listClients( export async function listClients(
request: FastifyRequest<{ Querystring: { projectId?: string } }>, request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const where: any = { const where: any = {
organizationId: request.client!.organizationId, organizationId: request.client!.organizationId,
@@ -300,7 +294,7 @@ export async function listClients(
export async function getClient( export async function getClient(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const client = await db.client.findFirst({ const client = await db.client.findFirst({
where: { where: {
@@ -318,7 +312,7 @@ export async function getClient(
export async function createClient( export async function createClient(
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>, request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zCreateClient.safeParse(request.body); const parsed = zCreateClient.safeParse(request.body);
@@ -374,7 +368,7 @@ export async function updateClient(
Params: { id: string }; Params: { id: string };
Body: z.infer<typeof zUpdateClient>; Body: z.infer<typeof zUpdateClient>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zUpdateClient.safeParse(request.body); const parsed = zUpdateClient.safeParse(request.body);
@@ -417,7 +411,7 @@ export async function updateClient(
export async function deleteClient( export async function deleteClient(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const client = await db.client.findFirst({ const client = await db.client.findFirst({
where: { where: {
@@ -444,7 +438,7 @@ export async function deleteClient(
// References CRUD // References CRUD
export async function listReferences( export async function listReferences(
request: FastifyRequest<{ Querystring: { projectId?: string } }>, request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const where: any = {}; const where: any = {};
@@ -488,7 +482,7 @@ export async function listReferences(
export async function getReference( export async function getReference(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const reference = await db.reference.findUnique({ const reference = await db.reference.findUnique({
where: { where: {
@@ -516,7 +510,7 @@ export async function getReference(
export async function createReference( export async function createReference(
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>, request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zCreateReference.safeParse(request.body); const parsed = zCreateReference.safeParse(request.body);
@@ -559,7 +553,7 @@ export async function updateReference(
Params: { id: string }; Params: { id: string };
Body: z.infer<typeof zUpdateReference>; Body: z.infer<typeof zUpdateReference>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zUpdateReference.safeParse(request.body); const parsed = zUpdateReference.safeParse(request.body);
@@ -616,7 +610,7 @@ export async function updateReference(
export async function deleteReference( export async function deleteReference(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const reference = await db.reference.findUnique({ const reference = await db.reference.findUnique({
where: { where: {

View File

@@ -1,16 +1,17 @@
import { LogError } from '@/utils/errors';
import { import {
Arctic, Arctic,
type OAuth2Tokens,
createSession, createSession,
generateSessionToken, generateSessionToken,
github, github,
google, google,
type OAuth2Tokens,
setLastAuthProviderCookie,
setSessionTokenCookie, setSessionTokenCookie,
} from '@openpanel/auth'; } from '@openpanel/auth';
import { type Account, connectUserToOrganization, db } from '@openpanel/db'; import { type Account, connectUserToOrganization, db } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { LogError } from '@/utils/errors';
async function getGithubEmail(githubAccessToken: string) { async function getGithubEmail(githubAccessToken: string) {
const emailListRequest = new Request('https://api.github.com/user/emails'); const emailListRequest = new Request('https://api.github.com/user/emails');
@@ -74,10 +75,14 @@ async function handleExistingUser({
setSessionTokenCookie( setSessionTokenCookie(
(...args) => reply.setCookie(...args), (...args) => reply.setCookie(...args),
sessionToken, sessionToken,
session.expiresAt, session.expiresAt
);
setLastAuthProviderCookie(
(...args) => reply.setCookie(...args),
providerName
); );
return reply.redirect( return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!, process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
); );
} }
@@ -103,7 +108,7 @@ async function handleNewUser({
existingUser, existingUser,
oauthUser, oauthUser,
providerName, providerName,
}, }
); );
} }
@@ -138,10 +143,14 @@ async function handleNewUser({
setSessionTokenCookie( setSessionTokenCookie(
(...args) => reply.setCookie(...args), (...args) => reply.setCookie(...args),
sessionToken, sessionToken,
session.expiresAt, session.expiresAt
);
setLastAuthProviderCookie(
(...args) => reply.setCookie(...args),
providerName
); );
return reply.redirect( return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!, process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
); );
} }
@@ -219,7 +228,7 @@ interface ValidatedOAuthQuery {
async function validateOAuthCallback( async function validateOAuthCallback(
req: FastifyRequest, req: FastifyRequest,
provider: Provider, provider: Provider
): Promise<ValidatedOAuthQuery> { ): Promise<ValidatedOAuthQuery> {
const schema = z.object({ const schema = z.object({
code: z.string(), code: z.string(),
@@ -353,7 +362,7 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
function redirectWithError(reply: FastifyReply, error: LogError | unknown) { function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL( const url = new URL(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!, process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
); );
url.pathname = '/login'; url.pathname = '/login';
if (error instanceof LogError) { if (error instanceof LogError) {

View File

@@ -7,7 +7,10 @@ import {
upsertProfile, upsertProfile,
} from '@openpanel/db'; } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue'; import {
type EventsQueuePayloadIncomingEvent,
getEventsGroupQueueShard,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import { import {
type IDecrementPayload, type IDecrementPayload,
@@ -112,6 +115,7 @@ interface TrackContext {
identity?: IIdentifyPayload; identity?: IIdentifyPayload;
deviceId: string; deviceId: string;
sessionId: string; sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
geo: GeoLocation; geo: GeoLocation;
} }
@@ -141,19 +145,21 @@ async function buildContext(
validatedBody.payload.profileId = profileId; validatedBody.payload.profileId = profileId;
} }
const overrideDeviceId =
validatedBody.type === 'track' &&
typeof validatedBody.payload?.properties?.__deviceId === 'string'
? validatedBody.payload?.properties.__deviceId
: undefined;
// Get geo location (needed for track and identify) // Get geo location (needed for track and identify)
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]); const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
const { deviceId, sessionId } = await getDeviceId({ const deviceIdResult = await getDeviceId({
projectId, projectId,
ip, ip,
ua, ua,
salts, salts,
overrideDeviceId: overrideDeviceId,
validatedBody.type === 'track' &&
typeof validatedBody.payload?.properties?.__deviceId === 'string'
? validatedBody.payload?.properties.__deviceId
: undefined,
}); });
return { return {
@@ -166,8 +172,9 @@ async function buildContext(
isFromPast: timestamp.isTimestampFromThePast, isFromPast: timestamp.isTimestampFromThePast,
}, },
identity, identity,
deviceId, deviceId: deviceIdResult.deviceId,
sessionId, sessionId: deviceIdResult.sessionId,
session: deviceIdResult.session,
geo, geo,
}; };
} }
@@ -176,13 +183,14 @@ async function handleTrack(
payload: ITrackPayload, payload: ITrackPayload,
context: TrackContext context: TrackContext
): Promise<void> { ): Promise<void> {
const { projectId, deviceId, geo, headers, timestamp, sessionId } = context; const { projectId, deviceId, geo, headers, timestamp, sessionId, session } =
context;
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties); const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer const groupId = uaInfo.isServer
? payload.profileId ? payload.profileId
? `${projectId}:${payload.profileId}` ? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}` : undefined
: deviceId; : deviceId;
const jobId = [ const jobId = [
slug(payload.name), slug(payload.name),
@@ -203,7 +211,7 @@ async function handleTrack(
} }
promises.push( promises.push(
getEventsGroupQueueShard(groupId).add({ getEventsGroupQueueShard(groupId || generateId()).add({
orderMs: timestamp.value, orderMs: timestamp.value,
data: { data: {
projectId, projectId,
@@ -217,8 +225,7 @@ async function handleTrack(
geo, geo,
deviceId, deviceId,
sessionId, sessionId,
currentDeviceId: '', // TODO: Remove session,
previousDeviceId: '', // TODO: Remove
}, },
groupId, groupId,
jobId, jobId,

View File

@@ -1,20 +1,19 @@
import { isBot } from '@/bots';
import { createBotEvent } from '@openpanel/db'; import { createBotEvent } from '@openpanel/db';
import type { import type {
DeprecatedPostEventPayload, DeprecatedPostEventPayload,
ITrackHandlerPayload, ITrackHandlerPayload,
} from '@openpanel/validation'; } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { isBot } from '@/bots';
export async function isBotHook( export async function isBotHook(
req: FastifyRequest<{ req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload; Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const bot = req.headers['user-agent'] const bot = req.headers['user-agent']
? isBot(req.headers['user-agent']) ? await isBot(req.headers['user-agent'])
: null; : null;
if (bot && req.client?.projectId) { if (bot && req.client?.projectId) {
@@ -44,6 +43,6 @@ export async function isBotHook(
} }
} }
return reply.status(202).send(); return reply.status(202).send({ bot });
} }
} }

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */
process.env.TZ = 'UTC'; process.env.TZ = 'UTC';
import compress from '@fastify/compress'; import compress from '@fastify/compress';
@@ -35,6 +36,7 @@ import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router'; import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router'; import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router'; import exportRouter from './routes/export.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import importRouter from './routes/import.router'; import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router'; import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router'; import liveRouter from './routes/live.router';
@@ -151,7 +153,7 @@ const startServer = async () => {
validateSessionToken(req.cookies.session) validateSessionToken(req.cookies.session)
); );
req.session = session; req.session = session;
} catch (e) { } catch {
req.session = EMPTY_SESSION; req.session = EMPTY_SESSION;
} }
} else if (process.env.DEMO_USER_ID) { } else if (process.env.DEMO_USER_ID) {
@@ -160,7 +162,7 @@ const startServer = async () => {
validateSessionToken(null) validateSessionToken(null)
); );
req.session = session; req.session = session;
} catch (e) { } catch {
req.session = EMPTY_SESSION; req.session = EMPTY_SESSION;
} }
} else { } else {
@@ -193,6 +195,7 @@ const startServer = async () => {
instance.register(liveRouter, { prefix: '/live' }); instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' }); instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' }); instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(gscCallbackRouter, { prefix: '/gsc' });
instance.register(miscRouter, { prefix: '/misc' }); instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' }); instance.register(aiRouter, { prefix: '/ai' });
}); });
@@ -220,35 +223,46 @@ const startServer = async () => {
); );
}); });
const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE'];
fastify.setErrorHandler((error, request, reply) => { fastify.setErrorHandler((error, request, reply) => {
if (error.statusCode === 429) {
return reply.status(429).send({
status: 429,
error: 'Too Many Requests',
message: 'You have exceeded the rate limit for this endpoint.',
});
}
if (error instanceof HttpError) { if (error instanceof HttpError) {
request.log.error(`${error.message}`, error); if (!SKIP_LOG_ERRORS.includes(error.code)) {
request.log.error('internal server error', { error });
}
if (process.env.NODE_ENV === 'production' && error.status === 500) { if (process.env.NODE_ENV === 'production' && error.status === 500) {
request.log.error('request error', { error }); return reply.status(500).send('Internal server error');
reply.status(500).send('Internal server error'); }
} else {
reply.status(error.status).send({ return reply.status(error.status).send({
status: error.status, status: error.status,
error: error.error, error: error.error,
message: error.message, message: error.message,
}); });
} }
} else if (error.statusCode === 429) {
reply.status(429).send({ if (!SKIP_LOG_ERRORS.includes(error.code)) {
status: 429,
error: 'Too Many Requests',
message: 'You have exceeded the rate limit for this endpoint.',
});
} else if (error.statusCode === 400) {
reply.status(400).send({
status: 400,
error,
message: 'The request was invalid.',
});
} else {
request.log.error('request error', { error }); request.log.error('request error', { error });
reply.status(500).send('Internal server error');
} }
const status = error?.statusCode ?? 500;
if (process.env.NODE_ENV === 'production' && status === 500) {
return reply.status(500).send('Internal server error');
}
return reply.status(status).send({
status,
error,
message: error.message,
});
}); });
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {

View File

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

View File

@@ -1,6 +1,5 @@
import { fetchDeviceId, handler } from '@/controllers/track.controller';
import type { FastifyPluginCallback } from 'fastify'; import type { FastifyPluginCallback } from 'fastify';
import { fetchDeviceId, handler } from '@/controllers/track.controller';
import { clientHook } from '@/hooks/client.hook'; import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook'; import { duplicateHook } from '@/hooks/duplicate.hook';
import { isBotHook } from '@/hooks/is-bot.hook'; import { isBotHook } from '@/hooks/is-bot.hook';
@@ -13,7 +12,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
fastify.route({ fastify.route({
method: 'POST', method: 'POST',
url: '/', url: '/',
handler: handler, handler,
}); });
fastify.route({ fastify.route({

View File

@@ -1,7 +1,12 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { generateDeviceId } from '@openpanel/common/server'; import { generateDeviceId } from '@openpanel/common/server';
import { getSafeJson } from '@openpanel/json'; import { getSafeJson } from '@openpanel/json';
import type {
EventsQueuePayloadCreateSessionEnd,
EventsQueuePayloadIncomingEvent,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import { pick } from 'ramda';
export async function getDeviceId({ export async function getDeviceId({
projectId, projectId,
@@ -37,14 +42,20 @@ export async function getDeviceId({
ua, ua,
}); });
return await getDeviceIdFromSession({ return await getInfoFromSession({
projectId, projectId,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
}); });
} }
async function getDeviceIdFromSession({ interface DeviceIdResult {
deviceId: string;
sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
}
async function getInfoFromSession({
projectId, projectId,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
@@ -52,7 +63,7 @@ async function getDeviceIdFromSession({
projectId: string; projectId: string;
currentDeviceId: string; currentDeviceId: string;
previousDeviceId: string; previousDeviceId: string;
}) { }): Promise<DeviceIdResult> {
try { try {
const multi = getRedisCache().multi(); const multi = getRedisCache().multi();
multi.hget( multi.hget(
@@ -65,21 +76,33 @@ async function getDeviceIdFromSession({
); );
const res = await multi.exec(); const res = await multi.exec();
if (res?.[0]?.[1]) { if (res?.[0]?.[1]) {
const data = getSafeJson<{ payload: { sessionId: string } }>( const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[0]?.[1] as string) ?? '' (res?.[0]?.[1] as string) ?? ''
); );
if (data) { if (data) {
const sessionId = data.payload.sessionId; return {
return { deviceId: currentDeviceId, sessionId }; deviceId: currentDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
} }
} }
if (res?.[1]?.[1]) { if (res?.[1]?.[1]) {
const data = getSafeJson<{ payload: { sessionId: string } }>( const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[1]?.[1] as string) ?? '' (res?.[1]?.[1] as string) ?? ''
); );
if (data) { if (data) {
const sessionId = data.payload.sessionId; return {
return { deviceId: previousDeviceId, sessionId }; deviceId: previousDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
} }
} }
} catch (error) { } catch (error) {

View File

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

View File

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

View File

@@ -0,0 +1,185 @@
---
title: Session Replay
description: Record and replay user sessions to understand exactly what users did. Loaded asynchronously so it never bloats your analytics bundle.
---
import { Callout } from 'fumadocs-ui/components/callout';
Session replay captures a structured recording of what users do in your app or website. You can replay any session to see which elements were clicked, how forms were filled, and where users ran into friction—without guessing.
<Callout type="info">
Session replay is **not enabled by default**. You explicitly opt in per-project. When disabled, the replay script is never downloaded, keeping your analytics bundle lean.
</Callout>
## How it works
OpenPanel session replay is built on [rrweb](https://www.rrweb.io/), an open-source library for recording and replaying web sessions. It captures DOM mutations, mouse movements, scroll positions, and interactions as structured data—not video.
The replay module is loaded **asynchronously** as a separate script (`op1-replay.js`). This means:
- Your main tracking script (`op1.js`) stays lightweight even when replay is disabled
- The replay module is only downloaded for sessions that are actually recorded
- No impact on page load performance when replay is turned off
## Limits & retention
- **Unlimited replays** — no cap on the number of sessions recorded
- **30-day retention** — replays are stored and accessible for 30 days
## Setup
### Script tag
Add `sessionReplay` to your `init` call. The replay script loads automatically from the same CDN as the main script.
```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,
sessionReplay: {
enabled: true,
},
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
### NPM package
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
sessionReplay: {
enabled: true,
},
});
```
With the npm package, the replay module is a dynamic import code-split by your bundler. It is never included in your main bundle when session replay is disabled.
## Options
| Option | Type | Default | Description |
|---|---|---|---|
| `enabled` | `boolean` | `false` | Enable session replay recording |
| `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 |
| `flushIntervalMs` | `number` | `10000` | How often (ms) recorded events are sent to the server |
| `maxEventsPerChunk` | `number` | `200` | Maximum number of events per payload chunk |
| `maxPayloadBytes` | `number` | `1048576` | Maximum payload size in bytes (1 MB) |
| `scriptUrl` | `string` | — | Custom URL for the replay script (script-tag builds only) |
## Privacy controls
Session replay captures user interactions. All text and inputs are masked by default — sensitive content is replaced with `***` before it ever leaves the browser.
### Text masking (default on)
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.
This is the safest default for GDPR compliance since replays cannot incidentally capture names, emails, or other personal data visible on the page.
### Selectively unmasking text
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,
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 at all
</div>
```
Or with a custom selector:
```ts
sessionReplay: {
enabled: true,
blockSelector: '.payment-form, .user-avatar',
blockClass: 'no-replay',
}
```
### Ignoring interactions
Use `ignoreSelector` to exclude specific elements from interaction tracking. The element remains visible in the replay but clicks and input events on it are not recorded.
```ts
sessionReplay: {
enabled: true,
ignoreSelector: '.debug-panel',
}
```
## Self-hosting
If you self-host OpenPanel, the replay script is served from your instance automatically. You can also override the script URL if you host it separately:
```ts
sessionReplay: {
enabled: true,
scriptUrl: 'https://your-cdn.example.com/op1-replay.js',
}
```
## Related
- [Session tracking](/features/session-tracking) — understand sessions without full replay
- [Session replay feature overview](/features/session-replay) — what you get with session replay
- [Web SDK](/docs/sdks/web) — full web SDK reference
- [Script tag](/docs/sdks/script) — using OpenPanel via a script tag

View File

@@ -53,14 +53,32 @@ GET /export/events
| `end` | string | End date for the event range (ISO format) | `2024-04-18` | | `end` | string | End date for the event range (ISO format) | `2024-04-18` |
| `page` | number | Page number for pagination (default: 1) | `2` | | `page` | number | Page number for pagination (default: 1) | `2` |
| `limit` | number | Number of events per page (default: 50, max: 1000) | `100` | | `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 #### 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 - **Comma-separated**: `?includes=profile,meta` (include both profile and meta in the response)
- `meta`: Include event metadata and configuration - **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 #### Example Request
@@ -129,12 +147,15 @@ Retrieve aggregated chart data for analytics and visualization. This endpoint pr
GET /export/charts 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 #### Query Parameters
| Parameter | Type | Description | Example | | Parameter | Type | Description | Example |
|-----------|------|-------------|---------| |-----------|------|-------------|---------|
| `projectId` | string | The ID of the project to fetch chart data from | `abc123` | | `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"}]` | | `breakdowns` | object[] | Array of breakdown dimensions | `[{"name":"country"}]` |
| `interval` | string | Time interval for data points | `day` | | `interval` | string | Time interval for data points | `day` |
| `range` | string | Predefined date range | `7d` | | `range` | string | Predefined date range | `7d` |
@@ -144,7 +165,7 @@ GET /export/charts
#### Event Configuration #### 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 | | Property | Type | Description | Required | Default |
|----------|------|-------------|----------|---------| |----------|------|-------------|----------|---------|
@@ -228,11 +249,13 @@ Common breakdown dimensions include:
#### Example Request #### Example Request
```bash ```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-id: YOUR_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -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 #### Example Advanced Request
```bash ```bash
@@ -241,7 +264,7 @@ curl 'https://api.openpanel.dev/export/charts' \
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \ -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \
-G \ -G \
--data-urlencode 'projectId=abc123' \ --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 'breakdowns=[{"name":"country"}]' \
--data-urlencode 'interval=day' \ --data-urlencode 'interval=day' \
--data-urlencode 'range=30d' --data-urlencode 'range=30d'

View File

@@ -0,0 +1,7 @@
{
"title": "Dashboard",
"pages": [
"understand-the-overview",
"..."
]
}

View File

@@ -0,0 +1,138 @@
---
title: "How to set up notifications and integrations"
description: "Get notified in Slack, Discord, or via webhook when users complete events or funnels. Learn how to connect integrations and configure notification rules in OpenPanel."
difficulty: beginner
timeToComplete: 10
date: 2026-02-27
updated: 2026-02-27
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Create an integration"
anchor: "create-integration"
- name: "Create a notification rule"
anchor: "create-rule"
- name: "Event rules"
anchor: "event-rules"
- name: "Funnel rules"
anchor: "funnel-rules"
- name: "View notifications"
anchor: "view-notifications"
---
## How it works
There are two separate concepts to understand before you start:
- **Integrations** are connections to external services like Slack, Discord, or a custom webhook. They live at the workspace/organization level and can be reused across all your projects.
- **Notification rules** are the conditions that trigger a notification. Rules live inside individual projects and reference one or more integrations. A rule does nothing until it has an integration attached—and an integration does nothing until a rule uses it.
- **Notifications** are the messages that are sent when a rule is triggered. A notification can be sent as a json object or a template with variables.
## Step 1: Create an integration [#create-integration]
Go to your workspace settings and open the **Integrations** section. Click **Add integration** and choose the service you want to connect.
OpenPanel currently supports:
- **Slack** — authenticate via OAuth and pick a channel
- **Discord** — paste a Discord webhook URL for a channel
- **Webhook** — send an HTTP POST to any URL you control
Fill in the required details and save. The integration is now available to all projects in your workspace.
<Figure
src="/screenshots/integrations-create.webp"
caption="Create a new integration for Slack, Discord, or a custom webhook."
/>
<Callout>Soon we have integrations for S3 and GCS to export your events to your own storage.</Callout>
## Step 2: Go to your project's notification rules [#create-rule]
Integrations alone don't do anything. To start receiving alerts, open the project you want to monitor, click **Notifications** in the left sidebar, and switch to the **Rules** tab.
Click **Add Rule** to open the rule editor on the right side of the screen.
Give your rule a name, then choose a **Type**. There are two types:
| Type | When it triggers |
|------|-----------------|
| **Event** | Immediately when a matching event is received |
| **Funnel** | After a session ends and all funnel steps have been completed in order |
## Event rules [#event-rules]
Event rules fire in real time. The moment OpenPanel receives an event that matches your filters, the notification is sent.
<Figure
src="/screenshots/notifications-event-rule.webp"
caption="An event rule called 'Onboarding user' that fires when a screen_view event occurs with path filters matching the onboarding flow."
/>
In the rule editor:
1. Set **Type** to **Events**
2. Add one or more events from the **Events** list. You can filter each event by its properties (for example, only trigger when `path` starts with `/onboarding`)
3. Write a **Template** for the notification message. Use `{{property_name}}` to insert event properties dynamically—for example, `New user with their first event from {{country}}`.
4. Under **Integrations**, select which integration(s) should receive the notification
Click **Update** to save the rule.
<Callout>Templates will not be used if you have Javascript transformer in your integration.</Callout>
## Funnel rules [#funnel-rules]
Funnel rules let you track multi-step flows and notify you only when a user completes every step in the correct sequence—for example, `session_start` → `subscription_checkout` → `subscription_created`.
<Figure
src="/screenshots/notifications-funnel-rule.webp"
caption="A funnel rule called 'Subscribe funnel' that notifies when a session completes all three steps in order."
/>
In the rule editor:
1. Set **Type** to **Funnel**
2. Add each event in the funnel, in the order they must occur. You can optionally add property filters to each step
3. Write a **Template** for the notification message
4. Select your **Integration(s)**
Click **Update** to save.
<Callout type="warning">**Important:** Funnel rule notifications are sent after the session ends, not immediately when the last step fires. OpenPanel waits until the session is complete before evaluating the funnel sequence.</Callout>
<Callout>Templates will not be used if you have Javascript transformer in your integration.</Callout>
## View notifications [#view-notifications]
Switch to the **Notifications** tab (the default view) to see every notification that has been triggered for your project. Each row shows the notification title alongside the country, OS, browser, and profile of the user who triggered it.
<Figure
src="/screenshots/notifications-list.webp"
caption="The Notifications tab shows a live feed of every triggered notification, with user context like country, OS, and browser."
/>
You can filter the list by creation date or search by title to find specific events.
## Frequently asked questions
<Faqs>
<FaqItem question="Can I use the same integration across multiple projects?">
Yes. Integrations are created at the workspace level, so any project in your organization can reference them in its notification rules.
</FaqItem>
<FaqItem question="Why haven't I received any funnel notifications?">
Funnel rules trigger after the session ends, not when the last event fires. If the user's session is still active, the notification is queued until the session closes. Make sure the full funnel sequence was completed within a single session.
</FaqItem>
<FaqItem question="Can I filter event rules to only fire for specific users or properties?">
Yes. For each event in the rule, click the filter icon to add property conditions—for example, only trigger when `plan` equals `enterprise` or `country` equals `US`.
</FaqItem>
<FaqItem question="What integrations are supported?">
Currently Slack, Discord, and custom webhooks. More integrations are coming soon.
</FaqItem>
<FaqItem question="Can I have multiple integrations on one rule?">
Yes. The integrations selector on each rule allows you to pick multiple destinations. A single triggered rule will send a notification to all selected integrations simultaneously.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,193 @@
---
title: "Understand the overview"
description: "The overview is the main page of every OpenPanel project. It gives you a real-time picture of how your site or app is performing right now and over any time range you choose. This page explains every section and every number so you know exactly what you're looking at."
date: 2026-02-27
---
## Top stats
The row of metric cards at the top of the page is the fastest way to understand the health of your project. Each card shows the value for the selected time range and a comparison to the previous period of the same length.
### Unique Visitors
The number of distinct profile IDs recorded in the selected period. How accurate this is depends on whether you use [identify](/docs/get-started/identify-users):
- **Without identify**: OpenPanel generates an anonymous profile ID that rotates every 24 hours. A visitor returning on 10 different days will be counted as 10 unique visitors, because each day produces a new ID.
- **With identify**: The profile ID is tied to the user's real identity. The same person visiting on 10 different days is counted as 1 unique visitor across the entire period.
If cross-day deduplication matters to your analysis, set up [user identification](/docs/get-started/identify-users).
### Sessions
The total number of sessions in the selected period. A session begins when someone arrives on your site and ends after 30 minutes of inactivity or when they close the tab. One visitor can have many sessions across a day.
### Pageviews
The total number of page views (`screen_view` events) recorded across all sessions. Every time a visitor loads a page—including navigating between pages in a single session—it counts as one pageview.
### Pages per Session
The average number of pages viewed within a single session, calculated as `total pageviews / total sessions`. A higher number means visitors are exploring more of your site before leaving.
### Bounce Rate
The percentage of sessions where a visitor viewed only a single page and left. Calculated as `single-page sessions / total sessions × 100`. Lower is generally better—it means more visitors are engaging beyond the first page.
> A session is counted as a bounce if the visitor triggered exactly one `screen_view` event before the session ended. Sessions where visitors read one article deeply and leave still count as bounces.
### Session Duration
The average length of a session in seconds, calculated only from sessions where the visitor did something after the first page load (duration > 0). Sessions where a visitor immediately left are excluded from the average to avoid skewing the number.
### Revenue
The total monetary value tracked via `revenue` events in the selected period, displayed in your account currency. Revenue is only shown if you are tracking revenue events. See the [revenue tracking docs](/features/revenue-tracking) for setup instructions.
---
## The time-series chart
Directly below the stat cards is a line chart that shows how the selected metric changes over time. Click any stat card to switch the chart to that metric.
The chart uses the **interval** you select (hour, day, week, or month) to group data points. A faint dashed line shows the equivalent period from the previous comparison window, so you can spot trends at a glance.
When any metric other than Revenue is active, the chart also overlays revenue as green bars on a secondary Y-axis—this lets you correlate traffic patterns with revenue without switching cards.
The trailing edge of the line (the current, incomplete interval) is shown as a dashed segment to remind you that the period is still accumulating data.
---
## Insights
If you have configured insights for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured.
---
## Sources
The Sources widget shows where your visitors came from. Switch between tabs to see different dimensions:
| Tab | What it shows |
|-----|---------------|
| **Refs** | Grouped referrer names (e.g., "Google", "Twitter", "Hacker News") |
| **Urls** | Raw referrer URLs |
| **Types** | Referrer categories: `search`, `social`, `email`, `unknown` |
| **Source** | `utm_source` query parameter values |
| **Medium** | `utm_medium` query parameter values |
| **Campaign** | `utm_campaign` query parameter values |
| **Term** | `utm_term` query parameter values |
| **Content** | `utm_content` query parameter values |
Referrer names and types are resolved automatically from the raw referrer URL using a built-in lookup table. Direct traffic (no referrer) appears as `(not set)`.
Each row shows sessions and pageviews. Clicking a row filters the entire overview page to only show data from that source.
---
## Pages
The Pages widget shows which URLs your visitors are landing on, exiting from, and spending time on.
| Tab | What it shows |
|-----|---------------|
| **Top pages** | Pages ranked by unique sessions. Each row is a `origin + path` combination. |
| **Entry pages** | The first page of each session—the page where visitors arrived. |
| **Exit pages** | The last page of each session—the page where visitors left. |
High exit rates on a page are not always bad—they can reflect a page that successfully answers a question. High bounce on an entry page is more diagnostic. Compare entry and exit distributions to understand the shape of your user journeys.
Clicking a page row filters the whole overview to sessions that included that page.
---
## Devices
The Devices widget breaks down your audience by hardware and software. Switch between tabs:
| Tab | What it shows |
|-----|---------------|
| **Device** | Device type: Desktop, Mobile, Tablet |
| **Brand** | Hardware brand (Apple, Samsung, etc.) |
| **Model** | Specific device model |
| **Browser** | Browser name (Chrome, Safari, Firefox, etc.) |
| **Browser ver.** | Browser version number |
| **OS** | Operating system (macOS, Windows, iOS, Android, etc.) |
| **OS ver.** | Operating system version |
Each row shows sessions and pageviews. Use this widget to prioritize which browsers and operating systems to test and optimize for.
---
## Events
The Events widget shows the most frequent custom events fired in the selected period, ranked by count. System events (`session_start`, `session_end`, `screen_view`) are excluded—only the events you instrument yourself appear here.
Click any event to filter the overview to sessions where that event was fired.
---
## Geo
The Geo widget shows the geographic distribution of your visitors. Switch between tabs:
| Tab | What it shows |
|-----|---------------|
| **Country** | Visitor country, derived from IP geolocation |
| **Region** | State or province |
| **City** | City level |
Below the table, a world map plots the same data as a heatmap—darker areas represent more sessions. This gives you a quick visual of where your audience is concentrated.
Clicking a country, region, or city filters the whole overview to that location.
---
## Activity heatmap
The activity heatmap at the bottom of the page shows when your visitors are most active, broken down by day of the week (Monday through Sunday) and hour of the day (00:0023:00). Each cell shows the **average** of the selected metric at that day-and-hour combination, averaged across all weeks in the selected period.
Darker cells indicate higher average values. Hover any cell to see the exact average.
You can switch the metric being visualized using the tabs above the heatmap:
- **Unique Visitors**
- **Sessions**
- **Pageviews**
- **Bounce Rate**
- **Pages / Session**
- **Session Duration**
Use the heatmap to identify peak traffic windows, plan campaigns, and schedule maintenance during quiet periods.
---
## User Journey
The User Journey (Sankey) diagram at the very bottom visualizes how visitors flow through your site within a session. It answers the question: after landing on page A, where do visitors go next?
**How it works:**
1. OpenPanel identifies the top 3 most common entry pages in the selected period.
2. From each entry page, it finds the top 3 most frequent next pages (step 2), then the top 3 from those (step 3), and so on up to the configured number of steps (default 5, adjustable to a maximum of 10).
3. Paths that represent less than 0.25% of total sessions are filtered out to reduce visual noise.
4. Consecutive duplicate pages within a session are collapsed into one step (e.g., if someone refreshed a page, it only counts once in the journey).
Each node shows the page URL. The width of the connecting flows is proportional to the number of sessions that followed that path.
Use the User Journey to find drop-off points, discover unexpected popular paths, and understand whether visitors are reaching your key conversion pages.
---
## Filters and time controls
Every widget on the overview page responds to the same set of global filters and time controls at the top of the page.
**Range**: choose a preset (Today, Last 7 days, Last 30 days, etc.) or a custom date range.
**Interval**: controls how data is grouped in the time-series chart (hour, day, week, month).
**Event filter**: narrow the entire overview to sessions that include a specific event—useful for analyzing the behavior of users who completed a particular action.
**Dimension filters**: clicking any row in any widget (a country, a source, a page) applies that value as a filter. Active filters are shown as chips below the time controls. Remove a filter by clicking the × on its chip.
**Live counter**: a green badge in the top-right corner shows the number of active visitors (visitors who fired an event in the last 5 minutes). Click it for a 30-minute session histogram.

View File

@@ -8,6 +8,7 @@ import { UserIcon,HardDriveIcon } from 'lucide-react'
## ✨ Key Features ## ✨ Key Features
- **🔍 Advanced Analytics**: [Funnels](/features/funnels), cohorts, user profiles, and session history - **🔍 Advanced Analytics**: [Funnels](/features/funnels), cohorts, user profiles, and session history
- **🎬 Session Replay**: [Record and replay user sessions](/features/session-replay) with privacy controls built in
- **📊 Real-time Dashboards**: Live data updates and interactive charts - **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns - **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts - **🔔 Smart Notifications**: Event and funnel-based alerts
@@ -28,6 +29,7 @@ import { UserIcon,HardDriveIcon } from 'lucide-react'
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ | | 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** | | 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ | | 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ | | 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ | | 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ | | 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
@@ -37,9 +39,13 @@ import { UserIcon,HardDriveIcon } from 'lucide-react'
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ | | 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access. ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers. ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
✅*** Plausible has simple goals ✅*** Plausible has simple goals
✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
## 🚀 Quick Start ## 🚀 Quick Start
Before you can start tracking your events you'll need to create an account or spin up your own instance of OpenPanel. Before you can start tracking your events you'll need to create an account or spin up your own instance of OpenPanel.

View File

@@ -8,6 +8,8 @@
"...(tracking)", "...(tracking)",
"---API---", "---API---",
"...api", "...api",
"---Dashboard---",
"...dashboard",
"---Self-hosting---", "---Self-hosting---",
"...self-hosting", "...self-hosting",
"---Migration---", "---Migration---",

View File

@@ -0,0 +1,166 @@
{
"slug": "session-replay",
"short_name": "Session replay",
"seo": {
"title": "Session Replay - Watch Real User Sessions | OpenPanel",
"description": "Replay real user sessions to understand exactly what happened. Privacy-first session replay with masking controls, unlimited recordings, and 30-day retention.",
"keywords": [
"session replay",
"session recording",
"user session replay",
"hotjar alternative",
"privacy-first session replay"
]
},
"hero": {
"heading": "See exactly what your users did",
"subheading": "Replay any user session to see clicks, scrolls, and interactions. Privacy controls built in. Loaded async so it never slows down your site.",
"badges": [
"Unlimited replays",
"30-day retention",
"Privacy controls built in",
"Async—zero bundle bloat"
]
},
"definition": {
"title": "What is session replay?",
"text": "Session replay lets you watch a structured recording of what a real user did during a visit to your site or app. You see every click, scroll, form interaction, and page navigation—played back in order.\n\nMost analytics tools tell you **what happened in aggregate**: 40% of users dropped off at step 2. Session replay shows you **why**: you can watch someone struggle with a confusing form label, miss a button, or hit an error state you didn't know existed.\n\nOpenPanel session replay is built on [rrweb](https://www.rrweb.io/), an open-source recording library. It captures DOM mutations and user interactions as structured data—not video. This matters because:\n\n- **Privacy is easier to manage** — you control exactly what gets recorded with CSS selectors, not hoping a video blur is accurate\n- **Storage is efficient** — structured data compresses far better than video\n- **Playback is instant** — no buffering or waiting for video to load\n\nSession replay in OpenPanel is **opt-in and off by default**. When disabled, the replay script is never loaded and adds zero bytes to your page. When enabled, the recorder is fetched asynchronously as a separate script, so your main analytics bundle stays lean regardless.\n\nPrivacy controls are first-class:\n\n- **All inputs masked by default** — form field values are never recorded\n- **Block any element** with a data attribute or CSS selector\n- **Mask specific text** without blocking the surrounding layout\n- **Ignore interactions** on sensitive elements\n\nReplays are linked to sessions, events, and user profiles. When a user reports a bug, you can pull up their session in seconds and see exactly what happened—no need to ask them to reproduce it."
},
"capabilities_section": {
"title": "What you get with session replay",
"intro": "Everything you need to understand real user behavior, with privacy controls built in from the start."
},
"capabilities": [
{
"title": "Full session playback",
"description": "Replay any recorded session from start to finish. See clicks, scrolls, form interactions, and navigation in the exact order they happened."
},
{
"title": "Linked to events and profiles",
"description": "Replays are tied to the user's session, event timeline, and profile. Jump from a funnel drop-off directly to the replay for context."
},
{
"title": "Input masking by default",
"description": "All form field values are masked out of the box. You see that a user typed something—not what they typed. Disable per-field if needed."
},
{
"title": "Block and mask controls",
"description": "Block entire elements from recording with a data attribute or CSS selector. Mask specific text. Ignore interactions on sensitive areas."
},
{
"title": "Async loading—zero bundle impact",
"description": "The replay module loads as a separate async script. When replay is disabled it's never fetched. When enabled it doesn't block your main bundle."
},
{
"title": "Unlimited replays, 30-day retention",
"description": "No cap on the number of sessions recorded. Every replay is stored for 30 days and available for playback at any time."
}
],
"screenshots": [
{
"src": "/features/feature-sessions.webp",
"alt": "Session overview showing user sessions with entry pages and duration",
"caption": "Browse all sessions. Click any one to open the replay."
},
{
"src": "/features/feature-sessions-details.webp",
"alt": "Session detail view showing events in order",
"caption": "The session timeline shows every event alongside the replay."
}
],
"how_it_works": {
"title": "How session replay works",
"intro": "Enable it once and every qualifying session is recorded automatically.",
"steps": [
{
"title": "Enable replay in your init config",
"description": "Set `sessionReplay: { enabled: true }` in your OpenPanel init options. That's all the configuration required to start recording."
},
{
"title": "The replay script loads asynchronously",
"description": "When a session starts, OpenPanel fetches the replay module (`op1-replay.js`) as a separate async script. It doesn't block page load or inflate your main bundle."
},
{
"title": "Interactions are captured and sent in chunks",
"description": "The recorder captures DOM changes and user interactions and sends them to OpenPanel in small chunks every 10 seconds and on page unload."
},
{
"title": "Replay any session from the dashboard",
"description": "Open any session in the dashboard and hit play. The replay reconstructs the user's exact experience. Jump to specific events from the timeline."
}
]
},
"use_cases": {
"title": "Who uses session replay",
"intro": "Teams that need to understand real user behavior beyond what metrics alone can show.",
"items": [
{
"title": "Product teams",
"description": "When the data says users drop off at a specific step, session replay shows you exactly why. See confusion, missed CTAs, and error states you didn't know existed."
},
{
"title": "Support and success teams",
"description": "When a user reports a bug or a confusing experience, pull up their session replay in seconds. You see what they saw—no need to ask them to reproduce it."
},
{
"title": "Privacy-conscious teams",
"description": "Input masking is on by default. Block sensitive UI with a data attribute. You get the behavioral insight without recording personal data."
}
]
},
"related_features": [
{
"slug": "session-tracking",
"title": "Session tracking",
"description": "Structured session data—entry pages, event timelines, duration—without requiring replay."
},
{
"slug": "event-tracking",
"title": "Event tracking",
"description": "Custom events appear alongside replays in the session timeline."
},
{
"slug": "funnels",
"title": "Funnels",
"description": "Jump from a funnel drop-off step directly to the session replay to understand why."
}
],
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions about session replay with OpenPanel.",
"items": [
{
"question": "Is session replay enabled by default?",
"answer": "No. Session replay is opt-in. You enable it by setting `sessionReplay: { enabled: true }` in your init config. When disabled, the replay script is never fetched and adds zero overhead to your page."
},
{
"question": "Does enabling session replay slow down my site?",
"answer": "No. The replay module loads as a separate async script (`op1-replay.js`), independent of the main tracking bundle (`op1.js`). It's fetched after the page loads and does not block rendering or the main analytics script."
},
{
"question": "How is this different from Hotjar or FullStory?",
"answer": "Hotjar and FullStory record video-like streams. OpenPanel captures structured DOM events using rrweb. The result looks similar in the viewer, but structured data gives you finer-grained privacy controls (CSS-selector masking, element blocking) and is more storage-efficient. OpenPanel is also open-source and can be self-hosted."
},
{
"question": "Are form inputs recorded?",
"answer": "No. All input field values are masked by default (`maskAllInputs: true`). You see that a user interacted with a field, but not what they typed. You can disable this on a per-field basis if needed."
},
{
"question": "How long are replays stored?",
"answer": "Replays are retained for 30 days. There is no limit on the number of sessions recorded."
},
{
"question": "Can I block specific parts of my UI from being recorded?",
"answer": "Yes. Add `data-openpanel-replay-block` to any element to replace it with a placeholder in the replay. Use `data-openpanel-replay-mask` to mask specific text. Both the attribute names and the CSS selectors they target are configurable."
},
{
"question": "Does session replay work with self-hosted OpenPanel?",
"answer": "Yes. When self-hosting, the replay script is served from your instance automatically. You can also override the script URL with the `scriptUrl` option if you host it on a CDN."
}
]
},
"cta": {
"label": "Start recording sessions",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}

View File

@@ -2,31 +2,32 @@
"slug": "session-tracking", "slug": "session-tracking",
"short_name": "Session tracking", "short_name": "Session tracking",
"seo": { "seo": {
"title": "Session Tracking Without Replays - Privacy-First", "title": "Session Tracking - Understand User Journeys | OpenPanel",
"description": "Understand user sessions from entry to exit-without recordings or privacy risk. See pages visited, events fired, and session duration with privacy-first analytics.", "description": "Understand user sessions from entry to exit. See pages visited, events fired, and session duration. Optionally add session replay to watch exactly what users did.",
"keywords": [ "keywords": [
"session tracking analytics", "session tracking analytics",
"user session tracking", "user session tracking",
"session analysis without replay" "session analysis",
"session replay analytics"
] ]
}, },
"hero": { "hero": {
"heading": "What happened in the session", "heading": "What happened in the session",
"subheading": "Pages visited, events fired, time spent. No recordings, no privacy risk. You still get the full picture.", "subheading": "Pages visited, events fired, time spent. Full structured session data—and optional session replay when you need to go deeper.",
"badges": [ "badges": [
"No session recordings",
"Privacy-first by design", "Privacy-first by design",
"Entry-to-exit visibility", "Entry-to-exit visibility",
"Sessions linked to events" "Sessions linked to events",
"Optional session replay"
] ]
}, },
"definition": { "definition": {
"title": "What is session tracking?", "title": "What is session tracking?",
"text": "A session is the **window of activity** between a user arriving on your site and leaving. It starts with an entry page, includes every page view and event along the way, and ends when the user goes idle or closes the tab.\n\nMost analytics tools either give you **too little** (aggregated page-view counts with no sense of flow) or **too much** (full session recordings that raise privacy concerns and take hours to review). OpenPanel sits in the middle: you get a **structured timeline** of what happened in each session, without recording a single pixel of the user's screen.\n\nFor every session, OpenPanel captures:\n\n- **Entry page and exit page** - where the user started and where they left\n- **Pages visited in order** - the path through your site or app\n- **Events fired** - signups, clicks, feature usage, or any custom event\n- **Session duration** - how long the session lasted\n- **Referrer and UTM parameters** - how the user got there\n- **Device, browser, and location** - context without fingerprinting\n\nThis means you can answer questions like:\n\n- **What pages do users visit before signing up?**\n- **Do users from organic search behave differently than paid traffic?**\n- **How long are sessions for users who convert vs. those who don't?**\n\nUnlike session replay tools (Hotjar, FullStory, LogRocket), there are **no recordings to watch**, **no PII captured on screen**, and **no consent banners** needed for video replay. You get the analytical value of sessions without the privacy overhead.\n\nSessions in OpenPanel connect directly to **events and user profiles**. Every event belongs to a session, and every session belongs to a user. This means funnels, retention, and user timelines all have session context built in." "text": "A session is the **window of activity** between a user arriving on your site and leaving. It starts with an entry page, includes every page view and event along the way, and ends when the user goes idle or closes the tab.\n\nMost analytics tools either give you **too little** (aggregated page-view counts with no sense of flow) or **too much** (full session recordings that can be slow to review). OpenPanel gives you both options:\n\n- **Session tracking** (always on) — a structured timeline of what happened in each session: pages visited, events fired, duration, referrer, and device context\n- **[Session replay](/features/session-replay)** (opt-in) — a playable recording of the session built on [rrweb](https://www.rrweb.io/), so you can see exactly what the user clicked and where they got confused\n\nFor every session, OpenPanel captures:\n\n- **Entry page and exit page** - where the user started and where they left\n- **Pages visited in order** - the path through your site or app\n- **Events fired** - signups, clicks, feature usage, or any custom event\n- **Session duration** - how long the session lasted\n- **Referrer and UTM parameters** - how the user got there\n- **Device, browser, and location** - context without fingerprinting\n\nThis means you can answer questions like:\n\n- **What pages do users visit before signing up?**\n- **Do users from organic search behave differently than paid traffic?**\n- **How long are sessions for users who convert vs. those who don't?**\n\nSession replay is **opt-in and off by default**. When disabled, the replay script is never loaded and adds no overhead. When enabled, it loads asynchronously as a separate script so your main analytics bundle stays lean.\n\nSessions in OpenPanel connect directly to **events and user profiles**. Every event belongs to a session, and every session belongs to a user. This means funnels, retention, and user timelines all have session context built in."
}, },
"capabilities_section": { "capabilities_section": {
"title": "What you get with session tracking", "title": "What you get with session tracking",
"intro": "Structured session data that answers real questions-without the privacy cost of recordings." "intro": "Structured session data that answers real questionswith optional replay when you need to see the full picture."
}, },
"capabilities": [ "capabilities": [
{ {
@@ -35,7 +36,7 @@
}, },
{ {
"title": "Page flow per session", "title": "Page flow per session",
"description": "View the ordered sequence of pages a user visited in a session. Understand navigation patterns without watching a recording." "description": "View the ordered sequence of pages a user visited in a session. Understand navigation patterns at a glance."
}, },
{ {
"title": "Events within a session", "title": "Events within a session",
@@ -50,8 +51,8 @@
"description": "Know how users arrived-organic search, paid campaign, direct link-and compare session quality across sources." "description": "Know how users arrived-organic search, paid campaign, direct link-and compare session quality across sources."
}, },
{ {
"title": "Device and location context", "title": "Session replay (opt-in)",
"description": "Capture browser, OS, and approximate location for each session. No fingerprinting-just standard request headers." "description": "Enable session replay to record and play back real user sessions. Privacy controls built ininputs masked by default. Loads async so it never bloats your bundle."
} }
], ],
"screenshots": [ "screenshots": [
@@ -63,7 +64,7 @@
{ {
"src": "/features/feature-sessions-details.webp", "src": "/features/feature-sessions-details.webp",
"alt": "Session events timeline showing user actions in order", "alt": "Session events timeline showing user actions in order",
"caption": "Every event tied to its session. Understand user journeys without replay tools." "caption": "Every event tied to its session. Drill into the timeline or open the replay."
} }
], ],
"how_it_works": { "how_it_works": {
@@ -80,29 +81,34 @@
}, },
{ {
"title": "Sessions connect to everything", "title": "Sessions connect to everything",
"description": "Each session links to the user profile, the events fired, and the pages visited. This means funnels, retention, and user timelines all include session context." "description": "Each session links to the user profile, the events fired, and the pages visited. Enable session replay to also record a playable video of the session."
} }
] ]
}, },
"use_cases": { "use_cases": {
"title": "Who uses session tracking", "title": "Who uses session tracking",
"intro": "Teams that need to understand user journeys without the overhead of session replays.", "intro": "Teams that need to understand user journeys, from structured data to full session replay.",
"items": [ "items": [
{ {
"title": "Product teams", "title": "Product teams",
"description": "Understand how users navigate your product. See the page flow and events in a session to identify friction points-without watching hours of recordings." "description": "Understand how users navigate your product. See the page flow and events in a session, then open a replay to see exactly where users got stuck."
}, },
{ {
"title": "Support and success teams", "title": "Support and success teams",
"description": "When a user reports an issue, pull up their recent sessions to see what pages they visited and what events they triggered. Context without asking \"can you describe what you did?\"" "description": "When a user reports an issue, pull up their recent sessions to see what pages they visited and what events they triggered. Open the replay for the full picture."
}, },
{ {
"title": "Privacy-conscious teams", "title": "Privacy-conscious teams",
"description": "Get session-level insights without recording user screens. No PII in screenshots, no video consent banners, no GDPR headaches from replay data." "description": "Session tracking works without cookies or recordings. Session replay is opt-in, with inputs masked by default and granular controls to block or mask any sensitive element."
} }
] ]
}, },
"related_features": [ "related_features": [
{
"slug": "session-replay",
"title": "Session replay",
"description": "Watch real user sessions. See clicks, scrolls, and form interactions played back in the dashboard."
},
{ {
"slug": "event-tracking", "slug": "event-tracking",
"title": "Event tracking", "title": "Event tracking",
@@ -119,8 +125,8 @@
"intro": "Common questions about session tracking with OpenPanel.", "intro": "Common questions about session tracking with OpenPanel.",
"items": [ "items": [
{ {
"question": "How is this different from session replay tools like Hotjar or FullStory?", "question": "Does OpenPanel have session replay?",
"answer": "Session replay tools record a video of the user's screen. OpenPanel doesn't record anything visual-it tracks structured data: which pages were visited, which events were triggered, and how long the session lasted. You get the analytical answers without the privacy cost or the hours spent watching recordings." "answer": "Yes. Session replay is available as an opt-in feature. Enable it by setting `sessionReplay: { enabled: true }` in your init config. When disabled, the replay script is never loaded. See the [session replay docs](/docs/session-replay) for setup and privacy options."
}, },
{ {
"question": "Do I need to set up session tracking separately?", "question": "Do I need to set up session tracking separately?",
@@ -132,7 +138,7 @@
}, },
{ {
"question": "Can I see individual user sessions?", "question": "Can I see individual user sessions?",
"answer": "Yes. You can view a user's session history, including the pages they visited and events they triggered in each session. This is available in the user profile view." "answer": "Yes. You can view a user's session history, including the pages they visited and events they triggered in each session. This is available in the user profile view. If session replay is enabled, you can also play back the session."
}, },
{ {
"question": "Does session tracking require cookies?", "question": "Does session tracking require cookies?",

View File

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

View File

@@ -0,0 +1,245 @@
---
title: "How to add session replay to your website"
description: "Add privacy-first session replay to any site in minutes using OpenPanel. See exactly what users do without recording sensitive data."
difficulty: beginner
timeToComplete: 10
date: 2026-02-27
updated: 2026-02-27
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install OpenPanel"
anchor: "install"
- name: "Enable session replay"
anchor: "enable"
- name: "Configure privacy controls"
anchor: "privacy"
- name: "View replays in the dashboard"
anchor: "view"
---
# How to add session replay to your website
This guide walks you through enabling [session replay](/features/session-replay) with OpenPanel. By the end, you'll be recording real user sessions you can play back in the dashboard to understand exactly what your users did.
Session replay captures clicks, scrolls, and interactions as structured data—not video. Privacy controls are built in, and the replay module loads asynchronously so it never slows down your main analytics.
## Prerequisites
- An OpenPanel account
- Your Client ID from the [OpenPanel dashboard](https://dashboard.openpanel.dev/onboarding)
- Either the `@openpanel/web` npm package installed, or access to add a script tag to your site
## Install OpenPanel [#install]
If you're starting fresh, add the OpenPanel script tag to your page. If you already have OpenPanel installed, skip to the next step.
```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,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
Or with npm:
```bash
npm install @openpanel/web
```
See the [Web SDK docs](/docs/sdks/web) or [Script tag docs](/docs/sdks/script) for a full install guide.
## Enable session replay [#enable]
Session replay is **off by default**. Enable it by adding `sessionReplay: { enabled: true }` to your init config.
### 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,
trackOutgoingLinks: true,
sessionReplay: {
enabled: true,
},
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
The replay script (`op1-replay.js`) is fetched automatically alongside the main script. Because it loads asynchronously, it doesn't affect page load time or the size of your main analytics bundle.
### NPM package
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
sessionReplay: {
enabled: true,
},
});
```
With the npm package, the replay module is a dynamic import resolved by your bundler. It is automatically code-split from your main bundle—if you don't enable replay, the module is never included.
### Next.js
For Next.js, enable replay in your `OpenPanelComponent`:
```tsx title="app/layout.tsx"
import { OpenPanelComponent } from '@openpanel/nextjs';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<OpenPanelComponent
clientId="YOUR_CLIENT_ID"
trackScreenViews={true}
sessionReplay={{
enabled: true,
}}
/>
{children}
</body>
</html>
);
}
```
## Configure privacy controls [#privacy]
Session replay captures real user behavior, so it's important to control what gets recorded. OpenPanel gives you several layers of control.
### Input masking (enabled by default)
All form input values are masked by default. The recorder sees that a user typed something, but not what they typed. You never need to add special attributes to password or credit card fields—they're masked automatically.
If you need to disable masking for a specific use case:
```ts
sessionReplay: {
enabled: true,
maskAllInputs: false,
}
```
### Block sensitive elements
Elements with `data-openpanel-replay-block` are replaced with a grey placeholder in the replay. The element and all its children are completely excluded from recording.
```html
<!-- This section will appear as a placeholder in replays -->
<div data-openpanel-replay-block>
<img src="user-avatar.jpg" alt="Profile photo" />
<p>Private user content</p>
</div>
```
You can also configure a CSS selector or class to block without adding data attributes to every element:
```ts
sessionReplay: {
enabled: true,
blockSelector: '.payment-form, .user-profile-card',
blockClass: 'no-replay',
}
```
### Mask specific text
To mask text within an element without blocking its layout from the replay, use `data-openpanel-replay-mask`:
```html
<p>
Account balance:
<span data-openpanel-replay-mask>$1,234.56</span>
</p>
```
The span's text appears as `***` in the replay while the surrounding layout remains visible.
Configure a custom selector to avoid adding attributes to every element:
```ts
sessionReplay: {
enabled: true,
maskTextSelector: '.balance, .account-number, [data-pii]',
}
```
### Ignore interactions
Use `ignoreSelector` to prevent interactions with specific elements from being captured. The element is still visible in the replay, but clicks and input events on it are not recorded.
```ts
sessionReplay: {
enabled: true,
ignoreSelector: '.internal-debug-toolbar',
}
```
## View replays in the dashboard [#view]
Navigate to your [OpenPanel dashboard](https://dashboard.openpanel.dev) and open the Sessions view. Any recorded session will show a replay button. Click it to play back the session from the beginning.
The replay timeline shows all events alongside the recording, so you can jump directly to a click, form submission, or page navigation.
Replays are also accessible from user profiles. Open any user's profile, find a session in their history, and click through to the replay.
## Performance considerations
The replay recorder buffers events locally and sends them to OpenPanel in chunks every 10 seconds (configurable via `flushIntervalMs`). On tab close or page hide, any remaining buffered events are flushed immediately.
The default chunk size limits are:
- **200 events per chunk** (`maxEventsPerChunk`)
- **1 MB per payload** (`maxPayloadBytes`)
These defaults work well for most sites. If you have pages with heavy DOM activity, you can lower `maxEventsPerChunk` to send smaller, more frequent chunks:
```ts
sessionReplay: {
enabled: true,
flushIntervalMs: 5000,
maxEventsPerChunk: 100,
}
```
## Next steps
- Read the [session replay docs](/docs/session-replay) for a full option reference
- Learn about [session tracking](/features/session-tracking) to understand what session data is available without replay
- See how [funnels](/features/funnels) and session replay work together to diagnose drop-offs
<Faqs>
<FaqItem question="Does session replay affect my page load speed?">
No. The replay module (`op1-replay.js`) loads as a separate async script after the page and the main analytics script. It does not block rendering or inflate your main bundle.
</FaqItem>
<FaqItem question="Is session replay enabled for all users?">
Yes, when enabled, all sessions are recorded by default. You can use the `sampleRate` option to record only a percentage of sessions if needed.
</FaqItem>
<FaqItem question="Are passwords and credit card numbers recorded?">
No. All input field values are masked by default (`maskAllInputs: true`). The recorder captures that a user typed something, but not the actual characters. Disable this only with a specific reason.
</FaqItem>
<FaqItem question="How long are replays kept?">
Replays are retained for 30 days. There is no limit on the number of sessions recorded.
</FaqItem>
<FaqItem question="Can I use session replay with self-hosted OpenPanel?">
Yes. The replay script is served from your self-hosted instance automatically. You can also use the `scriptUrl` option to load it from a custom CDN.
</FaqItem>
</Faqs>

View File

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

View File

@@ -3,6 +3,8 @@ title: Privacy Policy
description: Our privacy policy outlines how we handle your data, including usage information and cookies, to provide and improve our services. 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. 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. 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. - **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. - **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. - **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 ## Collecting and Using Your Personal Data
### Types of Data Collected ### 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 - Page URL and referrer
- First name and last name - Browser name and version
- Usage Data - 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 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.
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.
### Use of Your Personal Data ### 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. 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. Account data (email, name, billing) is processed within the EU. Our payment processor and transactional email provider operate under EU data protection standards.
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.
### Delete Your Personal Data ### 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. 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 ## 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. We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.

View File

@@ -3,6 +3,8 @@ title: Terms of Service
description: Legal terms and conditions governing the use of Openpanel's services and website. 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. Please read these terms and conditions carefully before using Our Service.
## Interpretation and Definitions ## Interpretation and Definitions
@@ -25,7 +27,7 @@ For the purposes of these Terms and Conditions:
- **Country** refers to: Sweden - **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. - **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. 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 ## Subscriptions
### Subscription period ### 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. 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 and Waiver
### Severability ### Severability

View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,45 +1,38 @@
import { import { ChevronRightIcon } from 'lucide-react';
BarChart3Icon,
ChevronRightIcon,
DollarSignIcon,
GlobeIcon,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { FeatureCard } from '@/components/feature-card'; import { FeatureCard } from '@/components/feature-card';
import { NotificationsIllustration } from '@/components/illustrations/notifications';
import { ProductAnalyticsIllustration } from '@/components/illustrations/product-analytics'; import { ProductAnalyticsIllustration } from '@/components/illustrations/product-analytics';
import { RetentionIllustration } from '@/components/illustrations/retention';
import { SessionReplayIllustration } from '@/components/illustrations/session-replay';
import { WebAnalyticsIllustration } from '@/components/illustrations/web-analytics'; import { WebAnalyticsIllustration } from '@/components/illustrations/web-analytics';
import { Section, SectionHeader } from '@/components/section'; import { Section, SectionHeader } from '@/components/section';
const features = [ function wrap(child: React.ReactNode) {
return <div className="h-48 overflow-hidden">{child}</div>;
}
const mediumFeatures = [
{ {
title: 'Revenue tracking', title: 'Retention',
description: description:
'Track revenue from your payments and get insights into your revenue sources.', 'Know how many users come back after day 1, day 7, day 30. Identify which behaviors predict long-term retention.',
icon: DollarSignIcon, illustration: wrap(<RetentionIllustration />),
link: { link: { href: '/features/retention', children: 'View retention' },
href: '/features/revenue-tracking',
children: 'More about revenue',
},
}, },
{ {
title: 'Profiles & Sessions', title: 'Session Replay',
description: description:
'Track individual users and their complete journey across your platform.', 'Watch real user sessions to see exactly what happened — clicks, scrolls, rage clicks. Privacy controls built in.',
icon: GlobeIcon, illustration: wrap(<SessionReplayIllustration />),
link: { link: { href: '/features/session-replay', children: 'See session replay' },
href: '/features/identify-users',
children: 'Identify your users',
},
}, },
{ {
title: 'Event Tracking', title: 'Notifications',
description: description:
'Capture every important interaction with flexible event tracking.', 'Get notified when a funnel is completed. Stay on top of key moments in your product without watching dashboards all day.',
icon: BarChart3Icon, illustration: wrap(<NotificationsIllustration />),
link: { link: { href: '/features/notifications', children: 'Set up notifications' },
href: '/features/event-tracking',
children: 'All about tracking',
},
}, },
]; ];
@@ -48,37 +41,39 @@ export function AnalyticsInsights() {
<Section className="container"> <Section className="container">
<SectionHeader <SectionHeader
className="mb-16" className="mb-16"
description="Combine web and product analytics in one platform. Track visitors, events, revenue, and user journeys, all with privacy-first tracking." description="From first page view to long-term retention — every touchpoint in one platform. No sampling, no data limits, no guesswork."
label="ANALYTICS & INSIGHTS" label="ANALYTICS & INSIGHTS"
title="See the full picture of your users and product performance" title="Everything you need to understand your users"
/> />
<div className="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard <FeatureCard
className="px-0 **:data-content:px-6" className="px-0 **:data-content:px-6"
description="Understand your website performance with privacy-first analytics and clear, actionable insights." description="Understand your website performance with privacy-first analytics. Track visitors, referrers, and page views without touching user cookies."
illustration={<WebAnalyticsIllustration />} illustration={<WebAnalyticsIllustration />}
title="Web Analytics" title="Web Analytics"
variant="large"
/> />
<FeatureCard <FeatureCard
className="px-0 **:data-content:px-6" className="px-0 **:data-content:px-6"
description="Turn raw data into clarity with real-time visualization of performance, behavior, and trends." description="Go beyond page views. Track custom events, understand user flows, and explore exactly how people use your product."
illustration={<ProductAnalyticsIllustration />} illustration={<ProductAnalyticsIllustration />}
title="Product Analytics" title="Product Analytics"
variant="large"
/> />
</div> </div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{features.map((feature) => ( {mediumFeatures.map((feature) => (
<FeatureCard <FeatureCard
className="px-0 pt-0 **:data-content:px-6"
description={feature.description} description={feature.description}
icon={feature.icon} illustration={feature.illustration}
key={feature.title} key={feature.title}
link={feature.link} link={feature.link}
title={feature.title} title={feature.title}
/> />
))} ))}
</div> </div>
<p className="mt-8 text-center"> <p className="mt-8 text-center">
<Link <Link
className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground" className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground"

View File

@@ -15,23 +15,23 @@ import { CollaborationChart } from './collaboration-chart';
const features = [ const features = [
{ {
title: 'Visualize your data', title: 'Flexible data visualization',
description: description:
'See your data in a visual way. You can create advanced reports and more to understand', 'Build line charts, bar charts, sankey flows, and custom dashboards. Combine metrics from any event into a single view.',
icon: ChartBarIcon, icon: ChartBarIcon,
slug: 'data-visualization', slug: 'data-visualization',
}, },
{ {
title: 'Share & Collaborate', title: 'Share & Collaborate',
description: description:
'Invite unlimited members with org-wide or project-level access. Share full dashboards or individual reports—publicly or behind a password.', 'Invite unlimited team members with org-wide or project-level access. Share dashboards publicly or lock them behind a password.',
icon: LayoutDashboardIcon, icon: LayoutDashboardIcon,
slug: 'share-and-collaborate', slug: 'share-and-collaborate',
}, },
{ {
title: 'Integrations', title: 'Integrations & Webhooks',
description: description:
'Get notified when new events are created, or forward specific events to your own systems with our easy-to-use integrations.', 'Forward events to your own systems or third-party tools. Connect OpenPanel to Slack, your data warehouse, or any webhook endpoint.',
icon: WorkflowIcon, icon: WorkflowIcon,
slug: 'integrations', slug: 'integrations',
}, },

View File

@@ -43,9 +43,9 @@ export function DataPrivacy() {
/> />
<div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard <FeatureCard
description="Privacy-first analytics without cookies, fingerprinting, or invasive tracking. Built for compliance and user trust." description="GDPR compliant and privacy-friendly analytics without cookies or invasive tracking. Data is EU hosted, and a Data Processing Agreement (DPA) is available to sign."
illustration={<PrivacyIllustration />} illustration={<PrivacyIllustration />}
title="Privacy-first" title="GDPR compliant"
variant="large" variant="large"
/> />
<FeatureCard <FeatureCard

View File

@@ -0,0 +1,68 @@
import { FeatureCard } from '@/components/feature-card';
import { ConversionsIllustration } from '@/components/illustrations/conversions';
import { GoogleSearchConsoleIllustration } from '@/components/illustrations/google-search-console';
import { RevenueIllustration } from '@/components/illustrations/revenue';
import { Section, SectionHeader } from '@/components/section';
function wrap(child: React.ReactNode) {
return <div className="h-48 overflow-hidden">{child}</div>;
}
const features = [
{
title: 'Revenue Tracking',
description:
'Connect payment events to track MRR and see which referrers drive the most revenue.',
illustration: wrap(<RevenueIllustration />),
link: {
href: '/features/revenue-tracking',
children: 'Track revenue',
},
},
{
title: 'Conversion Tracking',
description:
'Monitor conversion rates over time and break down by A/B variant, country, or device. Catch regressions before they cost you.',
illustration: wrap(<ConversionsIllustration />),
link: {
href: '/features/conversion',
children: 'Track conversions',
},
},
{
title: 'Google Search Console',
description:
'See which search queries bring organic traffic and how visitors convert after landing. Your SEO and product data, in one place.',
illustration: wrap(<GoogleSearchConsoleIllustration />),
link: {
href: '/features/integrations',
children: 'View integrations',
},
},
];
export function FeatureSpotlight() {
return (
<Section className="container">
<SectionHeader
className="mb-16"
description="OpenPanel goes beyond page views. Track revenue, monitor conversions, and connect your SEO data — all without switching tools."
label="GROWTH TOOLS"
title="Built for teams who ship and measure"
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{features.map((feature) => (
<FeatureCard
className="px-0 pt-0 **:data-content:px-6"
description={feature.description}
illustration={feature.illustration}
key={feature.title}
link={feature.link}
title={feature.title}
/>
))}
</div>
</Section>
);
}

View File

@@ -5,14 +5,14 @@ import {
CalendarIcon, CalendarIcon,
CookieIcon, CookieIcon,
CreditCardIcon, CreditCardIcon,
DatabaseIcon,
GithubIcon, GithubIcon,
ServerIcon, ShieldCheckIcon,
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { Competition } from '@/components/competition'; import { Competition } from '@/components/competition';
import { EuFlag } from '@/components/eu-flag';
import { GetStartedButton } from '@/components/get-started-button'; import { GetStartedButton } from '@/components/get-started-button';
import { Perks } from '@/components/perks'; import { Perks } from '@/components/perks';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -21,10 +21,10 @@ import { cn } from '@/lib/utils';
const perks = [ const perks = [
{ text: 'Free trial 30 days', icon: CalendarIcon }, { text: 'Free trial 30 days', icon: CalendarIcon },
{ text: 'No credit card required', icon: CreditCardIcon }, { text: 'No credit card required', icon: CreditCardIcon },
{ text: 'GDPR compliant', icon: ShieldCheckIcon },
{ text: 'EU hosted', icon: EuFlag },
{ text: 'Cookie-less tracking', icon: CookieIcon }, { text: 'Cookie-less tracking', icon: CookieIcon },
{ text: 'Open-source', icon: GithubIcon }, { text: 'Open-source', icon: GithubIcon },
{ text: 'Your data, your rules', icon: DatabaseIcon },
{ text: 'Self-hostable', icon: ServerIcon },
]; ];
const aspectRatio = 2946 / 1329; const aspectRatio = 2946 / 1329;
@@ -90,7 +90,7 @@ export function Hero() {
TRUSTED BY 1,000+ PROJECTS TRUSTED BY 1,000+ PROJECTS
</div> </div>
<h1 className="font-semibold text-4xl leading-[1.1] md:text-5xl"> <h1 className="font-semibold text-4xl leading-[1.1] md:text-5xl">
OpenPanel - The open-source alternative to <Competition /> The open-source alternative to <Competition />
</h1> </h1>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
An open-source web and product analytics platform that combines the An open-source web and product analytics platform that combines the

View File

@@ -55,6 +55,9 @@ export function Pricing() {
<div className="col mt-8 w-full items-baseline md:mt-auto"> <div className="col mt-8 w-full items-baseline md:mt-auto">
{selected ? ( {selected ? (
<> <>
<span className="mb-2 rounded-full bg-primary/10 px-2.5 py-0.5 font-medium text-primary text-xs">
30-day free trial
</span>
<div className="row items-end gap-3"> <div className="row items-end gap-3">
<NumberFlow <NumberFlow
className="font-bold text-5xl" className="font-bold text-5xl"
@@ -67,9 +70,6 @@ export function Pricing() {
locales={'en-US'} locales={'en-US'}
value={selected.price} value={selected.price}
/> />
<span className="mb-2 rounded-full bg-primary/10 px-2.5 py-0.5 font-medium text-primary text-xs">
30-day free trial
</span>
</div> </div>
<div className="row w-full justify-between"> <div className="row w-full justify-between">
<span className="-mt-2 text-muted-foreground/80 text-sm"> <span className="-mt-2 text-muted-foreground/80 text-sm">

View File

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

View File

@@ -1,5 +1,6 @@
import { AnalyticsInsights } from './_sections/analytics-insights'; import { AnalyticsInsights } from './_sections/analytics-insights';
import { Collaboration } from './_sections/collaboration'; import { Collaboration } from './_sections/collaboration';
import { FeatureSpotlight } from './_sections/feature-spotlight';
import { CtaBanner } from './_sections/cta-banner'; import { CtaBanner } from './_sections/cta-banner';
import { DataPrivacy } from './_sections/data-privacy'; import { DataPrivacy } from './_sections/data-privacy';
import { Faq } from './_sections/faq'; import { Faq } from './_sections/faq';
@@ -57,6 +58,7 @@ export default function HomePage() {
<Hero /> <Hero />
<WhyOpenPanel /> <WhyOpenPanel />
<AnalyticsInsights /> <AnalyticsInsights />
<FeatureSpotlight />
<Collaboration /> <Collaboration />
<Testimonials /> <Testimonials />
<Pricing /> <Pricing />

View File

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

View File

@@ -1,4 +1,5 @@
import type { MetadataRoute } from 'next'; import type { MetadataRoute } from 'next';
import { getAllForSlugs } from '@/lib/for';
import { url } from '@/lib/layout.shared'; import { url } from '@/lib/layout.shared';
import { import {
articleSource, articleSource,
@@ -14,6 +15,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const docs = await source.getPages(); const docs = await source.getPages();
const pages = await pageSource.getPages(); const pages = await pageSource.getPages();
const guides = await guideSource.getPages(); const guides = await guideSource.getPages();
const forSlugs = await getAllForSlugs();
return [ return [
{ {
url: url('/'), url: url('/'),
@@ -119,5 +121,17 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly' as const, changeFrequency: 'monthly' as const,
priority: 0.8, priority: 0.8,
})), })),
{
url: url('/for'),
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.7,
},
...forSlugs.map((slug) => ({
url: url(`/for/${slug}`),
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
})),
]; ];
} }

View File

@@ -0,0 +1,37 @@
function star(cx: number, cy: number, outerR: number, innerR: number) {
const pts: string[] = [];
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? outerR : innerR;
const angle = (i * Math.PI) / 5 - Math.PI / 2;
pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
}
return pts.join(' ');
}
const STARS = Array.from({ length: 12 }, (_, i) => {
const angle = (i * 30 - 90) * (Math.PI / 180);
return {
x: 12 + 5 * Math.cos(angle),
y: 8 + 5 * Math.sin(angle),
};
});
export function EuFlag({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 16"
xmlns="http://www.w3.org/2000/svg"
>
<rect fill="#003399" height="16" rx="1.5" width="24" />
{STARS.map((s, i) => (
<polygon
// biome-ignore lint/suspicious/noArrayIndexKey: static data
key={i}
fill="#FFCC00"
points={star(s.x, s.y, 1.1, 0.45)}
/>
))}
</svg>
);
}

View File

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

View File

@@ -0,0 +1,71 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
const variantA = [28, 31, 29, 34, 32, 36, 35, 38, 37, 40, 39, 42];
const variantB = [28, 30, 32, 35, 38, 37, 40, 42, 44, 43, 47, 50];
export function ConversionsIllustration() {
return (
<div className="h-full col gap-3 px-4 pb-3 pt-5">
{/* A/B variant cards */}
<div className="row gap-3">
<div className="col flex-1 gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-1.5">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
Variant A
</span>
</div>
<span className="font-bold font-mono text-xl">28.4%</span>
<SimpleChart
height={24}
points={variantA}
strokeColor="var(--foreground)"
width={200}
/>
</div>
<div className="col flex-1 gap-1 rounded-xl border border-emerald-500/30 bg-card p-3 transition-all delay-75 duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-1.5">
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 font-mono text-[9px] text-emerald-600 dark:text-emerald-400">
Variant B
</span>
</div>
<span className="font-bold font-mono text-xl text-emerald-500">
41.2%
</span>
<SimpleChart
height={24}
points={variantB}
strokeColor="rgb(34, 197, 94)"
width={200}
/>
</div>
</div>
{/* Breakdown label */}
<div className="col gap-1 rounded-xl border bg-card/60 px-3 py-2.5">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
Breakdown by experiment variant
</span>
<div className="row items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-muted">
<div
className="h-1 rounded-full bg-foreground/50"
style={{ width: '57%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">A: 57%</span>
</div>
<div className="row items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-muted">
<div
className="h-1 rounded-full bg-emerald-500"
style={{ width: '82%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">B: 82%</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
const queries = [
{
query: 'openpanel analytics',
clicks: 312,
impressions: '4.1k',
pos: 1.2,
},
{
query: 'open source mixpanel alternative',
clicks: 187,
impressions: '3.8k',
pos: 2.4,
},
{
query: 'web analytics without cookies',
clicks: 98,
impressions: '2.2k',
pos: 4.7,
},
];
export function GoogleSearchConsoleIllustration() {
return (
<div className="col h-full gap-2 px-4 pt-5 pb-3">
{/* Top stats */}
<div className="row mb-1 gap-2">
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Clicks
</span>
<span className="font-bold font-mono text-sm">740</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Impr.
</span>
<span className="font-bold font-mono text-sm">13k</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Avg. CTR
</span>
<span className="font-bold font-mono text-sm">5.7%</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Avg. Pos
</span>
<span className="font-bold font-mono text-sm">2.8</span>
</div>
</div>
{/* Query table */}
<div className="flex-1 overflow-hidden rounded-xl border border-border bg-card">
<div className="row border-border border-b px-3 py-1.5">
<span className="flex-1 text-[8px] text-muted-foreground uppercase tracking-wider">
Query
</span>
<span className="w-10 text-right text-[8px] text-muted-foreground uppercase tracking-wider">
Pos
</span>
</div>
{queries.map((q, i) => (
<div
className="row items-center border-border/50 border-b px-3 py-1.5 last:border-0"
key={q.query}
style={{ opacity: 1 - i * 0.18 }}
>
<span className="flex-1 truncate text-[9px]">{q.query}</span>
<span className="w-10 text-right font-mono text-[9px] text-muted-foreground">
{q.pos}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { CheckCircleIcon } from 'lucide-react';
export function NotificationsIllustration() {
return (
<div className="col h-full justify-center gap-3 px-6 py-4">
{/* Funnel completion notification */}
<div className="col gap-2 rounded-xl border border-border bg-card p-4 shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-2">
<CheckCircleIcon className="size-4 shrink-0 text-emerald-500" />
<span className="font-semibold text-xs">Funnel completed</span>
<span className="ml-auto text-[9px] text-muted-foreground">
just now
</span>
</div>
<p className="font-medium text-sm">Signup Flow 142 today</p>
<div className="row items-center gap-2">
<div className="h-1.5 flex-1 rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-emerald-500"
style={{ width: '71%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">
71% conversion
</span>
</div>
</div>
{/* Notification rule */}
<div className="col gap-1.5 px-3 opacity-80">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
Notification rule
</span>
<div className="row flex-wrap items-center gap-1.5">
<span className="text-[9px] text-muted-foreground">When</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
Signup Flow
</span>
<span className="text-[9px] text-muted-foreground">completes </span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
#growth
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
const cohorts = [
{ label: 'Week 1', values: [100, 68, 45, 38, 31] },
{ label: 'Week 2', values: [100, 72, 51, 42, 35] },
{ label: 'Week 3', values: [100, 65, 48, 39, null] },
{ label: 'Week 4', values: [100, 70, null, null, null] },
];
const headers = ['Day 0', 'Day 1', 'Day 7', 'Day 14', 'Day 30'];
function cellStyle(v: number | null) {
if (v === null) {
return {
backgroundColor: 'transparent',
borderColor: 'var(--border)',
color: 'var(--muted-foreground)',
};
}
const opacity = 0.12 + (v / 100) * 0.7;
return {
backgroundColor: `rgba(34, 197, 94, ${opacity})`,
borderColor: `rgba(34, 197, 94, 0.3)`,
color: v > 55 ? 'rgba(0,0,0,0.75)' : 'var(--foreground)',
};
}
export function RetentionIllustration() {
return (
<div className="h-full px-4 pb-3 pt-5">
<div className="col h-full gap-1.5">
<div className="row gap-1">
<div className="w-12 shrink-0" />
{headers.map((h) => (
<div
key={h}
className="flex-1 text-center text-[9px] text-muted-foreground"
>
{h}
</div>
))}
</div>
{cohorts.map(({ label, values }) => (
<div key={label} className="row flex-1 gap-1">
<div className="flex w-12 shrink-0 items-center text-[9px] text-muted-foreground">
{label}
</div>
{values.map((v, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static data
key={i}
className="flex flex-1 items-center justify-center rounded border text-[9px] font-medium transition-all duration-300 group-hover:scale-[1.03]"
style={cellStyle(v)}
>
{v !== null ? `${v}%` : '—'}
</div>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
const revenuePoints = [28, 34, 31, 40, 37, 44, 41, 50, 47, 56, 59, 65];
const referrers = [
{ name: 'google.com', amount: '$3,840', pct: 46 },
{ name: 'twitter.com', amount: '$1,920', pct: 23 },
{ name: 'github.com', amount: '$1,260', pct: 15 },
{ name: 'direct', amount: '$1,400', pct: 16 },
];
export function RevenueIllustration() {
return (
<div className="h-full col gap-3 px-4 pb-3 pt-5">
{/* MRR stat + chart */}
<div className="row gap-3">
<div className="col gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
MRR
</span>
<span className="font-bold font-mono text-xl text-emerald-500">
$8,420
</span>
<span className="text-[9px] text-emerald-500"> 12% this month</span>
</div>
<div className="col flex-1 gap-1 rounded-xl border bg-card px-3 py-2">
<span className="text-[9px] text-muted-foreground">MRR over time</span>
<SimpleChart
className="mt-1 flex-1"
height={36}
points={revenuePoints}
strokeColor="rgb(34, 197, 94)"
width={400}
/>
</div>
</div>
{/* Revenue by referrer */}
<div className="flex-1 overflow-hidden rounded-xl border bg-card">
<div className="row border-b border-border px-3 py-1.5">
<span className="flex-1 text-[8px] uppercase tracking-wider text-muted-foreground">
Referrer
</span>
<span className="text-[8px] uppercase tracking-wider text-muted-foreground">
Revenue
</span>
</div>
{referrers.map((r) => (
<div
className="row items-center gap-2 border-b border-border/50 px-3 py-1.5 last:border-0"
key={r.name}
>
<span className="text-[9px] text-muted-foreground flex-none w-20 truncate">
{r.name}
</span>
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<div
className="h-1 rounded-full bg-emerald-500/70"
style={{ width: `${r.pct}%` }}
/>
</div>
<span className="font-mono text-[9px] text-emerald-500 flex-none">
{r.amount}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { PlayIcon } from 'lucide-react';
export function SessionReplayIllustration() {
return (
<div className="h-full px-6 pb-3 pt-4">
<div className="col h-full overflow-hidden rounded-xl border border-border bg-background shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
{/* Browser chrome */}
<div className="row shrink-0 items-center gap-1.5 border-b border-border bg-muted/30 px-3 py-2">
<div className="h-2 w-2 rounded-full bg-red-400" />
<div className="h-2 w-2 rounded-full bg-yellow-400" />
<div className="h-2 w-2 rounded-full bg-green-400" />
<div className="mx-2 flex-1 rounded bg-background/80 px-2 py-0.5 text-[8px] text-muted-foreground">
app.example.com/pricing
</div>
</div>
{/* Page content */}
<div className="relative flex-1 overflow-hidden p-3">
<div className="mb-2 h-2 w-20 rounded-full bg-muted/60" />
<div className="mb-4 h-2 w-32 rounded-full bg-muted/40" />
<div className="row mb-3 gap-2">
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
</div>
<div className="mb-2 h-2 w-28 rounded-full bg-muted/30" />
<div className="h-2 w-24 rounded-full bg-muted/20" />
{/* Click heatspot */}
<div
className="absolute"
style={{ left: '62%', top: '48%' }}
>
<div className="h-4 w-4 animate-pulse rounded-full border-2 border-blue-500/70 bg-blue-500/20" />
</div>
<div
className="absolute"
style={{ left: '25%', top: '32%' }}
>
<div className="h-2.5 w-2.5 rounded-full border border-blue-500/40 bg-blue-500/25" />
</div>
{/* Cursor trail */}
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
style={{ overflow: 'visible' }}
>
<path
d="M 18% 22% Q 42% 28% 62% 48%"
fill="none"
stroke="rgb(59 130 246 / 0.35)"
strokeDasharray="3 2"
strokeWidth="1"
/>
</svg>
{/* Cursor */}
<div
className="absolute"
style={{
left: 'calc(62% + 8px)',
top: 'calc(48% + 6px)',
}}
>
<svg fill="none" height="12" viewBox="0 0 10 12" width="10">
<path
d="M0 0L0 10L3 7L5 11L6.5 10.5L4.5 6.5L8 6L0 0Z"
fill="var(--foreground)"
/>
</svg>
</div>
</div>
{/* Playback bar */}
<div className="row shrink-0 items-center gap-2 border-t border-border bg-muted/20 px-3 py-2">
<PlayIcon className="size-3 shrink-0 text-muted-foreground" />
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-muted">
<div
className="absolute left-0 top-0 h-1 rounded-full bg-blue-500"
style={{ width: '42%' }}
/>
</div>
<span className="font-mono text-[8px] text-muted-foreground">
0:52 / 2:05
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,188 +1,165 @@
'use client'; 'use client';
import { SimpleChart } from '@/components/simple-chart';
import { cn } from '@/lib/utils';
import NumberFlow from '@number-flow/react'; import NumberFlow from '@number-flow/react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowUpIcon } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const TRAFFIC_SOURCES = [ const VISITOR_DATA = [1840, 2100, 1950, 2400, 2250, 2650, 2980];
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const STATS = [
{ label: 'Visitors', value: 4128, formatted: null, change: 12, up: true },
{ label: 'Page views', value: 12438, formatted: '12.4k', change: 8, up: true },
{ label: 'Bounce rate', value: null, formatted: '42%', change: 3, up: false },
{ label: 'Avg. session', value: null, formatted: '3m 23s', change: 5, up: true },
];
const SOURCES = [
{ {
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com', icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
name: 'Google', name: 'google.com',
percentage: 49, pct: 49,
value: 2039,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Finstagram.com',
name: 'Instagram',
percentage: 23,
value: 920,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ffacebook.com',
name: 'Facebook',
percentage: 18,
value: 750,
}, },
{ {
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com', icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
name: 'Twitter', name: 'twitter.com',
percentage: 10, pct: 21,
value: 412, },
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgithub.com',
name: 'github.com',
pct: 14,
}, },
]; ];
const COUNTRIES = [ function AreaChart({ data }: { data: number[] }) {
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 }, const max = Math.max(...data);
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 }, const w = 400;
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 }, const h = 64;
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 }, const xStep = w / (data.length - 1);
]; const pts = data.map((v, i) => ({ x: i * xStep, y: h - (v / max) * h }));
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
const area = `${line} L ${w},${h} L 0,${h} Z`;
const last = pts[pts.length - 1];
return (
<svg className="w-full" viewBox={`0 0 ${w} ${h + 4}`}>
<defs>
<linearGradient id="wa-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgb(59 130 246)" stopOpacity="0.25" />
<stop offset="100%" stopColor="rgb(59 130 246)" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#wa-fill)" />
<path
d={line}
fill="none"
stroke="rgb(59 130 246)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<circle cx={last.x} cy={last.y} fill="rgb(59 130 246)" r="3" />
<circle
cx={last.x}
cy={last.y}
fill="none"
r="6"
stroke="rgb(59 130 246)"
strokeOpacity="0.3"
strokeWidth="1.5"
/>
</svg>
);
}
export function WebAnalyticsIllustration() { export function WebAnalyticsIllustration() {
const [currentSourceIndex, setCurrentSourceIndex] = useState(0); const [liveVisitors, setLiveVisitors] = useState(47);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const values = [47, 51, 44, 53, 49, 56];
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length); let i = 0;
}, 3000); const id = setInterval(() => {
i = (i + 1) % values.length;
return () => clearInterval(interval); setLiveVisitors(values[i]);
}, 2500);
return () => clearInterval(id);
}, []); }, []);
return ( return (
<div className="px-12 group aspect-video"> <div className="aspect-video col gap-2.5 p-5">
<div className="relative h-full col"> {/* Header */}
<MetricCard <div className="row items-center justify-between">
title="Session duration" <div className="row items-center gap-1.5">
value="3m 23s" <span className="relative flex h-1.5 w-1.5">
change="3%" <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]} <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
color="var(--foreground)" </span>
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300" <span className="text-[10px] font-medium text-muted-foreground">
/> <NumberFlow value={liveVisitors} /> online now
<MetricCard
title="Bounce rate"
value="46%"
change="3%"
chartPoints={[10, 46, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="var(--foreground)"
className="absolute w-full -rotate-2 -left-2 top-12 group-hover:-translate-y-1 group-hover:rotate-0 transition-all duration-300"
/>
<div className="col gap-4 w-[80%] md:w-[70%] ml-auto mt-auto">
<BarCell
{...TRAFFIC_SOURCES[currentSourceIndex]}
className="group-hover:scale-105 transition-all duration-300"
/>
<BarCell
{...TRAFFIC_SOURCES[
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
]}
className="group-hover:scale-105 transition-all duration-300"
/>
</div>
</div>
</div>
);
}
function MetricCard({
title,
value,
change,
chartPoints,
color,
className,
}: {
title: string;
value: string;
change: string;
chartPoints: number[];
color: string;
className?: string;
}) {
return (
<div className={cn('col bg-card rounded-lg p-4 pb-6 border', className)}>
<div className="row items-end justify-between">
<div>
<div className="text-muted-foreground text-sm">{title}</div>
<div className="text-2xl font-semibold font-mono">{value}</div>
</div>
<div className="row gap-2 items-center font-mono font-medium">
<ArrowUpIcon className="size-3" strokeWidth={3} />
<div>{change}</div>
</div>
</div>
<SimpleChart
width={400}
height={30}
points={chartPoints}
strokeColor={color}
className="mt-4"
/>
</div>
);
}
function BarCell({
icon,
name,
percentage,
value,
className,
}: {
icon: string;
name: string;
percentage: number;
value: number;
className?: string;
}) {
return (
<div
className={cn(
'relative p-4 py-2 bg-card rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.3)] border',
className,
)}
>
<div
className="absolute bg-background bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
style={{
width: `${percentage}%`,
}}
/>
<div className="relative row justify-between ">
<div className="row gap-2 items-center font-medium text-sm">
{icon.startsWith('http') ? (
<Image
alt="serie icon"
className="max-h-4 rounded-[2px] object-contain"
src={icon}
width={16}
height={16}
/>
) : (
<div className="text-2xl">{icon}</div>
)}
<AnimatePresence mode="popLayout">
<motion.div
key={name}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
>
{name}
</motion.div>
</AnimatePresence>
</div>
<div className="row gap-3 font-mono text-sm">
<span className="text-muted-foreground">
<NumberFlow value={percentage} />%
</span> </span>
<NumberFlow value={value} locales={'en-US'} />
</div> </div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] text-muted-foreground">
Last 7 days
</span>
</div>
{/* KPI tiles */}
<div className="grid grid-cols-4 gap-1.5">
{STATS.map((stat) => (
<div
className="col gap-0.5 rounded-lg border bg-card px-2 py-1.5"
key={stat.label}
>
<span className="text-[8px] text-muted-foreground">{stat.label}</span>
<span className="font-mono font-semibold text-xs leading-tight">
{stat.formatted ??
(stat.value !== null ? (
<NumberFlow locales="en-US" value={stat.value} />
) : null)}
</span>
<span
className={`text-[8px] ${stat.up ? 'text-emerald-500' : 'text-red-400'}`}
>
{stat.up ? '↑' : '↓'} {stat.change}%
</span>
</div>
))}
</div>
{/* Area chart */}
<div className="flex-1 col gap-1 overflow-hidden rounded-xl border bg-card px-3 pt-2 pb-1">
<span className="text-[8px] text-muted-foreground">Unique visitors</span>
<AreaChart data={VISITOR_DATA} />
<div className="row justify-between px-0.5">
{DAYS.map((d) => (
<span className="text-[7px] text-muted-foreground" key={d}>
{d}
</span>
))}
</div>
</div>
{/* Traffic sources */}
<div className="row gap-1.5">
{SOURCES.map((src) => (
<div
className="row flex-1 items-center gap-1.5 overflow-hidden rounded-lg border bg-card px-2 py-1.5"
key={src.name}
>
<Image
alt={src.name}
className="rounded-[2px] object-contain"
height={10}
src={src.icon}
width={10}
/>
<span className="flex-1 truncate text-[9px]">{src.name}</span>
<span className="font-mono text-[9px] text-muted-foreground">
{src.pct}%
</span>
</div>
))}
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,13 @@
import type React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
type PerkIcon = LucideIcon | React.ComponentType<{ className?: string }>;
export function Perks({ export function Perks({
perks, perks,
className, className,
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) { }: { perks: { text: string; icon: PerkIcon }[]; className?: string }) {
return ( return (
<ul className={cn('grid grid-cols-2 gap-2', className)}> <ul className={cn('grid grid-cols-2 gap-2', className)}>
{perks.map((perk) => ( {perks.map((perk) => (

View File

@@ -3,4 +3,15 @@
- `trackScreenViews` - If true, the library will automatically track screen views (default: false) - `trackScreenViews` - If true, the library will automatically track screen views (default: false)
- `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false) - `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false)
- `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false) - `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false)
- `sessionReplay` - Session replay configuration object (default: disabled). See [session replay docs](/docs/session-replay) for full options.
- `enabled` - Enable session replay recording (default: false)
- `maskAllInputs` - Mask all input field values (default: true)
- `maskTextSelector` - CSS selector for text elements to mask (default: `[data-openpanel-replay-mask]`)
- `blockSelector` - CSS selector for elements to replace with a placeholder (default: `[data-openpanel-replay-block]`)
- `blockClass` - Class name that blocks elements from being recorded
- `ignoreSelector` - CSS selector for elements excluded from interaction tracking
- `flushIntervalMs` - How often (ms) recorded events are sent to the server (default: 10000)
- `maxEventsPerChunk` - Maximum events per payload chunk (default: 200)
- `maxPayloadBytes` - Maximum payload size in bytes (default: 1048576)
- `scriptUrl` - Custom URL for the replay script (script-tag builds only)

View File

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

View File

@@ -13,7 +13,7 @@ import { Button } from '../ui/button';
const validator = zSignInEmail; const validator = zSignInEmail;
type IForm = z.infer<typeof validator>; type IForm = z.infer<typeof validator>;
export function SignInEmailForm() { export function SignInEmailForm({ isLastUsed }: { isLastUsed?: boolean }) {
const trpc = useTRPC(); const trpc = useTRPC();
const mutation = useMutation( const mutation = useMutation(
trpc.auth.signInEmail.mutationOptions({ trpc.auth.signInEmail.mutationOptions({
@@ -54,9 +54,16 @@ export function SignInEmailForm() {
type="password" type="password"
className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20" className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20"
/> />
<Button type="submit" size="lg"> <div className="relative">
<Button type="submit" size="lg" className="w-full">
Sign in Sign in
</Button> </Button>
{isLastUsed && (
<span className="absolute -top-2 right-3 text-[10px] font-medium bg-highlight text-white px-1.5 py-0.5 rounded-full leading-none">
Used last time
</span>
)}
</div>
<button <button
type="button" type="button"
onClick={() => onClick={() =>

View File

@@ -5,7 +5,8 @@ import { Button } from '../ui/button';
export function SignInGithub({ export function SignInGithub({
type, type,
inviteId, inviteId,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) { isLastUsed,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string; isLastUsed?: boolean }) {
const trpc = useTRPC(); const trpc = useTRPC();
const mutation = useMutation( const mutation = useMutation(
trpc.auth.signInOAuth.mutationOptions({ trpc.auth.signInOAuth.mutationOptions({
@@ -21,6 +22,7 @@ export function SignInGithub({
if (type === 'sign-up') return 'Sign up with Github'; if (type === 'sign-up') return 'Sign up with Github';
}; };
return ( return (
<div className="relative">
<Button <Button
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0" className="w-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0"
size="lg" size="lg"
@@ -43,5 +45,11 @@ export function SignInGithub({
</svg> </svg>
{title()} {title()}
</Button> </Button>
{isLastUsed && (
<span className="absolute -top-2 right-3 text-[10px] font-medium bg-highlight text-white px-1.5 py-0.5 rounded-full leading-none">
Used last time
</span>
)}
</div>
); );
} }

View File

@@ -1,11 +1,16 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { useTRPC } from '@/integrations/trpc/react';
export function SignInGoogle({ export function SignInGoogle({
type, type,
inviteId, inviteId,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) { isLastUsed,
}: {
type: 'sign-in' | 'sign-up';
inviteId?: string;
isLastUsed?: boolean;
}) {
const trpc = useTRPC(); const trpc = useTRPC();
const mutation = useMutation( const mutation = useMutation(
trpc.auth.signInOAuth.mutationOptions({ trpc.auth.signInOAuth.mutationOptions({
@@ -14,46 +19,57 @@ export function SignInGoogle({
window.location.href = res.url; window.location.href = res.url;
} }
}, },
}), })
); );
const title = () => { const title = () => {
if (type === 'sign-in') return 'Sign in with Google'; if (type === 'sign-in') {
if (type === 'sign-up') return 'Sign up with Google'; return 'Sign in with Google';
}
if (type === 'sign-up') {
return 'Sign up with Google';
}
}; };
return ( return (
<div className="relative">
<Button <Button
className="w-full bg-background hover:bg-def-100 border border-def-300 text-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0" className="w-full border border-def-300 bg-background text-foreground shadow-sm transition-all duration-200 hover:bg-def-100 hover:shadow-md [&_svg]:shrink-0"
size="lg"
onClick={() => onClick={() =>
mutation.mutate({ mutation.mutate({
provider: 'google', provider: 'google',
inviteId: type === 'sign-up' ? inviteId : undefined, inviteId: type === 'sign-up' ? inviteId : undefined,
}) })
} }
size="lg"
> >
<svg <svg
className="size-4 mr-2" className="mr-2 size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
> >
<path <path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/> />
<path <path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/> />
<path <path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/> />
<path <path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/> />
</svg> </svg>
{title()} {title()}
</Button> </Button>
{isLastUsed && (
<span className="absolute -top-2 right-3 rounded-full bg-highlight px-1.5 py-0.5 font-medium text-[10px] text-white leading-none">
Used last time
</span>
)}
</div>
); );
} }

View File

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

View File

@@ -1,7 +1,7 @@
import { formatDateTime, formatTime } from '@/utils/date'; import { DropdownMenuSeparator } from '@radix-ui/react-dropdown-menu';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns'; import { toast } from 'sonner';
import { ColumnCreatedAt } from '@/components/column-created-at'; import { ColumnCreatedAt } from '@/components/column-created-at';
import CopyInput from '@/components/forms/copy-input'; import CopyInput from '@/components/forms/copy-input';
import { createActionColumn } from '@/components/ui/data-table/data-table-helpers'; import { createActionColumn } from '@/components/ui/data-table/data-table-helpers';
@@ -10,9 +10,6 @@ import { handleError, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals'; import { pushModal, showConfirm } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { clipboard } from '@/utils/clipboard'; import { clipboard } from '@/utils/clipboard';
import { DropdownMenuSeparator } from '@radix-ui/react-dropdown-menu';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
export function useColumns() { export function useColumns() {
const columns: ColumnDef<RouterOutputs['client']['list'][number]>[] = [ const columns: ColumnDef<RouterOutputs['client']['list'][number]>[] = [
@@ -51,7 +48,7 @@ export function useColumns() {
queryClient.invalidateQueries(trpc.client.list.pathFilter()); queryClient.invalidateQueries(trpc.client.list.pathFilter());
}, },
onError: handleError, onError: handleError,
}), })
); );
return ( return (
<> <>
@@ -67,7 +64,6 @@ export function useColumns() {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
variant="destructive"
onClick={() => { onClick={() => {
showConfirm({ showConfirm({
title: 'Revoke client', title: 'Revoke client',
@@ -79,6 +75,7 @@ export function useColumns() {
}, },
}); });
}} }}
variant="destructive"
> >
Revoke Revoke
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -1,17 +1,16 @@
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import { PlusIcon } from 'lucide-react';
import { useColumns } from './columns';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/ui/data-table/data-table'; import { DataTable } from '@/components/ui/data-table/data-table';
import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar'; import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar';
import { useTable } from '@/components/ui/data-table/use-table'; import { useTable } from '@/components/ui/data-table/use-table';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { PlusIcon } from 'lucide-react';
import { useColumns } from './columns';
type Props = { interface Props {
query: UseQueryResult<RouterOutputs['client']['list'], unknown>; query: UseQueryResult<RouterOutputs['client']['list'], unknown>;
}; }
export const ClientsTable = ({ query }: Props) => { export const ClientsTable = ({ query }: Props) => {
const columns = useColumns(); const columns = useColumns();
@@ -30,13 +29,13 @@ export const ClientsTable = ({ query }: Props) => {
<DataTableToolbar table={table}> <DataTableToolbar table={table}>
<Button <Button
icon={PlusIcon} icon={PlusIcon}
responsive
onClick={() => pushModal('AddClient')} onClick={() => pushModal('AddClient')}
responsive
> >
Create client Create client
</Button> </Button>
</DataTableToolbar> </DataTableToolbar>
<DataTable table={table} loading={isLoading} /> <DataTable loading={isLoading} table={table} />
</> </>
); );
}; };

View File

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

View File

@@ -1,25 +1,20 @@
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { Link } from '@tanstack/react-router';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { EventIcon } from './event-icon';
import { Tooltiper } from '@/components/ui/tooltip'; import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import { Link } from '@tanstack/react-router';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceEventMinimal | IServiceEvent; type EventListItemProps = IServiceEventMinimal | IServiceEvent;
export function EventListItem(props: EventListItemProps) { export function EventListItem(props: EventListItemProps) {
const { organizationId, projectId } = useAppParams(); const { organizationId, projectId } = useAppParams();
const { createdAt, name, path, duration, meta } = props; const { createdAt, name, path, meta } = props;
const profile = 'profile' in props ? props.profile : null; const profile = 'profile' in props ? props.profile : null;
const number = useNumber();
const renderName = () => { const renderName = () => {
if (name === 'screen_view') { if (name === 'screen_view') {
if (path.includes('/')) { if (path.includes('/')) {
@@ -32,24 +27,15 @@ export function EventListItem(props: EventListItemProps) {
return name.replace(/_/g, ' '); return name.replace(/_/g, ' ');
}; };
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
const isMinimal = 'minimal' in props; const isMinimal = 'minimal' in props;
return ( return (
<>
<button <button
type="button" className={cn(
'card flex w-full items-center justify-between rounded-lg p-4 transition-colors hover:bg-light-background',
meta?.conversion &&
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`
)}
onClick={() => { onClick={() => {
if (!isMinimal) { if (!isMinimal) {
pushModal('EventDetails', { pushModal('EventDetails', {
@@ -59,20 +45,12 @@ export function EventListItem(props: EventListItemProps) {
}); });
} }
}} }}
className={cn( type="button"
'card hover:bg-light-background flex w-full items-center justify-between rounded-lg p-4 transition-colors',
meta?.conversion &&
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`,
)}
> >
<div> <div>
<div className="flex items-center gap-4 text-left "> <div className="flex items-center gap-4 text-left">
<EventIcon size="sm" name={name} meta={meta} /> <EventIcon meta={meta} name={name} size="sm" />
<span>
<span className="font-medium">{renderName()}</span> <span className="font-medium">{renderName()}</span>
{' '}
{renderDuration()}
</span>
</div> </div>
<div className="pl-10"> <div className="pl-10">
<div className="flex origin-left scale-75 gap-1"> <div className="flex origin-left scale-75 gap-1">
@@ -86,16 +64,16 @@ export function EventListItem(props: EventListItemProps) {
{profile && ( {profile && (
<Tooltiper asChild content={getProfileName(profile)}> <Tooltiper asChild content={getProfileName(profile)}>
<Link <Link
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
to={'/$organizationId/$projectId/profiles/$profileId'}
params={{ params={{
organizationId, organizationId,
projectId, projectId,
profileId: profile.id, profileId: profile.id,
}} }}
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline" to={'/$organizationId/$projectId/profiles/$profileId'}
> >
{getProfileName(profile)} {getProfileName(profile)}
</Link> </Link>
@@ -103,12 +81,11 @@ export function EventListItem(props: EventListItemProps) {
)} )}
<Tooltiper asChild content={createdAt.toLocaleString()}> <Tooltiper asChild content={createdAt.toLocaleString()}>
<div className=" text-muted-foreground"> <div className="text-muted-foreground">
{createdAt.toLocaleTimeString()} {createdAt.toLocaleTimeString()}
</div> </div>
</Tooltiper> </Tooltiper>
</div> </div>
</button> </button>
</>
); );
} }

View File

@@ -1,3 +1,4 @@
import { AnimatedNumber } from '../animated-number';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -8,71 +9,53 @@ import { useDebounceState } from '@/hooks/use-debounce-state';
import useWS from '@/hooks/use-ws'; import useWS from '@/hooks/use-ws';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { useParams } from '@tanstack/react-router';
import { AnimatedNumber } from '../animated-number';
export default function EventListener({ export default function EventListener({
onRefresh, onRefresh,
}: { }: {
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const params = useParams({
strict: false,
});
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const counter = useDebounceState(0, 1000); const counter = useDebounceState(0, 1000);
useWS<IServiceEventMinimal | IServiceEvent>( useWS<{ count: number }>(
`/live/events/${projectId}`, `/live/events/${projectId}`,
(event) => { ({ count }) => {
if (event) { counter.set((prev) => prev + count);
const isProfilePage = !!params?.profileId;
if (isProfilePage) {
const profile = 'profile' in event ? event.profile : null;
if (profile?.id === params?.profileId) {
counter.set((prev) => prev + 1);
}
return;
}
counter.set((prev) => prev + 1);
}
}, },
{ {
debounce: { debounce: {
delay: 1000, delay: 1000,
maxWait: 5000, maxWait: 5000,
}, },
}, }
); );
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
onClick={() => { onClick={() => {
counter.set(0); counter.set(0);
onRefresh(); onRefresh();
}} }}
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none" type="button"
> >
<div className="relative"> <div className="relative">
<div <div
className={cn( className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all', 'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all'
)} )}
/> />
<div <div
className={cn( className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all', 'absolute top-0 left-0 h-3 w-3 rounded-full bg-emerald-500 transition-all'
)} )}
/> />
</div> </div>
{counter.debounced === 0 ? ( {counter.debounced === 0 ? (
'Listening' 'Listening'
) : ( ) : (
<AnimatedNumber value={counter.debounced} suffix=" new events" /> <AnimatedNumber suffix=" new events" value={counter.debounced} />
)} )}
</button> </button>
</TooltipTrigger> </TooltipTrigger>

View File

@@ -1,15 +1,14 @@
import type { IServiceEvent } from '@openpanel/db';
import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { EventIcon } from '@/components/events/event-icon'; import { EventIcon } from '@/components/events/event-icon';
import { ProjectLink } from '@/components/links'; import { ProjectLink } from '@/components/links';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import type { IServiceEvent } from '@openpanel/db';
export function useColumns() { export function useColumns() {
const number = useNumber(); const number = useNumber();
@@ -28,17 +27,24 @@ export function useColumns() {
accessorKey: 'name', accessorKey: 'name',
header: 'Name', header: 'Name',
cell({ row }) { cell({ row }) {
const { name, path, duration, properties, revenue } = row.original; const { name, path, revenue } = row.original;
const fullTitle =
name === 'screen_view'
? path
: name === 'revenue' && revenue
? `${name} (${number.currency(revenue / 100)})`
: name.replace(/_/g, ' ');
const renderName = () => { const renderName = () => {
if (name === 'screen_view') { if (name === 'screen_view') {
if (path.includes('/')) { if (path.includes('/')) {
return <span className="max-w-md truncate">{path}</span>; return path;
} }
return ( return (
<> <>
<span className="text-muted-foreground">Screen: </span> <span className="text-muted-foreground">Screen: </span>
<span className="max-w-md truncate">{path}</span> {path}
</> </>
); );
} }
@@ -50,38 +56,27 @@ export function useColumns() {
return name.replace(/_/g, ' '); return name.replace(/_/g, ' ');
}; };
const renderDuration = () => {
if (name === 'screen_view') {
return ( return (
<span className="text-muted-foreground"> <div className="flex min-w-0 items-center gap-2">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
return (
<div className="flex items-center gap-2">
<button <button
type="button" className="shrink-0 transition-transform hover:scale-105"
className="transition-transform hover:scale-105"
onClick={() => { onClick={() => {
pushModal('EditEvent', { pushModal('EditEvent', {
id: row.original.id, id: row.original.id,
}); });
}} }}
type="button"
> >
<EventIcon <EventIcon
size="sm"
name={row.original.name}
meta={row.original.meta} meta={row.original.meta}
name={row.original.name}
size="sm"
/> />
</button> </button>
<span className="flex gap-2"> <span className="flex min-w-0 flex-1 gap-2">
<button <button
type="button" className="min-w-0 max-w-full truncate text-left font-medium hover:underline"
title={fullTitle}
onClick={() => { onClick={() => {
pushModal('EventDetails', { pushModal('EventDetails', {
id: row.original.id, id: row.original.id,
@@ -89,11 +84,10 @@ export function useColumns() {
projectId: row.original.projectId, projectId: row.original.projectId,
}); });
}} }}
className="font-medium hover:underline" type="button"
> >
{renderName()} <span className="block truncate">{renderName()}</span>
</button> </button>
{renderDuration()}
</span> </span>
</div> </div>
); );
@@ -107,8 +101,8 @@ export function useColumns() {
if (profile) { if (profile) {
return ( return (
<ProjectLink <ProjectLink
className="group row items-center gap-2 whitespace-nowrap font-medium hover:underline"
href={`/profiles/${encodeURIComponent(profile.id)}`} href={`/profiles/${encodeURIComponent(profile.id)}`}
className="group whitespace-nowrap font-medium hover:underline row items-center gap-2"
> >
<ProfileAvatar size="sm" {...profile} /> <ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)} {getProfileName(profile)}
@@ -119,8 +113,8 @@ export function useColumns() {
if (profileId && profileId !== deviceId) { if (profileId && profileId !== deviceId) {
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(profileId)}`}
className="whitespace-nowrap font-medium hover:underline" className="whitespace-nowrap font-medium hover:underline"
href={`/profiles/${encodeURIComponent(profileId)}`}
> >
Unknown Unknown
</ProjectLink> </ProjectLink>
@@ -130,8 +124,8 @@ export function useColumns() {
if (deviceId) { if (deviceId) {
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(deviceId)}`}
className="whitespace-nowrap font-medium hover:underline" className="whitespace-nowrap font-medium hover:underline"
href={`/profiles/${encodeURIComponent(deviceId)}`}
> >
Anonymous Anonymous
</ProjectLink> </ProjectLink>
@@ -152,10 +146,10 @@ export function useColumns() {
const { sessionId } = row.original; const { sessionId } = row.original;
return ( return (
<ProjectLink <ProjectLink
href={`/sessions/${encodeURIComponent(sessionId)}`}
className="whitespace-nowrap font-medium hover:underline" className="whitespace-nowrap font-medium hover:underline"
href={`/sessions/${encodeURIComponent(sessionId)}`}
> >
{sessionId.slice(0,6)} {sessionId.slice(0, 6)}
</ProjectLink> </ProjectLink>
); );
}, },
@@ -175,7 +169,7 @@ export function useColumns() {
cell({ row }) { cell({ row }) {
const { country, city } = row.original; const { country, city } = row.original;
return ( return (
<div className="row items-center gap-2 min-w-0"> <div className="row min-w-0 items-center gap-2">
<SerieIcon name={country} /> <SerieIcon name={country} />
<span className="truncate">{city}</span> <span className="truncate">{city}</span>
</div> </div>
@@ -189,7 +183,7 @@ export function useColumns() {
cell({ row }) { cell({ row }) {
const { os } = row.original; const { os } = row.original;
return ( return (
<div className="row items-center gap-2 min-w-0"> <div className="row min-w-0 items-center gap-2">
<SerieIcon name={os} /> <SerieIcon name={os} />
<span className="truncate">{os}</span> <span className="truncate">{os}</span>
</div> </div>
@@ -203,7 +197,7 @@ export function useColumns() {
cell({ row }) { cell({ row }) {
const { browser } = row.original; const { browser } = row.original;
return ( return (
<div className="row items-center gap-2 min-w-0"> <div className="row min-w-0 items-center gap-2">
<SerieIcon name={browser} /> <SerieIcon name={browser} />
<span className="truncate">{browser}</span> <span className="truncate">{browser}</span>
</div> </div>
@@ -221,14 +215,14 @@ export function useColumns() {
const { properties } = row.original; const { properties } = row.original;
const filteredProperties = Object.fromEntries( const filteredProperties = Object.fromEntries(
Object.entries(properties || {}).filter( Object.entries(properties || {}).filter(
([key]) => !key.startsWith('__'), ([key]) => !key.startsWith('__')
), )
); );
const items = Object.entries(filteredProperties); const items = Object.entries(filteredProperties);
const limit = 2; const limit = 2;
const data = items.slice(0, limit).map(([key, value]) => ({ const data = items.slice(0, limit).map(([key, value]) => ({
name: key, name: key,
value: value, value,
})); }));
if (items.length > limit) { if (items.length > limit) {
data.push({ data.push({

View File

@@ -35,6 +35,7 @@ type Props = {
>, >,
unknown unknown
>; >;
showEventListener?: boolean;
}; };
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[]; const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
@@ -215,7 +216,7 @@ const VirtualizedEventsTable = ({
); );
}; };
export const EventsTable = ({ query }: Props) => { export const EventsTable = ({ query, showEventListener = false }: Props) => {
const { isLoading } = query; const { isLoading } = query;
const columns = useColumns(); const columns = useColumns();
@@ -272,7 +273,7 @@ export const EventsTable = ({ query }: Props) => {
return ( return (
<> <>
<EventsTableToolbar query={query} table={table} /> <EventsTableToolbar query={query} table={table} showEventListener={showEventListener} />
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} /> <VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}> <div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<div <div
@@ -291,9 +292,11 @@ export const EventsTable = ({ query }: Props) => {
function EventsTableToolbar({ function EventsTableToolbar({
query, query,
table, table,
showEventListener,
}: { }: {
query: Props['query']; query: Props['query'];
table: Table<IServiceEvent>; table: Table<IServiceEvent>;
showEventListener: boolean;
}) { }) {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const [startDate, setStartDate] = useQueryState( const [startDate, setStartDate] = useQueryState(
@@ -305,7 +308,7 @@ function EventsTableToolbar({
return ( return (
<DataTableToolbarContainer> <DataTableToolbarContainer>
<div className="flex flex-1 flex-wrap items-center gap-2"> <div className="flex flex-1 flex-wrap items-center gap-2">
<EventListener onRefresh={() => query.refetch()} /> {showEventListener && <EventListener onRefresh={() => query.refetch()} />}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -1,13 +1,13 @@
// Based on Christin Alares tag input component (https://github.com/christianalares/seventy-seven) // Based on Christin Alares tag input component (https://github.com/christianalares/seventy-seven)
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { useAnimate } from 'framer-motion'; import { useAnimate } from 'framer-motion';
import { XIcon } from 'lucide-react'; import { XIcon } from 'lucide-react';
import type { ElementRef } from 'react'; import type { ElementRef } from 'react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
type Props = { interface Props {
placeholder: string; placeholder: string;
value: string[]; value: string[];
error?: string; error?: string;
@@ -15,7 +15,7 @@ type Props = {
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
renderTag?: (tag: string) => string; renderTag?: (tag: string) => string;
id?: string; id?: string;
}; }
const TagInput = ({ const TagInput = ({
value: propValue, value: propValue,
@@ -49,7 +49,7 @@ const TagInput = ({
e.preventDefault(); e.preventDefault();
const tagAlreadyExists = value.some( const tagAlreadyExists = value.some(
(tag) => tag.toLowerCase() === inputValue.toLowerCase(), (tag) => tag.toLowerCase() === inputValue.toLowerCase()
); );
if (inputValue) { if (inputValue) {
@@ -61,7 +61,7 @@ const TagInput = ({
}, },
{ {
duration: 0.3, duration: 0.3,
}, }
); );
return; return;
} }
@@ -100,50 +100,50 @@ const TagInput = ({
return ( return (
<div <div
ref={scope}
className={cn( className={cn(
'inline-flex w-full flex-wrap items-center gap-2 rounded-md border border-input p-1 px-3 ring-offset-background has-[input:focus]:ring-2 has-[input:focus]:ring-ring has-[input:focus]:ring-offset-1 bg-card', 'inline-flex w-full flex-wrap items-center gap-2 rounded-md border border-input bg-card p-1 px-3 ring-offset-background has-[input:focus]:ring-2 has-[input:focus]:ring-ring has-[input:focus]:ring-offset-1',
!!error && 'border-destructive', !!error && 'border-destructive'
)} )}
ref={scope}
> >
{value.map((tag, i) => { {value.map((tag, i) => {
const isCreating = false; const isCreating = false;
return ( return (
<span <span
data-tag={tag}
key={tag}
className={cn( className={cn(
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 ', 'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1',
isMarkedForDeletion && isMarkedForDeletion &&
i === value.length - 1 && i === value.length - 1 &&
'bg-destructive-foreground ring-2 ring-destructive/50 ring-offset-1', 'bg-destructive/15 ring-2 ring-destructive ring-offset-2 ring-offset-card',
isCreating && 'opacity-60', isCreating && 'opacity-60'
)} )}
data-tag={tag}
key={tag}
> >
{renderTag ? renderTag(tag) : tag} {renderTag ? renderTag(tag) : tag}
<Button <Button
size="icon"
variant="outline"
className="h-4 w-4 rounded-full" className="h-4 w-4 rounded-full"
onClick={() => removeTag(tag)} onClick={() => removeTag(tag)}
size="icon"
variant="outline"
> >
<span className="sr-only">Remove tag</span> <span className="sr-only">Remove tag</span>
<XIcon name="close" className="size-3" /> <XIcon className="size-3" name="close" />
</Button> </Button>
</span> </span>
); );
})} })}
<input <input
ref={inputRef} className="min-w-20 flex-1 bg-card py-1 focus-visible:outline-none"
placeholder={`${placeholder}`} id={id}
className="min-w-20 flex-1 py-1 focus-visible:outline-none bg-card" onBlur={handleBlur}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} placeholder={`${placeholder}`}
id={id} ref={inputRef}
value={inputValue}
/> />
</div> </div>
); );

View File

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

View File

@@ -1,119 +1,102 @@
import { LogoSquare } from '@/components/logo';
import { import {
Carousel, Carousel,
CarouselContent, CarouselContent,
CarouselItem, CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel'; } from '@/components/ui/carousel';
import { Link } from '@tanstack/react-router'; import Autoplay from 'embla-carousel-autoplay';
import { CodeIcon, CreditCardIcon, DollarSignIcon } from 'lucide-react'; import { QuoteIcon } from 'lucide-react';
import { SellingPoint } from './selling-points';
const onboardingSellingPoints = [ const testimonials = [
{ {
key: 'get-started', key: 'thomas',
render: () => ( bgImage: '/img-1.webp',
<SellingPoint quote:
bgImage="/img-6.webp" "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.",
title="Get started in minutes" author: 'Thomas Sanlis',
description={ site: 'uneed.best',
<>
<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: 'welcome', key: 'julien',
render: () => ( bgImage: '/img-2.webp',
<SellingPoint quote:
bgImage="/img-1.webp" 'After testing several product analytics tools, we chose OpenPanel and we are very satisfied. Profiles and Conversion Events are our favorite features.',
title="Best open-source alternative" author: 'Julien Hany',
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring" site: 'strackr.com',
/>
),
}, },
{ {
key: 'selling-point-2', key: 'piotr',
render: () => ( bgImage: '/img-3.webp',
<SellingPoint quote:
bgImage="/img-2.webp" 'The Overview tab is great — it has everything I need. The UI is beautiful, clean, modern, very pleasing to the eye.',
title="Fast and reliable" author: 'Piotr Kulpinski',
description="Never miss a beat with our real-time analytics" site: 'producthunt.com',
/>
),
}, },
{ {
key: 'selling-point-3', key: 'selfhost',
render: () => ( bgImage: '/img-4.webp',
<SellingPoint quote:
bgImage="/img-3.webp" "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.",
title="Easy to use" author: 'Self-hosting user',
description="Compared to other tools we have kept it simple" site: undefined,
/>
),
},
{
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"
/>
),
}, },
]; ];
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() { export function OnboardingLeftPanel() {
return ( return (
<div className="sticky top-0 h-screen overflow-hidden"> <div className="sticky top-0 h-screen overflow-hidden">
{/* Carousel */}
<div className="flex items-center justify-center h-full mt-24"> <div className="flex items-center justify-center h-full mt-24">
<Carousel <Carousel
className="w-full h-full [&>div]:h-full [&>div]:min-h-full" className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
opts={{ opts={{ loop: true, align: 'center' }}
loop: true, plugins={[Autoplay({ delay: 6000, stopOnInteraction: false })]}
align: 'center',
}}
> >
<CarouselContent className="h-full"> <CarouselContent className="h-full">
{onboardingSellingPoints.map((point, index) => ( {testimonials.map((t) => (
<CarouselItem <CarouselItem key={t.key} className="p-8 pb-32 pt-0">
key={`onboarding-point-${point.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"> <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> </div>
</CarouselItem> </CarouselItem>
))} ))}
</CarouselContent> </CarouselContent>
<CarouselPrevious className="left-12 bottom-30 top-auto" />
<CarouselNext className="right-12 bottom-30 top-auto" />
</Carousel> </Carousel>
</div> </div>
</div> </div>

View File

@@ -1,57 +0,0 @@
import { pushModal } from '@/modals';
import { SmartphoneIcon } from 'lucide-react';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectApp = ({ client }: Props) => {
return (
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<SmartphoneIcon className="size-4" />
App
</div>
<div className="text-muted-foreground mb-2">
Pick a framework below to get started.
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('app'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
);
};
export default ConnectApp;

View File

@@ -1,86 +0,0 @@
import { pushModal } from '@/modals';
import { ServerIcon } from 'lucide-react';
import Syntax from '@/components/syntax';
import { useAppContext } from '@/hooks/use-app-context';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectBackend = ({ client }: Props) => {
const context = useAppContext();
return (
<>
<div>
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<ServerIcon className="size-4" />
Backend
</div>
<div className="text-muted-foreground mb-2">
Try with a basic curl command
</div>
</div>
<Syntax
language="bash"
className="border"
code={`curl -X POST ${context.apiUrl}/track \\
-H "Content-Type: application/json" \\
-H "openpanel-client-id: ${client?.id}" \\
-H "openpanel-client-secret: ${client?.secret}" \\
-d '{
"type": "track",
"payload": {
"name": "test_event",
"properties": {
"test": "property"
}
}
}'`}
/>
</div>
<div>
<p className="text-muted-foreground mb-2">
Pick a framework below to get started.
</p>
<div className="grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('backend'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
</>
);
};
export default ConnectBackend;

View File

@@ -1,58 +1,67 @@
import { pushModal } from '@/modals';
import { MonitorIcon } from 'lucide-react';
import Syntax from '@/components/syntax';
import type { IServiceClient } from '@openpanel/db'; import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info'; import { frameworks } from '@openpanel/sdk-info';
import { CopyIcon, PlugIcon } from 'lucide-react';
import { Button } from '../ui/button';
import Syntax from '@/components/syntax';
import { useAppContext } from '@/hooks/use-app-context';
import { pushModal } from '@/modals';
import { clipboard } from '@/utils/clipboard';
type Props = { interface Props {
client: IServiceClient | null; client: IServiceClient | null;
}; }
const ConnectWeb = ({ client }: Props) => { const ConnectWeb = ({ client }: Props) => {
return ( const context = useAppContext();
<> const code = `<script>
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<MonitorIcon className="size-4" />
Website
</div>
<div className="text-muted-foreground mb-2">
Paste the script to your website
</div>
<Syntax
className="border"
code={`<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=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', { window.op('init', {${context.isSelfHosted ? `\n\tapiUrl: '${context.apiUrl}',` : ''}
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}', clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
trackScreenViews: true, trackScreenViews: true,
trackOutgoingLinks: true, trackOutgoingLinks: true,
trackAttributes: true, trackAttributes: true,
// sessionReplay: {
// enabled: true,
// },
}); });
</script> </script>
<script src="https://openpanel.dev/op1.js" defer async></script>`} <script src="https://openpanel.dev/op1.js" defer async></script>`;
/> return (
<div className="col gap-4">
<div className="col gap-2">
<div className="row items-center justify-between gap-4">
<div className="flex items-center gap-2 font-bold text-xl capitalize">
<PlugIcon className="size-4" />
Quick start
</div> </div>
<div> <div className="row gap-2">
<p className="text-muted-foreground mb-2"> <Button
icon={CopyIcon}
onClick={() => clipboard(code, null)}
variant="outline"
>
Copy
</Button>
</div>
</div>
<Syntax className="border" code={code} copyable={false} />
</div>
<div className="col gap-4">
<p className="text-center text-muted-foreground text-sm">
Or pick a framework below to get started. Or pick a framework below to get started.
</p> </p>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{frameworks {frameworks.map((framework) => (
.filter((framework) => framework.type.includes('website'))
.map((framework) => (
<button <button
type="button" className="flex items-center gap-4 rounded-md border p-2 text-left"
key={framework.name}
onClick={() => onClick={() =>
pushModal('Instructions', { pushModal('Instructions', {
framework, framework,
client, client,
}) })
} }
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left" type="button"
key={framework.name}
> >
<div className="h-10 w-10 rounded-md bg-def-200 p-2"> <div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" /> <framework.IconComponent className="h-full w-full" />
@@ -61,17 +70,17 @@ const ConnectWeb = ({ client }: Props) => {
</button> </button>
))} ))}
</div> </div>
<p className="mt-2 text-sm text-muted-foreground"> <p className="text-center text-muted-foreground text-sm">
Missing a framework?{' '} Missing a framework?{' '}
<a <a
href="mailto:hello@openpanel.dev"
className="text-foreground underline" className="text-foreground underline"
href="mailto:hello@openpanel.dev"
> >
Let us know! Let us know!
</a> </a>
</p> </p>
</div> </div>
</> </div>
); );
}; };

View File

@@ -1,72 +0,0 @@
import { useAppContext } from '@/hooks/use-app-context';
import { useClientSecret } from '@/hooks/use-client-secret';
import { clipboard } from '@/utils/clipboard';
import type { IServiceProjectWithClients } from '@openpanel/db';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
export function CurlPreview({
project,
}: { project: IServiceProjectWithClients }) {
const context = useAppContext();
const [secret] = useClientSecret();
const client = project.clients[0];
if (!client) {
return null;
}
const payload: Record<string, any> = {
type: 'track',
payload: {
name: 'screen_view',
properties: {
__title: `Testing OpenPanel - ${project.name}`,
__path: `${project.domain}`,
__referrer: `${context.dashboardUrl}`,
},
},
};
if (project.types.includes('app')) {
payload.payload.properties.__path = '/';
delete payload.payload.properties.__referrer;
}
if (project.types.includes('backend')) {
payload.payload.name = 'test_event';
payload.payload.properties = {};
}
const code = `curl -X POST ${context.apiUrl}/track \\
-H "Content-Type: application/json" \\
-H "openpanel-client-id: ${client.id}" \\
-H "openpanel-client-secret: ${secret}" \\
-H "User-Agent: ${typeof window !== 'undefined' ? window.navigator.userAgent : ''}" \\
-d '${JSON.stringify(payload)}'`;
return (
<div className="card">
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger
className="px-6"
onClick={() => {
clipboard(code, null);
}}
>
Try out the curl command
</AccordionTrigger>
<AccordionContent className="p-0">
<Syntax code={code} language="bash" />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -1,48 +1,28 @@
import useWS from '@/hooks/use-ws'; import type { IServiceEvent } from '@openpanel/db';
import { pushModal } from '@/modals'; import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { timeAgo } from '@/utils/date'; import { timeAgo } from '@/utils/date';
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
import { useState } from 'react';
import type { interface Props {
IServiceClient,
IServiceEvent,
IServiceProject,
} from '@openpanel/db';
type Props = {
project: IServiceProject;
client: IServiceClient | null;
events: IServiceEvent[]; events: IServiceEvent[];
onVerified: (verified: boolean) => void; }
};
const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
useWS<IServiceEvent>(
`/live/events/${client?.projectId}?type=received`,
(data) => {
setEvents((prev) => [...prev, data]);
onVerified(true);
},
);
const VerifyListener = ({ events }: Props) => {
const isConnected = events.length > 0; const isConnected = events.length > 0;
const renderIcon = () => { const renderIcon = () => {
if (isConnected) { if (isConnected) {
return ( return (
<CheckCircle2Icon <CheckCircle2Icon
strokeWidth={1.2}
size={40}
className="shrink-0 text-emerald-600" className="shrink-0 text-emerald-600"
size={40}
strokeWidth={1.2}
/> />
); );
} }
return ( return (
<Loader2 size={40} className="shrink-0 animate-spin text-highlight" /> <Loader2 className="shrink-0 animate-spin text-highlight" size={40} />
); );
}; };
@@ -51,27 +31,29 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
<div <div
className={cn( className={cn(
'flex gap-6 rounded-xl p-4 md:p-6', 'flex gap-6 rounded-xl p-4 md:p-6',
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10', isConnected
? 'bg-emerald-100 dark:bg-emerald-700/10'
: 'bg-blue-500/10'
)} )}
> >
{renderIcon()} {renderIcon()}
<div className="flex-1"> <div className="flex-1">
<div className="text-lg font-semibold leading-normal text-foreground/90"> <div className="font-semibold text-foreground/90 text-lg leading-normal">
{isConnected ? 'Success' : 'Waiting for events'} {isConnected ? 'Successfully connected' : 'Waiting for events'}
</div> </div>
{isConnected ? ( {isConnected ? (
<div className="flex flex-col-reverse"> <div className="mt-2 flex flex-col-reverse gap-1">
{events.length > 5 && ( {events.length > 5 && (
<div className="flex items-center gap-2 "> <div className="flex items-center gap-2">
<CheckIcon size={14} />{' '} <CheckIcon size={14} />{' '}
<span>{events.length - 5} more events</span> <span>{events.length - 5} more events</span>
</div> </div>
)} )}
{events.slice(-5).map((event) => ( {events.slice(-5).map((event) => (
<div key={event.id} className="flex items-center gap-2 "> <div className="flex items-center gap-2" key={event.id}>
<CheckIcon size={14} />{' '} <CheckIcon size={14} />{' '}
<span className="font-medium">{event.name}</span>{' '} <span className="font-medium">{event.name}</span>{' '}
<span className="ml-auto text-emerald-800"> <span className="ml-auto text-foreground/50 text-sm">
{timeAgo(event.createdAt, 'round')} {timeAgo(event.createdAt, 'round')}
</span> </span>
</div> </div>
@@ -84,23 +66,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
)} )}
</div> </div>
</div> </div>
<div className="mt-2 text-sm text-muted-foreground">
You can{' '}
<button
type="button"
className="underline"
onClick={() => {
pushModal('OnboardingTroubleshoot', {
client,
type: 'app',
});
}}
>
troubleshoot
</button>{' '}
if you are having issues connecting your app.
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,190 @@
import type { IServiceProjectWithClients } from '@openpanel/db';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { GlobeIcon, KeyIcon, UserIcon } from 'lucide-react';
import { toast } from 'sonner';
import CopyInput from '../forms/copy-input';
import { WithLabel } from '../forms/input-with-label';
import TagInput from '../forms/tag-input';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { useAppContext } from '@/hooks/use-app-context';
import { useClientSecret } from '@/hooks/use-client-secret';
import { handleError, useTRPC } from '@/integrations/trpc/react';
export function VerifyFaq({
project,
}: {
project: IServiceProjectWithClients;
}) {
const context = useAppContext();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [secret] = useClientSecret();
const updateProject = useMutation(
trpc.project.update.mutationOptions({
onError: handleError,
onSuccess: () => {
queryClient.invalidateQueries(
trpc.project.getProjectWithClients.queryFilter({
projectId: project.id,
})
);
toast.success('Allowed domains updated');
},
})
);
const client = project.clients[0];
if (!client) {
return null;
}
const handleCorsChange = (newValue: string[]) => {
const normalized = newValue
.map((item: string) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return trimmed ? `https://${trimmed}` : trimmed;
})
.filter(Boolean);
updateProject.mutate({ id: project.id, cors: normalized });
};
const showSecret = secret && secret !== '[CLIENT_SECRET]';
const payload: Record<string, any> = {
type: 'track',
payload: {
name: 'screen_view',
properties: {
__title: `Testing OpenPanel - ${project.name}`,
__path: `${project.domain}`,
__referrer: `${context.dashboardUrl}`,
},
},
};
if (project.types.includes('app')) {
payload.payload.properties.__path = '/';
delete payload.payload.properties.__referrer;
}
if (project.types.includes('backend')) {
payload.payload.name = 'test_event';
payload.payload.properties = {};
}
const code = `curl -X POST ${context.apiUrl}/track \\
-H "Content-Type: application/json" \\
-H "openpanel-client-id: ${client.id}" \\
-H "openpanel-client-secret: ${secret}" \\
-H "User-Agent: ${typeof window !== 'undefined' ? window.navigator.userAgent : ''}" \\
-d '${JSON.stringify(payload)}'`;
return (
<div className="card">
<Accordion collapsible type="single">
<AccordionItem value="item-1">
<AccordionTrigger className="px-6">
No events received?
</AccordionTrigger>
<AccordionContent className="col gap-4 p-6 pt-2">
<p>
Don't worry, this happens to everyone. Here are a few things you
can check:
</p>
<div className="col gap-2">
<Alert>
<UserIcon size={16} />
<AlertTitle>Ensure client ID is correct</AlertTitle>
<AlertDescription className="col gap-2">
<span>
For web tracking, the <code>clientId</code> in your snippet
must match this project. Copy it here if needed:
</span>
<CopyInput
className="[&_.font-mono]:text-sm"
label="Client ID"
value={client.id}
/>
</AlertDescription>
</Alert>
<Alert>
<GlobeIcon size={16} />
<AlertTitle>Correct domain configured</AlertTitle>
<AlertDescription className="col gap-2">
<span>
For websites it&apos;s important that the domain is
correctly configured. We authenticate requests based on the
domain. Update allowed domains below:
</span>
<WithLabel label="Allowed domains">
<TagInput
onChange={handleCorsChange}
placeholder="Accept events from these domains"
renderTag={(tag: string) =>
tag === '*' ? 'Accept events from any domains' : tag
}
value={project.cors ?? []}
/>
</WithLabel>
</AlertDescription>
</Alert>
<Alert>
<KeyIcon size={16} />
<AlertTitle>Wrong client secret</AlertTitle>
<AlertDescription className="col gap-2">
<span>
For app and backend events you need the correct{' '}
<code>clientSecret</code>. Copy it here if needed. Never use
the client secret in web or client-side code—it would expose
your credentials.
</span>
{showSecret && (
<CopyInput
className="[&_.font-mono]:text-sm"
label="Client secret"
value={secret}
/>
)}
</AlertDescription>
</Alert>
</div>
<p>
Still have issues? Join our{' '}
<a className="underline" href="https://go.openpanel.dev/discord">
discord channel
</a>{' '}
give us an email at{' '}
<a className="underline" href="mailto:hello@openpanel.dev">
hello@openpanel.dev
</a>{' '}
and we&apos;ll help you out.
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger className="px-6">
Personal curl example
</AccordionTrigger>
<AccordionContent className="p-0">
<Syntax code={code} language="bash" />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -1,18 +1,7 @@
import {
X_AXIS_STYLE_PROPS,
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import { sum } from '@openpanel/common'; import { sum } from '@openpanel/common';
import type { IServiceOrganization } from '@openpanel/db'; import type { IServiceOrganization } from '@openpanel/db';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Loader2Icon } from 'lucide-react'; import { Loader2Icon } from 'lucide-react';
import { pick } from 'ramda';
import { import {
Bar, Bar,
BarChart, BarChart,
@@ -23,16 +12,24 @@ import {
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { BarShapeBlue } from '../charts/common-bar'; import { BarShapeBlue } from '../charts/common-bar';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDate } from '@/utils/date';
type Props = { interface Props {
organization: IServiceOrganization; organization: IServiceOrganization;
}; }
function Card({ title, value }: { title: string; value: string }) { function Card({ title, value }: { title: string; value: string }) {
return ( return (
<div className="col gap-2 p-4 flex-1 min-w-0" title={`${title}: ${value}`}> <div className="col min-w-0 flex-1 gap-2 p-4" title={`${title}: ${value}`}>
<div className="text-muted-foreground truncate">{title}</div> <div className="truncate text-muted-foreground">{title}</div>
<div className="font-mono text-xl font-bold truncate">{value}</div> <div className="truncate font-bold font-mono text-xl">{value}</div>
</div> </div>
); );
} }
@@ -43,18 +40,20 @@ export default function BillingUsage({ organization }: Props) {
const usageQuery = useQuery( const usageQuery = useQuery(
trpc.subscription.usage.queryOptions({ trpc.subscription.usage.queryOptions({
organizationId: organization.id, organizationId: organization.id,
}), })
); );
// Determine interval based on data range - use weekly if more than 30 days // Determine interval based on data range - use weekly if more than 30 days
const getDataInterval = () => { const getDataInterval = () => {
if (!usageQuery.data || usageQuery.data.length === 0) return 'day'; if (!usageQuery.data || usageQuery.data.length === 0) {
return 'day';
}
const dates = usageQuery.data.map((item) => new Date(item.day)); const dates = usageQuery.data.map((item) => new Date(item.day));
const minDate = new Date(Math.min(...dates.map((d) => d.getTime()))); const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime()))); const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
const daysDiff = Math.ceil( const daysDiff = Math.ceil(
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24), (maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24)
); );
return daysDiff > 30 ? 'week' : 'day'; return daysDiff > 30 ? 'week' : 'day';
@@ -78,7 +77,7 @@ export default function BillingUsage({ organization }: Props) {
return wrapper( return wrapper(
<div className="center-center p-8"> <div className="center-center p-8">
<Loader2Icon className="animate-spin" /> <Loader2Icon className="animate-spin" />
</div>, </div>
); );
} }
@@ -86,13 +85,16 @@ export default function BillingUsage({ organization }: Props) {
return wrapper( return wrapper(
<div className="center-center p-8 font-medium"> <div className="center-center p-8 font-medium">
Issues loading usage data Issues loading usage data
</div>, </div>
); );
} }
if (usageQuery.data?.length === 0) { if (
usageQuery.data?.length === 0 ||
!usageQuery.data?.some((item) => item.count !== 0)
) {
return wrapper( return wrapper(
<div className="center-center p-8 font-medium">No usage data yet</div>, <div className="center-center p-8 font-medium">No usage data yet</div>
); );
} }
@@ -105,7 +107,9 @@ export default function BillingUsage({ organization }: Props) {
// Group daily data into weekly intervals if data spans more than 30 days // Group daily data into weekly intervals if data spans more than 30 days
const processChartData = () => { const processChartData = () => {
if (!usageQuery.data) return []; if (!usageQuery.data) {
return [];
}
if (useWeeklyIntervals) { if (useWeeklyIntervals) {
// Group daily data into weekly intervals // Group daily data into weekly intervals
@@ -157,7 +161,7 @@ export default function BillingUsage({ organization }: Props) {
Math.max( Math.max(
subscriptionPeriodEventsLimit, subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount, subscriptionPeriodEventsCount,
...chartData.map((item) => item.count), ...chartData.map((item) => item.count)
), ),
] as [number, number]; ] as [number, number];
@@ -165,7 +169,7 @@ export default function BillingUsage({ organization }: Props) {
return wrapper( return wrapper(
<> <>
<div className="-m-4 mb-4 grid grid-cols-2 [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border border-b"> <div className="-m-4 mb-4 grid grid-cols-2 border-b [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border">
{organization.hasSubscription ? ( {organization.hasSubscription ? (
<> <>
<Card <Card
@@ -186,7 +190,7 @@ export default function BillingUsage({ organization }: Props) {
1 - 1 -
subscriptionPeriodEventsCount / subscriptionPeriodEventsCount /
subscriptionPeriodEventsLimit, subscriptionPeriodEventsLimit,
'%', '%'
) )
} }
/> />
@@ -208,7 +212,7 @@ export default function BillingUsage({ organization }: Props) {
<Card <Card
title="Events from last 30 days" title="Events from last 30 days"
value={number.format( value={number.format(
sum(usageQuery.data?.map((item) => item.count) ?? []), sum(usageQuery.data?.map((item) => item.count) ?? [])
)} )}
/> />
</div> </div>
@@ -217,12 +221,12 @@ export default function BillingUsage({ organization }: Props) {
</div> </div>
{/* Events Chart */} {/* Events Chart */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground"> <h3 className="font-medium text-muted-foreground text-sm">
{useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'} {useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'}
</h3> </h3>
<div className="max-h-[300px] h-[250px] w-full p-4"> <div className="h-[250px] max-h-[300px] w-full p-4">
<ResponsiveContainer> <ResponsiveContainer>
<BarChart data={chartData} barSize={useWeeklyIntervals ? 20 : 8}> <BarChart barSize={useWeeklyIntervals ? 20 : 8} data={chartData}>
<RechartTooltip <RechartTooltip
content={<EventsTooltip useWeekly={useWeeklyIntervals} />} content={<EventsTooltip useWeekly={useWeeklyIntervals} />}
cursor={{ cursor={{
@@ -239,15 +243,15 @@ export default function BillingUsage({ organization }: Props) {
<YAxis {...yAxisProps} domain={[0, 'dataMax']} /> <YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid <CartesianGrid
horizontal={true} horizontal={true}
vertical={false}
strokeDasharray="3 3" strokeDasharray="3 3"
strokeOpacity={0.5} strokeOpacity={0.5}
vertical={false}
/> />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
</>, </>
); );
} }
@@ -261,7 +265,7 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
return ( return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl"> <div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
{useWeekly && payload.weekRange {useWeekly && payload.weekRange
? payload.weekRange ? payload.weekRange
: payload?.date : payload?.date
@@ -271,10 +275,10 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-0" /> <div className="h-10 w-1 rounded-full bg-chart-0" />
<div className="col gap-1"> <div className="col gap-1">
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
Events {useWeekly ? 'this week' : 'this day'} Events {useWeekly ? 'this week' : 'this day'}
</div> </div>
<div className="text-lg font-semibold text-chart-0"> <div className="font-semibold text-chart-0 text-lg">
{number.format(payload.count)} {number.format(payload.count)}
</div> </div>
</div> </div>
@@ -293,22 +297,22 @@ function TotalTooltip(props: any) {
return ( return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl"> <div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">Total Events</div> <div className="text-muted-foreground text-sm">Total Events</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-2" /> <div className="h-10 w-1 rounded-full bg-chart-2" />
<div className="col gap-1"> <div className="col gap-1">
<div className="text-sm text-muted-foreground">Your events count</div> <div className="text-muted-foreground text-sm">Your events count</div>
<div className="text-lg font-semibold text-chart-2"> <div className="font-semibold text-chart-2 text-lg">
{number.format(payload.count)} {number.format(payload.count)}
</div> </div>
</div> </div>
</div> </div>
{payload.limit > 0 && ( {payload.limit > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-1" /> <div className="h-10 w-1 rounded-full border-2 border-chart-1 border-dashed" />
<div className="col gap-1"> <div className="col gap-1">
<div className="text-sm text-muted-foreground">Your tier limit</div> <div className="text-muted-foreground text-sm">Your tier limit</div>
<div className="text-lg font-semibold text-chart-1"> <div className="font-semibold text-chart-1 text-lg">
{number.format(payload.limit)} {number.format(payload.limit)}
</div> </div>
</div> </div>

View File

@@ -1,9 +1,3 @@
import { Button } from '@/components/ui/button';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, useOnPushModal } from '@/modals';
import { formatDate } from '@/utils/date';
import type { IServiceOrganization } from '@openpanel/db'; import type { IServiceOrganization } from '@openpanel/db';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@@ -14,6 +8,12 @@ import { Progress } from '../ui/progress';
import { Widget, WidgetBody, WidgetHead } from '../widget'; import { Widget, WidgetBody, WidgetHead } from '../widget';
import { BillingFaq } from './billing-faq'; import { BillingFaq } from './billing-faq';
import BillingUsage from './billing-usage'; import BillingUsage from './billing-usage';
import { Button } from '@/components/ui/button';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, useOnPushModal } from '@/modals';
import { formatDate } from '@/utils/date';
type Props = { type Props = {
organization: IServiceOrganization; organization: IServiceOrganization;
@@ -28,13 +28,13 @@ export default function Billing({ organization }: Props) {
const productsQuery = useQuery( const productsQuery = useQuery(
trpc.subscription.products.queryOptions({ trpc.subscription.products.queryOptions({
organizationId: organization.id, organizationId: organization.id,
}), })
); );
const currentProductQuery = useQuery( const currentProductQuery = useQuery(
trpc.subscription.getCurrent.queryOptions({ trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id, organizationId: organization.id,
}), })
); );
const portalMutation = useMutation( const portalMutation = useMutation(
@@ -47,7 +47,7 @@ export default function Billing({ organization }: Props) {
onError(error) { onError(error) {
toast.error(error.message); toast.error(error.message);
}, },
}), })
); );
useWS(`/live/organization/${organization.id}`, () => { useWS(`/live/organization/${organization.id}`, () => {
@@ -55,7 +55,7 @@ export default function Billing({ organization }: Props) {
}); });
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>( const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
(organization.subscriptionInterval as 'year' | 'month') || 'month', (organization.subscriptionInterval as 'year' | 'month') || 'month'
); );
const products = useMemo(() => { const products = useMemo(() => {
@@ -66,7 +66,7 @@ export default function Billing({ organization }: Props) {
const currentProduct = currentProductQuery.data ?? null; const currentProduct = currentProductQuery.data ?? null;
const currentPrice = currentProduct?.prices.flatMap((p) => const currentPrice = currentProduct?.prices.flatMap((p) =>
p.type === 'recurring' && p.amountType === 'fixed' ? [p] : [], p.type === 'recurring' && p.amountType === 'fixed' ? [p] : []
)[0]; )[0];
const renderStatus = () => { const renderStatus = () => {
@@ -138,12 +138,12 @@ export default function Billing({ organization }: Props) {
}); });
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="col gap-8"> <div className="col gap-8">
{currentProduct && currentPrice ? ( {currentProduct && currentPrice ? (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead className="flex items-center justify-between gap-4"> <WidgetHead className="flex items-center justify-between gap-4">
<div className="flex-1 title truncate">{currentProduct.name}</div> <div className="title flex-1 truncate">{currentProduct.name}</div>
<div className="text-lg"> <div className="text-lg">
<span className="font-bold"> <span className="font-bold">
{number.currency(currentPrice.priceAmount / 100)} {number.currency(currentPrice.priceAmount / 100)}
@@ -157,58 +157,58 @@ export default function Billing({ organization }: Props) {
<WidgetBody> <WidgetBody>
{renderStatus()} {renderStatus()}
<div className="col mt-4"> <div className="col mt-4">
<div className="font-semibold mb-2"> <div className="mb-2 font-semibold">
{number.format(organization.subscriptionPeriodEventsCount)} /{' '} {number.format(organization.subscriptionPeriodEventsCount)} /{' '}
{number.format(Number(currentProduct.metadata.eventsLimit))} {number.format(Number(currentProduct.metadata.eventsLimit))}
</div> </div>
<Progress <Progress
size="sm"
value={ value={
(organization.subscriptionPeriodEventsCount / (organization.subscriptionPeriodEventsCount /
Number(currentProduct.metadata.eventsLimit)) * Number(currentProduct.metadata.eventsLimit)) *
100 100
} }
size="sm"
/> />
<div className="row justify-between mt-4"> <div className="row mt-4 justify-between">
<Button <Button
variant="outline"
size="sm"
onClick={() => onClick={() =>
portalMutation.mutate({ organizationId: organization.id }) portalMutation.mutate({ organizationId: organization.id })
} }
size="sm"
variant="outline"
> >
<svg <svg
className="size-4 mr-2" className="mr-2 size-4"
width="300" fill="none"
height="300" height="300"
viewBox="0 0 300 300" viewBox="0 0 300 300"
fill="none" width="300"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<g clip-path="url(#clip0_1_4)"> <g clip-path="url(#clip0_1_4)">
<path <path
fill-rule="evenodd"
clip-rule="evenodd" clip-rule="evenodd"
d="M66.4284 274.26C134.876 320.593 227.925 302.666 274.258 234.219C320.593 165.771 302.666 72.7222 234.218 26.3885C165.77 -19.9451 72.721 -2.0181 26.3873 66.4297C-19.9465 134.877 -2.01938 227.927 66.4284 274.26ZM47.9555 116.67C30.8375 169.263 36.5445 221.893 59.2454 256.373C18.0412 217.361 7.27564 150.307 36.9437 92.318C55.9152 55.2362 87.5665 29.3937 122.5 18.3483C90.5911 36.7105 62.5549 71.8144 47.9555 116.67ZM175.347 283.137C211.377 272.606 244.211 246.385 263.685 208.322C293.101 150.825 282.768 84.4172 242.427 45.2673C264.22 79.7626 269.473 131.542 252.631 183.287C237.615 229.421 208.385 265.239 175.347 283.137ZM183.627 266.229C207.945 245.418 228.016 210.604 236.936 168.79C251.033 102.693 232.551 41.1978 195.112 20.6768C214.97 47.3945 225.022 99.2902 218.824 157.333C214.085 201.724 200.814 240.593 183.627 266.229ZM63.7178 131.844C49.5155 198.43 68.377 260.345 106.374 280.405C85.9962 254.009 75.5969 201.514 81.8758 142.711C86.5375 99.0536 99.4504 60.737 116.225 35.0969C92.2678 55.983 72.5384 90.4892 63.7178 131.844ZM199.834 149.561C200.908 217.473 179.59 272.878 152.222 273.309C124.853 273.742 101.797 219.039 100.724 151.127C99.6511 83.2138 120.968 27.8094 148.337 27.377C175.705 26.9446 198.762 81.648 199.834 149.561Z" d="M66.4284 274.26C134.876 320.593 227.925 302.666 274.258 234.219C320.593 165.771 302.666 72.7222 234.218 26.3885C165.77 -19.9451 72.721 -2.0181 26.3873 66.4297C-19.9465 134.877 -2.01938 227.927 66.4284 274.26ZM47.9555 116.67C30.8375 169.263 36.5445 221.893 59.2454 256.373C18.0412 217.361 7.27564 150.307 36.9437 92.318C55.9152 55.2362 87.5665 29.3937 122.5 18.3483C90.5911 36.7105 62.5549 71.8144 47.9555 116.67ZM175.347 283.137C211.377 272.606 244.211 246.385 263.685 208.322C293.101 150.825 282.768 84.4172 242.427 45.2673C264.22 79.7626 269.473 131.542 252.631 183.287C237.615 229.421 208.385 265.239 175.347 283.137ZM183.627 266.229C207.945 245.418 228.016 210.604 236.936 168.79C251.033 102.693 232.551 41.1978 195.112 20.6768C214.97 47.3945 225.022 99.2902 218.824 157.333C214.085 201.724 200.814 240.593 183.627 266.229ZM63.7178 131.844C49.5155 198.43 68.377 260.345 106.374 280.405C85.9962 254.009 75.5969 201.514 81.8758 142.711C86.5375 99.0536 99.4504 60.737 116.225 35.0969C92.2678 55.983 72.5384 90.4892 63.7178 131.844ZM199.834 149.561C200.908 217.473 179.59 272.878 152.222 273.309C124.853 273.742 101.797 219.039 100.724 151.127C99.6511 83.2138 120.968 27.8094 148.337 27.377C175.705 26.9446 198.762 81.648 199.834 149.561Z"
fill="currentColor" fill="currentColor"
fill-rule="evenodd"
/> />
</g> </g>
<defs> <defs>
<clipPath id="clip0_1_4"> <clipPath id="clip0_1_4">
<rect width="300" height="300" fill="white" /> <rect fill="white" height="300" width="300" />
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>
Customer portal Customer portal
</Button> </Button>
<Button <Button
size="sm"
onClick={() => onClick={() =>
pushModal('SelectBillingPlan', { pushModal('SelectBillingPlan', {
organization, organization,
currentProduct, currentProduct,
}) })
} }
size="sm"
> >
{organization.isWillBeCanceled {organization.isWillBeCanceled
? 'Reactivate subscription' ? 'Reactivate subscription'
@@ -221,15 +221,13 @@ export default function Billing({ organization }: Props) {
) : ( ) : (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead className="flex items-center justify-between"> <WidgetHead className="flex items-center justify-between">
<div className="font-bold text-lg flex-1"> <div className="flex-1 font-bold text-lg">
{organization.isTrial {organization.isTrial
? 'Get started' ? 'Get started'
: 'No active subscription'} : 'No active subscription'}
</div> </div>
<div className="text-lg"> <div className="text-muted-foreground">
<span className="">
{organization.isTrial ? '30 days free trial' : ''} {organization.isTrial ? '30 days free trial' : ''}
</span>
</div> </div>
</WidgetHead> </WidgetHead>
<WidgetBody> <WidgetBody>
@@ -239,7 +237,7 @@ export default function Billing({ organization }: Props) {
{formatDate(organization.subscriptionEndsAt)} ( {formatDate(organization.subscriptionEndsAt)} (
{differenceInDays( {differenceInDays(
organization.subscriptionEndsAt, organization.subscriptionEndsAt,
new Date(), new Date()
) + 1}{' '} ) + 1}{' '}
days left) days left)
</p> </p>
@@ -250,29 +248,29 @@ export default function Billing({ organization }: Props) {
</p> </p>
)} )}
<div className="col mt-4"> <div className="col mt-4">
<div className="font-semibold mb-2"> <div className="mb-2 font-semibold">
{number.format(organization.subscriptionPeriodEventsCount)} /{' '} {number.format(organization.subscriptionPeriodEventsCount)} /{' '}
{number.format( {number.format(
Number(organization.subscriptionPeriodEventsLimit), Number(organization.subscriptionPeriodEventsLimit)
)} )}
</div> </div>
<Progress <Progress
size="sm"
value={ value={
(organization.subscriptionPeriodEventsCount / (organization.subscriptionPeriodEventsCount /
Number(organization.subscriptionPeriodEventsLimit)) * Number(organization.subscriptionPeriodEventsLimit)) *
100 100
} }
size="sm"
/> />
<div className="row justify-end mt-4"> <div className="row mt-4 justify-end">
<Button <Button
size="sm"
onClick={() => onClick={() =>
pushModal('SelectBillingPlan', { pushModal('SelectBillingPlan', {
organization, organization,
currentProduct, currentProduct,
}) })
} }
size="sm"
> >
Upgrade Upgrade
</Button> </Button>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,255 @@
import type { IChartRange, IInterval } from '@openpanel/validation';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { AlertCircleIcon, ChevronsUpDownIcon, SearchIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Pagination } from '@/components/pagination';
import { Input } from '@/components/ui/input';
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 [search, setSearch] = useState('');
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 allItems = query.data ?? [];
const items = useMemo(() => {
if (!search.trim()) {
return allItems;
}
const q = search.toLowerCase();
return allItems.filter(
(item) =>
item.query.toLowerCase().includes(q) ||
item.pages.some((p) => p.page.toLowerCase().includes(q))
);
}, [allItems, search]);
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 || allItems.length)) {
return null;
}
return (
<div className="card">
<div className="border-b">
<div className="flex items-center justify-between 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="relative">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="rounded-none border-0 border-t 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) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search keywords"
type="search"
value={search}
/>
</div>
</div>
<div className="divide-y">
{query.isLoading &&
[1, 2, 3].map((i) => (
<div className="space-y-2 p-4" key={i}>
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
</div>
))}
{paginatedItems.map((item) => {
const isOpen = expanded.has(item.query);
const avgCtr =
item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length;
return (
<div key={item.query}>
<button
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-muted/40"
onClick={() => toggle(item.query)}
type="button"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
'row shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs',
'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
)}
>
<AlertCircleIcon className="size-3" />
{item.pages.length} pages
</div>
<span className="truncate font-medium text-sm">
{item.query}
</span>
</div>
<div className="flex shrink-0 items-center gap-4">
<span className="whitespace-nowrap font-mono text-muted-foreground text-xs">
{item.totalImpressions.toLocaleString()} impr ·{' '}
{(avgCtr * 100).toFixed(1)}% avg CTR
</span>
<ChevronsUpDownIcon
className={cn(
'size-3.5 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)}
/>
</div>
</button>
{isOpen && (
<div className="border-t bg-muted/20 px-4 py-3">
<p className="mb-3 text-muted-foreground text-xs leading-normal">
These pages all rank for{' '}
<span className="font-medium text-foreground">
"{item.query}"
</span>
. Consider consolidating weaker pages into the top-ranking
one to concentrate link equity and avoid splitting clicks.
</p>
<div className="space-y-1.5">
{item.pages.map((page, idx) => {
// Strip hash fragments — GSC sometimes returns heading
// anchor URLs (e.g. /page#section) as separate entries
let cleanUrl = page.page;
let origin = '';
let path = page.page;
try {
const u = new URL(page.page);
u.hash = '';
cleanUrl = u.toString();
origin = u.origin;
path = u.pathname + u.search;
} catch {
cleanUrl = page.page.split('#')[0] ?? page.page;
}
const isWinner = idx === 0;
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60"
key={page.page}
onClick={() =>
pushModal('PageDetails', {
type: 'page',
projectId,
value: cleanUrl,
})
}
type="button"
>
<img
alt=""
className="size-3.5 shrink-0 rounded-sm"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display =
'none';
}}
src={`${apiUrl}/misc/favicon?url=${origin}`}
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{path || page.page}
</span>
{isWinner && (
<span className="shrink-0 rounded bg-emerald-100 px-1 py-0.5 font-medium text-emerald-700 text-xs dark:bg-emerald-900/30 dark:text-emerald-400">
#1
</span>
)}
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
pos {page.position.toFixed(1)} ·{' '}
{(page.ctr * 100).toFixed(1)}% CTR ·{' '}
{page.impressions.toLocaleString()} impr
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,360 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import {
AlertTriangleIcon,
EyeIcon,
MousePointerClickIcon,
SearchIcon,
TrendingUpIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Pagination } from '@/components/pagination';
import { Input } from '@/components/ui/input';
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 [search, setSearch] = useState('');
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 filteredInsights = useMemo(() => {
if (!search.trim()) {
return insights;
}
const q = search.toLowerCase();
return insights.filter(
(i) =>
i.path.toLowerCase().includes(q) || i.page.toLowerCase().includes(q)
);
}, [insights, search]);
const pageCount = Math.ceil(filteredInsights.length / pageSize) || 1;
const paginatedInsights = useMemo(
() => filteredInsights.slice(page * pageSize, (page + 1) * pageSize),
[filteredInsights, page, pageSize]
);
const rangeStart = filteredInsights.length ? page * pageSize + 1 : 0;
const rangeEnd = Math.min((page + 1) * pageSize, filteredInsights.length);
if (!(isLoading || insights.length)) {
return null;
}
return (
<div className="card">
<div className="border-b">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">Opportunities</h3>
{filteredInsights.length > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
{filteredInsights.length}
</span>
)}
</div>
{filteredInsights.length > 0 && (
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-muted-foreground text-xs">
{filteredInsights.length === 0
? '0 results'
: `${rangeStart}-${rangeEnd} of ${filteredInsights.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="relative">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="rounded-none border-0 border-t 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) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search pages"
type="search"
value={search}
/>
</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">
<span
className={cn(
'row shrink-0 items-center gap-1 self-start rounded-md px-1 py-0.5 font-medium text-xs',
config.color,
config.bg
)}
>
<Icon className="size-3" />
{config.label}
</span>
<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="ml-auto shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
{insight.metrics}
</span>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
<span className="font-medium text-foreground">
{insight.headline}.
</span>{' '}
{insight.suggestion}
</p>
</div>
</button>
);
})}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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