Compare commits
14 Commits
feature/gs
...
feature/op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a76b968ba | ||
|
|
9c3c1458bb | ||
|
|
a672b73947 | ||
|
|
bc08566cd4 | ||
|
|
bf39804767 | ||
|
|
664f1abe0a | ||
|
|
8afcf55154 | ||
|
|
4736f8509d | ||
|
|
05cf6bb39f | ||
|
|
6e1daf2c76 | ||
|
|
f2aa0273e6 | ||
|
|
1b898660ad | ||
|
|
9836f75e17 | ||
|
|
271d189ed0 |
@@ -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
|
||||
);
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { QuoteIcon } from 'lucide-react';
|
||||
import { QuoteIcon, StarIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Markdown from 'react-markdown';
|
||||
import { FeatureCardBackground } from '@/components/feature-card';
|
||||
@@ -94,13 +94,22 @@ export function WhyOpenPanel() {
|
||||
))}
|
||||
</div>
|
||||
<div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2">
|
||||
{quotes.map((quote) => (
|
||||
{quotes.slice(0, 2).map((quote) => (
|
||||
<figure
|
||||
className="group px-4 py-4 md:odd:border-r"
|
||||
key={quote.author}
|
||||
>
|
||||
<QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" />
|
||||
<blockquote className="prose text-xl">
|
||||
<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 mt-4 justify-between text-muted-foreground text-sm">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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,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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
|
||||
import { BoxSelectIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ButtonContainer } from '@/components/button-container';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
@@ -33,22 +32,21 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { data: events, refetch } = useQuery(
|
||||
trpc.event.events.queryOptions({ projectId })
|
||||
const { data: events } = useQuery(
|
||||
trpc.event.events.queryOptions(
|
||||
{ projectId },
|
||||
{
|
||||
refetchInterval: 2500,
|
||||
}
|
||||
)
|
||||
);
|
||||
const isVerified = events?.data && events.data.length > 0;
|
||||
const { data: project } = useQuery(
|
||||
trpc.project.getProjectWithClients.queryOptions({ projectId })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (events && events.data.length > 0) {
|
||||
setIsVerified(true);
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<FullPageEmptyState icon={BoxSelectIcon} title="Project not found" />
|
||||
@@ -64,15 +62,7 @@ function Component() {
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<div className="col gap-8 p-4">
|
||||
<VerifyListener
|
||||
client={client}
|
||||
events={events?.data ?? []}
|
||||
onVerified={() => {
|
||||
refetch();
|
||||
setIsVerified(true);
|
||||
}}
|
||||
project={project}
|
||||
/>
|
||||
<VerifyListener events={events?.data ?? []} />
|
||||
|
||||
<VerifyFaq project={project} />
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@openpanel/email": "workspace:*",
|
||||
"@openpanel/importer": "workspace:*",
|
||||
"@openpanel/integrations": "workspace:^",
|
||||
"@openpanel/js-runtime": "workspace:*",
|
||||
"@openpanel/json": "workspace:*",
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/importer": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
|
||||
@@ -78,6 +78,11 @@ export async function bootCron() {
|
||||
type: 'onboarding',
|
||||
pattern: '0 * * * *',
|
||||
},
|
||||
{
|
||||
name: 'gscSync',
|
||||
type: 'gscSync',
|
||||
pattern: '0 3 * * *',
|
||||
},
|
||||
];
|
||||
|
||||
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Queue, WorkerOptions } from 'bullmq';
|
||||
import { Worker } from 'bullmq';
|
||||
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import {
|
||||
cronQueue,
|
||||
EVENTS_GROUP_QUEUES_SHARDS,
|
||||
type EventsQueuePayloadIncomingEvent,
|
||||
cronQueue,
|
||||
eventsGroupQueues,
|
||||
gscQueue,
|
||||
importQueue,
|
||||
insightsQueue,
|
||||
miscQueue,
|
||||
@@ -14,13 +14,12 @@ import {
|
||||
sessionsQueue,
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisQueue } from '@openpanel/redis';
|
||||
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import type { Queue, WorkerOptions } from 'bullmq';
|
||||
import { Worker } from 'bullmq';
|
||||
import { Worker as GroupWorker } from 'groupmq';
|
||||
|
||||
import { cronJob } from './jobs/cron';
|
||||
import { incomingEvent } from './jobs/events.incoming-event';
|
||||
import { gscJob } from './jobs/gsc';
|
||||
import { importJob } from './jobs/import';
|
||||
import { insightsProjectJob } from './jobs/insights';
|
||||
import { miscJob } from './jobs/misc';
|
||||
@@ -59,6 +58,7 @@ function getEnabledQueues(): QueueName[] {
|
||||
'misc',
|
||||
'import',
|
||||
'insights',
|
||||
'gsc',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ function getConcurrencyFor(queueName: string, defaultValue = 1): number {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export async function bootWorkers() {
|
||||
export function bootWorkers() {
|
||||
const enabledQueues = getEnabledQueues();
|
||||
|
||||
const workers: (Worker | GroupWorker<any>)[] = [];
|
||||
@@ -116,12 +116,14 @@ export async function bootWorkers() {
|
||||
|
||||
for (const index of eventQueuesToStart) {
|
||||
const queue = eventsGroupQueues[index];
|
||||
if (!queue) continue;
|
||||
if (!queue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queueName = `events_${index}`;
|
||||
const concurrency = getConcurrencyFor(
|
||||
queueName,
|
||||
Number.parseInt(process.env.EVENT_JOB_CONCURRENCY || '10', 10),
|
||||
Number.parseInt(process.env.EVENT_JOB_CONCURRENCY || '10', 10)
|
||||
);
|
||||
|
||||
const worker = new GroupWorker<EventsQueuePayloadIncomingEvent['payload']>({
|
||||
@@ -129,7 +131,7 @@ export async function bootWorkers() {
|
||||
concurrency,
|
||||
logger: process.env.NODE_ENV === 'production' ? queueLogger : undefined,
|
||||
blockingTimeoutSec: Number.parseFloat(
|
||||
process.env.EVENT_BLOCKING_TIMEOUT_SEC || '1',
|
||||
process.env.EVENT_BLOCKING_TIMEOUT_SEC || '1'
|
||||
),
|
||||
handler: async (job) => {
|
||||
return await incomingEvent(job.data);
|
||||
@@ -169,7 +171,7 @@ export async function bootWorkers() {
|
||||
const notificationWorker = new Worker(
|
||||
notificationQueue.name,
|
||||
notificationJob,
|
||||
{ ...workerOptions, concurrency },
|
||||
{ ...workerOptions, concurrency }
|
||||
);
|
||||
workers.push(notificationWorker);
|
||||
logger.info('Started worker for notification', { concurrency });
|
||||
@@ -208,9 +210,20 @@ export async function bootWorkers() {
|
||||
logger.info('Started worker for insights', { concurrency });
|
||||
}
|
||||
|
||||
// Start gsc worker
|
||||
if (enabledQueues.includes('gsc')) {
|
||||
const concurrency = getConcurrencyFor('gsc', 5);
|
||||
const gscWorker = new Worker(gscQueue.name, gscJob, {
|
||||
...workerOptions,
|
||||
concurrency,
|
||||
});
|
||||
workers.push(gscWorker);
|
||||
logger.info('Started worker for gsc', { concurrency });
|
||||
}
|
||||
|
||||
if (workers.length === 0) {
|
||||
logger.warn(
|
||||
'No workers started. Check ENABLED_QUEUES environment variable.',
|
||||
'No workers started. Check ENABLED_QUEUES environment variable.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,7 +253,7 @@ export async function bootWorkers() {
|
||||
const elapsed = job.finishedOn - job.processedOn;
|
||||
eventsGroupJobDuration.observe(
|
||||
{ name: worker.name, status: 'failed' },
|
||||
elapsed,
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
logger.error('job failed', {
|
||||
@@ -253,23 +266,6 @@ export async function bootWorkers() {
|
||||
}
|
||||
});
|
||||
|
||||
(worker as Worker).on('completed', (job) => {
|
||||
if (job) {
|
||||
if (job.processedOn && job.finishedOn) {
|
||||
const elapsed = job.finishedOn - job.processedOn;
|
||||
logger.info('job completed', {
|
||||
jobId: job.id,
|
||||
worker: worker.name,
|
||||
elapsed,
|
||||
});
|
||||
eventsGroupJobDuration.observe(
|
||||
{ name: worker.name, status: 'success' },
|
||||
elapsed,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(worker as Worker).on('ioredis:close', () => {
|
||||
logger.error('worker closed due to ioredis:close', {
|
||||
worker: worker.name,
|
||||
@@ -279,7 +275,7 @@ export async function bootWorkers() {
|
||||
|
||||
async function exitHandler(
|
||||
eventName: string,
|
||||
evtOrExitCodeOrError: number | string | Error,
|
||||
evtOrExitCodeOrError: number | string | Error
|
||||
) {
|
||||
// Log the actual error details for unhandled rejections/exceptions
|
||||
if (evtOrExitCodeOrError instanceof Error) {
|
||||
@@ -325,7 +321,7 @@ export async function bootWorkers() {
|
||||
process.on(evt, (code) => {
|
||||
exitHandler(evt, code);
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return workers;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createInitialSalts } from '@openpanel/db';
|
||||
import {
|
||||
cronQueue,
|
||||
eventsGroupQueues,
|
||||
gscQueue,
|
||||
importQueue,
|
||||
insightsQueue,
|
||||
miscQueue,
|
||||
@@ -12,9 +13,8 @@ import {
|
||||
sessionsQueue,
|
||||
} from '@openpanel/queue';
|
||||
import express from 'express';
|
||||
import client from 'prom-client';
|
||||
|
||||
import { BullBoardGroupMQAdapter } from 'groupmq';
|
||||
import client from 'prom-client';
|
||||
import sourceMapSupport from 'source-map-support';
|
||||
import { bootCron } from './boot-cron';
|
||||
import { bootWorkers } from './boot-workers';
|
||||
@@ -39,7 +39,7 @@ async function start() {
|
||||
createBullBoard({
|
||||
queues: [
|
||||
...eventsGroupQueues.map(
|
||||
(queue) => new BullBoardGroupMQAdapter(queue) as any,
|
||||
(queue) => new BullBoardGroupMQAdapter(queue) as any
|
||||
),
|
||||
new BullMQAdapter(sessionsQueue),
|
||||
new BullMQAdapter(cronQueue),
|
||||
@@ -47,8 +47,9 @@ async function start() {
|
||||
new BullMQAdapter(miscQueue),
|
||||
new BullMQAdapter(importQueue),
|
||||
new BullMQAdapter(insightsQueue),
|
||||
new BullMQAdapter(gscQueue),
|
||||
],
|
||||
serverAdapter: serverAdapter,
|
||||
serverAdapter,
|
||||
});
|
||||
|
||||
app.use('/', serverAdapter.getRouter());
|
||||
|
||||
@@ -33,7 +33,7 @@ async function generateNewSalt() {
|
||||
return created;
|
||||
});
|
||||
|
||||
getSalts.clear();
|
||||
await getSalts.clear();
|
||||
|
||||
return newSalt;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessio
|
||||
import type { CronQueuePayload } from '@openpanel/queue';
|
||||
|
||||
import { jobdeleteProjects } from './cron.delete-projects';
|
||||
import { gscSyncAllJob } from './gsc';
|
||||
import { onboardingJob } from './cron.onboarding';
|
||||
import { ping } from './cron.ping';
|
||||
import { salt } from './cron.salt';
|
||||
@@ -41,5 +42,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
||||
case 'onboarding': {
|
||||
return await onboardingJob(job);
|
||||
}
|
||||
case 'gscSync': {
|
||||
return await gscSyncAllJob();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { getTime, isSameDomain, parsePath } from '@openpanel/common';
|
||||
import {
|
||||
getReferrerWithQuery,
|
||||
parseReferrer,
|
||||
parseUserAgent,
|
||||
} from '@openpanel/common/server';
|
||||
import { getReferrerWithQuery, parseReferrer } from '@openpanel/common/server';
|
||||
import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
|
||||
import {
|
||||
checkNotificationRulesForEvent,
|
||||
@@ -14,10 +10,12 @@ import {
|
||||
} from '@openpanel/db';
|
||||
import type { ILogger } from '@openpanel/logger';
|
||||
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
||||
import { getLock } from '@openpanel/redis';
|
||||
import { anyPass, isEmpty, isNil, mergeDeepRight, omit, reject } from 'ramda';
|
||||
import { logger as baseLogger } from '@/utils/logger';
|
||||
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
|
||||
import {
|
||||
createSessionEndJob,
|
||||
extendSessionEndJob,
|
||||
} from '@/utils/session-handler';
|
||||
|
||||
const GLOBAL_PROPERTIES = ['__path', '__referrer', '__timestamp', '__revenue'];
|
||||
|
||||
@@ -93,7 +91,8 @@ export async function incomingEvent(
|
||||
projectId,
|
||||
deviceId,
|
||||
sessionId,
|
||||
uaInfo: _uaInfo,
|
||||
uaInfo,
|
||||
session,
|
||||
} = jobPayload;
|
||||
const properties = body.properties ?? {};
|
||||
const reqId = headers['request-id'] ?? 'unknown';
|
||||
@@ -121,16 +120,15 @@ export async function incomingEvent(
|
||||
? null
|
||||
: parseReferrer(getProperty('__referrer'));
|
||||
const utmReferrer = getReferrerWithQuery(query);
|
||||
const userAgent = headers['user-agent'];
|
||||
const sdkName = headers['openpanel-sdk-name'];
|
||||
const sdkVersion = headers['openpanel-sdk-version'];
|
||||
// TODO: Remove both user-agent and parseUserAgent
|
||||
const uaInfo = _uaInfo ?? parseUserAgent(userAgent, properties);
|
||||
|
||||
const baseEvent = {
|
||||
const baseEvent: IServiceCreateEventPayload = {
|
||||
name: body.name,
|
||||
profileId,
|
||||
projectId,
|
||||
deviceId,
|
||||
sessionId,
|
||||
properties: omit(GLOBAL_PROPERTIES, {
|
||||
...properties,
|
||||
__hash: hash,
|
||||
@@ -149,7 +147,7 @@ export async function incomingEvent(
|
||||
origin,
|
||||
referrer: referrer?.url || '',
|
||||
referrerName: utmReferrer?.name || referrer?.name || referrer?.url,
|
||||
referrerType: referrer?.type || utmReferrer?.type || '',
|
||||
referrerType: utmReferrer?.type || referrer?.type || '',
|
||||
os: uaInfo.os,
|
||||
osVersion: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
@@ -161,16 +159,17 @@ export async function incomingEvent(
|
||||
body.name === 'revenue' && '__revenue' in properties
|
||||
? parseRevenue(properties.__revenue)
|
||||
: undefined,
|
||||
} as const;
|
||||
};
|
||||
|
||||
// if timestamp is from the past we dont want to create a new session
|
||||
if (uaInfo.isServer || isTimestampFromThePast) {
|
||||
const session = profileId
|
||||
? await sessionBuffer.getExistingSession({
|
||||
profileId,
|
||||
projectId,
|
||||
})
|
||||
: null;
|
||||
const session =
|
||||
profileId && !isTimestampFromThePast
|
||||
? await sessionBuffer.getExistingSession({
|
||||
profileId,
|
||||
projectId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const payload = {
|
||||
...baseEvent,
|
||||
@@ -198,82 +197,48 @@ export async function incomingEvent(
|
||||
return createEventAndNotify(payload as IServiceEvent, logger, projectId);
|
||||
}
|
||||
|
||||
const sessionEnd = await getSessionEnd({
|
||||
projectId,
|
||||
deviceId,
|
||||
profileId,
|
||||
});
|
||||
const activeSession = sessionEnd
|
||||
? await sessionBuffer.getExistingSession({
|
||||
sessionId: sessionEnd.sessionId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const payload: IServiceCreateEventPayload = merge(baseEvent, {
|
||||
deviceId: sessionEnd?.deviceId ?? deviceId,
|
||||
sessionId: sessionEnd?.sessionId ?? sessionId,
|
||||
referrer: sessionEnd?.referrer ?? baseEvent.referrer,
|
||||
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
|
||||
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
|
||||
// if the path is not set, use the last screen view path
|
||||
path: baseEvent.path || activeSession?.exit_path || '',
|
||||
origin: baseEvent.origin || activeSession?.exit_origin || '',
|
||||
referrer: session?.referrer ?? baseEvent.referrer,
|
||||
referrerName: session?.referrerName ?? baseEvent.referrerName,
|
||||
referrerType: session?.referrerType ?? baseEvent.referrerType,
|
||||
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
|
||||
|
||||
// If the triggering event is filtered, do not create session_start or the event (issue #2)
|
||||
const isExcluded = await isEventExcludedByProjectFilter(payload, projectId);
|
||||
if (isExcluded) {
|
||||
logger.info(
|
||||
'Skipping session_start and event (excluded by project filter)',
|
||||
{ event: payload.name, projectId }
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
await extendSessionEndJob({
|
||||
projectId,
|
||||
deviceId,
|
||||
}).catch((error) => {
|
||||
logger.error('Error finding and extending session end job', { error });
|
||||
throw error;
|
||||
});
|
||||
} else {
|
||||
await createEventAndNotify(
|
||||
{
|
||||
event: payload.name,
|
||||
projectId,
|
||||
}
|
||||
);
|
||||
return null;
|
||||
}
|
||||
...payload,
|
||||
name: 'session_start',
|
||||
createdAt: new Date(getTime(payload.createdAt) - 100),
|
||||
},
|
||||
logger,
|
||||
projectId
|
||||
).catch((error) => {
|
||||
logger.error('Error creating session start event', { event: payload });
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (!sessionEnd) {
|
||||
const locked = await getLock(
|
||||
`session_start:${projectId}:${sessionId}`,
|
||||
'1',
|
||||
1000
|
||||
);
|
||||
if (locked) {
|
||||
logger.info('Creating session start event', { event: payload });
|
||||
await createEventAndNotify(
|
||||
{
|
||||
...payload,
|
||||
name: 'session_start',
|
||||
createdAt: new Date(getTime(payload.createdAt) - 100),
|
||||
},
|
||||
logger,
|
||||
projectId
|
||||
).catch((error) => {
|
||||
logger.error('Error creating session start event', { event: payload });
|
||||
throw error;
|
||||
});
|
||||
} else {
|
||||
logger.info('Session start already claimed by another worker', {
|
||||
event: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const event = await createEventAndNotify(payload, logger, projectId);
|
||||
|
||||
if (!event) {
|
||||
// Skip creating session end when event was excluded
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!sessionEnd) {
|
||||
logger.info('Creating session end job', { event: payload });
|
||||
await createSessionEndJob({ payload }).catch((error) => {
|
||||
logger.error('Error creating session end job', { event: payload });
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return event;
|
||||
return createEventAndNotify(payload, logger, projectId);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,11 @@ describe('incomingEvent', () => {
|
||||
projectId,
|
||||
deviceId,
|
||||
sessionId: 'session-123',
|
||||
session: {
|
||||
referrer: '',
|
||||
referrerName: '',
|
||||
referrerType: '',
|
||||
},
|
||||
};
|
||||
|
||||
const changeDelay = vi.fn();
|
||||
|
||||
146
apps/worker/src/jobs/gsc.ts
Normal file
146
apps/worker/src/jobs/gsc.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { db, syncGscData } from '@openpanel/db';
|
||||
import type { GscQueuePayload } from '@openpanel/queue';
|
||||
import { gscQueue } from '@openpanel/queue';
|
||||
import type { Job } from 'bullmq';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const BACKFILL_MONTHS = 6;
|
||||
const CHUNK_DAYS = 14;
|
||||
|
||||
export function gscJob(job: Job<GscQueuePayload>) {
|
||||
switch (job.data.type) {
|
||||
case 'gscProjectSync':
|
||||
return gscProjectSyncJob(job.data.payload.projectId);
|
||||
case 'gscProjectBackfill':
|
||||
return gscProjectBackfillJob(job.data.payload.projectId);
|
||||
default:
|
||||
throw new Error('Unknown GSC job type');
|
||||
}
|
||||
}
|
||||
|
||||
async function gscProjectSyncJob(projectId: string) {
|
||||
const conn = await db.gscConnection.findUnique({ where: { projectId } });
|
||||
if (!conn?.siteUrl) {
|
||||
logger.warn('GSC sync skipped: no connection or siteUrl', { projectId });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Sync rolling 3-day window (GSC data can arrive late)
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() - 1); // yesterday
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - 2); // 3 days total
|
||||
|
||||
await syncGscData(projectId, startDate, endDate);
|
||||
|
||||
await db.gscConnection.update({
|
||||
where: { projectId },
|
||||
data: {
|
||||
lastSyncedAt: new Date(),
|
||||
lastSyncStatus: 'success',
|
||||
lastSyncError: null,
|
||||
},
|
||||
});
|
||||
logger.info('GSC sync completed', { projectId });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await db.gscConnection.update({
|
||||
where: { projectId },
|
||||
data: {
|
||||
lastSyncedAt: new Date(),
|
||||
lastSyncStatus: 'error',
|
||||
lastSyncError: message,
|
||||
},
|
||||
});
|
||||
logger.error('GSC sync failed', { projectId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function gscProjectBackfillJob(projectId: string) {
|
||||
const conn = await db.gscConnection.findUnique({ where: { projectId } });
|
||||
if (!conn?.siteUrl) {
|
||||
logger.warn('GSC backfill skipped: no connection or siteUrl', {
|
||||
projectId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.gscConnection.update({
|
||||
where: { projectId },
|
||||
data: { backfillStatus: 'running' },
|
||||
});
|
||||
|
||||
try {
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() - 1); // yesterday
|
||||
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setMonth(startDate.getMonth() - BACKFILL_MONTHS);
|
||||
|
||||
// Process in chunks to avoid timeouts and respect API limits
|
||||
let chunkEnd = new Date(endDate);
|
||||
while (chunkEnd > startDate) {
|
||||
const chunkStart = new Date(chunkEnd);
|
||||
chunkStart.setDate(chunkStart.getDate() - CHUNK_DAYS + 1);
|
||||
if (chunkStart < startDate) {
|
||||
chunkStart.setTime(startDate.getTime());
|
||||
}
|
||||
|
||||
logger.info('GSC backfill chunk', {
|
||||
projectId,
|
||||
from: chunkStart.toISOString().slice(0, 10),
|
||||
to: chunkEnd.toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
await syncGscData(projectId, chunkStart, chunkEnd);
|
||||
|
||||
chunkEnd = new Date(chunkStart);
|
||||
chunkEnd.setDate(chunkEnd.getDate() - 1);
|
||||
}
|
||||
|
||||
await db.gscConnection.update({
|
||||
where: { projectId },
|
||||
data: {
|
||||
backfillStatus: 'completed',
|
||||
lastSyncedAt: new Date(),
|
||||
lastSyncStatus: 'success',
|
||||
lastSyncError: null,
|
||||
},
|
||||
});
|
||||
logger.info('GSC backfill completed', { projectId });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await db.gscConnection.update({
|
||||
where: { projectId },
|
||||
data: {
|
||||
backfillStatus: 'failed',
|
||||
lastSyncStatus: 'error',
|
||||
lastSyncError: message,
|
||||
},
|
||||
});
|
||||
logger.error('GSC backfill failed', { projectId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function gscSyncAllJob() {
|
||||
const connections = await db.gscConnection.findMany({
|
||||
where: {
|
||||
siteUrl: { not: '' },
|
||||
},
|
||||
select: { projectId: true },
|
||||
});
|
||||
|
||||
logger.info('GSC nightly sync: enqueuing projects', {
|
||||
count: connections.length,
|
||||
});
|
||||
|
||||
for (const conn of connections) {
|
||||
await gscQueue.add('gscProjectSync', {
|
||||
type: 'gscProjectSync',
|
||||
payload: { projectId: conn.projectId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import client from 'prom-client';
|
||||
|
||||
import {
|
||||
botBuffer,
|
||||
eventBuffer,
|
||||
@@ -8,6 +6,7 @@ import {
|
||||
sessionBuffer,
|
||||
} from '@openpanel/db';
|
||||
import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue';
|
||||
import client from 'prom-client';
|
||||
|
||||
const Registry = client.Registry;
|
||||
|
||||
@@ -20,7 +19,7 @@ export const eventsGroupJobDuration = new client.Histogram({
|
||||
name: 'job_duration_ms',
|
||||
help: 'Duration of job processing (in ms)',
|
||||
labelNames: ['name', 'status'],
|
||||
buckets: [10, 25, 50, 100, 250, 500, 750, 1000, 2000, 5000, 10000, 30000], // 10ms to 30s
|
||||
buckets: [10, 25, 50, 100, 250, 500, 750, 1000, 2000, 5000, 10_000, 30_000], // 10ms to 30s
|
||||
});
|
||||
|
||||
register.registerMetric(eventsGroupJobDuration);
|
||||
@@ -28,57 +27,61 @@ register.registerMetric(eventsGroupJobDuration);
|
||||
queues.forEach((queue) => {
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_active_count`,
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_active_count`,
|
||||
help: 'Active count',
|
||||
async collect() {
|
||||
const metric = await queue.getActiveCount();
|
||||
this.set(metric);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_delayed_count`,
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_delayed_count`,
|
||||
help: 'Delayed count',
|
||||
async collect() {
|
||||
const metric = await queue.getDelayedCount();
|
||||
this.set(metric);
|
||||
if ('getDelayedCount' in queue) {
|
||||
const metric = await queue.getDelayedCount();
|
||||
this.set(metric);
|
||||
} else {
|
||||
this.set(0);
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_failed_count`,
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_failed_count`,
|
||||
help: 'Failed count',
|
||||
async collect() {
|
||||
const metric = await queue.getFailedCount();
|
||||
this.set(metric);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_completed_count`,
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_completed_count`,
|
||||
help: 'Completed count',
|
||||
async collect() {
|
||||
const metric = await queue.getCompletedCount();
|
||||
this.set(metric);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_waiting_count`,
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_waiting_count`,
|
||||
help: 'Waiting count',
|
||||
async collect() {
|
||||
const metric = await queue.getWaitingCount();
|
||||
this.set(metric);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -90,7 +93,7 @@ register.registerMetric(
|
||||
const metric = await eventBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
@@ -101,7 +104,7 @@ register.registerMetric(
|
||||
const metric = await profileBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
@@ -112,7 +115,7 @@ register.registerMetric(
|
||||
const metric = await botBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
@@ -123,7 +126,7 @@ register.registerMetric(
|
||||
const metric = await sessionBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
@@ -134,5 +137,5 @@ register.registerMetric(
|
||||
const metric = await replayBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
import {
|
||||
type EventsQueuePayloadCreateSessionEnd,
|
||||
sessionsQueue,
|
||||
} from '@openpanel/queue';
|
||||
import type { Job } from 'bullmq';
|
||||
import { logger } from './logger';
|
||||
import { sessionsQueue } from '@openpanel/queue';
|
||||
|
||||
export const SESSION_TIMEOUT = 1000 * 60 * 30;
|
||||
|
||||
const CHANGE_DELAY_THROTTLE_MS = process.env.CHANGE_DELAY_THROTTLE_MS
|
||||
? Number.parseInt(process.env.CHANGE_DELAY_THROTTLE_MS, 10)
|
||||
: 60_000; // 1 minute
|
||||
|
||||
const CHANGE_DELAY_THROTTLE_MAP = new Map<string, number>();
|
||||
|
||||
export async function extendSessionEndJob({
|
||||
projectId,
|
||||
deviceId,
|
||||
}: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
}) {
|
||||
const last = CHANGE_DELAY_THROTTLE_MAP.get(`${projectId}:${deviceId}`) ?? 0;
|
||||
const isThrottled = Date.now() - last < CHANGE_DELAY_THROTTLE_MS;
|
||||
|
||||
if (isThrottled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = getSessionEndJobId(projectId, deviceId);
|
||||
const job = await sessionsQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
await job.changeDelay(SESSION_TIMEOUT);
|
||||
CHANGE_DELAY_THROTTLE_MAP.set(`${projectId}:${deviceId}`, Date.now());
|
||||
}
|
||||
|
||||
const getSessionEndJobId = (projectId: string, deviceId: string) =>
|
||||
`sessionEnd:${projectId}:${deviceId}`;
|
||||
|
||||
@@ -33,106 +59,3 @@ export function createSessionEndJob({
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSessionEnd({
|
||||
projectId,
|
||||
deviceId,
|
||||
profileId,
|
||||
}: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
profileId: string;
|
||||
}) {
|
||||
const sessionEnd = await getSessionEndJob({
|
||||
projectId,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
if (sessionEnd) {
|
||||
const existingSessionIsAnonymous =
|
||||
sessionEnd.job.data.payload.profileId ===
|
||||
sessionEnd.job.data.payload.deviceId;
|
||||
|
||||
const eventIsIdentified =
|
||||
profileId && sessionEnd.job.data.payload.profileId !== profileId;
|
||||
|
||||
if (existingSessionIsAnonymous && eventIsIdentified) {
|
||||
await sessionEnd.job.updateData({
|
||||
...sessionEnd.job.data,
|
||||
payload: {
|
||||
...sessionEnd.job.data.payload,
|
||||
profileId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await sessionEnd.job.changeDelay(SESSION_TIMEOUT);
|
||||
return sessionEnd.job.data.payload;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getSessionEndJob(args: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
retryCount?: number;
|
||||
}): Promise<{
|
||||
deviceId: string;
|
||||
job: Job<EventsQueuePayloadCreateSessionEnd>;
|
||||
} | null> {
|
||||
const { retryCount = 0 } = args;
|
||||
|
||||
if (retryCount >= 6) {
|
||||
throw new Error('Failed to get session end');
|
||||
}
|
||||
|
||||
async function handleJobStates(
|
||||
job: Job<EventsQueuePayloadCreateSessionEnd>,
|
||||
deviceId: string
|
||||
): Promise<{
|
||||
deviceId: string;
|
||||
job: Job<EventsQueuePayloadCreateSessionEnd>;
|
||||
} | null> {
|
||||
const state = await job.getState();
|
||||
if (state !== 'delayed') {
|
||||
logger.debug(`[session-handler] Session end job is in "${state}" state`, {
|
||||
state,
|
||||
retryCount,
|
||||
jobTimestamp: new Date(job.timestamp).toISOString(),
|
||||
jobDelta: Date.now() - job.timestamp,
|
||||
jobId: job.id,
|
||||
payload: job.data.payload,
|
||||
});
|
||||
}
|
||||
|
||||
if (state === 'delayed' || state === 'waiting') {
|
||||
return { deviceId, job };
|
||||
}
|
||||
|
||||
if (state === 'active') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return getSessionEndJob({
|
||||
...args,
|
||||
retryCount: retryCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (state === 'completed') {
|
||||
await job.remove();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check current device job
|
||||
const currentJob = await sessionsQueue.getJob(
|
||||
getSessionEndJobId(args.projectId, args.deviceId)
|
||||
);
|
||||
if (currentJob) {
|
||||
return await handleJobStates(currentJob, args.deviceId);
|
||||
}
|
||||
|
||||
// Create session
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"useSemanticElements": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNestedTernary": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
@@ -70,7 +71,8 @@
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
"noForEach": "off",
|
||||
"noExcessiveCognitiveComplexity": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { GitHub } from 'arctic';
|
||||
|
||||
export type { OAuth2Tokens } from 'arctic';
|
||||
import * as Arctic from 'arctic';
|
||||
|
||||
export { Arctic };
|
||||
|
||||
export const github = new GitHub(
|
||||
process.env.GITHUB_CLIENT_ID ?? '',
|
||||
process.env.GITHUB_CLIENT_SECRET ?? '',
|
||||
process.env.GITHUB_REDIRECT_URI ?? '',
|
||||
);
|
||||
|
||||
export const google = new Arctic.Google(
|
||||
process.env.GOOGLE_CLIENT_ID ?? '',
|
||||
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
process.env.GOOGLE_REDIRECT_URI ?? '',
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GitHub } from 'arctic';
|
||||
|
||||
export type { OAuth2Tokens } from 'arctic';
|
||||
|
||||
import * as Arctic from 'arctic';
|
||||
|
||||
export { Arctic };
|
||||
@@ -8,11 +9,17 @@ export { Arctic };
|
||||
export const github = new GitHub(
|
||||
process.env.GITHUB_CLIENT_ID ?? '',
|
||||
process.env.GITHUB_CLIENT_SECRET ?? '',
|
||||
process.env.GITHUB_REDIRECT_URI ?? '',
|
||||
process.env.GITHUB_REDIRECT_URI ?? ''
|
||||
);
|
||||
|
||||
export const google = new Arctic.Google(
|
||||
process.env.GOOGLE_CLIENT_ID ?? '',
|
||||
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
process.env.GOOGLE_REDIRECT_URI ?? '',
|
||||
process.env.GOOGLE_REDIRECT_URI ?? ''
|
||||
);
|
||||
|
||||
export const googleGsc = new Arctic.Google(
|
||||
process.env.GOOGLE_CLIENT_ID ?? '',
|
||||
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
process.env.GSC_GOOGLE_REDIRECT_URI ?? ''
|
||||
);
|
||||
|
||||
85
packages/db/code-migrations/12-add-gsc.ts
Normal file
85
packages/db/code-migrations/12-add-gsc.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createTable, runClickhouseMigrationCommands } from '../src/clickhouse/migration';
|
||||
import { getIsCluster } from './helpers';
|
||||
|
||||
export async function up() {
|
||||
const isClustered = getIsCluster();
|
||||
|
||||
const commonMetricColumns = [
|
||||
'`clicks` UInt32 CODEC(Delta(4), LZ4)',
|
||||
'`impressions` UInt32 CODEC(Delta(4), LZ4)',
|
||||
'`ctr` Float32 CODEC(Gorilla, LZ4)',
|
||||
'`position` Float32 CODEC(Gorilla, LZ4)',
|
||||
'`synced_at` DateTime DEFAULT now() CODEC(Delta(4), LZ4)',
|
||||
];
|
||||
|
||||
const sqls: string[] = [
|
||||
// Daily totals — accurate overview numbers
|
||||
...createTable({
|
||||
name: 'gsc_daily',
|
||||
columns: [
|
||||
'`project_id` String CODEC(ZSTD(3))',
|
||||
'`date` Date CODEC(Delta(2), LZ4)',
|
||||
...commonMetricColumns,
|
||||
],
|
||||
orderBy: ['project_id', 'date'],
|
||||
partitionBy: 'toYYYYMM(date)',
|
||||
engine: 'ReplacingMergeTree(synced_at)',
|
||||
distributionHash: 'cityHash64(project_id)',
|
||||
replicatedVersion: '1',
|
||||
isClustered,
|
||||
}),
|
||||
|
||||
// Per-page breakdown
|
||||
...createTable({
|
||||
name: 'gsc_pages_daily',
|
||||
columns: [
|
||||
'`project_id` String CODEC(ZSTD(3))',
|
||||
'`date` Date CODEC(Delta(2), LZ4)',
|
||||
'`page` String CODEC(ZSTD(3))',
|
||||
...commonMetricColumns,
|
||||
],
|
||||
orderBy: ['project_id', 'date', 'page'],
|
||||
partitionBy: 'toYYYYMM(date)',
|
||||
engine: 'ReplacingMergeTree(synced_at)',
|
||||
distributionHash: 'cityHash64(project_id)',
|
||||
replicatedVersion: '1',
|
||||
isClustered,
|
||||
}),
|
||||
|
||||
// Per-query breakdown
|
||||
...createTable({
|
||||
name: 'gsc_queries_daily',
|
||||
columns: [
|
||||
'`project_id` String CODEC(ZSTD(3))',
|
||||
'`date` Date CODEC(Delta(2), LZ4)',
|
||||
'`query` String CODEC(ZSTD(3))',
|
||||
...commonMetricColumns,
|
||||
],
|
||||
orderBy: ['project_id', 'date', 'query'],
|
||||
partitionBy: 'toYYYYMM(date)',
|
||||
engine: 'ReplacingMergeTree(synced_at)',
|
||||
distributionHash: 'cityHash64(project_id)',
|
||||
replicatedVersion: '1',
|
||||
isClustered,
|
||||
}),
|
||||
];
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(__filename.replace('.ts', '.sql')),
|
||||
sqls
|
||||
.map((sql) =>
|
||||
sql
|
||||
.trim()
|
||||
.replace(/;$/, '')
|
||||
.replace(/\n{2,}/g, '\n')
|
||||
.concat(';'),
|
||||
)
|
||||
.join('\n\n---\n\n'),
|
||||
);
|
||||
|
||||
if (!process.argv.includes('--dry')) {
|
||||
await runClickhouseMigrationCommands(sqls);
|
||||
}
|
||||
}
|
||||
@@ -31,3 +31,5 @@ export * from './src/services/overview.service';
|
||||
export * from './src/services/pages.service';
|
||||
export * from './src/services/insights';
|
||||
export * from './src/session-context';
|
||||
export * from './src/gsc';
|
||||
export * from './src/encryption';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."gsc_connections" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"projectId" TEXT NOT NULL,
|
||||
"siteUrl" TEXT NOT NULL DEFAULT '',
|
||||
"accessToken" TEXT NOT NULL,
|
||||
"refreshToken" TEXT NOT NULL,
|
||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||
"lastSyncedAt" TIMESTAMP(3),
|
||||
"lastSyncStatus" TEXT,
|
||||
"lastSyncError" TEXT,
|
||||
"backfillStatus" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "gsc_connections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "gsc_connections_projectId_key" ON "public"."gsc_connections"("projectId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."gsc_connections" ADD CONSTRAINT "gsc_connections_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -203,6 +203,7 @@ model Project {
|
||||
notificationRules NotificationRule[]
|
||||
notifications Notification[]
|
||||
imports Import[]
|
||||
gscConnection GscConnection?
|
||||
|
||||
// When deleteAt > now(), the project will be deleted
|
||||
deleteAt DateTime?
|
||||
@@ -612,6 +613,24 @@ model InsightEvent {
|
||||
@@map("insight_events")
|
||||
}
|
||||
|
||||
model GscConnection {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
projectId String @unique
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
siteUrl String @default("")
|
||||
accessToken String
|
||||
refreshToken String
|
||||
accessTokenExpiresAt DateTime?
|
||||
lastSyncedAt DateTime?
|
||||
lastSyncStatus String?
|
||||
lastSyncError String?
|
||||
backfillStatus String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("gsc_connections")
|
||||
}
|
||||
|
||||
model EmailUnsubscribe {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
email String
|
||||
|
||||
@@ -2,42 +2,8 @@ import { getRedisCache } from '@openpanel/redis';
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ch } from '../clickhouse/client';
|
||||
|
||||
// Mock transformEvent to avoid circular dependency with buffers -> services -> buffers
|
||||
vi.mock('../services/event.service', () => ({
|
||||
transformEvent: (event: any) => ({
|
||||
id: event.id ?? 'id',
|
||||
name: event.name,
|
||||
deviceId: event.device_id,
|
||||
profileId: event.profile_id,
|
||||
projectId: event.project_id,
|
||||
sessionId: event.session_id,
|
||||
properties: event.properties ?? {},
|
||||
createdAt: new Date(event.created_at ?? Date.now()),
|
||||
country: event.country,
|
||||
city: event.city,
|
||||
region: event.region,
|
||||
longitude: event.longitude,
|
||||
latitude: event.latitude,
|
||||
os: event.os,
|
||||
osVersion: event.os_version,
|
||||
browser: event.browser,
|
||||
browserVersion: event.browser_version,
|
||||
device: event.device,
|
||||
brand: event.brand,
|
||||
model: event.model,
|
||||
duration: event.duration ?? 0,
|
||||
path: event.path ?? '',
|
||||
origin: event.origin ?? '',
|
||||
referrer: event.referrer,
|
||||
referrerName: event.referrer_name,
|
||||
referrerType: event.referrer_type,
|
||||
meta: event.meta,
|
||||
importedAt: undefined,
|
||||
sdkName: event.sdk_name,
|
||||
sdkVersion: event.sdk_version,
|
||||
profile: event.profile,
|
||||
}),
|
||||
}));
|
||||
// Break circular dep: event-buffer -> event.service -> buffers/index -> EventBuffer
|
||||
vi.mock('../services/event.service', () => ({}));
|
||||
|
||||
import { EventBuffer } from './event-buffer';
|
||||
|
||||
@@ -68,18 +34,16 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
// Get initial count
|
||||
const initialCount = await eventBuffer.getBufferSize();
|
||||
|
||||
// Add event
|
||||
await eventBuffer.add(event);
|
||||
eventBuffer.add(event);
|
||||
await eventBuffer.flush();
|
||||
|
||||
// Buffer counter should increase by 1
|
||||
const newCount = await eventBuffer.getBufferSize();
|
||||
expect(newCount).toBe(initialCount + 1);
|
||||
});
|
||||
|
||||
it('adds multiple screen_views - moves previous to buffer with duration', async () => {
|
||||
it('adds screen_view directly to buffer queue', async () => {
|
||||
const t0 = Date.now();
|
||||
const sessionId = 'session_1';
|
||||
|
||||
@@ -99,60 +63,23 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date(t0 + 1000).toISOString(),
|
||||
} as any;
|
||||
|
||||
const view3 = {
|
||||
project_id: 'p1',
|
||||
profile_id: 'u1',
|
||||
session_id: sessionId,
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0 + 3000).toISOString(),
|
||||
} as any;
|
||||
|
||||
// Add first screen_view
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
await eventBuffer.add(view1);
|
||||
|
||||
// Should be stored as "last" but NOT in queue yet
|
||||
eventBuffer.add(view1);
|
||||
await eventBuffer.flush();
|
||||
|
||||
// screen_view goes directly to buffer
|
||||
const count2 = await eventBuffer.getBufferSize();
|
||||
expect(count2).toBe(count1); // No change in buffer
|
||||
expect(count2).toBe(count1 + 1);
|
||||
|
||||
// Last screen_view should be retrievable
|
||||
const last1 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p1',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
expect(last1).not.toBeNull();
|
||||
expect(last1!.createdAt.toISOString()).toBe(view1.created_at);
|
||||
eventBuffer.add(view2);
|
||||
await eventBuffer.flush();
|
||||
|
||||
// Add second screen_view
|
||||
await eventBuffer.add(view2);
|
||||
|
||||
// Now view1 should be in buffer
|
||||
const count3 = await eventBuffer.getBufferSize();
|
||||
expect(count3).toBe(count1 + 1);
|
||||
|
||||
// view2 should now be the "last"
|
||||
const last2 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p1',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
expect(last2!.createdAt.toISOString()).toBe(view2.created_at);
|
||||
|
||||
// Add third screen_view
|
||||
await eventBuffer.add(view3);
|
||||
|
||||
// Now view2 should also be in buffer
|
||||
const count4 = await eventBuffer.getBufferSize();
|
||||
expect(count4).toBe(count1 + 2);
|
||||
|
||||
// view3 should now be the "last"
|
||||
const last3 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p1',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
expect(last3!.createdAt.toISOString()).toBe(view3.created_at);
|
||||
expect(count3).toBe(count1 + 2);
|
||||
});
|
||||
|
||||
it('adds session_end - moves last screen_view and session_end to buffer', async () => {
|
||||
it('adds session_end directly to buffer queue', async () => {
|
||||
const t0 = Date.now();
|
||||
const sessionId = 'session_2';
|
||||
|
||||
@@ -172,148 +99,44 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date(t0 + 5000).toISOString(),
|
||||
} as any;
|
||||
|
||||
// Add screen_view
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
await eventBuffer.add(view);
|
||||
|
||||
// Should be stored as "last", not in buffer yet
|
||||
eventBuffer.add(view);
|
||||
eventBuffer.add(sessionEnd);
|
||||
await eventBuffer.flush();
|
||||
|
||||
const count2 = await eventBuffer.getBufferSize();
|
||||
expect(count2).toBe(count1);
|
||||
|
||||
// Add session_end
|
||||
await eventBuffer.add(sessionEnd);
|
||||
|
||||
// Both should now be in buffer (+2)
|
||||
const count3 = await eventBuffer.getBufferSize();
|
||||
expect(count3).toBe(count1 + 2);
|
||||
|
||||
// Last screen_view should be cleared
|
||||
const last = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p2',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
expect(last).toBeNull();
|
||||
});
|
||||
|
||||
it('session_end with no previous screen_view - only adds session_end to buffer', async () => {
|
||||
const sessionId = 'session_3';
|
||||
|
||||
const sessionEnd = {
|
||||
project_id: 'p3',
|
||||
profile_id: 'u3',
|
||||
session_id: sessionId,
|
||||
name: 'session_end',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
await eventBuffer.add(sessionEnd);
|
||||
|
||||
// Only session_end should be in buffer (+1)
|
||||
const count2 = await eventBuffer.getBufferSize();
|
||||
expect(count2).toBe(count1 + 1);
|
||||
});
|
||||
|
||||
it('gets last screen_view by profileId', async () => {
|
||||
const view = {
|
||||
project_id: 'p4',
|
||||
profile_id: 'u4',
|
||||
session_id: 'session_4',
|
||||
name: 'screen_view',
|
||||
path: '/home',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
await eventBuffer.add(view);
|
||||
|
||||
// Query by profileId
|
||||
const result = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p4',
|
||||
profileId: 'u4',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('screen_view');
|
||||
expect(result!.path).toBe('/home');
|
||||
});
|
||||
|
||||
it('gets last screen_view by sessionId', async () => {
|
||||
const sessionId = 'session_5';
|
||||
const view = {
|
||||
project_id: 'p5',
|
||||
profile_id: 'u5',
|
||||
session_id: sessionId,
|
||||
name: 'screen_view',
|
||||
path: '/about',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
await eventBuffer.add(view);
|
||||
|
||||
// Query by sessionId
|
||||
const result = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p5',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('screen_view');
|
||||
expect(result!.path).toBe('/about');
|
||||
});
|
||||
|
||||
it('returns null for non-existent last screen_view', async () => {
|
||||
const result = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p_nonexistent',
|
||||
profileId: 'u_nonexistent',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(count2).toBe(count1 + 2);
|
||||
});
|
||||
|
||||
it('gets buffer count correctly', async () => {
|
||||
// Initially 0
|
||||
expect(await eventBuffer.getBufferSize()).toBe(0);
|
||||
|
||||
// Add regular event
|
||||
await eventBuffer.add({
|
||||
eventBuffer.add({
|
||||
project_id: 'p6',
|
||||
name: 'event1',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any);
|
||||
|
||||
await eventBuffer.flush();
|
||||
expect(await eventBuffer.getBufferSize()).toBe(1);
|
||||
|
||||
// Add another regular event
|
||||
await eventBuffer.add({
|
||||
eventBuffer.add({
|
||||
project_id: 'p6',
|
||||
name: 'event2',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any);
|
||||
|
||||
await eventBuffer.flush();
|
||||
expect(await eventBuffer.getBufferSize()).toBe(2);
|
||||
|
||||
// Add screen_view (not counted until flushed)
|
||||
await eventBuffer.add({
|
||||
// screen_view also goes directly to buffer
|
||||
eventBuffer.add({
|
||||
project_id: 'p6',
|
||||
profile_id: 'u6',
|
||||
session_id: 'session_6',
|
||||
name: 'screen_view',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any);
|
||||
|
||||
// Still 2 (screen_view is pending)
|
||||
expect(await eventBuffer.getBufferSize()).toBe(2);
|
||||
|
||||
// Add another screen_view (first one gets flushed)
|
||||
await eventBuffer.add({
|
||||
project_id: 'p6',
|
||||
profile_id: 'u6',
|
||||
session_id: 'session_6',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(Date.now() + 1000).toISOString(),
|
||||
} as any);
|
||||
|
||||
// Now 3 (2 regular + 1 flushed screen_view)
|
||||
await eventBuffer.flush();
|
||||
expect(await eventBuffer.getBufferSize()).toBe(3);
|
||||
});
|
||||
|
||||
@@ -330,8 +153,9 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date(Date.now() + 1000).toISOString(),
|
||||
} as any;
|
||||
|
||||
await eventBuffer.add(event1);
|
||||
await eventBuffer.add(event2);
|
||||
eventBuffer.add(event1);
|
||||
eventBuffer.add(event2);
|
||||
await eventBuffer.flush();
|
||||
|
||||
expect(await eventBuffer.getBufferSize()).toBe(2);
|
||||
|
||||
@@ -341,14 +165,12 @@ describe('EventBuffer', () => {
|
||||
|
||||
await eventBuffer.processBuffer();
|
||||
|
||||
// Should insert both events
|
||||
expect(insertSpy).toHaveBeenCalled();
|
||||
const callArgs = insertSpy.mock.calls[0]![0];
|
||||
expect(callArgs.format).toBe('JSONEachRow');
|
||||
expect(callArgs.table).toBe('events');
|
||||
expect(Array.isArray(callArgs.values)).toBe(true);
|
||||
|
||||
// Buffer should be empty after processing
|
||||
expect(await eventBuffer.getBufferSize()).toBe(0);
|
||||
|
||||
insertSpy.mockRestore();
|
||||
@@ -359,14 +181,14 @@ describe('EventBuffer', () => {
|
||||
process.env.EVENT_BUFFER_CHUNK_SIZE = '2';
|
||||
const eb = new EventBuffer();
|
||||
|
||||
// Add 4 events
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await eb.add({
|
||||
eb.add({
|
||||
project_id: 'p8',
|
||||
name: `event${i}`,
|
||||
created_at: new Date(Date.now() + i).toISOString(),
|
||||
} as any);
|
||||
}
|
||||
await eb.flush();
|
||||
|
||||
const insertSpy = vi
|
||||
.spyOn(ch, 'insert')
|
||||
@@ -374,14 +196,12 @@ describe('EventBuffer', () => {
|
||||
|
||||
await eb.processBuffer();
|
||||
|
||||
// With chunk size 2 and 4 events, should be called twice
|
||||
expect(insertSpy).toHaveBeenCalledTimes(2);
|
||||
const call1Values = insertSpy.mock.calls[0]![0].values as any[];
|
||||
const call2Values = insertSpy.mock.calls[1]![0].values as any[];
|
||||
expect(call1Values.length).toBe(2);
|
||||
expect(call2Values.length).toBe(2);
|
||||
|
||||
// Restore
|
||||
if (prev === undefined) delete process.env.EVENT_BUFFER_CHUNK_SIZE;
|
||||
else process.env.EVENT_BUFFER_CHUNK_SIZE = prev;
|
||||
|
||||
@@ -396,129 +216,61 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
await eventBuffer.add(event);
|
||||
eventBuffer.add(event);
|
||||
await eventBuffer.flush();
|
||||
|
||||
const count = await eventBuffer.getActiveVisitorCount('p9');
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('handles multiple sessions independently', async () => {
|
||||
it('handles multiple sessions independently — all events go to buffer', async () => {
|
||||
const t0 = Date.now();
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
|
||||
// Session 1
|
||||
const view1a = {
|
||||
eventBuffer.add({
|
||||
project_id: 'p10',
|
||||
profile_id: 'u10',
|
||||
session_id: 'session_10a',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0).toISOString(),
|
||||
} as any;
|
||||
|
||||
const view1b = {
|
||||
project_id: 'p10',
|
||||
profile_id: 'u10',
|
||||
session_id: 'session_10a',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0 + 1000).toISOString(),
|
||||
} as any;
|
||||
|
||||
// Session 2
|
||||
const view2a = {
|
||||
} as any);
|
||||
eventBuffer.add({
|
||||
project_id: 'p10',
|
||||
profile_id: 'u11',
|
||||
session_id: 'session_10b',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0).toISOString(),
|
||||
} as any;
|
||||
|
||||
const view2b = {
|
||||
} as any);
|
||||
eventBuffer.add({
|
||||
project_id: 'p10',
|
||||
profile_id: 'u10',
|
||||
session_id: 'session_10a',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0 + 1000).toISOString(),
|
||||
} as any);
|
||||
eventBuffer.add({
|
||||
project_id: 'p10',
|
||||
profile_id: 'u11',
|
||||
session_id: 'session_10b',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0 + 2000).toISOString(),
|
||||
} as any;
|
||||
} as any);
|
||||
await eventBuffer.flush();
|
||||
|
||||
await eventBuffer.add(view1a);
|
||||
await eventBuffer.add(view2a);
|
||||
await eventBuffer.add(view1b); // Flushes view1a
|
||||
await eventBuffer.add(view2b); // Flushes view2a
|
||||
|
||||
// Should have 2 events in buffer (one from each session)
|
||||
expect(await eventBuffer.getBufferSize()).toBe(2);
|
||||
|
||||
// Each session should have its own "last" screen_view
|
||||
const last1 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p10',
|
||||
sessionId: 'session_10a',
|
||||
});
|
||||
expect(last1!.createdAt.toISOString()).toBe(view1b.created_at);
|
||||
|
||||
const last2 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p10',
|
||||
sessionId: 'session_10b',
|
||||
});
|
||||
expect(last2!.createdAt.toISOString()).toBe(view2b.created_at);
|
||||
// All 4 events are in buffer directly
|
||||
expect(await eventBuffer.getBufferSize()).toBe(count1 + 4);
|
||||
});
|
||||
|
||||
it('screen_view without session_id goes directly to buffer', async () => {
|
||||
const view = {
|
||||
it('bulk adds events to buffer', async () => {
|
||||
const events = Array.from({ length: 5 }, (_, i) => ({
|
||||
project_id: 'p11',
|
||||
profile_id: 'u11',
|
||||
name: 'screen_view',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
name: `event${i}`,
|
||||
created_at: new Date(Date.now() + i).toISOString(),
|
||||
})) as any[];
|
||||
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
await eventBuffer.add(view);
|
||||
eventBuffer.bulkAdd(events);
|
||||
await eventBuffer.flush();
|
||||
|
||||
// Should go directly to buffer (no session_id)
|
||||
const count2 = await eventBuffer.getBufferSize();
|
||||
expect(count2).toBe(count1 + 1);
|
||||
});
|
||||
|
||||
it('updates last screen_view when new one arrives from same profile but different session', async () => {
|
||||
const t0 = Date.now();
|
||||
|
||||
const view1 = {
|
||||
project_id: 'p12',
|
||||
profile_id: 'u12',
|
||||
session_id: 'session_12a',
|
||||
name: 'screen_view',
|
||||
path: '/page1',
|
||||
created_at: new Date(t0).toISOString(),
|
||||
} as any;
|
||||
|
||||
const view2 = {
|
||||
project_id: 'p12',
|
||||
profile_id: 'u12',
|
||||
session_id: 'session_12b', // Different session!
|
||||
name: 'screen_view',
|
||||
path: '/page2',
|
||||
created_at: new Date(t0 + 1000).toISOString(),
|
||||
} as any;
|
||||
|
||||
await eventBuffer.add(view1);
|
||||
await eventBuffer.add(view2);
|
||||
|
||||
// Both sessions should have their own "last"
|
||||
const lastSession1 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p12',
|
||||
sessionId: 'session_12a',
|
||||
});
|
||||
expect(lastSession1!.path).toBe('/page1');
|
||||
|
||||
const lastSession2 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p12',
|
||||
sessionId: 'session_12b',
|
||||
});
|
||||
expect(lastSession2!.path).toBe('/page2');
|
||||
|
||||
// Profile should have the latest one
|
||||
const lastProfile = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p12',
|
||||
profileId: 'u12',
|
||||
});
|
||||
expect(lastProfile!.path).toBe('/page2');
|
||||
expect(await eventBuffer.getBufferSize()).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,33 +2,13 @@ import { getSafeJson } from '@openpanel/json';
|
||||
import {
|
||||
type Redis,
|
||||
getRedisCache,
|
||||
getRedisPub,
|
||||
publishEvent,
|
||||
} from '@openpanel/redis';
|
||||
import { ch } from '../clickhouse/client';
|
||||
import {
|
||||
type IClickhouseEvent,
|
||||
type IServiceEvent,
|
||||
transformEvent,
|
||||
} from '../services/event.service';
|
||||
import { type IClickhouseEvent } from '../services/event.service';
|
||||
import { BaseBuffer } from './base-buffer';
|
||||
|
||||
/**
|
||||
* Simplified Event Buffer
|
||||
*
|
||||
* Rules:
|
||||
* 1. All events go into a single list buffer (event_buffer:queue)
|
||||
* 2. screen_view events are handled specially:
|
||||
* - Store current screen_view as "last" for the session
|
||||
* - When a new screen_view arrives, flush the previous one with calculated duration
|
||||
* 3. session_end events:
|
||||
* - Retrieve the last screen_view (don't modify it)
|
||||
* - Push both screen_view and session_end to buffer
|
||||
* 4. Flush: Simply process all events from the list buffer
|
||||
*/
|
||||
|
||||
export class EventBuffer extends BaseBuffer {
|
||||
// Configurable limits
|
||||
private batchSize = process.env.EVENT_BUFFER_BATCH_SIZE
|
||||
? Number.parseInt(process.env.EVENT_BUFFER_BATCH_SIZE, 10)
|
||||
: 4000;
|
||||
@@ -36,124 +16,26 @@ export class EventBuffer extends BaseBuffer {
|
||||
? Number.parseInt(process.env.EVENT_BUFFER_CHUNK_SIZE, 10)
|
||||
: 1000;
|
||||
|
||||
private microBatchIntervalMs = process.env.EVENT_BUFFER_MICRO_BATCH_MS
|
||||
? Number.parseInt(process.env.EVENT_BUFFER_MICRO_BATCH_MS, 10)
|
||||
: 10;
|
||||
private microBatchMaxSize = process.env.EVENT_BUFFER_MICRO_BATCH_SIZE
|
||||
? Number.parseInt(process.env.EVENT_BUFFER_MICRO_BATCH_SIZE, 10)
|
||||
: 100;
|
||||
|
||||
private pendingEvents: IClickhouseEvent[] = [];
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isFlushing = false;
|
||||
/** Tracks consecutive flush failures for observability; reset on success. */
|
||||
private flushRetryCount = 0;
|
||||
|
||||
private activeVisitorsExpiration = 60 * 5; // 5 minutes
|
||||
|
||||
// LIST - Stores all events ready to be flushed
|
||||
/** How often (ms) we refresh the heartbeat key + zadd per visitor. */
|
||||
private heartbeatRefreshMs = 60_000; // 1 minute
|
||||
private lastHeartbeat = new Map<string, number>();
|
||||
private queueKey = 'event_buffer:queue';
|
||||
|
||||
// STRING - Tracks total buffer size incrementally
|
||||
protected bufferCounterKey = 'event_buffer:total_count';
|
||||
|
||||
// Script SHAs for loaded Lua scripts
|
||||
private scriptShas: {
|
||||
addScreenView?: string;
|
||||
addSessionEnd?: string;
|
||||
} = {};
|
||||
|
||||
// Hash key for storing last screen_view per session
|
||||
private getLastScreenViewKeyBySession(sessionId: string) {
|
||||
return `event_buffer:last_screen_view:session:${sessionId}`;
|
||||
}
|
||||
|
||||
// Hash key for storing last screen_view per profile
|
||||
private getLastScreenViewKeyByProfile(projectId: string, profileId: string) {
|
||||
return `event_buffer:last_screen_view:profile:${projectId}:${profileId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lua script for handling screen_view addition - RACE-CONDITION SAFE without GroupMQ
|
||||
*
|
||||
* Strategy: Use Redis GETDEL (atomic get-and-delete) to ensure only ONE thread
|
||||
* can process the "last" screen_view at a time.
|
||||
*
|
||||
* KEYS[1] = last screen_view key (by session) - stores both event and timestamp as JSON
|
||||
* KEYS[2] = last screen_view key (by profile, may be empty)
|
||||
* KEYS[3] = queue key
|
||||
* KEYS[4] = buffer counter key
|
||||
* ARGV[1] = new event with timestamp as JSON: {"event": {...}, "ts": 123456}
|
||||
* ARGV[2] = TTL for last screen_view (1 hour)
|
||||
*/
|
||||
private readonly addScreenViewScript = `
|
||||
local sessionKey = KEYS[1]
|
||||
local profileKey = KEYS[2]
|
||||
local queueKey = KEYS[3]
|
||||
local counterKey = KEYS[4]
|
||||
local newEventData = ARGV[1]
|
||||
local ttl = tonumber(ARGV[2])
|
||||
|
||||
-- GETDEL is atomic: get previous and delete in one operation
|
||||
-- This ensures only ONE thread gets the previous event
|
||||
local previousEventData = redis.call("GETDEL", sessionKey)
|
||||
|
||||
-- Store new screen_view as last for session
|
||||
redis.call("SET", sessionKey, newEventData, "EX", ttl)
|
||||
|
||||
-- Store new screen_view as last for profile (if key provided)
|
||||
if profileKey and profileKey ~= "" then
|
||||
redis.call("SET", profileKey, newEventData, "EX", ttl)
|
||||
end
|
||||
|
||||
-- If there was a previous screen_view, add it to queue with calculated duration
|
||||
if previousEventData then
|
||||
local prev = cjson.decode(previousEventData)
|
||||
local curr = cjson.decode(newEventData)
|
||||
|
||||
-- Calculate duration (ensure non-negative to handle clock skew)
|
||||
if prev.ts and curr.ts then
|
||||
prev.event.duration = math.max(0, curr.ts - prev.ts)
|
||||
end
|
||||
|
||||
redis.call("RPUSH", queueKey, cjson.encode(prev.event))
|
||||
redis.call("INCR", counterKey)
|
||||
return 1
|
||||
end
|
||||
|
||||
return 0
|
||||
`;
|
||||
|
||||
/**
|
||||
* Lua script for handling session_end - RACE-CONDITION SAFE
|
||||
*
|
||||
* Uses GETDEL to atomically retrieve and delete the last screen_view
|
||||
*
|
||||
* KEYS[1] = last screen_view key (by session)
|
||||
* KEYS[2] = last screen_view key (by profile, may be empty)
|
||||
* KEYS[3] = queue key
|
||||
* KEYS[4] = buffer counter key
|
||||
* ARGV[1] = session_end event JSON
|
||||
*/
|
||||
private readonly addSessionEndScript = `
|
||||
local sessionKey = KEYS[1]
|
||||
local profileKey = KEYS[2]
|
||||
local queueKey = KEYS[3]
|
||||
local counterKey = KEYS[4]
|
||||
local sessionEndJson = ARGV[1]
|
||||
|
||||
-- GETDEL is atomic: only ONE thread gets the last screen_view
|
||||
local previousEventData = redis.call("GETDEL", sessionKey)
|
||||
local added = 0
|
||||
|
||||
-- If there was a previous screen_view, add it to queue
|
||||
if previousEventData then
|
||||
local prev = cjson.decode(previousEventData)
|
||||
redis.call("RPUSH", queueKey, cjson.encode(prev.event))
|
||||
redis.call("INCR", counterKey)
|
||||
added = added + 1
|
||||
end
|
||||
|
||||
-- Add session_end to queue
|
||||
redis.call("RPUSH", queueKey, sessionEndJson)
|
||||
redis.call("INCR", counterKey)
|
||||
added = added + 1
|
||||
|
||||
-- Delete profile key
|
||||
if profileKey and profileKey ~= "" then
|
||||
redis.call("DEL", profileKey)
|
||||
end
|
||||
|
||||
return added
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
name: 'event',
|
||||
@@ -161,170 +43,97 @@ return added
|
||||
await this.processBuffer();
|
||||
},
|
||||
});
|
||||
// Load Lua scripts into Redis on startup
|
||||
this.loadScripts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Lua scripts into Redis and cache their SHAs.
|
||||
* This avoids sending the entire script on every call.
|
||||
*/
|
||||
private async loadScripts() {
|
||||
try {
|
||||
const redis = getRedisCache();
|
||||
const [screenViewSha, sessionEndSha] = await Promise.all([
|
||||
redis.script('LOAD', this.addScreenViewScript),
|
||||
redis.script('LOAD', this.addSessionEndScript),
|
||||
]);
|
||||
|
||||
this.scriptShas.addScreenView = screenViewSha as string;
|
||||
this.scriptShas.addSessionEnd = sessionEndSha as string;
|
||||
|
||||
this.logger.info('Loaded Lua scripts into Redis', {
|
||||
addScreenView: this.scriptShas.addScreenView,
|
||||
addSessionEnd: this.scriptShas.addSessionEnd,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load Lua scripts', { error });
|
||||
}
|
||||
}
|
||||
|
||||
bulkAdd(events: IClickhouseEvent[]) {
|
||||
const redis = getRedisCache();
|
||||
const multi = redis.multi();
|
||||
for (const event of events) {
|
||||
this.add(event, multi);
|
||||
this.add(event);
|
||||
}
|
||||
return multi.exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event into Redis buffer.
|
||||
*
|
||||
* Logic:
|
||||
* - screen_view: Store as "last" for session, flush previous if exists
|
||||
* - session_end: Flush last screen_view + session_end
|
||||
* - Other events: Add directly to queue
|
||||
*/
|
||||
async add(event: IClickhouseEvent, _multi?: ReturnType<Redis['multi']>) {
|
||||
add(event: IClickhouseEvent) {
|
||||
this.pendingEvents.push(event);
|
||||
|
||||
if (this.pendingEvents.length >= this.microBatchMaxSize) {
|
||||
this.flushLocalBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.flushTimer) {
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
this.flushLocalBuffer();
|
||||
}, this.microBatchIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
public async flush() {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
await this.flushLocalBuffer();
|
||||
}
|
||||
|
||||
private async flushLocalBuffer() {
|
||||
if (this.isFlushing || this.pendingEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isFlushing = true;
|
||||
|
||||
const eventsToFlush = this.pendingEvents;
|
||||
this.pendingEvents = [];
|
||||
|
||||
try {
|
||||
const redis = getRedisCache();
|
||||
const eventJson = JSON.stringify(event);
|
||||
const multi = _multi || redis.multi();
|
||||
const multi = redis.multi();
|
||||
|
||||
if (event.session_id && event.name === 'screen_view') {
|
||||
// Handle screen_view
|
||||
const sessionKey = this.getLastScreenViewKeyBySession(event.session_id);
|
||||
const profileKey = event.profile_id
|
||||
? this.getLastScreenViewKeyByProfile(
|
||||
event.project_id,
|
||||
event.profile_id,
|
||||
)
|
||||
: '';
|
||||
const timestamp = new Date(event.created_at || Date.now()).getTime();
|
||||
|
||||
// Combine event and timestamp into single JSON for atomic operations
|
||||
const eventWithTimestamp = JSON.stringify({
|
||||
event: event,
|
||||
ts: timestamp,
|
||||
});
|
||||
|
||||
this.evalScript(
|
||||
multi,
|
||||
'addScreenView',
|
||||
this.addScreenViewScript,
|
||||
4,
|
||||
sessionKey,
|
||||
profileKey,
|
||||
this.queueKey,
|
||||
this.bufferCounterKey,
|
||||
eventWithTimestamp,
|
||||
'3600', // 1 hour TTL
|
||||
);
|
||||
} else if (event.session_id && event.name === 'session_end') {
|
||||
// Handle session_end
|
||||
const sessionKey = this.getLastScreenViewKeyBySession(event.session_id);
|
||||
const profileKey = event.profile_id
|
||||
? this.getLastScreenViewKeyByProfile(
|
||||
event.project_id,
|
||||
event.profile_id,
|
||||
)
|
||||
: '';
|
||||
|
||||
this.evalScript(
|
||||
multi,
|
||||
'addSessionEnd',
|
||||
this.addSessionEndScript,
|
||||
4,
|
||||
sessionKey,
|
||||
profileKey,
|
||||
this.queueKey,
|
||||
this.bufferCounterKey,
|
||||
eventJson,
|
||||
);
|
||||
} else {
|
||||
// All other events go directly to queue
|
||||
multi.rpush(this.queueKey, eventJson).incr(this.bufferCounterKey);
|
||||
for (const event of eventsToFlush) {
|
||||
multi.rpush(this.queueKey, JSON.stringify(event));
|
||||
if (event.profile_id) {
|
||||
this.incrementActiveVisitorCount(
|
||||
multi,
|
||||
event.project_id,
|
||||
event.profile_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
multi.incrby(this.bufferCounterKey, eventsToFlush.length);
|
||||
|
||||
if (event.profile_id) {
|
||||
this.incrementActiveVisitorCount(
|
||||
multi,
|
||||
event.project_id,
|
||||
event.profile_id,
|
||||
);
|
||||
}
|
||||
await multi.exec();
|
||||
|
||||
if (!_multi) {
|
||||
await multi.exec();
|
||||
}
|
||||
|
||||
await publishEvent('events', 'received', transformEvent(event));
|
||||
this.flushRetryCount = 0;
|
||||
this.pruneHeartbeatMap();
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to add event to Redis buffer', { error });
|
||||
// Re-queue failed events at the front to preserve order and avoid data loss
|
||||
this.pendingEvents = eventsToFlush.concat(this.pendingEvents);
|
||||
|
||||
this.flushRetryCount += 1;
|
||||
this.logger.warn(
|
||||
'Failed to flush local buffer to Redis; events re-queued',
|
||||
{
|
||||
error,
|
||||
eventCount: eventsToFlush.length,
|
||||
flushRetryCount: this.flushRetryCount,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
this.isFlushing = false;
|
||||
// Events may have accumulated while we were flushing; schedule another flush if needed
|
||||
if (this.pendingEvents.length > 0 && !this.flushTimer) {
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
this.flushLocalBuffer();
|
||||
}, this.microBatchIntervalMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Lua script using EVALSHA (cached) or fallback to EVAL.
|
||||
* This avoids sending the entire script on every call.
|
||||
*/
|
||||
private evalScript(
|
||||
multi: ReturnType<Redis['multi']>,
|
||||
scriptName: keyof typeof this.scriptShas,
|
||||
scriptContent: string,
|
||||
numKeys: number,
|
||||
...args: (string | number)[]
|
||||
) {
|
||||
const sha = this.scriptShas[scriptName];
|
||||
|
||||
if (sha) {
|
||||
// Use EVALSHA with cached SHA
|
||||
multi.evalsha(sha, numKeys, ...args);
|
||||
} else {
|
||||
// Fallback to EVAL and try to reload script
|
||||
multi.eval(scriptContent, numKeys, ...args);
|
||||
this.logger.warn(`Script ${scriptName} not loaded, using EVAL fallback`);
|
||||
// Attempt to reload scripts in background
|
||||
this.loadScripts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the Redis buffer - simplified version.
|
||||
*
|
||||
* Simply:
|
||||
* 1. Fetch events from the queue (up to batchSize)
|
||||
* 2. Parse and sort them
|
||||
* 3. Insert into ClickHouse in chunks
|
||||
* 4. Publish saved events
|
||||
* 5. Clean up processed events from queue
|
||||
*/
|
||||
async processBuffer() {
|
||||
const redis = getRedisCache();
|
||||
|
||||
try {
|
||||
// Fetch events from queue
|
||||
const queueEvents = await redis.lrange(
|
||||
this.queueKey,
|
||||
0,
|
||||
@@ -336,7 +145,6 @@ return added
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse events
|
||||
const eventsToClickhouse: IClickhouseEvent[] = [];
|
||||
for (const eventStr of queueEvents) {
|
||||
const event = getSafeJson<IClickhouseEvent>(eventStr);
|
||||
@@ -350,14 +158,12 @@ return added
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort events by creation time
|
||||
eventsToClickhouse.sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at || 0).getTime() -
|
||||
new Date(b.created_at || 0).getTime(),
|
||||
);
|
||||
|
||||
// Insert events into ClickHouse in chunks
|
||||
this.logger.info('Inserting events into ClickHouse', {
|
||||
totalEvents: eventsToClickhouse.length,
|
||||
chunks: Math.ceil(eventsToClickhouse.length / this.chunkSize),
|
||||
@@ -371,14 +177,17 @@ return added
|
||||
});
|
||||
}
|
||||
|
||||
// Publish "saved" events
|
||||
const pubMulti = getRedisPub().multi();
|
||||
const countByProject = new Map<string, number>();
|
||||
for (const event of eventsToClickhouse) {
|
||||
await publishEvent('events', 'saved', transformEvent(event), pubMulti);
|
||||
countByProject.set(
|
||||
event.project_id,
|
||||
(countByProject.get(event.project_id) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
for (const [projectId, count] of countByProject) {
|
||||
publishEvent('events', 'batch', { projectId, count });
|
||||
}
|
||||
await pubMulti.exec();
|
||||
|
||||
// Clean up processed events from queue
|
||||
await redis
|
||||
.multi()
|
||||
.ltrim(this.queueKey, queueEvents.length, -1)
|
||||
@@ -394,45 +203,6 @@ return added
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the latest screen_view event for a given session or profile
|
||||
*/
|
||||
public async getLastScreenView(
|
||||
params:
|
||||
| {
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
},
|
||||
): Promise<IServiceEvent | null> {
|
||||
const redis = getRedisCache();
|
||||
|
||||
let lastScreenViewKey: string;
|
||||
if ('sessionId' in params) {
|
||||
lastScreenViewKey = this.getLastScreenViewKeyBySession(params.sessionId);
|
||||
} else {
|
||||
lastScreenViewKey = this.getLastScreenViewKeyByProfile(
|
||||
params.projectId,
|
||||
params.profileId,
|
||||
);
|
||||
}
|
||||
|
||||
const eventDataStr = await redis.get(lastScreenViewKey);
|
||||
|
||||
if (eventDataStr) {
|
||||
const eventData = getSafeJson<{ event: IClickhouseEvent; ts: number }>(
|
||||
eventDataStr,
|
||||
);
|
||||
if (eventData?.event) {
|
||||
return transformEvent(eventData.event);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getBufferSize() {
|
||||
return this.getBufferSizeWithCounter(async () => {
|
||||
const redis = getRedisCache();
|
||||
@@ -440,16 +210,32 @@ return added
|
||||
});
|
||||
}
|
||||
|
||||
private async incrementActiveVisitorCount(
|
||||
private pruneHeartbeatMap() {
|
||||
const cutoff = Date.now() - this.activeVisitorsExpiration * 1000;
|
||||
for (const [key, ts] of this.lastHeartbeat) {
|
||||
if (ts < cutoff) {
|
||||
this.lastHeartbeat.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private incrementActiveVisitorCount(
|
||||
multi: ReturnType<Redis['multi']>,
|
||||
projectId: string,
|
||||
profileId: string,
|
||||
) {
|
||||
// Track active visitors and emit expiry events when inactive for TTL
|
||||
const key = `${projectId}:${profileId}`;
|
||||
const now = Date.now();
|
||||
const last = this.lastHeartbeat.get(key) ?? 0;
|
||||
|
||||
if (now - last < this.heartbeatRefreshMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastHeartbeat.set(key, now);
|
||||
const zsetKey = `live:visitors:${projectId}`;
|
||||
const heartbeatKey = `live:visitor:${projectId}:${profileId}`;
|
||||
return multi
|
||||
multi
|
||||
.zadd(zsetKey, now, profileId)
|
||||
.set(heartbeatKey, '1', 'EX', this.activeVisitorsExpiration);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { deepMergeObjects } from '@openpanel/common';
|
||||
import { getSafeJson } from '@openpanel/json';
|
||||
import type { ILogger } from '@openpanel/logger';
|
||||
import { type Redis, getRedisCache } from '@openpanel/redis';
|
||||
import { getRedisCache, type Redis } from '@openpanel/redis';
|
||||
import shallowEqual from 'fast-deep-equal';
|
||||
import { omit } from 'ramda';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { TABLE_NAMES, ch, chQuery } from '../clickhouse/client';
|
||||
import { ch, chQuery, TABLE_NAMES } from '../clickhouse/client';
|
||||
import type { IClickhouseProfile } from '../services/profile.service';
|
||||
import { BaseBuffer } from './base-buffer';
|
||||
|
||||
@@ -89,7 +89,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
'os_version',
|
||||
'browser_version',
|
||||
],
|
||||
profile.properties,
|
||||
profile.properties
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,16 +97,16 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
? deepMergeObjects(existingProfile, omit(['created_at'], profile))
|
||||
: profile;
|
||||
|
||||
if (profile && existingProfile) {
|
||||
if (
|
||||
shallowEqual(
|
||||
omit(['created_at'], existingProfile),
|
||||
omit(['created_at'], mergedProfile),
|
||||
)
|
||||
) {
|
||||
this.logger.debug('Profile not changed, skipping');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
profile &&
|
||||
existingProfile &&
|
||||
shallowEqual(
|
||||
omit(['created_at'], existingProfile),
|
||||
omit(['created_at'], mergedProfile)
|
||||
)
|
||||
) {
|
||||
this.logger.debug('Profile not changed, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug('Merged profile will be inserted', {
|
||||
@@ -151,11 +151,11 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
|
||||
private async fetchProfile(
|
||||
profile: IClickhouseProfile,
|
||||
logger: ILogger,
|
||||
logger: ILogger
|
||||
): Promise<IClickhouseProfile | null> {
|
||||
const existingProfile = await this.fetchFromCache(
|
||||
profile.id,
|
||||
profile.project_id,
|
||||
profile.project_id
|
||||
);
|
||||
if (existingProfile) {
|
||||
logger.debug('Profile found in Redis');
|
||||
@@ -167,7 +167,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
|
||||
public async fetchFromCache(
|
||||
profileId: string,
|
||||
projectId: string,
|
||||
projectId: string
|
||||
): Promise<IClickhouseProfile | null> {
|
||||
const cacheKey = this.getProfileCacheKey({
|
||||
profileId,
|
||||
@@ -182,7 +182,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
|
||||
private async fetchFromClickhouse(
|
||||
profile: IClickhouseProfile,
|
||||
logger: ILogger,
|
||||
logger: ILogger
|
||||
): Promise<IClickhouseProfile | null> {
|
||||
logger.debug('Fetching profile from Clickhouse');
|
||||
const result = await chQuery<IClickhouseProfile>(
|
||||
@@ -207,7 +207,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
}
|
||||
GROUP BY id, project_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
LIMIT 1`
|
||||
);
|
||||
logger.debug('Clickhouse fetch result', {
|
||||
found: !!result[0],
|
||||
@@ -221,7 +221,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
const profiles = await this.redis.lrange(
|
||||
this.redisKey,
|
||||
0,
|
||||
this.batchSize - 1,
|
||||
this.batchSize - 1
|
||||
);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
@@ -231,7 +231,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
|
||||
this.logger.debug(`Processing ${profiles.length} profiles in buffer`);
|
||||
const parsedProfiles = profiles.map((p) =>
|
||||
getSafeJson<IClickhouseProfile>(p),
|
||||
getSafeJson<IClickhouseProfile>(p)
|
||||
);
|
||||
|
||||
for (const chunk of this.chunks(parsedProfiles, this.chunkSize)) {
|
||||
|
||||
@@ -58,6 +58,9 @@ export const TABLE_NAMES = {
|
||||
sessions: 'sessions',
|
||||
events_imports: 'events_imports',
|
||||
session_replay_chunks: 'session_replay_chunks',
|
||||
gsc_daily: 'gsc_daily',
|
||||
gsc_pages_daily: 'gsc_pages_daily',
|
||||
gsc_queries_daily: 'gsc_queries_daily',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
44
packages/db/src/encryption.ts
Normal file
44
packages/db/src/encryption.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
const ENCODING = 'base64';
|
||||
|
||||
function getKey(): Buffer {
|
||||
const raw = process.env.ENCRYPTION_KEY;
|
||||
if (!raw) {
|
||||
throw new Error('ENCRYPTION_KEY environment variable is not set');
|
||||
}
|
||||
const buf = Buffer.from(raw, 'hex');
|
||||
if (buf.length !== 32) {
|
||||
throw new Error(
|
||||
'ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'
|
||||
);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Format: base64(iv + tag + ciphertext)
|
||||
return Buffer.concat([iv, tag, encrypted]).toString(ENCODING);
|
||||
}
|
||||
|
||||
export function decrypt(ciphertext: string): string {
|
||||
const key = getKey();
|
||||
const buf = Buffer.from(ciphertext, ENCODING);
|
||||
const iv = buf.subarray(0, IV_LENGTH);
|
||||
const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(encrypted) + decipher.final('utf8');
|
||||
}
|
||||
576
packages/db/src/gsc.ts
Normal file
576
packages/db/src/gsc.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import { originalCh } from './clickhouse/client';
|
||||
import { decrypt, encrypt } from './encryption';
|
||||
import { createLogger } from '@openpanel/logger';
|
||||
import { db } from './prisma-client';
|
||||
|
||||
const logger = createLogger({ name: 'db:gsc' });
|
||||
|
||||
export interface GscSite {
|
||||
siteUrl: string;
|
||||
permissionLevel: string;
|
||||
}
|
||||
|
||||
async function refreshGscToken(
|
||||
refreshToken: string
|
||||
): Promise<{ accessToken: string; expiresAt: Date }> {
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||
throw new Error(
|
||||
'GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET is not set in this environment'
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: process.env.GOOGLE_CLIENT_ID,
|
||||
client_secret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Failed to refresh GSC token: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
const expiresAt = new Date(Date.now() + data.expires_in * 1000);
|
||||
return { accessToken: data.access_token, expiresAt };
|
||||
}
|
||||
|
||||
export async function getGscAccessToken(projectId: string): Promise<string> {
|
||||
const conn = await db.gscConnection.findUniqueOrThrow({
|
||||
where: { projectId },
|
||||
});
|
||||
|
||||
if (
|
||||
conn.accessTokenExpiresAt &&
|
||||
conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000
|
||||
) {
|
||||
logger.info('GSC using cached access token', {
|
||||
projectId,
|
||||
expiresAt: conn.accessTokenExpiresAt,
|
||||
});
|
||||
return decrypt(conn.accessToken);
|
||||
}
|
||||
|
||||
logger.info('GSC access token expired, attempting refresh', {
|
||||
projectId,
|
||||
expiresAt: conn.accessTokenExpiresAt,
|
||||
hasRefreshToken: !!conn.refreshToken,
|
||||
});
|
||||
|
||||
try {
|
||||
const { accessToken, expiresAt } = await refreshGscToken(
|
||||
decrypt(conn.refreshToken)
|
||||
);
|
||||
await db.gscConnection.update({
|
||||
where: { projectId },
|
||||
data: { accessToken: encrypt(accessToken), accessTokenExpiresAt: expiresAt },
|
||||
});
|
||||
logger.info('GSC token refreshed successfully', { projectId, expiresAt });
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to refresh token';
|
||||
logger.error('GSC token refresh failed', { projectId, error: errorMessage });
|
||||
await db.gscConnection.update({
|
||||
where: { projectId },
|
||||
data: {
|
||||
lastSyncStatus: 'token_expired',
|
||||
lastSyncError: errorMessage,
|
||||
},
|
||||
});
|
||||
throw new Error(
|
||||
`GSC token refresh failed for project ${projectId}: ${errorMessage}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGscSites(projectId: string): Promise<GscSite[]> {
|
||||
const accessToken = await getGscAccessToken(projectId);
|
||||
const res = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Failed to list GSC sites: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
siteEntry?: Array<{ siteUrl: string; permissionLevel: string }>;
|
||||
};
|
||||
return data.siteEntry ?? [];
|
||||
}
|
||||
|
||||
interface GscApiRow {
|
||||
keys: string[];
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface GscDimensionFilter {
|
||||
dimension: string;
|
||||
operator: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
interface GscFilterGroup {
|
||||
filters: GscDimensionFilter[];
|
||||
}
|
||||
|
||||
async function queryGscSearchAnalytics(
|
||||
accessToken: string,
|
||||
siteUrl: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
dimensions: string[],
|
||||
dimensionFilterGroups?: GscFilterGroup[]
|
||||
): Promise<GscApiRow[]> {
|
||||
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
||||
const url = `https://www.googleapis.com/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`;
|
||||
|
||||
const allRows: GscApiRow[] = [];
|
||||
let startRow = 0;
|
||||
const rowLimit = 25000;
|
||||
|
||||
while (true) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
startDate,
|
||||
endDate,
|
||||
dimensions,
|
||||
rowLimit,
|
||||
startRow,
|
||||
dataState: 'all',
|
||||
...(dimensionFilterGroups && { dimensionFilterGroups }),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`GSC query failed for dimensions [${dimensions.join(',')}]: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { rows?: GscApiRow[] };
|
||||
const rows = data.rows ?? [];
|
||||
allRows.push(...rows);
|
||||
|
||||
if (rows.length < rowLimit) break;
|
||||
startRow += rowLimit;
|
||||
}
|
||||
|
||||
return allRows;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function nowString(): string {
|
||||
return new Date().toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
export async function syncGscData(
|
||||
projectId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<void> {
|
||||
const conn = await db.gscConnection.findUniqueOrThrow({
|
||||
where: { projectId },
|
||||
});
|
||||
|
||||
if (!conn.siteUrl) {
|
||||
throw new Error('No GSC site URL configured for this project');
|
||||
}
|
||||
|
||||
const accessToken = await getGscAccessToken(projectId);
|
||||
const start = formatDate(startDate);
|
||||
const end = formatDate(endDate);
|
||||
const syncedAt = nowString();
|
||||
|
||||
// 1. Daily totals — authoritative numbers for overview chart
|
||||
const dailyRows = await queryGscSearchAnalytics(
|
||||
accessToken,
|
||||
conn.siteUrl,
|
||||
start,
|
||||
end,
|
||||
['date']
|
||||
);
|
||||
|
||||
if (dailyRows.length > 0) {
|
||||
await originalCh.insert({
|
||||
table: 'gsc_daily',
|
||||
values: dailyRows.map((row) => ({
|
||||
project_id: projectId,
|
||||
date: row.keys[0] ?? '',
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
synced_at: syncedAt,
|
||||
})),
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Per-page breakdown
|
||||
const pageRows = await queryGscSearchAnalytics(
|
||||
accessToken,
|
||||
conn.siteUrl,
|
||||
start,
|
||||
end,
|
||||
['date', 'page']
|
||||
);
|
||||
|
||||
if (pageRows.length > 0) {
|
||||
await originalCh.insert({
|
||||
table: 'gsc_pages_daily',
|
||||
values: pageRows.map((row) => ({
|
||||
project_id: projectId,
|
||||
date: row.keys[0] ?? '',
|
||||
page: row.keys[1] ?? '',
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
synced_at: syncedAt,
|
||||
})),
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Per-query breakdown
|
||||
const queryRows = await queryGscSearchAnalytics(
|
||||
accessToken,
|
||||
conn.siteUrl,
|
||||
start,
|
||||
end,
|
||||
['date', 'query']
|
||||
);
|
||||
|
||||
if (queryRows.length > 0) {
|
||||
await originalCh.insert({
|
||||
table: 'gsc_queries_daily',
|
||||
values: queryRows.map((row) => ({
|
||||
project_id: projectId,
|
||||
date: row.keys[0] ?? '',
|
||||
query: row.keys[1] ?? '',
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
synced_at: syncedAt,
|
||||
})),
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGscOverview(
|
||||
projectId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
interval: 'day' | 'week' | 'month' = 'day'
|
||||
): Promise<
|
||||
Array<{
|
||||
date: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}>
|
||||
> {
|
||||
const dateExpr =
|
||||
interval === 'month'
|
||||
? 'toStartOfMonth(date)'
|
||||
: interval === 'week'
|
||||
? 'toStartOfWeek(date)'
|
||||
: 'date';
|
||||
|
||||
const result = await originalCh.query({
|
||||
query: `
|
||||
SELECT
|
||||
${dateExpr} as date,
|
||||
sum(clicks) as clicks,
|
||||
sum(impressions) as impressions,
|
||||
avg(ctr) as ctr,
|
||||
avg(position) as position
|
||||
FROM gsc_daily
|
||||
FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND date >= {startDate: String}
|
||||
AND date <= {endDate: String}
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`,
|
||||
query_params: { projectId, startDate, endDate },
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
return result.json();
|
||||
}
|
||||
|
||||
export async function getGscPages(
|
||||
projectId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
limit = 100
|
||||
): Promise<
|
||||
Array<{
|
||||
page: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}>
|
||||
> {
|
||||
const result = await originalCh.query({
|
||||
query: `
|
||||
SELECT
|
||||
page,
|
||||
sum(clicks) as clicks,
|
||||
sum(impressions) as impressions,
|
||||
avg(ctr) as ctr,
|
||||
avg(position) as position
|
||||
FROM gsc_pages_daily
|
||||
FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND date >= {startDate: String}
|
||||
AND date <= {endDate: String}
|
||||
GROUP BY page
|
||||
ORDER BY clicks DESC
|
||||
LIMIT {limit: UInt32}
|
||||
`,
|
||||
query_params: { projectId, startDate, endDate, limit },
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
return result.json();
|
||||
}
|
||||
|
||||
export interface GscCannibalizedQuery {
|
||||
query: string;
|
||||
totalImpressions: number;
|
||||
totalClicks: number;
|
||||
pages: Array<{
|
||||
page: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const getGscCannibalization = cacheable(
|
||||
async (
|
||||
projectId: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<GscCannibalizedQuery[]> => {
|
||||
const conn = await db.gscConnection.findUniqueOrThrow({
|
||||
where: { projectId },
|
||||
});
|
||||
const accessToken = await getGscAccessToken(projectId);
|
||||
|
||||
const rows = await queryGscSearchAnalytics(
|
||||
accessToken,
|
||||
conn.siteUrl,
|
||||
startDate,
|
||||
endDate,
|
||||
['query', 'page']
|
||||
);
|
||||
|
||||
const map = new Map<
|
||||
string,
|
||||
{
|
||||
totalImpressions: number;
|
||||
totalClicks: number;
|
||||
pages: GscCannibalizedQuery['pages'];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const query = row.keys[0] ?? '';
|
||||
// Strip hash fragments — GSC records heading anchors (e.g. /page#section)
|
||||
// as separate URLs but Google treats them as the same page
|
||||
let page = row.keys[1] ?? '';
|
||||
try {
|
||||
const u = new URL(page);
|
||||
u.hash = '';
|
||||
page = u.toString();
|
||||
} catch {
|
||||
page = page.split('#')[0] ?? page;
|
||||
}
|
||||
|
||||
const entry = map.get(query) ?? {
|
||||
totalImpressions: 0,
|
||||
totalClicks: 0,
|
||||
pages: [],
|
||||
};
|
||||
entry.totalImpressions += row.impressions;
|
||||
entry.totalClicks += row.clicks;
|
||||
// Merge into existing page entry if already seen (from a different hash variant)
|
||||
const existing = entry.pages.find((p) => p.page === page);
|
||||
if (existing) {
|
||||
const totalImpressions = existing.impressions + row.impressions;
|
||||
if (totalImpressions > 0) {
|
||||
existing.position =
|
||||
(existing.position * existing.impressions + row.position * row.impressions) / totalImpressions;
|
||||
}
|
||||
existing.clicks += row.clicks;
|
||||
existing.impressions += row.impressions;
|
||||
existing.ctr =
|
||||
existing.impressions > 0 ? existing.clicks / existing.impressions : 0;
|
||||
} else {
|
||||
entry.pages.push({
|
||||
page,
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
});
|
||||
}
|
||||
map.set(query, entry);
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
.filter(([, v]) => v.pages.length >= 2 && v.totalImpressions >= 100)
|
||||
.sort(([, a], [, b]) => b.totalImpressions - a.totalImpressions)
|
||||
.slice(0, 50)
|
||||
.map(([query, v]) => ({
|
||||
query,
|
||||
totalImpressions: v.totalImpressions,
|
||||
totalClicks: v.totalClicks,
|
||||
pages: v.pages.sort((a, b) =>
|
||||
a.position !== b.position
|
||||
? a.position - b.position
|
||||
: b.impressions - a.impressions
|
||||
),
|
||||
}));
|
||||
},
|
||||
60 * 60 * 4
|
||||
);
|
||||
|
||||
export async function getGscPageDetails(
|
||||
projectId: string,
|
||||
page: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<{
|
||||
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
|
||||
queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>;
|
||||
}> {
|
||||
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
|
||||
const accessToken = await getGscAccessToken(projectId);
|
||||
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'page', operator: 'equals', expression: page }] }];
|
||||
|
||||
const [timeseriesRows, queryRows] = await Promise.all([
|
||||
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
|
||||
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['query'], filterGroups),
|
||||
]);
|
||||
|
||||
return {
|
||||
timeseries: timeseriesRows.map((row) => ({
|
||||
date: row.keys[0] ?? '',
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
})),
|
||||
queries: queryRows.map((row) => ({
|
||||
query: row.keys[0] ?? '',
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGscQueryDetails(
|
||||
projectId: string,
|
||||
query: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<{
|
||||
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
|
||||
pages: Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>;
|
||||
}> {
|
||||
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
|
||||
const accessToken = await getGscAccessToken(projectId);
|
||||
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'query', operator: 'equals', expression: query }] }];
|
||||
|
||||
const [timeseriesRows, pageRows] = await Promise.all([
|
||||
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
|
||||
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['page'], filterGroups),
|
||||
]);
|
||||
|
||||
return {
|
||||
timeseries: timeseriesRows.map((row) => ({
|
||||
date: row.keys[0] ?? '',
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
})),
|
||||
pages: pageRows.map((row) => ({
|
||||
page: row.keys[0] ?? '',
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGscQueries(
|
||||
projectId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
limit = 100
|
||||
): Promise<
|
||||
Array<{
|
||||
query: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}>
|
||||
> {
|
||||
const result = await originalCh.query({
|
||||
query: `
|
||||
SELECT
|
||||
query,
|
||||
sum(clicks) as clicks,
|
||||
sum(impressions) as impressions,
|
||||
avg(ctr) as ctr,
|
||||
avg(position) as position
|
||||
FROM gsc_queries_daily
|
||||
FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND date >= {startDate: String}
|
||||
AND date <= {endDate: String}
|
||||
GROUP BY query
|
||||
ORDER BY clicks DESC
|
||||
LIMIT {limit: UInt32}
|
||||
`,
|
||||
query_params: { projectId, startDate, endDate, limit },
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
return result.json();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import type { Client, Prisma } from '../prisma-client';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
@@ -34,7 +34,4 @@ export async function getClientById(
|
||||
});
|
||||
}
|
||||
|
||||
export const getClientByIdCached = cacheableLru(getClientById, {
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
});
|
||||
export const getClientByIdCached = cacheable(getClientById, 60 * 5);
|
||||
|
||||
@@ -168,7 +168,6 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
|
||||
device: event.device,
|
||||
brand: event.brand,
|
||||
model: event.model,
|
||||
duration: event.duration,
|
||||
path: event.path,
|
||||
origin: event.origin,
|
||||
referrer: event.referrer,
|
||||
@@ -216,7 +215,7 @@ export interface IServiceEvent {
|
||||
device?: string | undefined;
|
||||
brand?: string | undefined;
|
||||
model?: string | undefined;
|
||||
duration: number;
|
||||
duration?: number;
|
||||
path: string;
|
||||
origin: string;
|
||||
referrer: string | undefined;
|
||||
@@ -247,7 +246,7 @@ export interface IServiceEventMinimal {
|
||||
browser?: string | undefined;
|
||||
device?: string | undefined;
|
||||
brand?: string | undefined;
|
||||
duration: number;
|
||||
duration?: number;
|
||||
path: string;
|
||||
origin: string;
|
||||
referrer: string | undefined;
|
||||
@@ -379,7 +378,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
||||
device: payload.device ?? '',
|
||||
brand: payload.brand ?? '',
|
||||
model: payload.model ?? '',
|
||||
duration: payload.duration,
|
||||
duration: payload.duration ?? 0,
|
||||
referrer: payload.referrer ?? '',
|
||||
referrer_name: payload.referrerName ?? '',
|
||||
referrer_type: payload.referrerType ?? '',
|
||||
@@ -477,7 +476,7 @@ export async function getEventList(options: GetEventListOptions) {
|
||||
sb.where.cursor = `created_at < ${sqlstring.escape(formatClickhouseDate(cursor))}`;
|
||||
}
|
||||
|
||||
if (!cursor && !(startDate && endDate)) {
|
||||
if (!(cursor || (startDate && endDate))) {
|
||||
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
|
||||
}
|
||||
|
||||
@@ -562,9 +561,6 @@ export async function getEventList(options: GetEventListOptions) {
|
||||
if (select.model) {
|
||||
sb.select.model = 'model';
|
||||
}
|
||||
if (select.duration) {
|
||||
sb.select.duration = 'duration';
|
||||
}
|
||||
if (select.path) {
|
||||
sb.select.path = 'path';
|
||||
}
|
||||
@@ -771,7 +767,6 @@ class EventService {
|
||||
where,
|
||||
select,
|
||||
limit,
|
||||
orderBy,
|
||||
filters,
|
||||
}: {
|
||||
projectId: string;
|
||||
@@ -811,7 +806,6 @@ class EventService {
|
||||
select.event.deviceId && 'e.device_id as device_id',
|
||||
select.event.name && 'e.name as name',
|
||||
select.event.path && 'e.path as path',
|
||||
select.event.duration && 'e.duration as duration',
|
||||
select.event.country && 'e.country as country',
|
||||
select.event.city && 'e.city as city',
|
||||
select.event.os && 'e.os as os',
|
||||
@@ -896,7 +890,6 @@ class EventService {
|
||||
select.event.deviceId && 'e.device_id as device_id',
|
||||
select.event.name && 'e.name as name',
|
||||
select.event.path && 'e.path as path',
|
||||
select.event.duration && 'e.duration as duration',
|
||||
select.event.country && 'e.country as country',
|
||||
select.event.city && 'e.city as city',
|
||||
select.event.os && 'e.os as os',
|
||||
@@ -1032,7 +1025,6 @@ class EventService {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
duration: true,
|
||||
country: true,
|
||||
city: true,
|
||||
os: true,
|
||||
|
||||
@@ -90,7 +90,7 @@ export const getNotificationRulesByProjectId = cacheable(
|
||||
},
|
||||
});
|
||||
},
|
||||
60 * 24
|
||||
60 * 24,
|
||||
);
|
||||
|
||||
function getIntegration(integrationId: string | null) {
|
||||
|
||||
@@ -416,6 +416,30 @@ export class OverviewService {
|
||||
const where = this.getRawWhereClause('sessions', filters);
|
||||
const fillConfig = this.getFillConfig(interval, startDate, endDate);
|
||||
|
||||
// CTE: per-event screen_view durations via window function
|
||||
const rawScreenViewDurationsQuery = clix(this.client, timezone)
|
||||
.select([
|
||||
`${clix.toStartOf('created_at', interval as any, timezone)} AS date`,
|
||||
`dateDiff('millisecond', created_at, lead(created_at, 1, created_at) OVER (PARTITION BY session_id ORDER BY created_at)) AS duration`,
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', 'screen_view')
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(this.getRawWhereClause('events', filters));
|
||||
|
||||
// CTE: avg duration per date bucket
|
||||
const avgDurationByDateQuery = clix(this.client, timezone)
|
||||
.select([
|
||||
'date',
|
||||
'round(avgIf(duration, duration > 0), 2) / 1000 AS avg_session_duration',
|
||||
])
|
||||
.from('raw_screen_view_durations')
|
||||
.groupBy(['date']);
|
||||
|
||||
// Session aggregation with bounce rates
|
||||
const sessionAggQuery = clix(this.client, timezone)
|
||||
.select([
|
||||
@@ -473,6 +497,8 @@ export class OverviewService {
|
||||
.where('date', '!=', rollupDate)
|
||||
)
|
||||
.with('overall_unique_visitors', overallUniqueVisitorsQuery)
|
||||
.with('raw_screen_view_durations', rawScreenViewDurationsQuery)
|
||||
.with('avg_duration_by_date', avgDurationByDateQuery)
|
||||
.select<{
|
||||
date: string;
|
||||
bounce_rate: number;
|
||||
@@ -489,8 +515,7 @@ export class OverviewService {
|
||||
'dss.bounce_rate as bounce_rate',
|
||||
'uniq(e.profile_id) AS unique_visitors',
|
||||
'uniq(e.session_id) AS total_sessions',
|
||||
'round(avgIf(duration, duration > 0), 2) / 1000 AS _avg_session_duration',
|
||||
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
|
||||
'coalesce(dur.avg_session_duration, 0) AS avg_session_duration',
|
||||
'count(*) AS total_screen_views',
|
||||
'round((count(*) * 1.) / uniq(e.session_id), 2) AS views_per_session',
|
||||
'(SELECT unique_visitors FROM overall_unique_visitors) AS overall_unique_visitors',
|
||||
@@ -502,6 +527,10 @@ export class OverviewService {
|
||||
'daily_session_stats AS dss',
|
||||
`${clix.toStartOf('e.created_at', interval as any)} = dss.date`
|
||||
)
|
||||
.leftJoin(
|
||||
'avg_duration_by_date AS dur',
|
||||
`${clix.toStartOf('e.created_at', interval as any)} = dur.date`
|
||||
)
|
||||
.where('e.project_id', '=', projectId)
|
||||
.where('e.name', '=', 'screen_view')
|
||||
.where('e.created_at', 'BETWEEN', [
|
||||
@@ -509,7 +538,7 @@ export class OverviewService {
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(this.getRawWhereClause('events', filters))
|
||||
.groupBy(['date', 'dss.bounce_rate'])
|
||||
.groupBy(['date', 'dss.bounce_rate', 'dur.avg_session_duration'])
|
||||
.orderBy('date', 'ASC')
|
||||
.fill(fillConfig.from, fillConfig.to, fillConfig.step)
|
||||
.transform({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { ch, TABLE_NAMES } from '../clickhouse/client';
|
||||
import { clix } from '../clickhouse/query-builder';
|
||||
|
||||
export interface IGetPagesInput {
|
||||
@@ -7,6 +8,15 @@ export interface IGetPagesInput {
|
||||
endDate: string;
|
||||
timezone: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface IPageTimeseriesRow {
|
||||
origin: string;
|
||||
path: string;
|
||||
date: string;
|
||||
pageviews: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface ITopPage {
|
||||
@@ -28,6 +38,7 @@ export class PagesService {
|
||||
endDate,
|
||||
timezone,
|
||||
search,
|
||||
limit,
|
||||
}: IGetPagesInput): Promise<ITopPage[]> {
|
||||
// CTE: Get titles from the last 30 days for faster retrieval
|
||||
const titlesCte = clix(this.client, timezone)
|
||||
@@ -41,6 +52,24 @@ export class PagesService {
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 DAY'))
|
||||
.groupBy(['origin', 'path']);
|
||||
|
||||
// CTE: compute screen_view durations via window function (leadInFrame gives next event's timestamp)
|
||||
const screenViewDurationsCte = clix(this.client, timezone)
|
||||
.select([
|
||||
'project_id',
|
||||
'session_id',
|
||||
'path',
|
||||
'origin',
|
||||
`dateDiff('millisecond', created_at, lead(created_at, 1, created_at) OVER (PARTITION BY session_id ORDER BY created_at)) AS duration`,
|
||||
])
|
||||
.from(TABLE_NAMES.events, false)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', 'screen_view')
|
||||
.where('path', '!=', '')
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
]);
|
||||
|
||||
// Pre-filtered sessions subquery for better performance
|
||||
const sessionsSubquery = clix(this.client, timezone)
|
||||
.select(['id', 'project_id', 'is_bounce'])
|
||||
@@ -55,6 +84,7 @@ export class PagesService {
|
||||
// Main query: aggregate events and calculate bounce rate from pre-filtered sessions
|
||||
const query = clix(this.client, timezone)
|
||||
.with('page_titles', titlesCte)
|
||||
.with('screen_view_durations', screenViewDurationsCte)
|
||||
.select<ITopPage>([
|
||||
'e.origin as origin',
|
||||
'e.path as path',
|
||||
@@ -68,13 +98,63 @@ export class PagesService {
|
||||
2
|
||||
) as bounce_rate`,
|
||||
])
|
||||
.from(`${TABLE_NAMES.events} e`, false)
|
||||
.from('screen_view_durations e', false)
|
||||
.leftJoin(
|
||||
sessionsSubquery,
|
||||
'e.session_id = s.id AND e.project_id = s.project_id',
|
||||
's',
|
||||
's'
|
||||
)
|
||||
.leftJoin('page_titles pt', 'concat(e.origin, e.path) = pt.page_key')
|
||||
.when(!!search, (q) => {
|
||||
const term = `%${search}%`;
|
||||
q.whereGroup()
|
||||
.where('e.path', 'LIKE', term)
|
||||
.orWhere('e.origin', 'LIKE', term)
|
||||
.orWhere('pt.title', 'LIKE', term)
|
||||
.end();
|
||||
})
|
||||
.groupBy(['e.origin', 'e.path', 'pt.title'])
|
||||
.orderBy('sessions', 'DESC');
|
||||
if (limit !== undefined) {
|
||||
query.limit(limit);
|
||||
}
|
||||
return query.execute();
|
||||
}
|
||||
|
||||
async getPageTimeseries({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
interval,
|
||||
filterOrigin,
|
||||
filterPath,
|
||||
}: IGetPagesInput & {
|
||||
interval: IInterval;
|
||||
filterOrigin?: string;
|
||||
filterPath?: string;
|
||||
}): Promise<IPageTimeseriesRow[]> {
|
||||
const dateExpr = clix.toStartOf('e.created_at', interval, timezone);
|
||||
const useDateOnly = interval === 'month' || interval === 'week';
|
||||
const fillFrom = clix.toStartOf(
|
||||
clix.datetime(startDate, useDateOnly ? 'toDate' : 'toDateTime'),
|
||||
interval
|
||||
);
|
||||
const fillTo = clix.datetime(
|
||||
endDate,
|
||||
useDateOnly ? 'toDate' : 'toDateTime'
|
||||
);
|
||||
const fillStep = clix.toInterval('1', interval);
|
||||
|
||||
return clix(this.client, timezone)
|
||||
.select<IPageTimeseriesRow>([
|
||||
'e.origin as origin',
|
||||
'e.path as path',
|
||||
`${dateExpr} AS date`,
|
||||
'count() as pageviews',
|
||||
'uniq(e.session_id) as sessions',
|
||||
])
|
||||
.from(`${TABLE_NAMES.events} e`, false)
|
||||
.where('e.project_id', '=', projectId)
|
||||
.where('e.name', '=', 'screen_view')
|
||||
.where('e.path', '!=', '')
|
||||
@@ -82,14 +162,12 @@ export class PagesService {
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.when(!!search, (q) => {
|
||||
q.where('e.path', 'LIKE', `%${search}%`);
|
||||
})
|
||||
.groupBy(['e.origin', 'e.path', 'pt.title'])
|
||||
.orderBy('sessions', 'DESC')
|
||||
.limit(1000);
|
||||
|
||||
return query.execute();
|
||||
.when(!!filterOrigin, (q) => q.where('e.origin', '=', filterOrigin!))
|
||||
.when(!!filterPath, (q) => q.where('e.path', '=', filterPath!))
|
||||
.groupBy(['e.origin', 'e.path', 'date'])
|
||||
.orderBy('date', 'ASC')
|
||||
.fill(fillFrom, fillTo, fillStep)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { TABLE_NAMES, chQuery } from '../clickhouse/client';
|
||||
import { chQuery, TABLE_NAMES } from '../clickhouse/client';
|
||||
import type { Prisma, Project } from '../prisma-client';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function getProjectById(id: string) {
|
||||
return res;
|
||||
}
|
||||
|
||||
/** L1 LRU (60s) + L2 Redis. clear() invalidates Redis + local LRU; other nodes may serve stale from LRU for up to 60s. */
|
||||
export const getProjectByIdCached = cacheable(getProjectById, 60 * 60 * 24);
|
||||
|
||||
export async function getProjectWithClients(id: string) {
|
||||
@@ -44,7 +45,7 @@ export async function getProjectWithClients(id: string) {
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function getProjectsByOrganizationId(organizationId: string) {
|
||||
export function getProjectsByOrganizationId(organizationId: string) {
|
||||
return db.project.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
@@ -95,7 +96,7 @@ export async function getProjects({
|
||||
|
||||
if (access.length > 0) {
|
||||
return projects.filter((project) =>
|
||||
access.some((a) => a.projectId === project.id),
|
||||
access.some((a) => a.projectId === project.id)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +105,7 @@ export async function getProjects({
|
||||
|
||||
export const getProjectEventsCount = async (projectId: string) => {
|
||||
const res = await chQuery<{ count: number }>(
|
||||
`SELECT count(*) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} AND name NOT IN ('session_start', 'session_end')`,
|
||||
`SELECT count(*) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} AND name NOT IN ('session_start', 'session_end')`
|
||||
);
|
||||
return res[0]?.count;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { generateSalt } from '@openpanel/common/server';
|
||||
|
||||
import { cacheableLru } from '@openpanel/redis';
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
export const getSalts = cacheableLru(
|
||||
export const getSalts = cacheable(
|
||||
'op:salt',
|
||||
async () => {
|
||||
const [curr, prev] = await db.salt.findMany({
|
||||
@@ -24,10 +24,7 @@ export const getSalts = cacheableLru(
|
||||
|
||||
return salts;
|
||||
},
|
||||
{
|
||||
maxSize: 2,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
60 * 5,
|
||||
);
|
||||
|
||||
export async function createInitialSalts() {
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import { Polar } from '@polar-sh/sdk';
|
||||
import inquirer from 'inquirer';
|
||||
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { getSuccessUrl } from '..';
|
||||
|
||||
// Register the autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
|
||||
|
||||
interface Answers {
|
||||
isProduction: boolean;
|
||||
polarApiKey: string;
|
||||
productId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
async function promptForInput() {
|
||||
// Get all organizations first
|
||||
const organizations = await db.organization.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 1: Collect Polar credentials first
|
||||
const polarCredentials = await inquirer.prompt<{
|
||||
isProduction: boolean;
|
||||
polarApiKey: string;
|
||||
polarOrganizationId: string;
|
||||
}>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'isProduction',
|
||||
message: 'Is this for production?',
|
||||
choices: [
|
||||
{ name: 'Yes', value: true },
|
||||
{ name: 'No', value: false },
|
||||
],
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'polarApiKey',
|
||||
message: 'Enter your Polar API key:',
|
||||
validate: (input: string) => {
|
||||
if (!input) return 'API key is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Step 2: Initialize Polar client and fetch products
|
||||
const polar = new Polar({
|
||||
accessToken: polarCredentials.polarApiKey,
|
||||
server: polarCredentials.isProduction ? 'production' : 'sandbox',
|
||||
});
|
||||
|
||||
console.log('Fetching products from Polar...');
|
||||
const productsResponse = await polar.products.list({
|
||||
limit: 100,
|
||||
isArchived: false,
|
||||
sorting: ['price_amount'],
|
||||
});
|
||||
|
||||
const products = productsResponse.result.items;
|
||||
|
||||
if (products.length === 0) {
|
||||
throw new Error('No products found in Polar');
|
||||
}
|
||||
|
||||
// Step 3: Continue with product selection and organization selection
|
||||
const restOfAnswers = await inquirer.prompt<{
|
||||
productId: string;
|
||||
organizationId: string;
|
||||
}>([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'productId',
|
||||
message: 'Select product:',
|
||||
source: (answersSoFar: any, input = '') => {
|
||||
return products
|
||||
.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(input.toLowerCase()) ||
|
||||
product.id.toLowerCase().includes(input.toLowerCase()),
|
||||
)
|
||||
.map((product) => {
|
||||
const price = product.prices?.[0];
|
||||
const priceStr =
|
||||
price && 'priceAmount' in price && price.priceAmount
|
||||
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
|
||||
: 'No price';
|
||||
return {
|
||||
name: `${product.name} (${priceStr})`,
|
||||
value: product.id,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'organizationId',
|
||||
message: 'Select organization:',
|
||||
source: (answersSoFar: any, input = '') => {
|
||||
return organizations
|
||||
.filter(
|
||||
(org) =>
|
||||
org.name.toLowerCase().includes(input.toLowerCase()) ||
|
||||
org.id.toLowerCase().includes(input.toLowerCase()),
|
||||
)
|
||||
.map((org) => ({
|
||||
name: `${org.name} (${org.id})`,
|
||||
value: org.id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
...polarCredentials,
|
||||
...restOfAnswers,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Assigning existing product to organization...');
|
||||
const input = await promptForInput();
|
||||
|
||||
const polar = new Polar({
|
||||
accessToken: input.polarApiKey,
|
||||
server: input.isProduction ? 'production' : 'sandbox',
|
||||
});
|
||||
|
||||
const organization = await db.organization.findUniqueOrThrow({
|
||||
where: {
|
||||
id: input.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization.createdBy) {
|
||||
throw new Error(
|
||||
`Organization ${organization.name} does not have a creator. Cannot proceed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const user = organization.createdBy;
|
||||
|
||||
// Fetch product details for review
|
||||
const product = await polar.products.get({ id: input.productId });
|
||||
const price = product.prices?.[0];
|
||||
const priceStr =
|
||||
price && 'priceAmount' in price && price.priceAmount
|
||||
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
|
||||
: 'No price';
|
||||
|
||||
console.log('\nReview the following settings:');
|
||||
console.table({
|
||||
product: product.name,
|
||||
price: priceStr,
|
||||
organization: organization.name,
|
||||
email: user.email,
|
||||
name:
|
||||
[user.firstName, user.lastName].filter(Boolean).join(' ') || 'No name',
|
||||
});
|
||||
|
||||
const { confirmed } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmed',
|
||||
message: 'Do you want to proceed?',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('Operation canceled');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkoutLink = await polar.checkoutLinks.create({
|
||||
paymentProcessor: 'stripe',
|
||||
productId: input.productId,
|
||||
allowDiscountCodes: false,
|
||||
metadata: {
|
||||
organizationId: organization.id,
|
||||
userId: user.id,
|
||||
},
|
||||
successUrl: getSuccessUrl(
|
||||
input.isProduction
|
||||
? 'https://dashboard.openpanel.dev'
|
||||
: 'http://localhost:3000',
|
||||
organization.id,
|
||||
),
|
||||
});
|
||||
|
||||
console.log('\nCheckout link created:');
|
||||
console.table(checkoutLink);
|
||||
console.log('\nProduct assigned successfully!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => db.$disconnect());
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from '@openpanel/db';
|
||||
import { createLogger } from '@openpanel/logger';
|
||||
import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis';
|
||||
import { Queue, QueueEvents } from 'bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { Queue as GroupQueue } from 'groupmq';
|
||||
import type { ITrackPayload } from '../../validation';
|
||||
|
||||
@@ -66,6 +66,10 @@ export interface EventsQueuePayloadIncomingEvent {
|
||||
headers: Record<string, string | undefined>;
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: Pick<
|
||||
IServiceCreateEventPayload,
|
||||
'referrer' | 'referrerName' | 'referrerType'
|
||||
>;
|
||||
};
|
||||
}
|
||||
export interface EventsQueuePayloadCreateEvent {
|
||||
@@ -126,6 +130,10 @@ export type CronQueuePayloadFlushReplay = {
|
||||
type: 'flushReplay';
|
||||
payload: undefined;
|
||||
};
|
||||
export type CronQueuePayloadGscSync = {
|
||||
type: 'gscSync';
|
||||
payload: undefined;
|
||||
};
|
||||
export type CronQueuePayload =
|
||||
| CronQueuePayloadSalt
|
||||
| CronQueuePayloadFlushEvents
|
||||
@@ -136,7 +144,8 @@ export type CronQueuePayload =
|
||||
| CronQueuePayloadPing
|
||||
| CronQueuePayloadProject
|
||||
| CronQueuePayloadInsightsDaily
|
||||
| CronQueuePayloadOnboarding;
|
||||
| CronQueuePayloadOnboarding
|
||||
| CronQueuePayloadGscSync;
|
||||
|
||||
export type MiscQueuePayloadTrialEndingSoon = {
|
||||
type: 'trialEndingSoon';
|
||||
@@ -201,9 +210,6 @@ export const sessionsQueue = new Queue<SessionsQueuePayload>(
|
||||
},
|
||||
}
|
||||
);
|
||||
export const sessionsQueueEvents = new QueueEvents(getQueueName('sessions'), {
|
||||
connection: getRedisQueue(),
|
||||
});
|
||||
|
||||
export const cronQueue = new Queue<CronQueuePayload>(getQueueName('cron'), {
|
||||
connection: getRedisQueue(),
|
||||
@@ -268,3 +274,21 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type GscQueuePayloadSync = {
|
||||
type: 'gscProjectSync';
|
||||
payload: { projectId: string };
|
||||
};
|
||||
export type GscQueuePayloadBackfill = {
|
||||
type: 'gscProjectBackfill';
|
||||
payload: { projectId: string };
|
||||
};
|
||||
export type GscQueuePayload = GscQueuePayloadSync | GscQueuePayloadBackfill;
|
||||
|
||||
export const gscQueue = new Queue<GscQueuePayload>(getQueueName('gsc'), {
|
||||
connection: getRedisQueue(),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 50,
|
||||
removeOnFail: 100,
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user