Compare commits
33 Commits
self-hosti
...
feature/op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a76b968ba | ||
|
|
9c3c1458bb | ||
|
|
a672b73947 | ||
|
|
bc08566cd4 | ||
|
|
bf39804767 | ||
|
|
664f1abe0a | ||
|
|
8afcf55154 | ||
|
|
4736f8509d | ||
|
|
05cf6bb39f | ||
|
|
6e1daf2c76 | ||
|
|
f2aa0273e6 | ||
|
|
1b898660ad | ||
|
|
9836f75e17 | ||
|
|
271d189ed0 | ||
|
|
70ca44f039 | ||
|
|
00f6cd6f50 | ||
|
|
227d629dc5 | ||
|
|
f2e19093f0 | ||
|
|
7f85b2ac0a | ||
|
|
38965387da | ||
|
|
74bcb7ead2 | ||
|
|
2377f95b86 | ||
|
|
de6ca96628 | ||
|
|
9e46099246 | ||
|
|
83761638f2 | ||
|
|
885f7225db | ||
|
|
553e4cf675 | ||
|
|
f2c414b4b4 | ||
|
|
043730444a | ||
|
|
8c377c2066 | ||
|
|
647ac2a4af | ||
|
|
6251d143d1 | ||
|
|
b801d6a8ef |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.secrets
|
||||
packages/db/src/generated/prisma
|
||||
packages/db/code-migrations/*.sql
|
||||
**/.open-next
|
||||
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
packages/sdk/profileId.txt
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"groupmq": "catalog:",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/trpc": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
@@ -40,6 +39,7 @@
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-metrics": "^12.1.0",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"groupmq": "catalog:",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"sharp": "^0.33.5",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import bots from './bots';
|
||||
|
||||
// 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 includesBots = compiledBots.filter((bot) => 'includes' in bot);
|
||||
|
||||
export const isBot = cacheableLru(
|
||||
export const isBot = cacheable(
|
||||
'is-bot',
|
||||
(ua: string) => {
|
||||
// Check simple string patterns first (fast)
|
||||
@@ -40,8 +40,5 @@ export const isBot = cacheableLru(
|
||||
|
||||
return null;
|
||||
},
|
||||
{
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
60 * 5
|
||||
);
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { DateTime } from '@openpanel/common';
|
||||
import type { GetEventListOptions } from '@openpanel/db';
|
||||
import {
|
||||
ChartEngine,
|
||||
ClientType,
|
||||
db,
|
||||
getEventList,
|
||||
getEventsCountCached,
|
||||
getEventsCount,
|
||||
getSettingsForProject,
|
||||
} from '@openpanel/db';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { zChartEvent, zReport } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||
|
||||
async function getProjectId(
|
||||
request: FastifyRequest<{
|
||||
@@ -22,8 +20,7 @@ async function getProjectId(
|
||||
project_id?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
}>
|
||||
) {
|
||||
let projectId = request.query.projectId || request.query.project_id;
|
||||
|
||||
@@ -75,8 +72,20 @@ const eventsScheme = z.object({
|
||||
limit: z.coerce.number().optional().default(50),
|
||||
includes: z
|
||||
.preprocess(
|
||||
(arg) => (typeof arg === 'string' ? [arg] : arg),
|
||||
z.array(z.string()),
|
||||
(arg) => {
|
||||
if (arg == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(arg)) {
|
||||
return arg;
|
||||
}
|
||||
if (typeof arg === 'string') {
|
||||
const parts = arg.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return parts;
|
||||
}
|
||||
return arg;
|
||||
},
|
||||
z.array(z.string())
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
@@ -85,7 +94,7 @@ export async function events(
|
||||
request: FastifyRequest<{
|
||||
Querystring: z.infer<typeof eventsScheme>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const query = eventsScheme.safeParse(request.query);
|
||||
|
||||
@@ -97,7 +106,7 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectId(request, reply);
|
||||
const projectId = await getProjectId(request);
|
||||
const limit = query.data.limit;
|
||||
const page = Math.max(query.data.page, 1);
|
||||
const take = Math.max(Math.min(limit, 1000), 1);
|
||||
@@ -118,20 +127,20 @@ export async function events(
|
||||
meta: false,
|
||||
...query.data.includes?.reduce(
|
||||
(acc, key) => ({ ...acc, [key]: true }),
|
||||
{},
|
||||
{}
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const [data, totalCount] = await Promise.all([
|
||||
getEventList(options),
|
||||
getEventsCountCached(omit(['cursor', 'take'], options)),
|
||||
getEventsCount(options),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
meta: {
|
||||
count: data.length,
|
||||
totalCount: totalCount,
|
||||
totalCount,
|
||||
pages: Math.ceil(totalCount / options.take),
|
||||
current: cursor + 1,
|
||||
},
|
||||
@@ -158,7 +167,7 @@ const chartSchemeFull = zReport
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
// Backward compatibility - events will be migrated to series via preprocessing
|
||||
@@ -169,7 +178,7 @@ const chartSchemeFull = zReport
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
@@ -178,7 +187,7 @@ export async function charts(
|
||||
request: FastifyRequest<{
|
||||
Querystring: Record<string, string>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const query = chartSchemeFull.safeParse(parseQueryString(request.query));
|
||||
|
||||
@@ -190,7 +199,7 @@ export async function charts(
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectId(request, reply);
|
||||
const projectId = await getProjectId(request);
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const { events, series, ...rest } = query.data;
|
||||
|
||||
|
||||
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal file
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { googleGsc } from '@openpanel/auth';
|
||||
import { db, encrypt } from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { LogError } from '@/utils/errors';
|
||||
|
||||
const OAUTH_SENSITIVE_KEYS = ['code', 'state'];
|
||||
|
||||
function sanitizeOAuthQuery(
|
||||
query: Record<string, unknown> | null | undefined
|
||||
): Record<string, string> {
|
||||
if (!query || typeof query !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(query).map(([k, v]) => [
|
||||
k,
|
||||
OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function gscGoogleCallback(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const schema = z.object({
|
||||
code: z.string(),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
const query = schema.safeParse(req.query);
|
||||
if (!query.success) {
|
||||
throw new LogError(
|
||||
'Invalid GSC callback query params',
|
||||
sanitizeOAuthQuery(req.query as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
const { code, state } = query.data;
|
||||
|
||||
const rawStoredState = req.cookies.gsc_oauth_state ?? null;
|
||||
const rawCodeVerifier = req.cookies.gsc_code_verifier ?? null;
|
||||
const rawProjectId = req.cookies.gsc_project_id ?? null;
|
||||
|
||||
const storedStateResult =
|
||||
rawStoredState !== null ? req.unsignCookie(rawStoredState) : null;
|
||||
const codeVerifierResult =
|
||||
rawCodeVerifier !== null ? req.unsignCookie(rawCodeVerifier) : null;
|
||||
const projectIdResult =
|
||||
rawProjectId !== null ? req.unsignCookie(rawProjectId) : null;
|
||||
|
||||
if (
|
||||
!(
|
||||
storedStateResult?.value &&
|
||||
codeVerifierResult?.value &&
|
||||
projectIdResult?.value
|
||||
)
|
||||
) {
|
||||
throw new LogError('Missing GSC OAuth cookies', {
|
||||
storedState: !storedStateResult?.value,
|
||||
codeVerifier: !codeVerifierResult?.value,
|
||||
projectId: !projectIdResult?.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
storedStateResult?.valid &&
|
||||
codeVerifierResult?.valid &&
|
||||
projectIdResult?.valid
|
||||
)
|
||||
) {
|
||||
throw new LogError('Invalid GSC OAuth cookies', {
|
||||
storedState: !storedStateResult?.value,
|
||||
codeVerifier: !codeVerifierResult?.value,
|
||||
projectId: !projectIdResult?.value,
|
||||
});
|
||||
}
|
||||
|
||||
const stateStr = storedStateResult?.value;
|
||||
const codeVerifierStr = codeVerifierResult?.value;
|
||||
const projectIdStr = projectIdResult?.value;
|
||||
|
||||
if (state !== stateStr) {
|
||||
throw new LogError('GSC OAuth state mismatch', {
|
||||
hasState: true,
|
||||
hasStoredState: true,
|
||||
stateMismatch: true,
|
||||
});
|
||||
}
|
||||
|
||||
const tokens = await googleGsc.validateAuthorizationCode(
|
||||
code,
|
||||
codeVerifierStr
|
||||
);
|
||||
|
||||
const accessToken = tokens.accessToken();
|
||||
const refreshToken = tokens.hasRefreshToken()
|
||||
? tokens.refreshToken()
|
||||
: null;
|
||||
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new LogError('No refresh token returned from Google GSC OAuth');
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectIdStr },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new LogError('Project not found for GSC connection', {
|
||||
projectId: projectIdStr,
|
||||
});
|
||||
}
|
||||
|
||||
await db.gscConnection.upsert({
|
||||
where: { projectId: projectIdStr },
|
||||
create: {
|
||||
projectId: projectIdStr,
|
||||
accessToken: encrypt(accessToken),
|
||||
refreshToken: encrypt(refreshToken),
|
||||
accessTokenExpiresAt,
|
||||
siteUrl: '',
|
||||
},
|
||||
update: {
|
||||
accessToken: encrypt(accessToken),
|
||||
refreshToken: encrypt(refreshToken),
|
||||
accessTokenExpiresAt,
|
||||
lastSyncStatus: null,
|
||||
lastSyncError: null,
|
||||
},
|
||||
});
|
||||
|
||||
reply.clearCookie('gsc_oauth_state');
|
||||
reply.clearCookie('gsc_code_verifier');
|
||||
reply.clearCookie('gsc_project_id');
|
||||
|
||||
const dashboardUrl =
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
|
||||
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`;
|
||||
return reply.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
req.log.error(error);
|
||||
reply.clearCookie('gsc_oauth_state');
|
||||
reply.clearCookie('gsc_code_verifier');
|
||||
reply.clearCookie('gsc_project_id');
|
||||
return redirectWithError(reply, error);
|
||||
}
|
||||
}
|
||||
|
||||
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
||||
const url = new URL(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
url.pathname = '/login';
|
||||
if (error instanceof LogError) {
|
||||
url.searchParams.set('error', error.message);
|
||||
} else {
|
||||
url.searchParams.set('error', 'Failed to connect Google Search Console');
|
||||
}
|
||||
url.searchParams.set('correlationId', reply.request.id);
|
||||
return reply.redirect(url.toString());
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { isShuttingDown } from '@/utils/graceful-shutdown';
|
||||
import { chQuery, db } from '@openpanel/db';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isShuttingDown } from '@/utils/graceful-shutdown';
|
||||
|
||||
// For docker compose healthcheck
|
||||
export async function healthcheck(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const redisRes = await getRedisCache().ping();
|
||||
@@ -21,6 +21,7 @@ export async function healthcheck(
|
||||
ch: chRes && chRes.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.warn('healthcheck failed', { error });
|
||||
return reply.status(503).send({
|
||||
ready: false,
|
||||
reason: 'dependencies not ready',
|
||||
@@ -41,18 +42,22 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
// Perform lightweight dependency checks for readiness
|
||||
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 isReady = redisRes && dbRes && chRes;
|
||||
const isReady = redisRes;
|
||||
|
||||
if (!isReady) {
|
||||
return reply.status(503).send({
|
||||
ready: false,
|
||||
reason: 'dependencies not ready',
|
||||
const res = {
|
||||
redis: redisRes === 'PONG',
|
||||
db: !!dbRes,
|
||||
ch: chRes && chRes.length > 0,
|
||||
};
|
||||
request.log.warn('dependencies not ready', res);
|
||||
return reply.status(503).send({
|
||||
ready: false,
|
||||
reason: 'dependencies not ready',
|
||||
...res,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import {
|
||||
eventBuffer,
|
||||
getProfileById,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { eventBuffer } from '@openpanel/db';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
import {
|
||||
psubscribeToPublishedEvent,
|
||||
@@ -14,10 +7,7 @@ import {
|
||||
} from '@openpanel/redis';
|
||||
import { getProjectAccess } from '@openpanel/trpc';
|
||||
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
|
||||
|
||||
export function getLiveEventInfo(key: string) {
|
||||
return key.split(':').slice(2) as [string, string];
|
||||
}
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export function wsVisitors(
|
||||
socket: WebSocket,
|
||||
@@ -25,27 +15,38 @@ export function wsVisitors(
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => {
|
||||
if (event?.projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
const sendCount = () => {
|
||||
eventBuffer
|
||||
.getActiveVisitorCount(params.projectId)
|
||||
.then((count) => {
|
||||
socket.send(String(count));
|
||||
})
|
||||
.catch(() => {
|
||||
socket.send('0');
|
||||
});
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'events',
|
||||
'batch',
|
||||
({ projectId }) => {
|
||||
if (projectId === params.projectId) {
|
||||
sendCount();
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
const punsubscribe = psubscribeToPublishedEvent(
|
||||
'__keyevent@0__:expired',
|
||||
(key) => {
|
||||
const [projectId] = getLiveEventInfo(key);
|
||||
if (projectId && projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
socket.send(String(count));
|
||||
});
|
||||
const [, , projectId] = key.split(':');
|
||||
if (projectId === params.projectId) {
|
||||
sendCount();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => {
|
||||
@@ -62,18 +63,10 @@ export async function wsProjectEvents(
|
||||
};
|
||||
Querystring: {
|
||||
token?: string;
|
||||
type?: 'saved' | 'received';
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params, query } = req;
|
||||
const type = query.type || 'saved';
|
||||
|
||||
if (!['saved', 'received'].includes(type)) {
|
||||
socket.send('Invalid type');
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
const { params } = req;
|
||||
|
||||
const userId = req.session?.userId;
|
||||
if (!userId) {
|
||||
@@ -87,24 +80,20 @@ export async function wsProjectEvents(
|
||||
projectId: params.projectId,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
socket.send('No access');
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'events',
|
||||
type,
|
||||
async (event) => {
|
||||
if (event.projectId === params.projectId) {
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
socket.send(
|
||||
superjson.stringify(
|
||||
access
|
||||
? {
|
||||
...event,
|
||||
profile,
|
||||
}
|
||||
: transformMinimalEvent(event),
|
||||
),
|
||||
);
|
||||
'batch',
|
||||
({ projectId, count }) => {
|
||||
if (projectId === params.projectId) {
|
||||
socket.send(setSuperJson({ count }));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
@@ -116,7 +105,7 @@ export async function wsProjectNotifications(
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
@@ -143,9 +132,9 @@ export async function wsProjectNotifications(
|
||||
'created',
|
||||
(notification) => {
|
||||
if (notification.projectId === params.projectId) {
|
||||
socket.send(superjson.stringify(notification));
|
||||
socket.send(setSuperJson(notification));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
@@ -157,7 +146,7 @@ export async function wsOrganizationEvents(
|
||||
Params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
@@ -184,7 +173,7 @@ export async function wsOrganizationEvents(
|
||||
'subscription_updated',
|
||||
(message) => {
|
||||
socket.send(setSuperJson(message));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { stripTrailingSlash } from '@openpanel/common';
|
||||
import { hashPassword } from '@openpanel/common/server';
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
} from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
|
||||
// Validation schemas
|
||||
const zCreateProject = z.object({
|
||||
@@ -57,7 +57,7 @@ const zUpdateReference = z.object({
|
||||
// Projects CRUD
|
||||
export async function listProjects(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
@@ -74,7 +74,7 @@ export async function listProjects(
|
||||
|
||||
export async function getProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
@@ -92,7 +92,7 @@ export async function getProject(
|
||||
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zCreateProject.safeParse(request.body);
|
||||
|
||||
@@ -139,12 +139,9 @@ export async function createProject(
|
||||
},
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
project.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
...project.clients.map((client) => getClientByIdCached.clear(client.id)),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
@@ -165,7 +162,7 @@ export async function updateProject(
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateProject>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zUpdateProject.safeParse(request.body);
|
||||
|
||||
@@ -223,12 +220,9 @@ export async function updateProject(
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
existing.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
|
||||
]);
|
||||
|
||||
reply.send({ data: project });
|
||||
@@ -236,7 +230,7 @@ export async function updateProject(
|
||||
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
@@ -266,7 +260,7 @@ export async function deleteProject(
|
||||
// Clients CRUD
|
||||
export async function listClients(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const where: any = {
|
||||
organizationId: request.client!.organizationId,
|
||||
@@ -300,7 +294,7 @@ export async function listClients(
|
||||
|
||||
export async function getClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
@@ -318,7 +312,7 @@ export async function getClient(
|
||||
|
||||
export async function createClient(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zCreateClient.safeParse(request.body);
|
||||
|
||||
@@ -374,7 +368,7 @@ export async function updateClient(
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateClient>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zUpdateClient.safeParse(request.body);
|
||||
|
||||
@@ -417,7 +411,7 @@ export async function updateClient(
|
||||
|
||||
export async function deleteClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
@@ -444,7 +438,7 @@ export async function deleteClient(
|
||||
// References CRUD
|
||||
export async function listReferences(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const where: any = {};
|
||||
|
||||
@@ -488,7 +482,7 @@ export async function listReferences(
|
||||
|
||||
export async function getReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
@@ -516,7 +510,7 @@ export async function getReference(
|
||||
|
||||
export async function createReference(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zCreateReference.safeParse(request.body);
|
||||
|
||||
@@ -559,7 +553,7 @@ export async function updateReference(
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateReference>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zUpdateReference.safeParse(request.body);
|
||||
|
||||
@@ -616,7 +610,7 @@ export async function updateReference(
|
||||
|
||||
export async function deleteReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
github,
|
||||
google,
|
||||
type OAuth2Tokens,
|
||||
setLastAuthProviderCookie,
|
||||
setSessionTokenCookie,
|
||||
} from '@openpanel/auth';
|
||||
import { type Account, connectUserToOrganization, db } from '@openpanel/db';
|
||||
@@ -76,11 +77,10 @@ async function handleExistingUser({
|
||||
sessionToken,
|
||||
session.expiresAt
|
||||
);
|
||||
reply.setCookie('last-auth-provider', providerName, {
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
});
|
||||
setLastAuthProviderCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
providerName
|
||||
);
|
||||
return reply.redirect(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
@@ -145,11 +145,10 @@ async function handleNewUser({
|
||||
sessionToken,
|
||||
session.expiresAt
|
||||
);
|
||||
reply.setCookie('last-auth-provider', providerName, {
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
});
|
||||
setLastAuthProviderCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
providerName
|
||||
);
|
||||
return reply.redirect(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
upsertProfile,
|
||||
} from '@openpanel/db';
|
||||
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 {
|
||||
type IDecrementPayload,
|
||||
@@ -112,6 +115,7 @@ interface TrackContext {
|
||||
identity?: IIdentifyPayload;
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
|
||||
geo: GeoLocation;
|
||||
}
|
||||
|
||||
@@ -141,19 +145,21 @@ async function buildContext(
|
||||
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)
|
||||
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
|
||||
|
||||
const { deviceId, sessionId } = await getDeviceId({
|
||||
const deviceIdResult = await getDeviceId({
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
salts,
|
||||
overrideDeviceId:
|
||||
validatedBody.type === 'track' &&
|
||||
typeof validatedBody.payload?.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload?.properties.__deviceId
|
||||
: undefined,
|
||||
overrideDeviceId,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -166,8 +172,9 @@ async function buildContext(
|
||||
isFromPast: timestamp.isTimestampFromThePast,
|
||||
},
|
||||
identity,
|
||||
deviceId,
|
||||
sessionId,
|
||||
deviceId: deviceIdResult.deviceId,
|
||||
sessionId: deviceIdResult.sessionId,
|
||||
session: deviceIdResult.session,
|
||||
geo,
|
||||
};
|
||||
}
|
||||
@@ -176,13 +183,14 @@ async function handleTrack(
|
||||
payload: ITrackPayload,
|
||||
context: TrackContext
|
||||
): 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 groupId = uaInfo.isServer
|
||||
? payload.profileId
|
||||
? `${projectId}:${payload.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: undefined
|
||||
: deviceId;
|
||||
const jobId = [
|
||||
slug(payload.name),
|
||||
@@ -203,7 +211,7 @@ async function handleTrack(
|
||||
}
|
||||
|
||||
promises.push(
|
||||
getEventsGroupQueueShard(groupId).add({
|
||||
getEventsGroupQueueShard(groupId || generateId()).add({
|
||||
orderMs: timestamp.value,
|
||||
data: {
|
||||
projectId,
|
||||
@@ -217,6 +225,7 @@ async function handleTrack(
|
||||
geo,
|
||||
deviceId,
|
||||
sessionId,
|
||||
session,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { isBot } from '@/bots';
|
||||
import { createBotEvent } from '@openpanel/db';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isBot } from '@/bots';
|
||||
|
||||
export async function isBotHook(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const bot = req.headers['user-agent']
|
||||
? isBot(req.headers['user-agent'])
|
||||
? await isBot(req.headers['user-agent'])
|
||||
: null;
|
||||
|
||||
if (bot && req.client?.projectId) {
|
||||
@@ -44,6 +43,6 @@ export async function isBotHook(
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(202).send();
|
||||
return reply.status(202).send({ bot });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { timestampHook } from './hooks/timestamp.hook';
|
||||
import aiRouter from './routes/ai.router';
|
||||
import eventRouter from './routes/event.router';
|
||||
import exportRouter from './routes/export.router';
|
||||
import gscCallbackRouter from './routes/gsc-callback.router';
|
||||
import importRouter from './routes/import.router';
|
||||
import insightsRouter from './routes/insights.router';
|
||||
import liveRouter from './routes/live.router';
|
||||
@@ -194,6 +195,7 @@ const startServer = async () => {
|
||||
instance.register(liveRouter, { prefix: '/live' });
|
||||
instance.register(webhookRouter, { prefix: '/webhook' });
|
||||
instance.register(oauthRouter, { prefix: '/oauth' });
|
||||
instance.register(gscCallbackRouter, { prefix: '/gsc' });
|
||||
instance.register(miscRouter, { prefix: '/misc' });
|
||||
instance.register(aiRouter, { prefix: '/ai' });
|
||||
});
|
||||
|
||||
12
apps/api/src/routes/gsc-callback.router.ts
Normal file
12
apps/api/src/routes/gsc-callback.router.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
const router: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/callback',
|
||||
handler: gscGoogleCallback,
|
||||
});
|
||||
};
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
@@ -13,7 +12,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
handler: handler,
|
||||
handler,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { generateDeviceId } from '@openpanel/common/server';
|
||||
import { getSafeJson } from '@openpanel/json';
|
||||
import type {
|
||||
EventsQueuePayloadCreateSessionEnd,
|
||||
EventsQueuePayloadIncomingEvent,
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import { pick } from 'ramda';
|
||||
|
||||
export async function getDeviceId({
|
||||
projectId,
|
||||
@@ -37,14 +42,20 @@ export async function getDeviceId({
|
||||
ua,
|
||||
});
|
||||
|
||||
return await getDeviceIdFromSession({
|
||||
return await getInfoFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function getDeviceIdFromSession({
|
||||
interface DeviceIdResult {
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
|
||||
}
|
||||
|
||||
async function getInfoFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
@@ -52,7 +63,7 @@ async function getDeviceIdFromSession({
|
||||
projectId: string;
|
||||
currentDeviceId: string;
|
||||
previousDeviceId: string;
|
||||
}) {
|
||||
}): Promise<DeviceIdResult> {
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.hget(
|
||||
@@ -65,21 +76,33 @@ async function getDeviceIdFromSession({
|
||||
);
|
||||
const res = await multi.exec();
|
||||
if (res?.[0]?.[1]) {
|
||||
const data = getSafeJson<{ payload: { sessionId: string } }>(
|
||||
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
|
||||
(res?.[0]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
const sessionId = data.payload.sessionId;
|
||||
return { deviceId: currentDeviceId, sessionId };
|
||||
return {
|
||||
deviceId: currentDeviceId,
|
||||
sessionId: data.payload.sessionId,
|
||||
session: pick(
|
||||
['referrer', 'referrerName', 'referrerType'],
|
||||
data.payload
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (res?.[1]?.[1]) {
|
||||
const data = getSafeJson<{ payload: { sessionId: string } }>(
|
||||
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
|
||||
(res?.[1]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
const sessionId = data.payload.sessionId;
|
||||
return { deviceId: previousDeviceId, sessionId };
|
||||
return {
|
||||
deviceId: previousDeviceId,
|
||||
sessionId: data.payload.sessionId,
|
||||
session: pick(
|
||||
['referrer', 'referrerName', 'referrerType'],
|
||||
data.payload
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
76
apps/public/content/docs/(tracking)/consent-management.mdx
Normal file
76
apps/public/content/docs/(tracking)/consent-management.mdx
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Consent management
|
||||
description: Queue all tracking until the user gives consent, then flush everything with a single call.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
Some jurisdictions require explicit user consent before you can track events or record sessions. OpenPanel has built-in support for this: initialise with `disabled: true` and nothing is sent until you call `ready()`.
|
||||
|
||||
## How it works
|
||||
|
||||
When `disabled: true` is set, all calls to `track`, `identify`, `screenView`, and session replay chunks are held in an in-memory queue instead of being sent to the API. Once the user consents, call `ready()` and the entire queue is flushed immediately.
|
||||
|
||||
```ts
|
||||
const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
disabled: true, // nothing sent until ready() is called
|
||||
});
|
||||
|
||||
// Later, when the user accepts your consent banner:
|
||||
op.ready();
|
||||
```
|
||||
|
||||
If the user declines, simply don't call `ready()`. The queue is discarded when the page unloads.
|
||||
|
||||
## With session replay
|
||||
|
||||
Session replay chunks are also queued while `disabled: true`. Once `ready()` is called, buffered replay chunks flush along with any queued events.
|
||||
|
||||
```ts
|
||||
const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
disabled: true,
|
||||
trackScreenViews: true,
|
||||
sessionReplay: { enabled: true },
|
||||
});
|
||||
|
||||
// User accepts consent:
|
||||
op.ready();
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
The replay recorder starts as soon as the page loads (so no interactions are missed), but no data is sent until `ready()` is called.
|
||||
</Callout>
|
||||
|
||||
## Waiting for a user profile
|
||||
|
||||
If you want to hold events until you know who the user is rather than waiting for explicit consent, use `waitForProfile` instead. Events are queued until `identify()` is called with a `profileId`.
|
||||
|
||||
```ts
|
||||
const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
waitForProfile: true,
|
||||
});
|
||||
|
||||
// Events queue here...
|
||||
op.track('page_view');
|
||||
|
||||
// Queue is flushed once a profileId is set:
|
||||
op.identify({ profileId: 'user_123' });
|
||||
```
|
||||
|
||||
If the user never authenticates, the queue is never flushed automatically — no events will be sent. To handle anonymous users or guest flows, call `ready()` explicitly when you know the user won't identify:
|
||||
|
||||
```ts
|
||||
// User skipped login — flush queued events without a profileId
|
||||
op.ready();
|
||||
```
|
||||
|
||||
`ready()` always releases the queue regardless of whether `waitForProfile` or `disabled` is set.
|
||||
|
||||
## Related
|
||||
|
||||
- [Consent management guide](/guides/consent-management) — full walkthrough with a cookie banner example
|
||||
- [Session replay](/docs/session-replay) — privacy controls for replay recordings
|
||||
- [Identify users](/docs/get-started/identify-users) — link events to a user profile
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["sdks", "how-it-works", "session-replay", "..."]
|
||||
"pages": ["sdks", "how-it-works", "session-replay", "consent-management", "..."]
|
||||
}
|
||||
|
||||
@@ -67,8 +67,9 @@ With the npm package, the replay module is a dynamic import code-split by your b
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `enabled` | `boolean` | `false` | Enable session replay recording |
|
||||
| `maskAllInputs` | `boolean` | `true` | Mask all input field values with `*` |
|
||||
| `maskTextSelector` | `string` | `[data-openpanel-replay-mask]` | CSS selector for text elements to mask |
|
||||
| `maskAllInputs` | `boolean` | `true` | Mask all input field values |
|
||||
| `maskAllText` | `boolean` | `true` | Mask all text content in the recording |
|
||||
| `unmaskTextSelector` | `string` | — | CSS selector for elements whose text should NOT be masked when `maskAllText` is true |
|
||||
| `blockSelector` | `string` | `[data-openpanel-replay-block]` | CSS selector for elements to replace with a placeholder |
|
||||
| `blockClass` | `string` | — | Class name that blocks elements from being recorded |
|
||||
| `ignoreSelector` | `string` | — | CSS selector for elements excluded from interaction tracking |
|
||||
@@ -79,40 +80,72 @@ With the npm package, the replay module is a dynamic import code-split by your b
|
||||
|
||||
## Privacy controls
|
||||
|
||||
Session replay captures user interactions. These options protect sensitive content before it ever leaves the browser.
|
||||
Session replay captures user interactions. All text and inputs are masked by default — sensitive content is replaced with `***` before it ever leaves the browser.
|
||||
|
||||
### Masking inputs
|
||||
### Text masking (default on)
|
||||
|
||||
All input fields are masked by default (`maskAllInputs: true`). Recorded values appear as `***` in the replay. Disable this only if you have a specific reason—form field contents are almost always personal data.
|
||||
All text content is masked by default (`maskAllText: true`). This means visible page text, labels, and content are replaced with `***` in replays, in addition to input fields.
|
||||
|
||||
### Masking specific text
|
||||
This is the safest default for GDPR compliance since replays cannot incidentally capture names, emails, or other personal data visible on the page.
|
||||
|
||||
Add `data-openpanel-replay-mask` to any element to replace its text with `***` in replays:
|
||||
### Selectively unmasking text
|
||||
|
||||
```html
|
||||
<p data-openpanel-replay-mask>Sensitive text here</p>
|
||||
```
|
||||
|
||||
Or use a custom selector:
|
||||
If your pages display non-sensitive content you want visible in replays, use `unmaskTextSelector` to opt specific elements out of masking:
|
||||
|
||||
```ts
|
||||
sessionReplay: {
|
||||
enabled: true,
|
||||
maskTextSelector: '.pii, [data-sensitive]',
|
||||
unmaskTextSelector: '[data-openpanel-unmask]',
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<h1 data-openpanel-unmask>Product Analytics</h1>
|
||||
<p data-openpanel-unmask>Welcome to the dashboard</p>
|
||||
<!-- This stays masked: -->
|
||||
<p>John Doe · john@example.com</p>
|
||||
```
|
||||
|
||||
You can also use any CSS selector to target elements by class, tag, or attribute:
|
||||
|
||||
```ts
|
||||
sessionReplay: {
|
||||
enabled: true,
|
||||
unmaskTextSelector: '.replay-safe, nav, footer',
|
||||
}
|
||||
```
|
||||
|
||||
### Disabling full text masking
|
||||
|
||||
If you want to disable full text masking and return to selector-based masking, set `maskAllText: false`. In this mode only elements with `data-openpanel-replay-mask` are masked:
|
||||
|
||||
```ts
|
||||
sessionReplay: {
|
||||
enabled: true,
|
||||
maskAllText: false,
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<p data-openpanel-replay-mask>This will be masked</p>
|
||||
<p>This will be visible in replays</p>
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
Only disable `maskAllText` if you are confident your pages do not display personal data, or if you are masking all sensitive elements individually. You are responsible for ensuring your use of session replay complies with applicable privacy law.
|
||||
</Callout>
|
||||
|
||||
### Blocking elements
|
||||
|
||||
Elements matched by `blockSelector` or `blockClass` are replaced with a same-size grey placeholder in the replay. The element and all its children are never recorded.
|
||||
|
||||
```html
|
||||
<div data-openpanel-replay-block>
|
||||
This section won't appear in replays
|
||||
This section won't appear in replays at all
|
||||
</div>
|
||||
```
|
||||
|
||||
Or with a custom selector and class:
|
||||
Or with a custom selector:
|
||||
|
||||
```ts
|
||||
sessionReplay: {
|
||||
|
||||
@@ -53,14 +53,32 @@ GET /export/events
|
||||
| `end` | string | End date for the event range (ISO format) | `2024-04-18` |
|
||||
| `page` | number | Page number for pagination (default: 1) | `2` |
|
||||
| `limit` | number | Number of events per page (default: 50, max: 1000) | `100` |
|
||||
| `includes` | string or string[] | Additional fields to include in the response | `profile` or `["profile","meta"]` |
|
||||
| `includes` | string or string[] | Additional fields to include in the response. Pass multiple as comma-separated (`profile,meta`) or repeated params (`includes=profile&includes=meta`). | `profile` or `profile,meta` |
|
||||
|
||||
#### Include Options
|
||||
|
||||
The `includes` parameter allows you to fetch additional related data:
|
||||
The `includes` parameter allows you to fetch additional related data. When using query parameters, you can pass multiple values in either of these ways:
|
||||
|
||||
- `profile`: Include user profile information
|
||||
- `meta`: Include event metadata and configuration
|
||||
- **Comma-separated**: `?includes=profile,meta` (include both profile and meta in the response)
|
||||
- **Repeated parameter**: `?includes=profile&includes=meta` (same result; useful when building URLs programmatically)
|
||||
|
||||
Supported values (any of these can be combined; names match the response keys):
|
||||
|
||||
**Related data** (adds nested objects or extra lookups):
|
||||
|
||||
- `profile` — User profile for the event (id, email, firstName, lastName, etc.)
|
||||
- `meta` — Event metadata from project config (name, description, conversion flag)
|
||||
|
||||
**Event fields** (optional columns; these are in addition to the default fields):
|
||||
|
||||
- `properties` — Custom event properties
|
||||
- `region`, `longitude`, `latitude` — Extra geo (default already has `city`, `country`)
|
||||
- `osVersion`, `browserVersion`, `device`, `brand`, `model` — Extra device (default already has `os`, `browser`)
|
||||
- `origin`, `referrer`, `referrerName`, `referrerType` — Referrer/navigation
|
||||
- `revenue` — Revenue amount
|
||||
- `importedAt`, `sdkName`, `sdkVersion` — Import/SDK info
|
||||
|
||||
The response always includes: `id`, `name`, `deviceId`, `profileId`, `sessionId`, `projectId`, `createdAt`, `path`, `duration`, `city`, `country`, `os`, `browser`. Use `includes` to add any of the values above.
|
||||
|
||||
#### Example Request
|
||||
|
||||
@@ -129,12 +147,15 @@ Retrieve aggregated chart data for analytics and visualization. This endpoint pr
|
||||
GET /export/charts
|
||||
```
|
||||
|
||||
**Note:** The endpoint accepts either `series` or `events` for the event configuration; `series` is the preferred parameter name. Both use the same structure.
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Description | Example |
|
||||
|-----------|------|-------------|---------|
|
||||
| `projectId` | string | The ID of the project to fetch chart data from | `abc123` |
|
||||
| `events` | object[] | Array of event configurations to analyze | `[{"name":"screen_view","filters":[]}]` |
|
||||
| `series` | object[] | Array of event/series configurations to analyze (preferred over `events`) | `[{"name":"screen_view","filters":[]}]` |
|
||||
| `events` | object[] | Array of event configurations (deprecated in favor of `series`) | `[{"name":"screen_view","filters":[]}]` |
|
||||
| `breakdowns` | object[] | Array of breakdown dimensions | `[{"name":"country"}]` |
|
||||
| `interval` | string | Time interval for data points | `day` |
|
||||
| `range` | string | Predefined date range | `7d` |
|
||||
@@ -144,7 +165,7 @@ GET /export/charts
|
||||
|
||||
#### Event Configuration
|
||||
|
||||
Each event in the `events` array supports the following properties:
|
||||
Each item in the `series` or `events` array supports the following properties:
|
||||
|
||||
| Property | Type | Description | Required | Default |
|
||||
|----------|------|-------------|----------|---------|
|
||||
@@ -228,11 +249,13 @@ Common breakdown dimensions include:
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/export/charts?projectId=abc123&events=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \
|
||||
curl 'https://api.openpanel.dev/export/charts?projectId=abc123&series=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \
|
||||
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
You can use `events` instead of `series` in the query for backward compatibility; both accept the same structure.
|
||||
|
||||
#### Example Advanced Request
|
||||
|
||||
```bash
|
||||
@@ -241,7 +264,7 @@ curl 'https://api.openpanel.dev/export/charts' \
|
||||
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \
|
||||
-G \
|
||||
--data-urlencode 'projectId=abc123' \
|
||||
--data-urlencode 'events=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \
|
||||
--data-urlencode 'series=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \
|
||||
--data-urlencode 'breakdowns=[{"name":"country"}]' \
|
||||
--data-urlencode 'interval=day' \
|
||||
--data-urlencode 'range=30d'
|
||||
|
||||
@@ -59,7 +59,7 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d
|
||||
|
||||
## Insights
|
||||
|
||||
If you have configured [Insights](/features/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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
233
apps/public/content/guides/consent-management.mdx
Normal file
233
apps/public/content/guides/consent-management.mdx
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
title: "Consent management with OpenPanel"
|
||||
description: "Learn how to queue analytics events and session replays until the user gives consent, then flush everything at once with a single call."
|
||||
difficulty: beginner
|
||||
timeToComplete: 15
|
||||
date: 2026-03-02
|
||||
updated: 2026-03-02
|
||||
cover: /content/cover-default.jpg
|
||||
team: OpenPanel Team
|
||||
steps:
|
||||
- name: "Initialise with disabled: true"
|
||||
anchor: "disable"
|
||||
- name: "Build a consent banner"
|
||||
anchor: "banner"
|
||||
- name: "Call ready() on consent"
|
||||
anchor: "ready"
|
||||
- name: "Handle decline"
|
||||
anchor: "decline"
|
||||
- name: "Persist consent across page loads"
|
||||
anchor: "persist"
|
||||
---
|
||||
|
||||
# Consent management with OpenPanel
|
||||
|
||||
Privacy regulations like GDPR and CCPA require that you obtain explicit user consent before tracking behaviour or recording sessions. This guide shows how to use OpenPanel's built-in queue to hold all tracking until the user makes a choice, then flush everything at once—or discard it silently on decline.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenPanel installed via the `@openpanel/web` npm package or the script tag
|
||||
- Your Client ID from the [OpenPanel dashboard](https://dashboard.openpanel.dev/onboarding)
|
||||
|
||||
## Initialise with `disabled: true` [#disable]
|
||||
|
||||
Pass `disabled: true` when creating the OpenPanel instance. All tracking calls (`track`, `identify`, `screenView`, session replay chunks) are held in an in-memory queue instead of being sent to the API.
|
||||
|
||||
### Script tag
|
||||
|
||||
```html title="index.html"
|
||||
<script>
|
||||
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
||||
window.op('init', {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
disabled: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
```
|
||||
|
||||
### NPM package
|
||||
|
||||
```ts title="op.ts"
|
||||
import { OpenPanel } from '@openpanel/web';
|
||||
|
||||
export const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
disabled: true,
|
||||
});
|
||||
```
|
||||
|
||||
From this point on, any `op.track(...)` calls elsewhere in your app are safely queued and not transmitted.
|
||||
|
||||
## Build a consent banner [#banner]
|
||||
|
||||
How you build the UI is up to you. The key is to call `op.ready()` when the user accepts, and do nothing (or call `op.clear()`) when they decline.
|
||||
|
||||
```tsx title="ConsentBanner.tsx"
|
||||
import { op } from './op';
|
||||
|
||||
export function ConsentBanner() {
|
||||
function handleAccept() {
|
||||
localStorage.setItem('consent', 'granted');
|
||||
op.ready(); // flushes the queue and enables all future tracking
|
||||
hideBanner();
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
localStorage.setItem('consent', 'denied');
|
||||
hideBanner(); // queue is discarded on page unload
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-label="Cookie consent">
|
||||
<p>
|
||||
We use analytics to improve our product. Do you consent to anonymous
|
||||
usage tracking?
|
||||
</p>
|
||||
<button type="button" onClick={handleAccept}>Accept</button>
|
||||
<button type="button" onClick={handleDecline}>Decline</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Call `ready()` on consent [#ready]
|
||||
|
||||
`op.ready()` does two things:
|
||||
|
||||
1. Clears the `disabled` flag so all future events are sent immediately
|
||||
2. Flushes the entire queue — every event and session replay chunk buffered since page load is sent at once
|
||||
|
||||
This means you don't lose any events that happened before the user made their choice. The screen view for the page they landed on, any clicks they made while the banner was visible—all of it is captured and sent the moment they consent.
|
||||
|
||||
## Handle session replay [#replay]
|
||||
|
||||
If you have session replay enabled, the recorder starts capturing DOM changes as soon as the page loads (so no interactions are missed), but no data leaves the browser until `ready()` is called.
|
||||
|
||||
```ts title="op.ts"
|
||||
export const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
disabled: true,
|
||||
sessionReplay: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
On `op.ready()`, buffered replay chunks flush along with the queued events. The full session from the start of the page load is preserved.
|
||||
|
||||
## Handle decline [#decline]
|
||||
|
||||
If the user declines, don't call `ready()`. The queue lives only in memory and is automatically discarded when the tab closes or the page navigates away. No data is ever sent.
|
||||
|
||||
If you want to be explicit, you can clear the queue immediately:
|
||||
|
||||
```ts
|
||||
function handleDecline() {
|
||||
localStorage.setItem('consent', 'denied');
|
||||
// op stays disabled — nothing will be sent
|
||||
// The in-memory queue will be garbage collected
|
||||
}
|
||||
```
|
||||
|
||||
## Persist consent across page loads [#persist]
|
||||
|
||||
The `disabled` flag resets on every page load. You need to check the stored consent choice on initialisation and skip `disabled: true` if consent was already granted.
|
||||
|
||||
```ts title="op.ts"
|
||||
import { OpenPanel } from '@openpanel/web';
|
||||
|
||||
const hasConsent = localStorage.getItem('consent') === 'granted';
|
||||
|
||||
export const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
disabled: !hasConsent,
|
||||
});
|
||||
```
|
||||
|
||||
And in your banner component, only show it when no choice has been stored:
|
||||
|
||||
```tsx title="ConsentBanner.tsx"
|
||||
export function ConsentBanner() {
|
||||
const stored = localStorage.getItem('consent');
|
||||
if (stored) return null; // already decided, don't show
|
||||
|
||||
// ... render banner
|
||||
}
|
||||
```
|
||||
|
||||
## Full example
|
||||
|
||||
```ts title="op.ts"
|
||||
import { OpenPanel } from '@openpanel/web';
|
||||
|
||||
const hasConsent = localStorage.getItem('consent') === 'granted';
|
||||
|
||||
export const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
disabled: !hasConsent,
|
||||
sessionReplay: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx title="ConsentBanner.tsx"
|
||||
import { op } from './op';
|
||||
|
||||
export function ConsentBanner() {
|
||||
if (localStorage.getItem('consent')) return null;
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-label="Cookie consent">
|
||||
<p>We use analytics to improve our product. Do you consent?</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
localStorage.setItem('consent', 'granted');
|
||||
op.ready();
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
localStorage.setItem('consent', 'denied');
|
||||
}}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Consent management docs](/docs/consent-management) — quick reference for `disabled` and `waitForProfile`
|
||||
- [Session replay](/docs/session-replay) — privacy controls for what gets recorded
|
||||
- [Identify users](/docs/get-started/identify-users) — link events to a user profile
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="Are events lost if the user declines?">
|
||||
No events are sent if the user declines and you never call `ready()`. The queue lives in memory and is discarded when the page unloads.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What happens to events tracked before the banner appears?">
|
||||
They sit in the queue. If the user later accepts, they are all flushed. If the user declines, they are discarded.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Does session replay start before consent?">
|
||||
The recorder starts capturing DOM changes immediately so the full session can be reconstructed, but nothing is transmitted until `ready()` is called.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Do I need to handle this differently for the script tag vs npm?">
|
||||
No. The `disabled` option and `ready()` method work the same in both.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
132
apps/public/content/pages/dpa.mdx
Normal file
132
apps/public/content/pages/dpa.mdx
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
title: Data Processing Agreement
|
||||
description: OpenPanel's Data Processing Agreement (DPA) under Art. 28 GDPR for cloud customers who use OpenPanel to collect analytics on their websites and applications.
|
||||
---
|
||||
|
||||
_Last updated: March 3, 2026_
|
||||
|
||||
This Data Processing Agreement ("DPA") is incorporated into and forms part of the OpenPanel Terms of Service between OpenPanel AB ("OpenPanel", "we", "us") and the customer ("Controller", "you"). It applies where OpenPanel processes personal data on your behalf as part of the OpenPanel Cloud service.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
- **GDPR** means Regulation (EU) 2016/679 of the European Parliament and of the Council.
|
||||
- **Controller** means you, the customer, who determines the purposes and means of processing.
|
||||
- **Processor** means OpenPanel, who processes data on your behalf.
|
||||
- **Personal Data**, **Processing**, **Data Subject**, and **Supervisory Authority** have the meanings given in the GDPR.
|
||||
- **Sub-processor** means any third party engaged by OpenPanel to process Personal Data in connection with the service.
|
||||
|
||||
## 2. Our approach to privacy
|
||||
|
||||
OpenPanel is built to minimize personal data collection by design. We do not use cookies for analytics tracking. We do not store IP addresses. Instead, we generate a daily-rotating anonymous identifier using a one-way hash of the visitor's IP address, user agent, and project ID combined with a salt that is replaced every 24 hours. The raw IP address is discarded immediately and the identifier becomes irreversible once the salt is rotated.
|
||||
|
||||
The data we store per event is:
|
||||
|
||||
- Page URL and referrer
|
||||
- Browser name and version
|
||||
- Operating system name and version
|
||||
- Device type, brand, and model
|
||||
- City, country, and region (derived from IP at the time of the request; IP is then discarded)
|
||||
- Custom event properties you choose to send
|
||||
|
||||
No persistent identifiers, no cookies, no cross-site tracking.
|
||||
|
||||
Because of this approach, the analytics data OpenPanel collects in standard website tracking mode does not constitute personal data under GDPR Art. 4(1). However, we provide this DPA for customers who require it for their own compliance documentation and records of processing activities.
|
||||
|
||||
**Session replay (optional feature)**
|
||||
|
||||
OpenPanel optionally supports session replay, which must be explicitly enabled by the Controller. When enabled, session replay records DOM snapshots and user interactions (mouse movements, clicks, scrolls) on the Controller's website using rrweb. This data is stored against the session identifier and may incidentally capture personal data visible in the page (for example, a logged-in user's name displayed in the UI). All text content and form inputs are masked by default. The Controller is responsible for ensuring their use of session replay complies with applicable privacy law, including providing appropriate notice to end users. Additional masking options are available via the SDK configuration.
|
||||
|
||||
## 3. Scope and roles
|
||||
|
||||
OpenPanel acts as a **Processor** when processing data on behalf of the Controller. You act as the **Controller** for the analytics data collected from visitors to your websites and applications.
|
||||
|
||||
## 4. Processor obligations
|
||||
|
||||
OpenPanel commits to the following:
|
||||
|
||||
- Process Personal Data only on your documented instructions and for no other purpose.
|
||||
- Ensure that all personnel with access to Personal Data are bound by appropriate confidentiality obligations.
|
||||
- Implement and maintain technical and organizational measures in accordance with Section 7 of this DPA.
|
||||
- Not engage a Sub-processor without your prior general or specific written authorization and flow down equivalent data protection obligations to any Sub-processor.
|
||||
- Assist you, where reasonably possible, in responding to Data Subject requests to exercise their rights under GDPR.
|
||||
- Notify you without undue delay (and no later than 48 hours) upon becoming aware of a Personal Data breach.
|
||||
- Make available all information necessary to demonstrate compliance with this DPA and cooperate with audits conducted by you or your designated auditor, subject to reasonable notice and confidentiality obligations.
|
||||
- At your choice, delete or return all Personal Data upon termination of the service.
|
||||
|
||||
## 5. Your obligations as Controller
|
||||
|
||||
You confirm that:
|
||||
|
||||
- You have a lawful basis for the processing described in this DPA.
|
||||
- You have provided appropriate privacy notices to your end users.
|
||||
- You are responsible for the accuracy and lawfulness of the data you instruct OpenPanel to process.
|
||||
|
||||
## 6. Sub-processors
|
||||
|
||||
OpenPanel uses the following sub-processors to deliver the service. All sub-processors are located within the European Economic Area or provide adequate safeguards under GDPR Chapter V.
|
||||
|
||||
| Sub-processor | Purpose | Location |
|
||||
|---|---|---|
|
||||
| Hetzner Online GmbH | Cloud infrastructure and data storage | Germany (EU) |
|
||||
| Cloudflare R2 | Backup storage | EU |
|
||||
|
||||
We will inform you of any intended changes to this list (additions or replacements) with reasonable notice, giving you the opportunity to object.
|
||||
|
||||
## 7. Technical and organizational measures
|
||||
|
||||
OpenPanel implements the following measures under GDPR Art. 32:
|
||||
|
||||
**Data minimization and anonymization**
|
||||
- IP addresses are never stored. They are used only to derive geolocation and generate an anonymous daily identifier, then discarded.
|
||||
- Daily-rotating cryptographic salts ensure visitor identifiers cannot be reversed or linked to individuals after 24 hours.
|
||||
- No cookies or persistent cross-device identifiers are used.
|
||||
|
||||
**Access control**
|
||||
- Dashboard access is protected by authentication and role-based access control.
|
||||
- Production systems are accessible only to authorized personnel.
|
||||
|
||||
**Encryption and transport security**
|
||||
- All data is transmitted over HTTPS (TLS).
|
||||
|
||||
**Infrastructure and availability**
|
||||
- All data is hosted on Hetzner servers located in Germany within the EU.
|
||||
- Regular backups are performed.
|
||||
- No data leaves the EEA in the course of normal operations.
|
||||
|
||||
**Incident response**
|
||||
- We maintain procedures for detecting, reporting, and investigating Personal Data breaches.
|
||||
- In the event of a breach affecting your data, we will notify you within 48 hours of becoming aware.
|
||||
|
||||
**Open source**
|
||||
- The OpenPanel codebase is publicly available on GitHub, allowing independent review of our data handling practices.
|
||||
|
||||
## 8. International data transfers
|
||||
|
||||
OpenPanel stores and processes all analytics data on Hetzner infrastructure located in Germany. No Personal Data is transferred to countries outside the EEA in the course of delivering the service.
|
||||
|
||||
## 9. Data retention and deletion
|
||||
|
||||
**Analytics events** are retained for as long as your account is active. We do not currently enforce a maximum retention period on analytics event data. If we introduce a retention limit in the future, we will notify all customers in advance.
|
||||
|
||||
**Session replays** are retained for 30 days and then permanently deleted.
|
||||
|
||||
You can delete individual projects, all associated data, or your entire account at any time from within the dashboard. Upon account termination we will delete your data within 30 days unless we are required by law to retain it longer.
|
||||
|
||||
## 10. Governing law
|
||||
|
||||
This DPA is governed by the laws of Sweden and is interpreted in accordance with the GDPR.
|
||||
|
||||
## 11. How to execute this DPA
|
||||
|
||||
Using OpenPanel Cloud constitutes acceptance of this DPA as part of our Terms of Service.
|
||||
|
||||
If your organization requires a signed copy for your records of processing activities, you can download a pre-signed version below. Fill in your company details and countersign — no need to send it back to us.
|
||||
|
||||
[Download pre-signed DPA](/dpa/download)
|
||||
|
||||
## Contact
|
||||
|
||||
For questions about this DPA or data protection at OpenPanel:
|
||||
|
||||
- Email: [hello@openpanel.dev](mailto:hello@openpanel.dev)
|
||||
- Company: OpenPanel AB, Sankt Eriksgatan 100, 113 31 Stockholm, Sweden
|
||||
@@ -3,6 +3,8 @@ title: Privacy Policy
|
||||
description: Our privacy policy outlines how we handle your data, including usage information and cookies, to provide and improve our services.
|
||||
---
|
||||
|
||||
_Last updated: March 3, 2026_
|
||||
|
||||
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.
|
||||
|
||||
We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.
|
||||
@@ -23,7 +25,7 @@ For the purposes of this Privacy Policy:
|
||||
|
||||
- **Application** refers to Openpanel, the software program provided by the Company.
|
||||
|
||||
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Coderax AB, Sankt Eriksgatan 100, 113 31, Stockholm.
|
||||
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to OpenPanel AB, Sankt Eriksgatan 100, 113 31, Stockholm.
|
||||
|
||||
- **Cookies** are small files that are placed on Your computer, mobile device or any other device by a website, containing the details of Your browsing history on that website among its many uses.
|
||||
|
||||
@@ -43,65 +45,44 @@ For the purposes of this Privacy Policy:
|
||||
|
||||
- **You** means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
|
||||
|
||||
## What we do not collect
|
||||
|
||||
OpenPanel is built around the principle of collecting as little data as possible. When you use OpenPanel to track visitors on your websites and applications, the following is true:
|
||||
|
||||
- **No IP addresses are stored.** IP addresses are used transiently to derive city-level geolocation and to generate an anonymous visitor identifier. The raw IP address is discarded immediately after.
|
||||
- **No cookies are used for analytics tracking.** We do not set any tracking cookies on your visitors' browsers.
|
||||
- **No persistent cross-device identifiers.** Visitor identifiers are generated using a daily-rotating cryptographic hash of the IP address, user agent, and project ID. The hash cannot be reversed and becomes meaningless after 24 hours.
|
||||
- **No behavioral profiling.** We do not build profiles of individual users or track visitors across different websites.
|
||||
- **No data sold to third parties.** We will never sell, share, or transfer your data for advertising or any other commercial purpose.
|
||||
|
||||
## Collecting and Using Your Personal Data
|
||||
|
||||
### Types of Data Collected
|
||||
|
||||
#### Personal Data
|
||||
#### Analytics data (visitor data on your websites)
|
||||
|
||||
While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:
|
||||
When the OpenPanel tracking script is installed on a website, the following aggregated, anonymized data is collected per event:
|
||||
|
||||
- Email address
|
||||
- First name and last name
|
||||
- Usage Data
|
||||
- Page URL and referrer
|
||||
- Browser name and version
|
||||
- Operating system name and version
|
||||
- Device type, brand, and model
|
||||
- City, country, and region (derived from IP at request time; IP then discarded)
|
||||
- Custom event properties the website owner chooses to send
|
||||
|
||||
#### Usage Data
|
||||
No IP addresses, no cookies, no names, no email addresses, and no persistent identifiers are stored.
|
||||
|
||||
Usage Data is collected automatically when using the Service.
|
||||
#### Account data (OpenPanel dashboard users)
|
||||
|
||||
Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
|
||||
When you create an OpenPanel account, we collect:
|
||||
|
||||
When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.
|
||||
- Email address (required for login and transactional notifications)
|
||||
- Name (optional, used for display purposes)
|
||||
- Billing information (processed by our payment provider; we do not store full card details)
|
||||
|
||||
We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.
|
||||
#### Dashboard session
|
||||
|
||||
#### Tracking Technologies and Cookies
|
||||
|
||||
We use Cookies and similar tracking technologies to track the activity on Our Service and store certain information. Tracking technologies used are beacons, tags, and scripts to collect and track information and to improve and analyze Our Service. The technologies We use may include:
|
||||
|
||||
- **Cookies or Browser Cookies.** A cookie is a small file placed on Your Device. You can instruct Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do not accept Cookies, You may not be able to use some parts of our Service. Unless you have adjusted Your browser setting so that it will refuse Cookies, our Service may use Cookies.
|
||||
|
||||
- **Web Beacons.** Certain sections of our Service and our emails may contain small electronic files known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that permit the Company, for example, to count users who have visited those pages or opened an email and for other related website statistics (for example, recording the popularity of a certain section and verifying system and server integrity).
|
||||
|
||||
Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies remain on Your personal computer or mobile device when You go offline, while Session Cookies are deleted as soon as You close Your web browser. You can learn more about cookies [here](https://www.termsfeed.com/blog/cookies/#What_Are_Cookies).
|
||||
|
||||
We use both Session and Persistent Cookies for the purposes set out below:
|
||||
|
||||
- **Necessary / Essential Cookies**
|
||||
|
||||
Type: Session Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies are essential to provide You with services available through the Website and to enable You to use some of its features. They help to authenticate users and prevent fraudulent use of user accounts. Without these Cookies, the services that You have asked for cannot be provided, and We only use these Cookies to provide You with those services.
|
||||
|
||||
- **Cookies Policy / Notice Acceptance Cookies**
|
||||
|
||||
Type: Persistent Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies identify if users have accepted the use of cookies on the Website.
|
||||
|
||||
- **Functionality Cookies**
|
||||
|
||||
Type: Persistent Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies allow us to remember choices You make when You use the Website, such as remembering your login details or language preference. The purpose of these Cookies is to provide You with a more personal experience and to avoid You having to re-enter your preferences every time You use the Website.
|
||||
|
||||
For more information about the cookies we use and your choices regarding cookies, please visit our Cookies Policy or the Cookies section of our Privacy Policy.
|
||||
We use a single server-side session cookie to keep you logged in to the OpenPanel dashboard. This cookie is strictly necessary for authentication and is not used for tracking or analytics purposes. It is deleted when you log out or your session expires.
|
||||
|
||||
### Use of Your Personal Data
|
||||
|
||||
@@ -143,13 +124,11 @@ The Company will retain Your Personal Data only for as long as is necessary for
|
||||
|
||||
The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.
|
||||
|
||||
### Transfer of Your Personal Data
|
||||
### Data storage and transfers
|
||||
|
||||
Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.
|
||||
All analytics data is stored on Hetzner infrastructure located in Germany. All backups are stored on Cloudflare R2 within the EU. No analytics data is transferred outside the European Economic Area.
|
||||
|
||||
Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.
|
||||
|
||||
The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.
|
||||
Account data (email, name, billing) is processed within the EU. Our payment processor and transactional email provider operate under EU data protection standards.
|
||||
|
||||
### Delete Your Personal Data
|
||||
|
||||
@@ -197,6 +176,10 @@ Our Service may contain links to other websites that are not operated by Us. If
|
||||
|
||||
We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.
|
||||
|
||||
## Data Processing Agreement
|
||||
|
||||
If you use OpenPanel Cloud to collect analytics on behalf of your own users, OpenPanel acts as a data processor and you act as the data controller. Our Data Processing Agreement (DPA) governs this relationship and forms part of our Terms of Service. You can read and download a signed copy at [openpanel.dev/dpa](/dpa).
|
||||
|
||||
## Changes to this Privacy Policy
|
||||
|
||||
We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Terms of Service
|
||||
description: Legal terms and conditions governing the use of Openpanel's services and website.
|
||||
---
|
||||
|
||||
_Last updated: March 3, 2026_
|
||||
|
||||
Please read these terms and conditions carefully before using Our Service.
|
||||
|
||||
## Interpretation and Definitions
|
||||
@@ -25,7 +27,7 @@ For the purposes of these Terms and Conditions:
|
||||
|
||||
- **Country** refers to: Sweden
|
||||
|
||||
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Coderax AB, Sankt Eriksgatan 100, 113 31, Stockholm.
|
||||
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to OpenPanel AB, Sankt Eriksgatan 100, 113 31, Stockholm.
|
||||
|
||||
- **Device** means any device that can access the Service such as a computer, a cellphone or a digital tablet.
|
||||
|
||||
@@ -55,6 +57,16 @@ You represent that you are over the age of 18. The Company does not permit those
|
||||
|
||||
Your access to and use of the Service is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Company. Our Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your personal information when You use the Application or the Website and tells You about Your privacy rights and how the law protects You. Please read Our Privacy Policy carefully before using Our Service.
|
||||
|
||||
## Data Processing Agreement
|
||||
|
||||
Our Data Processing Agreement (DPA) under the European General Data Protection Regulation (GDPR) forms part of these Terms of Service. By using OpenPanel Cloud, you agree to the terms of the DPA. A copy is available at [openpanel.dev/dpa](/dpa).
|
||||
|
||||
## Your Data
|
||||
|
||||
You retain full ownership of all data you submit to the Service, including analytics data collected from your websites and applications. The Company claims no intellectual property rights over your data.
|
||||
|
||||
We will never sell, share, or transfer your data to third parties for advertising, marketing, or any commercial purpose. Your data is used solely to provide and improve the Service for you.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
### Subscription period
|
||||
@@ -157,14 +169,6 @@ If You have any concern or dispute about the Service, You agree to first try to
|
||||
|
||||
If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in which You are resident.
|
||||
|
||||
## United States Federal Government End Use Provisions
|
||||
|
||||
If You are a U.S. federal government end user, our Service is a "Commercial Item" as that term is defined at 48 C.F.R. §2.101.
|
||||
|
||||
## United States Legal Compliance
|
||||
|
||||
You represent and warrant that (i) You are not located in a country that is subject to the United States government embargo, or that has been designated by the United States government as a "terrorist supporting" country, and (ii) You are not listed on any United States government list of prohibited or restricted parties.
|
||||
|
||||
## Severability and Waiver
|
||||
|
||||
### Severability
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"dependencies": {
|
||||
"@nivo/funnel": "^0.99.0",
|
||||
"@number-flow/react": "0.5.10",
|
||||
"@opennextjs/cloudflare": "^1.16.5",
|
||||
"@opennextjs/cloudflare": "^1.17.1",
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/geo": "workspace:*",
|
||||
"@openpanel/nextjs": "^1.2.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
apps/public/public/signature.png
Normal file
BIN
apps/public/public/signature.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -1,56 +1,38 @@
|
||||
import {
|
||||
BarChart3Icon,
|
||||
ChevronRightIcon,
|
||||
DollarSignIcon,
|
||||
GlobeIcon,
|
||||
PlayCircleIcon,
|
||||
} from 'lucide-react';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { NotificationsIllustration } from '@/components/illustrations/notifications';
|
||||
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 { 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:
|
||||
'Track revenue from your payments and get insights into your revenue sources.',
|
||||
icon: DollarSignIcon,
|
||||
link: {
|
||||
href: '/features/revenue-tracking',
|
||||
children: 'More about revenue',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Profiles & Sessions',
|
||||
description:
|
||||
'Track individual users and their complete journey across your platform.',
|
||||
icon: GlobeIcon,
|
||||
link: {
|
||||
href: '/features/identify-users',
|
||||
children: 'Identify your users',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Event Tracking',
|
||||
description:
|
||||
'Capture every important interaction with flexible event tracking.',
|
||||
icon: BarChart3Icon,
|
||||
link: {
|
||||
href: '/features/event-tracking',
|
||||
children: 'All about tracking',
|
||||
},
|
||||
'Know how many users come back after day 1, day 7, day 30. Identify which behaviors predict long-term retention.',
|
||||
illustration: wrap(<RetentionIllustration />),
|
||||
link: { href: '/features/retention', children: 'View retention' },
|
||||
},
|
||||
{
|
||||
title: 'Session Replay',
|
||||
description:
|
||||
'Watch real user sessions to see exactly what happened. Privacy controls built in, loads async.',
|
||||
icon: PlayCircleIcon,
|
||||
link: {
|
||||
href: '/features/session-replay',
|
||||
children: 'See session replay',
|
||||
},
|
||||
'Watch real user sessions to see exactly what happened — clicks, scrolls, rage clicks. Privacy controls built in.',
|
||||
illustration: wrap(<SessionReplayIllustration />),
|
||||
link: { href: '/features/session-replay', children: 'See session replay' },
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description:
|
||||
'Get notified when a funnel is completed. Stay on top of key moments in your product without watching dashboards all day.',
|
||||
illustration: wrap(<NotificationsIllustration />),
|
||||
link: { href: '/features/notifications', children: 'Set up notifications' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -59,37 +41,39 @@ export function AnalyticsInsights() {
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
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"
|
||||
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">
|
||||
<FeatureCard
|
||||
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 />}
|
||||
title="Web Analytics"
|
||||
variant="large"
|
||||
/>
|
||||
<FeatureCard
|
||||
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 />}
|
||||
title="Product Analytics"
|
||||
variant="large"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((feature) => (
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{mediumFeatures.map((feature) => (
|
||||
<FeatureCard
|
||||
className="px-0 pt-0 **:data-content:px-6"
|
||||
description={feature.description}
|
||||
icon={feature.icon}
|
||||
illustration={feature.illustration}
|
||||
key={feature.title}
|
||||
link={feature.link}
|
||||
title={feature.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center">
|
||||
<Link
|
||||
className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
||||
|
||||
@@ -15,23 +15,23 @@ import { CollaborationChart } from './collaboration-chart';
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Visualize your data',
|
||||
title: 'Flexible data visualization',
|
||||
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,
|
||||
slug: 'data-visualization',
|
||||
},
|
||||
{
|
||||
title: 'Share & Collaborate',
|
||||
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,
|
||||
slug: 'share-and-collaborate',
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
title: 'Integrations & Webhooks',
|
||||
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,
|
||||
slug: 'integrations',
|
||||
},
|
||||
|
||||
@@ -43,9 +43,9 @@ export function DataPrivacy() {
|
||||
/>
|
||||
<div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<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 />}
|
||||
title="Privacy-first"
|
||||
title="GDPR compliant"
|
||||
variant="large"
|
||||
/>
|
||||
<FeatureCard
|
||||
|
||||
68
apps/public/src/app/(home)/_sections/feature-spotlight.tsx
Normal file
68
apps/public/src/app/(home)/_sections/feature-spotlight.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,14 @@ import {
|
||||
CalendarIcon,
|
||||
CookieIcon,
|
||||
CreditCardIcon,
|
||||
DatabaseIcon,
|
||||
GithubIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { Competition } from '@/components/competition';
|
||||
import { EuFlag } from '@/components/eu-flag';
|
||||
import { GetStartedButton } from '@/components/get-started-button';
|
||||
import { Perks } from '@/components/perks';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,10 +21,10 @@ import { cn } from '@/lib/utils';
|
||||
const perks = [
|
||||
{ text: 'Free trial 30 days', icon: CalendarIcon },
|
||||
{ text: 'No credit card required', icon: CreditCardIcon },
|
||||
{ text: 'GDPR compliant', icon: ShieldCheckIcon },
|
||||
{ text: 'EU hosted', icon: EuFlag },
|
||||
{ text: 'Cookie-less tracking', icon: CookieIcon },
|
||||
{ text: 'Open-source', icon: GithubIcon },
|
||||
{ text: 'Your data, your rules', icon: DatabaseIcon },
|
||||
{ text: 'Self-hostable', icon: ServerIcon },
|
||||
];
|
||||
|
||||
const aspectRatio = 2946 / 1329;
|
||||
@@ -90,7 +90,7 @@ export function Hero() {
|
||||
TRUSTED BY 1,000+ PROJECTS
|
||||
</div>
|
||||
<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>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
An open-source web and product analytics platform that combines the
|
||||
|
||||
@@ -55,6 +55,9 @@ export function Pricing() {
|
||||
<div className="col mt-8 w-full items-baseline md:mt-auto">
|
||||
{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">
|
||||
<NumberFlow
|
||||
className="font-bold text-5xl"
|
||||
@@ -67,9 +70,6 @@ export function Pricing() {
|
||||
locales={'en-US'}
|
||||
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 className="row w-full justify-between">
|
||||
<span className="-mt-2 text-muted-foreground/80 text-sm">
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
'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 { Section, SectionHeader, SectionLabel } from '@/components/section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { QuoteIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
|
||||
const images = [
|
||||
{
|
||||
@@ -65,55 +63,63 @@ const quotes: {
|
||||
];
|
||||
|
||||
export function WhyOpenPanel() {
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
return (
|
||||
<Section className="container gap-16">
|
||||
<SectionHeader label="Trusted by founders" title="Who uses OpenPanel?" />
|
||||
<div className="col overflow-hidden">
|
||||
<SectionLabel className="text-muted-foreground bg-background -mb-2 z-5 self-start pr-4">
|
||||
<SectionLabel className="z-5 -mb-2 self-start bg-background pr-4 text-muted-foreground">
|
||||
USED BY
|
||||
</SectionLabel>
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 -mx-4 border-y py-4">
|
||||
<div className="-mx-4 grid grid-cols-3 border-y py-4 md:grid-cols-6">
|
||||
{images.map((image) => (
|
||||
<div key={image.logo} className="px-4 border-r last:border-r-0 ">
|
||||
<div className="border-r px-4 last:border-r-0" key={image.logo}>
|
||||
<a
|
||||
className={cn('group center-center relative aspect-square')}
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
key={image.logo}
|
||||
className={cn('relative group center-center aspect-square')}
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
title={image.name}
|
||||
>
|
||||
<FeatureCardBackground />
|
||||
<Image
|
||||
src={image.logo}
|
||||
alt={image.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className={cn('size-16 object-contain dark:invert')}
|
||||
height={64}
|
||||
src={image.logo}
|
||||
width={64}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 -mx-4 border-y py-4">
|
||||
{quotes.slice(0, showMore ? quotes.length : 2).map((quote) => (
|
||||
<div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2">
|
||||
{quotes.slice(0, 2).map((quote) => (
|
||||
<figure
|
||||
className="group px-4 py-4 md:odd:border-r"
|
||||
key={quote.author}
|
||||
className="px-4 py-4 md:odd:border-r group"
|
||||
>
|
||||
<QuoteIcon className="size-10 text-muted-foreground/50 stroke-1 mb-2 group-hover:text-foreground group-hover:rotate-6 transition-all" />
|
||||
<blockquote className="text-xl prose">
|
||||
<div className="row items-center justify-between">
|
||||
<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>
|
||||
</blockquote>
|
||||
<figcaption className="row justify-between text-muted-foreground text-sm mt-4">
|
||||
<figcaption className="row mt-4 justify-between text-muted-foreground text-sm">
|
||||
<span>{quote.author}</span>
|
||||
{quote.site && (
|
||||
<cite className="not-italic">
|
||||
<a
|
||||
href={quote.site}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{quote.site.replace('https://', '')}
|
||||
</a>
|
||||
@@ -123,14 +129,6 @@ export function WhyOpenPanel() {
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowMore((p) => !p)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="self-end mt-4"
|
||||
>
|
||||
{showMore ? 'Show less' : 'View more reviews'}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AnalyticsInsights } from './_sections/analytics-insights';
|
||||
import { Collaboration } from './_sections/collaboration';
|
||||
import { FeatureSpotlight } from './_sections/feature-spotlight';
|
||||
import { CtaBanner } from './_sections/cta-banner';
|
||||
import { DataPrivacy } from './_sections/data-privacy';
|
||||
import { Faq } from './_sections/faq';
|
||||
@@ -57,6 +58,7 @@ export default function HomePage() {
|
||||
<Hero />
|
||||
<WhyOpenPanel />
|
||||
<AnalyticsInsights />
|
||||
<FeatureSpotlight />
|
||||
<Collaboration />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
|
||||
498
apps/public/src/app/dpa/download/page.tsx
Normal file
498
apps/public/src/app/dpa/download/page.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function DpaDownloadPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-black">
|
||||
{/* Print button - hidden when printing */}
|
||||
<div className="sticky top-0 z-10 flex justify-end gap-3 border-gray-200 border-b bg-white px-8 py-3 print:hidden">
|
||||
<button
|
||||
className="rounded bg-black px-4 py-2 font-medium text-sm text-white hover:bg-gray-800"
|
||||
onClick={() => window.print()}
|
||||
type="button"
|
||||
>
|
||||
Download / Print PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-3xl px-8 py-12 print:py-0">
|
||||
{/* Header */}
|
||||
<div className="mb-10 border-gray-300 border-b pb-8">
|
||||
<p className="mb-1 text-gray-500 text-xs uppercase tracking-widest">
|
||||
OpenPanel AB
|
||||
</p>
|
||||
<h1 className="mb-2 font-bold text-3xl">Data Processing Agreement</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Version 1.0 · Last updated: March 3, 2026
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mb-8 text-gray-700 text-sm leading-relaxed">
|
||||
This Data Processing Agreement ("DPA") is entered into between
|
||||
OpenPanel AB ("OpenPanel", "Processor") and the customer identified in
|
||||
the signature block below ("Controller"). It applies where OpenPanel
|
||||
processes personal data on behalf of the Controller as part of the
|
||||
OpenPanel Cloud service, and forms part of the OpenPanel Terms of
|
||||
Service.
|
||||
</p>
|
||||
|
||||
<Section number="1" title="Definitions">
|
||||
<ul className="list-none space-y-2 text-gray-700 text-sm">
|
||||
<li>
|
||||
<strong>GDPR</strong> means Regulation (EU) 2016/679 of the
|
||||
European Parliament and of the Council.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Controller</strong> means the customer, who determines the
|
||||
purposes and means of processing.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Processor</strong> means OpenPanel, who processes data on
|
||||
the Controller's behalf.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Personal Data</strong>, <strong>Processing</strong>,{' '}
|
||||
<strong>Data Subject</strong>, and{' '}
|
||||
<strong>Supervisory Authority</strong> have the meanings given in
|
||||
the GDPR.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Sub-processor</strong> means any third party engaged by
|
||||
OpenPanel to process Personal Data in connection with the service.
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section number="2" title="Our approach to privacy">
|
||||
<p className="mb-3 text-gray-700 text-sm leading-relaxed">
|
||||
OpenPanel is built to minimize personal data collection by design.
|
||||
We do not use cookies for analytics tracking. We do not store IP
|
||||
addresses. Instead, we generate a daily-rotating anonymous
|
||||
identifier using a one-way hash of the visitor's IP address, user
|
||||
agent, and project ID combined with a salt that is replaced every 24
|
||||
hours. The raw IP address is discarded immediately and the
|
||||
identifier becomes irreversible once the salt is rotated.
|
||||
</p>
|
||||
<p className="mb-2 text-gray-700 text-sm">
|
||||
The data we store per event is:
|
||||
</p>
|
||||
<ul className="mb-3 list-disc space-y-1 pl-5 text-gray-700 text-sm">
|
||||
<li>Page URL and referrer</li>
|
||||
<li>Browser name and version</li>
|
||||
<li>Operating system name and version</li>
|
||||
<li>Device type, brand, and model</li>
|
||||
<li>
|
||||
City, country, and region (derived from IP at the time of the
|
||||
request; IP is then discarded)
|
||||
</li>
|
||||
<li>Custom event properties the Controller chooses to send</li>
|
||||
</ul>
|
||||
<p className="mb-3 text-gray-700 text-sm">
|
||||
No persistent identifiers, no cookies, no cross-site tracking.
|
||||
Because of this approach, the analytics data OpenPanel collects in
|
||||
standard website tracking mode does not constitute personal data
|
||||
under GDPR Art. 4(1). We provide this DPA for Controllers who
|
||||
require it for their own compliance documentation and records of
|
||||
processing activities.
|
||||
</p>
|
||||
<p className="mb-1 text-gray-700 text-sm font-semibold">
|
||||
Session replay (optional feature)
|
||||
</p>
|
||||
<p className="text-gray-700 text-sm">
|
||||
OpenPanel optionally supports session replay, which must be
|
||||
explicitly enabled by the Controller. When enabled, session replay
|
||||
records DOM snapshots and user interactions (mouse movements, clicks,
|
||||
scrolls) using rrweb. All text content and form inputs are masked by
|
||||
default. The Controller is responsible for ensuring their use of
|
||||
session replay complies with applicable privacy law, including
|
||||
providing appropriate notice to end users.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section number="3" title="Scope and roles">
|
||||
<p className="text-gray-700 text-sm leading-relaxed">
|
||||
OpenPanel acts as a Processor when processing data on behalf of the
|
||||
Controller. The Controller is responsible for the analytics data
|
||||
collected from visitors to their websites and applications.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section number="4" title="Processor obligations">
|
||||
<p className="mb-2 text-gray-700 text-sm">
|
||||
OpenPanel commits to the following:
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-gray-700 text-sm">
|
||||
<li>
|
||||
Process Personal Data only on the Controller's documented
|
||||
instructions and for no other purpose.
|
||||
</li>
|
||||
<li>
|
||||
Ensure that all personnel with access to Personal Data are bound
|
||||
by appropriate confidentiality obligations.
|
||||
</li>
|
||||
<li>
|
||||
Implement and maintain technical and organizational measures in
|
||||
accordance with Section 7 of this DPA.
|
||||
</li>
|
||||
<li>
|
||||
Not engage a Sub-processor without prior general or specific
|
||||
written authorization and flow down equivalent data protection
|
||||
obligations to any Sub-processor.
|
||||
</li>
|
||||
<li>
|
||||
Assist the Controller, where reasonably possible, in responding to
|
||||
Data Subject requests to exercise their rights under GDPR.
|
||||
</li>
|
||||
<li>
|
||||
Notify the Controller without undue delay (and no later than 48
|
||||
hours) upon becoming aware of a Personal Data breach.
|
||||
</li>
|
||||
<li>
|
||||
Make available all information necessary to demonstrate compliance
|
||||
with this DPA and cooperate with audits conducted by the
|
||||
Controller or their designated auditor, subject to reasonable
|
||||
notice and confidentiality obligations.
|
||||
</li>
|
||||
<li>
|
||||
At the Controller's choice, delete or return all Personal Data
|
||||
upon termination of the service.
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section number="5" title="Controller obligations">
|
||||
<p className="mb-2 text-gray-700 text-sm">
|
||||
The Controller confirms that:
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-gray-700 text-sm">
|
||||
<li>
|
||||
They have a lawful basis for the processing described in this DPA.
|
||||
</li>
|
||||
<li>
|
||||
They have provided appropriate privacy notices to their end users.
|
||||
</li>
|
||||
<li>
|
||||
They are responsible for the accuracy and lawfulness of the data
|
||||
they instruct OpenPanel to process.
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section number="6" title="Sub-processors">
|
||||
<p className="mb-3 text-gray-700 text-sm">
|
||||
OpenPanel uses the following sub-processors to deliver the service:
|
||||
</p>
|
||||
<table className="mb-3 w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border border-gray-300 bg-gray-50">
|
||||
<th className="border border-gray-300 px-3 py-2 text-left font-semibold">
|
||||
Sub-processor
|
||||
</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-left font-semibold">
|
||||
Purpose
|
||||
</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-left font-semibold">
|
||||
Location
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-300 px-3 py-2">
|
||||
Hetzner Online GmbH
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-2">
|
||||
Cloud infrastructure and data storage
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-2">
|
||||
Germany (EU)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 px-3 py-2">
|
||||
Cloudflare R2
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-2">
|
||||
Backup storage
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-2">EU</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-gray-700 text-sm">
|
||||
OpenPanel will inform the Controller of any intended changes to this
|
||||
list with reasonable notice, giving the Controller the opportunity
|
||||
to object.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section number="7" title="Technical and organizational measures">
|
||||
<div className="space-y-4 text-gray-700 text-sm">
|
||||
<div>
|
||||
<p className="mb-1 font-semibold">
|
||||
Data minimization and anonymization
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
IP addresses are never stored. They are used only to derive
|
||||
geolocation and generate an anonymous daily identifier, then
|
||||
discarded.
|
||||
</li>
|
||||
<li>
|
||||
Daily-rotating cryptographic salts ensure visitor identifiers
|
||||
cannot be reversed or linked to individuals after 24 hours.
|
||||
</li>
|
||||
<li>
|
||||
No cookies or persistent cross-device identifiers are used.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-semibold">Access control</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Dashboard access is protected by authentication and role-based
|
||||
access control.
|
||||
</li>
|
||||
<li>
|
||||
Production systems are accessible only to authorized
|
||||
personnel.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-semibold">
|
||||
Encryption and transport security
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>All data is transmitted over HTTPS (TLS).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-semibold">
|
||||
Infrastructure and availability
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
All data is hosted on Hetzner servers located in Germany
|
||||
within the EU.
|
||||
</li>
|
||||
<li>Regular backups are performed.</li>
|
||||
<li>
|
||||
No data leaves the EEA in the course of normal operations.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-semibold">Incident response</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
We maintain procedures for detecting, reporting, and
|
||||
investigating Personal Data breaches.
|
||||
</li>
|
||||
<li>
|
||||
In the event of a breach affecting the Controller's data, we
|
||||
will notify them within 48 hours of becoming aware.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-semibold">Open source</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
The OpenPanel codebase is publicly available on GitHub,
|
||||
allowing independent review of our data handling practices.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section number="8" title="International data transfers">
|
||||
<p className="text-gray-700 text-sm leading-relaxed">
|
||||
OpenPanel stores and processes all analytics data on Hetzner
|
||||
infrastructure located in Germany. No Personal Data is transferred
|
||||
to countries outside the EEA in the course of delivering the
|
||||
service.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section number="9" title="Data retention and deletion">
|
||||
<ul className="list-disc space-y-1 pl-5 text-gray-700 text-sm">
|
||||
<li>
|
||||
<strong>Analytics events</strong> are retained for as long as the
|
||||
Controller's account is active. No maximum retention period is
|
||||
currently enforced. If a retention limit is introduced in the
|
||||
future, all customers will be notified in advance.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Session replays</strong> are retained for 30 days and then
|
||||
permanently deleted.
|
||||
</li>
|
||||
<li>
|
||||
The Controller can delete individual projects, all associated data,
|
||||
or their entire account at any time from within the dashboard. Upon
|
||||
account termination, OpenPanel will delete the Controller's data
|
||||
within 30 days unless required by law to retain it longer.
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section number="10" title="Governing law">
|
||||
<p className="text-gray-700 text-sm leading-relaxed">
|
||||
This DPA is governed by the laws of Sweden and is interpreted in
|
||||
accordance with the GDPR.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Exhibit A */}
|
||||
<div className="mb-8 border-black border-t-2 pt-8">
|
||||
<p className="mb-1 text-gray-500 text-xs uppercase tracking-widest">
|
||||
Annex
|
||||
</p>
|
||||
<h2 className="mb-4 font-bold text-xl">
|
||||
Exhibit A: Description of Processing
|
||||
</h2>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<tbody>
|
||||
<Row
|
||||
label="Nature of processing"
|
||||
value="Collection and storage of anonymized website analytics events (page views, custom events, session data). Optionally: session replay recording of DOM snapshots and user interactions."
|
||||
/>
|
||||
<Row
|
||||
label="Purpose of processing"
|
||||
value="To provide the Controller with website and product analytics via the OpenPanel Cloud dashboard. Session replay (if enabled) is used to allow the Controller to review user sessions for UX and debugging purposes."
|
||||
/>
|
||||
<Row
|
||||
label="Duration of processing"
|
||||
value="Analytics events: retained for the duration of the active account (no current maximum). Session replays: 30 days, then permanently deleted. All data deleted within 30 days of account termination."
|
||||
/>
|
||||
<Row
|
||||
label="Categories of data subjects"
|
||||
value="Visitors to the Controller's websites and applications"
|
||||
/>
|
||||
<Row
|
||||
label="Categories of personal data"
|
||||
value="Anonymized session identifiers (non-reversible after 24 hours), page URLs, referrers, browser type and version, operating system, device type, city-level geolocation (country, region, city). No IP addresses, no cookies, no names, no email addresses. If session replay is enabled: DOM snapshots and interaction recordings, which may incidentally contain personal data visible on the Controller's pages. All text content and form inputs are masked by default."
|
||||
/>
|
||||
<Row
|
||||
label="Special categories of data"
|
||||
value="None intended. The Controller is responsible for ensuring no special category data is captured via session replay."
|
||||
/>
|
||||
<Row
|
||||
label="Sub-processors"
|
||||
value="Hetzner Online GmbH (Germany) — cloud infrastructure; Cloudflare R2 (EU) — backup storage"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Signatures */}
|
||||
<div className="border-black border-t-2 pt-8">
|
||||
<p className="mb-1 text-gray-500 text-xs uppercase tracking-widest">
|
||||
Execution
|
||||
</p>
|
||||
<h2 className="mb-6 font-bold text-xl">Signatures</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-12">
|
||||
{/* Processor - pre-signed */}
|
||||
<div>
|
||||
<div className="col h-32 gap-2">
|
||||
<p className="font-semibold text-gray-500 text-xs uppercase tracking-widest">
|
||||
Processor
|
||||
</p>
|
||||
<p className="font-semibold text-sm">OpenPanel AB</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
Sankt Eriksgatan 100, 113 31 Stockholm, Sweden
|
||||
</p>
|
||||
</div>
|
||||
<SignatureLine
|
||||
label="Signature"
|
||||
value={
|
||||
<Image
|
||||
alt="Carl-Gerhard Lindesvärd signature"
|
||||
className="relative top-4 h-16 w-auto object-contain object-left"
|
||||
height={64}
|
||||
src="/signature.png"
|
||||
width={200}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SignatureLine label="Name" value="Carl-Gerhard Lindesvärd" />
|
||||
<SignatureLine label="Title" value="Founder" />
|
||||
<SignatureLine label="Date" value="March 3, 2026" />
|
||||
</div>
|
||||
|
||||
{/* Controller - blank */}
|
||||
<div>
|
||||
<div className="flex flex-col h-32 gap-2">
|
||||
<p className="font-semibold text-gray-500 text-xs uppercase tracking-widest">
|
||||
Controller
|
||||
</p>
|
||||
</div>
|
||||
<SignatureLine label="Company" value="" />
|
||||
<SignatureLine label="Signature" value="" />
|
||||
<SignatureLine label="Name" value="" />
|
||||
<SignatureLine label="Title" value="" />
|
||||
<SignatureLine label="Date" value="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 border-gray-200 border-t pt-6 text-center text-gray-400 text-xs print:mt-4">
|
||||
OpenPanel AB · hello@openpanel.dev · openpanel.dev/dpa
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
number,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
number: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-3 font-bold text-base">
|
||||
{number}. {title}
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<tr className="border border-gray-300">
|
||||
<td className="w-48 border border-gray-300 bg-gray-50 px-3 py-2 align-top font-semibold text-xs">
|
||||
{label}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-xs leading-relaxed">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureLine({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<p className="text-gray-500 text-xs">{label}</p>
|
||||
<div className="mt-1 flex h-7 items-end border-gray-400 border-b font-mono">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/public/src/components/eu-flag.tsx
Normal file
37
apps/public/src/components/eu-flag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,7 @@ export async function Footer() {
|
||||
<Link href="/sitemap.xml">Sitemap</Link>
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
<Link href="/terms">Terms of Service</Link>
|
||||
<Link href="/dpa">DPA</Link>
|
||||
<Link href="/cookies">Cookie Policy (just kidding)</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
71
apps/public/src/components/illustrations/conversions.tsx
Normal file
71
apps/public/src/components/illustrations/conversions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
47
apps/public/src/components/illustrations/notifications.tsx
Normal file
47
apps/public/src/components/illustrations/notifications.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
apps/public/src/components/illustrations/retention.tsx
Normal file
63
apps/public/src/components/illustrations/retention.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/public/src/components/illustrations/revenue.tsx
Normal file
72
apps/public/src/components/illustrations/revenue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/public/src/components/illustrations/session-replay.tsx
Normal file
89
apps/public/src/components/illustrations/session-replay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +1,165 @@
|
||||
'use client';
|
||||
import { SimpleChart } from '@/components/simple-chart';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import NumberFlow from '@number-flow/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowUpIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
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',
|
||||
name: 'Google',
|
||||
percentage: 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,
|
||||
name: 'google.com',
|
||||
pct: 49,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
|
||||
name: 'Twitter',
|
||||
percentage: 10,
|
||||
value: 412,
|
||||
name: 'twitter.com',
|
||||
pct: 21,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgithub.com',
|
||||
name: 'github.com',
|
||||
pct: 14,
|
||||
},
|
||||
];
|
||||
|
||||
const COUNTRIES = [
|
||||
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 },
|
||||
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 },
|
||||
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 },
|
||||
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 },
|
||||
];
|
||||
function AreaChart({ data }: { data: number[] }) {
|
||||
const max = Math.max(...data);
|
||||
const w = 400;
|
||||
const h = 64;
|
||||
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() {
|
||||
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
|
||||
const [liveVisitors, setLiveVisitors] = useState(47);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
const values = [47, 51, 44, 53, 49, 56];
|
||||
let i = 0;
|
||||
const id = setInterval(() => {
|
||||
i = (i + 1) % values.length;
|
||||
setLiveVisitors(values[i]);
|
||||
}, 2500);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="px-12 group aspect-video">
|
||||
<div className="relative h-full col">
|
||||
<MetricCard
|
||||
title="Session duration"
|
||||
value="3m 23s"
|
||||
change="3%"
|
||||
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
|
||||
color="var(--foreground)"
|
||||
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300"
|
||||
/>
|
||||
<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} />%
|
||||
<div className="aspect-video col gap-2.5 p-5">
|
||||
{/* Header */}
|
||||
<div className="row items-center justify-between">
|
||||
<div className="row items-center gap-1.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
<NumberFlow value={liveVisitors} /> online now
|
||||
</span>
|
||||
<NumberFlow value={value} locales={'en-US'} />
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
type PerkIcon = LucideIcon | React.ComponentType<{ className?: string }>;
|
||||
|
||||
export function Perks({
|
||||
perks,
|
||||
className,
|
||||
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) {
|
||||
}: { perks: { text: string; icon: PerkIcon }[]; className?: string }) {
|
||||
return (
|
||||
<ul className={cn('grid grid-cols-2 gap-2', className)}>
|
||||
{perks.map((perk) => (
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "pnpm with-env vite dev --port 3000",
|
||||
"start_deprecated": "pnpm with-env node .output/server/index.mjs",
|
||||
"preview": "vite preview",
|
||||
"deploy": "npx wrangler deploy",
|
||||
"deploy": "pnpm build && npx wrangler deploy",
|
||||
"cf-typegen": "wrangler types",
|
||||
"build": "pnpm with-env vite build",
|
||||
"serve": "vite preview",
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { pushModal } from '@/modals';
|
||||
import type {
|
||||
IReport,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
IReport,
|
||||
} from '@openpanel/validation';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { ReportChartType } from '../report/ReportChartType';
|
||||
import { ReportInterval } from '../report/ReportInterval';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { TimeWindowPicker } from '../time-window-picker';
|
||||
import { Button } from '../ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
|
||||
export function ChatReport({
|
||||
lazy,
|
||||
...props
|
||||
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
|
||||
}: {
|
||||
report: IReport & { startDate: string; endDate: string };
|
||||
lazy: boolean;
|
||||
}) {
|
||||
const [chartType, setChartType] = useState<IChartType>(
|
||||
props.report.chartType,
|
||||
props.report.chartType
|
||||
);
|
||||
const [startDate, setStartDate] = useState<string>(props.report.startDate);
|
||||
const [endDate, setEndDate] = useState<string>(props.report.endDate);
|
||||
@@ -35,47 +38,48 @@ export function ChatReport({
|
||||
};
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="text-center text-sm font-mono font-medium pt-4">
|
||||
<div className="pt-4 text-center font-medium font-mono text-sm">
|
||||
{props.report.name}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ReportChart lazy={lazy} report={report} />
|
||||
</div>
|
||||
<div className="row justify-between gap-1 border-t border-border p-2">
|
||||
<div className="row justify-between gap-1 border-border border-t p-2">
|
||||
<div className="col md:row gap-1">
|
||||
<TimeWindowPicker
|
||||
className="min-w-0"
|
||||
onChange={setRange}
|
||||
value={report.range}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
endDate={report.endDate}
|
||||
onChange={setRange}
|
||||
onEndDateChange={setEndDate}
|
||||
onIntervalChange={setInterval}
|
||||
onStartDateChange={setStartDate}
|
||||
startDate={report.startDate}
|
||||
value={report.range}
|
||||
/>
|
||||
<ReportInterval
|
||||
chartType={chartType}
|
||||
className="min-w-0"
|
||||
interval={interval}
|
||||
range={range}
|
||||
chartType={chartType}
|
||||
onChange={setInterval}
|
||||
range={range}
|
||||
/>
|
||||
<ReportChartType
|
||||
value={chartType}
|
||||
onChange={(type) => {
|
||||
setChartType(type);
|
||||
}}
|
||||
value={chartType}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={SaveIcon}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pushModal('SaveReport', {
|
||||
report,
|
||||
disableRedirect: true,
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Save report
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
||||
|
||||
const deltaChipVariants = cva(
|
||||
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
|
||||
'flex items-center justify-center gap-1 rounded-full font-semibold',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -12,9 +12,10 @@ const deltaChipVariants = cva(
|
||||
default: 'bg-muted text-muted-foreground',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
xs: 'px-1.5 py-0 leading-none text-[10px]',
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-2 py-1 text-sm',
|
||||
lg: 'px-2 py-1 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -30,6 +31,7 @@ type DeltaChipProps = VariantProps<typeof deltaChipVariants> & {
|
||||
};
|
||||
|
||||
const iconVariants: Record<NonNullable<DeltaChipProps['size']>, number> = {
|
||||
xs: 8,
|
||||
sm: 12,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
|
||||
@@ -1,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 { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
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;
|
||||
|
||||
export function EventListItem(props: EventListItemProps) {
|
||||
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 number = useNumber();
|
||||
|
||||
const renderName = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
@@ -32,83 +27,65 @@ export function EventListItem(props: EventListItemProps) {
|
||||
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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isMinimal) {
|
||||
pushModal('EventDetails', {
|
||||
id: props.id,
|
||||
projectId,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'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 className="flex items-center gap-4 text-left ">
|
||||
<EventIcon size="sm" name={name} meta={meta} />
|
||||
<span>
|
||||
<span className="font-medium">{renderName()}</span>
|
||||
{' '}
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-10">
|
||||
<div className="flex origin-left scale-75 gap-1">
|
||||
<SerieIcon name={props.country} />
|
||||
<SerieIcon name={props.os} />
|
||||
<SerieIcon name={props.browser} />
|
||||
</div>
|
||||
<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={() => {
|
||||
if (!isMinimal) {
|
||||
pushModal('EventDetails', {
|
||||
id: props.id,
|
||||
projectId,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-left">
|
||||
<EventIcon meta={meta} name={name} size="sm" />
|
||||
<span className="font-medium">{renderName()}</span>
|
||||
</div>
|
||||
<div className="pl-10">
|
||||
<div className="flex origin-left scale-75 gap-1">
|
||||
<SerieIcon name={props.country} />
|
||||
<SerieIcon name={props.os} />
|
||||
<SerieIcon name={props.browser} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{profile && (
|
||||
<Tooltiper asChild content={getProfileName(profile)}>
|
||||
<Link
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
to={'/$organizationId/$projectId/profiles/$profileId'}
|
||||
params={{
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId: profile.id,
|
||||
}}
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
</Tooltiper>
|
||||
)}
|
||||
|
||||
<Tooltiper asChild content={createdAt.toLocaleString()}>
|
||||
<div className=" text-muted-foreground">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{profile && (
|
||||
<Tooltiper asChild content={getProfileName(profile)}>
|
||||
<Link
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
params={{
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId: profile.id,
|
||||
}}
|
||||
to={'/$organizationId/$projectId/profiles/$profileId'}
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltiper asChild content={createdAt.toLocaleString()}>
|
||||
<div className="text-muted-foreground">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -8,71 +9,53 @@ import { useDebounceState } from '@/hooks/use-debounce-state';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
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({
|
||||
onRefresh,
|
||||
}: {
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const params = useParams({
|
||||
strict: false,
|
||||
});
|
||||
const { projectId } = useAppParams();
|
||||
const counter = useDebounceState(0, 1000);
|
||||
useWS<IServiceEventMinimal | IServiceEvent>(
|
||||
useWS<{ count: number }>(
|
||||
`/live/events/${projectId}`,
|
||||
(event) => {
|
||||
if (event) {
|
||||
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);
|
||||
}
|
||||
({ count }) => {
|
||||
counter.set((prev) => prev + count);
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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={() => {
|
||||
counter.set(0);
|
||||
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={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
|
||||
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>
|
||||
{counter.debounced === 0 ? (
|
||||
'Listening'
|
||||
) : (
|
||||
<AnimatedNumber value={counter.debounced} suffix=" new events" />
|
||||
<AnimatedNumber suffix=" new events" value={counter.debounced} />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -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 { ProjectLink } from '@/components/links';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
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 { pushModal } from '@/modals';
|
||||
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() {
|
||||
const number = useNumber();
|
||||
@@ -28,17 +27,24 @@ export function useColumns() {
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
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 = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
return <span className="max-w-md truncate">{path}</span>;
|
||||
return path;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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, ' ');
|
||||
};
|
||||
|
||||
const renderDuration = () => {
|
||||
if (name === 'screen_view') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{number.shortWithUnit(duration / 1000, 'min')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="transition-transform hover:scale-105"
|
||||
className="shrink-0 transition-transform hover:scale-105"
|
||||
onClick={() => {
|
||||
pushModal('EditEvent', {
|
||||
id: row.original.id,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<EventIcon
|
||||
size="sm"
|
||||
name={row.original.name}
|
||||
meta={row.original.meta}
|
||||
name={row.original.name}
|
||||
size="sm"
|
||||
/>
|
||||
</button>
|
||||
<span className="flex gap-2">
|
||||
<span className="flex min-w-0 flex-1 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 max-w-full truncate text-left font-medium hover:underline"
|
||||
title={fullTitle}
|
||||
onClick={() => {
|
||||
pushModal('EventDetails', {
|
||||
id: row.original.id,
|
||||
@@ -89,11 +84,10 @@ export function useColumns() {
|
||||
projectId: row.original.projectId,
|
||||
});
|
||||
}}
|
||||
className="font-medium hover:underline"
|
||||
type="button"
|
||||
>
|
||||
{renderName()}
|
||||
<span className="block truncate">{renderName()}</span>
|
||||
</button>
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -107,8 +101,8 @@ export function useColumns() {
|
||||
if (profile) {
|
||||
return (
|
||||
<ProjectLink
|
||||
className="group row items-center gap-2 whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
||||
className="group whitespace-nowrap font-medium hover:underline row items-center gap-2"
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{getProfileName(profile)}
|
||||
@@ -119,8 +113,8 @@ export function useColumns() {
|
||||
if (profileId && profileId !== deviceId) {
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${encodeURIComponent(profileId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(profileId)}`}
|
||||
>
|
||||
Unknown
|
||||
</ProjectLink>
|
||||
@@ -130,8 +124,8 @@ export function useColumns() {
|
||||
if (deviceId) {
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${encodeURIComponent(deviceId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(deviceId)}`}
|
||||
>
|
||||
Anonymous
|
||||
</ProjectLink>
|
||||
@@ -152,10 +146,10 @@ export function useColumns() {
|
||||
const { sessionId } = row.original;
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||
>
|
||||
{sessionId.slice(0,6)}
|
||||
{sessionId.slice(0, 6)}
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
@@ -175,7 +169,7 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { country, city } = row.original;
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<div className="row min-w-0 items-center gap-2">
|
||||
<SerieIcon name={country} />
|
||||
<span className="truncate">{city}</span>
|
||||
</div>
|
||||
@@ -189,7 +183,7 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { os } = row.original;
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<div className="row min-w-0 items-center gap-2">
|
||||
<SerieIcon name={os} />
|
||||
<span className="truncate">{os}</span>
|
||||
</div>
|
||||
@@ -203,7 +197,7 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { browser } = row.original;
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<div className="row min-w-0 items-center gap-2">
|
||||
<SerieIcon name={browser} />
|
||||
<span className="truncate">{browser}</span>
|
||||
</div>
|
||||
@@ -221,14 +215,14 @@ export function useColumns() {
|
||||
const { properties } = row.original;
|
||||
const filteredProperties = Object.fromEntries(
|
||||
Object.entries(properties || {}).filter(
|
||||
([key]) => !key.startsWith('__'),
|
||||
),
|
||||
([key]) => !key.startsWith('__')
|
||||
)
|
||||
);
|
||||
const items = Object.entries(filteredProperties);
|
||||
const limit = 2;
|
||||
const data = items.slice(0, limit).map(([key, value]) => ({
|
||||
name: key,
|
||||
value: value,
|
||||
value,
|
||||
}));
|
||||
if (items.length > limit) {
|
||||
data.push({
|
||||
|
||||
@@ -35,6 +35,7 @@ type Props = {
|
||||
>,
|
||||
unknown
|
||||
>;
|
||||
showEventListener?: boolean;
|
||||
};
|
||||
|
||||
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 columns = useColumns();
|
||||
|
||||
@@ -272,7 +273,7 @@ export const EventsTable = ({ query }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventsTableToolbar query={query} table={table} />
|
||||
<EventsTableToolbar query={query} table={table} showEventListener={showEventListener} />
|
||||
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
|
||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||
<div
|
||||
@@ -291,9 +292,11 @@ export const EventsTable = ({ query }: Props) => {
|
||||
function EventsTableToolbar({
|
||||
query,
|
||||
table,
|
||||
showEventListener,
|
||||
}: {
|
||||
query: Props['query'];
|
||||
table: Table<IServiceEvent>;
|
||||
showEventListener: boolean;
|
||||
}) {
|
||||
const { projectId } = useAppParams();
|
||||
const [startDate, setStartDate] = useQueryState(
|
||||
@@ -305,7 +308,7 @@ function EventsTableToolbar({
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<EventListener onRefresh={() => query.refetch()} />
|
||||
{showEventListener && <EventListener onRefresh={() => query.refetch()} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { MenuIcon, XIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { LogoSquare } from './logo';
|
||||
import { Button, LinkButton } from './ui/button';
|
||||
import { Button } from './ui/button';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export function LoginNavbar({ className }: { className?: string }) {
|
||||
const { isSelfHosted } = useAppContext();
|
||||
@@ -12,59 +12,61 @@ export function LoginNavbar({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 left-0 w-full row justify-between items-center p-8 z-10',
|
||||
className,
|
||||
'row absolute top-0 left-0 z-10 w-full items-center justify-between p-8',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<a href="https://openpanel.dev" className="row items-center gap-2">
|
||||
<a className="row items-center gap-2" href="https://openpanel.dev">
|
||||
<LogoSquare className="size-8 shrink-0" />
|
||||
<span className="font-medium text-sm text-muted-foreground">
|
||||
<span className="font-medium text-muted-foreground text-sm">
|
||||
{isSelfHosted ? 'Self-hosted analytics' : 'OpenPanel.dev'}
|
||||
</span>
|
||||
</a>
|
||||
<nav className="max-md:hidden">
|
||||
<ul className="row gap-4 items-center [&>li>a]:text-sm [&>li>a]:text-muted-foreground [&>li>a]:hover:underline">
|
||||
<li>
|
||||
<a href="https://openpanel.dev">OpenPanel Cloud</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openpanel.dev/compare/mixpanel-alternative">
|
||||
Mixpanel alternative
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openpanel.dev/compare/posthog-alternative">
|
||||
Posthog alternative
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openpanel.dev/articles/open-source-web-analytics">
|
||||
Open source analytics
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="md:hidden relative">
|
||||
{isSelfHosted && (
|
||||
<nav className="max-md:hidden">
|
||||
<ul className="row items-center gap-4 [&>li>a]:text-muted-foreground [&>li>a]:text-sm [&>li>a]:hover:underline">
|
||||
<li>
|
||||
<a href="https://openpanel.dev">OpenPanel Cloud</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openpanel.dev/compare/mixpanel-alternative">
|
||||
Mixpanel alternative
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openpanel.dev/compare/posthog-alternative">
|
||||
Posthog alternative
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openpanel.dev/articles/open-source-web-analytics">
|
||||
Open source analytics
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
<div className="relative md:hidden">
|
||||
<Button
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
>
|
||||
{mobileMenuOpen ? <XIcon size={20} /> : <MenuIcon size={20} />}
|
||||
</Button>
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
<nav className="absolute right-0 top-full mt-2 z-50 bg-card border border-border rounded-md shadow-lg min-w-48 py-2">
|
||||
<ul className="flex flex-col *:text-sm *:text-muted-foreground">
|
||||
<nav className="absolute top-full right-0 z-50 mt-2 min-w-48 rounded-md border border-border bg-card py-2 shadow-lg">
|
||||
<ul className="flex flex-col *:text-muted-foreground *:text-sm">
|
||||
<li>
|
||||
<a
|
||||
href="https://openpanel.dev"
|
||||
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
href="https://openpanel.dev"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
OpenPanel Cloud
|
||||
@@ -72,8 +74,8 @@ export function LoginNavbar({ className }: { className?: string }) {
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://openpanel.dev/compare/mixpanel-alternative"
|
||||
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
href="https://openpanel.dev/compare/mixpanel-alternative"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Posthog alternative
|
||||
@@ -81,8 +83,8 @@ export function LoginNavbar({ className }: { className?: string }) {
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://openpanel.dev/compare/mixpanel-alternative"
|
||||
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
href="https://openpanel.dev/compare/mixpanel-alternative"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Mixpanel alternative
|
||||
@@ -90,8 +92,8 @@ export function LoginNavbar({ className }: { className?: string }) {
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://openpanel.dev/articles/open-source-web-analytics"
|
||||
className="block px-4 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
href="https://openpanel.dev/articles/open-source-web-analytics"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Open source analytics
|
||||
|
||||
@@ -1,119 +1,102 @@
|
||||
import { LogoSquare } from '@/components/logo';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '@/components/ui/carousel';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { CodeIcon, CreditCardIcon, DollarSignIcon } from 'lucide-react';
|
||||
import { SellingPoint } from './selling-points';
|
||||
import Autoplay from 'embla-carousel-autoplay';
|
||||
import { QuoteIcon } from 'lucide-react';
|
||||
|
||||
const onboardingSellingPoints = [
|
||||
const testimonials = [
|
||||
{
|
||||
key: 'get-started',
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-6.webp"
|
||||
title="Get started in minutes"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
<DollarSignIcon className="size-4 inline-block mr-1 relative -top-0.5" />
|
||||
Free trial
|
||||
</p>
|
||||
<p>
|
||||
<CreditCardIcon className="size-4 inline-block mr-1 relative -top-0.5" />
|
||||
No credit card required
|
||||
</p>
|
||||
<p>
|
||||
<CodeIcon className="size-4 inline-block mr-1 relative -top-0.5" />
|
||||
Add our tracking code and get insights in real-time.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
key: 'thomas',
|
||||
bgImage: '/img-1.webp',
|
||||
quote:
|
||||
"OpenPanel is BY FAR the best open-source analytics I've ever seen. Better UX/UI, many more features, and incredible support from the founder.",
|
||||
author: 'Thomas Sanlis',
|
||||
site: 'uneed.best',
|
||||
},
|
||||
{
|
||||
key: 'welcome',
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-1.webp"
|
||||
title="Best open-source alternative"
|
||||
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring"
|
||||
/>
|
||||
),
|
||||
key: 'julien',
|
||||
bgImage: '/img-2.webp',
|
||||
quote:
|
||||
'After testing several product analytics tools, we chose OpenPanel and we are very satisfied. Profiles and Conversion Events are our favorite features.',
|
||||
author: 'Julien Hany',
|
||||
site: 'strackr.com',
|
||||
},
|
||||
{
|
||||
key: 'selling-point-2',
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-2.webp"
|
||||
title="Fast and reliable"
|
||||
description="Never miss a beat with our real-time analytics"
|
||||
/>
|
||||
),
|
||||
key: 'piotr',
|
||||
bgImage: '/img-3.webp',
|
||||
quote:
|
||||
'The Overview tab is great — it has everything I need. The UI is beautiful, clean, modern, very pleasing to the eye.',
|
||||
author: 'Piotr Kulpinski',
|
||||
site: 'producthunt.com',
|
||||
},
|
||||
{
|
||||
key: 'selling-point-3',
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-3.webp"
|
||||
title="Easy to use"
|
||||
description="Compared to other tools we have kept it simple"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'selling-point-4',
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-4.webp"
|
||||
title="Privacy by default"
|
||||
description="We have built our platform with privacy at its heart"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'selling-point-5',
|
||||
render: () => (
|
||||
<SellingPoint
|
||||
bgImage="/img-5.webp"
|
||||
title="Open source"
|
||||
description="You can inspect the code and self-host if you choose"
|
||||
/>
|
||||
),
|
||||
key: 'selfhost',
|
||||
bgImage: '/img-4.webp',
|
||||
quote:
|
||||
"After paying a lot to PostHog for years, OpenPanel gives us the same — in many ways better — analytics while keeping full ownership of our data. We don't want to run any business without OpenPanel anymore.",
|
||||
author: 'Self-hosting user',
|
||||
site: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
function TestimonialSlide({
|
||||
bgImage,
|
||||
quote,
|
||||
author,
|
||||
site,
|
||||
}: {
|
||||
bgImage: string;
|
||||
quote: string;
|
||||
author: string;
|
||||
site?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex flex-col justify-end h-full p-10 select-none">
|
||||
<img
|
||||
src={bgImage}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-black/10" />
|
||||
<div className="relative z-10 flex flex-col gap-4">
|
||||
<QuoteIcon className="size-10 text-white/40 stroke-1" />
|
||||
<blockquote className="text-3xl font-medium text-white leading-relaxed">
|
||||
{quote}
|
||||
</blockquote>
|
||||
<figcaption className="text-white/60 text-sm">
|
||||
— {author}
|
||||
{site && <span className="ml-1 text-white/40">· {site}</span>}
|
||||
</figcaption>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingLeftPanel() {
|
||||
return (
|
||||
<div className="sticky top-0 h-screen overflow-hidden">
|
||||
{/* Carousel */}
|
||||
<div className="flex items-center justify-center h-full mt-24">
|
||||
<Carousel
|
||||
className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
|
||||
opts={{
|
||||
loop: true,
|
||||
align: 'center',
|
||||
}}
|
||||
opts={{ loop: true, align: 'center' }}
|
||||
plugins={[Autoplay({ delay: 6000, stopOnInteraction: false })]}
|
||||
>
|
||||
<CarouselContent className="h-full">
|
||||
{onboardingSellingPoints.map((point, index) => (
|
||||
<CarouselItem
|
||||
key={`onboarding-point-${point.key}`}
|
||||
className="p-8 pb-32 pt-0"
|
||||
>
|
||||
{testimonials.map((t) => (
|
||||
<CarouselItem key={t.key} className="p-8 pb-32 pt-0">
|
||||
<div className="rounded-xl min-h-full h-full overflow-hidden bg-card border border-border shadow-lg">
|
||||
{point.render()}
|
||||
<TestimonialSlide
|
||||
bgImage={t.bgImage}
|
||||
quote={t.quote}
|
||||
author={t.author}
|
||||
site={t.site}
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className="left-12 bottom-30 top-auto" />
|
||||
<CarouselNext className="right-12 bottom-30 top-auto" />
|
||||
</Carousel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
import type {
|
||||
IServiceClient,
|
||||
IServiceEvent,
|
||||
IServiceProject,
|
||||
} from '@openpanel/db';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { timeAgo } from '@/utils/date';
|
||||
|
||||
interface Props {
|
||||
project: IServiceProject;
|
||||
client: IServiceClient | null;
|
||||
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 renderIcon = () => {
|
||||
@@ -49,16 +31,18 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
<div
|
||||
className={cn(
|
||||
'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()}
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-foreground/90 text-lg leading-normal">
|
||||
{isConnected ? 'Success' : 'Waiting for events'}
|
||||
{isConnected ? 'Successfully connected' : 'Waiting for events'}
|
||||
</div>
|
||||
{isConnected ? (
|
||||
<div className="flex flex-col-reverse">
|
||||
<div className="mt-2 flex flex-col-reverse gap-1">
|
||||
{events.length > 5 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckIcon size={14} />{' '}
|
||||
@@ -69,7 +53,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
<div className="flex items-center gap-2" key={event.id}>
|
||||
<CheckIcon size={14} />{' '}
|
||||
<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')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart, Bar, BarChart, Tooltip } from 'recharts';
|
||||
|
||||
import { formatDate, timeAgo } from '@/utils/date';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Bar, BarChart, Tooltip } from 'recharts';
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '../charts/chart-tooltip';
|
||||
import {
|
||||
PreviousDiffIndicatorPure,
|
||||
getDiffIndicator,
|
||||
PreviousDiffIndicatorPure,
|
||||
} from '../report-chart/common/previous-diff-indicator';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { formatDate, timeAgo } from '@/utils/date';
|
||||
|
||||
interface MetricCardProps {
|
||||
id: string;
|
||||
@@ -78,6 +71,9 @@ export function OverviewMetricCard({
|
||||
}
|
||||
|
||||
if (unit === 'timeAgo') {
|
||||
if (!value) {
|
||||
return <>{'N/A'}</>;
|
||||
}
|
||||
return <>{timeAgo(new Date(value))}</>;
|
||||
}
|
||||
|
||||
@@ -103,7 +99,7 @@ export function OverviewMetricCard({
|
||||
getPreviousMetric(current, previous)?.state,
|
||||
'#6ee7b7', // green
|
||||
'#fda4af', // red
|
||||
'#93c5fd', // blue
|
||||
'#93c5fd' // blue
|
||||
);
|
||||
|
||||
const renderTooltip = () => {
|
||||
@@ -115,7 +111,7 @@ export function OverviewMetricCard({
|
||||
{renderValue(
|
||||
data[currentIndex].current,
|
||||
'ml-1 font-light text-xl',
|
||||
false,
|
||||
false
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
@@ -132,60 +128,60 @@ export function OverviewMetricCard({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Tooltiper content={renderTooltip()} asChild sideOffset={-20}>
|
||||
<Tooltiper asChild content={renderTooltip()} sideOffset={-20}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'col-span-2 flex-1 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
|
||||
active && 'bg-def-100',
|
||||
active && 'bg-def-100'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<div className={cn('group relative p-4')}>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-4 right-4 bottom-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
||||
'absolute right-4 bottom-0 left-4 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<AutoSizer style={{ height: 20 }}>
|
||||
{({ width }) => (
|
||||
<BarChart
|
||||
width={width}
|
||||
height={20}
|
||||
data={data}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
}}
|
||||
height={20}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
onMouseMove={(event) => {
|
||||
setCurrentIndex(event.activeTooltipIndex ?? null);
|
||||
}}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
}}
|
||||
width={width}
|
||||
>
|
||||
<Tooltip content={() => null} cursor={false} />
|
||||
<Bar
|
||||
dataKey={'current'}
|
||||
type="step"
|
||||
fill={graphColors}
|
||||
fillOpacity={1}
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={0}
|
||||
type="step"
|
||||
/>
|
||||
</BarChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<OverviewMetricCardNumber
|
||||
label={label}
|
||||
value={renderValue(current, 'ml-1 font-light text-xl')}
|
||||
enhancer={
|
||||
<PreviousDiffIndicatorPure
|
||||
className="text-sm"
|
||||
size="sm"
|
||||
inverted={inverted}
|
||||
size="sm"
|
||||
{...getPreviousMetric(current, previous)}
|
||||
/>
|
||||
}
|
||||
isLoading={isLoading}
|
||||
label={label}
|
||||
value={renderValue(current, 'ml-1 font-light text-xl')}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
@@ -207,9 +203,9 @@ export function OverviewMetricCardNumber({
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0 col gap-2', className)}>
|
||||
<div className={cn('col min-w-0 gap-2', className)}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
|
||||
<span className="truncate font-medium text-muted-foreground text-sm leading-[1.1]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -219,11 +215,11 @@ export function OverviewMetricCardNumber({
|
||||
<Skeleton className="h-6 w-12" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate font-mono text-3xl leading-[1.1] font-bold w-full text-left">
|
||||
<div className="w-full truncate text-left font-bold font-mono text-3xl leading-[1.1]">
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute right-0 top-0 bottom-0 center justify-center col pr-4">
|
||||
<div className="center col absolute top-0 right-0 bottom-0 justify-center pr-4">
|
||||
{enhancer}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,17 +2,25 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { TimeWindowPicker } from '@/components/time-window-picker';
|
||||
|
||||
export function OverviewRange() {
|
||||
const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
|
||||
useOverviewOptions();
|
||||
const {
|
||||
range,
|
||||
setRange,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
endDate,
|
||||
startDate,
|
||||
setInterval,
|
||||
} = useOverviewOptions();
|
||||
|
||||
return (
|
||||
<TimeWindowPicker
|
||||
onChange={setRange}
|
||||
value={range}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
endDate={endDate}
|
||||
onChange={setRange}
|
||||
onEndDateChange={setEndDate}
|
||||
onIntervalChange={setInterval}
|
||||
onStartDateChange={setStartDate}
|
||||
startDate={startDate}
|
||||
value={range}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { eventQueryFiltersParser } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
|
||||
import {
|
||||
@@ -23,7 +26,9 @@ export default function OverviewTopEvents({
|
||||
shareId,
|
||||
}: OverviewTopEventsProps) {
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const [filters] = useEventQueryFilters();
|
||||
const { organizationId } = useAppParams();
|
||||
const navigate = useNavigate();
|
||||
const trpc = useTRPC();
|
||||
const { data: conversions } = useQuery(
|
||||
trpc.overview.topConversions.queryOptions({ projectId, shareId }),
|
||||
@@ -162,11 +167,23 @@ export default function OverviewTopEvents({
|
||||
<OverviewWidgetTableEvents
|
||||
data={filteredData}
|
||||
onItemClick={(name) => {
|
||||
if (widget.meta?.type === 'linkOut') {
|
||||
setFilter('properties.href', name);
|
||||
} else {
|
||||
setFilter('name', name);
|
||||
}
|
||||
const filterName =
|
||||
widget.meta?.type === 'linkOut'
|
||||
? 'properties.href'
|
||||
: 'name';
|
||||
const f = eventQueryFiltersParser.serialize([
|
||||
{
|
||||
id: filterName,
|
||||
name: filterName,
|
||||
operator: 'is',
|
||||
value: [name],
|
||||
},
|
||||
]);
|
||||
navigate({
|
||||
to: '/$organizationId/$projectId/events/events',
|
||||
params: { organizationId, projectId },
|
||||
search: { f },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface GscBreakdownTableProps {
|
||||
projectId: string;
|
||||
value: string;
|
||||
type: 'page' | 'query';
|
||||
}
|
||||
|
||||
export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableProps) {
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const dateInput = {
|
||||
range,
|
||||
startDate: startDate ?? undefined,
|
||||
endDate: endDate ?? undefined,
|
||||
};
|
||||
|
||||
const pageQuery = useQuery(
|
||||
trpc.gsc.getPageDetails.queryOptions(
|
||||
{ projectId, page: value, ...dateInput },
|
||||
{ enabled: type === 'page' },
|
||||
),
|
||||
);
|
||||
|
||||
const queryQuery = useQuery(
|
||||
trpc.gsc.getQueryDetails.queryOptions(
|
||||
{ projectId, query: value, ...dateInput },
|
||||
{ enabled: type === 'query' },
|
||||
),
|
||||
);
|
||||
|
||||
const isLoading = type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||
|
||||
const breakdownRows: Record<string, string | number>[] =
|
||||
type === 'page'
|
||||
? ((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ?? []) as Record<string, string | number>[]
|
||||
: ((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ?? []) as Record<string, string | number>[];
|
||||
|
||||
const breakdownKey = type === 'page' ? 'query' : 'page';
|
||||
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
|
||||
const pluralLabel = type === 'page' ? 'queries' : 'pages';
|
||||
|
||||
const maxClicks = Math.max(
|
||||
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
|
||||
1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-medium text-sm">Top {pluralLabel}</h3>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<OverviewWidgetTable
|
||||
data={[1, 2, 3, 4, 5]}
|
||||
keyExtractor={(i) => String(i)}
|
||||
getColumnPercentage={() => 0}
|
||||
columns={[
|
||||
{ name: breakdownLabel, width: 'w-full', render: () => <Skeleton className="h-4 w-2/3" /> },
|
||||
{ name: 'Clicks', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
|
||||
{ name: 'Impr.', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
|
||||
{ name: 'CTR', width: '60px', render: () => <Skeleton className="h-4 w-8" /> },
|
||||
{ name: 'Pos.', width: '55px', render: () => <Skeleton className="h-4 w-8" /> },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<OverviewWidgetTable
|
||||
data={breakdownRows}
|
||||
keyExtractor={(item) => String(item[breakdownKey])}
|
||||
getColumnPercentage={(item) => (item.clicks as number) / maxClicks}
|
||||
columns={[
|
||||
{
|
||||
name: breakdownLabel,
|
||||
width: 'w-full',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<span className="block truncate font-mono text-xs">
|
||||
{String(item[breakdownKey])}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clicks',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.clicks as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.clicks as number).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Impr.',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.impressions as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.impressions as number).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CTR',
|
||||
width: '60px',
|
||||
getSortValue: (item) => item.ctr as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{((item.ctr as number) * 100).toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pos.',
|
||||
width: '55px',
|
||||
getSortValue: (item) => item.position as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.position as number).toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
apps/start/src/components/page/gsc-cannibalization.tsx
Normal file
255
apps/start/src/components/page/gsc-cannibalization.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal file
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface ChartData {
|
||||
date: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||
ChartData,
|
||||
{ formatDate: (date: Date | string) => string }
|
||||
>(({ data, context }) => {
|
||||
const item = data[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{context.formatDate(item.date)}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(0)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Clicks</span>
|
||||
<span>{item.clicks.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
<ChartTooltipItem color={getChartColor(1)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Impressions</span>
|
||||
<span>{item.impressions.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface GscClicksChartProps {
|
||||
projectId: string;
|
||||
value: string;
|
||||
type: 'page' | 'query';
|
||||
}
|
||||
|
||||
export function GscClicksChart({
|
||||
projectId,
|
||||
value,
|
||||
type,
|
||||
}: GscClicksChartProps) {
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
const yAxisProps = useYAxisProps();
|
||||
const formatDateShort = useFormatDateInterval({ interval, short: true });
|
||||
const formatDateLong = useFormatDateInterval({ interval, short: false });
|
||||
|
||||
const dateInput = {
|
||||
range,
|
||||
startDate: startDate ?? undefined,
|
||||
endDate: endDate ?? undefined,
|
||||
};
|
||||
|
||||
const pageQuery = useQuery(
|
||||
trpc.gsc.getPageDetails.queryOptions(
|
||||
{ projectId, page: value, ...dateInput },
|
||||
{ enabled: type === 'page' }
|
||||
)
|
||||
);
|
||||
|
||||
const queryQuery = useQuery(
|
||||
trpc.gsc.getQueryDetails.queryOptions(
|
||||
{ projectId, query: value, ...dateInput },
|
||||
{ enabled: type === 'query' }
|
||||
)
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||
const timeseries =
|
||||
(type === 'page'
|
||||
? pageQuery.data?.timeseries
|
||||
: queryQuery.data?.timeseries) ?? [];
|
||||
|
||||
const data: ChartData[] = timeseries.map((r) => ({
|
||||
date: r.date,
|
||||
clicks: r.clicks,
|
||||
impressions: r.impressions,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">Clicks & Impressions</h3>
|
||||
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(0) }}
|
||||
/>
|
||||
Clicks
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(1) }}
|
||||
/>
|
||||
Impressions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<TooltipProvider formatDate={formatDateLong}>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="gsc-clicks-glow"
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
>
|
||||
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA slope="0.5" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
{...X_AXIS_STYLE_PROPS}
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => formatDateShort(v)}
|
||||
type="category"
|
||||
/>
|
||||
<YAxis {...yAxisProps} yAxisId="left" />
|
||||
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||
<Tooltip />
|
||||
<Line
|
||||
dataKey="clicks"
|
||||
dot={false}
|
||||
filter="url(#gsc-clicks-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="left"
|
||||
/>
|
||||
<Line
|
||||
dataKey="impressions"
|
||||
dot={false}
|
||||
filter="url(#gsc-clicks-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="right"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal file
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
// Industry average CTR by position (Google organic)
|
||||
const BENCHMARK: Record<number, number> = {
|
||||
1: 28.5,
|
||||
2: 15.7,
|
||||
3: 11.0,
|
||||
4: 8.0,
|
||||
5: 6.3,
|
||||
6: 5.0,
|
||||
7: 4.0,
|
||||
8: 3.3,
|
||||
9: 2.8,
|
||||
10: 2.5,
|
||||
11: 2.2,
|
||||
12: 2.0,
|
||||
13: 1.8,
|
||||
14: 1.5,
|
||||
15: 1.2,
|
||||
16: 1.1,
|
||||
17: 1.0,
|
||||
18: 0.9,
|
||||
19: 0.8,
|
||||
20: 0.7,
|
||||
};
|
||||
|
||||
interface PageEntry {
|
||||
path: string;
|
||||
ctr: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
position: number;
|
||||
yourCtr: number | null;
|
||||
benchmark: number;
|
||||
pages: PageEntry[];
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||
ChartData,
|
||||
Record<string, unknown>
|
||||
>(({ data }) => {
|
||||
const item = data[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>Position #{item.position}</div>
|
||||
</ChartTooltipHeader>
|
||||
{item.yourCtr != null && (
|
||||
<ChartTooltipItem color={getChartColor(0)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Your avg CTR</span>
|
||||
<span>{item.yourCtr.toFixed(1)}%</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
)}
|
||||
<ChartTooltipItem color={getChartColor(3)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Benchmark</span>
|
||||
<span>{item.benchmark.toFixed(1)}%</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
{item.pages.length > 0 && (
|
||||
<div className="mt-1.5 border-t pt-1.5">
|
||||
{item.pages.map((p) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 py-0.5"
|
||||
key={p.path}
|
||||
>
|
||||
<span className="max-w-40 truncate font-mono text-muted-foreground text-xs">
|
||||
{p.path}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums">
|
||||
{(p.ctr * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface GscCtrBenchmarkProps {
|
||||
data: Array<{
|
||||
page: string;
|
||||
position: number;
|
||||
ctr: number;
|
||||
impressions: number;
|
||||
}>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function GscCtrBenchmark({ data, isLoading }: GscCtrBenchmarkProps) {
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
const grouped = new Map<number, { ctrSum: number; pages: PageEntry[] }>();
|
||||
for (const d of data) {
|
||||
const pos = Math.round(d.position);
|
||||
if (pos < 1 || pos > 20 || d.impressions < 10) {
|
||||
continue;
|
||||
}
|
||||
let path = d.page;
|
||||
try {
|
||||
path = new URL(d.page).pathname;
|
||||
} catch {
|
||||
// keep as-is
|
||||
}
|
||||
const entry = grouped.get(pos) ?? { ctrSum: 0, pages: [] };
|
||||
entry.ctrSum += d.ctr * 100;
|
||||
entry.pages.push({ path, ctr: d.ctr, impressions: d.impressions });
|
||||
grouped.set(pos, entry);
|
||||
}
|
||||
|
||||
const chartData: ChartData[] = Array.from({ length: 20 }, (_, i) => {
|
||||
const pos = i + 1;
|
||||
const entry = grouped.get(pos);
|
||||
const pages = entry
|
||||
? [...entry.pages].sort((a, b) => b.ctr - a.ctr).slice(0, 5)
|
||||
: [];
|
||||
return {
|
||||
position: pos,
|
||||
yourCtr: entry ? entry.ctrSum / entry.pages.length : null,
|
||||
benchmark: BENCHMARK[pos] ?? 0,
|
||||
pages,
|
||||
};
|
||||
});
|
||||
|
||||
const hasAnyData = chartData.some((d) => d.yourCtr != null);
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">CTR vs Position</h3>
|
||||
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||
{hasAnyData && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(0) }}
|
||||
/>
|
||||
Your CTR
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full opacity-60"
|
||||
style={{ backgroundColor: getChartColor(3) }}
|
||||
/>
|
||||
Benchmark
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={chartData}>
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
{...X_AXIS_STYLE_PROPS}
|
||||
dataKey="position"
|
||||
domain={[1, 20]}
|
||||
tickFormatter={(v: number) => `#${v}`}
|
||||
ticks={[1, 5, 10, 15, 20]}
|
||||
type="number"
|
||||
/>
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, 'auto']}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
dataKey="yourCtr"
|
||||
dot={{ r: 3, fill: getChartColor(0) }}
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
dataKey="benchmark"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(3)}
|
||||
strokeDasharray="4 3"
|
||||
strokeOpacity={0.6}
|
||||
strokeWidth={1.5}
|
||||
type="monotone"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal file
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface ChartData {
|
||||
date: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||
ChartData,
|
||||
Record<string, unknown>
|
||||
>(({ data }) => {
|
||||
const item = data[0];
|
||||
if (!item) return null;
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{item.date}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(2)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Avg Position</span>
|
||||
<span>{item.position.toFixed(1)}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface GscPositionChartProps {
|
||||
data: Array<{ date: string; position: number }>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function GscPositionChart({ data, isLoading }: GscPositionChartProps) {
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
const chartData: ChartData[] = data.map((r) => ({
|
||||
date: r.date,
|
||||
position: r.position,
|
||||
}));
|
||||
|
||||
const positions = chartData.map((d) => d.position).filter((p) => p > 0);
|
||||
const minPos = positions.length ? Math.max(1, Math.floor(Math.min(...positions)) - 2) : 1;
|
||||
const maxPos = positions.length ? Math.ceil(Math.max(...positions)) + 2 : 20;
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">Avg Position</h3>
|
||||
<span className="text-muted-foreground text-xs">Lower is better</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={chartData}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="gsc-pos-glow"
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
>
|
||||
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA slope="0.5" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
{...X_AXIS_STYLE_PROPS}
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
type="category"
|
||||
/>
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[minPos, maxPos]}
|
||||
reversed
|
||||
tickFormatter={(v: number) => `#${v}`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
dataKey="position"
|
||||
dot={false}
|
||||
filter="url(#gsc-pos-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(2)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
apps/start/src/components/page/page-views-chart.tsx
Normal file
180
apps/start/src/components/page/page-views-chart.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface ChartData {
|
||||
date: string;
|
||||
pageviews: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||
ChartData,
|
||||
{ formatDate: (date: Date | string) => string }
|
||||
>(({ data, context }) => {
|
||||
const item = data[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{context.formatDate(item.date)}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(0)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Views</span>
|
||||
<span>{item.pageviews.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
<ChartTooltipItem color={getChartColor(1)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Sessions</span>
|
||||
<span>{item.sessions.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface PageViewsChartProps {
|
||||
projectId: string;
|
||||
origin: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function PageViewsChart({
|
||||
projectId,
|
||||
origin,
|
||||
path,
|
||||
}: PageViewsChartProps) {
|
||||
const { range, interval } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
const yAxisProps = useYAxisProps();
|
||||
const formatDateShort = useFormatDateInterval({ interval, short: true });
|
||||
const formatDateLong = useFormatDateInterval({ interval, short: false });
|
||||
|
||||
const query = useQuery(
|
||||
trpc.event.pageTimeseries.queryOptions({
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
origin,
|
||||
path,
|
||||
})
|
||||
);
|
||||
|
||||
const data: ChartData[] = (query.data ?? []).map((r) => ({
|
||||
date: r.date,
|
||||
pageviews: r.pageviews,
|
||||
sessions: r.sessions,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">Views & Sessions</h3>
|
||||
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(0) }}
|
||||
/>
|
||||
Views
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(1) }}
|
||||
/>
|
||||
Sessions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{query.isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<TooltipProvider formatDate={formatDateLong}>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="page-views-glow"
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
>
|
||||
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA slope="0.5" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
{...X_AXIS_STYLE_PROPS}
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => formatDateShort(v)}
|
||||
type="category"
|
||||
/>
|
||||
<YAxis {...yAxisProps} yAxisId="left" />
|
||||
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||
<Tooltip />
|
||||
<Line
|
||||
dataKey="pageviews"
|
||||
dot={false}
|
||||
filter="url(#page-views-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="left"
|
||||
/>
|
||||
<Line
|
||||
dataKey="sessions"
|
||||
dot={false}
|
||||
filter="url(#page-views-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="right"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
apps/start/src/components/page/pages-insights.tsx
Normal file
360
apps/start/src/components/page/pages-insights.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/start/src/components/pages/page-sparkline.tsx
Normal file
122
apps/start/src/components/pages/page-sparkline.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { LazyComponent } from '@/components/lazy-component';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
interface SparklineBarsProps {
|
||||
data: { date: string; pageviews: number }[];
|
||||
}
|
||||
|
||||
const defaultGap = 1;
|
||||
const height = 24;
|
||||
const width = 100;
|
||||
|
||||
function getTrendDirection(data: { pageviews: number }[]): '↑' | '↓' | '→' {
|
||||
const n = data.length;
|
||||
if (n < 3) {
|
||||
return '→';
|
||||
}
|
||||
const third = Math.max(1, Math.floor(n / 3));
|
||||
const firstAvg =
|
||||
data.slice(0, third).reduce((s, d) => s + d.pageviews, 0) / third;
|
||||
const lastAvg =
|
||||
data.slice(n - third).reduce((s, d) => s + d.pageviews, 0) / third;
|
||||
const threshold = firstAvg * 0.05;
|
||||
if (lastAvg - firstAvg > threshold) {
|
||||
return '↑';
|
||||
}
|
||||
if (firstAvg - lastAvg > threshold) {
|
||||
return '↓';
|
||||
}
|
||||
return '→';
|
||||
}
|
||||
|
||||
function SparklineBars({ data }: SparklineBarsProps) {
|
||||
if (!data.length) {
|
||||
return <div style={{ height, width }} />;
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.pageviews), 1);
|
||||
const total = data.length;
|
||||
// Compute bar width to fit SVG width; reduce gap if needed so barW >= 1 when possible
|
||||
let gap = defaultGap;
|
||||
let barW = Math.floor((width - gap * (total - 1)) / total);
|
||||
if (barW < 1 && total > 1) {
|
||||
gap = 0;
|
||||
barW = Math.floor((width - gap * (total - 1)) / total);
|
||||
}
|
||||
if (barW < 1) {
|
||||
barW = 1;
|
||||
}
|
||||
const trend = getTrendDirection(data);
|
||||
const trendColor =
|
||||
trend === '↑'
|
||||
? 'text-emerald-500'
|
||||
: trend === '↓'
|
||||
? 'text-red-500'
|
||||
: 'text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="shrink-0" height={height} width={width}>
|
||||
{data.map((d, i) => {
|
||||
const barH = Math.max(
|
||||
2,
|
||||
Math.round((d.pageviews / max) * (height * 0.8))
|
||||
);
|
||||
return (
|
||||
<rect
|
||||
className="fill-chart-0"
|
||||
height={barH}
|
||||
key={d.date}
|
||||
rx="1"
|
||||
width={barW}
|
||||
x={i * (barW + gap)}
|
||||
y={height - barH}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<Tooltiper
|
||||
content={
|
||||
trend === '↑'
|
||||
? 'Upward trend'
|
||||
: trend === '↓'
|
||||
? 'Downward trend'
|
||||
: 'Stable trend'
|
||||
}
|
||||
>
|
||||
<span className={`shrink-0 font-medium text-xs ${trendColor}`}>
|
||||
{trend}
|
||||
</span>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageSparklineProps {
|
||||
projectId: string;
|
||||
origin: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function PageSparkline({ projectId, origin, path }: PageSparklineProps) {
|
||||
const { range, interval } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const query = useQuery(
|
||||
trpc.event.pageTimeseries.queryOptions({
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
origin,
|
||||
path,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<LazyComponent fallback={<div style={{ height, width }} />}>
|
||||
<SparklineBars data={query.data ?? []} />
|
||||
</LazyComponent>
|
||||
);
|
||||
}
|
||||
206
apps/start/src/components/pages/table/columns.tsx
Normal file
206
apps/start/src/components/pages/table/columns.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { ExternalLinkIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { PageSparkline } from '@/components/pages/page-sparkline';
|
||||
import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
|
||||
export type PageRow = RouterOutputs['event']['pages'][number] & {
|
||||
gsc?: { clicks: number; impressions: number; ctr: number; position: number };
|
||||
};
|
||||
|
||||
export function useColumns({
|
||||
projectId,
|
||||
isGscConnected,
|
||||
previousMap,
|
||||
}: {
|
||||
projectId: string;
|
||||
isGscConnected: boolean;
|
||||
previousMap?: Map<string, number>;
|
||||
}): ColumnDef<PageRow>[] {
|
||||
const number = useNumber();
|
||||
const { apiUrl } = useAppContext();
|
||||
|
||||
return useMemo<ColumnDef<PageRow>[]>(() => {
|
||||
const cols: ColumnDef<PageRow>[] = [
|
||||
{
|
||||
id: 'page',
|
||||
accessorFn: (row) => `${row.origin}${row.path} ${row.title ?? ''}`,
|
||||
header: createHeaderColumn('Page'),
|
||||
size: 400,
|
||||
meta: { bold: true },
|
||||
cell: ({ row }) => {
|
||||
const page = row.original;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
alt=""
|
||||
className="size-4 shrink-0 rounded-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
src={`${apiUrl}/misc/favicon?url=${page.origin}`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
{page.title && (
|
||||
<div className="truncate font-medium text-sm leading-tight">
|
||||
{page.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate font-mono text-muted-foreground text-xs">
|
||||
{page.path}
|
||||
</span>
|
||||
<a
|
||||
className="shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
|
||||
href={page.origin + page.path}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'trend',
|
||||
header: 'Trend',
|
||||
enableSorting: false,
|
||||
size: 96,
|
||||
cell: ({ row }) => (
|
||||
<PageSparkline
|
||||
origin={row.original.origin}
|
||||
path={row.original.path}
|
||||
projectId={projectId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'pageviews',
|
||||
header: createHeaderColumn('Views'),
|
||||
size: 80,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{number.short(row.original.pageviews)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'sessions',
|
||||
header: createHeaderColumn('Sessions'),
|
||||
size: 90,
|
||||
cell: ({ row }) => {
|
||||
const prev = previousMap?.get(
|
||||
row.original.origin + row.original.path
|
||||
);
|
||||
if (prev == null) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
if (prev === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{number.short(row.original.sessions)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">new</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pct = ((row.original.sessions - prev) / prev) * 100;
|
||||
const isPos = pct >= 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{number.short(row.original.sessions)}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
||||
>
|
||||
{isPos ? '+' : ''}
|
||||
{pct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'bounce_rate',
|
||||
header: createHeaderColumn('Bounce'),
|
||||
size: 80,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{row.original.bounce_rate.toFixed(0)}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'avg_duration',
|
||||
header: createHeaderColumn('Duration'),
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<span className="whitespace-nowrap font-mono text-sm tabular-nums">
|
||||
{fancyMinutes(row.original.avg_duration)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isGscConnected) {
|
||||
cols.push(
|
||||
{
|
||||
id: 'gsc_impressions',
|
||||
accessorFn: (row) => row.gsc?.impressions ?? 0,
|
||||
header: createHeaderColumn('Impr.'),
|
||||
size: 80,
|
||||
cell: ({ row }) =>
|
||||
row.original.gsc ? (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{number.short(row.original.gsc.impressions)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'gsc_ctr',
|
||||
accessorFn: (row) => row.gsc?.ctr ?? 0,
|
||||
header: createHeaderColumn('CTR'),
|
||||
size: 70,
|
||||
cell: ({ row }) =>
|
||||
row.original.gsc ? (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{(row.original.gsc.ctr * 100).toFixed(1)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'gsc_clicks',
|
||||
accessorFn: (row) => row.gsc?.clicks ?? 0,
|
||||
header: createHeaderColumn('Clicks'),
|
||||
size: 80,
|
||||
cell: ({ row }) =>
|
||||
row.original.gsc ? (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{number.short(row.original.gsc.clicks)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [isGscConnected, number, apiUrl, projectId, previousMap]);
|
||||
}
|
||||
143
apps/start/src/components/pages/table/index.tsx
Normal file
143
apps/start/src/components/pages/table/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { DataTable } from '@/components/ui/data-table/data-table';
|
||||
import {
|
||||
AnimatedSearchInput,
|
||||
DataTableToolbarContainer,
|
||||
} from '@/components/ui/data-table/data-table-toolbar';
|
||||
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||
import { useTable } from '@/components/ui/data-table/use-table';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { type PageRow, useColumns } from './columns';
|
||||
|
||||
interface PagesTableProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function PagesTable({ projectId }: PagesTableProps) {
|
||||
const trpc = useTRPC();
|
||||
const { range, interval, startDate, endDate } = useOverviewOptions();
|
||||
const { debouncedSearch, setSearch, search } = useSearchQueryState();
|
||||
|
||||
const pagesQuery = useQuery(
|
||||
trpc.event.pages.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
search: debouncedSearch ?? undefined,
|
||||
range,
|
||||
interval,
|
||||
},
|
||||
{ placeholderData: keepPreviousData },
|
||||
),
|
||||
);
|
||||
|
||||
const connectionQuery = useQuery(
|
||||
trpc.gsc.getConnection.queryOptions({ projectId }),
|
||||
);
|
||||
|
||||
const isGscConnected = !!(connectionQuery.data?.siteUrl);
|
||||
|
||||
const gscPagesQuery = useQuery(
|
||||
trpc.gsc.getPages.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
range,
|
||||
startDate: startDate ?? undefined,
|
||||
endDate: endDate ?? undefined,
|
||||
limit: 10_000,
|
||||
},
|
||||
{ enabled: isGscConnected },
|
||||
),
|
||||
);
|
||||
|
||||
const previousPagesQuery = useQuery(
|
||||
trpc.event.previousPages.queryOptions(
|
||||
{ projectId, range, interval },
|
||||
{ placeholderData: keepPreviousData },
|
||||
),
|
||||
);
|
||||
|
||||
const previousMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const p of previousPagesQuery.data ?? []) {
|
||||
map.set(p.origin + p.path, p.sessions);
|
||||
}
|
||||
return map;
|
||||
}, [previousPagesQuery.data]);
|
||||
|
||||
const gscMap = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ clicks: number; impressions: number; ctr: number; position: number }
|
||||
>();
|
||||
for (const row of gscPagesQuery.data ?? []) {
|
||||
map.set(row.page, {
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [gscPagesQuery.data]);
|
||||
|
||||
const rawData: PageRow[] = useMemo(() => {
|
||||
return (pagesQuery.data ?? []).map((p) => ({
|
||||
...p,
|
||||
gsc: gscMap.get(p.origin + p.path),
|
||||
}));
|
||||
}, [pagesQuery.data, gscMap]);
|
||||
|
||||
const columns = useColumns({ projectId, isGscConnected, previousMap });
|
||||
|
||||
const { table } = useTable({
|
||||
columns,
|
||||
data: rawData,
|
||||
loading: pagesQuery.isLoading,
|
||||
pageSize: 50,
|
||||
name: 'pages',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTableToolbarContainer>
|
||||
<AnimatedSearchInput
|
||||
placeholder="Search pages"
|
||||
value={search ?? ''}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</DataTableToolbarContainer>
|
||||
<DataTable
|
||||
table={table}
|
||||
loading={pagesQuery.isLoading}
|
||||
empty={{
|
||||
title: 'No pages',
|
||||
description: debouncedSearch
|
||||
? `No pages found matching "${debouncedSearch}"`
|
||||
: 'Integrate our web SDK to your site to get pages here.',
|
||||
}}
|
||||
onRowClick={(row) => {
|
||||
if (!isGscConnected) {
|
||||
return;
|
||||
}
|
||||
const page = row.original;
|
||||
pushModal('PageDetails', {
|
||||
type: 'page',
|
||||
projectId,
|
||||
value: page.origin + page.path,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||
|
||||
import type { IProfileMetrics } from '@openpanel/db';
|
||||
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||
|
||||
type Props = {
|
||||
data: IProfileMetrics;
|
||||
@@ -102,7 +101,7 @@ const PROFILE_METRICS = [
|
||||
|
||||
export const ProfileMetrics = ({ data }: Props) => {
|
||||
return (
|
||||
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0">
|
||||
<div className="relative col-span-6 -m-4 mt-0 mb-0 md:m-0">
|
||||
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
|
||||
{PROFILE_METRICS.filter((metric) => {
|
||||
if (metric.hideOnZero && data[metric.key] === 0) {
|
||||
@@ -111,20 +110,20 @@ export const ProfileMetrics = ({ data }: Props) => {
|
||||
return true;
|
||||
}).map((metric) => (
|
||||
<OverviewMetricCard
|
||||
key={metric.key}
|
||||
data={[]}
|
||||
id={metric.key}
|
||||
inverted={metric.inverted}
|
||||
isLoading={false}
|
||||
key={metric.key}
|
||||
label={metric.title}
|
||||
metric={{
|
||||
current:
|
||||
metric.unit === 'timeAgo'
|
||||
? new Date(data[metric.key]).getTime()
|
||||
metric.unit === 'timeAgo' && data[metric.key]
|
||||
? new Date(data[metric.key]!).getTime()
|
||||
: (data[metric.key] as number) || 0,
|
||||
previous: null,
|
||||
}}
|
||||
unit={metric.unit}
|
||||
data={[]}
|
||||
inverted={metric.inverted}
|
||||
isLoading={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -18,19 +18,22 @@ import type { PaginationState, Table, Updater } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { memo } from 'react';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
type Props = {
|
||||
query: UseQueryResult<RouterOutputs['profile']['list'], unknown>;
|
||||
type: 'profiles' | 'power-users';
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceProfile[];
|
||||
|
||||
export const ProfilesTable = memo(
|
||||
({ type, query }: Props) => {
|
||||
({ type, query, pageSize = PAGE_SIZE }: Props) => {
|
||||
const { data, isLoading } = query;
|
||||
const columns = useColumns(type);
|
||||
|
||||
const { setPage, state: pagination } = useDataTablePagination();
|
||||
const { setPage, state: pagination } = useDataTablePagination(pageSize);
|
||||
const {
|
||||
columnVisibility,
|
||||
setColumnVisibility,
|
||||
@@ -83,7 +86,7 @@ export const ProfilesTable = memo(
|
||||
</>
|
||||
);
|
||||
},
|
||||
arePropsEqual(['query.isLoading', 'query.data', 'type']),
|
||||
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']),
|
||||
);
|
||||
|
||||
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import { Link, useRouter } from '@tanstack/react-router';
|
||||
import {
|
||||
Building2Icon,
|
||||
CheckIcon,
|
||||
ChevronsUpDownIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,18 +19,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import {
|
||||
Building2Icon,
|
||||
CheckIcon,
|
||||
ChevronsUpDownIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { pushModal } from '@/modals';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
projects: Array<{ id: string; name: string; organizationId: string }>;
|
||||
@@ -69,16 +67,16 @@ export default function ProjectSelector({
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex min-w-0 flex-1 items-center justify-start"
|
||||
role="combobox"
|
||||
size={'sm'}
|
||||
variant="outline"
|
||||
>
|
||||
<Building2Icon size={16} className="shrink-0" />
|
||||
<Building2Icon className="shrink-0" size={16} />
|
||||
<span className="mx-2 truncate">
|
||||
{projectId
|
||||
? projects.find((p) => p.id === projectId)?.name
|
||||
@@ -108,10 +106,10 @@ export default function ProjectSelector({
|
||||
{projects.length > 10 && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to={'/$organizationId'}
|
||||
params={{
|
||||
organizationId,
|
||||
}}
|
||||
to={'/$organizationId'}
|
||||
>
|
||||
All projects
|
||||
</Link>
|
||||
@@ -148,11 +146,13 @@ export default function ProjectSelector({
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled>
|
||||
New organization
|
||||
<DropdownMenuShortcut>
|
||||
<PlusIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={'/onboarding/project'}>
|
||||
New organization
|
||||
<DropdownMenuShortcut>
|
||||
<PlusIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { EventItem } from '../events/table/item';
|
||||
import { ProjectLink } from '../links';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { formatTimeAgoOrDateTime } from '@/utils/date';
|
||||
|
||||
interface RealtimeActiveSessionsProps {
|
||||
projectId: string;
|
||||
@@ -17,64 +15,52 @@ export function RealtimeActiveSessions({
|
||||
limit = 10,
|
||||
}: RealtimeActiveSessionsProps) {
|
||||
const trpc = useTRPC();
|
||||
const activeSessionsQuery = useQuery(
|
||||
trpc.realtime.activeSessions.queryOptions({
|
||||
projectId,
|
||||
}),
|
||||
const { data: sessions = [] } = useQuery(
|
||||
trpc.realtime.activeSessions.queryOptions(
|
||||
{ projectId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
);
|
||||
|
||||
const [state, setState] = useState<IServiceEvent[]>([]);
|
||||
|
||||
// Update state when initial data loads
|
||||
useEffect(() => {
|
||||
if (activeSessionsQuery.data && state.length === 0) {
|
||||
setState(activeSessionsQuery.data);
|
||||
}
|
||||
}, [activeSessionsQuery.data, state]);
|
||||
|
||||
// Set up WebSocket connection for real-time updates
|
||||
useWS<IServiceEvent>(
|
||||
`/live/events/${projectId}`,
|
||||
(session) => {
|
||||
setState((prev) => {
|
||||
// Add new session and remove duplicates, keeping most recent
|
||||
const filtered = prev.filter((s) => s.id !== session.id);
|
||||
return [session, ...filtered].slice(0, limit);
|
||||
});
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 5000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sessions = state.length > 0 ? state : (activeSessionsQuery.data ?? []);
|
||||
|
||||
return (
|
||||
<div className="col h-full max-md:hidden">
|
||||
<div className="hide-scrollbar h-full overflow-y-auto pb-10">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<div className="col gap-4">
|
||||
{sessions.map((session) => (
|
||||
<div className="col card h-full max-md:hidden">
|
||||
<div className="hide-scrollbar h-full overflow-y-auto">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<div className="col divide-y">
|
||||
{sessions.slice(0, limit).map((session) => (
|
||||
<motion.div
|
||||
key={session.id}
|
||||
layout
|
||||
// initial={{ opacity: 0, x: -200, scale: 0.8 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 200, scale: 0.8 }}
|
||||
key={session.id}
|
||||
layout
|
||||
transition={{ duration: 0.4, type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<EventItem
|
||||
event={session}
|
||||
viewOptions={{
|
||||
properties: false,
|
||||
origin: false,
|
||||
queryString: false,
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<ProjectLink
|
||||
className="relative block p-4 py-3 pr-14"
|
||||
href={`/sessions/${session.sessionId}`}
|
||||
>
|
||||
<div className="col flex-1 gap-1">
|
||||
{session.name === 'screen_view' && (
|
||||
<span className="text-muted-foreground text-xs leading-normal/80">
|
||||
{session.origin}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-sm leading-normal">
|
||||
{session.name === 'screen_view'
|
||||
? session.path
|
||||
: session.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatTimeAgoOrDateTime(session.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row absolute top-1/2 right-4 origin-right -translate-y-1/2 scale-50 gap-2">
|
||||
<SerieIcon name={session.referrerName} />
|
||||
<SerieIcon name={session.os} />
|
||||
<SerieIcon name={session.browser} />
|
||||
<SerieIcon name={session.device} />
|
||||
</div>
|
||||
</ProjectLink>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -97,7 +97,7 @@ interface PreviousDiffIndicatorPureProps {
|
||||
diff?: number | null | undefined;
|
||||
state?: string | null | undefined;
|
||||
inverted?: boolean;
|
||||
size?: 'sm' | 'lg' | 'md';
|
||||
size?: 'xs' | 'sm' | 'lg' | 'md';
|
||||
className?: string;
|
||||
showPrevious?: boolean;
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ const data = {
|
||||
"dropbox": "https://www.dropbox.com",
|
||||
"openai": "https://openai.com",
|
||||
"chatgpt.com": "https://chatgpt.com",
|
||||
"copilot.com": "https://www.copilot.com",
|
||||
"mailchimp": "https://mailchimp.com",
|
||||
"activecampaign": "https://www.activecampaign.com",
|
||||
"customer.io": "https://customer.io",
|
||||
|
||||
@@ -3,8 +3,10 @@ import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
import { Tables } from './chart';
|
||||
|
||||
interface BreakdownListProps {
|
||||
@@ -48,10 +50,9 @@ export function BreakdownList({
|
||||
});
|
||||
};
|
||||
|
||||
// Get the color index for a breakdown based on its position in the
|
||||
// visible series list (so colors match the chart bars)
|
||||
const getVisibleIndex = (id: string) => {
|
||||
return visibleSeriesIds.indexOf(id);
|
||||
// Get the stable color index for a breakdown (position in full list, matches chart)
|
||||
const getStableColorIndex = (id: string) => {
|
||||
return allBreakdowns.findIndex((b) => b.id === id);
|
||||
};
|
||||
|
||||
if (allBreakdowns.length === 0) {
|
||||
@@ -81,14 +82,12 @@ export function BreakdownList({
|
||||
{allBreakdowns.map((item, index) => {
|
||||
const isExpanded = expandedIds.has(item.id);
|
||||
const isVisible = visibleSeriesIds.includes(item.id);
|
||||
const visibleIndex = getVisibleIndex(item.id);
|
||||
const stableColorIndex = getStableColorIndex(item.id);
|
||||
const previousItem = previousData[index] ?? null;
|
||||
const hasBreakdownName =
|
||||
item.breakdowns && item.breakdowns.length > 0;
|
||||
const color =
|
||||
isVisible && visibleIndex !== -1
|
||||
? getChartColor(visibleIndex)
|
||||
: undefined;
|
||||
stableColorIndex >= 0 ? getChartColor(stableColorIndex) : undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="col">
|
||||
@@ -107,7 +106,7 @@ export function BreakdownList({
|
||||
className="shrink-0"
|
||||
style={{
|
||||
borderColor: color,
|
||||
backgroundColor: isVisible ? color : 'transparent',
|
||||
backgroundColor: isVisible && color ? color : 'transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -141,6 +140,14 @@ export function BreakdownList({
|
||||
'%',
|
||||
)}
|
||||
</div>
|
||||
{previousItem && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
item.lastStep.percent,
|
||||
previousItem.lastStep.percent,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right row gap-2 items-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
@@ -149,6 +156,14 @@ export function BreakdownList({
|
||||
<div className="font-mono font-semibold text-sm">
|
||||
{number.format(item.lastStep.count)}
|
||||
</div>
|
||||
{previousItem && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
item.lastStep.count,
|
||||
previousItem.lastStep.count,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,6 +175,7 @@ export function BreakdownList({
|
||||
current: item,
|
||||
previous: previousItem,
|
||||
}}
|
||||
noTopBorderRadius
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
|
||||
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
|
||||
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||
import { BarShapeBlue, BarShapeProps } from '@/components/charts/common-bar';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { IVisibleFunnelBreakdowns } from '@/hooks/use-visible-funnel-breakdowns';
|
||||
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
@@ -31,12 +17,24 @@ import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
import { SerieIcon } from '../common/serie-icon';
|
||||
import { SerieName } from '../common/serie-name';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||
import { BarShapeProps } from '@/components/charts/common-bar';
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
current: RouterOutputs['chart']['funnel']['current'][number];
|
||||
previous: RouterOutputs['chart']['funnel']['current'][number] | null;
|
||||
};
|
||||
noTopBorderRadius?: boolean;
|
||||
};
|
||||
|
||||
export const Metric = ({
|
||||
@@ -50,20 +48,16 @@ export const Metric = ({
|
||||
enhancer?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={cn('gap-1 justify-between flex-1 col', className)}>
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
<div className="row items-center gap-2 justify-between">
|
||||
<div className={cn('col flex-1 justify-between gap-1', className)}>
|
||||
<div className="text-muted-foreground text-sm">{label}</div>
|
||||
<div className="row items-center justify-between gap-2">
|
||||
<div className="font-mono font-semibold">{value}</div>
|
||||
{enhancer && <div>{enhancer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function Summary({
|
||||
data,
|
||||
}: {
|
||||
data: RouterOutputs['chart']['funnel'];
|
||||
}) {
|
||||
export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
const number = useNumber();
|
||||
const highestConversion = data.current
|
||||
.slice(0)
|
||||
@@ -81,10 +75,10 @@ export function Summary({
|
||||
<ChartName breakdowns={highestConversion.breakdowns ?? []} />
|
||||
}
|
||||
/>
|
||||
<span className="text-xl font-semibold font-mono">
|
||||
<span className="font-mono font-semibold text-xl">
|
||||
{number.formatWithUnit(
|
||||
highestConversion.lastStep.percent / 100,
|
||||
'%',
|
||||
'%'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -95,7 +89,7 @@ export function Summary({
|
||||
label="Most conversions"
|
||||
value={<ChartName breakdowns={highestCount.breakdowns ?? []} />}
|
||||
/>
|
||||
<span className="text-xl font-semibold font-mono">
|
||||
<span className="font-mono font-semibold text-xl">
|
||||
{number.format(highestCount.lastStep.count)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -107,7 +101,10 @@ export function Summary({
|
||||
function ChartName({
|
||||
breakdowns,
|
||||
className,
|
||||
}: { breakdowns: string[]; className?: string }) {
|
||||
}: {
|
||||
breakdowns: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 font-medium', className)}>
|
||||
{breakdowns.map((name, index) => {
|
||||
@@ -127,6 +124,7 @@ export function Tables({
|
||||
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
||||
previous: previousData,
|
||||
},
|
||||
noTopBorderRadius,
|
||||
}: Props) {
|
||||
const number = useNumber();
|
||||
const hasHeader = breakdowns.length > 0;
|
||||
@@ -145,11 +143,11 @@ export function Tables({
|
||||
} = useReportChartContext();
|
||||
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
const funnelWindow = funnelOptions?.funnelWindow;
|
||||
const funnelGroup = funnelOptions?.funnelGroup;
|
||||
|
||||
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
||||
if (!projectId || !step.event.id) return;
|
||||
if (!(projectId && step.event.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For funnels, we need to pass the step index so the modal can query
|
||||
// users who completed at least that step in the funnel sequence
|
||||
@@ -172,48 +170,56 @@ export function Tables({
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className={cn('col @container divide-y divide-border card')}>
|
||||
<div
|
||||
className={cn(
|
||||
'col @container card divide-y divide-border',
|
||||
noTopBorderRadius && 'rounded-t-none'
|
||||
)}
|
||||
>
|
||||
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
||||
<div className={cn('bg-def-100', !hasHeader && 'rounded-t-md')}>
|
||||
<div className="col max-md:divide-y md:row md:items-center md:divide-x divide-border">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-def-100',
|
||||
!hasHeader && 'rounded-t-md',
|
||||
noTopBorderRadius && 'rounded-t-none'
|
||||
)}
|
||||
>
|
||||
<div className="col md:row divide-border max-md:divide-y md:items-center md:divide-x">
|
||||
<Metric
|
||||
className="p-4 py-3"
|
||||
label="Conversion"
|
||||
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
||||
enhancer={
|
||||
previousData && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
lastStep?.percent,
|
||||
previousData.lastStep?.percent,
|
||||
previousData.lastStep?.percent
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
label="Conversion"
|
||||
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
||||
/>
|
||||
<Metric
|
||||
className="p-4 py-3"
|
||||
label="Completed"
|
||||
value={number.format(lastStep?.count)}
|
||||
enhancer={
|
||||
previousData && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
lastStep?.count,
|
||||
previousData.lastStep?.count,
|
||||
previousData.lastStep?.count
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
label="Completed"
|
||||
value={number.format(lastStep?.count)}
|
||||
/>
|
||||
{!!mostDropoffsStep && (
|
||||
<Metric
|
||||
className="p-4 py-3"
|
||||
label="Most dropoffs after"
|
||||
value={mostDropoffsStep?.event?.displayName}
|
||||
enhancer={
|
||||
<Tooltiper
|
||||
tooltipClassName="max-w-xs"
|
||||
content={
|
||||
<span>
|
||||
<span className="font-semibold">
|
||||
@@ -223,44 +229,26 @@ export function Tables({
|
||||
conversion rate will likely increase.
|
||||
</span>
|
||||
}
|
||||
tooltipClassName="max-w-xs"
|
||||
>
|
||||
<InfoIcon className="size-3" />
|
||||
</Tooltiper>
|
||||
}
|
||||
label="Most dropoffs after"
|
||||
value={mostDropoffsStep?.event?.displayName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col divide-y divide-def-200">
|
||||
<WidgetTable
|
||||
data={steps}
|
||||
keyExtractor={(item) => item.event.id!}
|
||||
className={'text-sm @container'}
|
||||
className={'@container text-sm'}
|
||||
columnClassName="px-2 group/row items-center"
|
||||
eachRow={(item, index) => {
|
||||
return (
|
||||
<div className="absolute inset-px !p-0">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full bg-def-300 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative',
|
||||
item.isHighestDropoff && [
|
||||
'bg-red-500/20',
|
||||
'group-hover/row:bg-red-500/70',
|
||||
],
|
||||
index === steps.length - 1 && 'rounded-bl-sm',
|
||||
)}
|
||||
style={{
|
||||
width: `${item.percent}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
name: 'Event',
|
||||
render: (item, index) => (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<div className="row relative min-w-0 items-center gap-2">
|
||||
<ColorSquare color={getChartColor(index)}>
|
||||
{alphabetIds[index]}
|
||||
</ColorSquare>
|
||||
@@ -295,17 +283,17 @@ export function Tables({
|
||||
name: '',
|
||||
render: (item) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const stepIndex = steps.findIndex(
|
||||
(s) => s.event.id === item.event.id,
|
||||
(s) => s.event.id === item.event.id
|
||||
);
|
||||
handleInspectStep(item, stepIndex);
|
||||
}}
|
||||
size="sm"
|
||||
title="View users who completed this step"
|
||||
variant="ghost"
|
||||
>
|
||||
<UsersIcon size={16} />
|
||||
</Button>
|
||||
@@ -314,6 +302,27 @@ export function Tables({
|
||||
width: '48px',
|
||||
},
|
||||
]}
|
||||
data={steps}
|
||||
eachRow={(item, index) => {
|
||||
return (
|
||||
<div className="!p-0 absolute inset-px">
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full bg-def-300 transition-colors group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900',
|
||||
item.isHighestDropoff && [
|
||||
'bg-red-500/20',
|
||||
'group-hover/row:bg-red-500/70',
|
||||
],
|
||||
index === steps.length - 1 && 'rounded-bl-sm'
|
||||
)}
|
||||
style={{
|
||||
width: `${item.percent}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
keyExtractor={(item) => item.event.id!}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,9 +372,11 @@ const useRechartData = ({
|
||||
...visibleBreakdowns.reduce((acc, visibleItem, visibleIdx) => {
|
||||
// Find the original index for this visible breakdown
|
||||
const originalIndex = current.findIndex(
|
||||
(item) => item.id === visibleItem.id,
|
||||
(item) => item.id === visibleItem.id
|
||||
);
|
||||
if (originalIndex === -1) return acc;
|
||||
if (originalIndex === -1) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const diff = previous?.[originalIndex];
|
||||
return {
|
||||
@@ -391,6 +402,47 @@ const useRechartData = ({
|
||||
);
|
||||
};
|
||||
|
||||
const StripedBarShape = (props: any) => {
|
||||
const { x, y, width, height, fill, stroke, value } = props;
|
||||
const patternId = `prev-stripes-${(fill || '').replace(/[^a-z0-9]/gi, '')}`;
|
||||
return (
|
||||
<g>
|
||||
<defs>
|
||||
<pattern
|
||||
height="6"
|
||||
id={patternId}
|
||||
patternTransform="rotate(-45)"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="6"
|
||||
>
|
||||
<rect fill="transparent" height="6" width="6" />
|
||||
<rect fill={fill} height="6" width="3" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
fill={`url(#${patternId})`}
|
||||
height={height}
|
||||
rx={3}
|
||||
width={width}
|
||||
x={x}
|
||||
y={y}
|
||||
/>
|
||||
{value > 0 && (
|
||||
<rect
|
||||
fill={stroke}
|
||||
height={2}
|
||||
opacity={0.6}
|
||||
rx={2}
|
||||
stroke="none"
|
||||
width={width}
|
||||
x={x}
|
||||
y={y - 3}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export function Chart({
|
||||
data,
|
||||
visibleBreakdowns,
|
||||
@@ -403,96 +455,162 @@ export function Chart({
|
||||
const yAxisProps = useYAxisProps();
|
||||
const hasBreakdowns = data.current.length > 1;
|
||||
const hasVisibleBreakdowns = visibleBreakdowns.length > 1;
|
||||
const hasPrevious =
|
||||
data.previous !== null &&
|
||||
data.previous !== undefined &&
|
||||
data.previous.length > 0;
|
||||
const showPreviousBars = hasPrevious && !hasBreakdowns;
|
||||
|
||||
const CustomLegend = useCallback(() => {
|
||||
if (!hasVisibleBreakdowns) return null;
|
||||
if (!hasVisibleBreakdowns) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
|
||||
{visibleBreakdowns.map((breakdown, idx) => (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
key={breakdown.id}
|
||||
style={{
|
||||
color: getChartColor(idx),
|
||||
}}
|
||||
>
|
||||
<SerieIcon name={breakdown.breakdowns ?? []} />
|
||||
<SerieName
|
||||
name={
|
||||
breakdown.breakdowns && breakdown.breakdowns.length > 0
|
||||
? breakdown.breakdowns
|
||||
: ['Funnel']
|
||||
}
|
||||
className="font-semibold"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4 -mb-2 flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs">
|
||||
{visibleBreakdowns.map((breakdown, idx) => {
|
||||
const stableIndex = data.current.findIndex((b) => b.id === breakdown.id);
|
||||
const colorIndex = stableIndex >= 0 ? stableIndex : idx;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded px-2 py-1"
|
||||
key={breakdown.id}
|
||||
style={{
|
||||
color: getChartColor(colorIndex),
|
||||
}}
|
||||
>
|
||||
<SerieIcon name={breakdown.breakdowns ?? []} />
|
||||
<SerieName
|
||||
className="font-semibold"
|
||||
name={
|
||||
breakdown.breakdowns && breakdown.breakdowns.length > 0
|
||||
? breakdown.breakdowns
|
||||
: ['Funnel']
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}, [visibleBreakdowns, hasVisibleBreakdowns]);
|
||||
|
||||
const PreviousLegend = useCallback(() => {
|
||||
if (!showPreviousBars) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="mt-4 -mb-2 flex flex-wrap justify-center gap-x-4 gap-y-1.5 text-xs">
|
||||
<div className="flex items-center gap-1.5 rounded px-2 py-1">
|
||||
<div
|
||||
className="h-3 w-3 rounded-[2px]"
|
||||
style={{
|
||||
background: 'rgba(59, 121, 255, 0.3)',
|
||||
borderTop: '2px solid rgba(59, 121, 255, 1)',
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium text-muted-foreground">Current</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded px-2 py-1">
|
||||
<svg height="12" viewBox="0 0 12 12" width="12">
|
||||
<defs>
|
||||
<pattern
|
||||
height="4"
|
||||
id="legend-stripes"
|
||||
patternTransform="rotate(-45)"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="4"
|
||||
>
|
||||
<rect fill="transparent" height="4" width="4" />
|
||||
<rect fill="rgba(59, 121, 255, 0.3)" height="4" width="2" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect fill="url(#legend-stripes)" height="12" rx="2" width="12" />
|
||||
</svg>
|
||||
<span className="font-medium text-muted-foreground">Previous</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [showPreviousBars]);
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
data={data.current}
|
||||
hasBreakdowns={hasBreakdowns}
|
||||
hasPrevious={hasPrevious}
|
||||
visibleBreakdownIds={new Set(visibleBreakdowns.map((b) => b.id))}
|
||||
>
|
||||
<div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
|
||||
<div className="card aspect-video max-h-[250px] w-full p-4 pb-1">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={true}
|
||||
className="stroke-border"
|
||||
horizontal={true}
|
||||
strokeDasharray="3 3"
|
||||
vertical={true}
|
||||
/>
|
||||
<XAxis
|
||||
{...xAxisProps}
|
||||
dataKey="id"
|
||||
allowDuplicatedCategory={false}
|
||||
type={'category'}
|
||||
scale="auto"
|
||||
dataKey="id"
|
||||
domain={undefined}
|
||||
interval="preserveStartEnd"
|
||||
tickSize={0}
|
||||
tickMargin={4}
|
||||
scale="auto"
|
||||
tickFormatter={(id) =>
|
||||
data.current[0].steps.find((step) => step.event.id === id)
|
||||
?.event.displayName ?? ''
|
||||
}
|
||||
tickMargin={4}
|
||||
tickSize={0}
|
||||
type={'category'}
|
||||
/>
|
||||
<YAxis {...yAxisProps} />
|
||||
{hasBreakdowns ? (
|
||||
visibleBreakdowns.map((item, breakdownIndex) => (
|
||||
<Bar
|
||||
key={`step:percent:${item.id}`}
|
||||
dataKey={`step:percent:${breakdownIndex}`}
|
||||
shape={<BarShapeProps />}
|
||||
>
|
||||
{rechartData.map((item, stepIndex) => (
|
||||
<Cell
|
||||
key={`${item.name}-${breakdownIndex}`}
|
||||
fill={getChartTranslucentColor(breakdownIndex)}
|
||||
stroke={getChartColor(breakdownIndex)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
))
|
||||
) : (
|
||||
<Bar
|
||||
data={rechartData}
|
||||
dataKey="step:percent:0"
|
||||
shape={<BarShapeProps />}
|
||||
>
|
||||
{hasBreakdowns &&
|
||||
visibleBreakdowns.map((item, breakdownIndex) => {
|
||||
const stableIndex = data.current.findIndex(
|
||||
(b) => b.id === item.id,
|
||||
);
|
||||
const colorIndex =
|
||||
stableIndex >= 0 ? stableIndex : breakdownIndex;
|
||||
return (
|
||||
<Bar
|
||||
dataKey={`step:percent:${breakdownIndex}`}
|
||||
key={`step:percent:${item.id}`}
|
||||
shape={<BarShapeProps />}
|
||||
>
|
||||
{rechartData.map((row, stepIndex) => (
|
||||
<Cell
|
||||
fill={getChartTranslucentColor(colorIndex)}
|
||||
key={`${row.name}-${breakdownIndex}`}
|
||||
stroke={getChartColor(colorIndex)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
);
|
||||
})}
|
||||
{!hasBreakdowns && (
|
||||
<Bar dataKey="step:percent:0" shape={<BarShapeProps />}>
|
||||
{rechartData.map((item, index) => (
|
||||
<Cell
|
||||
key={item.name}
|
||||
fill={getChartTranslucentColor(index)}
|
||||
key={item.name}
|
||||
stroke={getChartColor(index)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
)}
|
||||
{showPreviousBars && (
|
||||
<Bar dataKey="prev_step:percent:0" shape={<StripedBarShape />}>
|
||||
{rechartData.map((item, index) => (
|
||||
<Cell
|
||||
fill={getChartTranslucentColor(index)}
|
||||
key={`prev-${item.name}`}
|
||||
stroke={getChartColor(index)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
)}
|
||||
{hasVisibleBreakdowns && <Legend content={<CustomLegend />} />}
|
||||
{showPreviousBars && <Legend content={<PreviousLegend />} />}
|
||||
<Tooltip />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -506,27 +624,85 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
{
|
||||
data: RouterOutputs['chart']['funnel']['current'];
|
||||
visibleBreakdownIds: Set<string>;
|
||||
hasPrevious: boolean;
|
||||
hasBreakdowns: boolean;
|
||||
}
|
||||
>(({ data: dataArray, context, ...props }) => {
|
||||
const data = dataArray[0]!;
|
||||
const data = dataArray[0];
|
||||
const number = useNumber();
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
const variants = Object.keys(data).filter((key) =>
|
||||
key.startsWith('step:data:'),
|
||||
key.startsWith('step:data:')
|
||||
) as `step:data:${number}`[];
|
||||
|
||||
const index = context.data[0].steps.findIndex(
|
||||
(step) => step.event.id === (data as any).id,
|
||||
(step) => step.event.id === (data as any).id
|
||||
);
|
||||
|
||||
// Filter variants to only show visible breakdowns
|
||||
// The variant object contains the full breakdown item, so we can check its ID directly
|
||||
const visibleVariants = variants.filter((key) => {
|
||||
const variant = data[key];
|
||||
if (!variant) return false;
|
||||
if (!variant) {
|
||||
return false;
|
||||
}
|
||||
// The variant is the breakdown item itself (with step added), so it has an id property
|
||||
return context.visibleBreakdownIds.has(variant.id);
|
||||
});
|
||||
|
||||
if (!context.hasBreakdowns && context.hasPrevious) {
|
||||
const currentVariant = data['step:data:0'];
|
||||
const previousVariant = data['prev_step:data:0'];
|
||||
|
||||
if (!currentVariant?.step) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metric = getPreviousMetric(
|
||||
currentVariant.step.percent,
|
||||
previousVariant?.step.percent
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-muted-foreground">{data.name}</div>
|
||||
<div className="col gap-1.5">
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span className="text-muted-foreground">Current</span>
|
||||
<span>
|
||||
{number.format(currentVariant.step.count)} (
|
||||
{number.formatWithUnit(currentVariant.step.percent / 100, '%')})
|
||||
</span>
|
||||
</div>
|
||||
{previousVariant?.step && (
|
||||
<div className="flex justify-between gap-8 font-medium font-mono text-muted-foreground">
|
||||
<span>Previous</span>
|
||||
<span>
|
||||
{number.format(previousVariant.step.count)} (
|
||||
{number.formatWithUnit(previousVariant.step.percent / 100, '%')}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{metric && metric.diff != null && (
|
||||
<div className="mt-0.5 flex items-center justify-between gap-8 border-border border-t pt-1.5">
|
||||
<span className="font-medium text-sm">
|
||||
{metric.state === 'positive'
|
||||
? 'Improvement'
|
||||
: metric.state === 'negative'
|
||||
? 'Decline'
|
||||
: 'No change'}
|
||||
</span>
|
||||
<PreviousDiffIndicatorPure {...metric} size="xs" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
@@ -538,30 +714,33 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
if (!variant?.step) {
|
||||
return null;
|
||||
}
|
||||
// Find the original breakdown index for color
|
||||
// Find the original breakdown index for color (matches chart bar order)
|
||||
const originalBreakdownIndex = context.data.findIndex(
|
||||
(b) => b.id === variant.id,
|
||||
(b) => b.id === variant.id
|
||||
);
|
||||
let colorIndex = index;
|
||||
if (visibleVariants.length > 1) {
|
||||
colorIndex =
|
||||
originalBreakdownIndex >= 0 ? originalBreakdownIndex : visibleIndex;
|
||||
}
|
||||
return (
|
||||
<div className="row gap-2" key={key}>
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
className="w-[3px] rounded-full shrink-0"
|
||||
style={{
|
||||
background: getChartColor(
|
||||
visibleVariants.length > 1 ? visibleIndex : index,
|
||||
),
|
||||
background: getChartColor(colorIndex),
|
||||
}}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="col flex-1 gap-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<ChartName breakdowns={variant.breakdowns ?? []} />
|
||||
</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="col gap-1">
|
||||
<div className="flex items-center justify-between gap-4 font-mono font-medium">
|
||||
<div className="col gap-0.5">
|
||||
<span>
|
||||
{number.formatWithUnit(variant.step.percent / 100, '%')}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
({number.format(variant.step.count)})
|
||||
</span>
|
||||
</div>
|
||||
@@ -569,8 +748,9 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
variant.step.percent,
|
||||
prevVariant?.step.percent,
|
||||
prevVariant?.step.percent
|
||||
)}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import type { IServiceReport } from '@openpanel/db';
|
||||
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import EditReportName from '../report/edit-report-name';
|
||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||
@@ -14,18 +17,13 @@ import {
|
||||
setReport,
|
||||
} from '@/components/report/reportSlice';
|
||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { TimeWindowPicker } from '@/components/time-window-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { IServiceReport } from '@openpanel/db';
|
||||
import EditReportName from '../report/edit-report-name';
|
||||
|
||||
interface ReportEditorProps {
|
||||
report: IServiceReport | null;
|
||||
@@ -54,15 +52,15 @@ export default function ReportEditor({
|
||||
return (
|
||||
<Sheet>
|
||||
<div>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<EditReportName />
|
||||
{initialReport?.id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={ShareIcon}
|
||||
onClick={() =>
|
||||
pushModal('ShareReportModal', { reportId: initialReport.id })
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
@@ -71,9 +69,9 @@ export default function ReportEditor({
|
||||
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
className="self-start"
|
||||
icon={GanttChartSquareIcon}
|
||||
variant="cta"
|
||||
className="self-start"
|
||||
>
|
||||
Pick events
|
||||
</Button>
|
||||
@@ -88,23 +86,26 @@ export default function ReportEditor({
|
||||
/>
|
||||
<TimeWindowPicker
|
||||
className="min-w-0 flex-1"
|
||||
endDate={report.endDate}
|
||||
onChange={(value) => {
|
||||
dispatch(changeDateRanges(value));
|
||||
}}
|
||||
value={report.range}
|
||||
onStartDateChange={(date) => dispatch(changeStartDate(date))}
|
||||
onEndDateChange={(date) => dispatch(changeEndDate(date))}
|
||||
endDate={report.endDate}
|
||||
onIntervalChange={(interval) =>
|
||||
dispatch(changeInterval(interval))
|
||||
}
|
||||
onStartDateChange={(date) => dispatch(changeStartDate(date))}
|
||||
startDate={report.startDate}
|
||||
value={report.range}
|
||||
/>
|
||||
<ReportInterval
|
||||
chartType={report.chartType}
|
||||
className="min-w-0 flex-1"
|
||||
endDate={report.endDate}
|
||||
interval={report.interval}
|
||||
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
|
||||
range={report.range}
|
||||
chartType={report.chartType}
|
||||
startDate={report.startDate}
|
||||
endDate={report.endDate}
|
||||
/>
|
||||
<ReportLineType className="min-w-0 flex-1" />
|
||||
</div>
|
||||
@@ -114,7 +115,7 @@ export default function ReportEditor({
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 p-4" id="report-editor">
|
||||
{report.ready && (
|
||||
<ReportChart report={{ ...report, projectId }} isEditMode />
|
||||
<ReportChart isEditMode report={{ ...report, projectId }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
LayoutDashboardIcon,
|
||||
LayoutPanelTopIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
TrendingUpDownIcon,
|
||||
UndoDotIcon,
|
||||
UserCircleIcon,
|
||||
UsersIcon,
|
||||
WallpaperIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -55,10 +57,11 @@ export default function SidebarProjectMenu({
|
||||
label="Insights"
|
||||
/>
|
||||
<SidebarLink href={'/pages'} icon={LayersIcon} label="Pages" />
|
||||
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
|
||||
<SidebarLink href={'/realtime'} icon={Globe2Icon} label="Realtime" />
|
||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
||||
<SidebarLink href={'/profiles'} icon={UsersIcon} label="Profiles" />
|
||||
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
|
||||
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
|
||||
Manage
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { timeWindows } from '@openpanel/constants';
|
||||
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { endOfDay, format, startOfDay } from 'date-fns';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -11,24 +17,18 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { pushModal, useOnPushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { shouldIgnoreKeypress } from '@/utils/should-ignore-keypress';
|
||||
import { timeWindows } from '@openpanel/constants';
|
||||
import type { IChartRange } from '@openpanel/validation';
|
||||
import { endOfDay, format, startOfDay } from 'date-fns';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
value: IChartRange;
|
||||
onChange: (value: IChartRange) => void;
|
||||
onStartDateChange: (date: string) => void;
|
||||
onEndDateChange: (date: string) => void;
|
||||
onIntervalChange: (interval: IInterval) => void;
|
||||
endDate: string | null;
|
||||
startDate: string | null;
|
||||
className?: string;
|
||||
};
|
||||
}
|
||||
export function TimeWindowPicker({
|
||||
value,
|
||||
onChange,
|
||||
@@ -36,6 +36,7 @@ export function TimeWindowPicker({
|
||||
onStartDateChange,
|
||||
endDate,
|
||||
onEndDateChange,
|
||||
onIntervalChange,
|
||||
className,
|
||||
}: Props) {
|
||||
const isDateRangerPickerOpen = useRef(false);
|
||||
@@ -46,10 +47,11 @@ export function TimeWindowPicker({
|
||||
|
||||
const handleCustom = useCallback(() => {
|
||||
pushModal('DateRangerPicker', {
|
||||
onChange: ({ startDate, endDate }) => {
|
||||
onChange: ({ startDate, endDate, interval }) => {
|
||||
onStartDateChange(format(startOfDay(startDate), 'yyyy-MM-dd HH:mm:ss'));
|
||||
onEndDateChange(format(endOfDay(endDate), 'yyyy-MM-dd HH:mm:ss'));
|
||||
onChange('custom');
|
||||
onIntervalChange(interval);
|
||||
},
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
@@ -69,7 +71,7 @@ export function TimeWindowPicker({
|
||||
}
|
||||
|
||||
const match = Object.values(timeWindows).find(
|
||||
(tw) => event.key === tw.shortcut.toLowerCase(),
|
||||
(tw) => event.key === tw.shortcut.toLowerCase()
|
||||
);
|
||||
if (match?.key === 'custom') {
|
||||
handleCustom();
|
||||
@@ -84,9 +86,9 @@ export function TimeWindowPicker({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={CalendarIcon}
|
||||
className={cn('justify-start', className)}
|
||||
icon={CalendarIcon}
|
||||
variant="outline"
|
||||
>
|
||||
{timeWindow?.label}
|
||||
</Button>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
} from 'react-day-picker';
|
||||
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -29,99 +28,93 @@ function Calendar({
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
captionLayout={captionLayout}
|
||||
className={cn(
|
||||
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
'group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn(
|
||||
'flex gap-4 flex-col sm:flex-row relative',
|
||||
defaultClassNames.months,
|
||||
'relative flex flex-col gap-4 sm:flex-row',
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
|
||||
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
|
||||
defaultClassNames.nav,
|
||||
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_previous,
|
||||
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_next,
|
||||
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
|
||||
defaultClassNames.month_caption,
|
||||
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
|
||||
defaultClassNames.dropdowns,
|
||||
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm',
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
|
||||
defaultClassNames.dropdown_root,
|
||||
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
'absolute bg-popover inset-0 opacity-0',
|
||||
defaultClassNames.dropdown,
|
||||
'absolute inset-0 bg-popover opacity-0',
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
'select-none font-medium',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
||||
defaultClassNames.caption_label,
|
||||
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
||||
defaultClassNames.weekday,
|
||||
'flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground',
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn('flex w-full mt-2', defaultClassNames.week),
|
||||
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
'select-none w-(--cell-size)',
|
||||
defaultClassNames.week_number_header,
|
||||
'w-(--cell-size) select-none',
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
'text-[0.8rem] select-none text-muted-foreground',
|
||||
defaultClassNames.week_number,
|
||||
'select-none text-[0.8rem] text-muted-foreground',
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
|
||||
defaultClassNames.day,
|
||||
'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
'rounded-l-md bg-accent',
|
||||
defaultClassNames.range_start,
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||
today: cn(
|
||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today,
|
||||
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||
defaultClassNames.outside,
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
'text-muted-foreground opacity-50',
|
||||
defaultClassNames.disabled,
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
@@ -130,9 +123,9 @@ function Calendar({
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -169,6 +162,12 @@ function Calendar({
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
showOutsideDays={showOutsideDays}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -184,29 +183,31 @@ function CalendarDayButton({
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
if (modifiers.focused) {
|
||||
ref.current?.focus();
|
||||
}
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md data-[range-end=true]:bg-primary data-[range-middle=true]:bg-accent data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-accent-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface DataTableProps<TData> {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
onRowClick?: (row: import('@tanstack/react-table').Row<TData>) => void;
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
@@ -35,6 +36,7 @@ export function DataTable<TData>({
|
||||
table,
|
||||
loading,
|
||||
className,
|
||||
onRowClick,
|
||||
empty = {
|
||||
title: 'No data',
|
||||
description: 'We could not find any data here yet',
|
||||
@@ -78,6 +80,8 @@ export function DataTable<TData>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={onRowClick ? 'cursor-pointer' : undefined}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getISOWeek } from 'date-fns';
|
||||
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
|
||||
export function formatDateInterval(options: {
|
||||
@@ -8,15 +10,19 @@ export function formatDateInterval(options: {
|
||||
const { interval, date, short } = options;
|
||||
try {
|
||||
if (interval === 'hour' || interval === 'minute') {
|
||||
if (short) {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
...(!short
|
||||
? {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}
|
||||
: {}),
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
@@ -25,6 +31,9 @@ export function formatDateInterval(options: {
|
||||
}
|
||||
|
||||
if (interval === 'week') {
|
||||
if (short) {
|
||||
return `W${getISOWeek(date)}`;
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
@@ -33,6 +42,12 @@ export function formatDateInterval(options: {
|
||||
}
|
||||
|
||||
if (interval === 'day') {
|
||||
if (short) {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
@@ -41,7 +56,7 @@ export function formatDateInterval(options: {
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { getDefaultIntervalByDates } from '@openpanel/constants';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { endOfDay, subMonths } from 'date-fns';
|
||||
import { CheckIcon, XIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent } from './Modal/Container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { subMonths } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { CheckIcon, XIcon } from 'lucide-react';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
type Props = {
|
||||
onChange: (payload: { startDate: Date; endDate: Date }) => void;
|
||||
interface Props {
|
||||
onChange: (payload: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
interval: IInterval;
|
||||
}) => void;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
};
|
||||
}
|
||||
export default function DateRangerPicker({
|
||||
onChange,
|
||||
startDate: initialStartDate,
|
||||
@@ -25,20 +29,20 @@ export default function DateRangerPicker({
|
||||
const [endDate, setEndDate] = useState(initialEndDate);
|
||||
|
||||
return (
|
||||
<ModalContent className="p-4 md:p-8 min-w-fit">
|
||||
<ModalContent className="min-w-fit p-4 md:p-8">
|
||||
<Calendar
|
||||
captionLayout="dropdown"
|
||||
initialFocus
|
||||
mode="range"
|
||||
className="mx-auto min-h-[310px] p-0 [&_table]:mx-auto [&_table]:w-auto"
|
||||
defaultMonth={subMonths(
|
||||
startDate ? new Date(startDate) : new Date(),
|
||||
isBelowSm ? 0 : 1,
|
||||
isBelowSm ? 0 : 1
|
||||
)}
|
||||
selected={{
|
||||
from: startDate,
|
||||
to: endDate,
|
||||
hidden={{
|
||||
after: endOfDay(new Date()),
|
||||
}}
|
||||
toDate={new Date()}
|
||||
initialFocus
|
||||
mode="range"
|
||||
numberOfMonths={isBelowSm ? 1 : 2}
|
||||
onSelect={(range) => {
|
||||
if (range?.from) {
|
||||
setStartDate(range.from);
|
||||
@@ -47,33 +51,39 @@ export default function DateRangerPicker({
|
||||
setEndDate(range.to);
|
||||
}
|
||||
}}
|
||||
numberOfMonths={isBelowSm ? 1 : 2}
|
||||
className="mx-auto min-h-[310px] [&_table]:mx-auto [&_table]:w-auto p-0"
|
||||
selected={{
|
||||
from: startDate,
|
||||
to: endDate,
|
||||
}}
|
||||
/>
|
||||
<div className="col flex-col-reverse md:row gap-2">
|
||||
<div className="col md:row flex-col-reverse gap-2">
|
||||
<Button
|
||||
icon={XIcon}
|
||||
onClick={() => popModal()}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => popModal()}
|
||||
icon={XIcon}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{startDate && endDate && (
|
||||
<Button
|
||||
type="button"
|
||||
className="md:ml-auto"
|
||||
icon={startDate && endDate ? CheckIcon : XIcon}
|
||||
onClick={() => {
|
||||
popModal();
|
||||
if (startDate && endDate) {
|
||||
onChange({
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
interval: getDefaultIntervalByDates(
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString()
|
||||
)!,
|
||||
});
|
||||
}
|
||||
}}
|
||||
icon={startDate && endDate ? CheckIcon : XIcon}
|
||||
type="button"
|
||||
>
|
||||
{startDate && endDate
|
||||
? `Select ${formatDate(startDate)} - ${formatDate(endDate)}`
|
||||
|
||||
440
apps/start/src/modals/gsc-details.tsx
Normal file
440
apps/start/src/modals/gsc-details.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface GscChartData {
|
||||
date: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
}
|
||||
interface GscViewsChartData {
|
||||
date: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
|
||||
GscChartData | GscViewsChartData,
|
||||
Record<string, unknown>
|
||||
>(({ data }) => {
|
||||
const item = data[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
if (!('date' in item)) {
|
||||
return null;
|
||||
}
|
||||
if ('views' in item && item.views != null) {
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{item.date}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(0)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Views</span>
|
||||
<span>{item.views.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const clicks = 'clicks' in item ? item.clicks : undefined;
|
||||
const impressions = 'impressions' in item ? item.impressions : undefined;
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{item.date}</div>
|
||||
</ChartTooltipHeader>
|
||||
{clicks != null && (
|
||||
<ChartTooltipItem color={getChartColor(0)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Clicks</span>
|
||||
<span>{clicks.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
)}
|
||||
{impressions != null && (
|
||||
<ChartTooltipItem color={getChartColor(1)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Impressions</span>
|
||||
<span>{impressions.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
type Props =
|
||||
| {
|
||||
type: 'page';
|
||||
projectId: string;
|
||||
value: string;
|
||||
range: IChartRange;
|
||||
interval: IInterval;
|
||||
}
|
||||
| {
|
||||
type: 'query';
|
||||
projectId: string;
|
||||
value: string;
|
||||
range: IChartRange;
|
||||
interval: IInterval;
|
||||
};
|
||||
|
||||
export default function GscDetails(props: Props) {
|
||||
const { type, projectId, value, range, interval } = props;
|
||||
const trpc = useTRPC();
|
||||
|
||||
const dateInput = {
|
||||
range,
|
||||
interval,
|
||||
};
|
||||
|
||||
const pageQuery = useQuery(
|
||||
trpc.gsc.getPageDetails.queryOptions(
|
||||
{ projectId, page: value, ...dateInput },
|
||||
{ enabled: type === 'page' }
|
||||
)
|
||||
);
|
||||
|
||||
const queryQuery = useQuery(
|
||||
trpc.gsc.getQueryDetails.queryOptions(
|
||||
{ projectId, query: value, ...dateInput },
|
||||
{ enabled: type === 'query' }
|
||||
)
|
||||
);
|
||||
|
||||
const { origin: pageOrigin, path: pagePath } =
|
||||
type === 'page'
|
||||
? (() => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return { origin: url.origin, path: url.pathname + url.search };
|
||||
} catch {
|
||||
return {
|
||||
origin: typeof window !== 'undefined' ? window.location.origin : '',
|
||||
path: value,
|
||||
};
|
||||
}
|
||||
})()
|
||||
: { origin: '', path: '' };
|
||||
|
||||
const pageTimeseriesQuery = useQuery(
|
||||
trpc.event.pageTimeseries.queryOptions(
|
||||
{ projectId, ...dateInput, origin: pageOrigin, path: pagePath },
|
||||
{ enabled: type === 'page' && !!pagePath }
|
||||
)
|
||||
);
|
||||
|
||||
const data = type === 'page' ? pageQuery.data : queryQuery.data;
|
||||
const isLoading =
|
||||
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||
|
||||
const timeseries = data?.timeseries ?? [];
|
||||
const pageTimeseries = pageTimeseriesQuery.data ?? [];
|
||||
const breakdownRows =
|
||||
type === 'page'
|
||||
? ((data as { queries?: unknown[] } | undefined)?.queries ?? [])
|
||||
: ((data as { pages?: unknown[] } | undefined)?.pages ?? []);
|
||||
|
||||
const breakdownKey = type === 'page' ? 'query' : 'page';
|
||||
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
|
||||
|
||||
const maxClicks = Math.max(
|
||||
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
|
||||
1
|
||||
);
|
||||
|
||||
return (
|
||||
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
|
||||
{value}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="col gap-6">
|
||||
{type === 'page' && (
|
||||
<div className="card p-4">
|
||||
<h3 className="mb-4 font-medium text-sm">Views & Sessions</h3>
|
||||
{isLoading || pageTimeseriesQuery.isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<GscViewsChart
|
||||
data={pageTimeseries.map((r) => ({
|
||||
date: r.date,
|
||||
views: r.pageviews,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-4">
|
||||
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<GscTimeseriesChart data={timeseries} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-medium text-sm">
|
||||
Top {breakdownLabel.toLowerCase()}s
|
||||
</h3>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<OverviewWidgetTable
|
||||
columns={[
|
||||
{
|
||||
name: breakdownLabel,
|
||||
width: 'w-full',
|
||||
render: () => <Skeleton className="h-4 w-2/3" />,
|
||||
},
|
||||
{
|
||||
name: 'Clicks',
|
||||
width: '70px',
|
||||
render: () => <Skeleton className="h-4 w-10" />,
|
||||
},
|
||||
{
|
||||
name: 'Pos.',
|
||||
width: '55px',
|
||||
render: () => <Skeleton className="h-4 w-8" />,
|
||||
},
|
||||
]}
|
||||
data={[1, 2, 3, 4, 5]}
|
||||
getColumnPercentage={() => 0}
|
||||
keyExtractor={(i) => String(i)}
|
||||
/>
|
||||
) : (
|
||||
<OverviewWidgetTable
|
||||
columns={[
|
||||
{
|
||||
name: breakdownLabel,
|
||||
width: 'w-full',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<span className="block truncate font-mono text-xs">
|
||||
{String(item[breakdownKey])}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clicks',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.clicks as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.clicks as number).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Impr.',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.impressions as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.impressions as number).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CTR',
|
||||
width: '60px',
|
||||
getSortValue: (item) => item.ctr as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{((item.ctr as number) * 100).toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pos.',
|
||||
width: '55px',
|
||||
getSortValue: (item) => item.position as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.position as number).toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={breakdownRows as Record<string, string | number>[]}
|
||||
getColumnPercentage={(item) =>
|
||||
(item.clicks as number) / maxClicks
|
||||
}
|
||||
keyExtractor={(item) => String(item[breakdownKey])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
);
|
||||
}
|
||||
|
||||
function GscViewsChart({
|
||||
data,
|
||||
}: {
|
||||
data: Array<{ date: string; views: number }>;
|
||||
}) {
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="gsc-detail-glow"
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
>
|
||||
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA slope="0.5" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
{...X_AXIS_STYLE_PROPS}
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
type="category"
|
||||
/>
|
||||
<YAxis {...yAxisProps} yAxisId="left" />
|
||||
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||
<GscTooltip />
|
||||
<Line
|
||||
dataKey="views"
|
||||
dot={false}
|
||||
filter="url(#gsc-detail-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="left"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function GscTimeseriesChart({
|
||||
data,
|
||||
}: {
|
||||
data: Array<{ date: string; clicks: number; impressions: number }>;
|
||||
}) {
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="gsc-detail-glow"
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
>
|
||||
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA slope="0.5" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
{...X_AXIS_STYLE_PROPS}
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
type="category"
|
||||
/>
|
||||
<YAxis {...yAxisProps} yAxisId="left" />
|
||||
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||
<GscTooltip />
|
||||
<Line
|
||||
dataKey="clicks"
|
||||
dot={false}
|
||||
filter="url(#gsc-detail-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="left"
|
||||
/>
|
||||
<Line
|
||||
dataKey="impressions"
|
||||
dot={false}
|
||||
filter="url(#gsc-detail-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="right"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import PageDetails from './page-details';
|
||||
import { createPushModal } from 'pushmodal';
|
||||
import AddClient from './add-client';
|
||||
import AddDashboard from './add-dashboard';
|
||||
@@ -34,6 +35,7 @@ import OverviewTopPagesModal from '@/components/overview/overview-top-pages-moda
|
||||
import { op } from '@/utils/op';
|
||||
|
||||
const modals = {
|
||||
PageDetails,
|
||||
OverviewTopPagesModal,
|
||||
OverviewTopGenericModal,
|
||||
RequestPasswordReset,
|
||||
|
||||
49
apps/start/src/modals/page-details.tsx
Normal file
49
apps/start/src/modals/page-details.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { GscBreakdownTable } from '@/components/page/gsc-breakdown-table';
|
||||
import { GscClicksChart } from '@/components/page/gsc-clicks-chart';
|
||||
import { PageViewsChart } from '@/components/page/page-views-chart';
|
||||
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
|
||||
type Props = {
|
||||
type: 'page' | 'query';
|
||||
projectId: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export default function PageDetails({ type, projectId, value }: Props) {
|
||||
return (
|
||||
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
|
||||
{value}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="col gap-6">
|
||||
{type === 'page' &&
|
||||
(() => {
|
||||
let origin: string;
|
||||
let path: string;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
origin = url.origin;
|
||||
path = url.pathname + url.search;
|
||||
} catch {
|
||||
// value is path-only (e.g. "/docs/foo")
|
||||
origin =
|
||||
typeof window !== 'undefined' ? window.location.origin : '';
|
||||
path = value;
|
||||
}
|
||||
return (
|
||||
<PageViewsChart
|
||||
origin={origin}
|
||||
path={path}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<GscClicksChart projectId={projectId} type={type} value={value} />
|
||||
<GscBreakdownTable projectId={projectId} type={type} value={value} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.
|
||||
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
|
||||
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
|
||||
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
|
||||
import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo'
|
||||
import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports'
|
||||
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
|
||||
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
||||
@@ -71,6 +72,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from '.
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsGscRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.gsc'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
|
||||
@@ -312,6 +314,12 @@ const AppOrganizationIdProjectIdSessionsRoute =
|
||||
path: '/sessions',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdSeoRoute =
|
||||
AppOrganizationIdProjectIdSeoRouteImport.update({
|
||||
id: '/seo',
|
||||
path: '/seo',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdReportsRoute =
|
||||
AppOrganizationIdProjectIdReportsRouteImport.update({
|
||||
id: '/reports',
|
||||
@@ -488,6 +496,12 @@ const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
|
||||
path: '/imports',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdSettingsTabsGscRoute =
|
||||
AppOrganizationIdProjectIdSettingsTabsGscRouteImport.update({
|
||||
id: '/gsc',
|
||||
path: '/gsc',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
|
||||
id: '/events',
|
||||
@@ -606,6 +620,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
||||
@@ -640,6 +655,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
@@ -677,6 +693,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
|
||||
@@ -708,6 +725,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
@@ -747,6 +765,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||
'/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
|
||||
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||
@@ -789,6 +808,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
@@ -830,6 +850,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
| '/$organizationId/$projectId/references'
|
||||
| '/$organizationId/$projectId/reports'
|
||||
| '/$organizationId/$projectId/seo'
|
||||
| '/$organizationId/$projectId/sessions'
|
||||
| '/$organizationId/integrations'
|
||||
| '/$organizationId/members'
|
||||
@@ -864,6 +885,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/settings/clients'
|
||||
| '/$organizationId/$projectId/settings/details'
|
||||
| '/$organizationId/$projectId/settings/events'
|
||||
| '/$organizationId/$projectId/settings/gsc'
|
||||
| '/$organizationId/$projectId/settings/imports'
|
||||
| '/$organizationId/$projectId/settings/tracking'
|
||||
| '/$organizationId/$projectId/settings/widgets'
|
||||
@@ -901,6 +923,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
| '/$organizationId/$projectId/references'
|
||||
| '/$organizationId/$projectId/reports'
|
||||
| '/$organizationId/$projectId/seo'
|
||||
| '/$organizationId/$projectId/sessions'
|
||||
| '/$organizationId/integrations'
|
||||
| '/$organizationId/members'
|
||||
@@ -932,6 +955,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/settings/clients'
|
||||
| '/$organizationId/$projectId/settings/details'
|
||||
| '/$organizationId/$projectId/settings/events'
|
||||
| '/$organizationId/$projectId/settings/gsc'
|
||||
| '/$organizationId/$projectId/settings/imports'
|
||||
| '/$organizationId/$projectId/settings/tracking'
|
||||
| '/$organizationId/$projectId/settings/widgets'
|
||||
@@ -970,6 +994,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/realtime'
|
||||
| '/_app/$organizationId/$projectId/references'
|
||||
| '/_app/$organizationId/$projectId/reports'
|
||||
| '/_app/$organizationId/$projectId/seo'
|
||||
| '/_app/$organizationId/$projectId/sessions'
|
||||
| '/_app/$organizationId/integrations'
|
||||
| '/_app/$organizationId/integrations/_tabs'
|
||||
@@ -1012,6 +1037,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/details'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
||||
@@ -1310,6 +1336,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/seo': {
|
||||
id: '/_app/$organizationId/$projectId/seo'
|
||||
path: '/seo'
|
||||
fullPath: '/$organizationId/$projectId/seo'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSeoRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/reports': {
|
||||
id: '/_app/$organizationId/$projectId/reports'
|
||||
path: '/reports'
|
||||
@@ -1520,6 +1553,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/gsc': {
|
||||
id: '/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||
path: '/gsc'
|
||||
fullPath: '/$organizationId/$projectId/settings/gsc'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/events': {
|
||||
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||
path: '/events'
|
||||
@@ -1785,6 +1825,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
|
||||
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsGscRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
@@ -1799,6 +1840,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
|
||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsGscRoute:
|
||||
AppOrganizationIdProjectIdSettingsTabsGscRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
|
||||
@@ -1837,6 +1880,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
|
||||
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
|
||||
AppOrganizationIdProjectIdSeoRoute: typeof AppOrganizationIdProjectIdSeoRoute
|
||||
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
|
||||
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
||||
@@ -1862,6 +1906,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
||||
AppOrganizationIdProjectIdReferencesRoute,
|
||||
AppOrganizationIdProjectIdReportsRoute:
|
||||
AppOrganizationIdProjectIdReportsRoute,
|
||||
AppOrganizationIdProjectIdSeoRoute: AppOrganizationIdProjectIdSeoRoute,
|
||||
AppOrganizationIdProjectIdSessionsRoute:
|
||||
AppOrganizationIdProjectIdSessionsRoute,
|
||||
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,
|
||||
|
||||
@@ -42,5 +42,5 @@ function Component() {
|
||||
),
|
||||
);
|
||||
|
||||
return <EventsTable query={query} />;
|
||||
return <EventsTable query={query} showEventListener />;
|
||||
}
|
||||
|
||||
@@ -1,349 +1,22 @@
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { PagesTable } from '@/components/pages/table';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { FloatingPagination } from '@/components/pagination-floating';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TableButtons } from '@/components/ui/table';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
|
||||
component: Component,
|
||||
head: () => {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: createProjectTitle(PAGE_TITLES.PAGES),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle(PAGE_TITLES.PAGES) }],
|
||||
}),
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const take = 20;
|
||||
const { range, interval } = useOverviewOptions();
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
'cursor',
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
|
||||
const { debouncedSearch, setSearch, search } = useSearchQueryState();
|
||||
|
||||
// Track if we should use backend search (when client-side filtering finds nothing)
|
||||
const [useBackendSearch, setUseBackendSearch] = useState(false);
|
||||
|
||||
// Reset to client-side filtering when search changes
|
||||
useEffect(() => {
|
||||
setUseBackendSearch(false);
|
||||
setCursor(1);
|
||||
}, [debouncedSearch, setCursor]);
|
||||
|
||||
// Query for all pages (without search) - used for client-side filtering
|
||||
const allPagesQuery = useQuery(
|
||||
trpc.event.pages.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
cursor: 1,
|
||||
take: 1000,
|
||||
search: undefined, // No search - get all pages
|
||||
range,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Query for backend search (only when client-side filtering finds nothing)
|
||||
const backendSearchQuery = useQuery(
|
||||
trpc.event.pages.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
cursor: 1,
|
||||
take: 1000,
|
||||
search: debouncedSearch || undefined,
|
||||
range,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: useBackendSearch && !!debouncedSearch,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Client-side filtering: filter all pages by search query
|
||||
const clientSideFiltered = useMemo(() => {
|
||||
if (!debouncedSearch || useBackendSearch) {
|
||||
return allPagesQuery.data ?? [];
|
||||
}
|
||||
const searchLower = debouncedSearch.toLowerCase();
|
||||
return (allPagesQuery.data ?? []).filter(
|
||||
(page) =>
|
||||
page.path.toLowerCase().includes(searchLower) ||
|
||||
page.origin.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}, [allPagesQuery.data, debouncedSearch, useBackendSearch]);
|
||||
|
||||
// Check if client-side filtering found results
|
||||
useEffect(() => {
|
||||
if (
|
||||
debouncedSearch &&
|
||||
!useBackendSearch &&
|
||||
allPagesQuery.isSuccess &&
|
||||
clientSideFiltered.length === 0
|
||||
) {
|
||||
// No results from client-side filtering, switch to backend search
|
||||
setUseBackendSearch(true);
|
||||
}
|
||||
}, [
|
||||
debouncedSearch,
|
||||
useBackendSearch,
|
||||
allPagesQuery.isSuccess,
|
||||
clientSideFiltered.length,
|
||||
]);
|
||||
|
||||
// Determine which data source to use
|
||||
const allData = useBackendSearch
|
||||
? (backendSearchQuery.data ?? [])
|
||||
: clientSideFiltered;
|
||||
|
||||
const isLoading = useBackendSearch
|
||||
? backendSearchQuery.isLoading
|
||||
: allPagesQuery.isLoading;
|
||||
|
||||
// Client-side pagination: slice the items based on cursor
|
||||
const startIndex = (cursor - 1) * take;
|
||||
const endIndex = startIndex + take;
|
||||
const data = allData.slice(startIndex, endIndex);
|
||||
const totalPages = Math.ceil(allData.length / take);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Pages"
|
||||
description="Access all your pages here"
|
||||
className="mb-8"
|
||||
/>
|
||||
<TableButtons>
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
<Input
|
||||
className="self-auto"
|
||||
placeholder="Search path"
|
||||
value={search ?? ''}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setCursor(1);
|
||||
}}
|
||||
/>
|
||||
</TableButtons>
|
||||
{data.length === 0 && !isLoading && (
|
||||
<FullPageEmptyState
|
||||
title="No pages"
|
||||
description={
|
||||
debouncedSearch
|
||||
? `No pages found matching "${debouncedSearch}"`
|
||||
: 'Integrate our web sdk to your site to get pages here.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<PageCardSkeleton />
|
||||
<PageCardSkeleton />
|
||||
<PageCardSkeleton />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.map((page) => {
|
||||
return (
|
||||
<PageCard
|
||||
key={page.origin + page.path}
|
||||
page={page}
|
||||
range={range}
|
||||
interval={interval}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{allData.length !== 0 && (
|
||||
<div className="p-4">
|
||||
<FloatingPagination
|
||||
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
|
||||
canNextPage={cursor < totalPages}
|
||||
canPreviousPage={cursor > 1}
|
||||
pageIndex={cursor - 1}
|
||||
nextPage={() => {
|
||||
setCursor((p) => Math.min(p + 1, totalPages));
|
||||
}}
|
||||
previousPage={() => {
|
||||
setCursor((p) => Math.max(p - 1, 1));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PageHeader title="Pages" description="Access all your pages here" className="mb-8" />
|
||||
<PagesTable projectId={projectId} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const PageCard = memo(
|
||||
({
|
||||
page,
|
||||
range,
|
||||
interval,
|
||||
projectId,
|
||||
}: {
|
||||
page: RouterOutputs['event']['pages'][number];
|
||||
range: IChartRange;
|
||||
interval: IInterval;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const number = useNumber();
|
||||
const { apiUrl } = useAppContext();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="row gap-4 justify-between p-4 py-2 items-center">
|
||||
<div className="row gap-2 items-center h-16">
|
||||
<img
|
||||
src={`${apiUrl}/misc/og?url=${page.origin}${page.path}`}
|
||||
alt={page.title}
|
||||
className="size-10 rounded-sm object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="col min-w-0">
|
||||
<div className="font-medium leading-[28px] truncate">
|
||||
{page.title}
|
||||
</div>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`${page.origin}${page.path}`}
|
||||
className="text-muted-foreground font-mono truncate hover:underline"
|
||||
>
|
||||
{page.path}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row border-y">
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<div className="font-medium text-xl font-mono">
|
||||
{number.formatWithUnit(page.avg_duration, 'min')}
|
||||
</div>
|
||||
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
||||
duration
|
||||
</div>
|
||||
</div>
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<div className="font-medium text-xl font-mono">
|
||||
{number.formatWithUnit(page.bounce_rate / 100, '%')}
|
||||
</div>
|
||||
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
||||
bounce rate
|
||||
</div>
|
||||
</div>
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<div className="font-medium text-xl font-mono">
|
||||
{number.format(page.sessions)}
|
||||
</div>
|
||||
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
||||
sessions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReportChart
|
||||
options={{
|
||||
hideXAxis: true,
|
||||
hideYAxis: true,
|
||||
aspectRatio: 0.15,
|
||||
}}
|
||||
report={{
|
||||
breakdowns: [],
|
||||
metric: 'sum',
|
||||
range,
|
||||
interval,
|
||||
previous: true,
|
||||
chartType: 'linear',
|
||||
projectId,
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
id: 'A',
|
||||
name: 'screen_view',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'path',
|
||||
name: 'path',
|
||||
value: [page.path],
|
||||
operator: 'is',
|
||||
},
|
||||
{
|
||||
id: 'origin',
|
||||
name: 'origin',
|
||||
value: [page.origin],
|
||||
operator: 'is',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const PageCardSkeleton = memo(() => {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="row gap-4 justify-between p-4 py-2 items-center">
|
||||
<div className="row gap-2 items-center h-16">
|
||||
<Skeleton className="size-10 rounded-sm" />
|
||||
<div className="col min-w-0">
|
||||
<Skeleton className="h-3 w-32 mb-2" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row border-y">
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<Skeleton className="h-6 w-16 mb-1" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<Skeleton className="h-6 w-12 mb-1" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<Skeleton className="h-6 w-14 mb-1" />
|
||||
<Skeleton className="h-4 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Skeleton className="h-16 w-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,12 +24,12 @@ export const Route = createFileRoute(
|
||||
function Component() {
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { page } = useDataTablePagination();
|
||||
const { page } = useDataTablePagination(50);
|
||||
const { debouncedSearch } = useSearchQueryState();
|
||||
const query = useQuery(
|
||||
trpc.profile.list.queryOptions(
|
||||
{
|
||||
cursor: (page - 1) * 50,
|
||||
cursor: page - 1,
|
||||
projectId,
|
||||
take: 50,
|
||||
search: debouncedSearch,
|
||||
|
||||
@@ -31,7 +31,7 @@ function Component() {
|
||||
const query = useQuery(
|
||||
trpc.profile.list.queryOptions(
|
||||
{
|
||||
cursor: (page - 1) * 50,
|
||||
cursor: page - 1,
|
||||
projectId,
|
||||
take: 50,
|
||||
search: debouncedSearch,
|
||||
|
||||
@@ -23,11 +23,11 @@ export const Route = createFileRoute(
|
||||
function Component() {
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { page } = useDataTablePagination();
|
||||
const { page } = useDataTablePagination(50);
|
||||
const query = useQuery(
|
||||
trpc.profile.powerUsers.queryOptions(
|
||||
{
|
||||
cursor: (page - 1) * 50,
|
||||
cursor: page - 1,
|
||||
projectId,
|
||||
take: 50,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { Fullscreen, FullscreenClose } from '@/components/fullscreen-toggle';
|
||||
import RealtimeMap from '@/components/realtime/map';
|
||||
import { RealtimeActiveSessions } from '@/components/realtime/realtime-active-sessions';
|
||||
@@ -7,12 +9,10 @@ import { RealtimePaths } from '@/components/realtime/realtime-paths';
|
||||
import { RealtimeReferrals } from '@/components/realtime/realtime-referrals';
|
||||
import RealtimeReloader from '@/components/realtime/realtime-reloader';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/realtime',
|
||||
'/_app/$organizationId/$projectId/realtime'
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -36,8 +36,8 @@ function Component() {
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -47,7 +47,7 @@ function Component() {
|
||||
<RealtimeReloader projectId={projectId} />
|
||||
|
||||
<div className="row relative">
|
||||
<div className="overflow-hidden aspect-[4/2] w-full">
|
||||
<div className="aspect-[4/2] w-full overflow-hidden">
|
||||
<RealtimeMap
|
||||
markers={coordinatesQuery.data ?? []}
|
||||
sidebarConfig={{
|
||||
@@ -56,18 +56,17 @@ function Component() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-8 left-8 bottom-0 col gap-4">
|
||||
<div className="card p-4 w-72 bg-background/90">
|
||||
<div className="col absolute top-8 bottom-4 left-8 gap-4">
|
||||
<div className="card w-72 bg-background/90 p-4">
|
||||
<RealtimeLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
<div className="w-72 flex-1 min-h-0 relative">
|
||||
<div className="relative min-h-0 w-72 flex-1">
|
||||
<RealtimeActiveSessions projectId={projectId} />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-def-100 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4 pt-4 md:p-8 md:pt-0">
|
||||
<div className="grid grid-cols-1 gap-4 p-4 pt-4 md:grid-cols-2 md:p-8 md:pt-0 xl:grid-cols-3">
|
||||
<div>
|
||||
<RealtimeGeo projectId={projectId} />
|
||||
</div>
|
||||
|
||||
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
@@ -0,0 +1,821 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { GscCannibalization } from '@/components/page/gsc-cannibalization';
|
||||
import { GscCtrBenchmark } from '@/components/page/gsc-ctr-benchmark';
|
||||
import { GscPositionChart } from '@/components/page/gsc-position-chart';
|
||||
import { PagesInsights } from '@/components/page/pages-insights';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/seo')({
|
||||
component: SeoPage,
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('SEO') }],
|
||||
}),
|
||||
});
|
||||
|
||||
interface GscChartData {
|
||||
date: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
|
||||
GscChartData,
|
||||
Record<string, unknown>
|
||||
>(({ data }) => {
|
||||
const item = data[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{item.date}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(0)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Clicks</span>
|
||||
<span>{item.clicks.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
<ChartTooltipItem color={getChartColor(1)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Impressions</span>
|
||||
<span>{item.impressions.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function SeoPage() {
|
||||
const { projectId, organizationId } = useAppParams();
|
||||
const trpc = useTRPC();
|
||||
const navigate = useNavigate();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const dateInput = {
|
||||
range,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
|
||||
const connectionQuery = useQuery(
|
||||
trpc.gsc.getConnection.queryOptions({ projectId })
|
||||
);
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
const isConnected = connection?.siteUrl;
|
||||
|
||||
const overviewQuery = useQuery(
|
||||
trpc.gsc.getOverview.queryOptions(
|
||||
{ projectId, ...dateInput, interval: interval ?? 'day' },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const pagesQuery = useQuery(
|
||||
trpc.gsc.getPages.queryOptions(
|
||||
{ projectId, ...dateInput, limit: 50 },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const queriesQuery = useQuery(
|
||||
trpc.gsc.getQueries.queryOptions(
|
||||
{ projectId, ...dateInput, limit: 50 },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const searchEnginesQuery = useQuery(
|
||||
trpc.gsc.getSearchEngines.queryOptions({ projectId, ...dateInput })
|
||||
);
|
||||
|
||||
const aiEnginesQuery = useQuery(
|
||||
trpc.gsc.getAiEngines.queryOptions({ projectId, ...dateInput })
|
||||
);
|
||||
|
||||
const previousOverviewQuery = useQuery(
|
||||
trpc.gsc.getPreviousOverview.queryOptions(
|
||||
{ projectId, ...dateInput, interval: interval ?? 'day' },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const [pagesPage, setPagesPage] = useState(0);
|
||||
const [queriesPage, setQueriesPage] = useState(0);
|
||||
const pageSize = 15;
|
||||
|
||||
const [pagesSearch, setPagesSearch] = useState('');
|
||||
const [queriesSearch, setQueriesSearch] = useState('');
|
||||
|
||||
const pages = pagesQuery.data ?? [];
|
||||
const queries = queriesQuery.data ?? [];
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
if (!pagesSearch.trim()) {
|
||||
return pages;
|
||||
}
|
||||
const q = pagesSearch.toLowerCase();
|
||||
return pages.filter((row) => {
|
||||
return String(row.page).toLowerCase().includes(q);
|
||||
});
|
||||
}, [pages, pagesSearch]);
|
||||
|
||||
const filteredQueries = useMemo(() => {
|
||||
if (!queriesSearch.trim()) {
|
||||
return queries;
|
||||
}
|
||||
const q = queriesSearch.toLowerCase();
|
||||
return queries.filter((row) => {
|
||||
return String(row.query).toLowerCase().includes(q);
|
||||
});
|
||||
}, [queries, queriesSearch]);
|
||||
|
||||
const paginatedPages = useMemo(
|
||||
() => filteredPages.slice(pagesPage * pageSize, (pagesPage + 1) * pageSize),
|
||||
[filteredPages, pagesPage, pageSize]
|
||||
);
|
||||
|
||||
const paginatedQueries = useMemo(
|
||||
() =>
|
||||
filteredQueries.slice(
|
||||
queriesPage * pageSize,
|
||||
(queriesPage + 1) * pageSize
|
||||
),
|
||||
[filteredQueries, queriesPage, pageSize]
|
||||
);
|
||||
|
||||
const pagesPageCount = Math.ceil(filteredPages.length / pageSize) || 1;
|
||||
const queriesPageCount = Math.ceil(filteredQueries.length / pageSize) || 1;
|
||||
|
||||
if (connectionQuery.isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader description="Google Search Console data" title="SEO" />
|
||||
<div className="mt-8 space-y-4">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<FullPageEmptyState
|
||||
className="pt-[20vh]"
|
||||
description="Connect Google Search Console to track your search impressions, clicks, and keyword rankings."
|
||||
icon={SearchIcon}
|
||||
title="No SEO data yet"
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/$organizationId/$projectId/settings/gsc',
|
||||
params: { organizationId, projectId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Connect Google Search Console
|
||||
</Button>
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data ?? [];
|
||||
const prevOverview = previousOverviewQuery.data ?? [];
|
||||
|
||||
const sumOverview = (rows: typeof overview) =>
|
||||
rows.reduce(
|
||||
(acc, row) => ({
|
||||
clicks: acc.clicks + row.clicks,
|
||||
impressions: acc.impressions + row.impressions,
|
||||
ctr: acc.ctr + row.ctr,
|
||||
position: acc.position + row.position,
|
||||
}),
|
||||
{ clicks: 0, impressions: 0, ctr: 0, position: 0 }
|
||||
);
|
||||
|
||||
const totals = sumOverview(overview);
|
||||
const prevTotals = sumOverview(prevOverview);
|
||||
const n = Math.max(overview.length, 1);
|
||||
const pn = Math.max(prevOverview.length, 1);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
</>
|
||||
}
|
||||
description={`Search performance for ${connection.siteUrl}`}
|
||||
title="SEO"
|
||||
/>
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||
<div className="card col-span-1 grid grid-cols-2 overflow-hidden rounded-md lg:col-span-2">
|
||||
<OverviewMetricCard
|
||||
data={overview.map((r) => ({ current: r.clicks, date: r.date }))}
|
||||
id="clicks"
|
||||
isLoading={overviewQuery.isLoading}
|
||||
label="Clicks"
|
||||
metric={{ current: totals.clicks, previous: prevTotals.clicks }}
|
||||
/>
|
||||
<OverviewMetricCard
|
||||
data={overview.map((r) => ({
|
||||
current: r.impressions,
|
||||
date: r.date,
|
||||
}))}
|
||||
id="impressions"
|
||||
isLoading={overviewQuery.isLoading}
|
||||
label="Impressions"
|
||||
metric={{
|
||||
current: totals.impressions,
|
||||
previous: prevTotals.impressions,
|
||||
}}
|
||||
/>
|
||||
<OverviewMetricCard
|
||||
data={overview.map((r) => ({
|
||||
current: r.ctr * 100,
|
||||
date: r.date,
|
||||
}))}
|
||||
id="ctr"
|
||||
isLoading={overviewQuery.isLoading}
|
||||
label="Avg CTR"
|
||||
metric={{
|
||||
current: (totals.ctr / n) * 100,
|
||||
previous: (prevTotals.ctr / pn) * 100,
|
||||
}}
|
||||
unit="%"
|
||||
/>
|
||||
<OverviewMetricCard
|
||||
data={overview.map((r) => ({
|
||||
current: r.position,
|
||||
date: r.date,
|
||||
}))}
|
||||
id="position"
|
||||
inverted
|
||||
isLoading={overviewQuery.isLoading}
|
||||
label="Avg Position"
|
||||
metric={{
|
||||
current: totals.position / n,
|
||||
previous: prevTotals.position / pn,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<SearchEngines
|
||||
engines={searchEnginesQuery.data?.engines ?? []}
|
||||
isLoading={searchEnginesQuery.isLoading}
|
||||
previousTotal={searchEnginesQuery.data?.previousTotal ?? 0}
|
||||
total={searchEnginesQuery.data?.total ?? 0}
|
||||
/>
|
||||
<AiEngines
|
||||
engines={aiEnginesQuery.data?.engines ?? []}
|
||||
isLoading={aiEnginesQuery.isLoading}
|
||||
previousTotal={aiEnginesQuery.data?.previousTotal ?? 0}
|
||||
total={aiEnginesQuery.data?.total ?? 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GscChart data={overview} isLoading={overviewQuery.isLoading} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<GscPositionChart
|
||||
data={overview}
|
||||
isLoading={overviewQuery.isLoading}
|
||||
/>
|
||||
<GscCtrBenchmark
|
||||
data={pagesQuery.data ?? []}
|
||||
isLoading={pagesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<GscTable
|
||||
isLoading={pagesQuery.isLoading}
|
||||
keyField="page"
|
||||
keyLabel="Page"
|
||||
maxClicks={Math.max(...paginatedPages.map((p) => p.clicks), 1)}
|
||||
onNextPage={() =>
|
||||
setPagesPage((p) => Math.min(pagesPageCount - 1, p + 1))
|
||||
}
|
||||
onPreviousPage={() => setPagesPage((p) => Math.max(0, p - 1))}
|
||||
onRowClick={(value) =>
|
||||
pushModal('PageDetails', { type: 'page', projectId, value })
|
||||
}
|
||||
onSearchChange={(v) => {
|
||||
setPagesSearch(v);
|
||||
setPagesPage(0);
|
||||
}}
|
||||
pageCount={pagesPageCount}
|
||||
pageIndex={pagesPage}
|
||||
pageSize={pageSize}
|
||||
rows={paginatedPages}
|
||||
searchPlaceholder="Search pages"
|
||||
searchValue={pagesSearch}
|
||||
title="Top pages"
|
||||
totalCount={filteredPages.length}
|
||||
/>
|
||||
<GscTable
|
||||
isLoading={queriesQuery.isLoading}
|
||||
keyField="query"
|
||||
keyLabel="Query"
|
||||
maxClicks={Math.max(...paginatedQueries.map((q) => q.clicks), 1)}
|
||||
onNextPage={() =>
|
||||
setQueriesPage((p) => Math.min(queriesPageCount - 1, p + 1))
|
||||
}
|
||||
onPreviousPage={() => setQueriesPage((p) => Math.max(0, p - 1))}
|
||||
onRowClick={(value) =>
|
||||
pushModal('PageDetails', { type: 'query', projectId, value })
|
||||
}
|
||||
onSearchChange={(v) => {
|
||||
setQueriesSearch(v);
|
||||
setQueriesPage(0);
|
||||
}}
|
||||
pageCount={queriesPageCount}
|
||||
pageIndex={queriesPage}
|
||||
pageSize={pageSize}
|
||||
rows={paginatedQueries}
|
||||
searchPlaceholder="Search queries"
|
||||
searchValue={queriesSearch}
|
||||
title="Top queries"
|
||||
totalCount={filteredQueries.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<GscCannibalization
|
||||
endDate={endDate ?? undefined}
|
||||
interval={interval ?? 'day'}
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
startDate={startDate ?? undefined}
|
||||
/>
|
||||
<PagesInsights projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TrafficSourceWidget({
|
||||
title,
|
||||
engines,
|
||||
total,
|
||||
previousTotal,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
}: {
|
||||
title: string;
|
||||
engines: Array<{ name: string; sessions: number }>;
|
||||
total: number;
|
||||
previousTotal: number;
|
||||
isLoading: boolean;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
const displayed =
|
||||
engines.length > 8
|
||||
? [
|
||||
...engines.slice(0, 7),
|
||||
{
|
||||
name: 'Others',
|
||||
sessions: engines.slice(7).reduce((s, d) => s + d.sessions, 0),
|
||||
},
|
||||
]
|
||||
: engines.slice(0, 8);
|
||||
|
||||
const max = displayed[0]?.sessions ?? 1;
|
||||
const pctChange =
|
||||
previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : null;
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{!isLoading && total > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium font-mono text-sm tabular-nums">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
{pctChange !== null && (
|
||||
<span
|
||||
className={`font-mono text-xs tabular-nums ${pctChange >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
||||
>
|
||||
{pctChange >= 0 ? '+' : ''}
|
||||
{pctChange.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{isLoading &&
|
||||
[1, 2, 3, 4].map((i) => (
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5" key={i}>
|
||||
<div className="size-4 animate-pulse rounded-sm bg-muted" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
|
||||
<div className="ml-auto h-3 w-8 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
{!isLoading && engines.length === 0 && (
|
||||
<p className="col-span-2 px-4 py-6 text-center text-muted-foreground text-xs">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading &&
|
||||
displayed.map((engine) => {
|
||||
const pct = total > 0 ? (engine.sessions / total) * 100 : 0;
|
||||
const barPct = (engine.sessions / max) * 100;
|
||||
return (
|
||||
<div className="relative px-4 py-2.5" key={engine.name}>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-muted/50"
|
||||
style={{ width: `${barPct}%` }}
|
||||
/>
|
||||
<div className="relative flex items-center gap-2">
|
||||
{engine.name !== 'Others' && (
|
||||
<SerieIcon
|
||||
className="size-3.5 shrink-0 rounded-sm"
|
||||
name={engine.name}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-xs capitalize">
|
||||
{engine.name.replace(/\..+$/, '')}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums">
|
||||
{engine.sessions.toLocaleString()}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-muted-foreground text-xs">
|
||||
{pct.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchEngines(props: {
|
||||
engines: Array<{ name: string; sessions: number }>;
|
||||
total: number;
|
||||
previousTotal: number;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TrafficSourceWidget
|
||||
{...props}
|
||||
emptyMessage="No search traffic in this period"
|
||||
title="Search engines"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AiEngines(props: {
|
||||
engines: Array<{ name: string; sessions: number }>;
|
||||
total: number;
|
||||
previousTotal: number;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TrafficSourceWidget
|
||||
{...props}
|
||||
emptyMessage="No AI traffic in this period"
|
||||
title="AI referrals"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GscChart({
|
||||
data,
|
||||
isLoading,
|
||||
}: {
|
||||
data: Array<{ date: string; clicks: number; impressions: number }>;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const color = getChartColor(0);
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<ResponsiveContainer height={200} width="100%">
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="gsc-line-glow"
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
>
|
||||
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA slope="0.5" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
{...X_AXIS_STYLE_PROPS}
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
type="category"
|
||||
/>
|
||||
<YAxis {...yAxisProps} yAxisId="left" />
|
||||
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||
<GscTooltip />
|
||||
<Line
|
||||
dataKey="clicks"
|
||||
dot={false}
|
||||
filter="url(#gsc-line-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="left"
|
||||
/>
|
||||
<Line
|
||||
dataKey="impressions"
|
||||
dot={false}
|
||||
filter="url(#gsc-line-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="right"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GscTableRow {
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
function GscTable({
|
||||
title,
|
||||
rows,
|
||||
keyField,
|
||||
keyLabel,
|
||||
maxClicks,
|
||||
isLoading,
|
||||
onRowClick,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
totalCount,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
}: {
|
||||
title: string;
|
||||
rows: GscTableRow[];
|
||||
keyField: string;
|
||||
keyLabel: string;
|
||||
maxClicks: number;
|
||||
isLoading: boolean;
|
||||
onRowClick?: (value: string) => void;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
totalCount?: number;
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
pageCount?: number;
|
||||
onPreviousPage?: () => void;
|
||||
onNextPage?: () => void;
|
||||
}) {
|
||||
const showPagination =
|
||||
totalCount != null &&
|
||||
pageSize != null &&
|
||||
pageCount != null &&
|
||||
onPreviousPage != null &&
|
||||
onNextPage != null &&
|
||||
pageIndex != null;
|
||||
const canPreviousPage = (pageIndex ?? 0) > 0;
|
||||
const canNextPage = (pageIndex ?? 0) < (pageCount ?? 1) - 1;
|
||||
const rangeStart = totalCount ? (pageIndex ?? 0) * (pageSize ?? 0) + 1 : 0;
|
||||
const rangeEnd = Math.min(
|
||||
(pageIndex ?? 0) * (pageSize ?? 0) + (pageSize ?? 0),
|
||||
totalCount ?? 0
|
||||
);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
</div>
|
||||
<OverviewWidgetTable
|
||||
columns={[
|
||||
{
|
||||
name: keyLabel,
|
||||
width: 'w-full',
|
||||
render: () => <Skeleton className="h-4 w-2/3" />,
|
||||
},
|
||||
{
|
||||
name: 'Clicks',
|
||||
width: '70px',
|
||||
render: () => <Skeleton className="h-4 w-10" />,
|
||||
},
|
||||
{
|
||||
name: 'Impr.',
|
||||
width: '70px',
|
||||
render: () => <Skeleton className="h-4 w-10" />,
|
||||
},
|
||||
{
|
||||
name: 'CTR',
|
||||
width: '60px',
|
||||
render: () => <Skeleton className="h-4 w-8" />,
|
||||
},
|
||||
{
|
||||
name: 'Pos.',
|
||||
width: '55px',
|
||||
render: () => <Skeleton className="h-4 w-8" />,
|
||||
},
|
||||
]}
|
||||
data={[1, 2, 3, 4, 5]}
|
||||
getColumnPercentage={() => 0}
|
||||
keyExtractor={(i) => String(i)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="border-b">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{showPagination && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||
{totalCount === 0
|
||||
? '0 results'
|
||||
: `${rangeStart}-${rangeEnd} of ${totalCount}`}
|
||||
</span>
|
||||
<Pagination
|
||||
canNextPage={canNextPage}
|
||||
canPreviousPage={canPreviousPage}
|
||||
nextPage={onNextPage}
|
||||
pageIndex={pageIndex}
|
||||
previousPage={onPreviousPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onSearchChange != null && (
|
||||
<div className="relative">
|
||||
<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) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder ?? 'Search'}
|
||||
type="search"
|
||||
value={searchValue ?? ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<OverviewWidgetTable
|
||||
columns={[
|
||||
{
|
||||
name: keyLabel,
|
||||
width: 'w-full',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<button
|
||||
className="block w-full truncate text-left font-mono text-xs hover:underline"
|
||||
onClick={() => onRowClick?.(String(item[keyField]))}
|
||||
type="button"
|
||||
>
|
||||
{String(item[keyField])}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clicks',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.clicks,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{item.clicks.toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Impr.',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.impressions,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{item.impressions.toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CTR',
|
||||
width: '60px',
|
||||
getSortValue: (item) => item.ctr,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.ctr * 100).toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pos.',
|
||||
width: '55px',
|
||||
getSortValue: (item) => item.position,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{item.position.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={rows}
|
||||
getColumnPercentage={(item) => item.clicks / maxClicks}
|
||||
keyExtractor={(item) => String(item[keyField])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||
)({
|
||||
component: GscSettings,
|
||||
});
|
||||
|
||||
function GscSettings() {
|
||||
const { projectId } = useAppParams();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedSite, setSelectedSite] = useState('');
|
||||
|
||||
const connectionQuery = useQuery(
|
||||
trpc.gsc.getConnection.queryOptions(
|
||||
{ projectId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
);
|
||||
|
||||
const sitesQuery = useQuery(
|
||||
trpc.gsc.getSites.queryOptions(
|
||||
{ projectId },
|
||||
{ enabled: !!connectionQuery.data && !connectionQuery.data.siteUrl }
|
||||
)
|
||||
);
|
||||
|
||||
const initiateOAuth = useMutation(
|
||||
trpc.gsc.initiateOAuth.mutationOptions({
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.url;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to initiate Google Search Console connection');
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const selectSite = useMutation(
|
||||
trpc.gsc.selectSite.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success('Site connected', {
|
||||
description: 'Backfill of 6 months of data has started.',
|
||||
});
|
||||
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to select site');
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const disconnect = useMutation(
|
||||
trpc.gsc.disconnect.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success('Disconnected from Google Search Console');
|
||||
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to disconnect');
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
|
||||
if (connectionQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not connected at all
|
||||
if (!connection) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Connect your Google Search Console property to import search
|
||||
performance data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You will be redirected to Google to authorize access. Only read-only
|
||||
access to Search Console data is requested.
|
||||
</p>
|
||||
<Button
|
||||
className="w-fit"
|
||||
disabled={initiateOAuth.isPending}
|
||||
onClick={() => initiateOAuth.mutate({ projectId })}
|
||||
>
|
||||
{initiateOAuth.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Connect Google Search Console
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected but no site selected yet
|
||||
if (!connection.siteUrl) {
|
||||
const sites = sitesQuery.data ?? [];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">Select a property</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Choose which Google Search Console property to connect to this
|
||||
project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-lg border p-6">
|
||||
{sitesQuery.isLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : sites.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No Search Console properties found for this Google account.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Select onValueChange={setSelectedSite} value={selectedSite}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a property..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sites.map((site) => (
|
||||
<SelectItem key={site.siteUrl} value={site.siteUrl}>
|
||||
{site.siteUrl}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!selectedSite || selectSite.isPending}
|
||||
onClick={() =>
|
||||
selectSite.mutate({ projectId, siteUrl: selectedSite })
|
||||
}
|
||||
>
|
||||
{selectSite.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Connect property
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => disconnect.mutate({ projectId })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Token expired — show reconnect prompt
|
||||
if (connection.lastSyncStatus === 'token_expired') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Connected to Google Search Console.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-destructive/50 bg-destructive/5 p-6">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span className="font-medium text-sm">Authorization expired</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your Google Search Console authorization has expired or been
|
||||
revoked. Please reconnect to continue syncing data.
|
||||
</p>
|
||||
{connection.lastSyncError && (
|
||||
<p className="break-words font-mono text-muted-foreground text-xs">
|
||||
{connection.lastSyncError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-fit"
|
||||
disabled={initiateOAuth.isPending}
|
||||
onClick={() => initiateOAuth.mutate({ projectId })}
|
||||
>
|
||||
{initiateOAuth.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Reconnect Google Search Console
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate({ projectId })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{disconnect.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fully connected
|
||||
const syncStatusIcon =
|
||||
connection.lastSyncStatus === 'success' ? (
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
) : connection.lastSyncStatus === 'error' ? (
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
) : null;
|
||||
|
||||
const syncStatusVariant =
|
||||
connection.lastSyncStatus === 'success'
|
||||
? 'success'
|
||||
: connection.lastSyncStatus === 'error'
|
||||
? 'destructive'
|
||||
: 'secondary';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Connected to Google Search Console.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y rounded-lg border">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="font-medium text-sm">Property</div>
|
||||
<div className="font-mono text-muted-foreground text-sm">
|
||||
{connection.siteUrl}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connection.backfillStatus && (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="font-medium text-sm">Backfill</div>
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={
|
||||
connection.backfillStatus === 'completed'
|
||||
? 'success'
|
||||
: connection.backfillStatus === 'failed'
|
||||
? 'destructive'
|
||||
: connection.backfillStatus === 'running'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{connection.backfillStatus === 'running' && (
|
||||
<Loader2Icon className="mr-1 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{connection.backfillStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection.lastSyncedAt && (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="font-medium text-sm">Last synced</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connection.lastSyncStatus && (
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={syncStatusVariant as any}
|
||||
>
|
||||
{syncStatusIcon}
|
||||
{connection.lastSyncStatus}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection.lastSyncError && (
|
||||
<div className="p-4">
|
||||
<div className="font-medium text-destructive text-sm">
|
||||
Last error
|
||||
</div>
|
||||
<div className="mt-1 break-words font-mono text-muted-foreground text-sm">
|
||||
{connection.lastSyncError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate({ projectId })}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{disconnect.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
import { IMPORT_PROVIDERS } from '@openpanel/importer/providers';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
Download,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
XCircleIcon,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import {
|
||||
IntegrationCard,
|
||||
@@ -19,21 +31,9 @@ import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { IMPORT_PROVIDERS } from '@openpanel/importer/providers';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
Download,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
XCircleIcon,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/imports',
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/imports'
|
||||
)({
|
||||
component: ImportsSettings,
|
||||
});
|
||||
@@ -48,8 +48,8 @@ function ImportsSettings() {
|
||||
{ projectId },
|
||||
{
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
const imports = importsQuery.data ?? [];
|
||||
|
||||
@@ -61,7 +61,7 @@ function ImportsSettings() {
|
||||
});
|
||||
queryClient.invalidateQueries(trpc.import.list.pathFilter());
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const retryImport = useMutation(
|
||||
@@ -72,11 +72,11 @@ function ImportsSettings() {
|
||||
});
|
||||
queryClient.invalidateQueries(trpc.import.list.pathFilter());
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const handleProviderSelect = (
|
||||
provider: (typeof IMPORT_PROVIDERS)[number],
|
||||
provider: (typeof IMPORT_PROVIDERS)[number]
|
||||
) => {
|
||||
pushModal('AddImport', {
|
||||
provider: provider.id,
|
||||
@@ -93,10 +93,10 @@ function ImportsSettings() {
|
||||
failed: 'destructive',
|
||||
};
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
pending: <Loader2Icon className="w-4 h-4 animate-spin" />,
|
||||
processing: <Loader2Icon className="w-4 h-4 animate-spin" />,
|
||||
completed: <CheckCircleIcon className="w-4 h-4" />,
|
||||
failed: <XCircleIcon className="w-4 h-4" />,
|
||||
pending: <Loader2Icon className="h-4 w-4 animate-spin" />,
|
||||
processing: <Loader2Icon className="h-4 w-4 animate-spin" />,
|
||||
completed: <CheckCircleIcon className="h-4 w-4" />,
|
||||
failed: <XCircleIcon className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
if (status === 'failed') {
|
||||
@@ -105,7 +105,7 @@ function ImportsSettings() {
|
||||
content={errorMessage}
|
||||
tooltipClassName="max-w-xs break-words"
|
||||
>
|
||||
<Badge variant={variants[status] || 'default'} className="capitalize">
|
||||
<Badge className="capitalize" variant={variants[status] || 'default'}>
|
||||
{icons[status] || null}
|
||||
{status}
|
||||
</Badge>
|
||||
@@ -114,7 +114,7 @@ function ImportsSettings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || 'default'} className="capitalize">
|
||||
<Badge className="capitalize" variant={variants[status] || 'default'}>
|
||||
{icons[status] || null}
|
||||
{status}
|
||||
</Badge>
|
||||
@@ -124,26 +124,26 @@ function ImportsSettings() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{IMPORT_PROVIDERS.map((provider) => (
|
||||
<IntegrationCard
|
||||
key={provider.id}
|
||||
description={provider.description}
|
||||
icon={
|
||||
<IntegrationCardLogoImage
|
||||
src={provider.logo}
|
||||
backgroundColor={provider.backgroundColor}
|
||||
className="p-4"
|
||||
src={provider.logo}
|
||||
/>
|
||||
}
|
||||
key={provider.id}
|
||||
name={provider.name}
|
||||
description={provider.description}
|
||||
>
|
||||
<IntegrationCardFooter className="row justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleProviderSelect(provider)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Import Data
|
||||
</Button>
|
||||
</IntegrationCardFooter>
|
||||
@@ -153,9 +153,9 @@ function ImportsSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">Import History</h3>
|
||||
<h3 className="mb-4 font-medium text-lg">Import History</h3>
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -172,8 +172,8 @@ function ImportsSettings() {
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<FullPageEmptyState
|
||||
title="No imports yet"
|
||||
description="Your import history will appear here."
|
||||
title="No imports yet"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -196,7 +196,7 @@ function ImportsSettings() {
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right justify-end row">
|
||||
<TableCell className="row justify-end text-right">
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -204,9 +204,9 @@ function ImportsSettings() {
|
||||
{imports.map((imp) => (
|
||||
<TableRow key={imp.id}>
|
||||
<TableCell className="font-medium capitalize">
|
||||
<div className="row gap-2 items-center">
|
||||
<div className="row items-center gap-2">
|
||||
<div>{imp.config.provider}</div>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
<Badge className="uppercase" variant="outline">
|
||||
{imp.config.type}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@ function ImportsSettings() {
|
||||
<div className="space-y-1">
|
||||
{getStatusBadge(imp.status, imp.errorMessage)}
|
||||
{imp.statusMessage && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{imp.statusMessage}
|
||||
</div>
|
||||
)}
|
||||
@@ -237,13 +237,13 @@ function ImportsSettings() {
|
||||
tooltipClassName="max-w-xs"
|
||||
>
|
||||
{imp.totalEvents.toLocaleString()}{' '}
|
||||
<InfoIcon className="w-4 h-4 inline-block relative -top-px" />
|
||||
<InfoIcon className="relative -top-px inline-block h-4 w-4" />
|
||||
</Tooltiper>
|
||||
</div>
|
||||
{imp.status === 'processing' && (
|
||||
<div className="w-full bg-secondary rounded-full h-1.5">
|
||||
<div className="h-1.5 w-full rounded-full bg-secondary">
|
||||
<div
|
||||
className="bg-primary h-1.5 rounded-full transition-all"
|
||||
className="h-1.5 rounded-full bg-primary transition-all"
|
||||
style={{
|
||||
width: `${Math.min(Math.round((imp.processedEvents / imp.totalEvents) * 100), 100)}%`,
|
||||
}}
|
||||
@@ -265,7 +265,7 @@ function ImportsSettings() {
|
||||
<TableCell>
|
||||
<Tooltiper
|
||||
content={
|
||||
<pre className="font-mono text-sm leading-normal whitespace-pre-wrap break-words">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-sm leading-normal">
|
||||
{JSON.stringify(imp.config, null, 2)}
|
||||
</pre>
|
||||
}
|
||||
@@ -274,20 +274,20 @@ function ImportsSettings() {
|
||||
<Badge>Config</Badge>
|
||||
</Tooltiper>
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<TableCell className="space-x-2 text-right">
|
||||
{imp.status === 'failed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => retryImport.mutate({ id: imp.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteImport.mutate({ id: imp.id })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
@@ -45,6 +45,7 @@ function ProjectDashboard() {
|
||||
{ id: 'tracking', label: 'Tracking script' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
{ id: 'imports', label: 'Imports' },
|
||||
{ id: 'gsc', label: 'Google Search' },
|
||||
];
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
|
||||
import { LoginLeftPanel } from '@/components/login-left-panel';
|
||||
import { LoginNavbar } from '@/components/login-navbar';
|
||||
import { OnboardingLeftPanel } from '@/components/onboarding-left-panel';
|
||||
|
||||
export const Route = createFileRoute('/_login')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
@@ -16,7 +16,7 @@ function AuthLayout() {
|
||||
<div className="relative grid min-h-screen md:grid-cols-2">
|
||||
<LoginNavbar />
|
||||
<div className="hidden md:block">
|
||||
<LoginLeftPanel />
|
||||
<OnboardingLeftPanel />
|
||||
</div>
|
||||
<div className="center-center mx-auto w-full max-w-md px-4">
|
||||
<Outlet />
|
||||
|
||||
@@ -57,11 +57,11 @@ function Component() {
|
||||
<div className="col w-full gap-8 text-left">
|
||||
<div>
|
||||
<h1 className="mb-2 font-bold text-3xl text-foreground">
|
||||
Create an account
|
||||
Start tracking in minutes
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Let's start with creating your account. By creating an account you
|
||||
accept the{' '}
|
||||
Join 1,000+ projects already using OpenPanel. By creating an account
|
||||
you accept the{' '}
|
||||
<a
|
||||
className="underline transition-colors hover:text-foreground"
|
||||
href="https://openpanel.dev/terms"
|
||||
@@ -111,6 +111,9 @@ function Component() {
|
||||
<SignInGithub inviteId={inviteId} type="sign-up" />
|
||||
<SignInGoogle inviteId={inviteId} type="sign-up" />
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
No credit card required · Free 30-day trial · Cancel anytime
|
||||
</p>
|
||||
|
||||
<Or className="my-6" />
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user