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
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -125,8 +135,7 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/${{ env.repo_owner }}/api:latest
|
ghcr.io/${{ env.repo_owner }}/api:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }}
|
||||||
ghcr.io/${{ env.repo_owner }}/api:${{ github.sha }}
|
|
||||||
build-args: |
|
build-args: |
|
||||||
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
|
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
|
||||||
|
|
||||||
@@ -140,6 +149,16 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -159,7 +178,6 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/${{ env.repo_owner }}/worker:latest
|
ghcr.io/${{ env.repo_owner }}/worker:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }}
|
||||||
ghcr.io/${{ env.repo_owner }}/worker:${{ github.sha }}
|
|
||||||
build-args: |
|
build-args: |
|
||||||
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
|
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.secrets
|
.secrets
|
||||||
|
packages/db/src/generated/prisma
|
||||||
|
|
||||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||||
packages/sdk/profileId.txt
|
packages/sdk/profileId.txt
|
||||||
@@ -168,6 +169,9 @@ dist
|
|||||||
|
|
||||||
.vscode-test
|
.vscode-test
|
||||||
|
|
||||||
|
# Wrangler build artifacts and cache
|
||||||
|
.wrangler/
|
||||||
|
|
||||||
# yarn v2
|
# yarn v2
|
||||||
|
|
||||||
.yarn/cache
|
.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
|
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/validation/package.json packages/validation/
|
||||||
COPY packages/integrations/package.json packages/integrations/
|
COPY packages/integrations/package.json packages/integrations/
|
||||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||||
|
COPY patches ./patches
|
||||||
|
|
||||||
# BUILD
|
# BUILD
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
@@ -66,6 +67,8 @@ RUN pnpm codegen && \
|
|||||||
# PROD
|
# PROD
|
||||||
FROM base AS prod
|
FROM base AS prod
|
||||||
|
|
||||||
|
ENV npm_config_build_from_source=true
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 \
|
python3 \
|
||||||
make \
|
make \
|
||||||
@@ -75,12 +78,14 @@ WORKDIR /app
|
|||||||
COPY --from=build /app/package.json ./
|
COPY --from=build /app/package.json ./
|
||||||
COPY --from=build /app/pnpm-lock.yaml ./
|
COPY --from=build /app/pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile --prod && \
|
RUN pnpm install --frozen-lockfile --prod && \
|
||||||
|
pnpm rebuild && \
|
||||||
pnpm store prune
|
pnpm store prune
|
||||||
|
|
||||||
# FINAL
|
# FINAL
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV npm_config_build_from_source=true
|
||||||
|
|
||||||
WORKDIR /app
|
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/constants ./packages/constants
|
||||||
COPY --from=build /app/packages/validation ./packages/validation
|
COPY --from=build /app/packages/validation ./packages/validation
|
||||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||||
|
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||||
RUN pnpm db:codegen
|
RUN pnpm db:codegen
|
||||||
|
|
||||||
WORKDIR /app/apps/api
|
WORKDIR /app/apps/api
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@openpanel/api",
|
"name": "@openpanel/api",
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"testing": "API_PORT=3333 pnpm dev",
|
||||||
"start": "node dist/index.js",
|
"start": "dotenv -e ../../.env node dist/index.js",
|
||||||
"build": "rm -rf dist && tsup",
|
"build": "rm -rf dist && tsdown",
|
||||||
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
|
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -31,15 +32,13 @@
|
|||||||
"@openpanel/redis": "workspace:*",
|
"@openpanel/redis": "workspace:*",
|
||||||
"@openpanel/trpc": "workspace:*",
|
"@openpanel/trpc": "workspace:*",
|
||||||
"@openpanel/validation": "workspace:*",
|
"@openpanel/validation": "workspace:*",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^11.6.0",
|
||||||
"ai": "^4.2.10",
|
"ai": "^4.2.10",
|
||||||
"bcrypt": "^5.1.1",
|
|
||||||
"fast-json-stable-hash": "^1.0.3",
|
"fast-json-stable-hash": "^1.0.3",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"fastify-metrics": "^12.1.0",
|
"fastify-metrics": "^12.1.0",
|
||||||
"fastify-raw-body": "^5.0.0",
|
"fastify-raw-body": "^5.0.0",
|
||||||
"groupmq": "1.0.0-next.19",
|
"groupmq": "1.0.0-next.19",
|
||||||
"ico-to-png": "^0.2.2",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"request-ip": "^3.3.0",
|
"request-ip": "^3.3.0",
|
||||||
@@ -65,7 +64,7 @@
|
|||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"tsup": "^7.2.0",
|
"tsdown": "^0.14.2",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
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';
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@@ -493,9 +493,11 @@ async function main() {
|
|||||||
const [type, file = 'mock-basic.json'] = process.argv.slice(2);
|
const [type, file = 'mock-basic.json'] = process.argv.slice(2);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'send':
|
case 'send': {
|
||||||
await triggerEvents(require(`./${file}`));
|
const data = await import(`./${file}`, { assert: { type: 'json' } });
|
||||||
|
await triggerEvents(data.default);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'sim':
|
case 'sim':
|
||||||
await simultaneousRequests();
|
await simultaneousRequests();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export async function chat(
|
|||||||
|
|
||||||
await db.chat.create({
|
await db.chat.create({
|
||||||
data: {
|
data: {
|
||||||
messages: messagesToSave.slice(-10),
|
messages: messagesToSave.slice(-10) as any,
|
||||||
projectId,
|
projectId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,53 +1,231 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import icoToPng from 'ico-to-png';
|
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
import { getClientIp } from '@/utils/get-client-ip';
|
import { getClientIp } from '@/utils/get-client-ip';
|
||||||
import { createHash } from '@openpanel/common/server';
|
|
||||||
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
||||||
import { getGeoLocation } from '@openpanel/geo';
|
import { getGeoLocation } from '@openpanel/geo';
|
||||||
import { cacheable, getCache, getRedisCache } from '@openpanel/redis';
|
import { getCache, getRedisCache } from '@openpanel/redis';
|
||||||
|
|
||||||
interface GetFaviconParams {
|
interface GetFaviconParams {
|
||||||
url: string;
|
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 {
|
try {
|
||||||
const res = await fetch(url);
|
if (!raw) throw new Error('Missing ?url');
|
||||||
const contentType = res.headers.get('content-type');
|
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')) {
|
// Binary cache functions (more efficient than base64)
|
||||||
return null;
|
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) {
|
// Size guard
|
||||||
return null;
|
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 response.arrayBuffer();
|
||||||
const arrayBuffer = await res.arrayBuffer();
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
return await icoToPng(buffer, 30);
|
// 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, {
|
.resize(30, 30, {
|
||||||
fit: 'cover',
|
fit: 'cover',
|
||||||
})
|
})
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get image from url', {
|
logger.warn('Sharp failed to process image, trying fallback', {
|
||||||
error,
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
url,
|
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(
|
export async function getFavicon(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
@@ -55,68 +233,110 @@ export async function getFavicon(
|
|||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
) {
|
) {
|
||||||
function sendBuffer(buffer: Buffer, cacheKey?: string) {
|
try {
|
||||||
if (cacheKey) {
|
const url = validateUrl(request.query.url);
|
||||||
getRedisCache().set(`favicon:${cacheKey}`, buffer.toString('base64'));
|
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) {
|
const cacheKey = createCacheKey(url.toString());
|
||||||
return reply.status(404).send('Not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = decodeURIComponent(request.query.url);
|
// Check cache first
|
||||||
|
const cached = await getFromCacheBinary(cacheKey);
|
||||||
if (imageExtensions.find((ext) => url.endsWith(ext))) {
|
if (cached) {
|
||||||
const cacheKey = createHash(url, 32);
|
reply.header('Content-Type', cached.contentType);
|
||||||
const cache = await getRedisCache().get(`favicon:${cacheKey}`);
|
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
||||||
if (cache) {
|
return reply.send(cached.buffer);
|
||||||
return sendBuffer(Buffer.from(cache, 'base64'));
|
|
||||||
}
|
}
|
||||||
const buffer = await getImageBuffer(url);
|
|
||||||
if (buffer && buffer.byteLength > 0) {
|
let imageUrl: URL;
|
||||||
return sendBuffer(buffer, cacheKey);
|
|
||||||
|
// 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);
|
// Fetch the image
|
||||||
const cache = await getRedisCache().get(`favicon:${hostname}`);
|
const { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||||
|
|
||||||
if (cache) {
|
if (status !== 200 || buffer.length === 0) {
|
||||||
return sendBuffer(Buffer.from(cache, 'base64'));
|
return reply.send(createFallbackImage());
|
||||||
}
|
|
||||||
|
|
||||||
const meta = await parseUrlMeta(url);
|
|
||||||
if (meta?.favicon) {
|
|
||||||
const buffer = await getImageBuffer(meta.favicon);
|
|
||||||
if (buffer && buffer.byteLength > 0) {
|
|
||||||
return sendBuffer(buffer, hostname);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await getImageBuffer(
|
// Process the image (resize to 30x30 PNG, or serve ICO as-is)
|
||||||
'https://www.iconsdb.com/icons/download/orange/warning-128.png',
|
const processedBuffer = await processImage(
|
||||||
);
|
buffer,
|
||||||
if (buffer && buffer.byteLength > 0) {
|
imageUrl.toString(),
|
||||||
return sendBuffer(buffer, hostname);
|
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(
|
export async function clearFavicons(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
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) {
|
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(
|
export async function ping(
|
||||||
@@ -181,3 +401,77 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
|||||||
const geo = await getGeoLocation(ip);
|
const geo = await getGeoLocation(ip);
|
||||||
return reply.status(200).send(geo);
|
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,
|
sessionToken,
|
||||||
session.expiresAt,
|
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({
|
async function handleNewUser({
|
||||||
@@ -138,7 +140,9 @@ async function handleNewUser({
|
|||||||
sessionToken,
|
sessionToken,
|
||||||
session.expiresAt,
|
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
|
// Provider-specific user fetching
|
||||||
@@ -348,7 +352,9 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
||||||
const url = new URL(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
|
const url = new URL(
|
||||||
|
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
||||||
|
);
|
||||||
url.pathname = '/login';
|
url.pathname = '/login';
|
||||||
if (error instanceof LogError) {
|
if (error instanceof LogError) {
|
||||||
url.searchParams.set('error', error.message);
|
url.searchParams.set('error', error.message);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
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 { db, getOrganizationByProjectIdCached } from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
@@ -100,7 +105,7 @@ export async function slackWebhook(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return reply.redirect(
|
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) {
|
} catch (err) {
|
||||||
request.log.error(err);
|
request.log.error(err);
|
||||||
@@ -184,7 +189,7 @@ export async function polarWebhook(
|
|||||||
data: {
|
data: {
|
||||||
subscriptionId: event.data.id,
|
subscriptionId: event.data.id,
|
||||||
subscriptionCustomerId: event.data.customer.id,
|
subscriptionCustomerId: event.data.customer.id,
|
||||||
subscriptionPriceId: event.data.priceId,
|
subscriptionPriceId: event.data.prices[0]?.id ?? null,
|
||||||
subscriptionProductId: event.data.productId,
|
subscriptionProductId: event.data.productId,
|
||||||
subscriptionStatus: event.data.status,
|
subscriptionStatus: event.data.status,
|
||||||
subscriptionStartsAt: event.data.currentPeriodStart,
|
subscriptionStartsAt: event.data.currentPeriodStart,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const ignoreMethods = ['OPTIONS'];
|
|||||||
const getTrpcInput = (
|
const getTrpcInput = (
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
): Record<string, unknown> | undefined => {
|
): Record<string, unknown> | undefined => {
|
||||||
const input = path(['query', 'input'], request);
|
const input = path<any>(['query', 'input'], request);
|
||||||
try {
|
try {
|
||||||
return typeof input === 'string' ? JSON.parse(input).json : input;
|
return typeof input === 'string' ? JSON.parse(input).json : input;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -95,15 +95,13 @@ const startServer = async () => {
|
|||||||
if (isPrivatePath) {
|
if (isPrivatePath) {
|
||||||
// Allow multiple dashboard domains
|
// Allow multiple dashboard domains
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
process.env.NEXT_PUBLIC_DASHBOARD_URL,
|
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL,
|
||||||
...(process.env.API_CORS_ORIGINS?.split(',') ?? []),
|
...(process.env.API_CORS_ORIGINS?.split(',') ?? []),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
const isAllowed = origin && allowedOrigins.includes(origin);
|
const isAllowed = origin && allowedOrigins.includes(origin);
|
||||||
|
|
||||||
logger.info('Allowed origins', { allowedOrigins, origin, isAllowed });
|
|
||||||
|
|
||||||
return callback(null, {
|
return callback(null, {
|
||||||
origin: isAllowed ? origin : false,
|
origin: isAllowed ? origin : false,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -160,6 +158,12 @@ const startServer = async () => {
|
|||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createContext,
|
createContext: createContext,
|
||||||
onError(ctx) {
|
onError(ctx) {
|
||||||
|
if (
|
||||||
|
ctx.error.code === 'UNAUTHORIZED' &&
|
||||||
|
ctx.path === 'organization.list'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
ctx.req.log.error('trpc error', {
|
ctx.req.log.error('trpc error', {
|
||||||
error: ctx.error,
|
error: ctx.error,
|
||||||
path: ctx.path,
|
path: ctx.path,
|
||||||
@@ -191,7 +195,10 @@ const startServer = async () => {
|
|||||||
instance.get('/healthz/live', liveness);
|
instance.get('/healthz/live', liveness);
|
||||||
instance.get('/healthz/ready', readiness);
|
instance.get('/healthz/ready', readiness);
|
||||||
instance.get('/', (_request, reply) =>
|
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,
|
handler: controller.getFavicon,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/og',
|
||||||
|
handler: controller.getOgImage,
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/og/clear',
|
||||||
|
handler: controller.clearOgImages,
|
||||||
|
});
|
||||||
|
|
||||||
fastify.route({
|
fastify.route({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/favicon/clear',
|
url: '/favicon/clear',
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ function fallbackFavicon(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
||||||
const match = favicons.find(
|
const match = favicons
|
||||||
(favicon) =>
|
.sort((a, b) => {
|
||||||
favicon.rel === 'shortcut icon' ||
|
return a.rel.length - b.rel.length;
|
||||||
favicon.rel === 'icon' ||
|
})
|
||||||
favicon.rel === 'apple-touch-icon',
|
.find(
|
||||||
);
|
(favicon) =>
|
||||||
|
favicon.rel === 'shortcut icon' ||
|
||||||
|
favicon.rel === 'icon' ||
|
||||||
|
favicon.rel === 'apple-touch-icon',
|
||||||
|
);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
return match.href;
|
return match.href;
|
||||||
@@ -18,11 +22,32 @@ function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
|||||||
return null;
|
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) {
|
function transform(data: UrlMetaData, url: string) {
|
||||||
const favicon = findBestFavicon(data.favicons);
|
const favicon = findBestFavicon(data.favicons);
|
||||||
|
const ogImage = findBestOgImage(data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
favicon: favicon ? new URL(favicon, url).toString() : fallbackFavicon(url),
|
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;
|
href: string;
|
||||||
sizes: 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) {
|
export async function parseUrlMeta(url: string) {
|
||||||
@@ -42,6 +72,7 @@ export async function parseUrlMeta(url: string) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
favicon: fallbackFavicon(url),
|
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