feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -106,6 +106,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Generate tags
id: tags
run: |
# Sanitize branch name by replacing / with -
BRANCH_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
# Get first 4 characters of commit SHA
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-4)
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -125,8 +135,7 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ env.repo_owner }}/api:latest
ghcr.io/${{ env.repo_owner }}/api:${{ github.sha }}
ghcr.io/${{ env.repo_owner }}/api:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }}
build-args: |
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
@@ -140,6 +149,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Generate tags
id: tags
run: |
# Sanitize branch name by replacing / with -
BRANCH_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
# Get first 4 characters of commit SHA
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-4)
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -159,7 +178,6 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ env.repo_owner }}/worker:latest
ghcr.io/${{ env.repo_owner }}/worker:${{ github.sha }}
ghcr.io/${{ env.repo_owner }}/worker:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }}
build-args: |
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy

4
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.secrets
packages/db/src/generated/prisma
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
packages/sdk/profileId.txt
@@ -168,6 +169,9 @@ dist
.vscode-test
# Wrangler build artifacts and cache
.wrangler/
# yarn v2
.yarn/cache

View File

@@ -1,4 +1,4 @@
ARG NODE_VERSION=20.15.1
ARG NODE_VERSION=22.20.0
FROM node:${NODE_VERSION}-slim AS base
@@ -43,6 +43,7 @@ COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY patches ./patches
# BUILD
FROM base AS build
@@ -66,6 +67,8 @@ RUN pnpm codegen && \
# PROD
FROM base AS prod
ENV npm_config_build_from_source=true
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
@@ -75,12 +78,14 @@ WORKDIR /app
COPY --from=build /app/package.json ./
COPY --from=build /app/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod && \
pnpm rebuild && \
pnpm store prune
# FINAL
FROM base AS runner
ENV NODE_ENV=production
ENV npm_config_build_from_source=true
WORKDIR /app
@@ -106,6 +111,7 @@ COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen
WORKDIR /app/apps/api

View File

@@ -1,11 +1,12 @@
{
"name": "@openpanel/api",
"version": "0.0.4",
"type": "module",
"scripts": {
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsdown",
"testing": "API_PORT=3333 pnpm dev",
"start": "node dist/index.js",
"build": "rm -rf dist && tsup",
"start": "dotenv -e ../../.env node dist/index.js",
"build": "rm -rf dist && tsdown",
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
"typecheck": "tsc --noEmit"
},
@@ -31,15 +32,13 @@
"@openpanel/redis": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@openpanel/validation": "workspace:*",
"@trpc/server": "^10.45.2",
"@trpc/server": "^11.6.0",
"ai": "^4.2.10",
"bcrypt": "^5.1.1",
"fast-json-stable-hash": "^1.0.3",
"fastify": "^5.2.1",
"fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0",
"groupmq": "1.0.0-next.19",
"ico-to-png": "^0.2.2",
"jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1",
"request-ip": "^3.3.0",
@@ -65,7 +64,7 @@
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.14",
"js-yaml": "^4.1.0",
"tsup": "^7.2.0",
"typescript": "^5.2.2"
"tsdown": "^0.14.2",
"typescript": "catalog:"
}
}

View File

@@ -1,5 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import yaml from 'js-yaml';
async function main() {

View File

@@ -493,9 +493,11 @@ async function main() {
const [type, file = 'mock-basic.json'] = process.argv.slice(2);
switch (type) {
case 'send':
await triggerEvents(require(`./${file}`));
case 'send': {
const data = await import(`./${file}`, { assert: { type: 'json' } });
await triggerEvents(data.default);
break;
}
case 'sim':
await simultaneousRequests();
break;

View File

@@ -112,7 +112,7 @@ export async function chat(
await db.chat.create({
data: {
messages: messagesToSave.slice(-10),
messages: messagesToSave.slice(-10) as any,
projectId,
},
});

View File

@@ -1,53 +1,231 @@
import crypto from 'node:crypto';
import { logger } from '@/utils/logger';
import { parseUrlMeta } from '@/utils/parseUrlMeta';
import type { FastifyReply, FastifyRequest } from 'fastify';
import icoToPng from 'ico-to-png';
import sharp from 'sharp';
import { getClientIp } from '@/utils/get-client-ip';
import { createHash } from '@openpanel/common/server';
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import { cacheable, getCache, getRedisCache } from '@openpanel/redis';
import { getCache, getRedisCache } from '@openpanel/redis';
interface GetFaviconParams {
url: string;
}
async function getImageBuffer(url: string) {
// Configuration
const TTL_SECONDS = 60 * 60 * 24; // 24h
const MAX_BYTES = 1_000_000; // 1MB cap
const USER_AGENT = 'OpenPanel-FaviconProxy/1.0 (+https://openpanel.dev)';
// Helper functions
function createCacheKey(url: string, prefix = 'favicon'): string {
const hash = crypto.createHash('sha256').update(url).digest('hex');
return `${prefix}:v2:${hash}`;
}
function validateUrl(raw?: string): URL | null {
try {
const res = await fetch(url);
const contentType = res.headers.get('content-type');
if (!raw) throw new Error('Missing ?url');
const url = new URL(raw);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only http/https URLs are allowed');
}
return url;
} catch (error) {
return null;
}
}
if (!contentType?.includes('image')) {
return null;
// Binary cache functions (more efficient than base64)
async function getFromCacheBinary(
key: string,
): Promise<{ buffer: Buffer; contentType: string } | null> {
const redis = getRedisCache();
const [bufferBase64, contentType] = await Promise.all([
redis.get(key),
redis.get(`${key}:ctype`),
]);
if (!bufferBase64 || !contentType) return null;
return { buffer: Buffer.from(bufferBase64, 'base64'), contentType };
}
async function setToCacheBinary(
key: string,
buffer: Buffer,
contentType: string,
): Promise<void> {
const redis = getRedisCache();
await Promise.all([
redis.set(key, buffer.toString('base64'), 'EX', TTL_SECONDS),
redis.set(`${key}:ctype`, contentType, 'EX', TTL_SECONDS),
]);
}
// Fetch image with timeout and size limits
async function fetchImage(
url: URL,
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await fetch(url.toString(), {
redirect: 'follow',
signal: controller.signal,
headers: {
'user-agent': USER_AGENT,
accept: 'image/*,*/*;q=0.8',
},
});
clearTimeout(timeout);
if (!response.ok) {
return {
buffer: Buffer.alloc(0),
contentType: 'text/plain',
status: response.status,
};
}
if (!res.ok) {
return null;
// Size guard
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (contentLength > MAX_BYTES) {
throw new Error(`Remote file too large: ${contentLength} bytes`);
}
if (contentType === 'image/x-icon' || url.endsWith('.ico')) {
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return await icoToPng(buffer, 30);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Additional size check for actual content
if (buffer.length > MAX_BYTES) {
throw new Error('Remote file exceeded size limit');
}
return await sharp(await res.arrayBuffer())
const contentType =
response.headers.get('content-type') || 'application/octet-stream';
return { buffer, contentType, status: 200 };
} catch (error) {
clearTimeout(timeout);
return { buffer: Buffer.alloc(0), contentType: 'text/plain', status: 500 };
}
}
// Check if URL is an ICO file
function isIcoFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
}
function isSvgFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
}
// Process image with Sharp (resize to 30x30 PNG)
async function processImage(
buffer: Buffer,
originalUrl?: string,
contentType?: string,
): Promise<Buffer> {
// If it's an ICO file, just return it as-is (no conversion needed)
if (originalUrl && isIcoFile(originalUrl, contentType)) {
logger.info('Serving ICO file directly', {
originalUrl,
bufferSize: buffer.length,
});
return buffer;
}
if (originalUrl && isSvgFile(originalUrl, contentType)) {
logger.info('Serving SVG file directly', {
originalUrl,
bufferSize: buffer.length,
});
return buffer;
}
// If buffer isnt to big just return it as well
if (buffer.length < 5000) {
logger.info('Serving image directly without processing', {
originalUrl,
bufferSize: buffer.length,
});
return buffer;
}
try {
// For other formats, process with Sharp
return await sharp(buffer)
.resize(30, 30, {
fit: 'cover',
})
.png()
.toBuffer();
} catch (error) {
logger.error('Failed to get image from url', {
error,
url,
logger.warn('Sharp failed to process image, trying fallback', {
error: error instanceof Error ? error.message : 'Unknown error',
originalUrl,
bufferSize: buffer.length,
});
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
}
}
const imageExtensions = ['svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'];
// Create a simple transparent fallback image when Sharp can't process the original
function createFallbackImage(): Buffer {
// 1x1 transparent PNG
return Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
}
// Process OG image with Sharp (resize to 300px width)
async function processOgImage(
buffer: Buffer,
originalUrl?: string,
contentType?: string,
): Promise<Buffer> {
// If buffer is small enough, return it as-is
if (buffer.length < 10000) {
logger.info('Serving OG image directly without processing', {
originalUrl,
bufferSize: buffer.length,
});
return buffer;
}
try {
// For OG images, process with Sharp to 300px width, maintaining aspect ratio
return await sharp(buffer)
.resize(300, null, {
fit: 'inside',
withoutEnlargement: true,
})
.png()
.toBuffer();
} catch (error) {
logger.warn('Sharp failed to process OG image, trying fallback', {
error: error instanceof Error ? error.message : 'Unknown error',
originalUrl,
bufferSize: buffer.length,
});
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
}
}
// Check if URL is a direct image
function isDirectImage(url: URL): boolean {
const imageExtensions = ['svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'];
return (
imageExtensions.some((ext) => url.pathname.endsWith(`.${ext}`)) ||
url.toString().includes('googleusercontent.com')
);
}
export async function getFavicon(
request: FastifyRequest<{
@@ -55,68 +233,110 @@ export async function getFavicon(
}>,
reply: FastifyReply,
) {
function sendBuffer(buffer: Buffer, cacheKey?: string) {
if (cacheKey) {
getRedisCache().set(`favicon:${cacheKey}`, buffer.toString('base64'));
try {
const url = validateUrl(request.query.url);
if (!url) {
return createFallbackImage();
}
reply.header('Cache-Control', 'public, max-age=604800');
reply.header('Expires', new Date(Date.now() + 604800000).toUTCString());
reply.type('image/png');
return reply.send(buffer);
}
if (!request.query.url) {
return reply.status(404).send('Not found');
}
const cacheKey = createCacheKey(url.toString());
const url = decodeURIComponent(request.query.url);
if (imageExtensions.find((ext) => url.endsWith(ext))) {
const cacheKey = createHash(url, 32);
const cache = await getRedisCache().get(`favicon:${cacheKey}`);
if (cache) {
return sendBuffer(Buffer.from(cache, 'base64'));
// Check cache first
const cached = await getFromCacheBinary(cacheKey);
if (cached) {
reply.header('Content-Type', cached.contentType);
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(cached.buffer);
}
const buffer = await getImageBuffer(url);
if (buffer && buffer.byteLength > 0) {
return sendBuffer(buffer, cacheKey);
let imageUrl: URL;
// If it's a direct image URL, use it directly
if (isDirectImage(url)) {
imageUrl = url;
} else {
// For website URLs, extract favicon from HTML
const meta = await parseUrlMeta(url.toString());
if (meta?.favicon) {
imageUrl = new URL(meta.favicon);
} else {
// Fallback to Google's favicon service
const { hostname } = url;
imageUrl = new URL(
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
);
}
}
}
const { hostname } = new URL(url);
const cache = await getRedisCache().get(`favicon:${hostname}`);
// Fetch the image
const { buffer, contentType, status } = await fetchImage(imageUrl);
if (cache) {
return sendBuffer(Buffer.from(cache, 'base64'));
}
const meta = await parseUrlMeta(url);
if (meta?.favicon) {
const buffer = await getImageBuffer(meta.favicon);
if (buffer && buffer.byteLength > 0) {
return sendBuffer(buffer, hostname);
if (status !== 200 || buffer.length === 0) {
return reply.send(createFallbackImage());
}
}
const buffer = await getImageBuffer(
'https://www.iconsdb.com/icons/download/orange/warning-128.png',
);
if (buffer && buffer.byteLength > 0) {
return sendBuffer(buffer, hostname);
}
// Process the image (resize to 30x30 PNG, or serve ICO as-is)
const processedBuffer = await processImage(
buffer,
imageUrl.toString(),
contentType,
);
return reply.status(404).send('Not found');
// Determine the correct content type for caching and response
const isIco = isIcoFile(imageUrl.toString(), contentType);
const responseContentType = isIco ? 'image/x-icon' : contentType;
// Cache the result with correct content type
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
reply.header('Content-Type', responseContentType);
reply.header('Cache-Control', 'public, max-age=3600, immutable');
return reply.send(processedBuffer);
} catch (error: any) {
logger.error('Favicon fetch error', {
error: error.message,
url: request.query.url,
});
const message =
process.env.NODE_ENV === 'production'
? 'Bad request'
: (error?.message ?? 'Error');
reply.header('Cache-Control', 'no-store');
return reply.status(400).send(message);
}
}
export async function clearFavicons(
request: FastifyRequest,
reply: FastifyReply,
) {
const keys = await getRedisCache().keys('favicon:*');
const redis = getRedisCache();
const keys = await redis.keys('favicon:*');
// Delete both the binary data and content-type keys
for (const key of keys) {
await getRedisCache().del(key);
await redis.del(key);
await redis.del(`${key}:ctype`);
}
return reply.status(404).send('OK');
return reply.status(200).send('OK');
}
export async function clearOgImages(
request: FastifyRequest,
reply: FastifyReply,
) {
const redis = getRedisCache();
const keys = await redis.keys('og:*');
// Delete both the binary data and content-type keys
for (const key of keys) {
await redis.del(key);
await redis.del(`${key}:ctype`);
}
return reply.status(200).send('OK');
}
export async function ping(
@@ -181,3 +401,77 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
const geo = await getGeoLocation(ip);
return reply.status(200).send(geo);
}
export async function getOgImage(
request: FastifyRequest<{
Querystring: {
url: string;
};
}>,
reply: FastifyReply,
) {
try {
const url = validateUrl(request.query.url);
if (!url) {
return getFavicon(request, reply);
}
const cacheKey = createCacheKey(url.toString(), 'og');
// Check cache first
const cached = await getFromCacheBinary(cacheKey);
if (cached) {
reply.header('Content-Type', cached.contentType);
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(cached.buffer);
}
let imageUrl: URL;
// If it's a direct image URL, use it directly
if (isDirectImage(url)) {
imageUrl = url;
} else {
// For website URLs, extract OG image from HTML
const meta = await parseUrlMeta(url.toString());
if (meta?.ogImage) {
imageUrl = new URL(meta.ogImage);
} else {
// No OG image found, return a fallback
return getFavicon(request, reply);
}
}
// Fetch the image
const { buffer, contentType, status } = await fetchImage(imageUrl);
if (status !== 200 || buffer.length === 0) {
return getFavicon(request, reply);
}
// Process the image (resize to 1200x630 for OG standards, or serve as-is if reasonable size)
const processedBuffer = await processOgImage(
buffer,
imageUrl.toString(),
contentType,
);
// Cache the result
await setToCacheBinary(cacheKey, processedBuffer, 'image/png');
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600, immutable');
return reply.send(processedBuffer);
} catch (error: any) {
logger.error('OG image fetch error', {
error: error.message,
url: request.query.url,
});
const message =
process.env.NODE_ENV === 'production'
? 'Bad request'
: (error?.message ?? 'Error');
reply.header('Cache-Control', 'no-store');
return reply.status(400).send(message);
}
}

View File

@@ -76,7 +76,9 @@ async function handleExistingUser({
sessionToken,
session.expiresAt,
);
return reply.redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
);
}
async function handleNewUser({
@@ -138,7 +140,9 @@ async function handleNewUser({
sessionToken,
session.expiresAt,
);
return reply.redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
);
}
// Provider-specific user fetching
@@ -348,7 +352,9 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
}
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
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);

View File

@@ -1,5 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
import {
sendSlackNotification,
@@ -100,7 +105,7 @@ export async function slackWebhook(
});
return reply.redirect(
`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/${projectId}/settings/integrations?tab=installed`,
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/${projectId}/settings/integrations?tab=installed`,
);
} catch (err) {
request.log.error(err);
@@ -184,7 +189,7 @@ export async function polarWebhook(
data: {
subscriptionId: event.data.id,
subscriptionCustomerId: event.data.customer.id,
subscriptionPriceId: event.data.priceId,
subscriptionPriceId: event.data.prices[0]?.id ?? null,
subscriptionProductId: event.data.productId,
subscriptionStatus: event.data.status,
subscriptionStartsAt: event.data.currentPeriodStart,

View File

@@ -7,7 +7,7 @@ const ignoreMethods = ['OPTIONS'];
const getTrpcInput = (
request: FastifyRequest,
): Record<string, unknown> | undefined => {
const input = path(['query', 'input'], request);
const input = path<any>(['query', 'input'], request);
try {
return typeof input === 'string' ? JSON.parse(input).json : input;
} catch (e) {

View File

@@ -95,15 +95,13 @@ const startServer = async () => {
if (isPrivatePath) {
// Allow multiple dashboard domains
const allowedOrigins = [
process.env.NEXT_PUBLIC_DASHBOARD_URL,
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL,
...(process.env.API_CORS_ORIGINS?.split(',') ?? []),
].filter(Boolean);
const origin = req.headers.origin;
const isAllowed = origin && allowedOrigins.includes(origin);
logger.info('Allowed origins', { allowedOrigins, origin, isAllowed });
return callback(null, {
origin: isAllowed ? origin : false,
credentials: true,
@@ -160,6 +158,12 @@ const startServer = async () => {
router: appRouter,
createContext: createContext,
onError(ctx) {
if (
ctx.error.code === 'UNAUTHORIZED' &&
ctx.path === 'organization.list'
) {
return;
}
ctx.req.log.error('trpc error', {
error: ctx.error,
path: ctx.path,
@@ -191,7 +195,10 @@ const startServer = async () => {
instance.get('/healthz/live', liveness);
instance.get('/healthz/ready', readiness);
instance.get('/', (_request, reply) =>
reply.send({ name: 'openpanel sdk api' }),
reply.send({
status: 'ok',
message: 'Successfully running OpenPanel.dev API',
}),
);
});

View File

@@ -20,6 +20,18 @@ const miscRouter: FastifyPluginCallback = async (fastify) => {
handler: controller.getFavicon,
});
fastify.route({
method: 'GET',
url: '/og',
handler: controller.getOgImage,
});
fastify.route({
method: 'GET',
url: '/og/clear',
handler: controller.clearOgImages,
});
fastify.route({
method: 'GET',
url: '/favicon/clear',

View File

@@ -5,12 +5,16 @@ function fallbackFavicon(url: string) {
}
function findBestFavicon(favicons: UrlMetaData['favicons']) {
const match = favicons.find(
(favicon) =>
favicon.rel === 'shortcut icon' ||
favicon.rel === 'icon' ||
favicon.rel === 'apple-touch-icon',
);
const match = favicons
.sort((a, b) => {
return a.rel.length - b.rel.length;
})
.find(
(favicon) =>
favicon.rel === 'shortcut icon' ||
favicon.rel === 'icon' ||
favicon.rel === 'apple-touch-icon',
);
if (match) {
return match.href;
@@ -18,11 +22,32 @@ function findBestFavicon(favicons: UrlMetaData['favicons']) {
return null;
}
function findBestOgImage(data: UrlMetaData): string | null {
// Priority order for OG images
const candidates = [
data['og:image:secure_url'],
data['og:image:url'],
data['og:image'],
data['twitter:image:src'],
data['twitter:image'],
];
for (const candidate of candidates) {
if (candidate?.trim()) {
return candidate.trim();
}
}
return null;
}
function transform(data: UrlMetaData, url: string) {
const favicon = findBestFavicon(data.favicons);
const ogImage = findBestOgImage(data);
return {
favicon: favicon ? new URL(favicon, url).toString() : fallbackFavicon(url),
ogImage: ogImage ? new URL(ogImage, url).toString() : null,
};
}
@@ -32,6 +57,11 @@ interface UrlMetaData {
href: string;
sizes: string;
}[];
'og:image'?: string;
'og:image:url'?: string;
'og:image:secure_url'?: string;
'twitter:image'?: string;
'twitter:image:src'?: string;
}
export async function parseUrlMeta(url: string) {
@@ -42,6 +72,7 @@ export async function parseUrlMeta(url: string) {
} catch (err) {
return {
favicon: fallbackFavicon(url),
ogImage: null,
};
}
}

23
apps/api/tsdown.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'tsdown';
import type { Options } from 'tsdown';
const options: Options = {
clean: true,
entry: ['src/index.ts'],
noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u],
external: ['@hyperdx/node-opentelemetry', 'winston', '@node-rs/argon2'],
sourcemap: true,
platform: 'node',
shims: true,
inputOptions: {
jsx: 'react',
},
};
if (process.env.WATCH) {
options.watch = ['src', '../../packages'];
options.onSuccess = 'node --enable-source-maps dist/index.js';
options.minify = false;
}
export default defineConfig(options);

View File

@@ -1,26 +0,0 @@
import { defineConfig } from 'tsup';
import type { Options } from 'tsup';
const options: Options = {
clean: true,
entry: ['src/index.ts'],
noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u],
external: [
'@hyperdx/node-opentelemetry',
'winston',
'@node-rs/argon2',
'bcrypt',
],
ignoreWatch: ['../../**/{.git,node_modules,dist}/**'],
sourcemap: true,
splitting: false,
};
if (process.env.WATCH) {
options.watch = ['src/**/*', '../../packages/**/*'];
options.onSuccess = 'node dist/index.js';
options.minify = false;
}
export default defineConfig(options);

View File

@@ -1,39 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo

View File

@@ -1,3 +0,0 @@
[auth]
token=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

View File

@@ -1,103 +0,0 @@
ARG NODE_VERSION=20.15.1
FROM node:${NODE_VERSION}-slim AS base
# FIX: Bad workaround (https://github.com/nodejs/corepack/issues/612)
ENV COREPACK_INTEGRITY_KEYS=0
ENV SKIP_ENV_VALIDATION="1"
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
ARG ENABLE_INSTRUMENTATION_HOOK
ENV ENABLE_INSTRUMENTATION_HOOK=$ENABLE_INSTRUMENTATION_HOOK
ARG NEXT_PUBLIC_SELF_HOSTED
ENV NEXT_PUBLIC_SELF_HOSTED=$NEXT_PUBLIC_SELF_HOSTED
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# Install necessary dependencies for prisma
RUN apt-get update && apt-get install -y \
openssl \
libssl3 \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
WORKDIR /app
ARG CACHE_BUST
RUN echo "CACHE BUSTER: $CACHE_BUST"
COPY package.json package.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY apps/dashboard/package.json apps/dashboard/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/json/package.json packages/json/package.json
COPY packages/redis/package.json packages/redis/package.json
COPY packages/queue/package.json packages/queue/package.json
COPY packages/common/package.json packages/common/package.json
COPY packages/auth/package.json packages/auth/package.json
COPY packages/email/package.json packages/email/package.json
COPY packages/constants/package.json packages/constants/package.json
COPY packages/payments/package.json packages/payments/package.json
COPY packages/validation/package.json packages/validation/package.json
COPY packages/integrations/package.json packages/integrations/package.json
COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json
# BUILD
FROM base AS build
WORKDIR /app
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY apps/dashboard apps/dashboard
COPY packages packages
COPY tooling tooling
RUN pnpm db:codegen
WORKDIR /app/apps/dashboard
# Will be replaced on runtime
ENV NEXT_PUBLIC_DASHBOARD_URL="__NEXT_PUBLIC_DASHBOARD_URL__"
ENV NEXT_PUBLIC_API_URL="__NEXT_PUBLIC_API_URL__"
RUN pnpm run build
# RUNNER
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Set the correct permissions for the entire /app directory
COPY --from=build --chown=nextjs:nodejs /app/apps/dashboard/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/apps/dashboard/.next/static ./apps/dashboard/.next/static
COPY --from=build --chown=nextjs:nodejs /app/apps/dashboard/public ./apps/dashboard/public
# Copy and set permissions for the entrypoint script
COPY --from=build --chown=nextjs:nodejs /app/apps/dashboard/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENTRYPOINT [ "/app/entrypoint.sh", "node", "/app/apps/dashboard/server.js"]

View File

@@ -1 +0,0 @@
# Dashboard

View File

@@ -1,16 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/utils/cn"
}
}

View File

@@ -1,33 +0,0 @@
#!/bin/sh
set -e
echo "> Replace env variable placeholders with runtime values..."
# Define environment variables to check (space-separated string)
variables_to_replace="NEXT_PUBLIC_DASHBOARD_URL NEXT_PUBLIC_API_URL NEXT_PUBLIC_SELF_HOSTED"
# Replace env variable placeholders with real values
for key in $variables_to_replace; do
value=$(eval echo \$"$key")
if [ -n "$value" ]; then
echo " - Searching for $key with value $value..."
# Use standard placeholder format for all variables
placeholder="__${key}__"
# Run the replacement
find /app -type f \( -name "*.js" -o -name "*.html" \) | while read -r file; do
if grep -q "$placeholder" "$file"; then
echo " - Replacing in file: $file"
sed -i "s|$placeholder|$value|g" "$file"
fi
done
else
echo " - Skipping $key as it has no value set."
fi
done
echo "> Done!"
echo "> Running $@"
# Execute the container's main process (CMD in Dockerfile)
exec "$@"

View File

@@ -1,47 +0,0 @@
// @ts-expect-error
import { PrismaPlugin } from '@prisma/nextjs-monorepo-workaround-plugin';
/** @type {import("next").NextConfig} */
const config = {
output: 'standalone',
webpack: (config, { isServer }) => {
if (isServer) {
config.plugins = [...config.plugins, new PrismaPlugin()];
}
return config;
},
reactStrictMode: true,
transpilePackages: [
'@openpanel/queue',
'@openpanel/db',
'@openpanel/common',
'@openpanel/constants',
'@openpanel/redis',
'@openpanel/validation',
'@openpanel/email',
],
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
experimental: {
// Avoid "Critical dependency: the request of a dependency is an expression"
serverComponentsExternalPackages: [
'bullmq',
'ioredis',
'@hyperdx/node-opentelemetry',
'@node-rs/argon2',
],
instrumentationHook: !!process.env.ENABLE_INSTRUMENTATION_HOOK,
},
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ['en'],
defaultLocale: 'en',
},
};
export default config;

View File

@@ -1,149 +0,0 @@
{
"name": "@openpanel/dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "rm -rf .next && pnpm with-env next dev",
"testing": "pnpm dev",
"build": "pnpm with-env next build",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"@ai-sdk/react": "^1.2.5",
"@clickhouse/client": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.3.4",
"@hyperdx/node-opentelemetry": "^0.8.1",
"@openpanel/auth": "workspace:^",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/db": "workspace:^",
"@openpanel/integrations": "workspace:^",
"@openpanel/json": "workspace:*",
"@openpanel/nextjs": "1.0.3",
"@openpanel/queue": "workspace:^",
"@openpanel/sdk-info": "workspace:^",
"@openpanel/validation": "workspace:^",
"@prisma/nextjs-monorepo-workaround-plugin": "^5.12.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-portal": "^1.1.1",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@reduxjs/toolkit": "^1.9.7",
"@t3-oss/env-nextjs": "^0.7.3",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.11.8",
"@tanstack/react-virtual": "^3.13.2",
"@trpc/client": "^10.45.2",
"@trpc/next": "^10.45.2",
"@trpc/react-query": "^10.45.2",
"@trpc/server": "^10.45.2",
"@types/d3": "^7.4.3",
"ai": "^4.2.10",
"bcrypt": "^5.1.1",
"bind-event-listener": "^3.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^0.2.1",
"d3": "^7.8.5",
"date-fns": "^3.3.1",
"embla-carousel-react": "8.0.0-rc22",
"flag-icons": "^7.1.0",
"framer-motion": "^11.0.28",
"geist": "^1.3.1",
"hamburger-react": "^2.5.0",
"input-otp": "^1.2.4",
"javascript-time-ago": "^2.5.9",
"katex": "^0.16.21",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.513.0",
"mathjs": "^12.3.2",
"mitt": "^3.0.1",
"next": "14.2.1",
"next-auth": "^4.24.5",
"next-themes": "^0.2.1",
"nextjs-toploader": "^1.6.11",
"nuqs": "^2.0.2",
"prisma-error-enum": "^0.1.3",
"pushmodal": "^1.0.3",
"ramda": "^0.29.1",
"random-animal-name": "^0.1.1",
"rc-virtual-list": "^3.14.5",
"react": "18.2.0",
"react-animate-height": "^3.2.3",
"react-animated-numbers": "^0.18.0",
"react-day-picker": "^8.10.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.50.1",
"react-in-viewport": "1.0.0-alpha.30",
"react-markdown": "^10.1.0",
"react-redux": "^8.1.3",
"react-responsive": "^9.0.2",
"react-simple-maps": "3.0.0",
"react-svg-worldmap": "2.0.0-alpha.16",
"react-syntax-highlighter": "^15.5.0",
"react-use-websocket": "^4.7.0",
"react-virtualized-auto-sizer": "^1.0.22",
"recharts": "^2.12.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-highlight": "^0.1.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"short-unique-id": "^5.0.3",
"slugify": "^1.6.6",
"sonner": "^1.4.0",
"sqlstring": "^2.3.3",
"superjson": "^1.13.3",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^2.14.0",
"zod": "catalog:"
},
"devDependencies": {
"@openpanel/payments": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/bcrypt": "^5.0.2",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "20.14.8",
"@types/ramda": "^0.29.10",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-simple-maps": "^3.0.4",
"@types/react-syntax-highlighter": "^15.5.11",
"@types/sqlstring": "^2.3.2",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

View File

@@ -1,8 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = config;

View File

@@ -1,31 +0,0 @@
import Chat from '@/components/chat/chat';
import { db, getOrganizationById } from '@openpanel/db';
import type { UIMessage } from 'ai';
export default async function ChatPage({
params,
}: {
params: { organizationSlug: string; projectId: string };
}) {
const { projectId } = await params;
const [organization, chat] = await Promise.all([
getOrganizationById(params.organizationSlug),
db.chat.findFirst({
where: {
projectId,
},
orderBy: {
createdAt: 'desc',
},
}),
]);
const messages = ((chat?.messages as UIMessage[]) || []).slice(-10);
return (
<Chat
projectId={projectId}
initialMessages={messages}
organization={organization}
/>
);
}

View File

@@ -1,181 +0,0 @@
'use client';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportChart } from '@/components/report-chart';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/useAppParams';
import { api, handleError } from '@/trpc/client';
import { cn } from '@/utils/cn';
import {
ChevronRight,
LayoutPanelTopIcon,
MoreHorizontal,
PlusIcon,
Trash,
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
timeWindows,
} from '@openpanel/constants';
import type { IServiceDashboard, getReportsByDashboardId } from '@openpanel/db';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
dashboard: IServiceDashboard;
}
export function ListReports({ reports, dashboard }: ListReportsProps) {
const router = useRouter();
const params = useAppParams<{ dashboardId: string }>();
const { range, startDate, endDate, interval } = useOverviewOptions();
const deletion = api.report.delete.useMutation({
onError: handleError,
onSuccess() {
router.refresh();
toast('Report deleted');
},
});
return (
<>
<div className="row mb-4 items-center justify-between">
<h1 className="text-3xl font-semibold">{dashboard.name}</h1>
<div className="flex items-center justify-end gap-2">
<OverviewRange />
<OverviewInterval />
<Button
icon={PlusIcon}
onClick={() => {
router.push(
`/${params.organizationId}/${
params.projectId
}/reports?${new URLSearchParams({
dashboardId: params.dashboardId,
}).toString()}`,
);
}}
>
<span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span>
</Button>
</div>
</div>
<div className="flex max-w-6xl flex-col gap-8">
{reports.map((report) => {
const chartRange = report.range;
return (
<div className="card" key={report.id}>
<Link
href={`/${params.organizationId}/${params.projectId}/reports/${report.id}`}
className="flex items-center justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100"
shallow
>
<div>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange].label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>{timeWindows[range].label}</span>
)
)}
</div>
)}
</div>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
event.stopPropagation();
deletion.mutate({
reportId: report.id,
});
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight
className="opacity-10 transition-opacity"
size={16}
/>
</div>
</Link>
<div
className={cn('p-4', report.chartType === 'metric' && 'p-0')}
>
<ReportChart
{...report}
report={{
...report,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}}
/>
</div>
</div>
);
})}
{reports.length === 0 && (
<FullPageEmptyState title="No reports" icon={LayoutPanelTopIcon}>
<p>You can visualize your data with a report</p>
<Button
onClick={() =>
router.push(
`/${params.organizationId}/${
params.projectId
}/reports?${new URLSearchParams({
dashboardId: params.dashboardId,
}).toString()}`,
)
}
className="mt-14"
icon={PlusIcon}
>
Create report
</Button>
</FullPageEmptyState>
)}
</div>
</>
);
}

View File

@@ -1,32 +0,0 @@
import { Padding } from '@/components/ui/padding';
import { notFound } from 'next/navigation';
import { getDashboardById, getReportsByDashboardId } from '@openpanel/db';
import { ListReports } from './list-reports';
interface PageProps {
params: {
projectId: string;
dashboardId: string;
};
}
export default async function Page({
params: { projectId, dashboardId },
}: PageProps) {
const [dashboard, reports] = await Promise.all([
getDashboardById(dashboardId, projectId),
getReportsByDashboardId(dashboardId),
]);
if (!dashboard) {
return notFound();
}
return (
<Padding>
<ListReports reports={reports} dashboard={dashboard} />
</Padding>
);
}

View File

@@ -1,22 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
export function HeaderDashboards() {
return (
<div className="mb-4 flex items-center justify-between">
<h1 className="text-3xl font-semibold">Dashboards</h1>
<Button
icon={PlusIcon}
onClick={() => {
pushModal('AddDashboard');
}}
>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { Padding } from '@/components/ui/padding';
import withSuspense from '@/hocs/with-suspense';
import { getDashboardsByProjectId } from '@openpanel/db';
import { HeaderDashboards } from './header';
import { ListDashboards } from './list-dashboards';
interface Props {
projectId: string;
}
const ListDashboardsServer = async ({ projectId }: Props) => {
const dashboards = await getDashboardsByProjectId(projectId);
return (
<Padding>
<HeaderDashboards />
<ListDashboards dashboards={dashboards} />
</Padding>
);
};
export default withSuspense(ListDashboardsServer, FullPageLoadingState);

View File

@@ -1,11 +0,0 @@
import ListDashboardsServer from './list-dashboards';
interface PageProps {
params: {
projectId: string;
};
}
export default function Page({ params: { projectId } }: PageProps) {
return <ListDashboardsServer projectId={projectId} />;
}

View File

@@ -1,43 +0,0 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { EventsTable } from '@/components/events/table';
import { EventsTableColumns } from '@/components/events/table/events-table-columns';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
type Props = {
projectId: string;
profileId?: string;
};
const Conversions = ({ projectId }: Props) => {
const query = api.event.conversions.useInfiniteQuery(
{
projectId,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
keepPreviousData: true,
},
);
return (
<div>
<TableButtons>
<EventsTableColumns />
{query.isRefetching && (
<div className="center-center size-8 rounded border bg-background">
<Loader2Icon
size={12}
className="size-4 shrink-0 animate-spin text-black text-highlight"
/>
</div>
)}
</TableButtons>
<EventsTable query={query} />
</div>
);
};
export default Conversions;

View File

@@ -1,93 +0,0 @@
'use client';
import { TableButtons } from '@/components/data-table';
import EventListener from '@/components/events/event-listener';
import { EventsTable } from '@/components/events/table';
import { EventsTableColumns } from '@/components/events/table/events-table-columns';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { Button } from '@/components/ui/button';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { pushModal } from '@/modals';
import { api } from '@/trpc/client';
import { format } from 'date-fns';
import { CalendarIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
type Props = {
projectId: string;
profileId?: string;
};
const Events = ({ projectId, profileId }: Props) => {
const [filters] = useEventQueryFilters();
const [startDate, setStartDate] = useQueryState(
'startDate',
parseAsIsoDateTime,
);
const [eventNames] = useEventQueryNamesFilter();
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
const query = api.event.events.useInfiniteQuery(
{
projectId,
filters,
events: eventNames,
profileId,
startDate: startDate || undefined,
endDate: endDate || undefined,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
keepPreviousData: true,
},
);
return (
<div>
<TableButtons>
<EventListener onRefresh={() => query.refetch()} />
<Button
variant="outline"
size="sm"
icon={CalendarIcon}
onClick={() => {
pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => {
setStartDate(startDate);
setEndDate(endDate);
},
startDate: startDate || undefined,
endDate: endDate || undefined,
});
}}
>
{startDate && endDate
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`
: 'Date range'}
</Button>
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
<EventsTableColumns />
{query.isRefetching && (
<div className="center-center size-8 rounded border bg-background">
<Loader2Icon
size={12}
className="size-4 shrink-0 animate-spin text-black text-highlight"
/>
</div>
)}
</TableButtons>
<EventsTable query={query} />
</div>
);
};
export default Events;

View File

@@ -1,49 +0,0 @@
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
import Charts from './charts';
import Conversions from './conversions';
import Events from './events';
interface PageProps {
params: {
projectId: string;
};
searchParams: Record<string, string>;
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['events', 'conversions', 'charts'])
.withDefault('events')
.parseServerSide(searchParams.tab);
return (
<>
<Padding>
<div className="mb-4">
<PageTabs>
<PageTabsLink href={'?tab=events'} isActive={tab === 'events'}>
Events
</PageTabsLink>
<PageTabsLink
href={'?tab=conversions'}
isActive={tab === 'conversions'}
>
Conversions
</PageTabsLink>
<PageTabsLink href={'?tab=charts'} isActive={tab === 'charts'}>
Charts
</PageTabsLink>
</PageTabs>
</div>
{tab === 'events' && <Events projectId={projectId} />}
{tab === 'conversions' && <Conversions projectId={projectId} />}
{tab === 'charts' && <Charts projectId={projectId} />}
</Padding>
</>
);
}

View File

@@ -1,33 +0,0 @@
'use client';
import { cn } from '@/utils/cn';
import { useSelectedLayoutSegments } from 'next/navigation';
const NOT_MIGRATED_PAGES = ['reports'];
export default function LayoutContent({
children,
}: {
children: React.ReactNode;
}) {
const segments = useSelectedLayoutSegments();
if (segments[0] && NOT_MIGRATED_PAGES.includes(segments[0])) {
return (
<div className="pb-20 transition-all lg:pl-72 max-w-screen-2xl">
{children}
</div>
);
}
return (
<div
className={cn(
'pb-20 transition-all max-lg:mt-12 lg:pl-72 max-w-screen-2xl',
segments.includes('chat') && 'pb-0',
)}
>
{children}
</div>
);
}

View File

@@ -1,243 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import {
BanknoteIcon,
ChartLineIcon,
DollarSignIcon,
GanttChartIcon,
Globe2Icon,
HeartHandshakeIcon,
LayersIcon,
LayoutPanelTopIcon,
PlusIcon,
ScanEyeIcon,
ServerIcon,
SparklesIcon,
UsersIcon,
WallpaperIcon,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { ProjectLink } from '@/components/links';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { CommandShortcut } from '@/components/ui/command';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
import { differenceInDays, format } from 'date-fns';
function LinkWithIcon({
href,
icon: Icon,
label,
active: overrideActive,
className,
}: {
href: string;
icon: LucideIcon;
label: React.ReactNode;
active?: boolean;
className?: string;
}) {
const pathname = usePathname();
const active = overrideActive || href === pathname;
return (
<ProjectLink
className={cn(
'text-text flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200',
active && 'bg-def-200',
className,
)}
href={href}
>
<Icon size={20} />
<div className="flex-1">{label}</div>
</ProjectLink>
);
}
interface LayoutMenuProps {
dashboards: IServiceDashboards;
organization: IServiceOrganization;
}
export default function LayoutMenu({
dashboards,
organization,
}: LayoutMenuProps) {
const number = useNumber();
const {
isTrial,
isExpired,
isExceeded,
isCanceled,
subscriptionEndsAt,
subscriptionPeriodEventsCount,
subscriptionPeriodEventsLimit,
subscriptionProductId,
} = organization;
return (
<>
<div className="col border rounded mb-2 divide-y">
{(subscriptionProductId === '036efa2a-b3b4-4c75-b24a-9cac6bb8893b' ||
subscriptionProductId === 'a18b4bee-d3db-4404-be6f-fba2f042d9ed') && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row items-center gap-2 hover:bg-def-200 text-destructive',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Free plan is removed</div>
<div className="text-sm opacity-80">
We've removed the free plan. You can upgrade to a paid plan to
continue using OpenPanel.
</div>
</div>
</ProjectLink>
)}
{process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' && (
<a
className="rounded p-2 row items-center gap-2 hover:bg-def-200"
href="https://openpanel.dev/supporter"
>
<HeartHandshakeIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Become a supporter</div>
</div>
</a>
)}
{isTrial && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row items-center gap-2 hover:bg-def-200 text-destructive',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">
Free trial ends in{' '}
{differenceInDays(subscriptionEndsAt, new Date())} days
</div>
</div>
</ProjectLink>
)}
{isExpired && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row gap-2 hover:bg-def-200 text-red-600',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-0.5">
<div className="font-medium">Subscription expired</div>
<div className="text-sm opacity-80">
You can still use OpenPanel but you won't have access to new
incoming data.
</div>
</div>
</ProjectLink>
)}
{isCanceled && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row gap-2 hover:bg-def-200 text-red-600',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-0.5">
<div className="font-medium">Subscription canceled</div>
<div className="text-sm opacity-80">
{differenceInDays(new Date(), subscriptionEndsAt)} days ago
</div>
</div>
</ProjectLink>
)}
{isExceeded && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row gap-2 hover:bg-def-200 text-destructive',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-0.5">
<div className="font-medium">Events limit exceeded</div>
<div className="text-sm opacity-80">
{number.format(subscriptionPeriodEventsCount)} /{' '}
{number.format(subscriptionPeriodEventsLimit)}
</div>
</div>
</ProjectLink>
)}
<ProjectLink
href={'/chat'}
className={cn('rounded p-2 row gap-2 hover:bg-def-200 items-center')}
>
<SparklesIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Ask AI</div>
</div>
<CommandShortcut>K</CommandShortcut>
</ProjectLink>
<ProjectLink
href={'/reports'}
className={cn('rounded p-2 row gap-2 hover:bg-def-200 items-center')}
>
<ChartLineIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Create report</div>
</div>
<CommandShortcut>J</CommandShortcut>
</ProjectLink>
</div>
<LinkWithIcon icon={WallpaperIcon} label="Overview" href={'/'} />
<LinkWithIcon
icon={LayoutPanelTopIcon}
label="Dashboards"
href={'/dashboards'}
/>
<LinkWithIcon icon={LayersIcon} label="Pages" href={'/pages'} />
<LinkWithIcon icon={Globe2Icon} label="Realtime" href={'/realtime'} />
<LinkWithIcon icon={GanttChartIcon} label="Events" href={'/events'} />
<LinkWithIcon icon={UsersIcon} label="Profiles" href={'/profiles'} />
<LinkWithIcon icon={ScanEyeIcon} label="Retention" href={'/retention'} />
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<div className="text-muted-foreground">Your dashboards</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground"
onClick={() => pushModal('AddDashboard')}
>
<PlusIcon size={16} />
</Button>
</div>
<div className="flex flex-col gap-2">
{dashboards.map((item) => (
<LinkWithIcon
key={item.id}
icon={LayoutPanelTopIcon}
label={item.name}
href={`/dashboards/${item.id}`}
/>
))}
</div>
</div>
{process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' && (
<div className="mt-auto w-full ">
<div className={cn('text-sm w-full text-center')}>
Self-hosted instance
</div>
</div>
)}
</>
);
}

View File

@@ -1,43 +0,0 @@
'use client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { Building } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { IServiceOrganization } from '@openpanel/db';
interface LayoutOrganizationSelectorProps {
organizations: IServiceOrganization[];
}
export default function LayoutOrganizationSelector({
organizations,
}: LayoutOrganizationSelectorProps) {
const params = useAppParams();
const router = useRouter();
const organization = organizations.find(
(item) => item.id === params.organizationId,
);
return (
<Combobox
className="w-full"
placeholder="Select organization"
icon={Building}
value={organization?.id}
items={
organizations
.filter((item) => item.id)
.map((item) => ({
label: item.name,
value: item.id,
})) ?? []
}
onChange={(value) => {
router.push(`/${value}`);
}}
/>
);
}

View File

@@ -1,90 +0,0 @@
'use client';
import { LogoSquare } from '@/components/logo';
import SettingsToggle from '@/components/settings-toggle';
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { MenuIcon, XIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import type {
IServiceDashboards,
IServiceOrganization,
getProjectsByOrganizationId,
} from '@openpanel/db';
import { useAppParams } from '@/hooks/useAppParams';
import Link from 'next/link';
import LayoutMenu from './layout-menu';
import LayoutProjectSelector from './layout-project-selector';
interface LayoutSidebarProps {
organizations: IServiceOrganization[];
dashboards: IServiceDashboards;
projectId: string;
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
}
export function LayoutSidebar({
organizations,
dashboards,
projects,
}: LayoutSidebarProps) {
const [active, setActive] = useState(false);
const pathname = usePathname();
const { organizationId } = useAppParams();
const organization = organizations.find((o) => o.id === organizationId)!;
useEffect(() => {
setActive(false);
}, [pathname]);
return (
<>
<button
type="button"
onClick={() => setActive(false)}
className={cn(
'fixed bottom-0 left-0 right-0 top-0 z-40 backdrop-blur-sm transition-opacity',
active
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0',
)}
/>
<div
className={cn(
'fixed left-0 top-0 z-40 flex h-screen w-72 flex-col border-r border-border bg-card transition-transform',
'-translate-x-72 lg:-translate-x-0', // responsive
active && 'translate-x-0', // force active on mobile
)}
>
<div className="absolute -right-12 flex h-16 items-center lg:hidden">
<Button
size="icon"
onClick={() => setActive((p) => !p)}
variant={'outline'}
>
{active ? <XIcon size={16} /> : <MenuIcon size={16} />}
</Button>
</div>
<div className="flex h-16 shrink-0 items-center gap-4 border-b border-border px-4">
<Link href="/">
<LogoSquare className="max-h-8" />
</Link>
<LayoutProjectSelector
align="start"
projects={projects}
organizations={organizations}
/>
<SettingsToggle />
</div>
<div className="flex flex-grow flex-col gap-2 overflow-auto p-4">
<LayoutMenu dashboards={dashboards} organization={organization} />
</div>
<div className="fixed bottom-0 left-0 right-0">
<div className="h-8 w-full bg-gradient-to-t from-card to-card/0" />
</div>
</div>
</>
);
}

View File

@@ -1,22 +0,0 @@
import { cn } from '@/utils/cn';
interface StickyBelowHeaderProps {
children: React.ReactNode;
className?: string;
}
export function StickyBelowHeader({
children,
className,
}: StickyBelowHeaderProps) {
return (
<div
className={cn(
'top-0 z-20 border-b border-border bg-card md:sticky',
className,
)}
>
{children}
</div>
);
}

View File

@@ -1,66 +0,0 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import {
getDashboardsByProjectId,
getOrganizations,
getProjects,
} from '@openpanel/db';
import { auth } from '@openpanel/auth/nextjs';
import LayoutContent from './layout-content';
import { LayoutSidebar } from './layout-sidebar';
import SideEffects from './side-effects';
interface AppLayoutProps {
children: React.ReactNode;
params: {
organizationSlug: string;
projectId: string;
};
}
export default async function AppLayout({
children,
params: { organizationSlug: organizationId, projectId },
}: AppLayoutProps) {
const { userId } = await auth();
const [organizations, projects, dashboards] = await Promise.all([
getOrganizations(userId),
getProjects({ organizationId, userId }),
getDashboardsByProjectId(projectId),
]);
const organization = organizations.find((item) => item.id === organizationId);
if (!organization) {
return (
<FullPageEmptyState title="Not found" className="min-h-screen">
The organization you were looking for could not be found.
</FullPageEmptyState>
);
}
if (!projects.find((item) => item.id === projectId)) {
return (
<FullPageEmptyState title="Not found" className="min-h-screen">
The project you were looking for could not be found.
</FullPageEmptyState>
);
}
return (
<div id="dashboard">
<LayoutSidebar
{...{
organizationId,
projectId,
organizations,
projects,
dashboards,
}}
/>
<LayoutContent>{children}</LayoutContent>
<SideEffects organization={organization} />
</div>
);
}

View File

@@ -1,15 +0,0 @@
interface PageLayoutProps {
title: React.ReactNode;
}
function PageLayout({ title }: PageLayoutProps) {
return (
<>
<div className="sticky top-0 z-20 flex h-16 flex-shrink-0 items-center justify-between border-b border-border bg-card px-4 pl-14 lg:pl-4">
<div className="text-xl font-medium">{title}</div>
</div>
</>
);
}
export default PageLayout;

View File

@@ -1,34 +0,0 @@
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
import { Pages } from './pages';
interface PageProps {
params: {
projectId: string;
};
searchParams: {
tab: string;
};
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['pages', 'trends'])
.withDefault('pages')
.parseServerSide(searchParams.tab);
return (
<Padding>
<PageTabs className="mb-4">
<PageTabsLink href="?tab=pages" isActive={tab === 'pages'}>
Pages
</PageTabsLink>
</PageTabs>
{tab === 'pages' && <Pages projectId={projectId} />}
</Padding>
);
}

View File

@@ -1,193 +0,0 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { Input } from '@/components/ui/input';
import { useDebounceValue } from '@/hooks/useDebounceValue';
import { type RouterOutputs, api } from '@/trpc/client';
import { parseAsInteger, useQueryState } from 'nuqs';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Pagination } from '@/components/pagination';
import { ReportChart } from '@/components/report-chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IChartRange, IInterval } from '@openpanel/validation';
import { memo } from 'react';
export function Pages({ projectId }: { projectId: string }) {
const take = 20;
const { range, interval } = useOverviewOptions();
const [filters, setFilters] = useEventQueryFilters();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
);
const [search, setSearch] = useQueryState('search', {
defaultValue: '',
shallow: true,
});
const debouncedSearch = useDebounceValue(search, 500);
const query = api.event.pages.useQuery(
{
projectId,
cursor,
take,
search: debouncedSearch,
range,
interval,
filters,
},
{
keepPreviousData: true,
},
);
const data = query.data ?? [];
return (
<>
<TableButtons>
<OverviewRange />
<OverviewInterval />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
<Input
className="self-auto"
placeholder="Search path"
value={search ?? ''}
onChange={(e) => {
setSearch(e.target.value);
setCursor(0);
}}
/>
</TableButtons>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.map((page) => {
return (
<PageCard
key={page.path}
page={page}
range={range}
interval={interval}
projectId={projectId}
/>
);
})}
</div>
<div className="p-4">
<Pagination
take={20}
count={9999}
cursor={cursor}
setCursor={setCursor}
className="self-auto"
size="base"
loading={query.isFetching}
/>
</div>
</>
);
}
const PageCard = memo(
({
page,
range,
interval,
projectId,
}: {
page: RouterOutputs['event']['pages'][number];
range: IChartRange;
interval: IInterval;
projectId: string;
}) => {
const number = useNumber();
return (
<div className="card">
<div className="row gap-4 justify-between p-4 py-2 items-center">
<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 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={{
hideID: true,
hideXAxis: true,
hideYAxis: true,
aspectRatio: 0.15,
}}
report={{
lineType: 'linear',
breakdowns: [],
name: 'screen_view',
metric: 'sum',
range,
interval,
previous: true,
chartType: 'linear',
projectId,
events: [
{
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>
);
},
);

View File

@@ -1,20 +0,0 @@
import withLoadingWidget from '@/hocs/with-loading-widget';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import MostEvents from './most-events';
type Props = {
projectId: string;
profileId: string;
};
const MostEventsServer = async ({ projectId, profileId }: Props) => {
const data = await chQuery<{ count: number; name: string }>(
`SELECT count(*) as count, name FROM ${TABLE_NAMES.events} WHERE name NOT IN ('screen_view', 'session_start', 'session_end') AND project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY name ORDER BY count DESC`,
);
return <MostEvents data={data} />;
};
export default withLoadingWidget(MostEventsServer);

View File

@@ -1,80 +0,0 @@
import ClickToCopy from '@/components/click-to-copy';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { Padding } from '@/components/ui/padding';
import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation';
import { getProfileById, getProfileByIdCached } from '@openpanel/db';
import MostEventsServer from './most-events';
import PopularRoutesServer from './popular-routes';
import ProfileActivityServer from './profile-activity';
import ProfileCharts from './profile-charts';
import Events from './profile-events';
import ProfileMetrics from './profile-metrics';
interface PageProps {
params: {
projectId: string;
profileId: string;
};
searchParams: {
events?: string;
cursor?: string;
f?: string;
startDate: string;
endDate: string;
};
}
export default async function Page({
params: { projectId, profileId },
}: PageProps) {
const profile = await getProfileById(
decodeURIComponent(profileId),
projectId,
);
if (!profile) {
return notFound();
}
return (
<Padding>
<div className="row mb-4 items-center gap-4">
<ProfileAvatar {...profile} />
<div className="min-w-0">
<ClickToCopy value={profile.id}>
<h1 className="max-w-full truncate text-3xl font-semibold">
{getProfileName(profile)}
</h1>
</ClickToCopy>
</div>
</div>
<div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-6">
<ProfileMetrics projectId={projectId} profile={profile} />
</div>
<div className="col-span-6">
<ProfileActivityServer
profileId={profileId}
projectId={projectId}
/>
</div>
<div className="col-span-6 md:col-span-3">
<MostEventsServer profileId={profileId} projectId={projectId} />
</div>
<div className="col-span-6 md:col-span-3">
<PopularRoutesServer profileId={profileId} projectId={projectId} />
</div>
<ProfileCharts profileId={profileId} projectId={projectId} />
</div>
<div className="mt-8">
<Events profileId={profileId} projectId={projectId} />
</div>
</div>
</Padding>
);
}

View File

@@ -1,20 +0,0 @@
import withLoadingWidget from '@/hocs/with-loading-widget';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import PopularRoutes from './popular-routes';
type Props = {
projectId: string;
profileId: string;
};
const PopularRoutesServer = async ({ projectId, profileId }: Props) => {
const data = await chQuery<{ count: number; path: string }>(
`SELECT count(*) as count, path FROM ${TABLE_NAMES.events} WHERE name = 'screen_view' AND project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY path ORDER BY count DESC`,
);
return <PopularRoutes data={data} />;
};
export default withLoadingWidget(PopularRoutesServer);

View File

@@ -1,20 +0,0 @@
import withLoadingWidget from '@/hocs/with-loading-widget';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import ProfileActivity from './profile-activity';
type Props = {
projectId: string;
profileId: string;
};
const ProfileActivityServer = async ({ projectId, profileId }: Props) => {
const data = await chQuery<{ count: number; date: string }>(
`SELECT count(*) as count, toStartOfDay(created_at) as date FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY date ORDER BY date DESC`,
);
return <ProfileActivity data={data} />;
};
export default withLoadingWidget(ProfileActivityServer);

View File

@@ -1,164 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Widget,
WidgetBody,
WidgetHead,
WidgetTitle,
} from '@/components/widget';
import { cn } from '@/utils/cn';
import {
addMonths,
eachDayOfInterval,
endOfMonth,
format,
formatISO,
isSameMonth,
startOfMonth,
subMonths,
} from 'date-fns';
import { ActivityIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { useState } from 'react';
type Props = {
data: { count: number; date: string }[];
};
const ProfileActivity = ({ data }: Props) => {
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
const endDate = endOfMonth(startDate);
return (
<Widget className="w-full">
<WidgetHead className="flex justify-between">
<WidgetTitle icon={ActivityIcon}>Activity</WidgetTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setStartDate(subMonths(startDate, 1))}
>
<ChevronLeftIcon size={14} />
</Button>
<Button
variant="outline"
size="icon"
disabled={isSameMonth(startDate, new Date())}
onClick={() => setStartDate(addMonths(startDate, 1))}
>
<ChevronRightIcon size={14} />
</Button>
</div>
</WidgetHead>
<WidgetBody>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<div className="mb-2 text-sm">
{format(subMonths(startDate, 3), 'MMMM yyyy')}
</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 3)),
end: endOfMonth(subMonths(startDate, 3)),
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' }),
),
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-sm">
{format(subMonths(startDate, 2), 'MMMM yyyy')}
</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 2)),
end: endOfMonth(subMonths(startDate, 2)),
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' }),
),
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-sm">
{format(subMonths(startDate, 1), 'MMMM yyyy')}
</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 1)),
end: endOfMonth(subMonths(startDate, 1)),
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' }),
),
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-sm">{format(startDate, 'MMMM yyyy')}</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startDate,
end: endDate,
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' }),
),
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
</div>
</div>
</WidgetBody>
</Widget>
);
};
export default ProfileActivity;

View File

@@ -1,106 +0,0 @@
'use client';
import { ReportChart } from '@/components/report-chart';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { memo } from 'react';
import type { IChartProps } from '@openpanel/validation';
type Props = {
profileId: string;
projectId: string;
};
const ProfileCharts = ({ profileId, projectId }: Props) => {
const pageViewsChart: IChartProps = {
projectId,
chartType: 'linear',
events: [
{
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
],
id: 'A',
name: 'screen_view',
displayName: 'Events',
},
],
breakdowns: [
{
id: 'path',
name: 'path',
},
],
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '30d',
previous: false,
metric: 'sum',
};
const eventsChart: IChartProps = {
projectId,
chartType: 'linear',
events: [
{
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
],
id: 'A',
name: '*',
displayName: 'Events',
},
],
breakdowns: [
{
id: 'name',
name: 'name',
},
],
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '30d',
previous: false,
metric: 'sum',
};
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<span className="title">Page views</span>
</WidgetHead>
<WidgetBody>
<ReportChart report={pageViewsChart} />
</WidgetBody>
</Widget>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody>
<ReportChart report={eventsChart} />
</WidgetBody>
</Widget>
</>
);
};
// No clue why I need to check for equality here
export default memo(ProfileCharts, (a, b) => {
return a.profileId === b.profileId && a.projectId === b.projectId;
});

View File

@@ -1,55 +0,0 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { EventsTable } from '@/components/events/table';
import { EventsTableColumns } from '@/components/events/table/events-table-columns';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
type Props = {
projectId: string;
profileId: string;
};
const Events = ({ projectId, profileId }: Props) => {
const [filters] = useEventQueryFilters();
const query = api.event.events.useInfiniteQuery(
{
projectId,
filters,
profileId,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
keepPreviousData: true,
},
);
return (
<div>
<TableButtons>
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
<EventsTableColumns />
{query.isRefetching && (
<div className="center-center size-8 rounded border bg-background">
<Loader2Icon
size={12}
className="size-4 shrink-0 animate-spin text-black text-highlight"
/>
</div>
)}
</TableButtons>
<EventsTable query={query} />
</div>
);
};
export default Events;

View File

@@ -1,18 +0,0 @@
import withSuspense from '@/hocs/with-suspense';
import type { IServiceProfile } from '@openpanel/db';
import { getProfileMetrics } from '@openpanel/db';
import ProfileMetrics from './profile-metrics';
type Props = {
projectId: string;
profile: IServiceProfile;
};
const ProfileMetricsServer = async ({ projectId, profile }: Props) => {
const data = await getProfileMetrics(profile.id, projectId);
return <ProfileMetrics data={data} profile={profile} />;
};
export default withSuspense(ProfileMetricsServer, () => null);

View File

@@ -1,123 +0,0 @@
'use client';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { formatDateTime, utc } from '@/utils/date';
import { formatDistanceToNow } from 'date-fns';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import type { IProfileMetrics, IServiceProfile } from '@openpanel/db';
type Props = {
data: IProfileMetrics;
profile: IServiceProfile;
};
function Card({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
<div className="text-muted-foreground">{title}</div>
<div className="truncate font-mono text-2xl font-bold">{value}</div>
</div>
);
}
function Info({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2">
<div className="capitalize text-muted-foreground">{title}</div>
<div className="truncate font-mono">
{value
? typeof value === 'string'
? value
: JSON.stringify(value)
: '-'}
</div>
</div>
);
}
const ProfileMetrics = ({ data, profile }: Props) => {
const [tab, setTab] = useQueryState(
'tab',
parseAsStringEnum(['profile', 'properties']).withDefault('profile'),
);
const number = useNumber();
return (
<div className="@container">
<div className="grid grid-cols-2 overflow-hidden whitespace-nowrap rounded-md border bg-background @xl:grid-cols-3 @4xl:grid-cols-6">
<div className="col-span-2 @xl:col-span-3 @4xl:col-span-6">
<div className="row border-b">
<button
type="button"
onClick={() => setTab('profile')}
className={cn(
'p-4',
'opacity-50',
tab === 'profile' &&
'border-b border-foreground text-foreground opacity-100',
)}
>
Profile
</button>
<div className="h-full w-px bg-border" />
<button
type="button"
onClick={() => setTab('properties')}
className={cn(
'p-4',
'opacity-50',
tab === 'properties' &&
'border-b border-foreground text-foreground opacity-100',
)}
>
Properties
</button>
</div>
<div className="grid grid-cols-2 gap-4 p-4">
{tab === 'profile' && (
<>
<Info title="ID" value={profile.id} />
<Info title="First name" value={profile.firstName} />
<Info title="Last name" value={profile.lastName} />
<Info title="Email" value={profile.email} />
<Info
title="Updated"
value={formatDateTime(new Date(profile.createdAt))}
/>
<ListPropertiesIcon {...profile.properties} />
</>
)}
{tab === 'properties' &&
Object.entries(profile.properties)
.filter(([key, value]) => value !== undefined)
.map(([key, value]) => (
<Info key={key} title={key} value={value as string} />
))}
</div>
</div>
<Card
title="First seen"
value={formatDistanceToNow(utc(data.firstSeen))}
/>
<Card
title="Last seen"
value={formatDistanceToNow(utc(data.lastSeen))}
/>
<Card title="Sessions" value={number.format(data.sessions)} />
<Card
title="Avg. Session"
value={number.formatWithUnit(data.durationAvg / 1000, 'min')}
/>
<Card
title="P90. Session"
value={number.formatWithUnit(data.durationP90 / 1000, 'min')}
/>
<Card title="Page views" value={number.format(data.screenViews)} />
</div>
</div>
);
};
export default ProfileMetrics;

View File

@@ -1,44 +0,0 @@
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
import PowerUsers from './power-users';
import Profiles from './profiles';
interface PageProps {
params: {
projectId: string;
};
searchParams: Record<string, string>;
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['profiles', 'power-users'])
.withDefault('profiles')
.parseServerSide(searchParams.tab);
return (
<>
<Padding>
<div className="mb-4">
<PageTabs>
<PageTabsLink href={'?tab=profiles'} isActive={tab === 'profiles'}>
Profiles
</PageTabsLink>
<PageTabsLink
href={'?tab=power-users'}
isActive={tab === 'power-users'}
>
Power users
</PageTabsLink>
</PageTabs>
</div>
{tab === 'profiles' && <Profiles projectId={projectId} />}
{tab === 'power-users' && <PowerUsers projectId={projectId} />}
</Padding>
</>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
import { ProfilesTable } from '@/components/profiles/table';
import { api } from '@/trpc/client';
import { parseAsInteger, useQueryState } from 'nuqs';
type Props = {
projectId: string;
profileId?: string;
};
const Events = ({ projectId }: Props) => {
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
);
const query = api.profile.powerUsers.useQuery(
{
cursor,
projectId,
take: 50,
// filters,
},
{
keepPreviousData: true,
},
);
return (
<div>
<ProfilesTable
query={query}
cursor={cursor}
setCursor={setCursor}
type="power-users"
/>
</div>
);
};
export default Events;

View File

@@ -1,68 +0,0 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { cn } from '@/utils/cn';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
interface Props {
projectId: string;
}
export default async function ProfileLastSeenServer({ projectId }: Props) {
interface Row {
days: number;
count: number;
}
// Days since last event from users
// group by days
const res = await chQuery<Row>(
`SELECT age('days',created_at, now()) as days, count(distinct profile_id) as count FROM ${TABLE_NAMES.events} where project_id = ${escape(projectId)} group by days order by days ASC LIMIT 51`,
);
const maxValue = Math.max(...res.map((x) => x.count));
const minValue = Math.min(...res.map((x) => x.count));
const calculateRatio = (currentValue: number) =>
Math.max(
0.1,
Math.min(1, (currentValue - minValue) / (maxValue - minValue)),
);
const renderItem = (item: Row) => (
<div className="flex w-1/12 flex-col items-center p-1">
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn('aspect-square w-full shrink-0 rounded bg-highlight')}
style={{
opacity: calculateRatio(item.count),
}}
/>
</TooltipTrigger>
<TooltipContent>
{item.count} profiles last seen{' '}
{item.days === 0 ? 'today' : `${item.days} days ago`}
</TooltipContent>
</Tooltip>
<div className="mt-1 text-[10px]">{item.days}</div>
</div>
);
return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Last seen</div>
</WidgetHead>
<WidgetBody>
<div className="flex w-full flex-wrap items-start justify-start">
{res.map(renderItem)}
</div>
<div className="text-center text-sm text-muted-foreground">DAYS</div>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,51 +0,0 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { ProfilesTable } from '@/components/profiles/table';
import { Input } from '@/components/ui/input';
import { useDebounceValue } from '@/hooks/useDebounceValue';
import { api } from '@/trpc/client';
import { parseAsInteger, useQueryState } from 'nuqs';
type Props = {
projectId: string;
profileId?: string;
};
const Events = ({ projectId }: Props) => {
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
);
const [search, setSearch] = useQueryState('search', {
defaultValue: '',
shallow: true,
});
const debouncedSearch = useDebounceValue(search, 500);
const query = api.profile.list.useQuery(
{
cursor,
projectId,
take: 50,
search: debouncedSearch,
},
{
keepPreviousData: true,
},
);
return (
<div>
<TableButtons>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search profiles"
/>
</TableButtons>
<ProfilesTable query={query} cursor={cursor} setCursor={setCursor} />
</div>
);
};
export default Events;

View File

@@ -1,222 +0,0 @@
export type Coordinate = {
lat: number;
long: number;
};
export function haversineDistance(
coord1: Coordinate,
coord2: Coordinate,
): number {
const R = 6371; // Earth's radius in kilometers
const lat1Rad = coord1.lat * (Math.PI / 180);
const lat2Rad = coord2.lat * (Math.PI / 180);
const deltaLatRad = (coord2.lat - coord1.lat) * (Math.PI / 180);
const deltaLonRad = (coord2.long - coord1.long) * (Math.PI / 180);
const a =
Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
Math.cos(lat1Rad) *
Math.cos(lat2Rad) *
Math.sin(deltaLonRad / 2) *
Math.sin(deltaLonRad / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in kilometers
}
export function findFarthestPoints(
coordinates: Coordinate[],
): [Coordinate, Coordinate] {
if (coordinates.length < 2) {
throw new Error('At least two coordinates are required');
}
let maxDistance = 0;
let point1: Coordinate = coordinates[0]!;
let point2: Coordinate = coordinates[1]!;
for (let i = 0; i < coordinates.length; i++) {
for (let j = i + 1; j < coordinates.length; j++) {
const distance = haversineDistance(coordinates[i]!, coordinates[j]!);
if (distance > maxDistance) {
maxDistance = distance;
point1 = coordinates[i]!;
point2 = coordinates[j]!;
}
}
}
return [point1, point2];
}
export function getAverageCenter(coordinates: Coordinate[]): Coordinate {
if (coordinates.length === 0) {
return { long: 0, lat: 20 };
}
let sumLong = 0;
let sumLat = 0;
for (const coord of coordinates) {
sumLong += coord.long;
sumLat += coord.lat;
}
const avgLat = sumLat / coordinates.length;
const avgLong = sumLong / coordinates.length;
return { long: avgLong, lat: avgLat };
}
function sortCoordinates(a: Coordinate, b: Coordinate): number {
return a.long === b.long ? a.lat - b.lat : a.long - b.long;
}
function cross(o: Coordinate, a: Coordinate, b: Coordinate): number {
return (
(a.long - o.long) * (b.lat - o.lat) - (a.lat - o.lat) * (b.long - o.long)
);
}
// convex hull
export function getOuterMarkers(coordinates: Coordinate[]): Coordinate[] {
const sorted = coordinates.sort(sortCoordinates);
if (sorted.length <= 3) return sorted;
const lower: Coordinate[] = [];
for (const coord of sorted) {
while (
lower.length >= 2 &&
cross(lower[lower.length - 2]!, lower[lower.length - 1]!, coord) <= 0
) {
lower.pop();
}
lower.push(coord);
}
const upper: Coordinate[] = [];
for (let i = coordinates.length - 1; i >= 0; i--) {
while (
upper.length >= 2 &&
cross(upper[upper.length - 2]!, upper[upper.length - 1]!, sorted[i]!) <= 0
) {
upper.pop();
}
upper.push(sorted[i]!);
}
upper.pop();
lower.pop();
return lower.concat(upper);
}
export function calculateCentroid(polygon: Coordinate[]): Coordinate {
if (polygon.length < 3) {
throw new Error('At least three points are required to form a polygon.');
}
let area = 0;
let centroidLat = 0;
let centroidLong = 0;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const x0 = polygon[j]!.long;
const y0 = polygon[j]!.lat;
const x1 = polygon[i]!.long;
const y1 = polygon[i]!.lat;
const a = x0 * y1 - x1 * y0;
area += a;
centroidLong += (x0 + x1) * a;
centroidLat += (y0 + y1) * a;
}
area = area / 2;
if (area === 0) {
// This should not happen for a proper convex hull
throw new Error('Area of the polygon is zero, check the coordinates.');
}
centroidLat /= 6 * area;
centroidLong /= 6 * area;
return { lat: centroidLat, long: centroidLong };
}
export function calculateGeographicMidpoint(
coordinate: Coordinate[],
): Coordinate {
let minLat = Number.POSITIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
let minLong = Number.POSITIVE_INFINITY;
let maxLong = Number.NEGATIVE_INFINITY;
for (const { lat, long } of coordinate) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (long < minLong) minLong = long;
if (long > maxLong) maxLong = long;
}
// Handling the wrap around the international date line
let midLong: number;
if (maxLong > minLong) {
midLong = (maxLong + minLong) / 2;
} else {
// Adjust calculation when spanning the dateline
midLong = ((maxLong + 360 + minLong) / 2) % 360;
}
const midLat = (maxLat + minLat) / 2;
return { lat: midLat, long: midLong };
}
export function clusterCoordinates(coordinates: Coordinate[], radius = 25) {
const clusters: {
center: Coordinate;
count: number;
members: Coordinate[];
}[] = [];
const visited = new Set<number>();
coordinates.forEach((coord, idx) => {
if (!visited.has(idx)) {
const cluster = {
members: [coord],
center: { lat: coord.lat, long: coord.long },
count: 0,
};
coordinates.forEach((otherCoord, otherIdx) => {
if (
!visited.has(otherIdx) &&
haversineDistance(coord, otherCoord) <= radius
) {
cluster.members.push(otherCoord);
visited.add(otherIdx);
cluster.count++;
}
});
// Calculate geographic center for the cluster
cluster.center = cluster.members.reduce(
(center, cur) => {
return {
lat: center.lat + cur.lat / cluster.members.length,
long: center.long + cur.long / cluster.members.length,
};
},
{ lat: 0, long: 0 },
);
clusters.push(cluster);
}
});
return clusters.map((cluster) => ({
center: cluster.center,
count: cluster.count,
members: cluster.members,
}));
}

View File

@@ -1,20 +0,0 @@
import { subMinutes } from 'date-fns';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery, formatClickhouseDate } from '@openpanel/db';
import type { Coordinate } from './coordinates';
import Map from './map';
type Props = {
projectId: string;
};
const RealtimeMap = async ({ projectId }: Props) => {
const res = await chQuery<Coordinate>(
`SELECT DISTINCT city, longitude as long, latitude as lat FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)} AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}' ORDER BY created_at DESC`,
);
return <Map markers={res} />;
};
export default RealtimeMap;

View File

@@ -1,185 +0,0 @@
'use client';
import { useFullscreen } from '@/components/fullscreen-toggle';
import { Tooltiper } from '@/components/ui/tooltip';
import { cn } from '@/utils/cn';
import { bind } from 'bind-event-listener';
import { useTheme } from 'next-themes';
import { Fragment, useEffect, useRef, useState } from 'react';
import {
ComposableMap,
Geographies,
Geography,
Marker,
} from 'react-simple-maps';
import type { Coordinate } from './coordinates';
import {
calculateGeographicMidpoint,
clusterCoordinates,
getAverageCenter,
getOuterMarkers,
} from './coordinates';
import {
CustomZoomableGroup,
GEO_MAP_URL,
determineZoom,
getBoundingBox,
useAnimatedState,
} from './map.helpers';
import { calculateMarkerSize } from './markers';
type Props = {
markers: Coordinate[];
};
const Map = ({ markers }: Props) => {
const [isFullscreen] = useFullscreen();
const showCenterMarker = false;
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(
null,
);
// const { markers, toggle } = useActiveMarkers(_m);
const hull = getOuterMarkers(markers);
const center =
hull.length < 2
? getAverageCenter(markers)
: calculateGeographicMidpoint(hull);
const boundingBox = getBoundingBox(hull);
const [zoom] = useAnimatedState(
markers.length === 1
? 1
: determineZoom(boundingBox, size ? size?.height / size?.width : 1),
);
const [long] = useAnimatedState(center.long);
const [lat] = useAnimatedState(center.lat);
useEffect(() => {
return bind(window, {
type: 'resize',
listener() {
if (ref.current) {
setSize({
width: ref.current.clientWidth,
height: ref.current.clientHeight,
});
}
},
});
}, []);
useEffect(() => {
if (ref.current) {
setSize({
width: ref.current.clientWidth,
height: ref.current.clientHeight,
});
}
}, []);
const adjustSizeBasedOnZoom = (size: number) => {
const minMultiplier = 1;
const maxMultiplier = 7;
// Linearly interpolate the multiplier based on the zoom level
const multiplier =
maxMultiplier - ((zoom - 1) * (maxMultiplier - minMultiplier)) / (20 - 1);
return size * multiplier;
};
const theme = useTheme();
return (
<div className={cn('absolute bottom-0 left-0 right-0 top-0')} ref={ref}>
{size === null ? (
<></>
) : (
<>
<ComposableMap
projection="geoMercator"
projectionConfig={{
rotate: [0, 0, 0],
scale: 100 * 20,
}}
>
<CustomZoomableGroup zoom={zoom * 0.06} center={[long, lat]}>
<Geographies geography={GEO_MAP_URL}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
fill={theme.resolvedTheme === 'dark' ? '#000' : '#e5eef6'}
stroke={
theme.resolvedTheme === 'dark' ? '#333' : '#bcccda'
}
pointerEvents={'none'}
/>
))
}
</Geographies>
{showCenterMarker && (
<Marker coordinates={[center.long, center.lat]}>
<circle
r={adjustSizeBasedOnZoom(30)}
fill="green"
stroke="#fff"
strokeWidth={adjustSizeBasedOnZoom(2)}
/>
</Marker>
)}
{clusterCoordinates(markers).map((marker) => {
const size = adjustSizeBasedOnZoom(
calculateMarkerSize(marker.count),
);
const coordinates: [number, number] = [
marker.center.long,
marker.center.lat,
];
return (
<Fragment key={coordinates.join('-')}>
<Marker coordinates={coordinates}>
<circle
r={size}
fill={
theme.resolvedTheme === 'dark' ? '#3d79ff' : '#2266ec'
}
className="animate-ping opacity-20"
/>
</Marker>
<Tooltiper asChild content={`${marker.count} visitors`}>
<Marker coordinates={coordinates}>
<circle
r={size}
fill={
theme.resolvedTheme === 'dark'
? '#3d79ff'
: '#2266ec'
}
fillOpacity={0.5}
/>
</Marker>
</Tooltiper>
</Fragment>
);
})}
</CustomZoomableGroup>
</ComposableMap>
</>
)}
{/* <Button
className="fixed bottom-[100px] left-[320px] z-50 opacity-0"
onClick={() => {
toggle();
}}
>
Toogle
</Button> */}
</div>
);
};
export default Map;

View File

@@ -1,145 +0,0 @@
import {
Fullscreen,
FullscreenClose,
FullscreenOpen,
} from '@/components/fullscreen-toggle';
import { ReportChart } from '@/components/report-chart';
import { Suspense } from 'react';
import RealtimeMap from './map';
import RealtimeLiveEventsServer from './realtime-live-events';
import { RealtimeLiveHistogram } from './realtime-live-histogram';
import RealtimeReloader from './realtime-reloader';
type Props = {
params: {
projectId: string;
};
};
export default function Page({ params: { projectId } }: Props) {
return (
<>
<Fullscreen>
<FullscreenClose />
<RealtimeReloader projectId={projectId} />
<Suspense>
<RealtimeMap projectId={projectId} />
</Suspense>
<div className="row relative z-10 min-h-screen items-start gap-4 overflow-hidden p-8">
<FullscreenOpen />
<div className="card min-w-52 bg-card/80 p-4 md:min-w-80">
<RealtimeLiveHistogram projectId={projectId} />
</div>
<div className="col-span-2">
<RealtimeLiveEventsServer projectId={projectId} limit={5} />
</div>
</div>
<div className="relative z-10 -mt-32 grid gap-4 p-8 md:grid-cols-3">
<div className="card p-4">
<div className="mb-6">
<div className="font-bold">Pages</div>
</div>
<ReportChart
options={{
hideID: true,
}}
report={{
projectId,
events: [
{
filters: [],
segment: 'event',
id: 'A',
name: 'screen_view',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: 'minute',
name: 'Top sources',
range: '30min',
previous: false,
metric: 'sum',
}}
/>
</div>
<div className="card p-4">
<div className="mb-6">
<div className="font-bold">Cities</div>
</div>
<ReportChart
options={{
hideID: true,
}}
report={{
projectId,
events: [
{
segment: 'event',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'city',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: 'minute',
name: 'Top sources',
range: '30min',
previous: false,
metric: 'sum',
}}
/>
</div>
<div className="card p-4">
<div className="mb-6">
<div className="font-bold">Referrers</div>
</div>
<ReportChart
options={{
hideID: true,
}}
report={{
projectId,
events: [
{
segment: 'event',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: 'minute',
name: 'Top sources',
range: '30min',
previous: false,
metric: 'sum',
}}
/>
</div>
</div>
</Fullscreen>
</>
);
}

View File

@@ -1,21 +0,0 @@
import { escape } from 'sqlstring';
import { TABLE_NAMES, getEvents } from '@openpanel/db';
import LiveEvents from './live-events';
type Props = {
projectId: string;
limit?: number;
};
const RealtimeLiveEventsServer = async ({ projectId, limit = 30 }: Props) => {
const events = await getEvents(
`SELECT * FROM ${TABLE_NAMES.events} WHERE created_at > now() - INTERVAL 2 HOUR AND project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT ${limit}`,
{
profile: true,
},
);
return <LiveEvents events={events} projectId={projectId} limit={limit} />;
};
export default RealtimeLiveEventsServer;

View File

@@ -1,46 +0,0 @@
'use client';
import { EventListItem } from '@/components/events/event-list-item';
import useWS from '@/hooks/useWS';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
type Props = {
events: (IServiceEventMinimal | IServiceEvent)[];
projectId: string;
limit: number;
};
const RealtimeLiveEvents = ({ events, projectId, limit }: Props) => {
const [state, setState] = useState(events ?? []);
useWS<IServiceEventMinimal | IServiceEvent>(
`/live/events/${projectId}`,
(event) => {
setState((p) => [event, ...p].slice(0, limit));
},
);
return (
<AnimatePresence mode="popLayout" initial={false}>
<div className="flex gap-4">
{state.map((event) => (
<motion.div
key={event.id}
layout
initial={{ opacity: 0, y: -200, x: 0, scale: 0.5 }}
animate={{ opacity: 1, y: 0, x: 0, scale: 1 }}
exit={{ opacity: 0, y: 0, x: 200, scale: 1.2 }}
transition={{ duration: 0.6, type: 'spring' }}
>
<div className="w-[380px]">
<EventListItem {...event} />
</div>
</motion.div>
))}
</div>
</AnimatePresence>
);
};
export default RealtimeLiveEvents;

View File

@@ -1,35 +0,0 @@
'use client';
import useWS from '@/hooks/useWS';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
type Props = {
projectId: string;
};
const RealtimeReloader = ({ projectId }: Props) => {
const client = useQueryClient();
const router = useRouter();
useWS<number>(
`/live/events/${projectId}`,
() => {
if (!document.hidden) {
client.refetchQueries({
type: 'active',
});
}
},
{
debounce: {
maxWait: 60000,
delay: 60000,
},
},
);
return null;
};
export default RealtimeReloader;

View File

@@ -1,29 +0,0 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import EditReportName from '@/components/report/edit-report-name';
import { notFound } from 'next/navigation';
import { getReportById } from '@openpanel/db';
import ReportEditor from '../report-editor';
interface PageProps {
params: {
projectId: string;
reportId: string;
};
}
export default async function Page({ params: { reportId } }: PageProps) {
const report = await getReportById(reportId);
if (!report) {
return notFound();
}
return (
<>
<PageLayout title={<EditReportName name={report.name} />} />
<ReportEditor report={report} />
</>
);
}

View File

@@ -1,13 +0,0 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import EditReportName from '@/components/report/edit-report-name';
import ReportEditor from './report-editor';
export default function Page() {
return (
<>
<PageLayout title={<EditReportName name={undefined} />} />
<ReportEditor report={null} />
</>
);
}

View File

@@ -1,103 +0,0 @@
'use client';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
Tooltip as RechartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
type Props = {
data: { users: number; days: number }[];
};
function Tooltip(props: any) {
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div>
<div className="text-sm text-muted-foreground">
Days since last seen
</div>
<div className="text-lg font-semibold">{payload.days}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Active users</div>
<div className="text-lg font-semibold">{payload.users}</div>
</div>
</div>
);
}
const Chart = ({ data }: Props) => {
const xAxisProps = useXAxisProps();
const yAxisProps = useYAxisProps();
return (
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>
<AreaChart data={data}>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<RechartTooltip content={<Tooltip />} />
<Area
dataKey="users"
stroke={getChartColor(0)}
strokeWidth={2}
fill={'url(#bg)'}
isAnimationActive={false}
/>
<XAxis
{...xAxisProps}
dataKey="days"
scale="auto"
type="category"
label={{
value: 'DAYS',
position: 'insideBottom',
offset: 0,
fontSize: 10,
}}
/>
<YAxis
{...yAxisProps}
label={{
value: 'USERS',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 10,
}}
dataKey="users"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default Chart;

View File

@@ -1,25 +0,0 @@
import { Widget, WidgetHead } from '@/components/widget';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getRetentionLastSeenSeries } from '@openpanel/db';
import Chart from './chart';
type Props = {
projectId: string;
};
const LastActiveUsersServer = async ({ projectId }: Props) => {
const res = await getRetentionLastSeenSeries({ projectId });
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Last time in days a user was active</span>
</WidgetHead>
<Chart data={res} />
</Widget>
);
};
export default withLoadingWidget(LastActiveUsersServer);

View File

@@ -1,65 +0,0 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Padding } from '@/components/ui/padding';
import { AlertCircleIcon } from 'lucide-react';
import LastActiveUsersServer from './last-active-users';
import RollingActiveUsers from './rolling-active-users';
import UsersRetentionSeries from './users-retention-series';
import WeeklyCohortsServer from './weekly-cohorts';
type Props = {
params: {
projectId: string;
};
};
const Retention = ({ params: { projectId } }: Props) => {
return (
<Padding>
<h1 className="mb-4 text-3xl font-semibold">Retention</h1>
<div className="flex max-w-6xl flex-col gap-8">
<Alert>
<AlertCircleIcon size={18} />
<AlertTitle>Experimental feature</AlertTitle>
<AlertDescription>
<p>
This page is an experimental feature and we&apos;ll be working
hard to make it even better. Stay tuned!
</p>
<p>
Please DM me on{' '}
<a
href="https://go.openpanel.dev/discord"
className="font-medium underline"
>
Discord
</a>{' '}
or{' '}
<a
href="https://x.com/OpenPanelDev"
className="font-medium underline"
>
X/Twitter
</a>{' '}
if you notice any issues.
</p>
</AlertDescription>
</Alert>
<RollingActiveUsers projectId={projectId} />
<Alert>
<AlertCircleIcon size={18} />
<AlertTitle>Retention info</AlertTitle>
<AlertDescription>
This information is only relevant if you supply a user ID to the
SDK!
</AlertDescription>
</Alert>
<LastActiveUsersServer projectId={projectId} />
{/* <UsersRetentionSeries projectId={projectId} /> */}
<WeeklyCohortsServer projectId={projectId} />
</div>
</Padding>
);
};
export default Retention;

View File

@@ -1,146 +0,0 @@
'use client';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
Tooltip as RechartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import type { IServiceRetentionRollingActiveUsers } from '@openpanel/db';
type Props = {
data: {
daily: IServiceRetentionRollingActiveUsers[];
weekly: IServiceRetentionRollingActiveUsers[];
monthly: IServiceRetentionRollingActiveUsers[];
};
};
function Tooltip(props: any) {
const payload = props.payload?.[2]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">{payload.date}</div>
<div>
<div className="text-sm text-muted-foreground">
Monthly active users
</div>
<div className="text-lg font-semibold text-chart-2">{payload.mau}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Weekly active users</div>
<div className="text-lg font-semibold text-chart-1">{payload.wau}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Daily active users</div>
<div className="text-lg font-semibold text-chart-0">{payload.dau}</div>
</div>
</div>
);
}
const Chart = ({ data }: Props) => {
const rechartData = data.daily.map((d) => ({
date: new Date(d.date).getTime(),
dau: d.users,
wau: data.weekly.find((w) => w.date === d.date)?.users,
mau: data.monthly.find((m) => m.date === d.date)?.users,
}));
const xAxisProps = useXAxisProps({ interval: 'day' });
const yAxisProps = useYAxisProps();
return (
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>
<AreaChart data={rechartData}>
<defs>
<linearGradient id="dau" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="wau" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(1)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(1)}
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="mau" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(2)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(2)}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<RechartTooltip content={<Tooltip />} />
<Area
dataKey="dau"
stroke={getChartColor(0)}
strokeWidth={2}
fill={'url(#dau)'}
isAnimationActive={false}
/>
<Area
dataKey="wau"
stroke={getChartColor(1)}
strokeWidth={2}
fill={'url(#wau)'}
isAnimationActive={false}
/>
<Area
dataKey="mau"
stroke={getChartColor(2)}
strokeWidth={2}
fill={'url(#mau)'}
isAnimationActive={false}
/>
<XAxis {...xAxisProps} dataKey="date" />
<YAxis
{...yAxisProps}
label={{
value: 'UNIQUE USERS',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 10,
}}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default Chart;

View File

@@ -1,35 +0,0 @@
import { Widget, WidgetHead } from '@/components/widget';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getRollingActiveUsers } from '@openpanel/db';
import Chart from './chart';
type Props = {
projectId: string;
};
const RollingActiveUsersServer = async ({ projectId }: Props) => {
const series = await Promise.all([
await getRollingActiveUsers({ projectId, days: 1 }),
await getRollingActiveUsers({ projectId, days: 7 }),
await getRollingActiveUsers({ projectId, days: 30 }),
]);
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Rolling active users</span>
</WidgetHead>
<Chart
data={{
daily: series[0],
weekly: series[1],
monthly: series[2],
}}
/>
</Widget>
);
};
export default withLoadingWidget(RollingActiveUsersServer);

View File

@@ -1,117 +0,0 @@
'use client';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
Tooltip as RechartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { round } from '@openpanel/common';
type Props = {
data: {
date: string;
active_users: number;
retained_users: number;
retention: number;
}[];
};
function Tooltip({ payload }: any) {
const { date, active_users, retained_users, retention } =
payload?.[0]?.payload || {};
const formatDate = useFormatDateInterval('day');
if (!date) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(date))}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Active Users</div>
<div className="text-lg font-semibold">{active_users}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Retained Users</div>
<div className="text-lg font-semibold">{retained_users}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Retention</div>
<div className="text-lg font-semibold">{round(retention, 2)}%</div>
</div>
</div>
);
}
const Chart = ({ data }: Props) => {
const xAxisProps = useXAxisProps();
const yAxisProps = useYAxisProps();
return (
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>
<AreaChart data={data}>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<RechartTooltip content={<Tooltip />} />
<Area
dataKey="retention"
stroke={getChartColor(0)}
strokeWidth={2}
fill={'url(#bg)'}
isAnimationActive={false}
/>
<XAxis
{...xAxisProps}
dataKey="date"
tickFormatter={(m: string) => formatDate(new Date(m))}
allowDuplicatedCategory={false}
label={{
value: 'DATE',
position: 'insideBottom',
offset: 0,
fontSize: 10,
}}
/>
<YAxis
{...yAxisProps}
label={{
value: 'RETENTION (%)',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 10,
}}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default Chart;

View File

@@ -1,25 +0,0 @@
import { Widget, WidgetHead } from '@/components/widget';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getRetentionSeries } from '@openpanel/db';
import Chart from './chart';
type Props = {
projectId: string;
};
const UsersRetentionSeries = async ({ projectId }: Props) => {
const res = await getRetentionSeries({ projectId });
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Stickyness / Retention (%)</span>
</WidgetHead>
<Chart data={res} />
</Widget>
);
};
export default withLoadingWidget(UsersRetentionSeries);

View File

@@ -1,132 +0,0 @@
import { Widget, WidgetHead } from '@/components/widget';
import { WidgetTableHead } from '@/components/widget-table';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { cn } from '@/utils/cn';
import { getRetentionCohortTable } from '@openpanel/db';
type Props = {
projectId: string;
};
const Cell = ({ value, ratio }: { value: number; ratio: number }) => {
return (
<td
className={cn('relative h-8 border', ratio !== 0 && 'border-background')}
>
<div
className="absolute inset-0 z-0 bg-highlight"
style={{ opacity: ratio }}
/>
<div className="relative z-10">{value}</div>
</td>
);
};
const WeeklyCohortsServer = async ({ projectId }: Props) => {
const res = await getRetentionCohortTable({ projectId });
const minValue = 0;
const maxValue = Math.max(
...res.flatMap((row) => [
row.period_0,
row.period_1,
row.period_2,
row.period_3,
row.period_4,
row.period_5,
row.period_6,
row.period_7,
row.period_8,
row.period_9,
]),
);
const calculateRatio = (currentValue: number) =>
currentValue === 0
? 0
: Math.max(
0.1,
Math.min(1, (currentValue - minValue) / (maxValue - minValue)),
);
return (
<Widget>
<WidgetHead>
<span className="title">Weekly Cohorts</span>
</WidgetHead>
<div className="overflow-hidden rounded-b-xl">
<div className="-m-px">
<table className="w-full table-fixed border-collapse text-center">
<WidgetTableHead className="[&_th]:border-b-2 [&_th]:!text-center">
<tr>
<th>Week</th>
<th>0</th>
<th>1</th>
<th>2</th>
<th>3</th>
<th>4</th>
<th>5</th>
<th>6</th>
<th>7</th>
<th>8</th>
<th>9</th>
</tr>
</WidgetTableHead>
<tbody>
{res.map((row) => (
<tr key={row.first_seen}>
<td className="text-def-1000 bg-def-100 font-medium">
{row.first_seen}
</td>
<Cell
value={row.period_0}
ratio={calculateRatio(row.period_0)}
/>
<Cell
value={row.period_1}
ratio={calculateRatio(row.period_1)}
/>
<Cell
value={row.period_2}
ratio={calculateRatio(row.period_2)}
/>
<Cell
value={row.period_3}
ratio={calculateRatio(row.period_3)}
/>
<Cell
value={row.period_4}
ratio={calculateRatio(row.period_4)}
/>
<Cell
value={row.period_5}
ratio={calculateRatio(row.period_5)}
/>
<Cell
value={row.period_6}
ratio={calculateRatio(row.period_6)}
/>
<Cell
value={row.period_7}
ratio={calculateRatio(row.period_7)}
/>
<Cell
value={row.period_8}
ratio={calculateRatio(row.period_8)}
/>
<Cell
value={row.period_9}
ratio={calculateRatio(row.period_9)}
/>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Widget>
);
};
export default withLoadingWidget(WeeklyCohortsServer);

View File

@@ -1,36 +0,0 @@
import { ActiveIntegrations } from '@/components/integrations/active-integrations';
import { AllIntegrations } from '@/components/integrations/all-integrations';
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
interface PageProps {
params: {
projectId: string;
};
searchParams: {
tab: string;
};
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['installed', 'available'])
.withDefault('available')
.parseServerSide(searchParams.tab);
return (
<Padding className="col gap-8">
<div className="col gap-4">
<h2 className="text-3xl font-semibold">Your integrations</h2>
<ActiveIntegrations />
</div>
<div className="col gap-4">
<h2 className="text-3xl font-semibold">Available integrations</h2>
<AllIntegrations />
</div>
</Padding>
);
}

View File

@@ -1,3 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -1,40 +0,0 @@
import { NotificationRules } from '@/components/notifications/notification-rules';
import { Notifications } from '@/components/notifications/notifications';
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
interface PageProps {
params: {
projectId: string;
};
searchParams: {
tab: string;
};
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['notifications', 'rules'])
.withDefault('notifications')
.parseServerSide(searchParams.tab);
return (
<Padding>
<PageTabs className="mb-4">
<PageTabsLink
href="?tab=notifications"
isActive={tab === 'notifications'}
>
Notifications
</PageTabsLink>
<PageTabsLink href="?tab=rules" isActive={tab === 'rules'}>
Rules
</PageTabsLink>
</PageTabs>
{tab === 'notifications' && <Notifications />}
{tab === 'rules' && <NotificationRules />}
</Padding>
);
}

View File

@@ -1,28 +0,0 @@
import { TableButtons } from '@/components/data-table';
import { InvitesTable } from '@/components/settings/invites';
import { getInvites, getProjectsByOrganizationId } from '@openpanel/db';
import CreateInvite from './create-invite';
interface Props {
organizationId: string;
}
const InvitesServer = async ({ organizationId }: Props) => {
const [invites, projects] = await Promise.all([
getInvites(organizationId),
getProjectsByOrganizationId(organizationId),
]);
return (
<div>
<TableButtons>
<CreateInvite projects={projects} />
</TableButtons>
<InvitesTable data={invites} projects={projects} />
</div>
);
};
export default InvitesServer;

View File

@@ -1,3 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -1,18 +0,0 @@
import { MembersTable } from '@/components/settings/members';
import { getMembers, getProjectsByOrganizationId } from '@openpanel/db';
interface Props {
organizationId: string;
}
const MembersServer = async ({ organizationId }: Props) => {
const [members, projects] = await Promise.all([
getMembers(organizationId),
getProjectsByOrganizationId(organizationId),
]);
return <MembersTable data={members} projects={projects} />;
};
export default MembersServer;

View File

@@ -1,302 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useAppParams } from '@/hooks/useAppParams';
import useWS from '@/hooks/useWS';
import { showConfirm } from '@/modals';
import { api } from '@/trpc/client';
import type { IServiceOrganization } from '@openpanel/db';
import { useOpenPanel } from '@openpanel/nextjs';
import type { IPolarPrice } from '@openpanel/payments';
import { Loader2Icon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useQueryState } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function Billing({ organization }: Props) {
const router = useRouter();
const { projectId } = useAppParams();
const op = useOpenPanel();
const [customerSessionToken, setCustomerSessionToken] = useQueryState(
'customer_session_token',
);
const productsQuery = api.subscription.products.useQuery({
organizationId: organization.id,
});
useWS(`/live/organization/${organization.id}`, (event) => {
router.refresh();
});
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
(organization.subscriptionInterval as 'year' | 'month') || 'month',
);
const products = useMemo(() => {
return (productsQuery.data || [])
.filter((product) => product.recurringInterval === recurringInterval)
.filter((product) => product.prices.some((p) => p.amountType !== 'free'));
}, [productsQuery.data, recurringInterval]);
useEffect(() => {
if (organization.subscriptionInterval) {
setRecurringInterval(
organization.subscriptionInterval as 'year' | 'month',
);
}
}, [organization.subscriptionInterval]);
useEffect(() => {
if (customerSessionToken) {
op.track('subscription_created');
}
}, [customerSessionToken]);
function renderBillingTable() {
if (productsQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productsQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
return (
<WidgetTable
className="w-full max-w-full [&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
columnClassName="!h-auto"
data={products}
keyExtractor={(item) => item.id}
columns={[
{
name: 'Tier',
className: 'text-left',
width: 'auto',
render(item) {
return <div className="font-medium">{item.name}</div>;
},
},
{
name: 'Price',
width: 'auto',
render(item) {
const price = item.prices[0];
if (!price) {
return null;
}
if (price.amountType === 'free') {
return null;
// return (
// <div className="row gap-2 whitespace-nowrap">
// <div className="items-center text-right justify-end gap-4 flex-1 row">
// <span>Free</span>
// <CheckoutButton
// disabled={item.disabled}
// key={price.id}
// price={price}
// organization={organization}
// projectId={projectId}
// />
// </div>
// </div>
// );
}
if (price.amountType !== 'fixed') {
return null;
}
return (
<div className="row gap-2 whitespace-nowrap">
<div className="items-center text-right justify-end gap-4 flex-1 col md:row">
<span>
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: price.priceCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(price.priceAmount / 100)}
{' / '}
{recurringInterval === 'year' ? 'year' : 'month'}
</span>
<CheckoutButton
disabled={item.disabled}
key={price.id}
price={price}
organization={organization}
projectId={projectId}
/>
</div>
</div>
);
},
},
]}
/>
);
}
return (
<>
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Billing</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{recurringInterval === 'year'
? 'Yearly (2 months free)'
: 'Monthly'}
</span>
<Switch
checked={recurringInterval === 'year'}
onCheckedChange={(checked) =>
setRecurringInterval(checked ? 'year' : 'month')
}
/>
</div>
</WidgetHead>
<WidgetBody>
<div className="-m-4">
{renderBillingTable()}
<div className="text-center p-4 border-t">
<p>Do you need higher limits? </p>
<p>
Reach out to{' '}
<a
className="underline font-medium"
href="mailto:hello@openpanel.dev"
>
hello@openpanel.dev
</a>{' '}
and we'll help you out.
</p>
</div>
</div>
</WidgetBody>
</Widget>
<Dialog
open={!!customerSessionToken}
onOpenChange={(open) => {
setCustomerSessionToken(null);
if (!open) {
router.refresh();
}
}}
>
<DialogContent>
<DialogTitle>Subscription created</DialogTitle>
<DialogDescription>
We have registered your subscription. It'll be activated within a
couple of seconds.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button>OK</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function CheckoutButton({
price,
organization,
projectId,
disabled,
}: {
price: IPolarPrice;
organization: IServiceOrganization;
projectId: string;
disabled?: string | null;
}) {
const op = useOpenPanel();
const isCurrentPrice = organization.subscriptionPriceId === price.id;
const checkout = api.subscription.checkout.useMutation({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
});
const isCanceled =
organization.subscriptionStatus === 'active' &&
isCurrentPrice &&
organization.subscriptionCanceledAt;
const isActive =
organization.subscriptionStatus === 'active' && isCurrentPrice;
return (
<Tooltiper
content={disabled}
tooltipClassName="max-w-xs"
side="left"
disabled={!disabled}
>
<Button
disabled={disabled !== null || (isActive && !isCanceled)}
key={price.id}
onClick={() => {
const createCheckout = () =>
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
if (organization.subscriptionStatus === 'active') {
showConfirm({
title: 'Are you sure?',
text: `You're about the change your subscription.`,
onConfirm: () => {
op.track('subscription_change');
createCheckout();
},
});
} else {
op.track('subscription_checkout', {
product: price.productId,
});
createCheckout();
}
}}
loading={checkout.isLoading}
className="w-28"
variant={isActive ? 'outline' : 'default'}
>
{isCanceled ? 'Reactivate' : isActive ? 'Active' : 'Activate'}
</Button>
</Tooltiper>
);
}

View File

@@ -1,288 +0,0 @@
'use client';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useNumber } from '@/hooks/useNumerFormatter';
import { api } from '@/trpc/client';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import { sum } from '@openpanel/common';
import type { IServiceOrganization } from '@openpanel/db';
import { Loader2Icon } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
Tooltip as RechartTooltip,
ReferenceLine,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
type Props = {
organization: IServiceOrganization;
};
function Card({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2 p-4 flex-1 min-w-0" title={`${title}: ${value}`}>
<div className="text-muted-foreground truncate">{title}</div>
<div className="font-mono text-xl font-bold truncate">{value}</div>
</div>
);
}
export default function Usage({ organization }: Props) {
const number = useNumber();
const xAxisProps = useXAxisProps({ interval: 'day' });
const yAxisProps = useYAxisProps({});
const usageQuery = api.subscription.usage.useQuery({
organizationId: organization.id,
});
const wrapper = (node: React.ReactNode) => (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span>
</WidgetHead>
<WidgetBody>{node}</WidgetBody>
</Widget>
);
if (usageQuery.isLoading) {
return wrapper(
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>,
);
}
if (usageQuery.isError) {
return wrapper(
<div className="center-center p-8 font-medium">
Issues loading usage data
</div>,
);
}
const subscriptionPeriodEventsLimit = organization.hasSubscription
? organization.subscriptionPeriodEventsLimit
: 0;
const subscriptionPeriodEventsCount = organization.hasSubscription
? organization.subscriptionPeriodEventsCount
: 0;
const domain = [
0,
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
...usageQuery.data.map((item) => item.count),
),
] as [number, number];
domain[1] += domain[1] * 0.05;
return wrapper(
<>
<div className="border-b divide-x divide-border -m-4 mb-4 grid grid-cols-2 md:grid-cols-4">
{organization.hasSubscription ? (
<>
<Card
title="Period"
value={
organization.subscriptionCurrentPeriodStart &&
organization.subscriptionCurrentPeriodEnd
? `${formatDate(organization.subscriptionCurrentPeriodStart)}-${formatDate(organization.subscriptionCurrentPeriodEnd)}`
: '🤷‍♂️'
}
/>
<Card
title="Limit"
value={number.format(subscriptionPeriodEventsLimit)}
/>
<Card
title="Events count"
value={number.format(subscriptionPeriodEventsCount)}
/>
<Card
title="Left to use"
value={
subscriptionPeriodEventsLimit === 0
? '👀'
: number.formatWithUnit(
1 -
subscriptionPeriodEventsCount /
subscriptionPeriodEventsLimit,
'%',
)
}
/>
</>
) : (
<>
<div className="col-span-2">
<Card title="Subscription" value={'No active subscription'} />
</div>
<div className="col-span-2">
<Card
title="Events from last 30 days"
value={number.format(
sum(usageQuery.data.map((item) => item.count)),
)}
/>
</div>
</>
)}
</div>
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>
<BarChart
data={usageQuery.data.map((item) => ({
date: new Date(item.day).getTime(),
count: item.count,
limit: subscriptionPeriodEventsLimit,
total: subscriptionPeriodEventsCount,
}))}
barSize={8}
>
<defs>
<linearGradient id="usage" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<RechartTooltip
content={<Tooltip />}
cursor={{
stroke: 'hsl(var(--def-400))',
fill: 'hsl(var(--def-200))',
}}
/>
{organization.hasSubscription && (
<>
<ReferenceLine
y={subscriptionPeriodEventsLimit}
stroke={getChartColor(1)}
strokeWidth={2}
strokeDasharray="3 3"
strokeOpacity={0.5}
strokeLinecap="round"
label={{
value: `Limit (${number.format(subscriptionPeriodEventsLimit)})`,
fill: getChartColor(1),
position: 'insideTopRight',
fontSize: 12,
}}
/>
<ReferenceLine
y={subscriptionPeriodEventsCount}
stroke={getChartColor(2)}
strokeWidth={2}
strokeDasharray="3 3"
strokeOpacity={0.5}
strokeLinecap="round"
label={{
value: `Your events count (${number.format(subscriptionPeriodEventsCount)})`,
fill: getChartColor(2),
position:
subscriptionPeriodEventsCount > 1000
? 'insideTop'
: 'insideBottom',
fontSize: 12,
}}
/>
</>
)}
<Bar
dataKey="count"
stroke={getChartColor(0)}
strokeWidth={0.5}
fill={'url(#usage)'}
isAnimationActive={false}
/>
<XAxis {...xAxisProps} dataKey="date" />
<YAxis
{...yAxisProps}
domain={domain}
interval={0}
ticks={[
0,
subscriptionPeriodEventsLimit * 0.25,
subscriptionPeriodEventsLimit * 0.5,
subscriptionPeriodEventsLimit * 0.75,
subscriptionPeriodEventsLimit,
]}
/>
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
/>
</BarChart>
</ResponsiveContainer>
</div>
</>,
);
}
function Tooltip(props: any) {
const number = useNumber();
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">
{formatDate(payload.date)}
</div>
{payload.limit !== 0 && (
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-1" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Your tier limit</div>
<div className="text-lg font-semibold text-chart-1">
{number.format(payload.limit)}
</div>
</div>
</div>
)}
{payload.total !== 0 && (
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-2" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">
Total events count
</div>
<div className="text-lg font-semibold text-chart-2">
{number.format(payload.total)}
</div>
</div>
</div>
)}
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-0" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Events this day</div>
<div className="text-lg font-semibold text-chart-0">
{number.format(payload.count)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { ShieldAlertIcon } from 'lucide-react';
import { notFound } from 'next/navigation';
import { parseAsStringEnum } from 'nuqs/server';
import { auth } from '@openpanel/auth/nextjs';
import { db } from '@openpanel/db';
import InvitesServer from './invites';
import MembersServer from './members';
import Billing from './organization/billing';
import { BillingFaq } from './organization/billing-faq';
import CurrentSubscription from './organization/current-subscription';
import Organization from './organization/organization';
import Usage from './organization/usage';
interface PageProps {
params: {
organizationSlug: string;
};
searchParams: Record<string, string>;
}
export default async function Page({
params: { organizationSlug: organizationId },
searchParams,
}: PageProps) {
const isBillingEnabled = process.env.NEXT_PUBLIC_SELF_HOSTED !== 'true';
const tab = parseAsStringEnum(['org', 'billing', 'members', 'invites'])
.withDefault('org')
.parseServerSide(searchParams.tab);
const session = await auth();
const organization = await db.organization.findUnique({
where: {
id: organizationId,
members: {
some: {
userId: session.userId,
},
},
},
include: {
members: {
select: {
role: true,
userId: true,
},
},
},
});
if (!organization) {
return notFound();
}
const member = organization.members.find(
(member) => member.userId === session.userId,
);
const hasAccess = member?.role === 'org:admin';
if (!hasAccess) {
return (
<FullPageEmptyState icon={ShieldAlertIcon} title="No access">
You do not have access to this page. You need to be an admin of this
organization to access this page.
</FullPageEmptyState>
);
}
return (
<Padding>
<PageTabs className="mb-4">
<PageTabsLink href={'?tab=org'} isActive={tab === 'org'}>
Organization
</PageTabsLink>
{isBillingEnabled && (
<PageTabsLink href={'?tab=billing'} isActive={tab === 'billing'}>
Billing
</PageTabsLink>
)}
<PageTabsLink href={'?tab=members'} isActive={tab === 'members'}>
Members
</PageTabsLink>
<PageTabsLink href={'?tab=invites'} isActive={tab === 'invites'}>
Invites
</PageTabsLink>
</PageTabs>
{tab === 'org' && <Organization organization={organization} />}
{tab === 'billing' && isBillingEnabled && (
<div className="flex flex-col-reverse md:flex-row gap-8 max-w-screen-lg">
<div className="col gap-8 w-full">
<Billing organization={organization} />
<Usage organization={organization} />
<BillingFaq />
</div>
<CurrentSubscription organization={organization} />
</div>
)}
{tab === 'members' && <MembersServer organizationId={organizationId} />}
{tab === 'invites' && <InvitesServer organizationId={organizationId} />}
</Padding>
);
}

View File

@@ -1 +0,0 @@
export { default } from './organization/page';

View File

@@ -1,89 +0,0 @@
'use client';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { getUserById } from '@openpanel/db';
const validator = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
});
type IForm = z.infer<typeof validator>;
interface EditProfileProps {
profile: Awaited<ReturnType<typeof getUserById>>;
}
export default function EditProfile({ profile }: EditProfileProps) {
const router = useRouter();
const { register, handleSubmit, reset, formState } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
firstName: profile.firstName ?? '',
lastName: profile.lastName ?? '',
email: profile.email ?? '',
},
});
const mutation = api.user.update.useMutation({
onSuccess(res) {
toast('Profile updated', {
description: 'Your profile has been updated.',
});
reset({
firstName: res.firstName ?? '',
lastName: res.lastName ?? '',
email: res.email,
});
router.refresh();
},
onError: handleError,
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Your profile</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody className="flex flex-col gap-4">
<InputWithLabel
label="First name"
placeholder="Your first name"
defaultValue={profile.firstName ?? ''}
{...register('firstName')}
/>
<InputWithLabel
label="Last name"
placeholder="Your last name"
defaultValue={profile.lastName ?? ''}
{...register('lastName')}
/>
<InputWithLabel
disabled
label="Email"
placeholder="Your email"
defaultValue={profile.email ?? ''}
{...register('email')}
/>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -1,3 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -1,18 +0,0 @@
'use client';
import SignOutButton from '@/components/sign-out-button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
export function Logout() {
return (
<Widget className="border-destructive">
<WidgetHead>
<span className="title">Sad part</span>
</WidgetHead>
<WidgetBody>
<p className="mb-4">Sometimes you need to go. See you next time</p>
<SignOutButton />
</WidgetBody>
</Widget>
);
}

View File

@@ -1,17 +0,0 @@
import { Padding } from '@/components/ui/padding';
import { auth } from '@openpanel/auth/nextjs';
import { getUserById } from '@openpanel/db';
import EditProfile from './edit-profile';
export default async function Page() {
const { userId } = await auth();
const profile = await getUserById(userId!);
return (
<Padding>
<h1 className="mb-4 text-2xl font-bold">Profile</h1>
<EditProfile profile={profile} />
</Padding>
);
}

View File

@@ -1,3 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -1,42 +0,0 @@
import { Padding } from '@/components/ui/padding';
import {
db,
getClientsByOrganizationId,
getProjectWithClients,
getProjectsByOrganizationId,
} from '@openpanel/db';
import { notFound } from 'next/navigation';
import DeleteProject from './delete-project';
import EditProjectDetails from './edit-project-details';
import EditProjectFilters from './edit-project-filters';
import ProjectClients from './project-clients';
interface PageProps {
params: {
projectId: string;
};
}
export default async function Page({ params: { projectId } }: PageProps) {
const project = await getProjectWithClients(projectId);
if (!project) {
notFound();
}
return (
<Padding>
<div className="col gap-4">
<div className="row justify-between items-center">
<h1 className="text-2xl font-bold">{project.name}</h1>
</div>
<EditProjectDetails project={project} />
<EditProjectFilters project={project} />
<ProjectClients project={project} />
<DeleteProject project={project} />
</div>
</Padding>
);
}

View File

@@ -1,45 +0,0 @@
'use client';
import { ClientsTable } from '@/components/clients/table';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { pushModal } from '@/modals';
import type {
IServiceClientWithProject,
IServiceProjectWithClients,
} from '@openpanel/db';
import { PlusIcon } from 'lucide-react';
import { omit } from 'ramda';
type Props = { project: IServiceProjectWithClients };
export default function ProjectClients({ project }: Props) {
return (
<Widget className="max-w-screen-md w-full overflow-hidden">
<WidgetHead className="flex items-center justify-between">
<span className="title">Clients</span>
<Button
variant="outline"
icon={PlusIcon}
className="-my-1"
onClick={() => pushModal('AddClient')}
>
New client
</Button>
</WidgetHead>
<WidgetBody className="p-0 [&>div]:border-none [&>div]:rounded-none">
<ClientsTable
// @ts-expect-error
query={{
data: project.clients.map((item) => ({
...item,
project: omit(['clients'], item),
})) as unknown as IServiceClientWithProject[],
isFetching: false,
isLoading: false,
}}
/>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,29 +0,0 @@
'use client';
import { DataTable } from '@/components/data-table';
import { columns } from '@/components/references/table';
import { Button } from '@/components/ui/button';
import { Padding } from '@/components/ui/padding';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import type { IServiceReference } from '@openpanel/db';
interface ListProjectsProps {
data: IServiceReference[];
}
export default function ListReferences({ data }: ListProjectsProps) {
return (
<Padding>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-2xl font-bold">References</h1>
<Button icon={PlusIcon} onClick={() => pushModal('AddReference')}>
<span className="max-sm:hidden">Create reference</span>
<span className="sm:hidden">Reference</span>
</Button>
</div>
<DataTable data={data} columns={columns} />
</Padding>
);
}

View File

@@ -1,3 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -1,25 +0,0 @@
import { getReferences } from '@openpanel/db';
import ListReferences from './list-references';
interface PageProps {
params: {
projectId: string;
};
}
export default async function Page({ params: { projectId } }: PageProps) {
const references = await getReferences({
where: {
projectId,
},
take: 50,
skip: 0,
});
return (
<>
<ListReferences data={references} />
</>
);
}

View File

@@ -1,61 +0,0 @@
'use client';
import { differenceInHours } from 'date-fns';
import { useEffect, useState } from 'react';
import { ProjectLink } from '@/components/links';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ModalHeader } from '@/modals/Modal/Container';
import type { IServiceOrganization } from '@openpanel/db';
import { useOpenPanel } from '@openpanel/nextjs';
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
import Billing from './settings/organization/organization/billing';
interface SideEffectsProps {
organization: IServiceOrganization;
}
export default function SideEffectsFreePlan({
organization,
}: SideEffectsProps) {
const op = useOpenPanel();
const willEndInHours = organization.subscriptionEndsAt
? differenceInHours(organization.subscriptionEndsAt, new Date())
: null;
const [isFreePlan, setIsFreePlan] = useState<boolean>(
!!organization.subscriptionProductId &&
FREE_PRODUCT_IDS.includes(organization.subscriptionProductId),
);
useEffect(() => {
if (isFreePlan) {
op.track('free_plan_removed');
}
}, []);
return (
<Dialog open={isFreePlan} onOpenChange={setIsFreePlan}>
<DialogContent className="max-w-xl">
<ModalHeader
onClose={() => setIsFreePlan(false)}
title={'Free plan has been removed'}
text={
<>
Please upgrade your plan to continue using OpenPanel. Select a
tier which is appropriate for your needs or{' '}
<ProjectLink
href="/settings/organization?tab=billing"
className="underline text-foreground"
>
manage billing
</ProjectLink>
</>
}
/>
<div className="-mx-4 mt-4">
<Billing organization={organization} />
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,91 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog';
import { ModalHeader } from '@/modals/Modal/Container';
import { api, handleError } from '@/trpc/client';
import { TIMEZONES } from '@openpanel/common';
import type { IServiceOrganization } from '@openpanel/db';
import { toast } from 'sonner';
interface SideEffectsProps {
organization: IServiceOrganization;
}
export default function SideEffectsTimezone({
organization,
}: SideEffectsProps) {
const [isMissingTimezone, setIsMissingTimezone] = useState<boolean>(
!organization.timezone,
);
const defaultTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const [timezone, setTimezone] = useState<string>(
TIMEZONES.includes(defaultTimezone) ? defaultTimezone : '',
);
const mutation = api.organization.update.useMutation({
onSuccess(res) {
toast('Timezone updated', {
description: 'Your timezone has been updated.',
});
window.location.reload();
},
onError: handleError,
});
return (
<Dialog open={isMissingTimezone} onOpenChange={setIsMissingTimezone}>
<DialogContent
className="max-w-xl"
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<ModalHeader
onClose={false}
title="Select your timezone"
text={
<>
We have introduced new features that requires your timezone.
Please select the timezone you want to use for your organization.
</>
}
/>
<Combobox
items={TIMEZONES.map((item) => ({
value: item,
label: item,
}))}
value={timezone}
onChange={setTimezone}
placeholder="Select a timezone"
searchable
size="lg"
className="w-full px-4"
/>
<DialogFooter className="mt-4">
<Button
size="lg"
disabled={!TIMEZONES.includes(timezone)}
loading={mutation.isLoading}
onClick={() =>
mutation.mutate({
id: organization.id,
name: organization.name,
timezone: timezone ?? '',
})
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,67 +0,0 @@
'use client';
import { differenceInHours } from 'date-fns';
import { useEffect, useState } from 'react';
import { ProjectLink } from '@/components/links';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ModalHeader } from '@/modals/Modal/Container';
import type { IServiceOrganization } from '@openpanel/db';
import { useOpenPanel } from '@openpanel/nextjs';
import Billing from './settings/organization/organization/billing';
interface SideEffectsProps {
organization: IServiceOrganization;
}
export default function SideEffectsTrial({ organization }: SideEffectsProps) {
const op = useOpenPanel();
const willEndInHours = organization.subscriptionEndsAt
? differenceInHours(organization.subscriptionEndsAt, new Date())
: null;
const [isTrialDialogOpen, setIsTrialDialogOpen] = useState<boolean>(
willEndInHours !== null &&
organization.subscriptionStatus === 'trialing' &&
organization.subscriptionEndsAt !== null &&
willEndInHours <= 48,
);
useEffect(() => {
if (isTrialDialogOpen) {
op.track('trial_expires_soon');
}
}, [isTrialDialogOpen]);
return (
<>
<Dialog open={isTrialDialogOpen} onOpenChange={setIsTrialDialogOpen}>
<DialogContent className="max-w-xl">
<ModalHeader
onClose={() => setIsTrialDialogOpen(false)}
title={
willEndInHours !== null && willEndInHours > 0
? `Your trial is ending in ${willEndInHours} hours`
: 'Your trial has ended'
}
text={
<>
Please upgrade your plan to continue using OpenPanel. Select a
tier which is appropriate for your needs or{' '}
<ProjectLink
href="/settings/organization?tab=billing"
className="underline text-foreground"
>
manage billing
</ProjectLink>
</>
}
/>
<div className="-mx-4 mt-4">
<Billing organization={organization} />
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
import { differenceInHours } from 'date-fns';
import { useEffect, useState } from 'react';
import { ProjectLink } from '@/components/links';
import { Combobox } from '@/components/ui/combobox';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ModalHeader } from '@/modals/Modal/Container';
import type { IServiceOrganization } from '@openpanel/db';
import { useOpenPanel } from '@openpanel/nextjs';
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
import Billing from './settings/organization/organization/billing';
import SideEffectsFreePlan from './side-effects-free-plan';
import SideEffectsTimezone from './side-effects-timezone';
import SideEffectsTrial from './side-effects-trial';
interface SideEffectsProps {
organization: IServiceOrganization;
}
export default function SideEffects({ organization }: SideEffectsProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Avoids hydration errors
if (!mounted) {
return null;
}
return (
<>
<SideEffectsTimezone organization={organization} />
<SideEffectsTrial organization={organization} />
<SideEffectsFreePlan organization={organization} />
</>
);
}

View File

@@ -1,65 +0,0 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullWidthNavbar from '@/components/full-width-navbar';
import ProjectCard from '@/components/projects/project-card';
import { redirect } from 'next/navigation';
import SettingsToggle from '@/components/settings-toggle';
import { auth } from '@openpanel/auth/nextjs';
import { getOrganizations, getProjects } from '@openpanel/db';
import LayoutProjectSelector from './[projectId]/layout-project-selector';
interface PageProps {
params: {
organizationSlug: string;
};
}
export default async function Page({
params: { organizationSlug: organizationId },
}: PageProps) {
const { userId } = await auth();
const [organizations, projects] = await Promise.all([
getOrganizations(userId),
getProjects({ organizationId, userId }),
]);
const organization = organizations.find((org) => org.id === organizationId);
if (!organization) {
return (
<FullPageEmptyState title="Not found" className="min-h-screen">
The organization you were looking for could not be found.
</FullPageEmptyState>
);
}
if (projects.length === 0) {
return redirect('/onboarding/project');
}
if (projects.length === 1 && projects[0]) {
return redirect(`/${organizationId}/${projects[0].id}`);
}
return (
<div>
<FullWidthNavbar>
<div className="row gap-4">
<LayoutProjectSelector
align="start"
projects={projects}
organizations={organizations}
/>
<SettingsToggle />
</div>
</FullWidthNavbar>
<div className="mx-auto flex flex-col gap-4 p-4 pt-20 md:w-[95vw] lg:w-[80vw] max-w-screen-2xl">
<div className="grid gap-4 md:grid-cols-2">
{projects.map((item) => (
<ProjectCard key={item.id} {...item} />
))}
</div>
</div>
</div>
);
}

View File

@@ -1,15 +0,0 @@
import { redirect } from 'next/navigation';
import { auth } from '@openpanel/auth/nextjs';
import { getOrganizations } from '@openpanel/db';
export default async function Page() {
const { userId } = await auth();
const organizations = await getOrganizations(userId);
if (organizations.length > 0) {
return redirect(`/${organizations[0]?.id}`);
}
return redirect('/onboarding/project');
}

View File

@@ -1,22 +0,0 @@
import LiveEventsServer from './live-events';
type Props = {
children: React.ReactNode;
};
const Page = ({ children }: Props) => {
return (
<>
<div className="bg-def-100">
<div className="grid h-full md:grid-cols-[min(400px,40vw)_1fr]">
<div className="min-h-screen border-r border-r-background bg-gradient-to-r from-background to-def-200 max-md:hidden">
<LiveEventsServer />
</div>
<div className="min-h-screen p-4">{children}</div>
</div>
</div>
</>
);
};
export default Page;

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