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:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
26
.github/workflows/docker-build.yml
vendored
26
.github/workflows/docker-build.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function chat(
|
||||
|
||||
await db.chat.create({
|
||||
data: {
|
||||
messages: messagesToSave.slice(-10),
|
||||
messages: messagesToSave.slice(-10) as any,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
23
apps/api/tsdown.config.ts
Normal 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);
|
||||
@@ -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);
|
||||
39
apps/dashboard/.gitignore
vendored
39
apps/dashboard/.gitignore
vendored
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
[auth]
|
||||
token=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
|
||||
@@ -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"]
|
||||
@@ -1 +0,0 @@
|
||||
# Dashboard
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 "$@"
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './organization/page';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user