move back 😏

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-19 23:23:14 +01:00
parent e4643ce63e
commit ffed9bfaa5
249 changed files with 0 additions and 0 deletions

42
apps/dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# 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*
yarn-debug.log*
yarn-error.log*
.pnpm-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

116
apps/dashboard/Dockerfile Normal file
View File

@@ -0,0 +1,116 @@
FROM --platform=linux/amd64 node:20-slim AS base
ARG NEXT_PUBLIC_DASHBOARD_URL
ENV NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
ARG CLICKHOUSE_DB
ENV CLICKHOUSE_DB=$CLICKHOUSE_DB
ARG CLICKHOUSE_PASSWORD
ENV CLICKHOUSE_PASSWORD=$CLICKHOUSE_PASSWORD
ARG CLICKHOUSE_URL
ENV CLICKHOUSE_URL=$CLICKHOUSE_URL
ARG CLICKHOUSE_USER
ENV CLICKHOUSE_USER=$CLICKHOUSE_USER
ARG REDIS_URL
ENV REDIS_URL=$REDIS_URL
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ARG CLERK_SECRET_KEY
ENV CLERK_SECRET_KEY=$CLERK_SECRET_KEY
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
ARG NODE_VERSION=20
RUN apt update \
&& apt install -y curl \
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
&& bash n $NODE_VERSION \
&& rm n \
&& npm install -g n
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/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/constants/package.json packages/constants/package.json
COPY packages/validation/package.json packages/validation/package.json
COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json
# BUILD
FROM base AS build
WORKDIR /app/apps/dashboard
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /app
COPY apps apps
COPY packages packages
COPY tooling tooling
RUN pnpm db:codegen
WORKDIR /app/apps/dashboard
RUN pnpm run build
# PROD
FROM base AS prod
WORKDIR /app/apps/dashboard
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
# FINAL
FROM base AS runner
COPY --from=build /app/package.json /app/package.json
COPY --from=prod /app/node_modules /app/node_modules
# Apps
COPY --from=build /app/apps/dashboard /app/apps/dashboard
# Apps node_modules
COPY --from=prod /app/apps/dashboard/node_modules /app/apps/dashboard/node_modules
# Packages
COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/redis /app/packages/redis
COPY --from=build /app/packages/common /app/packages/common
COPY --from=build /app/packages/queue /app/packages/queue
COPY --from=build /app/packages/constants /app/packages/constants
COPY --from=build /app/packages/validation /app/packages/validation
COPY --from=build /app/packages/sdks/sdk /app/packages/sdks/sdk
# Packages node_modules
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
COPY --from=prod /app/packages/redis/node_modules /app/packages/redis/node_modules
COPY --from=prod /app/packages/common/node_modules /app/packages/common/node_modules
COPY --from=prod /app/packages/validation/node_modules /app/packages/validation/node_modules
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
RUN pnpm db:codegen
WORKDIR /app/apps/dashboard
EXPOSE 3000
CMD ["pnpm", "start"]

28
apps/dashboard/README.md Normal file
View File

@@ -0,0 +1,28 @@
# Create T3 App
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
## What's next? How do I make an app with this?
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.

View File

@@ -0,0 +1,6 @@
- new org
- create project
- all trpc mutations seems to break in prod
- top event convertions
- create events_meta (name, color, icon)
- edit event convertion

View File

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

View File

@@ -0,0 +1,35 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
await import('./src/env.mjs');
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
transpilePackages: [
'@openpanel/queue',
'@openpanel/db',
'@openpanel/common',
'@openpanel/constants',
'@openpanel/redis',
'@openpanel/validation',
],
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
experimental: {
// Avoid "Critical dependency: the request of a dependency is an expression"
serverComponentsExternalPackages: ['bullmq', 'ioredis'],
},
/**
* 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;

132
apps/dashboard/package.json Normal file
View File

@@ -0,0 +1,132 @@
{
"name": "@openpanel/dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "rm -rf .next && pnpm with-env next dev",
"testing": "pnpm dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"format": "prettier --check \"**/*.{tsx,mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"@clerk/nextjs": "^4.29.7",
"@clickhouse/client": "^0.2.9",
"@hookform/resolvers": "^3.3.4",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/db": "workspace:^",
"@openpanel/queue": "workspace:^",
"@openpanel/validation": "workspace:^",
"@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-progress": "^1.0.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-slot": "^1.0.2",
"@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",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.11.8",
"@trpc/client": "^10.45.1",
"@trpc/next": "^10.45.1",
"@trpc/react-query": "^10.45.1",
"@trpc/server": "^10.45.1",
"@types/d3": "^7.4.3",
"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",
"hamburger-react": "^2.5.0",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.331.0",
"mathjs": "^12.3.2",
"mitt": "^3.0.1",
"next": "~14.0.4",
"next-auth": "^4.24.5",
"next-themes": "^0.2.1",
"nuqs": "^1.16.1",
"prisma-error-enum": "^0.1.3",
"ramda": "^0.29.1",
"random-animal-name": "^0.1.1",
"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-redux": "^8.1.3",
"react-responsive": "^9.0.2",
"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",
"request-ip": "^3.3.0",
"short-unique-id": "^5.0.3",
"slugify": "^1.6.6",
"sonner": "^1.4.0",
"superjson": "^1.13.3",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^2.14.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@openpanel/eslint-config": "workspace:*",
"@openpanel/prettier-config": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/bcrypt": "^5.0.2",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^18.19.15",
"@types/ramda": "^0.29.10",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-syntax-highlighter": "^15.5.11",
"@types/request-ip": "^0.0.41",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
},
"ct3aMetadata": {
"initVersion": "7.21.0"
},
"eslintConfig": {
"root": true,
"extends": [
"@openpanel/eslint-config/base",
"@openpanel/eslint-config/react",
"@openpanel/eslint-config/nextjs"
]
},
"prettier": "@openpanel/prettier-config"
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,15 @@
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="824" height="824" fill="#007BFF" fill-opacity="0.3"/>
<path d="M0 0L824 0V824H0L0 0Z" fill="url(#paint0_linear_0_131)"/>
<path d="M436 220H508C520.73 220 532.939 225.057 541.941 234.059C550.943 243.061 556 255.27 556 268V604" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M172 604H244" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M436 604H652" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M364 412V412.24" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M436 233.488V621.256C435.999 624.902 435.168 628.499 433.569 631.775C431.97 635.052 429.646 637.92 426.772 640.164C423.899 642.408 420.553 643.968 416.987 644.726C413.421 645.483 409.729 645.418 406.192 644.536L244 604V257.488C244.002 246.784 247.581 236.388 254.169 227.952C260.757 219.516 269.976 213.524 280.36 210.928L376.36 186.928C383.434 185.16 390.818 185.026 397.951 186.538C405.084 188.05 411.779 191.167 417.528 195.652C423.277 200.138 427.928 205.874 431.129 212.426C434.329 218.978 435.995 226.196 436 233.488Z" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="paint0_linear_0_131" x1="353" y1="93.0001" x2="528" y2="747.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563EB"/>
<stop offset="1" stop-color="#1D54CD"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -0,0 +1,5 @@
<svg width="278" height="278" viewBox="0 0 278 278" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="278" height="278" rx="20" fill="#2664EB"/>
<path d="M148.959 203H128.873C128.291 203 128 202.698 128 202.095L128.349 74.7242C128.349 74.2414 128.582 74 129.048 74H163.456C174.402 74 183.048 77.4702 189.394 84.4105C195.798 91.2905 199 100.675 199 112.564C199 121.255 197.341 128.829 194.022 135.286C190.645 141.684 186.279 146.632 180.923 150.133C175.566 153.633 169.744 155.383 163.456 155.383H149.833V202.095C149.833 202.698 149.542 203 148.959 203ZM163.456 95.9979L149.833 96.179V132.933H163.456C167.241 132.933 170.53 131.062 173.325 127.32C176.119 123.518 177.517 118.599 177.517 112.564C177.517 107.736 176.265 103.783 173.761 100.705C171.258 97.567 167.823 95.9979 163.456 95.9979Z" fill="white" fill-opacity="0.9"/>
<path d="M114.47 203C108.074 203 102.177 201.36 96.7791 198.079C91.4395 194.798 87.1267 190.434 83.8408 184.986C80.6136 179.479 79 173.445 79 166.884L79.176 109.853C79.176 103.174 80.7896 97.1696 84.0169 91.8386C87.1854 86.4489 91.4688 82.143 96.8671 78.921C102.265 75.6403 108.133 74 114.47 74C121.042 74 126.939 75.611 132.161 78.8331C137.442 82.0552 141.667 86.3903 144.835 91.8386C148.063 97.2282 149.676 103.233 149.676 109.853L149.852 166.884C149.852 173.445 148.268 179.45 145.099 184.898C141.872 190.405 137.589 194.798 132.249 198.079C126.91 201.36 120.983 203 114.47 203ZM114.47 181.295C118.108 181.295 121.277 179.83 123.976 176.901C126.675 173.913 128.025 170.574 128.025 166.884L127.848 109.853C127.848 105.869 126.587 102.501 124.064 99.7473C121.541 96.9939 118.343 95.6172 114.47 95.6172C110.774 95.6172 107.605 96.9646 104.965 99.6594C102.324 102.354 101.004 105.752 101.004 109.853V166.884C101.004 170.809 102.324 174.206 104.965 177.077C107.605 179.889 110.774 181.295 114.47 181.295Z" fill="white" fill-opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,140 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { LazyChart } from '@/components/report/chart/LazyChart';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
} from '@openpanel/constants';
import type { getReportsByDashboardId } from '@openpanel/db';
import { OverviewReportRange } from '../../overview-sticky-header';
interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
}
export function ListReports({ reports }: ListReportsProps) {
const router = useRouter();
const params = useAppParams<{ dashboardId: string }>();
const { range, startDate, endDate } = useOverviewOptions();
return (
<>
<StickyBelowHeader className="p-4 items-center justify-between flex">
<OverviewReportRange />
<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>
</StickyBelowHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
{reports.map((report) => {
const chartRange = report.range; // timeRanges[report.range];
return (
<div className="card" key={report.id}>
<Link
href={`/${params.organizationId}/${params.projectId}/reports/${report.id}`}
className="flex border-b border-border p-4 leading-none [&_svg]:hover:opacity-100 items-center justify-between"
shallow
>
<div>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 text-sm flex gap-2">
<span
className={
range !== null || (startDate && endDate)
? 'line-through'
: ''
}
>
{chartRange}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null && <span>{range}</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger className="h-8 w-8 hover:border rounded justify-center items-center flex">
<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 === 'bar' && 'overflow-auto max-h-[300px]'
)}
>
<LazyChart
{...report}
range={range ?? report.range}
startDate={startDate}
endDate={endDate}
interval={
getDefaultIntervalByDates(startDate, endDate) ||
(range ? getDefaultIntervalByRange(range) : report.interval)
}
editMode={false}
/>
</div>
</div>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,35 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { notFound } from 'next/navigation';
import { getDashboardById, getReportsByDashboardId } from '@openpanel/db';
import { ListReports } from './list-reports';
interface PageProps {
params: {
organizationId: string;
projectId: string;
dashboardId: string;
};
}
export default async function Page({
params: { organizationId, projectId, dashboardId },
}: PageProps) {
const [dashboard, reports] = await Promise.all([
getDashboardById(dashboardId, projectId),
getReportsByDashboardId(dashboardId),
getExists(organizationId),
]);
if (!dashboard) {
return notFound();
}
return (
<PageLayout title={dashboard.name} organizationSlug={organizationId}>
<ListReports reports={reports} />
</PageLayout>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import { StickyBelowHeader } from '../layout-sticky-below-header';
export function HeaderDashboards() {
return (
<StickyBelowHeader>
<div className="p-4 flex justify-between items-center">
<div />
<Button
icon={PlusIcon}
onClick={() => {
pushModal('AddDashboard');
}}
>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
</div>
</StickyBelowHeader>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { api, handleErrorToastOptions } from '@/app/_trpc/client';
import { Card, CardActions, CardActionsItem } from '@/components/card';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceDashboards } from '@openpanel/db';
interface ListDashboardsProps {
dashboards: IServiceDashboards;
}
export function ListDashboards({ dashboards }: ListDashboardsProps) {
const router = useRouter();
const params = useAppParams();
const { organizationId, projectId } = params;
const deletion = api.dashboard.delete.useMutation({
onError: (error, variables) => {
return handleErrorToastOptions({
action: {
label: 'Force delete',
onClick: () => {
deletion.mutate({
forceDelete: true,
id: variables.id,
});
},
},
})(error);
},
onSuccess() {
router.refresh();
toast('Success', {
description: 'Dashboard deleted.',
});
},
});
if (dashboards.length === 0) {
return (
<FullPageEmptyState title="No dashboards" icon={LayoutPanelTopIcon}>
<p>You have not created any dashboards for this project yet</p>
<Button
onClick={() => pushModal('AddDashboard')}
className="mt-14"
icon={PlusIcon}
>
Create dashboard
</Button>
</FullPageEmptyState>
);
}
return (
<>
<div className="grid sm:grid-cols-2 gap-4 p-4">
{dashboards.map((item) => (
<Card key={item.id} hover>
<div>
<Link
href={`/${organizationId}/${projectId}/dashboards/${item.id}`}
className="block p-4 flex flex-col"
>
<span className="font-medium">{item.name}</span>
</Link>
</div>
<CardActions>
<CardActionsItem className="w-full" asChild>
<button
onClick={() => {
pushModal('EditDashboard', item);
}}
>
<Pencil size={16} />
Edit
</button>
</CardActionsItem>
<CardActionsItem className="text-destructive w-full" asChild>
<button
onClick={() => {
deletion.mutate({
id: item.id,
});
}}
>
<Trash size={16} />
Delete
</button>
</CardActionsItem>
</CardActions>
</Card>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,30 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getDashboardsByProjectId } from '@openpanel/db';
import { HeaderDashboards } from './header-dashboards';
import { ListDashboards } from './list-dashboards';
interface PageProps {
params: {
projectId: string;
organizationId: string;
};
}
export default async function Page({
params: { projectId, organizationId },
}: PageProps) {
const [dashboards] = await Promise.all([
getDashboardsByProjectId(projectId),
await getExists(organizationId, projectId),
]);
return (
<PageLayout title="Dashboards" organizationSlug={organizationId}>
{dashboards.length > 0 && <HeaderDashboards />}
<ListDashboards dashboards={dashboards} />
</PageLayout>
);
}

View File

@@ -0,0 +1,42 @@
import { ChartSwitchShortcut } from '@/components/report/chart';
import type { IChartEvent } from '@openpanel/validation';
interface Props {
projectId: string;
events?: string[];
filters?: any[];
}
export function EventsPerDayChart({ projectId, filters, events }: Props) {
const fallback: IChartEvent[] = [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
];
return (
<div className="card p-4 mb-8">
<ChartSwitchShortcut
projectId={projectId}
range="1m"
chartType="histogram"
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: fallback
}
/>
</div>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { Fragment } from 'react';
import { Widget, WidgetHead } from '@/components/widget';
import { isSameDay } from 'date-fns';
import type { IServiceCreateEventPayload } from '@openpanel/db';
import { EventListItem } from '../event-list-item';
function showDateHeader(a: Date, b?: Date) {
if (!b) return true;
return !isSameDay(a, b);
}
interface EventListProps {
data: IServiceCreateEventPayload[];
}
export function EventConversionsList({ data }: EventListProps) {
return (
<Widget>
<WidgetHead>
<div className="title">Conversions</div>
</WidgetHead>
<div className="flex flex-col gap-2 overflow-y-auto max-h-80 p-4">
{data.map((item, index, list) => (
<Fragment key={item.id}>
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
<div className="flex flex-row justify-between gap-2 [&:not(:first-child)]:mt-12">
<div className="flex gap-2">
<div className="bg-slate-100 border border-slate-300 rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2">
{item.createdAt.toLocaleDateString()}
</div>
</div>
</div>
)}
<EventListItem {...item} />
</Fragment>
))}
</div>
</Widget>
);
}

View File

@@ -0,0 +1,32 @@
import { Widget } from '@/components/widget';
import { db, getEvents } from '@openpanel/db';
import { EventConversionsList } from './event-conversions-list';
interface Props {
projectId: string;
}
export default async function EventConversionsListServer({ projectId }: Props) {
const conversions = await db.eventMeta.findMany({
where: {
project_id: projectId,
conversion: true,
},
});
if (conversions.length === 0) {
return null;
}
const events = await getEvents(
`SELECT * FROM events WHERE project_id = '${projectId}' AND name IN (${conversions.map((c) => `'${c.name}'`).join(', ')}) ORDER BY created_at DESC LIMIT 20;`,
{
profile: true,
meta: true,
}
);
return <EventConversionsList data={events} />;
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { ChartSwitchShortcut } from '@/components/report/chart';
import { Button } from '@/components/ui/button';
import { KeyValue } from '@/components/ui/key-value';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { round } from 'mathjs';
import type { IServiceCreateEventPayload } from '@openpanel/db';
import { EventEdit } from './event-edit';
interface Props {
event: IServiceCreateEventPayload;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
export function EventDetails({ event, open, setOpen }: Props) {
const { name } = event;
const [isEditOpen, setIsEditOpen] = useState(false);
const [, setFilter] = useEventQueryFilters({ shallow: false });
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
const common = [
{
name: 'Duration',
value: event.duration ? round(event.duration / 1000, 1) : undefined,
},
{
name: 'Referrer',
value: event.referrer,
onClick() {
setFilter('referrer', event.referrer ?? '');
},
},
{
name: 'Referrer name',
value: event.referrerName,
onClick() {
setFilter('referrer_name', event.referrerName ?? '');
},
},
{
name: 'Referrer type',
value: event.referrerType,
onClick() {
setFilter('referrer_type', event.referrerType ?? '');
},
},
{
name: 'Brand',
value: event.brand,
onClick() {
setFilter('brand', event.brand ?? '');
},
},
{
name: 'Model',
value: event.model,
onClick() {
setFilter('model', event.model ?? '');
},
},
{
name: 'Browser',
value: event.browser,
onClick() {
setFilter('browser', event.browser ?? '');
},
},
{
name: 'Browser version',
value: event.browserVersion,
onClick() {
setFilter('browser_version', event.browserVersion ?? '');
},
},
{
name: 'OS',
value: event.os,
onClick() {
setFilter('os', event.os ?? '');
},
},
{
name: 'OS version',
value: event.osVersion,
onClick() {
setFilter('os_version', event.osVersion ?? '');
},
},
{
name: 'City',
value: event.city,
onClick() {
setFilter('city', event.city ?? '');
},
},
{
name: 'Region',
value: event.region,
onClick() {
setFilter('region', event.region ?? '');
},
},
{
name: 'Country',
value: event.country,
onClick() {
setFilter('country', event.country ?? '');
},
},
{
name: 'Continent',
value: event.continent,
onClick() {
setFilter('continent', event.continent ?? '');
},
},
{
name: 'Device',
value: event.device,
onClick() {
setFilter('device', event.device ?? '');
},
},
].filter((item) => typeof item.value === 'string' && item.value);
const properties = Object.entries(event.properties)
.map(([name, value]) => ({
name,
value: value as string | number | undefined,
}))
.filter((item) => typeof item.value === 'string' && item.value);
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent>
<div>
<div className="flex flex-col gap-8">
<SheetHeader>
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
</SheetHeader>
{properties.length > 0 && (
<div>
<div className="text-sm font-medium mb-2">Params</div>
<div className="flex gap-2 flex-wrap">
{properties.map((item) => (
<KeyValue
key={item.name}
name={item.name}
value={item.value}
onClick={() => {
setFilter(
`properties.${item.name}`,
item.value ? String(item.value) : '',
'is'
);
}}
/>
))}
</div>
</div>
)}
<div>
<div className="text-sm font-medium mb-2">Common</div>
<div className="flex gap-2 flex-wrap">
{common.map((item) => (
<KeyValue
key={item.name}
name={item.name}
value={item.value}
onClick={item.onClick}
/>
))}
</div>
</div>
<div>
<div className="flex justify-between text-sm font-medium mb-2">
<div>Similar events</div>
<button
className="hover:underline text-muted-foreground"
onClick={() => {
setEvents([event.name]);
setOpen(false);
}}
>
Show all
</button>
</div>
<ChartSwitchShortcut
projectId={event.projectId}
chartType="histogram"
events={[
{
id: 'A',
name: event.name,
displayName: 'Similar events',
segment: 'event',
filters: [],
},
]}
/>
</div>
</div>
</div>
<SheetFooter>
<Button
variant={'secondary'}
className="w-full"
onClick={() => setIsEditOpen(true)}
>
Customize "{name}"
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<EventEdit event={event} open={isEditOpen} setOpen={setIsEditOpen} />
</>
);
}

View File

@@ -0,0 +1,176 @@
import type { Dispatch, SetStateAction } from 'react';
import { useEffect, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { cn } from '@/utils/cn';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceCreateEventPayload } from '@openpanel/db';
import {
EventIconColors,
EventIconMapper,
EventIconRecords,
} from './event-icon';
interface Props {
event: IServiceCreateEventPayload;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
export function EventEdit({ event, open, setOpen }: Props) {
const router = useRouter();
const { name, meta, projectId } = event;
const [selectedIcon, setIcon] = useState(
meta?.icon ??
EventIconRecords[name]?.icon ??
EventIconRecords.default?.icon ??
''
);
const [selectedColor, setColor] = useState(
meta?.color ??
EventIconRecords[name]?.color ??
EventIconRecords.default?.color ??
''
);
const [conversion, setConversion] = useState(!!meta?.conversion);
useEffect(() => {
if (meta?.icon) {
setIcon(meta.icon);
}
}, [meta?.icon]);
useEffect(() => {
if (meta?.color) {
setColor(meta.color);
}
}, [meta?.color]);
useEffect(() => {
setConversion(meta?.conversion ?? false);
}, [meta?.conversion]);
const SelectedIcon = EventIconMapper[selectedIcon]!;
const mutation = api.event.updateEventMeta.useMutation({
onSuccess() {
setOpen(false);
toast('Event updated');
router.refresh();
},
});
const getBg = (color: string) => `bg-${color}-200`;
const getText = (color: string) => `text-${color}-700`;
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit "{name}"</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-8 my-8">
<div>
<Label className="mb-4 block">Conversion</Label>
<label className="cursor-pointer flex items-center select-none border border-border rounded-md p-4 gap-4">
<Checkbox
checked={conversion}
onCheckedChange={(checked) => {
if (checked === 'indeterminate') return;
setConversion(checked);
}}
/>
<div>
<span>Yes, this event is important!</span>
</div>
</label>
</div>
<div>
<Label className="mb-4 block">Pick a icon</Label>
<div className="flex flex-wrap gap-4">
{Object.entries(EventIconMapper).map(([name, Icon]) => (
<button
key={name}
onClick={() => {
setIcon(name);
}}
className={cn(
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer inline-flex transition-all bg-slate-100 flex items-center justify-center',
name === selectedIcon
? 'scale-110 ring-1 ring-black'
: '[&_svg]:opacity-50'
)}
>
<Icon size={16} />
</button>
))}
</div>
</div>
<div>
<Label className="mb-4 block">Pick a color</Label>
<div className="flex flex-wrap gap-4">
{EventIconColors.map((color) => (
<button
key={color}
onClick={() => {
setColor(color);
}}
className={cn(
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer transition-all flex justify-center items-center',
color === selectedColor ? 'ring-1 ring-black' : '',
getBg(color)
)}
>
{SelectedIcon ? (
<SelectedIcon size={16} />
) : (
<svg
className={`${getText(color)} opacity-70`}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12.1" cy="12.1" r="4" />
</svg>
)}
</button>
))}
</div>
</div>
</div>
<SheetFooter>
<Button
className="w-full"
onClick={() =>
mutation.mutate({
projectId,
name,
icon: selectedIcon,
color: selectedColor,
conversion,
})
}
>
Update event
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { Tooltip, TooltipContent } from '@/components/ui/tooltip';
import { cn } from '@/utils/cn';
import { TooltipTrigger } from '@radix-ui/react-tooltip';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import type { LucideIcon } from 'lucide-react';
import * as Icons from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { EventMeta } from '@openpanel/db';
const variants = cva('flex items-center justify-center shrink-0 rounded-full', {
variants: {
size: {
sm: 'w-6 h-6',
default: 'w-10 h-10',
},
},
defaultVariants: {
size: 'default',
},
});
type EventIconProps = VariantProps<typeof variants> & {
name: string;
meta?: EventMeta;
projectId: string;
className?: string;
};
export const EventIconRecords: Record<
string,
{
icon: string;
color: string;
}
> = {
default: {
icon: 'BotIcon',
color: 'slate',
},
screen_view: {
icon: 'MonitorPlayIcon',
color: 'blue',
},
session_start: {
icon: 'ActivityIcon',
color: 'teal',
},
link_out: {
icon: 'ExternalLinkIcon',
color: 'indigo',
},
};
export const EventIconMapper: Record<string, LucideIcon> = {
DownloadIcon: Icons.DownloadIcon,
BotIcon: Icons.BotIcon,
BoxIcon: Icons.BoxIcon,
AccessibilityIcon: Icons.AccessibilityIcon,
ActivityIcon: Icons.ActivityIcon,
AirplayIcon: Icons.AirplayIcon,
AlarmCheckIcon: Icons.AlarmCheckIcon,
AlertTriangleIcon: Icons.AlertTriangleIcon,
BellIcon: Icons.BellIcon,
BoltIcon: Icons.BoltIcon,
CandyIcon: Icons.CandyIcon,
ConeIcon: Icons.ConeIcon,
MonitorPlayIcon: Icons.MonitorPlayIcon,
PizzaIcon: Icons.PizzaIcon,
SearchIcon: Icons.SearchIcon,
HomeIcon: Icons.HomeIcon,
MailIcon: Icons.MailIcon,
AngryIcon: Icons.AngryIcon,
AnnoyedIcon: Icons.AnnoyedIcon,
ArchiveIcon: Icons.ArchiveIcon,
AwardIcon: Icons.AwardIcon,
BadgeCheckIcon: Icons.BadgeCheckIcon,
BeerIcon: Icons.BeerIcon,
BluetoothIcon: Icons.BluetoothIcon,
BookIcon: Icons.BookIcon,
BookmarkIcon: Icons.BookmarkIcon,
BookCheckIcon: Icons.BookCheckIcon,
BookMinusIcon: Icons.BookMinusIcon,
BookPlusIcon: Icons.BookPlusIcon,
CalendarIcon: Icons.CalendarIcon,
ClockIcon: Icons.ClockIcon,
CogIcon: Icons.CogIcon,
LoaderIcon: Icons.LoaderIcon,
CrownIcon: Icons.CrownIcon,
FileIcon: Icons.FileIcon,
KeyRoundIcon: Icons.KeyRoundIcon,
GemIcon: Icons.GemIcon,
GlobeIcon: Icons.GlobeIcon,
LightbulbIcon: Icons.LightbulbIcon,
LightbulbOffIcon: Icons.LightbulbOffIcon,
LockIcon: Icons.LockIcon,
MessageCircleIcon: Icons.MessageCircleIcon,
RadioIcon: Icons.RadioIcon,
RepeatIcon: Icons.RepeatIcon,
ShareIcon: Icons.ShareIcon,
ExternalLinkIcon: Icons.ExternalLinkIcon,
};
export const EventIconColors = [
'rose',
'pink',
'fuchsia',
'purple',
'violet',
'indigo',
'blue',
'sky',
'cyan',
'teal',
'emerald',
'green',
'lime',
'yellow',
'amber',
'orange',
'red',
'stone',
'neutral',
'zinc',
'grey',
'slate',
];
export function EventIcon({ className, name, size, meta }: EventIconProps) {
const Icon =
EventIconMapper[
meta?.icon ??
EventIconRecords[name]?.icon ??
EventIconRecords.default?.icon ??
''
]!;
const color =
meta?.color ??
EventIconRecords[name]?.color ??
EventIconRecords.default?.color ??
'';
return (
<div className={cn(`bg-${color}-200`, variants({ size }), className)}>
<Icon size={size === 'sm' ? 14 : 20} className={`text-${color}-700`} />
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import Link from 'next/link';
import type { IServiceCreateEventPayload } from '@openpanel/db';
import { EventDetails } from './event-details';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceCreateEventPayload;
export function EventListItem(props: EventListItemProps) {
const { organizationId, projectId } = useAppParams();
const { createdAt, name, path, duration, meta, profile } = props;
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const number = useNumber();
const renderName = () => {
if (name === 'screen_view') {
if (path.includes('/')) {
return path;
}
return `Route: ${path}`;
}
return name.replace(/_/g, ' ');
};
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
return (
<>
<EventDetails
event={props}
open={isDetailsOpen}
setOpen={setIsDetailsOpen}
/>
<button
onClick={() => setIsDetailsOpen(true)}
className={cn(
'w-full card p-4 flex hover:bg-slate-50 rounded-lg transition-colors justify-between items-center',
meta?.conversion && `bg-${meta.color}-50 hover:bg-${meta.color}-100`
)}
>
<div className="flex gap-4 items-center text-left text-sm">
<EventIcon size="sm" name={name} meta={meta} projectId={projectId} />
<span>
<span className="font-medium">{renderName()}</span>
{' '}
{renderDuration()}
</span>
</div>
<div className="flex gap-4">
<Tooltiper
asChild
content={`${profile?.firstName} ${profile?.lastName}`}
>
<Link
onClick={(e) => {
e.stopPropagation();
}}
href={`/${organizationId}/${projectId}/profiles/${profile?.id}`}
className="text-muted-foreground text-sm hover:underline whitespace-nowrap max-w-[80px] overflow-hidden text-ellipsis"
>
{profile?.firstName} {profile?.lastName}
</Link>
</Tooltiper>
<Tooltiper asChild content={createdAt.toLocaleString()}>
<div className="text-muted-foreground text-sm">
{createdAt.toLocaleTimeString()}
</div>
</Tooltiper>
</div>
</button>
</>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { Fragment, Suspense } from 'react';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { useCursor } from '@/hooks/useCursor';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { isSameDay } from 'date-fns';
import {
ChevronLeftIcon,
ChevronRightIcon,
GanttChartIcon,
} from 'lucide-react';
import type { IServiceCreateEventPayload } from '@openpanel/db';
import { EventListItem } from './event-list-item';
import EventListener from './event-listener';
function showDateHeader(a: Date, b?: Date) {
if (!b) return true;
return !isSameDay(a, b);
}
interface EventListProps {
data: IServiceCreateEventPayload[];
count: number;
}
export function EventList({ data, count }: EventListProps) {
const { cursor, setCursor } = useCursor();
const [filters] = useEventQueryFilters();
return (
<>
{data.length === 0 ? (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
{cursor !== 0 ? (
<>
<p>Looks like you have reached the end of the list</p>
<Button
className="mt-4"
variant="outline"
size="sm"
onClick={() => setCursor((p) => Math.max(0, p - 1))}
>
Go back
</Button>
</>
) : (
<>
{filters.length ? (
<p>Could not find any events with your filter</p>
) : (
<p>We have not recieved any events yet</p>
)}
</>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-2">
{data.map((item, index, list) => (
<Fragment key={item.id}>
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
<div className="flex flex-row justify-between gap-2 [&:not(:first-child)]:mt-12">
{index === 0 ? <EventListener /> : <div />}
<div className="flex gap-2">
<div className="bg-slate-100 border border-slate-300 rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2">
{item.createdAt.toLocaleDateString()}
</div>
{index === 0 && (
<Pagination
size="sm"
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
)}
</div>
</div>
)}
<EventListItem {...item} />
</Fragment>
))}
</div>
<Pagination
className="mt-2"
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</>
)}
</>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import useWebSocket from 'react-use-websocket';
import { toast } from 'sonner';
import type { IServiceCreateEventPayload } from '@openpanel/db';
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
ssr: false,
loading: () => <div>0</div>,
});
export default function EventListener() {
const router = useRouter();
const { projectId } = useAppParams();
const ws = String(process.env.NEXT_PUBLIC_API_URL)
.replace(/^https/, 'wss')
.replace(/^http/, 'ws');
const [counter, setCounter] = useState(0);
const [socketUrl] = useState(`${ws}/live/events/${projectId}`);
useWebSocket(socketUrl, {
shouldReconnect: () => true,
onMessage(payload) {
const event = JSON.parse(payload.data) as IServiceCreateEventPayload;
if (event?.name) {
setCounter((prev) => prev + 1);
toast(`New event ${event.name} from ${event.country}!`);
}
},
});
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
setCounter(0);
router.refresh();
}}
className="bg-white border border-border rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2"
>
<div className="relative">
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all'
)}
></div>
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all'
)}
></div>
</div>
{counter === 0 ? (
'Listening'
) : (
<>
<AnimatedNumbers
includeComma
transitions={(index) => ({
type: 'spring',
duration: index + 0.3,
damping: 10,
stiffness: 200,
})}
animateToNumber={counter}
locale="en"
/>
new events
</>
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter === 0 ? 'Listening to new events' : 'Click to refresh'}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,89 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import {
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { parseAsInteger } from 'nuqs';
import { getEventList, getEventsCount } from '@openpanel/db';
import { StickyBelowHeader } from '../layout-sticky-below-header';
import { EventsPerDayChart } from './charts/events-per-day-chart';
import EventConversionsListServer from './event-conversions-list';
import { EventList } from './event-list';
interface PageProps {
params: {
projectId: string;
organizationId: string;
};
searchParams: {
events?: string;
cursor?: string;
f?: string;
};
}
const nuqsOptions = {
shallow: false,
};
export default async function Page({
params: { projectId, organizationId },
searchParams,
}: PageProps) {
const filters =
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? undefined;
const eventsFilter = eventQueryNamesFilter.parseServerSide(
searchParams.events ?? ''
);
const [events, count] = await Promise.all([
getEventList({
cursor:
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
projectId,
take: 50,
events: eventsFilter,
filters,
}),
getEventsCount({
projectId,
events: eventsFilter,
filters,
}),
getExists(organizationId, projectId),
]);
return (
<PageLayout title="Events" organizationSlug={organizationId}>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
nuqsOptions={nuqsOptions}
enableEventsFilter
/>
<OverviewFiltersButtons
className="p-0 justify-end"
nuqsOptions={nuqsOptions}
/>
</StickyBelowHeader>
<div className="grid md:grid-cols-2 p-4 gap-4">
<div>
<EventList data={events} count={count} />
</div>
<div>
<EventsPerDayChart
projectId={projectId}
events={eventsFilter}
filters={filters}
/>
<EventConversionsListServer projectId={projectId} />
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,161 @@
'use client';
import { useEffect } from 'react';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { useUser } from '@clerk/nextjs';
import {
BookmarkIcon,
BuildingIcon,
CogIcon,
GanttChartIcon,
KeySquareIcon,
LayoutPanelTopIcon,
UserIcon,
UsersIcon,
WallpaperIcon,
WarehouseIcon,
} from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { IServiceDashboards } from '@openpanel/db';
function LinkWithIcon({
href,
icon: Icon,
label,
active: overrideActive,
className,
}: {
href: string;
icon: React.ElementType<LucideProps>;
label: React.ReactNode;
active?: boolean;
className?: string;
}) {
const pathname = usePathname();
const active = overrideActive || href === pathname;
return (
<Link
className={cn(
'text-slate-800 text-sm font-medium flex gap-2 items-center px-3 py-2 transition-colors hover:bg-blue-100 leading-none rounded-md transition-all',
active && 'bg-blue-50',
className
)}
href={href}
>
<Icon size={20} />
<div className="flex-1">{label}</div>
</Link>
);
}
interface LayoutMenuProps {
dashboards: IServiceDashboards;
}
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
const { user } = useUser();
const pathname = usePathname();
const params = useAppParams();
const hasProjectId =
params.projectId &&
params.projectId !== 'null' &&
params.projectId !== 'undefined';
const projectId = hasProjectId
? params.projectId
: (user?.unsafeMetadata.projectId as string);
useEffect(() => {
if (hasProjectId) {
user?.update({
unsafeMetadata: {
projectId: params.projectId,
},
});
}
}, [params.projectId, hasProjectId]);
return (
<>
<LinkWithIcon
icon={WallpaperIcon}
label="Overview"
href={`/${params.organizationId}/${projectId}`}
/>
<LinkWithIcon
icon={LayoutPanelTopIcon}
label="Dashboards"
href={`/${params.organizationId}/${projectId}/dashboards`}
/>
<LinkWithIcon
icon={GanttChartIcon}
label="Events"
href={`/${params.organizationId}/${projectId}/events`}
/>
<LinkWithIcon
icon={UsersIcon}
label="Profiles"
href={`/${params.organizationId}/${projectId}/profiles`}
/>
<LinkWithIcon
icon={CogIcon}
label="Settings"
href={`/${params.organizationId}/${projectId}/settings/organization`}
/>
{pathname?.includes('/settings/') && (
<div className="pl-7 flex flex-col gap-1">
<LinkWithIcon
icon={BuildingIcon}
label="Organization"
href={`/${params.organizationId}/${projectId}/settings/organization`}
/>
<LinkWithIcon
icon={WarehouseIcon}
label="Projects"
href={`/${params.organizationId}/${projectId}/settings/projects`}
/>
<LinkWithIcon
icon={KeySquareIcon}
label="Clients"
href={`/${params.organizationId}/${projectId}/settings/clients`}
/>
<LinkWithIcon
icon={UserIcon}
label="Profile (yours)"
href={`/${params.organizationId}/${projectId}/settings/profile`}
/>
<LinkWithIcon
icon={BookmarkIcon}
label="References"
href={`/${params.organizationId}/${projectId}/settings/references`}
/>
</div>
)}
{dashboards.length > 0 && (
<div className="mt-8">
<div className="font-medium mb-2 text-sm">Your dashboards</div>
<div className="flex flex-col gap-2">
{dashboards.map((item) => (
<LinkWithIcon
key={item.id}
icon={LayoutPanelTopIcon}
label={
<div className="flex justify-between gap-0.5 items-center">
<span>{item.name}</span>
<span className="text-xs text-muted-foreground">
{item.project.name}
</span>
</div>
}
href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`}
/>
))}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,47 @@
'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.slug === params.organizationId
);
if (!organization) {
return null;
}
return (
<Combobox
className="w-full"
placeholder="Select organization"
icon={Building}
value={organization.slug}
items={
organizations
.filter((item) => item.slug)
.map((item) => ({
label: item.name,
value: item.slug!,
})) ?? []
}
onChange={(value) => {
router.push(`/${value}`);
}}
/>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { usePathname, useRouter } from 'next/navigation';
import type { getProjectsByOrganizationSlug } from '@openpanel/db';
interface LayoutProjectSelectorProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
}
export default function LayoutProjectSelector({
projects,
}: LayoutProjectSelectorProps) {
const router = useRouter();
const { organizationId, projectId } = useAppParams();
const pathname = usePathname() || '';
return (
<div>
<Combobox
portal
align="end"
className="w-auto min-w-0 max-sm:max-w-[100px]"
placeholder={'Select project'}
onChange={(value) => {
if (organizationId && projectId) {
const split = pathname.replace(projectId, value).split('/');
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx
router.push(split.slice(0, 4).join('/'));
} else {
router.push(`/${organizationId}/${value}`);
}
}}
value={projectId}
items={
projects.map((item) => ({
label: item.name,
value: item.id,
})) ?? []
}
/>
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import { useEffect, useState } from 'react';
import { Logo } from '@/components/logo';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { Rotate as Hamburger } from 'hamburger-react';
import { PlusIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
import LayoutMenu from './layout-menu';
import LayoutOrganizationSelector from './layout-organization-selector';
interface LayoutSidebarProps {
organizations: IServiceOrganization[];
dashboards: IServiceDashboards;
organizationId: string;
projectId: string;
}
export function LayoutSidebar({
organizations,
dashboards,
organizationId,
projectId,
}: LayoutSidebarProps) {
const [active, setActive] = useState(false);
const pathname = usePathname();
useEffect(() => {
setActive(false);
}, [pathname]);
return (
<>
<button
onClick={() => setActive(false)}
className={cn(
'fixed top-0 left-0 right-0 bottom-0 backdrop-blur-sm z-30 transition-opacity',
active
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
)}
/>
<div
className={cn(
'fixed top-0 left-0 h-screen border-r border-border w-72 bg-white flex flex-col z-30 transition-transform',
'-translate-x-72 lg:-translate-x-0', // responsive
active && 'translate-x-0' // force active on mobile
)}
>
<div className="absolute -right-12 h-16 flex items-center lg:hidden">
<Hamburger toggled={active} onToggle={setActive} size={20} />
</div>
<div className="h-16 border-b border-border px-4 shrink-0 flex items-center">
<Link href="/">
<Logo />
</Link>
</div>
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
<LayoutMenu dashboards={dashboards} />
{/* Placeholder for LayoutOrganizationSelector */}
<div className="h-32 block shrink-0"></div>
</div>
<div className="fixed bottom-0 left-0 right-0">
<div className="bg-gradient-to-t from-white to-white/0 h-8 w-full"></div>
<div className="bg-white p-4 pt-0 flex flex-col gap-2">
<Link
className={cn('flex gap-2', buttonVariants())}
href={`/${organizationId}/${projectId}/reports`}
>
<PlusIcon size={16} />
Create a report
</Link>
<LayoutOrganizationSelector organizations={organizations} />
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,22 @@
import { cn } from '@/utils/cn';
interface StickyBelowHeaderProps {
children: React.ReactNode;
className?: string;
}
export function StickyBelowHeader({
children,
className,
}: StickyBelowHeaderProps) {
return (
<div
className={cn(
'md:sticky bg-white border-b border-border z-20 [[id=dashboard]_&]:top-16 [[id=dashboard]_&]:rounded-none rounded-lg top-0',
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { notFound } from 'next/navigation';
import {
getCurrentOrganizations,
getDashboardsByProjectId,
getProjectsByOrganizationSlug,
} from '@openpanel/db';
import { LayoutSidebar } from './layout-sidebar';
interface AppLayoutProps {
children: React.ReactNode;
params: {
organizationId: string;
projectId: string;
};
}
export default async function AppLayout({
children,
params: { organizationId, projectId },
}: AppLayoutProps) {
const [organizations, projects, dashboards] = await Promise.all([
getCurrentOrganizations(),
getProjectsByOrganizationSlug(organizationId),
getDashboardsByProjectId(projectId),
]);
if (!organizations.find((item) => item.slug === organizationId)) {
return notFound();
}
if (!projects.find((item) => item.id === projectId)) {
return notFound();
}
return (
<div id="dashboard">
<LayoutSidebar
{...{ organizationId, projectId, organizations, dashboards }}
/>
<div className="lg:pl-72 transition-all">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportRange } from '@/components/report/ReportRange';
import { endOfDay, startOfDay } from 'date-fns';
export function OverviewReportRange() {
const { range, setRange, setEndDate, setStartDate, startDate, endDate } =
useOverviewOptions();
return (
<ReportRange
range={range}
onRangeChange={(value) => {
setRange(value);
setStartDate(null);
setEndDate(null);
}}
dates={{
startDate,
endDate,
}}
onDatesChange={(val) => {
if (!val) return;
if (val.from && val.to) {
setRange(null);
setStartDate(startOfDay(val.from).toISOString());
setEndDate(endOfDay(val.to).toISOString());
} else if (val.from) {
setStartDate(startOfDay(val.from).toISOString());
} else if (val.to) {
setEndDate(endOfDay(val.to).toISOString());
}
}}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { getProjectsByOrganizationSlug } from '@openpanel/db';
import LayoutProjectSelector from './layout-project-selector';
interface PageLayoutProps {
children: React.ReactNode;
title: React.ReactNode;
organizationSlug: string;
}
export default async function PageLayout({
children,
title,
organizationSlug,
}: PageLayoutProps) {
const projects = await getProjectsByOrganizationSlug(organizationSlug);
return (
<>
<div className="h-16 border-b border-border flex-shrink-0 sticky top-0 bg-white px-4 flex items-center justify-between z-20 pl-12 lg:pl-4">
<div className="text-xl font-medium">{title}</div>
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
</div>
<div>{children}</div>
</>
);
}

View File

@@ -0,0 +1,67 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import { OverviewShare } from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { getExists } from '@/server/pageExists';
import { db } from '@openpanel/db';
import OverviewMetrics from '../../../../components/overview/overview-metrics';
import { StickyBelowHeader } from './layout-sticky-below-header';
import { OverviewReportRange } from './overview-sticky-header';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
const [share] = await Promise.all([
db.shareOverview.findUnique({
where: {
project_id: projectId,
},
}),
getExists(organizationId, projectId),
]);
return (
<PageLayout title="Overview" organizationSlug={organizationId}>
<StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
<OverviewShare data={share} />
</div>
</div>
<OverviewFiltersButtons />
</StickyBelowHeader>
<div className="p-4 grid gap-4 grid-cols-6">
<div className="col-span-6">
<OverviewLiveHistogram projectId={projectId} />
</div>
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,218 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { ChartSwitch } from '@/components/report/chart';
import { SerieIcon } from '@/components/report/chart/SerieIcon';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import {
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { notFound } from 'next/navigation';
import { parseAsInteger, parseAsString } from 'nuqs';
import type { GetEventListOptions } from '@openpanel/db';
import {
getConversionEventNames,
getEventList,
getEventsCount,
getProfileById,
getProfileName,
} from '@openpanel/db';
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { EventList } from '../../events/event-list';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
interface PageProps {
params: {
projectId: string;
profileId: string;
organizationId: string;
};
searchParams: {
events?: string;
cursor?: string;
f?: string;
startDate: string;
endDate: string;
};
}
export default async function Page({
params: { projectId, profileId, organizationId },
searchParams,
}: PageProps) {
const eventListOptions: GetEventListOptions = {
projectId,
profileId,
take: 50,
cursor:
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
events: eventQueryNamesFilter.parseServerSide(searchParams.events ?? ''),
filters:
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ??
undefined,
};
const startDate = parseAsString.parseServerSide(searchParams.startDate);
const endDate = parseAsString.parseServerSide(searchParams.endDate);
const [profile, events, count, conversions] = await Promise.all([
getProfileById(profileId),
getEventList(eventListOptions),
getEventsCount(eventListOptions),
getConversionEventNames(projectId),
getExists(organizationId, projectId),
]);
const chartSelectedEvents: IChartEvent[] = [
{
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
],
id: 'A',
name: '*',
displayName: 'Events',
},
];
if (conversions.length) {
chartSelectedEvents.push({
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
{
id: 'name',
name: 'name',
operator: 'is',
value: conversions.map((c) => c.name),
},
],
id: 'B',
name: '*',
displayName: 'Conversions',
});
}
const profileChart: IChartInput = {
projectId,
startDate,
endDate,
chartType: 'histogram',
events: chartSelectedEvents,
breakdowns: [],
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '1m',
previous: false,
metric: 'sum',
};
if (!profile) {
return notFound();
}
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
{getProfileName(profile)}
</div>
}
>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
projectId={projectId}
mode="events"
nuqsOptions={{ shallow: false }}
/>
<OverviewFiltersButtons
nuqsOptions={{ shallow: false }}
className="p-0 justify-end"
/>
</StickyBelowHeader>
<div className="p-4">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 mb-8">
<div>
<EventList data={events} count={count} />
</div>
<div className="flex flex-col gap-4">
<Widget className="w-full">
<WidgetHead>
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody className="flex gap-2">
<ChartSwitch {...profileChart} />
</WidgetBody>
</Widget>
<Widget className="w-full">
<WidgetHead className="flex justify-between items-center">
<span className="title">Profile</span>
<ProfileAvatar {...profile} />
</WidgetHead>
<div className="grid grid-cols-1 text-sm">
<ValueRow name={'ID'} value={profile.id} />
<ValueRow name={'First name'} value={profile.firstName} />
<ValueRow name={'Last name'} value={profile.lastName} />
<ValueRow name={'Mail'} value={profile.email} />
<ValueRow
name={'Last seen'}
value={profile.createdAt.toLocaleString()}
/>
</div>
</Widget>
<Widget className="w-full">
<WidgetHead>
<span className="title">Properties</span>
</WidgetHead>
<div className="grid grid-cols-1 text-sm">
{Object.entries(profile.properties)
.filter(([, value]) => !!value)
.map(([key, value]) => (
<ValueRow key={key} name={key} value={value} />
))}
</div>
</Widget>
</div>
</div>
</div>
</PageLayout>
);
}
function ValueRow({ name, value }: { name: string; value?: unknown }) {
if (!value) {
return null;
}
return (
<div className="flex flex-row justify-between p-2 px-4">
<div className="font-medium text-muted-foreground capitalize">
{name.replace('_', ' ')}
</div>
<div className="flex gap-2 items-center text-right">
{typeof value === 'string' ? (
<>
<SerieIcon name={value} /> {value}
</>
) : (
<>{value}</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { parseAsInteger } from 'nuqs';
import { StickyBelowHeader } from '../layout-sticky-below-header';
import ProfileLastSeenServer from './profile-last-seen';
import ProfileListServer from './profile-list';
import ProfileTopServer from './profile-top';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
searchParams: {
f?: string;
cursor?: string;
};
}
const nuqsOptions = {
shallow: false,
};
export default async function Page({
params: { organizationId, projectId },
searchParams: { cursor, f },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout title="Profiles" organizationSlug={organizationId}>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
projectId={projectId}
nuqsOptions={nuqsOptions}
mode="events"
/>
<OverviewFiltersButtons
className="p-0 justify-end"
nuqsOptions={nuqsOptions}
/>
</StickyBelowHeader>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<ProfileListServer
projectId={projectId}
cursor={parseAsInteger.parseServerSide(cursor ?? '') ?? undefined}
filters={
eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined
}
/>
<div className="flex flex-col gap-4">
<ProfileLastSeenServer projectId={projectId} />
<ProfileTopServer
projectId={projectId}
organizationId={organizationId}
/>
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,76 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { cn } from '@/utils/cn';
import { 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 events where project_id = '${projectId}' group by days order by days ASC`
);
const take = 18;
const split = take / 2;
const max = Math.max(...res.map((item) => item.count));
const renderItem = (item: Row) => (
<div
key={item.days}
className="flex-1 shrink-0 h-full flex flex-col items-center"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full flex-1 bg-slate-200 rounded flex flex-col justify-end">
<div
className={cn(
'w-full rounded',
item.days < split ? 'bg-blue-600' : 'bg-blue-400'
)}
style={{
height: `${(item.count / max) * 100}%`,
}}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{item.count} profiles last seen{' '}
{item.days === 0 ? 'today' : `${item.days} days ago`}
</TooltipContent>
</Tooltip>
<div className="text-xs mt-1">{item.days}</div>
</div>
);
return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Last seen</div>
</WidgetHead>
<WidgetBody>
<div className="flex aspect-[3/1] w-full gap-1 items-end">
{res.length >= 18 ? (
<>
{res.slice(0, split).map(renderItem)}
{res.slice(-split).map(renderItem)}
</>
) : (
res.map(renderItem)
)}
</div>
<div className="text-center text-xs text-muted-foreground">DAYS</div>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,30 @@
import { getProfileList, getProfileListCount } from '@openpanel/db';
import type { IChartEventFilter } from '@openpanel/validation';
import { ProfileList } from './profile-list';
interface Props {
projectId: string;
cursor?: number;
filters?: IChartEventFilter[];
}
export default async function ProfileListServer({
projectId,
cursor,
filters,
}: Props) {
const [profiles, count] = await Promise.all([
getProfileList({
projectId,
take: 10,
cursor,
filters,
}),
getProfileListCount({
projectId,
filters,
}),
]);
return <ProfileList data={profiles} count={count} />;
}

View File

@@ -0,0 +1,111 @@
'use client';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { Button } from '@/components/ui/button';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetHead } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useAppParams } from '@/hooks/useAppParams';
import { useCursor } from '@/hooks/useCursor';
import { UsersIcon } from 'lucide-react';
import Link from 'next/link';
import { getProfileName } from '@openpanel/db';
import type { IServiceProfile } from '@openpanel/db';
interface ProfileListProps {
data: IServiceProfile[];
count: number;
}
export function ProfileList({ data, count }: ProfileListProps) {
const { organizationId, projectId } = useAppParams();
const { cursor, setCursor } = useCursor();
return (
<Widget>
<WidgetHead className="flex justify-between items-center">
<div className="title">Profiles</div>
<Pagination
size="sm"
cursor={cursor}
setCursor={setCursor}
count={count}
take={10}
/>
</WidgetHead>
{data.length ? (
<>
<WidgetTable
data={data}
keyExtractor={(item) => item.id}
columns={[
{
name: 'Name',
render(profile) {
return (
<Link
href={`/${organizationId}/${projectId}/profiles/${profile.id}`}
className="flex gap-2 items-center font-medium"
>
<ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)}
</Link>
);
},
},
{
name: '',
render(profile) {
return <ListPropertiesIcon {...profile.properties} />;
},
},
{
name: 'Last seen',
render(profile) {
return (
<Tooltiper
asChild
content={profile.createdAt.toLocaleString()}
>
<div className="text-muted-foreground text-sm">
{profile.createdAt.toLocaleTimeString()}
</div>
</Tooltiper>
);
},
},
]}
/>
<div className="p-4 border-t border-border">
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={10}
/>
</div>
</>
) : (
<FullPageEmptyState title="No profiles here" icon={UsersIcon}>
{cursor !== 0 ? (
<>
<p>Looks like you have reached the end of the list</p>
<Button
className="mt-4"
variant="outline"
size="sm"
onClick={() => setCursor(count / 10 - 1)}
>
Go back
</Button>
</>
) : (
<p>Looks like there is no profiles here</p>
)}
</FullPageEmptyState>
)}
</Widget>
);
}

View File

@@ -0,0 +1,70 @@
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { Widget, WidgetHead } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import Link from 'next/link';
import { chQuery, getProfileName, getProfiles } from '@openpanel/db';
interface Props {
projectId: string;
organizationId: string;
}
export default async function ProfileTopServer({
organizationId,
projectId,
}: Props) {
// Days since last event from users
// group by days
const res = await chQuery<{ profile_id: string; count: number }>(
`SELECT profile_id, count(*) as count from events where profile_id != '' and project_id = '${projectId}' group by profile_id order by count() DESC LIMIT 10`
);
const profiles = await getProfiles({ ids: res.map((r) => r.profile_id) });
const list = res.map((item) => {
return {
count: item.count,
...(profiles.find((p) => p.id === item.profile_id)! ?? {}),
};
});
return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Power users</div>
</WidgetHead>
<WidgetTable
data={list.filter((item) => !!item.id)}
keyExtractor={(item) => item.id}
columns={[
{
name: 'Name',
render(profile) {
return (
<Link
href={`/${organizationId}/${projectId}/profiles/${profile.id}`}
className="flex gap-2 items-center font-medium"
>
<ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)}
</Link>
);
},
},
{
name: '',
render(profile) {
return <ListPropertiesIcon {...profile.properties} />;
},
},
{
name: 'Events',
render(profile) {
return profile.count;
},
},
]}
/>
</Widget>
);
}

View File

@@ -0,0 +1,43 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug, getReportById } from '@openpanel/db';
import ReportEditor from '../report-editor';
interface PageProps {
params: {
projectId: string;
reportId: string;
organizationId: string;
};
}
export default async function Page({
params: { reportId, organizationId, projectId },
}: PageProps) {
const [report] = await Promise.all([
getReportById(reportId),
getExists(organizationId, projectId),
]);
if (!report) {
return notFound();
}
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex gap-2 items-center cursor-pointer">
{report.name}
<Pencil size={16} />
</div>
}
>
<ReportEditor report={report} />
</PageLayout>
);
}

View File

@@ -0,0 +1,35 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug } from '@openpanel/db';
import ReportEditor from './report-editor';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex gap-2 items-center cursor-pointer">
Unnamed report
<Pencil size={16} />
</div>
}
>
<ReportEditor report={null} />
</PageLayout>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useEffect } from 'react';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { ChartSwitch } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
import { ReportRange } from '@/components/report/ReportRange';
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import {
changeDateRanges,
changeDates,
changeEndDate,
changeStartDate,
ready,
reset,
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
import { endOfDay, startOfDay } from 'date-fns';
import { GanttChartSquareIcon } from 'lucide-react';
import type { IServiceReport } from '@openpanel/db';
interface ReportEditorProps {
report: IServiceReport | null;
}
export default function ReportEditor({
report: initialReport,
}: ReportEditorProps) {
const { projectId } = useAppParams();
const dispatch = useDispatch();
const report = useSelector((state) => state.report);
// Set report if reportId exists
useEffect(() => {
if (initialReport) {
dispatch(setReport(initialReport));
} else {
dispatch(ready());
}
return () => {
dispatch(reset());
};
}, [initialReport, dispatch]);
return (
<Sheet>
<StickyBelowHeader className="p-4 grid grid-cols-2 gap-2 md:grid-cols-6">
<SheetTrigger asChild>
<div>
<Button icon={GanttChartSquareIcon} variant="cta">
Pick events
</Button>
</div>
</SheetTrigger>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
<ReportChartType className="min-w-0 flex-1" />
<ReportRange
className="min-w-0 flex-1"
range={report.range}
onRangeChange={(value) => {
dispatch(changeDateRanges(value));
}}
dates={{
startDate: report.startDate,
endDate: report.endDate,
}}
onDatesChange={(val) => {
if (!val) return;
if (val.from && val.to) {
dispatch(
changeDates({
startDate: startOfDay(val.from).toISOString(),
endDate: endOfDay(val.to).toISOString(),
})
);
} else if (val.from) {
dispatch(changeStartDate(startOfDay(val.from).toISOString()));
} else if (val.to) {
dispatch(changeEndDate(endOfDay(val.to).toISOString()));
}
}}
/>
<ReportInterval className="min-w-0 flex-1" />
<ReportLineType className="min-w-0 flex-1" />
</div>
<div className="col-start-2 md:col-start-6 row-start-1 text-right">
<ReportSaveButton />
</div>
</StickyBelowHeader>
<div className="flex flex-col gap-4 p-4">
{report.ready && (
<ChartSwitch {...report} projectId={projectId} editMode />
)}
</div>
<SheetContent className="!max-w-lg" side="left">
<ReportSidebar />
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { columns } from '@/components/clients/table';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import type { getClientsByOrganizationId } from '@openpanel/db';
interface ListClientsProps {
clients: Awaited<ReturnType<typeof getClientsByOrganizationId>>;
}
export default function ListClients({ clients }: ListClientsProps) {
return (
<>
<StickyBelowHeader>
<div className="p-4 flex items-center justify-between">
<div />
<Button icon={PlusIcon} onClick={() => pushModal('AddClient')}>
<span className="max-sm:hidden">Create client</span>
<span className="sm:hidden">Client</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={clients} columns={columns} />
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getClientsByOrganizationId } from '@openpanel/db';
import ListClients from './list-clients';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const clients = await getClientsByOrganizationId(organizationId);
return (
<PageLayout title="Clients" organizationSlug={organizationId}>
<ListClients clients={clients} />
</PageLayout>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { getOrganizationBySlug } from '@openpanel/db';
const validator = z.object({
id: z.string().min(2),
name: z.string().min(2),
});
type IForm = z.infer<typeof validator>;
interface EditOrganizationProps {
organization: Awaited<ReturnType<typeof getOrganizationBySlug>>;
}
export default function EditOrganization({
organization,
}: EditOrganizationProps) {
const router = useRouter();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
defaultValues: organization ?? undefined,
});
const mutation = api.organization.update.useMutation({
onSuccess(res) {
toast('Organization updated', {
description: 'Your organization has been updated.',
});
reset(res);
router.refresh();
},
onError: handleError,
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Org. details</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody>
<InputWithLabel
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,60 @@
import { api } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';
import { SendIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { zInviteUser } from '@openpanel/validation';
type IForm = z.infer<typeof zInviteUser>;
export function InviteUser() {
const router = useRouter();
const { organizationId: organizationSlug } = useAppParams();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
resolver: zodResolver(zInviteUser),
defaultValues: {
organizationSlug,
email: '',
role: 'org:member',
},
});
const mutation = api.organization.inviteUser.useMutation({
onSuccess() {
toast('User invited!', {
description: 'The user has been invited to the organization.',
});
reset();
router.refresh();
},
});
return (
<form
onSubmit={handleSubmit((values) => mutation.mutate(values))}
className="flex items-end gap-4"
>
<InputWithLabel
className="w-full max-w-sm"
label="Email"
placeholder="Who do you want to invite?"
{...register('email')}
/>
<Button
icon={SendIcon}
type="submit"
disabled={!formState.isDirty}
loading={mutation.isLoading}
>
Invite user
</Button>
</form>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import type { IServiceInvites } from '@openpanel/db';
import { InviteUser } from './invite-user';
interface InvitedUsersProps {
invites: IServiceInvites;
}
export default function InvitedUsers({ invites }: InvitedUsersProps) {
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Invites</span>
</WidgetHead>
<WidgetBody>
<InviteUser />
<div className="font-medium mt-8 mb-2">Invited users</div>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((item) => {
return (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.email}</TableCell>
<TableCell>{item.role}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
{new Date(item.createdAt).toLocaleString()}
</TableCell>
</TableRow>
);
})}
{invites.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="italic">
No invites
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,33 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { clerkClient } from '@clerk/nextjs';
import { notFound } from 'next/navigation';
import { getInvites, getOrganizationBySlug } from '@openpanel/db';
import EditOrganization from './edit-organization';
import InvitedUsers from './invited-users';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const organization = await getOrganizationBySlug(organizationId);
if (!organization) {
return notFound();
}
const invites = await getInvites(organization.id);
return (
<PageLayout title={organization.name} organizationSlug={organizationId}>
<div className="p-4 grid grid-cols-1 gap-4">
<EditOrganization organization={organization} />
<InvitedUsers invites={invites} />
</div>
</PageLayout>
);
}

View File

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

View File

@@ -0,0 +1,85 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
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(res);
router.refresh();
},
onError: handleError,
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Your profile</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody className="flex flex-col gap-4">
<InputWithLabel
label="First name"
placeholder="Your first name"
defaultValue={profile.firstName ?? ''}
{...register('firstName')}
/>
<InputWithLabel
label="Last name"
placeholder="Your last name"
defaultValue={profile.lastName ?? ''}
{...register('lastName')}
/>
<InputWithLabel
disabled
label="Email"
placeholder="Your email"
defaultValue={profile.email ?? ''}
{...register('email')}
/>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import { buttonVariants } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { SignOutButton } from '@clerk/nextjs';
export function Logout() {
return (
<Widget className="border-destructive">
<WidgetHead>
<span className="title">Sad part</span>
</WidgetHead>
<WidgetBody>
<p className="mb-4">
Sometime&apos;s you need to go. See you next time
</p>
<SignOutButton
// @ts-expect-error
className={buttonVariants({ variant: 'destructive' })}
/>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,28 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { auth } from '@clerk/nextjs';
import { getUserById } from '@openpanel/db';
import EditProfile from './edit-profile';
import { Logout } from './logout';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const { userId } = auth();
await getExists(organizationId);
const profile = await getUserById(userId!);
return (
<PageLayout title={profile.lastName} organizationSlug={organizationId}>
<div className="p-4 flex flex-col gap-4">
<EditProfile profile={profile} />
<Logout />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/data-table';
import { columns } from '@/components/projects/table';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import type { getProjectsByOrganizationSlug } from '@openpanel/db';
interface ListProjectsProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
}
export default function ListProjects({ projects }: ListProjectsProps) {
const organizationId = useAppParams().organizationId;
return (
<>
<StickyBelowHeader>
<div className="p-4 flex items-center justify-between">
<div />
<Button
icon={PlusIcon}
onClick={() =>
pushModal('AddProject', {
organizationId,
})
}
>
<span className="max-sm:hidden">Create project</span>
<span className="sm:hidden">Project</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={projects} columns={columns} />
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getProjectsByOrganizationSlug } from '@openpanel/db';
import ListProjects from './list-projects';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const projects = await getProjectsByOrganizationSlug(organizationId);
return (
<PageLayout title="Projects" organizationSlug={organizationId}>
<ListProjects projects={projects} />
</PageLayout>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/data-table';
import { columns } from '@/components/references/table';
import { Button } from '@/components/ui/button';
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 (
<>
<StickyBelowHeader>
<div className="p-4 flex items-center justify-between">
<div />
<Button icon={PlusIcon} onClick={() => pushModal('AddReference')}>
<span className="max-sm:hidden">Create reference</span>
<span className="sm:hidden">Reference</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={data} columns={columns} />
</div>
</>
);
}

View File

@@ -0,0 +1,32 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getReferences } from '@openpanel/db';
import ListReferences from './list-references';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
const references = await getReferences({
where: {
project_id: projectId,
},
take: 50,
skip: 0,
});
return (
<PageLayout title="References" organizationSlug={organizationId}>
<ListReferences data={references} />
</PageLayout>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import { LogoSquare } from '@/components/logo';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';
import { SaveIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { api, handleError } from '../../_trpc/client';
const validation = z.object({
name: z.string().min(1),
});
type IForm = z.infer<typeof validation>;
export function CreateProject() {
const params = useAppParams();
const router = useRouter();
const form = useForm<IForm>({
resolver: zodResolver(validation),
});
const mutation = api.project.create.useMutation({
onError: handleError,
onSuccess() {
toast.success('Project created');
router.refresh();
},
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
name: values.name,
organizationId: params.organizationId,
});
};
return (
<>
<div>
<LogoSquare className="w-20 md:w-28 mb-8" />
<h1 className="font-medium text-3xl">Create your first project</h1>
<div className="text-lg">
A project is just a container for your events. You can create as many
as you want.
</div>
<form
className="mt-8 flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Label>Project name</Label>
<Input
placeholder="My App"
size="large"
error={form.formState.errors.name?.message}
{...form.register('name')}
/>
</div>
<div className="flex justify-end">
<Button
type="submit"
size="lg"
icon={SaveIcon}
loading={mutation.isLoading}
>
Create project
</Button>
</div>
</form>
</div>
</>
);
}

View File

@@ -0,0 +1,69 @@
import { LogoSquare } from '@/components/logo';
import { ProjectCard } from '@/components/projects/project-card';
import { notFound, redirect } from 'next/navigation';
import {
getOrganizationBySlug,
getProjectsByOrganizationSlug,
isWaitlistUserAccepted,
} from '@openpanel/db';
import { CreateProject } from './create-project';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const [organization, projects] = await Promise.all([
getOrganizationBySlug(organizationId),
getProjectsByOrganizationSlug(organizationId),
]);
if (!organization) {
return notFound();
}
if (process.env.BLOCK) {
const isAccepted = await isWaitlistUserAccepted();
if (!isAccepted) {
return (
<div className="p-4 flex items-center justify-center h-screen">
<div className="max-w-lg w-full">
<LogoSquare className="w-20 md:w-28 mb-8" />
<h1 className="font-medium text-3xl">Not quite there yet</h1>
<div className="text-lg">
We're still working on Openpanel, but we're not quite there yet.
We'll let you know when we're ready to go!
</div>
</div>
</div>
);
}
}
if (projects.length === 0) {
return (
<div className="flex items-center justify-center h-screen p-4 ">
<div className="max-w-lg w-full">
<CreateProject />
</div>
</div>
);
}
if (projects.length === 1 && projects[0]) {
return redirect(`/${organizationId}/${projects[0].id}`);
}
return (
<div className="max-w-xl w-full mx-auto flex flex-col gap-4 pt-20 p-4 ">
<h1 className="font-medium text-xl">Select project</h1>
{projects.map((item) => (
<ProjectCard key={item.id} {...item} />
))}
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { CreateClientSuccess } from '@/components/clients/create-client-success';
import { LogoSquare } from '@/components/logo';
import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/utils/cn';
import { zodResolver } from '@hookform/resolvers/zod';
import { SaveIcon, WallpaperIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { api, handleError } from '../_trpc/client';
const validation = z
.object({
organization: z.string().min(3),
project: z.string().min(3),
cors: z.string().nullable(),
tab: z.string(),
})
.refine(
(data) =>
data.tab === 'other' || (data.tab === 'website' && data.cors !== ''),
{
message: 'Cors is required',
path: ['cors'],
}
);
type IForm = z.infer<typeof validation>;
export function CreateOrganization() {
const router = useRouter();
const form = useForm<IForm>({
resolver: zodResolver(validation),
defaultValues: {
organization: '',
project: '',
cors: '',
tab: 'website',
},
});
const mutation = api.onboarding.organziation.useMutation({
onError: handleError,
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
...values,
cors: values.tab === 'website' ? values.cors : null,
});
};
if (mutation.isSuccess && mutation.data.client) {
return (
<div className="card p-4 md:p-8">
<LogoSquare className="w-20 mb-4" />
<h1 className="font-medium text-3xl">Nice job!</h1>
<div className="mb-4">
You're ready to start using our SDK. Save the client ID and secret (if
you have any)
</div>
<CreateClientSuccess {...mutation.data.client} />
<div className="flex gap-4 mt-4">
<a
className={cn(buttonVariants({ variant: 'secondary' }), 'flex-1')}
href="https://docs.openpanel.dev/docs"
target="_blank"
>
Read docs
</a>
<Button
className="flex-1"
onClick={() => router.refresh()}
icon={WallpaperIcon}
>
Dashboard
</Button>
</div>
</div>
);
}
return (
<div className="card p-4 md:p-8">
<LogoSquare className="w-20 mb-4" />
<h1 className="font-medium text-3xl">Welcome to Openpanel</h1>
<div className="text-lg">
Create your organization below (can be personal or a company) and your
first project.
</div>
<form
className="mt-8 flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Label>Organization name *</Label>
<Input
placeholder="Organization name"
error={form.formState.errors.organization?.message}
{...form.register('organization')}
/>
</div>
<div>
<Label>Project name *</Label>
<Input
placeholder="Project name"
error={form.formState.errors.project?.message}
{...form.register('project')}
/>
</div>
<Tabs
defaultValue="website"
onValueChange={(val) => form.setValue('tab', val)}
className="h-28"
>
<TabsList className="bg-slate-200">
<TabsTrigger value="website">Website</TabsTrigger>
<TabsTrigger value="other">Other</TabsTrigger>
</TabsList>
<TabsContent value="website">
<Label>Cors *</Label>
<Input
placeholder="https://example.com"
error={form.formState.errors.cors?.message}
{...form.register('cors')}
/>
</TabsContent>
<TabsContent value="other">
<div className="p-2 px-3 bg-white rounded text-sm">
🔑 You will get a secret to use for your API requests.
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end">
<Button
type="submit"
size="lg"
icon={SaveIcon}
loading={mutation.isLoading}
>
Create
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,41 @@
// import { CreateOrganization } from '@clerk/nextjs';
import { LogoSquare } from '@/components/logo';
import { redirect } from 'next/navigation';
import { getCurrentOrganizations, isWaitlistUserAccepted } from '@openpanel/db';
import { CreateOrganization } from './create-organization';
export default async function Page() {
const organizations = await getCurrentOrganizations();
if (process.env.BLOCK) {
const isAccepted = await isWaitlistUserAccepted();
if (!isAccepted) {
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-lg w-full">
<LogoSquare className="w-20 md:w-28 mb-8" />
<h1 className="font-medium text-3xl">Not quite there yet</h1>
<div className="text-lg">
We're still working on Openpanel, but we're not quite there yet.
We'll let you know when we're ready to go!
</div>
</div>
</div>
);
}
}
if (organizations.length > 0) {
return redirect(`/${organizations[0]?.slug}`);
}
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-lg w-full">
<CreateOrganization />
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { OverviewReportRange } from '@/app/(app)/[organizationId]/[projectId]/overview-sticky-header';
import { Logo } from '@/components/logo';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import OverviewMetrics from '@/components/overview/overview-metrics';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug, getShareOverviewById } from '@openpanel/db';
interface PageProps {
params: {
id: string;
};
}
export default async function Page({ params: { id } }: PageProps) {
const share = await getShareOverviewById(id);
if (!share) {
return notFound();
}
if (!share.public) {
return notFound();
}
const projectId = share.project_id;
const organization = await getOrganizationBySlug(share.organization_slug);
return (
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-end mb-4">
<div className="leading-none">
<span className="text-white mb-4">{organization?.name}</span>
<h1 className="text-white text-xl font-medium">
{share.project?.name}
</h1>
</div>
<a href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share">
<Logo className="text-white max-sm:[&_span]:hidden" />
</a>
</div>
<div className="max-sm:-mx-3 bg-slate-100 rounded-lg shadow ring-8 ring-blue-600/50">
<StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
{/* <OverviewFiltersDrawer projectId={projectId} mode="events" /> */}
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
</div>
</div>
<OverviewFiltersButtons />
</StickyBelowHeader>
<div className="p-4 grid gap-4 grid-cols-6">
<div className="col-span-6">
<OverviewLiveHistogram projectId={projectId} />
</div>
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import type { AppRouter } from '@/server/api/root';
import type { TRPCClientErrorBase } from '@trpc/react-query';
import { createTRPCReact } from '@trpc/react-query';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { ExternalToast } from 'sonner';
import { toast } from 'sonner';
export const api = createTRPCReact<AppRouter>({});
/**
* Inference helper for inputs.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs.
*
* @example type HelloOutput = RouterOutputs['example']['hello']
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export type IChartData = RouterOutputs['chart']['chart'];
export type IChartSerieDataItem = IChartData['series'][number]['data'][number];
export function handleError(error: TRPCClientErrorBase<any>) {
toast('Error', {
description: error.message,
});
}
export function handleErrorToastOptions(options: ExternalToast) {
return function (error: TRPCClientErrorBase<any>) {
toast('Error', {
description: error.message,
...options,
});
};
}

View File

@@ -0,0 +1,25 @@
import { appRouter } from '@/server/api/root';
import { getAuth } from '@clerk/nextjs/server';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
async createContext({ req }) {
const session = getAuth(req as any);
return {
session,
};
},
onError(opts) {
const { error, type, path, input, ctx, req } = opts;
console.error('---- TRPC ERROR');
console.error('Error:', error);
console.error('Context:', ctx);
console.error();
},
});
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,87 @@
'use client';
import { useState } from 'react';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Logo } from '@/components/logo';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody } from '@/components/widget';
import { zodResolver } from '@hookform/resolvers/zod';
import { KeySquareIcon } from 'lucide-react';
import { signIn } from 'next-auth/react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const validator = z.object({
email: z.string().email(),
password: z.string().min(6),
});
type IForm = z.infer<typeof validator>;
export default function Auth() {
const form = useForm<IForm>({
resolver: zodResolver(validator),
});
const router = useRouter();
const pathname = usePathname();
const [state, setState] = useState<string | null>(null);
return (
<div className="flex items-center justify-center flex-col h-screen p-4">
<Widget className="max-w-md w-full mb-4">
<WidgetBody>
<div className="flex justify-center py-8">
<Logo />
</div>
<form
onSubmit={form.handleSubmit(async (values) => {
const res = await signIn('credentials', {
email: values.email,
password: values.password,
redirect: false,
}).catch(() => {
setState('Something went wrong. Please try again later');
});
if (res?.ok) {
router.refresh();
}
if (res?.status === 401) {
setState('Wrong email or password. Please try again');
}
})}
className="flex flex-col gap-4"
>
<InputWithLabel
label="Email"
placeholder="Your email"
error={form.formState.errors.email?.message}
{...form.register('email')}
/>
<InputWithLabel
label="Password"
placeholder="...and your password"
error={form.formState.errors.password?.message}
{...form.register('password')}
/>
{state !== null && (
<Alert variant="destructive">
<KeySquareIcon className="h-4 w-4" />
<AlertTitle>Failed</AlertTitle>
<AlertDescription>{state}</AlertDescription>
</Alert>
)}
<Button type="submit">Sign in</Button>
<Link href="/register" className="text-center text-sm">
No account?{' '}
<span className="font-medium text-blue-600">Sign up here!</span>
</Link>
</form>
</WidgetBody>
</Widget>
<p className="text-xs">Terms & conditions</p>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { cn } from '@/utils/cn';
import Providers from './providers';
import '@/styles/globals.css';
import '/node_modules/flag-icons/css/flag-icons.min.css';
export const metadata = {
title: 'Overview - Openpanel.dev',
};
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: 1,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="light">
<body
className={cn('min-h-screen font-sans antialiased grainy bg-slate-100')}
>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,20 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Openpanel.dev',
short_name: 'Openpanel.dev',
description: '',
start_url: '/',
display: 'standalone',
background_color: '#fff',
theme_color: '#fff',
icons: [
{
src: 'https://openpanel.dev/favicon.ico',
sizes: 'any',
type: 'image/x-icon',
},
],
};
}

View File

@@ -0,0 +1,71 @@
'use client';
import React, { useRef, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { TooltipProvider } from '@/components/ui/tooltip';
import { ModalProvider } from '@/modals';
import type { AppStore } from '@/redux';
import makeStore from '@/redux';
import { ClerkProvider, useAuth } from '@clerk/nextjs';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpLink } from '@trpc/client';
import { Provider as ReduxProvider } from 'react-redux';
import { Toaster } from 'sonner';
import superjson from 'superjson';
function AllProviders({ children }: { children: React.ReactNode }) {
const { getToken } = useAuth();
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
networkMode: 'always',
refetchOnMount: true,
refetchOnWindowFocus: false,
},
},
})
);
const [trpcClient] = useState(() =>
api.createClient({
transformer: superjson,
links: [
httpLink({
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/api/trpc`,
async headers() {
return { Authorization: `Bearer ${await getToken()}` };
},
}),
],
})
);
const storeRef = useRef<AppStore>();
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore();
}
return (
<ReduxProvider store={storeRef.current}>
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}>
{children}
<Toaster />
<ModalProvider />
</TooltipProvider>
</QueryClientProvider>
</api.Provider>
</ReduxProvider>
);
}
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<AllProviders>{children}</AllProviders>
</ClerkProvider>
);
}

View File

@@ -0,0 +1,11 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
export function ButtonContainer({
className,
...props
}: HtmlProps<HTMLDivElement>) {
return (
<div className={cn('flex justify-between mt-6', className)} {...props} />
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
import { MoreHorizontal } from 'lucide-react';
type CardProps = HtmlProps<HTMLDivElement> & {
hover?: boolean;
};
export function Card({ children, hover, className }: CardProps) {
return (
<div
className={cn(
'card relative',
hover && 'transition-all hover:border-black',
className
)}
>
{children}
</div>
);
}
interface CardActionsProps {
children: React.ReactNode;
}
export function CardActions({ children }: CardActionsProps) {
return (
<div className="absolute top-2 right-2 z-10">
<DropdownMenu>
<DropdownMenuTrigger className="h-8 w-8 hover:border rounded justify-center items-center flex">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>{children}</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export const CardActionsItem = DropdownMenuItem;

View File

@@ -0,0 +1,68 @@
import * as d3 from 'd3';
export function ChartSSR({
data,
dots = false,
}: {
dots?: boolean;
data: { value: number; date: Date }[];
}) {
const xScale = d3
.scaleTime()
.domain([data[0]!.date, data[data.length - 1]!.date])
.range([0, 100]);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data.map((d) => d.value)) ?? 0])
.range([100, 0]);
const line = d3
.line<(typeof data)[number]>()
.curve(d3.curveMonotoneX)
.x((d) => xScale(d.date))
.y((d) => yScale(d.value));
const d = line(data);
if (!d) {
return null;
}
return (
<div className="@container relative h-full w-full">
{/* Chart area */}
<svg className="absolute inset-0 h-full w-full overflow-visible">
<svg
viewBox="0 0 100 100"
className="overflow-visible"
preserveAspectRatio="none"
>
{/* Line */}
<path
d={d}
fill="none"
className="text-blue-600"
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
{/* Circles */}
{dots &&
data.map((d) => (
<path
key={d.date.toString()}
d={`M ${xScale(d.date)} ${yScale(d.value)} l 0.0001 0`}
vectorEffect="non-scaling-stroke"
strokeWidth="8"
strokeLinecap="round"
fill="none"
stroke="currentColor"
className="text-gray-400"
/>
))}
</svg>
</svg>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { api } from '@/app/_trpc/client';
import { pushModal, showConfirm } from '@/modals';
import { clipboard } from '@/utils/clipboard';
import { MoreHorizontal } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceClientWithProject } from '@openpanel/db';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
export function ClientActions(client: IServiceClientWithProject) {
const { id } = client;
const router = useRouter();
const deletion = api.client.remove.useMutation({
onSuccess() {
toast('Success', {
description: 'Client revoked, incoming requests will be rejected.',
});
router.refresh();
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => clipboard(id)}>
Copy client ID
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
pushModal('EditClient', client);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => {
showConfirm({
title: 'Revoke client',
text: 'Are you sure you want to revoke this client? This action cannot be undone.',
onConfirm() {
deletion.mutate({
id,
});
},
});
}}
>
Revoke
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,47 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { clipboard } from '@/utils/clipboard';
import { Copy, RocketIcon } from 'lucide-react';
import Link from 'next/link';
import type { IServiceClient } from '@openpanel/db';
import { Label } from '../ui/label';
type Props = IServiceClient;
export function CreateClientSuccess({ id, secret, cors }: Props) {
return (
<div className="grid gap-4">
<button className="text-left" onClick={() => clipboard(id)}>
<Label>Client ID</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{id}
<Copy size={16} />
</div>
</button>
{secret ? (
<button className="text-left" onClick={() => clipboard(secret)}>
<Label>Secret</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{secret}
<Copy size={16} />
</div>
</button>
) : (
<div className="text-left">
<Label>Cors settings</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{cors}
</div>
</div>
)}
<Alert>
<RocketIcon className="h-4 w-4" />
<AlertTitle>Get started!</AlertTitle>
<AlertDescription>
Read our documentation to get started. Easy peasy!
</AlertDescription>
</Alert>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { formatDate } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientActions } from './client-actions';
export const columns: ColumnDef<IServiceClientWithProject>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return (
<div>
<div>{row.original.name}</div>
<div className="text-sm text-muted-foreground">
{row.original.project.name}
</div>
</div>
);
},
},
{
accessorKey: 'id',
header: 'Client ID',
},
{
accessorKey: 'cors',
header: 'Cors',
},
{
accessorKey: 'secret',
header: 'Secret',
cell: (info) =>
info.getValue() ? (
<div className="italic text-muted-foreground">Hidden</div>
) : (
'None'
),
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
return formatDate(date);
},
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => <ClientActions {...row.original} />,
},
];

View File

@@ -0,0 +1,23 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
import { useChartContext } from './report/chart/ChartProvider';
type ColorSquareProps = HtmlProps<HTMLDivElement>;
export function ColorSquare({ children, className }: ColorSquareProps) {
const { hideID } = useChartContext();
if (hideID) {
return null;
}
return (
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-purple-500 text-xs font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import type { ColumnDef } from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './ui/table';
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
}
export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,69 @@
import { SerieIcon } from '../report/chart/SerieIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface Props {
country?: string;
city?: string;
os?: string;
os_version?: string;
browser?: string;
browser_version?: string;
referrer_name?: string;
referrer_type?: string;
}
export function ListPropertiesIcon({
country,
city,
os,
os_version,
browser,
browser_version,
referrer_name,
referrer_type,
}: Props) {
return (
<div className="flex gap-1">
{country && (
<Tooltip>
<TooltipTrigger>
<SerieIcon name={country} />
</TooltipTrigger>
<TooltipContent>
{country}, {city}
</TooltipContent>
</Tooltip>
)}
{os && (
<Tooltip>
<TooltipTrigger>
<SerieIcon name={os} />
</TooltipTrigger>
<TooltipContent>
{os} ({os_version})
</TooltipContent>
</Tooltip>
)}
{browser && (
<Tooltip>
<TooltipTrigger>
<SerieIcon name={browser} />
</TooltipTrigger>
<TooltipContent>
{browser} ({browser_version})
</TooltipContent>
</Tooltip>
)}
{referrer_name && (
<Tooltip>
<TooltipTrigger>
<SerieIcon name={referrer_name} />
</TooltipTrigger>
<TooltipContent>
{referrer_name} ({referrer_type})
</TooltipContent>
</Tooltip>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { forwardRef } from 'react';
import { Input } from '../ui/input';
import type { InputProps } from '../ui/input';
import { Label } from '../ui/label';
type InputWithLabelProps = InputProps & {
label: string;
error?: string | undefined;
};
export const InputWithLabel = forwardRef<HTMLInputElement, InputWithLabelProps>(
({ label, className, ...props }, ref) => {
return (
<div className={className}>
<div className="block mb-2 flex justify-between">
<Label className="mb-0" htmlFor={label}>
{label}
</Label>
{props.error && (
<span className="text-sm text-destructive leading-none">
{props.error}
</span>
)}
</div>
<Input ref={ref} id={label} {...props} />
</div>
);
}
);
InputWithLabel.displayName = 'InputWithLabel';

View File

@@ -0,0 +1,28 @@
import { BoxSelectIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
interface FullPageEmptyStateProps {
icon?: LucideIcon;
title: string;
children: React.ReactNode;
}
export function FullPageEmptyState({
icon: Icon = BoxSelectIcon,
title,
children,
}: FullPageEmptyStateProps) {
return (
<div className="p-4 flex items-center justify-center">
<div className="p-8 w-full max-w-xl flex flex-col items-center justify-center">
<div className="w-24 h-24 bg-white shadow-sm rounded-full flex justify-center items-center mb-6">
<Icon size={60} strokeWidth={1} />
</div>
<h1 className="text-xl font-medium mb-1">{title}</h1>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from '@/utils/cn';
interface LogoProps {
className?: string;
}
export function LogoSquare({ className }: LogoProps) {
return (
<img
src="/logo.svg"
className={cn('rounded-md', className)}
alt="Openpanel logo"
/>
);
}
export function Logo({ className }: LogoProps) {
return (
<div
className={cn('text-xl font-medium flex gap-2 items-center', className)}
>
<LogoSquare className="max-h-8" />
<span>openpanel.dev</span>
</div>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { Button } from '@/components/ui/button';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
interface OverviewFiltersButtonsProps {
className?: string;
nuqsOptions?: NuqsOptions;
}
export function OverviewFiltersButtons({
className,
nuqsOptions,
}: OverviewFiltersButtonsProps) {
const [events, setEvents] = useEventQueryNamesFilter(nuqsOptions);
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
if (filters.length === 0 && events.length === 0) return null;
return (
<div className={cn('flex flex-wrap gap-2 px-4 pb-4', className)}>
{events.map((event) => (
<Button
key={event}
size="sm"
variant="outline"
icon={X}
onClick={() => setEvents((p) => p.filter((e) => e !== event))}
>
<strong>{event}</strong>
</Button>
))}
{filters.map((filter) => {
if (!filter.value[0]) {
return null;
}
return (
<Button
key={filter.name}
size="sm"
variant="outline"
icon={X}
onClick={() => setFilter(filter.name, filter.value[0], 'is')}
>
<span className="mr-1">{filter.name} is</span>
<strong>{filter.value[0]}</strong>
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useEventNames } from '@/hooks/useEventNames';
import { useEventProperties } from '@/hooks/useEventProperties';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { useEventValues } from '@/hooks/useEventValues';
import { useProfileProperties } from '@/hooks/useProfileProperties';
import { useProfileValues } from '@/hooks/useProfileValues';
import { XIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@openpanel/validation';
export interface OverviewFiltersDrawerContentProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
mode: 'profiles' | 'events';
}
export function OverviewFiltersDrawerContent({
projectId,
nuqsOptions,
enableEventsFilter,
mode,
}: OverviewFiltersDrawerContentProps) {
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames(projectId);
const eventProperties = useEventProperties(projectId);
const profileProperties = useProfileProperties(projectId);
const properties = mode === 'events' ? eventProperties : profileProperties;
return (
<div>
<SheetHeader className="mb-8">
<SheetTitle>Overview filters</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-4">
{enableEventsFilter && (
<ComboboxAdvanced
className="w-full"
value={event}
onChange={setEvent}
// First items is * which is only used for report editing
items={eventNames.slice(1).map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
)}
<Combobox
className="w-full"
onChange={(value) => {
setFilter(value, '');
}}
value=""
placeholder="Filter by property"
label="What do you want to filter by?"
items={properties.map((item) => ({
label: item,
value: item,
}))}
searchable
/>
</div>
<div className="flex flex-col gap-4 mt-8">
{filters
.filter((filter) => filter.value[0] !== null)
.map((filter) => {
return mode === 'events' ? (
<FilterOptionEvent
key={filter.name}
projectId={projectId}
setFilter={setFilter}
{...filter}
/>
) : (
<FilterOptionProfile
key={filter.name}
projectId={projectId}
setFilter={setFilter}
{...filter}
/>
);
})}
</div>
</div>
);
}
export function FilterOptionEvent({
setFilter,
projectId,
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator
) => void;
}) {
const values = useEventValues(
projectId,
filter.name === 'path' ? 'screen_view' : 'session_start',
filter.name
);
return (
<div className="flex gap-2 items-center">
<div>{filter.name}</div>
<Combobox
className="flex-1"
onChange={(value) => setFilter(filter.name, value, filter.operator)}
placeholder={'Select a value'}
items={values.map((value) => ({
value,
label: value,
}))}
value={String(filter.value[0] ?? '')}
/>
<Button
size="icon"
variant="ghost"
onClick={() =>
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
}
>
<XIcon />
</Button>
</div>
);
}
export function FilterOptionProfile({
setFilter,
projectId,
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator
) => void;
}) {
const values = useProfileValues(projectId, filter.name);
return (
<div className="flex gap-2 items-center">
<div>{filter.name}</div>
<Combobox
className="flex-1"
onChange={(value) => setFilter(filter.name, value, filter.operator)}
placeholder={'Select a value'}
items={values.map((value) => ({
value,
label: value,
}))}
value={String(filter.value[0] ?? '')}
/>
<Button
size="icon"
variant="ghost"
onClick={() =>
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
}
>
<XIcon />
</Button>
</div>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { FilterIcon } from 'lucide-react';
import type { OverviewFiltersDrawerContentProps } from './overview-filters-drawer-content';
import { OverviewFiltersDrawerContent } from './overview-filters-drawer-content';
export function OverviewFiltersDrawer(
props: OverviewFiltersDrawerContentProps
) {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" responsive icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFiltersDrawerContent {...props} />
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,11 @@
import { getLiveVisitors } from '@openpanel/db';
import type { LiveCounterProps } from './live-counter';
import LiveCounter from './live-counter';
export default async function ServerLiveCounter(
props: Omit<LiveCounterProps, 'data'>
) {
const count = await getLiveVisitors(props.projectId);
return <LiveCounter data={count} {...props} />;
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useRef, useState } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import useWebSocket from 'react-use-websocket';
import { toast } from 'sonner';
import { useOverviewOptions } from '../useOverviewOptions';
export interface LiveCounterProps {
data: number;
projectId: string;
}
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
ssr: false,
loading: () => <div>0</div>,
});
const FIFTEEN_SECONDS = 1000 * 15;
export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
const { setLiveHistogram } = useOverviewOptions();
const ws = String(process.env.NEXT_PUBLIC_API_URL)
.replace(/^https/, 'wss')
.replace(/^http/, 'ws');
const client = useQueryClient();
const [counter, setCounter] = useState(data);
const [socketUrl] = useState(`${ws}/live/visitors/${projectId}`);
const lastRefresh = useRef(Date.now());
useWebSocket(socketUrl, {
shouldReconnect: () => true,
onMessage(event) {
const value = parseInt(event.data, 10);
if (!isNaN(value)) {
setCounter(value);
if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
lastRefresh.current = Date.now();
toast('Refreshed data');
client.refetchQueries({
type: 'active',
});
}
}
},
});
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setLiveHistogram((p) => !p)}
className="border border-border rounded h-8 px-3 leading-none flex items-center font-medium gap-2"
>
<div className="relative">
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all',
counter === 0 && 'bg-destructive opacity-0'
)}
></div>
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all',
counter === 0 && 'bg-destructive'
)}
></div>
</div>
<AnimatedNumbers
includeComma
transitions={(index) => ({
type: 'spring',
duration: index + 0.3,
damping: 10,
stiffness: 200,
})}
animateToNumber={counter}
locale="en"
/>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{counter} unique visitors last 5 minutes</p>
<p>Click to see activity for the last 30 minutes</p>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,23 @@
import { BarChartIcon, LineChartIcon } from 'lucide-react';
import { Button } from '../ui/button';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewChartToggle() {
const { chartType, setChartType } = useOverviewOptions();
return (
<Button
size={'icon'}
variant={'outline'}
onClick={() => {
setChartType((p) => (p === 'linear' ? 'bar' : 'linear'));
}}
>
{chartType === 'bar' ? (
<LineChartIcon size={16} />
) : (
<BarChartIcon size={16} />
)}
</Button>
);
}

View File

@@ -0,0 +1,16 @@
import { getConversionEventNames } from '@openpanel/db';
import type { OverviewLatestEventsProps } from './overview-latest-events';
import OverviewLatestEvents from './overview-latest-events';
export default async function OverviewLatestEventsServer({
projectId,
}: Omit<OverviewLatestEventsProps, 'conversions'>) {
const eventNames = await getConversionEventNames(projectId);
return (
<OverviewLatestEvents
projectId={projectId}
conversions={eventNames.map((item) => item.name)}
/>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../../widget';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';
export interface OverviewLatestEventsProps {
projectId: string;
conversions: string[];
}
export default function OverviewLatestEvents({
projectId,
conversions,
}: OverviewLatestEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
title: 'Top events',
btn: 'All',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: conversions.length === 0,
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions,
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch hideID {...widget.chart} previous={false} />
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { Fragment } from 'react';
import { api } from '@/app/_trpc/client';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react';
import AnimateHeight from 'react-animate-height';
import type { IChartInput } from '@openpanel/validation';
import { redisSub } from '../../../../../packages/redis';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewLiveHistogramProps {
projectId: string;
}
export function OverviewLiveHistogram({
projectId,
}: OverviewLiveHistogramProps) {
const { liveHistogram } = useOverviewOptions();
const report: IChartInput = {
projectId,
events: [
{
segment: 'user',
filters: [
{
id: '1',
name: 'name',
operator: 'is',
value: ['screen_view', 'session_start'],
},
],
id: 'A',
name: '*',
displayName: 'Active users',
},
],
chartType: 'histogram',
interval: 'minute',
range: '30min',
name: '',
metric: 'sum',
breakdowns: [],
lineType: 'monotone',
previous: false,
};
const countReport: IChartInput = {
name: '',
projectId,
events: [
{
segment: 'user',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval: 'minute',
range: '30min',
previous: false,
metric: 'sum',
};
const res = api.chart.chart.useQuery(report);
const countRes = api.chart.chart.useQuery(countReport);
const metrics = res.data?.series[0]?.metrics;
const minutes = (res.data?.series[0]?.data || []).slice(-30);
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
if (res.isInitialLoading || countRes.isInitialLoading) {
// prettier-ignore
const staticArray = [
10, 25, 30, 45, 20, 5, 55, 18, 40, 12,
50, 35, 8, 22, 38, 42, 15, 28, 52, 5,
48, 14, 32, 58, 7, 19, 33, 56, 24, 5
];
return (
<Wrapper count={0} open={liveHistogram}>
{staticArray.map((percent, i) => (
<div
key={i}
className="flex-1 rounded-md bg-slate-200 animate-pulse"
style={{ height: `${percent}%` }}
/>
))}
</Wrapper>
);
}
if (!res.isSuccess && !countRes.isSuccess) {
return null;
}
return (
<Wrapper open={liveHistogram} count={liveCount}>
{minutes.map((minute) => {
return (
<Tooltip key={minute.date}>
<TooltipTrigger asChild>
<div
className={cn(
'flex-1 rounded-md hover:scale-110 transition-all ease-in-out',
minute.count === 0 ? 'bg-slate-200' : 'bg-blue-600'
)}
style={{
height:
minute.count === 0
? '5%'
: `${(minute.count / metrics!.max) * 100}%`,
}}
/>
</TooltipTrigger>
<TooltipContent side="top">
<div>{minute.count} active users</div>
<div>@ {new Date(minute.date).toLocaleTimeString()}</div>
</TooltipContent>
</Tooltip>
);
})}
</Wrapper>
);
}
interface WrapperProps {
open: boolean;
children: React.ReactNode;
count: number;
}
function Wrapper({ open, children, count }: WrapperProps) {
return (
<AnimateHeight duration={500} height={open ? 'auto' : 0}>
<div className="flex items-end flex-col md:flex-row">
<div className="md:mr-2 flex md:flex-col max-md:justify-between items-end max-md:w-full max-md:mb-2 md:card md:p-4">
<div className="text-sm max-md:mb-1">Last 30 minutes</div>
<div className="text-2xl font-bold text-ellipsis overflow-hidden whitespace-nowrap">
{count}
</div>
</div>
<div className="max-h-[150px] aspect-[5/1] flex flex-1 gap-0.5 md:gap-2 items-end w-full relative">
<div className="absolute -top-3 right-0 text-xs text-muted-foreground">
NOW
</div>
{/* <div className="md:absolute top-0 left-0 md:card md:p-4 mr-2 md:bg-white/90 z-50"> */}
{children}
</div>
</div>
</AnimateHeight>
);
}

View File

@@ -0,0 +1,227 @@
'use client';
import { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ChartSwitch } from '@/components/report/chart';
import { Widget, WidgetBody } from '@/components/widget';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import type { IChartInput } from '@openpanel/validation';
interface OverviewMetricsProps {
projectId: string;
}
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { previous, range, interval, metric, setMetric, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const reports = [
{
id: 'Visitors',
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
displayName: 'Visitors',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Visitors',
range,
previous,
metric: 'sum',
},
{
id: 'Sessions',
projectId,
startDate,
endDate,
events: [
{
segment: 'session',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
displayName: 'Sessions',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Sessions',
range,
previous,
metric: 'sum',
},
{
id: 'Pageviews',
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'screen_view',
displayName: 'Pageviews',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Pageviews',
range,
previous,
metric: 'sum',
},
{
id: 'Views per session',
projectId,
startDate,
endDate,
events: [
{
segment: 'user_average',
filters,
id: 'A',
name: 'screen_view',
displayName: 'Views per session',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Views per session',
range,
previous,
metric: 'average',
},
{
id: 'Bounce rate',
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
{
id: '1',
name: 'properties.__bounce',
operator: 'is',
value: ['true'],
},
...filters,
],
id: 'A',
name: 'session_end',
displayName: 'Bounce rate',
},
{
segment: 'event',
filters: filters,
id: 'B',
name: 'session_end',
displayName: 'Bounce rate',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Bounce rate',
range,
previous,
previousIndicatorInverted: true,
formula: 'A/B*100',
metric: 'average',
unit: '%',
},
{
id: 'Visit duration',
projectId,
startDate,
endDate,
events: [
{
segment: 'property_average',
filters: [
{
name: 'duration',
operator: 'isNot',
value: ['0'],
id: 'A',
},
...filters,
],
id: 'A',
property: 'duration',
name: isPageFilter ? 'screen_view' : 'session_end',
displayName: isPageFilter ? 'Time on page' : 'Visit duration',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Visit duration',
range,
previous,
formula: 'A/1000',
metric: 'average',
unit: 'min',
},
] satisfies (IChartInput & { id: string })[];
const selectedMetric = reports[metric]!;
return (
<>
<div className="grid grid-cols-6 col-span-6 gap-1">
{reports.map((report, index) => (
<button
key={index}
className={cn(
'relative col-span-3 md:col-span-2 lg:col-span-1 group transition-all scale-95',
index === metric && 'shadow-md rounded-xl scale-105 z-10'
)}
onClick={() => {
setMetric(index);
}}
>
<ChartSwitch hideID {...report} />
{/* add active border */}
</button>
))}
</div>
<Widget className="col-span-6">
<WidgetHead>
<div className="title">{selectedMetric.events[0]?.displayName}</div>
</WidgetHead>
<WidgetBody>
<ChartSwitch
key={selectedMetric.id}
hideID
{...selectedMetric}
chartType="linear"
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { api } from '@/app/_trpc/client';
import { pushModal } from '@/modals';
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import type { ShareOverview } from '@openpanel/db';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
interface OverviewShareProps {
data: ShareOverview | null;
}
export function OverviewShare({ data }: OverviewShareProps) {
const router = useRouter();
const mutation = api.share.shareOverview.useMutation({
onSuccess() {
router.refresh();
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={data && data.public ? Globe2Icon : LockIcon} responsive>
{data && data.public ? 'Public' : 'Private'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{(!data || data.public === false) && (
<DropdownMenuItem onClick={() => pushModal('ShareOverviewModal')}>
<Globe2Icon size={16} className="mr-2" />
Make public
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem asChild>
<Link
href={`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/share/overview/${data.id}`}
>
<EyeIcon size={16} className="mr-2" />
View
</Link>
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem
onClick={() => {
mutation.mutate({
public: false,
projectId: data?.project_id,
organizationId: data?.organization_slug,
password: null,
});
}}
>
<LockIcon size={16} className="mr-2" />
Make private
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,225 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopDevicesProps {
projectId: string;
}
export default function OverviewTopDevices({
projectId,
}: OverviewTopDevicesProps) {
const { interval, range, previous, startDate, endDate, chartType } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
title: 'Top devices',
btn: 'Devices',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'device',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
browser: {
title: 'Top browser',
btn: 'Browser',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
browser_version: {
title: 'Top Browser Version',
btn: 'Browser Version',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
os: {
title: 'Top OS',
btn: 'OS',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
os_version: {
title: 'Top OS version',
btn: 'OS Version',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'devices':
setFilter('device', item.name);
break;
case 'browser':
setFilter('browser', item.name);
break;
case 'browser_version':
setFilter('browser_version', item.name);
break;
case 'os':
setFilter('os', item.name);
break;
case 'os_version':
setFilter('os_version', item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,16 @@
import { getConversionEventNames } from '@openpanel/db';
import type { OverviewTopEventsProps } from './overview-top-events';
import OverviewTopEvents from './overview-top-events';
export default async function OverviewTopEventsServer({
projectId,
}: Omit<OverviewTopEventsProps, 'conversions'>) {
const eventNames = await getConversionEventNames(projectId);
return (
<OverviewTopEvents
projectId={projectId}
conversions={eventNames.map((item) => item.name)}
/>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { Button } from '@/components/ui/button';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { BarChartIcon, LineChart, LineChartIcon } from 'lucide-react';
import { Widget, WidgetBody } from '../../widget';
import { OverviewChartToggle } from '../overview-chart-toggle';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';
export interface OverviewTopEventsProps {
projectId: string;
conversions: string[];
}
export default function OverviewTopEvents({
projectId,
conversions,
}: OverviewTopEventsProps) {
const {
interval,
range,
previous,
startDate,
endDate,
chartType,
setChartType,
} = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
title: 'Top events',
btn: 'All',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: conversions.length === 0,
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions,
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch hideID {...widget.chart} previous={false} />
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopGeoProps {
projectId: string;
}
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const { interval, range, previous, startDate, endDate, chartType } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
countries: {
title: 'Top countries',
btn: 'Countries',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
regions: {
title: 'Top regions',
btn: 'Regions',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'region',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
cities: {
title: 'Top cities',
btn: 'Cities',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'city',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'countries':
setWidget('regions');
setFilter('country', item.name);
break;
case 'regions':
setWidget('cities');
setFilter('region', item.name);
break;
case 'cities':
setFilter('city', item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...{
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopPagesProps {
projectId: string;
}
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, previous, startDate, endDate, chartType } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
top: {
title: 'Top pages',
btn: 'Top pages',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'screen_view',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType,
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
entries: {
title: 'Entry Pages',
btn: 'Entries',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType,
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
exits: {
title: 'Exit Pages',
btn: 'Exits',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_end',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType,
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
setFilter('path', item.name);
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,327 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopSourcesProps {
projectId: string;
}
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { interval, range, previous, startDate, endDate, chartType } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
all: {
title: 'Top sources',
btn: 'All',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top groups',
range: range,
previous: previous,
metric: 'sum',
},
},
domain: {
title: 'Top urls',
btn: 'URLs',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
type: {
title: 'Top types',
btn: 'Types',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_type',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top types',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_source: {
title: 'UTM Source',
btn: 'Source',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_source',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_medium: {
title: 'UTM Medium',
btn: 'Medium',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_medium',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_campaign: {
title: 'UTM Campaign',
btn: 'Campaign',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_campaign',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_term: {
title: 'UTM Term',
btn: 'Term',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_term',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_content: {
title: 'UTM Content',
btn: 'Content',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_content',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'all':
setFilter('referrer_name', item.name);
setWidget('domain');
break;
case 'domain':
setFilter('referrer', item.name);
break;
case 'type':
setFilter('referrer_type', item.name);
setWidget('domain');
break;
case 'utm_source':
setFilter('properties.query.utm_source', item.name);
break;
case 'utm_medium':
setFilter('properties.query.utm_medium', item.name);
break;
case 'utm_campaign':
setFilter('properties.query.utm_campaign', item.name);
break;
case 'utm_term':
setFilter('properties.query.utm_term', item.name);
break;
case 'utm_content':
setFilter('properties.query.utm_content', item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { Children, useEffect, useRef, useState } from 'react';
import { useThrottle } from '@/hooks/useThrottle';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react';
import { last } from 'ramda';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import type { WidgetHeadProps } from '../widget';
import { WidgetHead as WidgetHeadBase } from '../widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (
<WidgetHeadBase
className={cn(
'flex flex-col p-0 [&_.title]:p-4 [&_.title]:flex [&_.title]:justify-between [&_.title]:items-center',
className
)}
{...props}
/>
);
}
export function WidgetButtons({
className,
children,
...props
}: WidgetHeadProps) {
const container = useRef<HTMLDivElement>(null);
const sizes = useRef<number[]>([]);
const [slice, setSlice] = useState(3); // Show 3 buttons by default
const gap = 16;
const handleResize = useThrottle(() => {
if (container.current) {
if (sizes.current.length === 0) {
// Get buttons
const buttons: HTMLButtonElement[] = Array.from(
container.current.querySelectorAll(`button`)
);
// Get sizes and cache them
sizes.current = buttons.map(
(button) => Math.ceil(button.offsetWidth) + gap
);
}
const containerWidth = container.current.offsetWidth;
const buttonsWidth = sizes.current.reduce((acc, size) => acc + size, 0);
const moreWidth = (last(sizes.current) ?? 0) + gap;
if (buttonsWidth > containerWidth) {
const res = sizes.current.reduce(
(acc, size, index) => {
if (acc.size + size + moreWidth > containerWidth) {
return { index: acc.index, size: acc.size + size };
}
return { index, size: acc.size + size };
},
{ index: 0, size: 0 }
);
setSlice(res.index);
} else {
setSlice(sizes.current.length - 1);
}
}
}, 30);
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize, children]);
const hidden = '!opacity-0 absolute pointer-events-none';
return (
<div
ref={container}
className={cn(
'px-4 self-stretch justify-start transition-opacity flex flex-wrap [&_button]:text-xs [&_button]:opacity-50 [&_button]:whitespace-nowrap [&_button.active]:opacity-100 [&_button.active]:border-b [&_button.active]:border-black [&_button]:py-1',
className
)}
style={{ gap }}
{...props}
>
{Children.map(children, (child, index) => {
return (
<div className={cn('flex', slice < index ? hidden : 'opacity-100')}>
{child}
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center gap-1 select-none',
sizes.current.length - 1 === slice ? hidden : 'opacity-50'
)}
>
More <ChevronsUpDownIcon size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="[&_button]:w-full">
<DropdownMenuGroup>
{Children.map(children, (child, index) => {
if (index <= slice) {
return null;
}
return <DropdownMenuItem asChild>{child}</DropdownMenuItem>;
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

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