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

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

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

20
apps/start/.cta.json Normal file
View File

@@ -0,0 +1,20 @@
{
"projectName": "start",
"mode": "file-router",
"typescript": true,
"tailwind": true,
"packageManager": "pnpm",
"git": true,
"version": 1,
"framework": "react-cra",
"chosenAddOns": [
"biome",
"start",
"tRPC",
"shadcn",
"sentry",
"tanstack-query",
"store",
"table"
]
}

337
apps/start/.cursorrules Normal file
View File

@@ -0,0 +1,337 @@
We use Sentry for watching for errors in our deployed application, as well as for instrumentation of our application.
## Error collection
Error collection is automatic and configured in `src/router.tsx`.
## Instrumentation
We want our server functions instrumented. So if you see a function name like `createServerFn`, you can instrument it with Sentry. You'll need to import `Sentry`:
```tsx
import * as Sentry from '@sentry/tanstackstart-react'
```
And then wrap the implementation of the server function with `Sentry.startSpan`, like so:
```tsx
Sentry.startSpan({ name: 'Requesting all the pokemon' }, async () => {
// Some lengthy operation here
await fetch('https://api.pokemon.com/data/')
})
```
# shadcn instructions
Use the latest version of Shadcn to install new components, like this command to add a button component:
```bash
pnpx shadcn@latest add button
```
# TanStack Router v1 — Routing Concepts (React) Cheat Sheet
> Quick, copypastable reference for filebased routing in TanStack Router v1 (React).
## TL;DR
* **Root route** is always matched; it wraps everything.
* **createFileRoute(path)** defines a route; the path is automanaged by the bundler/CLI.
* **Index routes** use a **trailing slash** (`/posts/`) to target the exact parent path.
* **Dynamic segments** use `$param` (e.g. `/posts/$postId`).
* **Splat (catchall)** route is a lone `$` segment, exposing `params._splat`.
* **Optional segments** use `{-$param}`.
* **Layout routes** are normal routes that render an `<Outlet/>` for children.
* **Pathless layout routes** start with `_` (e.g. `/_settingsShell`), wrap children but dont match URL segments.
* **Nonnested** routes suffix a segment with `_` to un-nest (e.g. `posts_.$postId.edit.tsx`).
* Prefix files/folders with `-` to **exclude** them from routing (colocation).
* Use `(group)` folders to **group** files only (no path impact).
---
## 1) Anatomy of a Route
```tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: PostsComponent,
})
```
* `createFileRoute(path)` → declare a route for this file.
* Path string is autowritten/maintained by the bundler/CLI for type safety.
### Root Route
```tsx
import { createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute()
```
* No path; always matched; can host loaders, components, search param validation, etc.
**With context**:
```tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
export interface MyRouterContext { queryClient: QueryClient }
export const Route = createRootRouteWithContext<MyRouterContext>()
```
---
## 2) Basic Routes
```tsx
// routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: () => <div>About</div>,
})
```
* Exact path match. Renders `component`.
### Index Routes (trailing slash)
```tsx
// routes/posts.index.tsx OR routes/posts/ index file
export const Route = createFileRoute('/posts/')({
component: () => <div>Please select a post!</div>,
})
```
* Matches **exactly** `/posts`. The **trailing `/`** denotes an index.
---
## 3) Dynamic Segments
```tsx
// routes/posts.$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params }) => fetchPost(params.postId),
component: PostComponent,
})
function PostComponent() {
const { postId } = Route.useParams()
return <div>Post ID: {postId}</div>
}
```
* `$postId` captures `123` for `/posts/123` → `{ postId: '123' }`.
* You can chain: `/posts/$postId/$revisionId` → `{ postId, revisionId }`.
### Splat / CatchAll
```tsx
// routes/files/$.tsx
export const Route = createFileRoute('/files/$')({ component: Files })
```
* Captures the rest of the path into `params._splat` (e.g. `documents/hello-world`).
### Optional Parameters
```tsx
// routes/posts.{-$category}.tsx
export const Route = createFileRoute('/posts/{-$category}')({ component: Posts })
function Posts() {
const { category } = Route.useParams()
return <div>{category ? `Posts in ${category}` : 'All Posts'}</div>
}
```
* Matches `/posts` and `/posts/tech`.
* **Priority note:** routes with optional params are ranked lower than exact matches (e.g. `/posts/featured` beats `/posts/{-$category}`).
---
## 4) Layout & Pathless Layout Routes
### Layout Routes
```tsx
// routes/app.tsx
import { Outlet, createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/app')({ component: AppLayout })
function AppLayout() {
return (
<div>
<h1>App</h1>
<Outlet />
</div>
)
}
```
**Rendering matrix**
* `/app` → `<AppLayout>`
* `/app/dashboard` → `<AppLayout><Dashboard/>`
* `/app/settings` → `<AppLayout><Settings/>`
### Pathless Layout Routes (no URL segment)
```tsx
// routes/_shell.tsx
import { Outlet, createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_shell')({ component: Shell })
function Shell() {
return (
<div>
<Header/>
<Outlet />
</div>
)
}
```
* File/folder name starts with `_` → doesnt match a URL segment; wraps children.
* **Cannot** include dynamic `$param` in the pathless segment name. Use a real `$param` directory **beside** your `_shell` if needed:
```
routes/
├─ $postId/
├─ _postShell/
```
---
## 5) NonNested Routes ("un-nesting")
Append `_` after a segment name to un-nest a file so it renders its own tree:
```
routes/
├─ posts.tsx
├─ posts.$postId.tsx
├─ posts_.$postId.edit.tsx // un-nested
```
**Resulting renders**
* `/posts` → `<Posts>`
* `/posts/123` → `<Posts><Post id=123>`
* `/posts/123/edit` → `<PostEditor id=123>` (not wrapped by `<Posts>`)
---
## 6) Excluding Files & Colocation
Prefix with `-` to exclude from route generation and safely colocate utilities:
```
routes/
├─ posts.tsx
├─ -posts-table.tsx // ignored
├─ -components/ // ignored
│ ├─ header.tsx // ignored
│ └─ footer.tsx // ignored
```
```tsx
import { PostsTable } from './-posts-table'
```
* Excluded entries are not added to the generated `routeTree.gen.ts`.
---
## 7) Pathless Route Group Directories
Group routes for organization only using `(group)` folders — no effect on paths or nesting:
```
routes/
├─ index.tsx
├─ (app)/
│ ├─ dashboard.tsx
│ ├─ settings.tsx
│ └─ users.tsx
├─ (auth)/
│ ├─ login.tsx
│ └─ register.tsx
```
**URLs render as** `/dashboard`, `/settings`, `/users`, `/login`, `/register`.
---
## 8) Useful APIs & Hooks
* `Route.useParams()` — typed path params for the current file route.
* `Route.useLoaderData()` — typed data returned from the current routes `loader`.
* `<Outlet/>` — placeholder for child routes.
* (See docs for: search param validation, data loading, mutations, type safety, preloading, SSR, etc.)
---
## 9) Common File Naming Patterns (filebased routing)
* `__root.tsx` — root route file.
* `index.tsx` or trailing `/` in `createFileRoute` — index under a segment.
* `$param.tsx` — dynamic segment.
* `$.tsx` — splat (catchall) segment.
* `{-$param}.tsx` — optional segment.
* `_segment.tsx` or `/_segment/route.tsx` — pathless layout route.
* `segment_.tsx` — un-nest that segment from parents.
* `-utils.tsx`, `-components/` — excluded from routing.
* `(group)/` — pathless grouping-only folder.
---
## 10) Quick Reference Examples
```tsx
// Simple loader + params + component
export const Route = createFileRoute('/users/$userId')({
loader: ({ params }) => getUser(params.userId),
component: () => {
const { userId } = Route.useParams()
const user = Route.useLoaderData()
return <UserCard id={userId} data={user} />
},
})
```
```tsx
// Layout
export const Route = createFileRoute('/dashboard')({ component: Layout })
function Layout() {
return <>
<Sidebar />
<Outlet />
</>
}
```
```tsx
// Optional + Splat combo (illustrative)
export const Route = createFileRoute('/docs/{-$section}/$')({ component: Docs })
```
---
## 11) Gotchas & Tips
* Prefer **filebased** routing for less boilerplate and excellent type safety.
* Remember the **trailing slash** for index routes.
* Optional routes are **lower priority** than exact matches.
* Pathless layout routes **cannot** be dynamic.
* Use `-` prefix liberally to colocate nonroute code near routes.
* Use `(group)` folders to tame big route directories.
---
### Further Reading
* Route Trees, Route Matching, FileBased Routing, Outlets, Path/Search Params, Data Loading, SSR, etc.
Happy routing! 🚦

12
apps/start/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
count.txt
.env
.nitro
.tanstack
.output
.vinxi
todos.json

41
apps/start/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,41 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit"
},
"typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.suggest.autoImports": true,
"typescript.validate.enable": true,
"typescript.preferences.strictNullChecks": true,
"typescript.preferences.noUnusedLocals": true,
"typescript.preferences.noUnusedParameters": true
}

310
apps/start/README.md Normal file
View File

@@ -0,0 +1,310 @@
Welcome to your new TanStack app!
# Getting Started
To run this application:
```bash
pnpm install
pnpm start
```
# Building For Production
To build this application for production:
```bash
pnpm build
```
## Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
```bash
pnpm test
```
## Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
## Linting & Formatting
This project uses [Biome](https://biomejs.dev/) for linting and formatting. The following scripts are available:
```bash
pnpm lint
pnpm format
pnpm check
```
## Shadcn
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
```bash
pnpx shadcn@latest add button
```
## Routing
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
### Adding A Route
To add a new route to your application just add another a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `<Outlet />` component.
Here is an example layout that includes a header:
```tsx
import { Outlet, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { Link } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => (
<>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
<Outlet />
<TanStackRouterDevtools />
</>
),
})
```
The `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout.
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
const peopleRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/people",
loader: async () => {
const response = await fetch("https://swapi.dev/api/people");
return response.json() as Promise<{
results: {
name: string;
}[];
}>;
},
component: () => {
const data = peopleRoute.useLoaderData();
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
);
},
});
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
### React-Query
React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze.
First add your dependencies:
```bash
pnpm add @tanstack/react-query @tanstack/react-query-devtools
```
Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// ...
const queryClient = new QueryClient();
// ...
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
```
You can also add TanStack Query Devtools to the root route (optional).
```tsx
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const rootRoute = createRootRoute({
component: () => (
<>
<Outlet />
<ReactQueryDevtools buttonPosition="top-right" />
<TanStackRouterDevtools />
</>
),
});
```
Now you can use `useQuery` to fetch your data.
```tsx
import { useQuery } from "@tanstack/react-query";
import "./App.css";
function App() {
const { data } = useQuery({
queryKey: ["people"],
queryFn: () =>
fetch("https://swapi.dev/api/people")
.then((res) => res.json())
.then((data) => data.results as { name: string }[]),
initialData: [],
});
return (
<div>
<ul>
{data.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
</div>
);
}
export default App;
```
You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
## State Management
Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project.
First you need to add TanStack Store as a dependency:
```bash
pnpm add @tanstack/store
```
Now let's create a simple counter in the `src/App.tsx` file as a demonstration.
```tsx
import { useStore } from "@tanstack/react-store";
import { Store } from "@tanstack/store";
import "./App.css";
const countStore = new Store(0);
function App() {
const count = useStore(countStore);
return (
<div>
<button onClick={() => countStore.setState((n) => n + 1)}>
Increment - {count}
</button>
</div>
);
}
export default App;
```
One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.
Let's check this out by doubling the count using derived state.
```tsx
import { useStore } from "@tanstack/react-store";
import { Store, Derived } from "@tanstack/store";
import "./App.css";
const countStore = new Store(0);
const doubledStore = new Derived({
fn: () => countStore.state * 2,
deps: [countStore],
});
doubledStore.mount();
function App() {
const count = useStore(countStore);
const doubledCount = useStore(doubledStore);
return (
<div>
<button onClick={() => countStore.setState((n) => n + 1)}>
Increment - {count}
</button>
<div>Doubled - {doubledCount}</div>
</div>
);
}
export default App;
```
We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.
Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook.
You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).

118
apps/start/ROUTING.md Normal file
View File

@@ -0,0 +1,118 @@
# Routing Structure
This SaaS application uses TanStack Router with pathless routes (using underscore prefix) for better organization and shared layouts.
## Route Structure
### Landing Page
- **Route**: `/`
- **File**: `src/routes/index.tsx`
- **Purpose**: Landing page that redirects to latest organization or shows all organizations
### Authentication Routes (Pathless Layout: `_auth`)
- **Layout**: `src/routes/_auth.tsx` - Shared layout for auth pages
- **Login**: `/login` - `src/routes/_auth.login.tsx`
- **Onboarding**: `/onboarding` - `src/routes/_auth.onboarding.tsx`
- **Index**: `/onboarding/` - `src/routes/_auth.onboarding.index.tsx`
- **Step 1**: `/onboarding/step1` - `src/routes/_auth.onboarding.step1.tsx`
### App Routes (Pathless Layout: `_app`)
- **Layout**: `src/routes/_app.tsx` - Shared layout with sidebar for app pages
- **Organization**: `/$organizationId` - `src/routes/_app.$organizationId.tsx`
- **Project**: `/$organizationId/$projectId` - `src/routes/_app.$organizationId.$projectId.tsx`
## Pathless Routes
### `_auth` Layout
- **Purpose**: Groups authentication and onboarding routes
- **Shared Layout**: Clean, centered layout with white background
- **Routes**: `/login`, `/onboarding/*`
### `_app` Layout
- **Purpose**: Groups organization and project routes
- **Shared Layout**: Sidebar + main content layout
- **Routes**: `/$organizationId`, `/$organizationId/$projectId`
## Landing Page Behavior
The landing page (`/`) intelligently handles user state:
1. **If user has a latest organization**: Shows "Continue where you left off" button
2. **If no latest organization**: Shows login/onboarding options
3. **Always shows**: List of all user's organizations
## Sidebar Behavior
The sidebar in the `(app)` layout dynamically changes based on the route:
- **Organization level** (`/$organizationId`): Shows projects list
- **Project level** (`/$organizationId/$projectId`): Shows dashboard navigation
## Adding New Routes
### For Auth Routes
Create new files with the pattern `src/routes/_auth.your-route.tsx`:
```typescript
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_auth/your-route')({
component: YourComponent,
});
```
### For App Routes
Create new files with the pattern `src/routes/_app.your-route.tsx`:
```typescript
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/your-route')({
component: YourComponent,
});
```
### For Onboarding Nested Routes
Create new files with the pattern `src/routes/_auth.onboarding.your-step.tsx`:
```typescript
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_auth/onboarding/your-step')({
component: YourComponent,
});
```
## Route Parameters
- `$organizationId`: Dynamic organization identifier
- `$projectId`: Dynamic project identifier
These parameters are automatically available in the route components via the `useParams()` hook.
## Example Usage
```typescript
import { useParams } from '@tanstack/react-router';
function ProjectDashboard() {
const { organizationId, projectId } = useParams({
from: '/_app/$organizationId/$projectId'
});
// Use organizationId and projectId
}
```
## File Structure
```
src/routes/
├── __root.tsx # Root layout
├── index.tsx # Landing page
├── _auth.tsx # Auth layout (pathless)
├── _auth.login.tsx # Login page
├── _auth.onboarding.tsx # Onboarding layout
├── _auth.onboarding.index.tsx # Onboarding main page
├── _auth.onboarding.step1.tsx # Onboarding step 1
├── _app.tsx # App layout (pathless)
├── _app.$organizationId.tsx # Organization projects list
└── _app.$organizationId.$projectId.tsx # Project dashboard
```

31
apps/start/biome.json Normal file
View File

@@ -0,0 +1,31 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": ["src/routeTree.gen.ts"],
"include": ["src/*", ".vscode/*", "index.html", "vite.config.js"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

174
apps/start/package.json Normal file
View File

@@ -0,0 +1,174 @@
{
"name": "start",
"private": true,
"type": "module",
"scripts": {
"dev": "pnpm with-env vite dev --port 3000",
"start_deprecated": "pnpm with-env node .output/server/index.mjs",
"preview": "vite preview",
"deploy": "npx wrangler deploy",
"cf-typegen": "wrangler types",
"build": "vite build",
"serve": "vite preview",
"test": "vitest run",
"format": "biome format",
"lint": "biome lint",
"check": "biome check",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"@ai-sdk/react": "^1.2.5",
"@clickhouse/client": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "^9.6.0",
"@hookform/resolvers": "^3.3.4",
"@hyperdx/node-opentelemetry": "^0.8.1",
"@number-flow/react": "0.3.5",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/integrations": "workspace:^",
"@openpanel/json": "workspace:*",
"@openpanel/payments": "workspace:*",
"@openpanel/sdk-info": "workspace:^",
"@openpanel/validation": "workspace:^",
"@openpanel/web": "^1.0.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-portal": "^1.1.9",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "1.2.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/tanstackstart-react": "^9.12.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-devtools": "^0.7.6",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-router": "^1.132.47",
"@tanstack/react-router-devtools": "^1.132.51",
"@tanstack/react-router-ssr-query": "^1.132.47",
"@tanstack/react-start": "^1.132.56",
"@tanstack/react-store": "^0.8.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/router-plugin": "^1.132.56",
"@tanstack/store": "^0.8.0",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"@trpc/tanstack-react-query": "^11.6.0",
"@types/d3": "^7.4.3",
"ai": "^4.2.10",
"bind-event-listener": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"d3": "^7.8.5",
"date-fns": "^3.3.1",
"debounce": "^2.2.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "8.0.0-rc22",
"flag-icons": "^7.1.0",
"framer-motion": "^11.0.28",
"hamburger-react": "^2.5.0",
"input-otp": "^1.2.4",
"javascript-time-ago": "^2.5.9",
"katex": "^0.16.21",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.476.0",
"mathjs": "^12.3.2",
"mitt": "^3.0.1",
"nuqs": "^2.5.2",
"prisma-error-enum": "^0.1.3",
"pushmodal": "^1.0.3",
"ramda": "^0.29.1",
"random-animal-name": "^0.1.1",
"rc-virtual-list": "^3.14.5",
"react": "catalog:",
"react-animate-height": "^3.2.3",
"react-animated-numbers": "^1.1.1",
"react-day-picker": "^9.9.0",
"react-dom": "catalog:",
"react-grid-layout": "^1.5.2",
"react-hook-form": "^7.50.1",
"react-in-viewport": "1.0.0-beta.8",
"react-markdown": "^10.1.0",
"react-redux": "^8.1.3",
"react-resizable": "^3.0.5",
"react-responsive": "^9.0.2",
"react-simple-maps": "3.0.0",
"react-svg-worldmap": "2.0.0-alpha.16",
"react-syntax-highlighter": "^15.5.0",
"react-use-websocket": "^4.7.0",
"react-virtualized-auto-sizer": "^1.0.22",
"recharts": "^2.15.4",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-highlight": "^0.1.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"short-unique-id": "^5.0.3",
"slugify": "^1.6.6",
"sonner": "^1.4.0",
"sqlstring": "^2.3.3",
"superjson": "^2.2.2",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.6",
"usehooks-ts": "^2.14.0",
"vite-tsconfig-paths": "^5.1.4",
"zod": "catalog:"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@cloudflare/vite-plugin": "^1.13.12",
"@openpanel/db": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@tanstack/devtools-event-client": "^0.3.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",
"@types/lodash.throttle": "^4.1.9",
"@types/ramda": "^0.31.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react-simple-maps": "^3.0.4",
"@types/react-syntax-highlighter": "^15.5.11",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"typescript": "catalog:",
"vite": "^6.3.5",
"vitest": "^3.0.5",
"web-vitals": "^4.2.4",
"wrangler": "^4.42.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
apps/start/public/img-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
apps/start/public/img-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
apps/start/public/img-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
apps/start/public/img-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
apps/start/public/img-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
apps/start/public/img-6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,6 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1000" height="1000" rx="100" fill="#2564EB"/>
<rect x="548.075" y="287.946" width="129.343" height="427.822" rx="64.6715" fill="white"/>
<rect x="747.064" y="287.946" width="129.343" height="218.886" rx="64.6715" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M300.392 283.344C202.279 283.344 122.742 362.881 122.742 460.994V533.594C122.742 631.708 202.279 711.244 300.392 711.244C398.506 711.244 478.042 631.708 478.042 533.594V460.994C478.042 362.881 398.506 283.344 300.392 283.344ZM300.714 387.844C264.997 387.844 236.042 416.799 236.042 452.516V542.058C236.042 577.775 264.997 606.73 300.714 606.73C336.431 606.73 365.385 577.776 365.385 542.058V452.516C365.385 416.799 336.431 387.844 300.714 387.844Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,10 @@
// import * as Sentry from '@sentry/tanstackstart-react';
// import { createMiddleware } from '@tanstack/react-start';
// registerGlobalMiddleware({
// middleware: [
// createMiddleware({
// type: 'function',
// }).server(Sentry.sentryGlobalServerMiddlewareHandler()),
// ],
// });

View File

@@ -0,0 +1,21 @@
import ReactAnimateHeight from 'react-animate-height';
type Props = {
children: React.ReactNode;
className?: string;
open: boolean;
};
const AnimateHeight = ({ children, className, open }: Props) => {
return (
<ReactAnimateHeight
duration={300}
height={open ? 'auto' : 0}
className={className}
>
{children}
</ReactAnimateHeight>
);
};
export default AnimateHeight;

View File

@@ -0,0 +1,20 @@
import type { NumberFlowProps } from '@number-flow/react';
import { useEffect, useState } from 'react';
// NumberFlow is breaking ssr and forces loaders to fetch twice
export function AnimatedNumber(props: NumberFlowProps) {
const [Component, setComponent] =
useState<React.ComponentType<NumberFlowProps> | null>(null);
useEffect(() => {
import('@number-flow/react').then(({ default: NumberFlow }) => {
setComponent(NumberFlow);
});
}, []);
if (!Component) {
return <>{props.value}</>;
}
return <Component {...props} />;
}

View File

@@ -0,0 +1,11 @@
import { cn } from '@/utils/cn';
export function Or({ className }: { className?: string }) {
return (
<div className={cn('row items-center gap-4', className)}>
<div className="h-px w-full bg-def-300" />
<span className="text-muted-foreground text-sm font-medium px-2">OR</span>
<div className="h-px w-full bg-def-300" />
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { zResetPassword } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { InputWithLabel } from '../forms/input-with-label';
import { Button } from '../ui/button';
const validator = zResetPassword;
type IForm = z.infer<typeof validator>;
export function ResetPasswordForm({ token }: { token: string }) {
const navigate = useNavigate();
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.resetPassword.mutationOptions({
onSuccess() {
toast.success('Password reset successfully');
navigate({
to: '/login',
});
},
onError(error) {
toast.error(error.message);
},
}),
);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
token: token ?? '',
password: '',
},
});
const onSubmit = form.handleSubmit(async (data) => {
mutation.mutate(data);
});
return (
<div className="col gap-8">
<div>
<h1 className="text-3xl font-bold text-foreground mb-2">
Reset your password
</h1>
<p className="text-muted-foreground">
Already have an account?{' '}
<a href="/login" className="underline">
Sign in
</a>
</p>
</div>
<form onSubmit={onSubmit} className="col gap-6">
<InputWithLabel
label="New password"
placeholder="New password"
type="password"
{...form.register('password')}
/>
<Button type="submit">Reset password</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { type ISignInShare, zSignInShare } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { LogoSquare } from '../logo';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
export function ShareEnterPassword({ shareId }: { shareId: string }) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInShare.mutationOptions({
onSuccess() {},
onError() {
toast.error('Incorrect password');
},
}),
);
const form = useForm<ISignInShare>({
resolver: zodResolver(zSignInShare),
defaultValues: {
password: '',
shareId,
},
});
const onSubmit = form.handleSubmit((data) => {
mutation.mutate({
password: data.password,
shareId,
});
});
return (
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">Overview is locked</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this overview
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">
<Input
{...form.register('password')}
type="password"
placeholder="Enter your password"
size="large"
/>
<Button type="submit">Get access</Button>
</form>
</div>
<div className="p-6 text-sm max-w-sm col gap-0.5">
<p>
Powered by{' '}
<a href="https://openpanel.dev" className="font-medium">
OpenPanel.dev
</a>
</p>
<p>
The best web and product analytics tool out there (our honest
opinion).
</p>
<p>
<a href="https://dashboard.openpanel.dev/onboarding">
Try it for free today!
</a>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { zodResolver } from '@hookform/resolvers/zod';
import { zSignInEmail } from '@openpanel/validation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useRouter } from '@tanstack/react-router';
import { type SubmitHandler, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { InputWithLabel } from '../forms/input-with-label';
import { Button } from '../ui/button';
const validator = zSignInEmail;
type IForm = z.infer<typeof validator>;
export function SignInEmailForm() {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInEmail.mutationOptions({
async onSuccess() {
toast.success('Successfully signed in');
window.location.href = '/';
},
onError(error) {
toast.error(error.message);
},
}),
);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
email: 'lindesvard+22@gmail.com',
password: 'demodemo',
},
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
...values,
});
};
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4">
<InputWithLabel
{...form.register('email')}
error={form.formState.errors.email?.message}
label="Email"
className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20"
/>
<InputWithLabel
{...form.register('password')}
error={form.formState.errors.password?.message}
label="Password"
type="password"
className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20"
/>
<Button type="submit" size="lg">
Sign in
</Button>
<button
type="button"
onClick={() =>
pushModal('RequestPasswordReset', {
email: form.getValues('email'),
})
}
className="text-sm text-muted-foreground hover:text-highlight hover:underline transition-colors duration-200 text-center mt-2"
>
Forgot password?
</button>
</form>
);
}

View File

@@ -0,0 +1,47 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation } from '@tanstack/react-query';
import { Button } from '../ui/button';
export function SignInGithub({
type,
inviteId,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInOAuth.mutationOptions({
onSuccess(res) {
if (res.url) {
window.location.href = res.url;
}
},
}),
);
const title = () => {
if (type === 'sign-in') return 'Sign in with Github';
if (type === 'sign-up') return 'Sign up with Github';
};
return (
<Button
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0"
size="lg"
onClick={() =>
mutation.mutate({
provider: 'github',
inviteId: type === 'sign-up' ? inviteId : undefined,
})
}
>
<svg
className="size-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{title()}
</Button>
);
}

View File

@@ -0,0 +1,59 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation } from '@tanstack/react-query';
import { Button } from '../ui/button';
export function SignInGoogle({
type,
inviteId,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInOAuth.mutationOptions({
onSuccess(res) {
if (res.url) {
window.location.href = res.url;
}
},
}),
);
const title = () => {
if (type === 'sign-in') return 'Sign in with Google';
if (type === 'sign-up') return 'Sign up with Google';
};
return (
<Button
className="w-full bg-background hover:bg-def-100 border border-def-300 text-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0"
size="lg"
onClick={() =>
mutation.mutate({
provider: 'google',
inviteId: type === 'sign-up' ? inviteId : undefined,
})
}
>
<svg
className="size-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
{title()}
</Button>
);
}

View File

@@ -0,0 +1,87 @@
import { useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { zSignUpEmail } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { type SubmitHandler, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { InputWithLabel } from '../forms/input-with-label';
import { Button } from '../ui/button';
const validator = zSignUpEmail;
type IForm = z.infer<typeof validator>;
export function SignUpEmailForm({
inviteId,
}: { inviteId: string | undefined }) {
const router = useRouter();
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signUpEmail.mutationOptions({
onSuccess() {
toast.success('Successfully signed up');
window.location.href = '/onboarding/project';
},
onError(error) {
toast.error(error.message);
},
}),
);
const form = useForm<IForm>({
resolver: zodResolver(validator),
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
...values,
inviteId,
});
};
return (
<form className="col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<div className="row gap-4 w-full flex-1">
<InputWithLabel
label="First name"
className="flex-1"
type="text"
{...form.register('firstName')}
error={form.formState.errors.firstName?.message}
/>
<InputWithLabel
label="Last name"
className="flex-1"
type="text"
{...form.register('lastName')}
error={form.formState.errors.lastName?.message}
/>
</div>
<InputWithLabel
label="Email"
className="w-full"
type="email"
{...form.register('email')}
error={form.formState.errors.email?.message}
/>
<div className="row gap-4 w-full">
<InputWithLabel
label="Password"
className="flex-1"
type="password"
{...form.register('password')}
error={form.formState.errors.password?.message}
/>
<InputWithLabel
label="Confirm password"
className="flex-1"
type="password"
{...form.register('confirmPassword')}
error={form.formState.errors.confirmPassword?.message}
/>
</div>
<Button type="submit" className="w-full" size="lg">
Create account
</Button>
</form>
);
}

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('mt-6 flex justify-between', className)} {...props} />
);
}

View File

@@ -0,0 +1,48 @@
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:-translate-y-0.5',
className,
)}
>
{children}
</div>
);
}
interface CardActionsProps {
children: React.ReactNode;
}
export function CardActions({ children }: CardActionsProps) {
return (
<div className="absolute right-2 top-2 z-10">
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>{children}</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export const CardActionsItem = DropdownMenuItem;

View File

@@ -0,0 +1,112 @@
import * as d3 from 'd3';
export function ChartSSR({
data,
dots = false,
color = 'blue',
}: {
dots?: boolean;
color?: 'blue' | 'green' | 'red';
data: { value: number; date: Date }[];
}) {
if (data.length === 0) {
return null;
}
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 area = d3
.area<(typeof data)[number]>()
.curve(d3.curveMonotoneX)
.x((d) => xScale(d.date))
.y0(yScale(0))
.y1((d) => yScale(d.value));
const pathLine = line(data);
const pathArea = area(data);
if (!pathLine) {
return null;
}
const gradientId = `gradient-${color}`;
return (
<div className="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"
>
<defs>
<linearGradient
id={gradientId}
x1="0"
y1="0"
x2="0"
y2="100%"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="50%" stopColor={color} stopOpacity={0.05} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{/* Gradient area */}
{pathArea && (
<path
d={pathArea}
fill={`url(#${gradientId})`}
vectorEffect="non-scaling-stroke"
/>
)}
{/* Line */}
<path
d={pathLine}
fill="none"
className={
color === 'green'
? 'text-green-600'
: color === 'red'
? 'text-red-600'
: 'text-highlight'
}
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,59 @@
import { createContext, useContext as useBaseContext } from 'react';
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
export function createChartTooltip<
PropsFromTooltip extends Record<string, unknown>,
PropsFromContext extends Record<string, unknown>,
>(
Tooltip: React.ComponentType<
{
context: PropsFromContext;
data: PropsFromTooltip[];
} & TooltipProps<number, string>
>,
) {
const context = createContext<PropsFromContext | null>(null);
const useContext = () => {
const value = useBaseContext(context);
if (!value) {
throw new Error('ChartTooltip context not found');
}
return value;
};
const InnerTooltip = (tooltip: TooltipProps<number, string>) => {
const context = useContext();
const data = tooltip.payload?.map((p) => p.payload) ?? [];
if (!data || !tooltip.active) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
<Tooltip data={data} context={context} {...tooltip} />
</div>
);
};
return {
TooltipProvider: ({
children,
...value
}: {
children: React.ReactNode;
} & PropsFromContext) => {
return (
<context.Provider value={value as unknown as PropsFromContext}>
{children}
</context.Provider>
);
},
Tooltip: (props: TooltipProps<number, string>) => {
return (
<RechartsTooltip {...props} content={<InnerTooltip {...props} />} />
);
},
};
}

View File

@@ -0,0 +1,47 @@
import { Bar } from 'recharts';
type Options = {
borderHeight: number;
border: string;
fill: string;
active: { border: string; fill: string };
};
export const BarWithBorder = (options: Options) => {
return (props: any) => {
const { x, y, width, height, value, isActive } = props;
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
stroke="none"
fill={isActive ? options.active.fill : options.fill}
/>
{value > 0 && (
<rect
x={x}
y={y - options.borderHeight - 2}
width={width}
height={options.borderHeight}
stroke="none"
fill={isActive ? options.active.border : options.border}
/>
)}
</g>
);
};
};
export const BarShapeBlue = BarWithBorder({
borderHeight: 2,
border: 'rgba(59, 121, 255, 1)',
fill: 'rgba(59, 121, 255, 0.3)',
active: {
border: 'rgba(59, 121, 255, 1)',
fill: 'rgba(59, 121, 255, 0.4)',
},
});

View File

@@ -0,0 +1,70 @@
import { cn } from '@/utils/cn';
import type { useChat } from '@ai-sdk/react';
import { useLocalStorage } from 'usehooks-ts';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
type Props = Pick<
ReturnType<typeof useChat>,
'handleSubmit' | 'handleInputChange' | 'input' | 'append'
> & {
projectId: string;
isLimited: boolean;
};
export function ChatForm({
handleSubmit: handleSubmitProp,
input,
handleInputChange,
append,
projectId,
isLimited,
}: Props) {
const [quickActions, setQuickActions] = useLocalStorage<string[]>(
`chat-quick-actions:${projectId}`,
[],
);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
handleSubmitProp(e);
setQuickActions([input, ...quickActions].slice(0, 5));
};
return (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-def-100 to-def-100/50 backdrop-blur-sm z-20">
<ScrollArea orientation="horizontal">
<div className="row gap-2 px-4">
{quickActions.map((q) => (
<Button
disabled={isLimited}
key={q}
type="button"
variant="outline"
size="sm"
onClick={() => {
append({
role: 'user',
content: q,
});
}}
>
{q}
</Button>
))}
</div>
</ScrollArea>
<form onSubmit={handleSubmit} className="p-4 pt-2">
<input
disabled={isLimited}
className={cn(
'w-full h-12 px-4 outline-none border border-border text-foreground rounded-md font-mono placeholder:text-foreground/50 bg-background/50',
isLimited && 'opacity-50 cursor-not-allowed',
)}
value={input}
placeholder="Ask me anything..."
onChange={handleInputChange}
/>
</form>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { Markdown } from '@/components/markdown';
import { cn } from '@/utils/cn';
import { zChartInputAI } from '@openpanel/validation';
import type { UIMessage } from 'ai';
import { Loader2Icon, UserIcon } from 'lucide-react';
import { Fragment, memo } from 'react';
import { Card } from '../card';
import { LogoSquare } from '../logo';
import { Skeleton } from '../skeleton';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
import { ChatReport } from './chat-report';
export const ChatMessage = memo(
({
message,
isLast,
isStreaming,
debug,
}: {
message: UIMessage;
isLast: boolean;
isStreaming: boolean;
debug: boolean;
}) => {
const showIsStreaming = isLast && isStreaming;
return (
<div className="max-w-xl w-full">
<div className="row">
<div className="w-8 shrink-0">
<div className="size-6 relative">
{message.role === 'assistant' ? (
<LogoSquare className="size-full rounded-full" />
) : (
<div className="size-full bg-black text-white rounded-full center-center">
<UserIcon className="size-4" />
</div>
)}
<div
className={cn(
'absolute inset-0 bg-background rounded-full center-center opacity-0',
showIsStreaming && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin text-foreground" />
</div>
</div>
</div>
<div className="col gap-4 flex-1">
{message.parts.map((p, index) => {
const key = index.toString() + p.type;
const isToolInvocation = p.type === 'tool-invocation';
if (p.type === 'step-start') {
return null;
}
if (!isToolInvocation && p.type !== 'text') {
return <Debug enabled={debug} json={p} />;
}
if (p.type === 'text') {
return (
<div className="prose dark:prose-invert prose-sm" key={key}>
<Markdown>{p.text}</Markdown>
</div>
);
}
if (isToolInvocation && p.toolInvocation.state === 'result') {
const { result } = p.toolInvocation;
if (result.type === 'report') {
const report = zChartInputAI.safeParse(result.report);
if (report.success) {
return (
<Fragment key={key}>
<Debug json={result} enabled={debug} />
<ChatReport report={report.data} lazy={!isLast} />
</Fragment>
);
}
}
return (
<Debug
key={key}
json={p.toolInvocation.result}
enabled={debug}
/>
);
}
return null;
})}
{showIsStreaming && (
<div className="w-full col gap-2">
<Skeleton className="w-3/5 h-4" />
<Skeleton className="w-4/5 h-4" />
<Skeleton className="w-2/5 h-4" />
</div>
)}
</div>
</div>
{!isLast && (
<div className="w-full shrink-0 pl-8 mt-4">
<div className="h-px bg-border" />
</div>
)}
</div>
);
},
);
function Debug({ enabled, json }: { enabled?: boolean; json?: any }) {
if (!enabled) {
return null;
}
return (
<Accordion type="single" collapsible>
<Card>
<AccordionItem value={'json'}>
<AccordionTrigger className="text-left p-4 py-2 w-full font-medium font-mono row items-center">
<span className="flex-1">Show JSON result</span>
</AccordionTrigger>
<AccordionContent className="p-2">
<Syntax
wrapLines
language="json"
code={JSON.stringify(json, null, 2)}
/>
</AccordionContent>
</AccordionItem>
</Card>
</Accordion>
);
}

View File

@@ -0,0 +1,80 @@
import { useScrollAnchor } from '@/hooks/use-scroll-anchor';
import type { UIMessage } from 'ai';
import { Loader2Icon } from 'lucide-react';
import { useEffect } from 'react';
import { ProjectLink } from '../links';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { ScrollArea } from '../ui/scroll-area';
import { ChatMessage } from './chat-message';
export function ChatMessages({
messages,
debug,
status,
isLimited,
}: {
messages: UIMessage[];
debug: boolean;
status: 'submitted' | 'streaming' | 'ready' | 'error';
isLimited: boolean;
}) {
const { messagesRef, scrollRef, visibilityRef, scrollToBottom } =
useScrollAnchor();
useEffect(() => {
scrollToBottom();
}, []);
useEffect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === 'user') {
scrollToBottom();
}
}, [messages]);
return (
<ScrollArea className="h-full" ref={scrollRef}>
<div ref={messagesRef} className="p-8 col gap-2">
{messages.map((m, index) => {
return (
<ChatMessage
key={m.id}
message={m}
isStreaming={status === 'streaming'}
isLast={index === messages.length - 1}
debug={debug}
/>
);
})}
{status === 'submitted' && (
<div className="card p-4 center-center max-w-xl pl-8">
<Loader2Icon className="w-4 h-4 animate-spin" />
</div>
)}
{isLimited && (
<div className="max-w-xl pl-8 mt-8">
<Alert variant={'warning'}>
<AlertTitle>Upgrade your account</AlertTitle>
<AlertDescription>
<p>
To keep using this feature you need to upgrade your account.
</p>
<p>
<ProjectLink
href="/settings/organization?tab=billing"
className="font-medium underline"
>
Visit Billing
</ProjectLink>{' '}
to upgrade.
</p>
</AlertDescription>
</Alert>
</div>
)}
<div className="h-20 p-4 w-full" />
<div className="w-full h-px" ref={visibilityRef} />
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,85 @@
import { pushModal } from '@/modals';
import type {
IChartInputAi,
IChartRange,
IChartType,
IInterval,
} from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { ReportChart } from '../report-chart';
import { ReportChartType } from '../report/ReportChartType';
import { ReportInterval } from '../report/ReportInterval';
import { TimeWindowPicker } from '../time-window-picker';
import { Button } from '../ui/button';
export function ChatReport({
lazy,
...props
}: { report: IChartInputAi; lazy: boolean }) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
);
const [startDate, setStartDate] = useState<string>(props.report.startDate);
const [endDate, setEndDate] = useState<string>(props.report.endDate);
const [range, setRange] = useState<IChartRange>(props.report.range);
const [interval, setInterval] = useState<IInterval>(props.report.interval);
const report = {
...props.report,
lineType: 'linear' as const,
chartType,
startDate: range === 'custom' ? startDate : null,
endDate: range === 'custom' ? endDate : null,
range,
interval,
};
return (
<div className="card">
<div className="text-center text-sm font-mono font-medium pt-4">
{props.report.name}
</div>
<div className="p-4">
<ReportChart lazy={lazy} report={report} />
</div>
<div className="row justify-between gap-1 border-t border-border p-2">
<div className="col md:row gap-1">
<TimeWindowPicker
className="min-w-0"
onChange={setRange}
value={report.range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval
className="min-w-0"
interval={interval}
range={range}
chartType={chartType}
onChange={setInterval}
/>
<ReportChartType
value={chartType}
onChange={(type) => {
setChartType(type);
}}
/>
</div>
<Button
icon={SaveIcon}
variant="outline"
size="sm"
onClick={() => {
pushModal('SaveReport', {
report,
disableRedirect: true,
});
}}
>
Save report
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { ChatForm } from '@/components/chat/chat-form';
import { ChatMessages } from '@/components/chat/chat-messages';
import { useAppContext } from '@/hooks/use-app-context';
import { useChat } from '@ai-sdk/react';
import type { IServiceOrganization } from '@openpanel/db';
import type { UIMessage } from 'ai';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { toast } from 'sonner';
const getErrorMessage = (error: Error) => {
try {
const parsed = JSON.parse(error.message);
return parsed.message || error.message;
} catch (e) {
return error.message;
}
};
export default function Chat({
initialMessages,
projectId,
organization,
}: {
initialMessages?: UIMessage[];
projectId: string;
organization: IServiceOrganization;
}) {
const context = useAppContext();
const { messages, input, handleInputChange, handleSubmit, status, append } =
useChat({
onError(error) {
const message = getErrorMessage(error);
toast.error(message);
},
api: `${context.apiUrl}/ai/chat?projectId=${projectId}`,
initialMessages: (initialMessages ?? []) as any,
fetch: (url, options) => {
return fetch(url, {
...options,
credentials: 'include',
mode: 'cors',
});
},
});
const [debug] = useQueryState('debug', parseAsBoolean.withDefault(false));
const isLimited = Boolean(
messages.length > 5 &&
(organization.isCanceled ||
organization.isTrial ||
organization.isWillBeCanceled ||
organization.isExceeded ||
organization.isExpired),
);
return (
<div className="h-screen w-full col relative">
<ChatMessages
messages={messages}
debug={debug}
status={status}
isLimited={isLimited}
/>
<ChatForm
handleSubmit={handleSubmit}
input={input}
handleInputChange={handleInputChange}
append={append}
projectId={projectId}
isLimited={isLimited}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { clipboard } from '@/utils/clipboard';
import { toast } from 'sonner';
import { Tooltiper } from './ui/tooltip';
type Props = {
children: React.ReactNode;
className?: string;
value: string;
};
const ClickToCopy = ({ children, value }: Props) => {
return (
<Tooltiper
content="Click to copy"
asChild
className="cursor-pointer"
onClick={() => {
clipboard(value);
toast('Copied to clipboard');
}}
>
{children}
</Tooltiper>
);
};
export default ClickToCopy;

View File

@@ -0,0 +1,38 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { RocketIcon } from 'lucide-react';
import CopyInput from '../forms/copy-input';
type Props = { id: string; secret: string };
export function CreateClientSuccess({ id, secret }: Props) {
return (
<div className="grid gap-4">
<CopyInput label="Client ID" value={id} />
{secret && (
<div className="w-full">
<CopyInput label="Secret" value={secret} />
<p className="mt-1 text-sm text-muted-foreground">
You will only need the secret if you want to send server events.
</p>
</div>
)}
<Alert>
<RocketIcon className="h-4 w-4" />
<AlertTitle>Get started!</AlertTitle>
<AlertDescription>
Read our{' '}
<a
target="_blank"
href="https://openpanel.dev/docs"
className="underline"
rel="noreferrer"
>
documentation
</a>{' '}
to get started. Easy peasy!
</AlertDescription>
</Alert>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { formatDateTime, formatTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import CopyInput from '@/components/forms/copy-input';
import { createActionColumn } from '@/components/ui/data-table/data-table-helpers';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import type { RouterOutputs } from '@/trpc/client';
import { clipboard } from '@/utils/clipboard';
import { DropdownMenuSeparator } from '@radix-ui/react-dropdown-menu';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
export function useColumns() {
const columns: ColumnDef<RouterOutputs['client']['list'][number]>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return <div className="font-medium">{row.original.name}</div>;
},
},
{
accessorKey: 'id',
header: 'Client ID',
cell: ({ row }) => <CopyInput label={null} value={row.original.id} />,
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
createActionColumn(({ row }) => {
const client = row.original;
const trpc = useTRPC();
const queryClient = useQueryClient();
const deletion = useMutation(
trpc.client.remove.mutationOptions({
onSuccess() {
toast('Success', {
description:
'Client revoked, incoming requests will be rejected.',
});
queryClient.invalidateQueries(trpc.client.list.pathFilter());
},
onError: handleError,
}),
);
return (
<>
<DropdownMenuItem onClick={() => clipboard(client.id)}>
Copy client ID
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
pushModal('EditClient', client);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="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: client.id,
});
},
});
}}
>
Revoke
</DropdownMenuItem>
</>
);
}),
];
return columns;
}

View File

@@ -0,0 +1,30 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { DataTable } from '@/components/ui/data-table/data-table';
import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar';
import { useTable } from '@/components/ui/data-table/use-table';
import type { RouterOutputs } from '@/trpc/client';
import { useColumns } from './columns';
type Props = {
query: UseQueryResult<RouterOutputs['client']['list'], unknown>;
};
export const ClientsTable = ({ query }: Props) => {
const columns = useColumns();
const { data, isLoading } = query;
const { table } = useTable({
columns,
data: data ?? [],
loading: isLoading,
pageSize: 50,
});
return (
<>
<DataTableToolbar table={table} />
<DataTable table={table} loading={isLoading} />
</>
);
};

View File

@@ -0,0 +1,17 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
type ColorSquareProps = HtmlProps<HTMLDivElement>;
export function ColorSquare({ children, className }: ColorSquareProps) {
return (
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-sm font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem] font-mono',
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { cn } from '@/utils/cn';
interface DotProps {
className?: string;
size?: number;
animated?: boolean;
}
function filterCn(filter: string[], className: string | undefined) {
const split: string[] = className?.split(' ') || [];
return split
.filter((item) => !filter.some((filterItem) => item.startsWith(filterItem)))
.join(' ');
}
export function Dot({ className, size = 8, animated }: DotProps) {
const style = {
width: size,
height: size,
};
return (
<div
className={cn(
'relative',
filterCn(['bg-', 'animate-', 'group-hover/row'], className),
)}
style={style}
>
<div
className={cn(
'absolute !m-0 rounded-full',
animated !== false && 'animate-ping',
className,
)}
style={style}
/>
<div
className={cn(
'absolute !m-0 rounded-full',
filterCn(['animate-', 'group-hover/row'], className),
)}
style={style}
/>
</div>
);
}

View File

@@ -0,0 +1,241 @@
import { cn } from '@/utils/cn';
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 type { EventMeta } from '@openpanel/db';
const variants = cva('flex shrink-0 items-center justify-center rounded-full', {
variants: {
size: {
xs: 'h-5 w-5',
sm: 'h-6 w-6',
default: 'h-10 w-10',
},
},
defaultVariants: {
size: 'default',
},
});
type EventIconProps = VariantProps<typeof variants> & {
name: string;
meta?: EventMeta;
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,
UserIcon: Icons.UserIcon,
UsersIcon: Icons.UsersIcon,
UserPlusIcon: Icons.UserPlusIcon,
UserMinusIcon: Icons.UserMinusIcon,
UserCheckIcon: Icons.UserCheckIcon,
UserXIcon: Icons.UserXIcon,
PlayIcon: Icons.PlayIcon,
PauseIcon: Icons.PauseIcon,
SkipForwardIcon: Icons.SkipForwardIcon,
SkipBackIcon: Icons.SkipBackIcon,
VolumeIcon: Icons.VolumeIcon,
VolumeOffIcon: Icons.VolumeOffIcon,
ImageIcon: Icons.ImageIcon,
VideoIcon: Icons.VideoIcon,
MusicIcon: Icons.MusicIcon,
CameraIcon: Icons.CameraIcon,
ClickIcon: Icons.MousePointerClickIcon,
ChevronDownIcon: Icons.ChevronDownIcon,
ChevronUpIcon: Icons.ChevronUpIcon,
ChevronLeftIcon: Icons.ChevronLeftIcon,
ChevronRightIcon: Icons.ChevronRightIcon,
ArrowUpIcon: Icons.ArrowUpIcon,
ArrowDownIcon: Icons.ArrowDownIcon,
ArrowLeftIcon: Icons.ArrowLeftIcon,
ArrowRightIcon: Icons.ArrowRightIcon,
PhoneIcon: Icons.PhoneIcon,
MessageSquareIcon: Icons.MessageSquareIcon,
SendIcon: Icons.SendIcon,
ShoppingCartIcon: Icons.ShoppingCartIcon,
ShoppingBagIcon: Icons.ShoppingBagIcon,
CreditCardIcon: Icons.CreditCardIcon,
DollarSignIcon: Icons.DollarSignIcon,
EuroIcon: Icons.EuroIcon,
HeartIcon: Icons.HeartIcon,
StarIcon: Icons.StarIcon,
ThumbsUpIcon: Icons.ThumbsUpIcon,
ThumbsDownIcon: Icons.ThumbsDownIcon,
SmileIcon: Icons.SmileIcon,
FrownIcon: Icons.FrownIcon,
BarChartIcon: Icons.BarChartIcon,
LineChartIcon: Icons.LineChartIcon,
PieChartIcon: Icons.PieChartIcon,
TrendingUpIcon: Icons.TrendingUpIcon,
TrendingDownIcon: Icons.TrendingDownIcon,
TargetIcon: Icons.TargetIcon,
ShieldIcon: Icons.ShieldIcon,
EyeIcon: Icons.EyeIcon,
EyeOffIcon: Icons.EyeOffIcon,
KeyIcon: Icons.KeyIcon,
UnlockIcon: Icons.UnlockIcon,
SettingsIcon: Icons.SettingsIcon,
RefreshCwIcon: Icons.RefreshCwIcon,
TrashIcon: Icons.TrashIcon,
EditIcon: Icons.EditIcon,
PlusIcon: Icons.PlusIcon,
MinusIcon: Icons.MinusIcon,
XIcon: Icons.XIcon,
CheckIcon: Icons.CheckIcon,
SaveIcon: Icons.SaveIcon,
UploadIcon: Icons.UploadIcon,
SmartphoneIcon: Icons.SmartphoneIcon,
TabletIcon: Icons.TabletIcon,
LaptopIcon: Icons.LaptopIcon,
MonitorIcon: Icons.MonitorIcon,
WifiIcon: Icons.WifiIcon,
MapPinIcon: Icons.MapPinIcon,
NavigationIcon: Icons.NavigationIcon,
CompassIcon: Icons.CompassIcon,
FolderIcon: Icons.FolderIcon,
FileTextIcon: Icons.FileTextIcon,
FilePlusIcon: Icons.FilePlusIcon,
FileMinusIcon: Icons.FileMinusIcon,
DatabaseIcon: Icons.DatabaseIcon,
AlertCircleIcon: Icons.AlertCircleIcon,
InfoIcon: Icons.InfoIcon,
HelpCircleIcon: Icons.HelpCircleIcon,
CheckCircleIcon: Icons.CheckCircleIcon,
XCircleIcon: Icons.XCircleIcon,
CalendarDaysIcon: Icons.CalendarDaysIcon,
CalendarPlusIcon: Icons.CalendarPlusIcon,
TimerIcon: Icons.TimerIcon,
FilterIcon: Icons.FilterIcon,
SortAscIcon: Icons.ArrowUpAZIcon,
SortDescIcon: Icons.ArrowDownZAIcon,
CopyIcon: Icons.CopyIcon,
LinkIcon: Icons.LinkIcon,
QrCodeIcon: Icons.QrCodeIcon,
ScanIcon: Icons.ScanIcon,
ZapIcon: Icons.ZapIcon,
FlameIcon: Icons.FlameIcon,
RocketIcon: Icons.RocketIcon,
TrophyIcon: Icons.TrophyIcon,
};
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 === 'xs' ? 12 : size === 'sm' ? 14 : 20}
className={`text-${color}-700`}
/>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import { Link } from '@tanstack/react-router';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceEventMinimal | IServiceEvent;
export function EventListItem(props: EventListItemProps) {
const { organizationId, projectId } = useAppParams();
const { createdAt, name, path, duration, meta } = props;
const profile = 'profile' in props ? props.profile : null;
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;
};
const isMinimal = 'minimal' in props;
return (
<>
<button
type="button"
onClick={() => {
if (!isMinimal) {
pushModal('EventDetails', {
id: props.id,
projectId,
createdAt,
});
}
}}
className={cn(
'card hover:bg-light-background flex w-full items-center justify-between rounded-lg p-4 transition-colors',
meta?.conversion &&
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`,
)}
>
<div>
<div className="flex items-center gap-4 text-left ">
<EventIcon size="sm" name={name} meta={meta} />
<span>
<span className="font-medium">{renderName()}</span>
{' '}
{renderDuration()}
</span>
</div>
<div className="pl-10">
<div className="flex origin-left scale-75 gap-1">
<SerieIcon name={props.country} />
<SerieIcon name={props.os} />
<SerieIcon name={props.browser} />
</div>
</div>
</div>
<div className="flex gap-4">
{profile && (
<Tooltiper asChild content={getProfileName(profile)}>
<Link
onClick={(e) => {
e.stopPropagation();
}}
to={'/$organizationId/$projectId/profiles/$profileId'}
params={{
organizationId,
projectId,
profileId: profile.id,
}}
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
>
{getProfileName(profile)}
</Link>
</Tooltiper>
)}
<Tooltiper asChild content={createdAt.toLocaleString()}>
<div className=" text-muted-foreground">
{createdAt.toLocaleTimeString()}
</div>
</Tooltiper>
</div>
</button>
</>
);
}

View File

@@ -0,0 +1,74 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params';
import { useDebounceState } from '@/hooks/use-debounce-state';
import useWS from '@/hooks/use-ws';
import { cn } from '@/utils/cn';
import type { IServiceEventMinimal } from '@openpanel/db';
import { AnimatedNumber } from '../animated-number';
export default function EventListener({
onRefresh,
}: {
onRefresh: () => void;
}) {
const { projectId } = useAppParams();
const counter = useDebounceState(0, 1000);
useWS<IServiceEventMinimal>(
`/live/events/${projectId}`,
(event) => {
if (event?.name) {
counter.set((prev) => prev + 1);
}
},
{
debounce: {
delay: 1000,
maxWait: 5000,
},
},
);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
counter.set(0);
onRefresh();
}}
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
>
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
)}
/>
</div>
{counter.debounced === 0 ? (
'Listening'
) : (
<AnimatedNumber value={counter.debounced} suffix=" new events" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter.debounced === 0
? 'Listening to new events'
: 'Click to refresh'}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,49 @@
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } 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.5">
{country && (
<Tooltiper content={[country, city].filter(Boolean).join(', ')}>
<SerieIcon name={country} />
</Tooltiper>
)}
{os && (
<Tooltiper content={`${os} (${os_version})`}>
<SerieIcon name={os} />
</Tooltiper>
)}
{browser && (
<Tooltiper content={`${browser} (${browser_version})`}>
<SerieIcon name={browser} />
</Tooltiper>
)}
{referrer_name && (
<Tooltiper content={`${referrer_name} (${referrer_type})`}>
<SerieIcon name={referrer_name} />
</Tooltiper>
)}
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { EventIcon } from '@/components/events/event-icon';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals';
import { formatDateTime, formatTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import type { IServiceEvent } from '@openpanel/db';
export function useColumns() {
const number = useNumber();
const columns: ColumnDef<IServiceEvent>[] = [
{
size: 300,
accessorKey: 'name',
header: 'Name',
cell({ row }) {
const { name, path, duration } = row.original;
const renderName = () => {
if (name === 'screen_view') {
if (path.includes('/')) {
return <span className="max-w-md truncate">{path}</span>;
}
return (
<>
<span className="text-muted-foreground">Screen: </span>
<span className="max-w-md truncate">{path}</span>
</>
);
}
return name.replace(/_/g, ' ');
};
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
return (
<div className="flex items-center gap-2">
<button
type="button"
className="transition-transform hover:scale-105"
onClick={() => {
pushModal('EditEvent', {
id: row.original.id,
});
}}
>
<EventIcon
size="sm"
name={row.original.name}
meta={row.original.meta}
/>
</button>
<span className="flex gap-2">
<button
type="button"
onClick={() => {
pushModal('EventDetails', {
id: row.original.id,
createdAt: row.original.createdAt,
projectId: row.original.projectId,
});
}}
className="font-medium"
>
{renderName()}
</button>
{renderDuration()}
</span>
</div>
);
},
},
{
accessorKey: 'createdAt',
header: 'Created at',
size: 170,
cell({ row }) {
const date = row.original.createdAt;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
{
accessorKey: 'profileId',
header: 'Profile',
cell({ row }) {
const { profile, profileId, deviceId } = row.original;
if (profile) {
return (
<ProjectLink
href={`/profiles/${profile.id}`}
className="whitespace-nowrap font-medium hover:underline"
>
{getProfileName(profile)}
</ProjectLink>
);
}
if (profileId && profileId !== deviceId) {
return (
<ProjectLink
href={`/profiles/${profileId}`}
className="whitespace-nowrap font-medium hover:underline"
>
Unknown
</ProjectLink>
);
}
if (deviceId) {
return (
<ProjectLink
href={`/profiles/${deviceId}`}
className="whitespace-nowrap font-medium hover:underline"
>
Anonymous
</ProjectLink>
);
}
return null;
},
},
{
accessorKey: 'sessionId',
header: 'Session ID',
size: 320,
},
{
accessorKey: 'deviceId',
header: 'Device ID',
size: 320,
},
{
accessorKey: 'country',
header: 'Country',
size: 150,
cell({ row }) {
const { country, city } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={country} />
<span className="truncate">{city}</span>
</div>
);
},
},
{
accessorKey: 'os',
header: 'OS',
size: 130,
cell({ row }) {
const { os } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={os} />
<span className="truncate">{os}</span>
</div>
);
},
},
{
accessorKey: 'browser',
header: 'Browser',
size: 110,
cell({ row }) {
const { browser } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={browser} />
<span className="truncate">{browser}</span>
</div>
);
},
},
{
accessorKey: 'properties',
header: 'Properties',
size: 400,
cell({ row }) {
const { properties } = row.original;
const filteredProperties = Object.fromEntries(
Object.entries(properties || {}).filter(
([key]) => !key.startsWith('__'),
),
);
const items = Object.entries(filteredProperties);
return (
<div className="row flex-wrap gap-x-4 gap-y-1 overflow-hidden text-sm">
{items.slice(0, 4).map(([key, value]) => (
<div key={key} className="row items-center gap-1 min-w-0">
<span className="text-muted-foreground">{key}</span>
<span className="truncate font-medium">{String(value)}</span>
</div>
))}
{items.length > 5 && (
<span className="truncate">{items.length - 5} more</span>
)}
</div>
);
},
},
];
return columns;
}

View File

@@ -0,0 +1,298 @@
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from '@/components/ui/popover';
import { Check, ChevronsUpDown, Settings2Icon } from 'lucide-react';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { Button } from '@/components/ui/button';
import { DataTableToolbarContainer } from '@/components/ui/data-table/data-table-toolbar';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal';
import { cn } from '@/utils/cn';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { format } from 'date-fns';
import throttle from 'lodash.throttle';
import { CalendarIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { last } from 'ramda';
import { memo, useEffect, useRef, useState } from 'react';
import { useInViewport } from 'react-in-viewport';
import { useLocalStorage } from 'usehooks-ts';
import EventListener from '../event-listener';
import { EventItem, EventItemSkeleton } from './item';
export const useEventsViewOptions = () => {
return useLocalStorage<Record<string, boolean | undefined>>(
'@op:events-table-view-options',
{
properties: false,
},
);
};
type Props = {
query: UseInfiniteQueryResult<
TRPCInfiniteData<
RouterInputs['event']['events'],
RouterOutputs['event']['events']
>,
unknown
>;
};
export const EventsTable = memo(
({ query }: Props) => {
const [viewOptions] = useEventsViewOptions();
const { isLoading } = query;
const parentRef = useRef<HTMLDivElement>(null);
const [scrollMargin, setScrollMargin] = useState(0);
const inViewportRef = useRef<HTMLDivElement>(null);
const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, {
disconnectOnLeave: true,
});
const data = query.data?.pages?.flatMap((p) => p.data) ?? [];
const virtualizer = useWindowVirtualizer({
count: data.length,
estimateSize: () => 55,
scrollMargin,
overscan: 10,
});
useEffect(() => {
const updateScrollMargin = throttle(() => {
if (parentRef.current) {
setScrollMargin(
parentRef.current.getBoundingClientRect().top + window.scrollY,
);
}
}, 500);
// Initial calculation
updateScrollMargin();
// Listen for resize events
window.addEventListener('resize', updateScrollMargin);
return () => {
window.removeEventListener('resize', updateScrollMargin);
};
}, []);
useEffect(() => {
virtualizer.measure();
}, [viewOptions, virtualizer]);
const hasNextPage = last(query.data?.pages ?? [])?.meta.next;
useEffect(() => {
if (
hasNextPage &&
data.length > 0 &&
inViewport &&
enterCount > 0 &&
query.isFetchingNextPage === false
) {
query.fetchNextPage();
}
}, [inViewport, enterCount, hasNextPage]);
const visibleItems = virtualizer.getVirtualItems();
return (
<>
<EventsTableToolbar query={query} />
<div ref={parentRef} className="w-full">
{isLoading && (
<div className="w-full gap-2 col">
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
</div>
)}
{!isLoading && data.length === 0 && (
<FullPageEmptyState
title="No events"
description={"Start sending events and you'll see them here"}
/>
)}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{visibleItems.map((virtualRow) => (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${
virtualRow.start - virtualizer.options.scrollMargin
}px)`,
paddingBottom: '8px', // Gap between items
}}
>
<EventItem
event={data[virtualRow.index]!}
viewOptions={viewOptions}
/>
</div>
))}
</div>
</div>
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<div
className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin" />
</div>
</div>
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'query.isFetchingNextPage']),
);
function EventsTableToolbar({
query,
}: {
query: Props['query'];
}) {
const { projectId } = useAppParams();
const [startDate, setStartDate] = useQueryState(
'startDate',
parseAsIsoDateTime,
);
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
return (
<DataTableToolbarContainer>
<div className="flex flex-1 flex-wrap items-center gap-2">
<EventListener onRefresh={() => query.refetch()} />
<Button
variant="outline"
size="sm"
icon={CalendarIcon}
onClick={() => {
pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => {
setStartDate(startDate);
setEndDate(endDate);
},
startDate: startDate || undefined,
endDate: endDate || undefined,
});
}}
>
{startDate && endDate
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`
: 'Date range'}
</Button>
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
</div>
<EventsViewOptions />
</DataTableToolbarContainer>
);
}
export function EventsViewOptions() {
const [viewOptions, setViewOptions] = useEventsViewOptions();
const columns = {
origin: 'Show origin',
queryString: 'Show query string',
referrer: 'Referrer',
country: 'Country',
os: 'OS',
browser: 'Browser',
profileId: 'Profile',
createdAt: 'Created at',
properties: 'Properties',
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
aria-label="Toggle columns"
role="combobox"
variant="outline"
size="sm"
className="ml-auto hidden h-8 lg:flex"
>
<Settings2Icon className="size-4 mr-2" />
View
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent align="end" className="w-44 p-0">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
{Object.entries(columns).map(([column, label]) => (
<CommandItem
key={column}
onSelect={() =>
setViewOptions({
...viewOptions,
// biome-ignore lint/complexity/noUselessTernary: we need this this viewOptions[column] can be undefined
[column]: viewOptions[column] === false ? true : false,
})
}
>
<span className="truncate">{label}</span>
<Check
className={cn(
'ml-auto size-4 shrink-0',
viewOptions[column] !== false
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -0,0 +1,178 @@
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { formatTimeAgoOrDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { IServiceEvent } from '@openpanel/db';
import { memo } from 'react';
import { Skeleton } from '../../skeleton';
import { EventIcon } from '../event-icon';
interface EventItemProps {
event: IServiceEvent | Record<string, never>;
viewOptions: Record<string, boolean | undefined>;
className?: string;
}
export const EventItem = memo<EventItemProps>(
({ event, viewOptions, className }) => {
let url: string | null = '';
if (event.path && event.origin) {
if (viewOptions.origin !== false && event.origin) {
url += event.origin;
}
url += event.path;
const query = Object.entries(event.properties || {})
.filter(([key]) => key.startsWith('__query'))
.map(([key, value]) => [key.replace('__query.', ''), value]);
if (viewOptions.queryString !== false && query.length) {
query.forEach(([key, value], index) => {
url += `${index === 0 ? '?' : '&'}${key}=${value}`;
});
}
}
return (
<div className={cn('group card @container overflow-hidden', className)}>
<div
onClick={() => {
pushModal('EventDetails', {
id: event.id,
projectId: event.projectId,
createdAt: event.createdAt,
});
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
pushModal('EventDetails', {
id: event.id,
projectId: event.projectId,
createdAt: event.createdAt,
});
}
}}
data-slot="inner"
className={cn(
'col gap-2 flex-1 p-2',
// Desktop
'@lg:row @lg:items-center',
'cursor-pointer',
event.meta?.color
? `hover:bg-${event.meta.color}-50 dark:hover:bg-${event.meta.color}-900`
: 'hover:bg-def-200',
)}
>
<div className="min-w-0 flex-1 row items-center gap-4">
<button
type="button"
className="transition-transform hover:scale-105"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
pushModal('EditEvent', {
id: event.id,
});
}}
>
<EventIcon name={event.name} size="sm" meta={event.meta} />
</button>
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all">
{event.name === 'screen_view' ? (
<>
<span className="text-muted-foreground mr-2">Visit:</span>
<span className="font-medium min-w-0">
{url ? url : event.path}
</span>
</>
) : (
<>
<span className="text-muted-foreground mr-2">Event:</span>
<span className="font-medium">{event.name}</span>
</>
)}
</span>
</div>
<div className="row gap-2 items-center @max-lg:pl-10">
{event.referrerName && viewOptions.referrerName !== false && (
<Pill
icon={<SerieIcon className="mr-2" name={event.referrerName} />}
>
<span>{event.referrerName}</span>
</Pill>
)}
{event.os && viewOptions.os !== false && (
<Pill icon={<SerieIcon name={event.os} />}>{event.os}</Pill>
)}
{event.browser && viewOptions.browser !== false && (
<Pill icon={<SerieIcon name={event.browser} />}>
{event.browser}
</Pill>
)}
{event.country && viewOptions.country !== false && (
<Pill icon={<SerieIcon name={event.country} />}>
{event.country}
</Pill>
)}
{viewOptions.profileId !== false && (
<Pill
className="@max-xl:ml-auto @max-lg:[&>span]:inline mx-4"
icon={<ProfileAvatar size="xs" {...event.profile} />}
>
{getProfileName(event.profile)}
</Pill>
)}
{viewOptions.createdAt !== false && (
<span className="text-sm text-neutral-500">
{formatTimeAgoOrDateTime(event.createdAt)}
</span>
)}
</div>
</div>
{viewOptions.properties !== false && (
<div
data-slot="extra"
className="border-t border-neutral-200 p-4 py-2 bg-def-100"
>
<pre className="text-sm leading-tight">
{JSON.stringify(event.properties, null, 2)}
</pre>
</div>
)}
</div>
);
},
);
export const EventItemSkeleton = () => {
return (
<div className="card h-10 p-2 gap-4 row items-center">
<Skeleton className="size-6 rounded-full" />
<Skeleton className="w-1/2 h-3" />
<div className="row gap-2 ml-auto">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-4 w-14" />
</div>
</div>
);
};
function Pill({
children,
icon,
className,
}: { children: React.ReactNode; icon?: React.ReactNode; className?: string }) {
return (
<div
className={cn(
'shrink-0 whitespace-nowrap inline-flex gap-2 items-center rounded-full @3xl:text-muted-foreground h-6 text-xs font-mono',
className,
)}
>
{icon && <div className="size-4 center-center">{icon}</div>}
<div className="hidden @3xl:inline">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { cn } from '@/utils/cn';
import { useEffect, useRef } from 'react';
type Props = {
className?: string;
children: React.ReactNode;
};
export function FadeIn({ className, children }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
ref.current.classList.remove('opacity-0');
ref.current.classList.add('opacity-100');
}
}, []);
return (
<div
className={cn('opacity-0 transition-opacity duration-500', className)}
ref={ref}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { op } from '@/utils/op';
import { useLocation, useRouteContext } from '@tanstack/react-router';
import { SparklesIcon } from 'lucide-react';
import { Button } from './ui/button';
export function FeedbackButton() {
const context = useRouteContext({ strict: false });
return (
<Button
variant={'outline'}
className="w-full text-left justify-start [&_svg]:mx-2"
icon={SparklesIcon}
onClick={() => {
op.track('feedback_button_clicked');
if ('uj' in window) {
(window.uj as any).identify({
id: context.session?.userId,
firstName: context.session?.user?.firstName,
});
(window.uj as any).showWidget();
}
}}
>
Give feedback
</Button>
);
}

View File

@@ -0,0 +1,54 @@
import { cn } from '@/utils/cn';
import { slug } from '@/utils/slug';
import type { LucideIcon } from 'lucide-react';
import { forwardRef } from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { Switch } from '../ui/switch';
type Props = {
label: string;
description: string;
Icon: LucideIcon;
children?: React.ReactNode;
error?: string;
} & ControllerRenderProps;
export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
(
{ label, description, Icon, children, onChange, value, disabled, error },
ref,
) => {
const id = slug(label);
return (
<div>
<label
className={cn(
'flex items-center gap-4 px-4 py-6 transition-colors hover:bg-def-200',
disabled && 'cursor-not-allowed opacity-50',
)}
htmlFor={id}
>
{Icon && <div className="w-6 shrink-0">{<Icon />}</div>}
<div className="flex-1">
<div className="font-medium">{label}</div>
<div className=" text-muted-foreground">{description}</div>
{error && <div className="text-sm text-red-600">{error}</div>}
</div>
<div>
<Switch
ref={ref}
disabled={disabled}
checked={!!value}
onCheckedChange={onChange}
id={id}
/>
</div>
</label>
{children}
</div>
);
},
);
CheckboxItem.displayName = 'CheckboxItem';

View File

@@ -0,0 +1,29 @@
import { clipboard } from '@/utils/clipboard';
import { cn } from '@/utils/cn';
import { CopyIcon } from 'lucide-react';
import { Label } from '../ui/label';
type Props = {
label: React.ReactNode;
value: string;
className?: string;
};
const CopyInput = ({ label, value, className }: Props) => {
return (
<button
type="button"
className={cn('w-full text-left', className)}
onClick={() => clipboard(value)}
>
{!!label && <Label>{label}</Label>}
<div className="font-mono flex items-center justify-between rounded bg-muted p-2 px-3 ">
{value}
<CopyIcon size={16} />
</div>
</button>
);
};
export default CopyInput;

View File

@@ -0,0 +1,68 @@
import { BanIcon, InfoIcon } from 'lucide-react';
import { forwardRef } from 'react';
import { Input } from '../ui/input';
import type { InputProps } from '../ui/input';
import { Label } from '../ui/label';
import { Tooltiper } from '../ui/tooltip';
type WithLabel = {
children: React.ReactNode;
label: string;
error?: string | undefined;
info?: React.ReactNode;
className?: string;
};
type InputWithLabelProps = InputProps & Omit<WithLabel, 'children'>;
export const WithLabel = ({
children,
className,
label,
info,
error,
}: WithLabel) => {
return (
<div className={className}>
<div className="mb-2 flex items-end justify-between">
<Label
className="mb-0 flex flex-1 shrink-0 items-center gap-1 whitespace-nowrap"
htmlFor={label}
>
{label}
{info && (
<Tooltiper content={info}>
<InfoIcon size={14} />
</Tooltiper>
)}
</Label>
{error && (
<Tooltiper
asChild
content={error}
tooltipClassName="max-w-80 leading-normal"
align="end"
>
<div className="flex items-center gap-1 leading-none text-destructive">
Issues
<BanIcon size={14} />
</div>
</Tooltiper>
)}
</div>
{children}
</div>
);
};
export const InputWithLabel = forwardRef<HTMLInputElement, InputWithLabelProps>(
(props, ref) => {
return (
<WithLabel {...props}>
<Input ref={ref} id={props.label} {...props} />
</WithLabel>
);
},
);
InputWithLabel.displayName = 'InputWithLabel';

View File

@@ -0,0 +1,152 @@
// Based on Christin Alares tag input component (https://github.com/christianalares/seventy-seven)
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { useAnimate } from 'framer-motion';
import { XIcon } from 'lucide-react';
import type { ElementRef } from 'react';
import { useEffect, useRef, useState } from 'react';
type Props = {
placeholder: string;
value: string[];
error?: string;
className?: string;
onChange: (value: string[]) => void;
renderTag?: (tag: string) => string;
id?: string;
};
const TagInput = ({
value: propValue,
onChange,
renderTag,
placeholder,
error,
id,
}: Props) => {
const value = (
Array.isArray(propValue) ? propValue : propValue ? [propValue] : []
).filter(Boolean);
const [isMarkedForDeletion, setIsMarkedForDeletion] = useState(false);
const inputRef = useRef<ElementRef<'input'>>(null);
const [inputValue, setInputValue] = useState('');
const [scope, animate] = useAnimate();
const appendTag = (tag: string) => {
onChange([...value, tag.trim()]);
};
const removeTag = (tag: string) => {
onChange(value.filter((t) => t !== tag));
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
const tagAlreadyExists = value.some(
(tag) => tag.toLowerCase() === inputValue.toLowerCase(),
);
if (inputValue) {
if (tagAlreadyExists) {
animate(
`span[data-tag="${inputValue.toLowerCase()}"]`,
{
scale: [1, 1.3, 1],
},
{
duration: 0.3,
},
);
return;
}
appendTag(inputValue);
setInputValue('');
}
}
if (e.key === 'Backspace' && inputValue === '') {
if (!isMarkedForDeletion) {
setIsMarkedForDeletion(true);
return;
}
const last = value[value.length - 1];
if (last) {
removeTag(last);
}
setIsMarkedForDeletion(false);
setInputValue('');
}
};
const handleBlur = () => {
if (inputValue) {
appendTag(inputValue);
setInputValue('');
}
};
useEffect(() => {
if (inputValue.length > 0) {
setIsMarkedForDeletion(false);
}
}, [inputValue]);
return (
<div
ref={scope}
className={cn(
'inline-flex w-full flex-wrap items-center gap-2 rounded-md border border-input p-1 px-3 ring-offset-background has-[input:focus]:ring-2 has-[input:focus]:ring-ring has-[input:focus]:ring-offset-1 bg-card',
!!error && 'border-destructive',
)}
>
{value.map((tag, i) => {
const isCreating = false;
return (
<span
data-tag={tag}
key={tag}
className={cn(
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 ',
isMarkedForDeletion &&
i === value.length - 1 &&
'bg-destructive-foreground ring-2 ring-destructive/50 ring-offset-1',
isCreating && 'opacity-60',
)}
>
{renderTag ? renderTag(tag) : tag}
<Button
size="icon"
variant="outline"
className="h-4 w-4 rounded-full"
onClick={() => removeTag(tag)}
>
<span className="sr-only">Remove tag</span>
<XIcon name="close" className="size-3" />
</Button>
</span>
);
})}
<input
ref={inputRef}
placeholder={`${placeholder}`}
className="min-w-20 flex-1 py-1 focus-visible:outline-none bg-card"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
id={id}
/>
</div>
);
};
export default TagInput;

View File

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

View File

@@ -0,0 +1,20 @@
import { ServerCrashIcon } from 'lucide-react';
import { FullPageEmptyState } from './full-page-empty-state';
export const FullPageErrorState = ({
title = 'Error...',
description = 'Something went wrong...',
children,
}: { title?: string; description?: string; children?: React.ReactNode }) => {
return (
<FullPageEmptyState
className="min-h-[calc(100vh-theme(spacing.16))]"
title={title}
icon={ServerCrashIcon}
>
{description}
{children && <div className="mt-4">{children}</div>}
</FullPageEmptyState>
);
};

View File

@@ -0,0 +1,25 @@
import type { LucideIcon } from 'lucide-react';
import { Loader2Icon } from 'lucide-react';
import { FullPageEmptyState } from './full-page-empty-state';
const FullPageLoadingState = ({
title = 'Fetching...',
description = 'Please wait while we fetch your data...',
}: { title?: string; description?: string }) => {
return (
<FullPageEmptyState
className="min-h-[calc(100vh-theme(spacing.16))]"
title={title}
icon={
((props) => (
<Loader2Icon {...props} className="animate-spin" />
)) as LucideIcon
}
>
{description}
</FullPageEmptyState>
);
};
export default FullPageLoadingState;

View File

@@ -0,0 +1,21 @@
import { cn } from '@/utils/cn';
import { LogoSquare } from './logo';
type Props = {
children: React.ReactNode;
className?: string;
};
const FullWidthNavbar = ({ children, className }: Props) => {
return (
<div className={cn('border-b border-border bg-card', className)}>
<div className="mx-auto flex h-14 w-full items-center justify-between px-4 md:w-[95vw] lg:w-[80vw] max-w-screen-2xl">
<LogoSquare className="size-8" />
{children}
</div>
</div>
);
};
export default FullWidthNavbar;

View File

@@ -0,0 +1,109 @@
import { cn } from '@/utils/cn';
import { bind } from 'bind-event-listener';
import { ChevronLeftIcon, FullscreenIcon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useEffect, useRef, useState } from 'react';
import { useDebounce } from 'usehooks-ts';
import { Button } from './ui/button';
import { Tooltiper } from './ui/tooltip';
type Props = {
children: React.ReactNode;
className?: string;
};
export const useFullscreen = () =>
useQueryState(
'fullscreen',
parseAsBoolean.withDefault(false).withOptions({
history: 'push',
}),
);
export const Fullscreen = (props: Props) => {
const [isFullscreen] = useFullscreen();
return (
<div
className={cn(
isFullscreen
? 'fixed inset-0 z-50 overflow-auto bg-def-200'
: 'w-full min-h-full col',
)}
>
{props.children}
</div>
);
};
export const FullscreenOpen = () => {
const [fullscreen, setIsFullscreen] = useFullscreen();
if (fullscreen) {
return null;
}
return (
<Tooltiper content="Toggle fullscreen" asChild>
<Button
variant="outline"
size="icon"
onClick={() => {
setIsFullscreen((p) => !p);
}}
>
<FullscreenIcon className="size-4" />
</Button>
</Tooltiper>
);
};
export const FullscreenClose = () => {
const [fullscreen, setIsFullscreen] = useFullscreen();
const isFullscreenDebounced = useDebounce(fullscreen, 1000);
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
let timer: any;
const unsub = bind(window, {
type: 'mousemove',
listener(ev) {
if (fullscreen) {
setVisible(true);
clearTimeout(timer);
timer = setTimeout(() => {
if (!ref.current?.contains(ev.target as Node)) {
setVisible(false);
}
}, 500);
}
},
});
return () => {
unsub();
clearTimeout(timer);
};
}, [fullscreen]);
if (!fullscreen) {
return null;
}
return (
<div className="fixed bottom-0 top-0 z-50 flex items-center">
<Tooltiper content="Exit full screen" asChild>
<button
type="button"
ref={ref}
className={cn(
'flex h-20 w-20 -translate-x-20 items-center justify-center rounded-full bg-foreground transition-transform',
visible && isFullscreenDebounced && '-translate-x-10',
)}
onClick={() => {
setIsFullscreen(false);
}}
>
<ChevronLeftIcon className="ml-6 text-background" />
</button>
</Tooltiper>
</div>
);
};

View File

@@ -0,0 +1,87 @@
import { cn } from '@/utils/cn';
export const Grid: React.FC<
React.HTMLAttributes<HTMLDivElement> & { columns: number }
> = ({ className, columns, children, ...props }) => (
<div className={cn('card', className)}>
<div className="relative w-full overflow-auto rounded-md">
<div
className={cn('grid w-full')}
style={{
gridTemplateColumns: `repeat(${columns}, auto)`,
width: 'max-content',
minWidth: '100%',
}}
{...props}
>
{children}
</div>
</div>
</div>
);
export const GridHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div className={cn('contents', className)} {...props}>
{children}
</div>
);
export const GridBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div
className={cn('contents [&>*:last-child]:border-0', className)}
{...props}
>
{children}
</div>
);
export const GridCell: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
as?: React.ElementType;
colSpan?: number;
isHeader?: boolean;
}
> = ({
className,
children,
as: Component = 'div',
colSpan,
isHeader,
...props
}) => (
<Component
className={cn(
'flex min-h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
isHeader && 'h-10 bg-def-100 font-semibold text-muted-foreground',
colSpan && `col-span-${colSpan}`,
className,
)}
{...props}
>
<div className="truncate w-full">{children}</div>
</Component>
);
export const GridRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div
className={cn(
'contents transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
>
{children}
</div>
);

View File

@@ -0,0 +1,121 @@
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { BoxSelectIcon } from 'lucide-react';
import { useMemo } from 'react';
import { PingBadge } from '../ping';
import { Button } from '../ui/button';
import {
IntegrationCard,
IntegrationCardFooter,
IntegrationCardLogo,
IntegrationCardSkeleton,
} from './integration-card';
import { INTEGRATIONS } from './integrations';
export function ActiveIntegrations() {
const { organizationId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.integration.list.queryOptions({
organizationId: organizationId!,
}),
);
const client = useQueryClient();
const deletion = useMutation(
trpc.integration.delete.mutationOptions({
onSuccess() {
client.refetchQueries(
trpc.integration.list.queryFilter({
organizationId,
}),
);
},
}),
);
const data = useMemo(() => {
return (query.data || [])
.map((item) => {
const integration = INTEGRATIONS.find(
(integration) => integration.type === item.config.type,
)!;
return {
...item,
integration,
};
})
.filter((item) => item.integration);
}, [query.data]);
const isLoading = query.isLoading;
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 auto-rows-auto">
{isLoading && (
<>
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
</>
)}
{!isLoading && data.length === 0 && (
<IntegrationCard
icon={
<IntegrationCardLogo className="bg-def-200 text-foreground">
<BoxSelectIcon className="size-10" strokeWidth={1} />
</IntegrationCardLogo>
}
name="No integrations yet"
description="Integrations allow you to connect your systems to OpenPanel. You can add them in the available integrations section."
/>
)}
<AnimatePresence mode="popLayout">
{data.map((item) => {
return (
<motion.div key={item.id} layout="position">
<IntegrationCard {...item.integration} name={item.name}>
<IntegrationCardFooter className="row justify-between items-center">
<PingBadge>Connected</PingBadge>
<div className="row gap-2">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
showConfirm({
title: `Delete ${item.name}?`,
text: 'This action cannot be undone.',
onConfirm: () => {
deletion.mutate({
id: item.id,
});
},
});
}}
>
Delete
</Button>
<Button
variant="ghost"
onClick={() => {
pushModal('AddIntegration', {
id: item.id,
type: item.config.type,
});
}}
>
Edit
</Button>
</div>
</IntegrationCardFooter>
</IntegrationCard>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlugIcon } from 'lucide-react';
import { IntegrationCard, IntegrationCardFooter } from './integration-card';
import { INTEGRATIONS } from './integrations';
export function AllIntegrations() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{INTEGRATIONS.map((integration) => (
<IntegrationCard
key={integration.name}
icon={integration.icon}
name={integration.name}
description={integration.description}
>
<IntegrationCardFooter className="row justify-end">
<Button
variant="outline"
onClick={() => {
pushModal('AddIntegration', {
type: integration.type,
});
}}
>
<PlugIcon className="size-4 mr-2" />
Connect
</Button>
</IntegrationCardFooter>
</IntegrationCard>
))}
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { sendTestDiscordNotification } from '@openpanel/integrations/src/discord';
import { zCreateDiscordIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { path, mergeDeepRight } from 'ramda';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateDiscordIntegration>;
export function DiscordIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
organizationId,
projectId,
config: {
type: 'discord' as const,
url: '',
headers: {},
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateDiscordIntegration),
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({
onSuccess,
onError() {
toast.error('Failed to create integration');
},
}),
);
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
const handleTest = async () => {
const webhookUrl = form.getValues('config.url');
if (!webhookUrl) {
return toast.error('Webhook URL is required');
}
const res = await sendTestDiscordNotification(webhookUrl);
if (res.ok) {
toast.success('Test notification sent');
} else {
toast.error('Failed to send test notification');
}
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
placeholder="Eg. My personal discord"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="Discord Webhook URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<div className="row gap-4">
<Button type="button" variant="outline" onClick={handleTest}>
Test connection
</Button>
<Button type="submit" className="flex-1">
Create
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,68 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateSlackIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateSlackIntegration>;
export function SlackIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
defaultValues: {
id: defaultValues?.id,
organizationId,
projectId,
name: defaultValues?.name ?? '',
},
resolver: zodResolver(zCreateSlackIntegration),
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdateSlack.mutationOptions({
async onSuccess(res) {
window.location.href = res.slackInstallUrl;
onSuccess();
},
onError() {
toast.error('Failed to create integration');
},
}),
);
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
placeholder="Eg. My personal slack"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -0,0 +1,77 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateWebhookIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { path, mergeDeepRight } from 'ramda';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateWebhookIntegration>;
export function WebhookIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
organizationId,
projectId,
config: {
type: 'webhook' as const,
url: '',
headers: {},
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateWebhookIntegration),
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({
onSuccess,
onError() {
toast.error('Failed to create integration');
},
}),
);
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
placeholder="Eg. Zapier webhook"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -0,0 +1,144 @@
import { Skeleton } from '@/components/skeleton';
import { cn } from '@/utils/cn';
export function IntegrationCardFooter({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('row p-4 border-t rounded-b', className)}>
{children}
</div>
);
}
export function IntegrationCardHeader({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('relative row p-4 border-b rounded-t', className)}>
{children}
</div>
);
}
export function IntegrationCardHeaderButtons({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'absolute right-4 top-0 bottom-0 row items-center gap-2',
className,
)}
>
{children}
</div>
);
}
export function IntegrationCardLogoImage({
src,
backgroundColor,
}: {
src: string;
backgroundColor: string;
}) {
return (
<IntegrationCardLogo
style={{
backgroundColor,
}}
>
<img src={src} alt="Integration Logo" />
</IntegrationCardLogo>
);
}
export function IntegrationCardLogo({
children,
className,
...props
}: {
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'size-14 rounded overflow-hidden shrink-0 center-center',
className,
)}
{...props}
>
{children}
</div>
);
}
export function IntegrationCard({
icon,
name,
description,
children,
}: {
icon: React.ReactNode;
name: string;
description: string;
children?: React.ReactNode;
}) {
return (
<div className="card self-start">
<IntegrationCardContent
icon={icon}
name={name}
description={description}
/>
{children}
</div>
);
}
export function IntegrationCardContent({
icon,
name,
description,
}: {
icon: React.ReactNode;
name: string;
description: string;
}) {
return (
<div className="row gap-4 p-4">
{icon}
<div className="col gap-1">
<h2 className="title">{name}</h2>
<p className="text-muted-foreground leading-tight">{description}</p>
</div>
</div>
);
}
export function IntegrationCardSkeleton() {
return (
<div className="card self-start">
<div className="row gap-4 p-4">
<Skeleton className="size-14 rounded shrink-0" />
<div className="col gap-1 flex-grow">
<Skeleton className="h-5 w-1/2 mb-2" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { IIntegrationConfig } from '@openpanel/validation';
import { WebhookIcon } from 'lucide-react';
import {
IntegrationCardLogo,
IntegrationCardLogoImage,
} from './integration-card';
export const INTEGRATIONS: {
type: IIntegrationConfig['type'];
name: string;
description: string;
icon: React.ReactNode;
}[] = [
{
type: 'slack',
name: 'Slack',
description:
'Connect your Slack workspace to get notified when new issues are created.',
icon: (
<IntegrationCardLogoImage
src="https://play-lh.googleusercontent.com/mzJpTCsTW_FuR6YqOPaLHrSEVCSJuXzCljdxnCKhVZMcu6EESZBQTCHxMh8slVtnKqo"
backgroundColor="#481449"
/>
),
},
{
type: 'discord',
name: 'Discord',
description:
'Connect your Discord server to get notified when new issues are created.',
icon: (
<IntegrationCardLogoImage
src="https://static.vecteezy.com/system/resources/previews/006/892/625/non_2x/discord-logo-icon-editorial-free-vector.jpg"
backgroundColor="#5864F2"
/>
),
},
{
type: 'webhook',
name: 'Webhook',
description:
'Create a webhook to take actions in your own systems when new events are created.',
icon: (
<IntegrationCardLogo className="bg-foreground text-background">
<WebhookIcon className="size-10" />
</IntegrationCardLogo>
),
},
];

View File

@@ -0,0 +1,79 @@
import { type ReactNode, type RefObject, useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
export interface LazyComponentProps {
/**
* Whether to enable lazy loading. If false, component renders immediately.
* @default true
*/
lazy?: boolean;
/**
* Content to render when the component is in viewport (or immediately if lazy=false)
*/
children: ReactNode;
/**
* Optional loading placeholder to show while waiting for viewport intersection
*/
fallback?: ReactNode;
/**
* Additional className for the wrapper div
*/
className?: string;
/**
* Custom viewport options for intersection observer
*/
viewportOptions?: {
rootMargin?: string;
threshold?: number | number[];
};
/**
* Whether to disconnect the intersection observer after first load
* @default true
*/
disconnectOnLeave?: boolean;
}
/**
* A reusable lazy loading component that renders its children only when
* they come into the viewport (or immediately if lazy=false).
*
* Uses intersection observer under the hood for efficient viewport detection.
*/
export const LazyComponent = ({
lazy = true,
children,
fallback = null,
className,
viewportOptions,
disconnectOnLeave = true,
}: LazyComponentProps) => {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(
ref as RefObject<HTMLElement>,
viewportOptions,
{
disconnectOnLeave,
},
);
useEffect(() => {
if (inViewport) {
once.current = true;
}
}, [inViewport]);
const shouldRender = lazy ? once.current || inViewport : true;
return (
<div ref={ref} className={className}>
{shouldRender ? children : (fallback ?? <div />)}
</div>
);
};

View File

@@ -0,0 +1,36 @@
import { useAppParams } from '@/hooks/use-app-params';
import { Link, type LinkComponentProps } from '@tanstack/react-router';
import { omit } from 'ramda';
export function ProjectLink({
children,
...props
}: LinkComponentProps & {
children: React.ReactNode;
className?: string;
title?: string;
exact?: boolean;
}) {
const { organizationId, projectId } = useAppParams();
if (typeof props.href === 'string') {
return (
<Link
to={
`/$organizationId/$projectId/${props.href.replace(/^\//, '')}` as any
}
activeOptions={{ exact: props.exact ?? true }}
params={
{
organizationId,
projectId,
} as any
}
{...omit(['href'], props)}
>
{children}
</Link>
);
}
return <p>ProjectLink</p>;
}

View File

@@ -0,0 +1,104 @@
import { LogoSquare } from '@/components/logo';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { SellingPoint } from './selling-points';
const sellingPoints = [
{
key: 'welcome',
render: () => (
<SellingPoint
bgImage="/img-1.png"
title="Best open-source alternative"
description="Mixpanel to expensive, Google Analytics has no privacy, Amplitude old and boring"
/>
),
},
{
key: 'selling-point-2',
render: () => (
<SellingPoint
bgImage="/img-2.png"
title="Fast and reliable"
description="Never miss a beat with our real-time analytics"
/>
),
},
{
key: 'selling-point-3',
render: () => (
<SellingPoint
bgImage="/img-3.png"
title="Easy to use"
description="Compared to other tools we have kept it simple"
/>
),
},
{
key: 'selling-point-4',
render: () => (
<SellingPoint
bgImage="/img-4.png"
title="Privacy by default"
description="We have built our platform with privacy at its heart"
/>
),
},
{
key: 'selling-point-5',
render: () => (
<SellingPoint
bgImage="/img-5.png"
title="Open source"
description="You can inspect the code and self-host if you choose"
/>
),
},
];
export function LoginLeftPanel() {
return (
<div className="relative h-screen overflow-hidden">
<div className="row justify-between items-center p-8">
<LogoSquare className="h-8 w-8" />
<a
href="https://openpanel.dev"
className="text-sm text-muted-foreground"
>
Back to website
</a>
</div>
{/* Carousel */}
<div className="flex items-center justify-center h-full">
<Carousel
className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
opts={{
loop: true,
align: 'center',
}}
>
<CarouselContent className="h-full">
{sellingPoints.map((point, index) => (
<CarouselItem
key={`selling-point-${point.key}`}
className="p-8 pb-32 pt-0"
>
<div className="rounded-xl min-h-full h-full overflow-hidden bg-card border border-border shadow-lg">
{point.render()}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-12 bottom-30 top-auto" />
<CarouselNext className="right-12 bottom-30 top-auto" />
</Carousel>
</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('flex items-center gap-2 text-xl font-medium', className)}
>
<LogoSquare className="max-h-8" />
<span>openpanel.dev</span>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { memo } from 'react';
import ReactMarkdown, { type Options } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkHighlight from 'remark-highlight';
import remarkMath from 'remark-math';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import 'katex/dist/katex.min.css';
export const Markdown = memo<Options>(
(props) => (
<ReactMarkdown
{...props}
remarkPlugins={[remarkParse, remarkHighlight, remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, remarkRehype]}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
'className' in prevProps &&
'className' in nextProps &&
prevProps.className === nextProps.className,
);
Markdown.displayName = 'Markdown';

View File

@@ -0,0 +1,142 @@
import { EventListItem } from '@/components/events/event-list-item';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import type { IServiceEventMinimal } from '@openpanel/db';
const useWebEventGenerator = () => {
const [events, setEvents] = useState<IServiceEventMinimal[]>([]);
const eventNames = [
'screen_view',
'session_start',
'session_end',
'submit_form',
'sign_in',
'sign_up',
'purchase_flow',
'purchase_flow_completed',
'subscription_started',
];
const browsers = [
'Chrome WebView',
'Firefox',
'Safari',
'Edge',
'Chrome',
'Opera',
'Internet Explorer',
];
const paths = [
'/features/',
'/contact/',
'/about/',
'/pricing/',
'/blog/',
'/signup/',
'/login/',
];
const countries = [
'BY',
'US',
'FR',
'IN',
'DE',
'JP',
'BR',
'ZA',
'EG',
'AU',
'RU',
'CN',
'IT',
'GB',
'CA',
];
const os = [
'Windows',
'MacOS',
'iOS',
'Android',
'Linux',
'Chrome OS',
'Windows Phone',
];
// Function to generate a random event
const generateEvent = (index?: number): IServiceEventMinimal => {
const event = {
id: Math.random().toString(36).substring(2, 15),
name: eventNames[Math.floor(Math.random() * eventNames.length)]!,
projectId: 'marketing-site',
sessionId: Math.random().toString(36).substring(2, 15),
createdAt: new Date(new Date().getTime() - (index || 0) * 1000),
country: countries[Math.floor(Math.random() * countries.length)],
longitude: 27.5709,
latitude: 53.9007,
os: os[Math.floor(Math.random() * os.length)],
browser: browsers[Math.floor(Math.random() * browsers.length)],
device: 'mobile',
brand: 'Xiaomi',
duration: 0,
path: paths[Math.floor(Math.random() * paths.length)]!,
origin: 'https://www.voxie.com',
referrer: 'https://syndicatedsearch.goog',
meta: undefined,
minimal: true,
};
return event;
};
useEffect(() => {
let timer: NodeJS.Timeout;
// Generate initial 30 events
const initialEvents = Array.from({ length: 30 }).map((_, index) => {
return generateEvent(index);
});
setEvents(initialEvents);
function createNewEvent() {
const newEvent = generateEvent();
setEvents((prevEvents) => [newEvent, ...prevEvents]);
timer = setTimeout(() => createNewEvent(), Math.random() * 3000);
}
createNewEvent();
return () => clearInterval(timer);
}, []);
return events;
};
export const MockEventList = () => {
const state = useWebEventGenerator();
return (
<div className="hide-scrollbar h-screen overflow-y-auto">
<div className="text-background-foreground py-16 text-center text-2xl font-bold">
Real time data
<br />
at your fingertips
</div>
<AnimatePresence mode="popLayout" initial>
<div className="flex flex-col gap-4 p-4">
{state.map((event) => (
<motion.div
key={event.id}
layout
initial={{ opacity: 0, x: -400, scale: 0.5 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 200, scale: 1.2 }}
transition={{ duration: 0.6, type: 'spring' }}
>
<EventListItem {...event} minimal />
</motion.div>
))}
</div>
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { useAppParams } from '@/hooks/use-app-params';
import useWS from '@/hooks/use-ws';
import type { Notification } from '@openpanel/db';
import { BellIcon } from 'lucide-react';
import { toast } from 'sonner';
export function NotificationProvider() {
const { projectId } = useAppParams();
if (!projectId) return null;
return <InnerNotificationProvider projectId={projectId} />;
}
export function InnerNotificationProvider({
projectId,
}: { projectId: string }) {
useWS<Notification>(`/live/notifications/${projectId}`, (notification) => {
toast(notification.title, {
description: notification.message,
icon: <BellIcon className="size-4" />,
});
});
return null;
}

View File

@@ -0,0 +1,84 @@
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { PencilRulerIcon, PlusIcon } from 'lucide-react';
import { useMemo } from 'react';
import { FullPageEmptyState } from '../full-page-empty-state';
import { IntegrationCardSkeleton } from '../integrations/integration-card';
import { Button } from '../ui/button';
import { RuleCard } from './rule-card';
export function NotificationRules() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.notification.rules.queryOptions({
projectId,
}),
);
const data = useMemo(() => {
return query.data || [];
}, [query.data]);
const isLoading = query.isLoading;
if (!isLoading && data.length === 0) {
return (
<FullPageEmptyState title="No rules yet" icon={PencilRulerIcon}>
<p>
You have not created any rules yet. Create a rule to start getting
notifications.
</p>
<Button
className="mt-8"
variant="outline"
onClick={() =>
pushModal('AddNotificationRule', {
rule: undefined,
})
}
>
Add Rule
</Button>
</FullPageEmptyState>
);
}
return (
<div>
<div className="mb-2">
<Button
icon={PlusIcon}
variant="outline"
onClick={() =>
pushModal('AddNotificationRule', {
rule: undefined,
})
}
>
Add Rule
</Button>
</div>
<div className="col gap-4 w-full grid md:grid-cols-2">
{isLoading && (
<>
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
</>
)}
<AnimatePresence mode="popLayout">
{data.map((item) => {
return (
<motion.div key={item.id} layout="position">
<RuleCard rule={item} />
</motion.div>
);
})}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { NotificationsTable } from './table';
export function Notifications() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.notification.list.queryOptions({
projectId,
}),
);
return <NotificationsTable query={query} />;
}

View File

@@ -0,0 +1,133 @@
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import type { RouterOutputs } from '@/trpc/client';
import type { NotificationRule } from '@openpanel/db';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FilterIcon } from 'lucide-react';
import { toast } from 'sonner';
import { ColorSquare } from '../color-square';
import {
IntegrationCardFooter,
IntegrationCardHeader,
} from '../integrations/integration-card';
import { PingBadge } from '../ping';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Tooltiper } from '../ui/tooltip';
function EventBadge({
event,
}: { event: NotificationRule['config']['events'][number] }) {
return (
<Tooltiper
disabled={!event.filters.length}
content={
<div className="col gap-2 font-mono">
{event.filters.map((filter) => (
<div key={filter.id}>
{filter.name} {filter.operator} {JSON.stringify(filter.value)}
</div>
))}
</div>
}
>
<Badge variant="outline" className="inline-flex">
{event.name === '*' ? 'Any event' : event.name}
{Boolean(event.filters.length) && (
<FilterIcon className="size-2 ml-1" />
)}
</Badge>
</Tooltiper>
);
}
export function RuleCard({
rule,
}: { rule: RouterOutputs['notification']['rules'][number] }) {
const trpc = useTRPC();
const client = useQueryClient();
const deletion = useMutation(
trpc.notification.deleteRule.mutationOptions({
onSuccess() {
toast.success('Rule deleted');
client.refetchQueries(trpc.notification.rules.pathFilter());
},
}),
);
const renderConfig = () => {
switch (rule.config.type) {
case 'events':
return (
<div className="row gap-2 items-baseline flex-wrap">
<div>Get notified when</div>
{rule.config.events.map((event) => (
<EventBadge key={event.id} event={event} />
))}
<div>occurs</div>
</div>
);
case 'funnel':
return (
<div className="col gap-4">
<div>Get notified when a session has completed this funnel</div>
<div className="col gap-2">
{rule.config.events.map((event, index) => (
<div
key={event.id}
className="row gap-2 items-center font-mono"
>
<ColorSquare>{index + 1}</ColorSquare>
<EventBadge key={event.id} event={event} />
</div>
))}
</div>
</div>
);
}
};
return (
<div className="card">
<IntegrationCardHeader>
<div className="title">{rule.name}</div>
</IntegrationCardHeader>
<div className="p-4 col gap-2">{renderConfig()}</div>
<IntegrationCardFooter className="row gap-2 justify-between items-center">
<div className="row gap-2 flex-wrap">
{rule.integrations.map((integration) => (
<PingBadge key={integration.id}>{integration.name}</PingBadge>
))}
</div>
<div className="row gap-2">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
showConfirm({
title: `Delete ${rule.name}?`,
text: 'This action cannot be undone.',
onConfirm: () => {
deletion.mutate({
id: rule.id,
});
},
});
}}
>
Delete
</Button>
<Button
variant="ghost"
onClick={() => {
pushModal('AddNotificationRule', {
rule,
});
}}
>
Edit
</Button>
</div>
</IntegrationCardFooter>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { formatDateTime, formatTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers';
import type { RouterOutputs } from '@/trpc/client';
import type { INotificationPayload } from '@openpanel/db';
function getEventFromPayload(payload: INotificationPayload | null) {
if (payload?.type === 'event') {
return payload.event;
}
if (payload?.type === 'funnel') {
return payload.funnel[0] || null;
}
return null;
}
export function useColumns() {
const columns: ColumnDef<RouterOutputs['notification']['list'][number]>[] = [
{
accessorKey: 'title',
header: 'Title',
cell({ row }) {
const { title } = row.original;
return (
<div className="row gap-2 items-center">
{/* {isReadAt === null && <PingBadge>Unread</PingBadge>} */}
<span className="max-w-md truncate font-medium">{title}</span>
</div>
);
},
meta: {
variant: 'text',
placeholder: 'Search',
label: 'Title',
},
},
{
accessorKey: 'message',
header: 'Message',
cell({ row }) {
const { message } = row.original;
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
{message}
</div>
);
},
meta: {
label: 'Message',
hidden: true,
},
},
{
accessorKey: 'integration',
header: 'Integration',
cell({ row }) {
const integration = row.original.integration;
return <div>{integration?.name}</div>;
},
meta: {
label: 'Integration',
},
},
{
accessorKey: 'notificationRule',
header: 'Rule',
cell({ row }) {
const rule = row.original.notificationRule;
return <div>{rule?.name}</div>;
},
meta: {
label: 'Rule',
hidden: true,
},
},
{
accessorKey: 'country',
header: 'Country',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={event.country} />
<span>{event.city}</span>
</div>
);
},
meta: {
label: 'Country',
},
},
{
accessorKey: 'os',
header: 'OS',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<div className="flex min-w-full items-center gap-2">
<SerieIcon name={event.os} />
<span>{event.os}</span>
</div>
);
},
meta: {
label: 'OS',
},
},
{
accessorKey: 'browser',
header: 'Browser',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={event.browser} />
<span>{event.browser}</span>
</div>
);
},
meta: {
label: 'Browser',
},
},
{
accessorKey: 'profile',
header: createHeaderColumn('Profile'),
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<ProjectLink
href={`/profiles/${event.profileId}`}
className="inline-flex min-w-full flex-none items-center gap-2"
>
{event.profileId}
</ProjectLink>
);
},
meta: {
label: 'Profile',
},
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
if (!date) {
return null;
}
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
filterFn: 'isWithinRange',
meta: {
variant: 'dateRange',
placeholder: 'Created at',
label: 'Created at',
},
},
];
return columns;
}

View File

@@ -0,0 +1,32 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { DataTable } from '@/components/ui/data-table/data-table';
import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar';
import { useTable } from '@/components/ui/data-table/use-table';
import type { RouterOutputs } from '@/trpc/client';
import { useColumns } from './columns';
type Props = {
query: UseQueryResult<
RouterOutputs['notification']['list'][number][],
unknown
>;
};
export const NotificationsTable = ({ query }: Props) => {
const columns = useColumns();
const { data, isLoading } = query;
const { table } = useTable({
columns,
data: data ?? [],
loading: isLoading,
pageSize: 50,
});
return (
<>
<DataTableToolbar table={table} />
<DataTable table={table} loading={isLoading} />;
</>
);
};

View File

@@ -0,0 +1,132 @@
import { LogoSquare } from '@/components/logo';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { Link } from '@tanstack/react-router';
import { CodeIcon, CreditCardIcon, DollarSignIcon } from 'lucide-react';
import { SellingPoint } from './selling-points';
const onboardingSellingPoints = [
{
key: 'get-started',
render: () => (
<SellingPoint
bgImage="/img-6.png"
title="Get started in minutes"
description={
<>
<p>
<DollarSignIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Free trial
</p>
<p>
<CreditCardIcon className="size-4 inline-block mr-1 relative -top-0.5" />
No credit card required
</p>
<p>
<CodeIcon className="size-4 inline-block mr-1 relative -top-0.5" />
Add our tracking code and get insights in real-time.
</p>
</>
}
/>
),
},
{
key: 'welcome',
render: () => (
<SellingPoint
bgImage="/img-1.png"
title="Best open-source alternative"
description="Mixpanel to expensive, Google Analytics has no privacy, Amplitude old and boring"
/>
),
},
{
key: 'selling-point-2',
render: () => (
<SellingPoint
bgImage="/img-2.png"
title="Fast and reliable"
description="Never miss a beat with our real-time analytics"
/>
),
},
{
key: 'selling-point-3',
render: () => (
<SellingPoint
bgImage="/img-3.png"
title="Easy to use"
description="Compared to other tools we have kept it simple"
/>
),
},
{
key: 'selling-point-4',
render: () => (
<SellingPoint
bgImage="/img-4.png"
title="Privacy by default"
description="We have built our platform with privacy at its heart"
/>
),
},
{
key: 'selling-point-5',
render: () => (
<SellingPoint
bgImage="/img-5.png"
title="Open source"
description="You can inspect the code and self-host if you choose"
/>
),
},
];
export function OnboardingLeftPanel() {
return (
<div className="sticky top-0 h-screen overflow-hidden">
<div className="row justify-between items-center p-8">
<LogoSquare className="h-8 w-8" />
<div className="text-sm text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="text-sm text-muted-foreground underline">
Sign in
</Link>
</div>
</div>
{/* Carousel */}
<div className="flex items-center justify-center h-full">
<Carousel
className="w-full h-full [&>div]:h-full [&>div]:min-h-full"
opts={{
loop: true,
align: 'center',
}}
>
<CarouselContent className="h-full">
{onboardingSellingPoints.map((point, index) => (
<CarouselItem
key={`onboarding-point-${point.key}`}
className="p-8 pb-32 pt-0"
>
<div className="rounded-xl min-h-full h-full overflow-hidden bg-card border border-border shadow-lg">
{point.render()}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-12 bottom-30 top-auto" />
<CarouselNext className="right-12 bottom-30 top-auto" />
</Carousel>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { pushModal } from '@/modals';
import { SmartphoneIcon } from 'lucide-react';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectApp = ({ client }: Props) => {
return (
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<SmartphoneIcon className="size-4" />
App
</div>
<div className="text-muted-foreground mb-2">
Pick a framework below to get started.
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('app'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
);
};
export default ConnectApp;

View File

@@ -0,0 +1,86 @@
import { pushModal } from '@/modals';
import { ServerIcon } from 'lucide-react';
import Syntax from '@/components/syntax';
import { useAppContext } from '@/hooks/use-app-context';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectBackend = ({ client }: Props) => {
const context = useAppContext();
return (
<>
<div>
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<ServerIcon className="size-4" />
Backend
</div>
<div className="text-muted-foreground mb-2">
Try with a basic curl command
</div>
</div>
<Syntax
language="bash"
className="border"
code={`curl -X POST ${context.apiUrl}/track \\
-H "Content-Type: application/json" \\
-H "openpanel-client-id: ${client?.id}" \\
-H "openpanel-client-secret: ${client?.secret}" \\
-d '{
"type": "track",
"payload": {
"name": "test_event",
"properties": {
"test": "property"
}
}
}'`}
/>
</div>
<div>
<p className="text-muted-foreground mb-2">
Pick a framework below to get started.
</p>
<div className="grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('backend'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
</>
);
};
export default ConnectBackend;

View File

@@ -0,0 +1,78 @@
import { pushModal } from '@/modals';
import { MonitorIcon } from 'lucide-react';
import Syntax from '@/components/syntax';
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
type Props = {
client: IServiceClient | null;
};
const ConnectWeb = ({ client }: Props) => {
return (
<>
<div>
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
<MonitorIcon className="size-4" />
Website
</div>
<div className="text-muted-foreground mb-2">
Paste the script to your website
</div>
<Syntax
className="border"
code={`<script>
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);};
window.op('init', {
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>`}
/>
</div>
<div>
<p className="text-muted-foreground mb-2">
Or pick a framework below to get started.
</p>
<div className="grid gap-4 md:grid-cols-2">
{frameworks
.filter((framework) => framework.type.includes('website'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"
className="text-foreground underline"
>
Let us know!
</a>
</p>
</div>
</>
);
};
export default ConnectWeb;

View File

@@ -0,0 +1,72 @@
import { useAppContext } from '@/hooks/use-app-context';
import { useClientSecret } from '@/hooks/use-client-secret';
import { clipboard } from '@/utils/clipboard';
import type { IServiceProjectWithClients } from '@openpanel/db';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
export function CurlPreview({
project,
}: { project: IServiceProjectWithClients }) {
const context = useAppContext();
const [secret] = useClientSecret();
const client = project.clients[0];
if (!client) {
return null;
}
const payload: Record<string, any> = {
type: 'track',
payload: {
name: 'screen_view',
properties: {
__title: `Testing OpenPanel - ${project.name}`,
__path: `${project.domain}`,
__referrer: `${context.dashboardUrl}`,
},
},
};
if (project.types.includes('app')) {
payload.payload.properties.__path = '/';
delete payload.payload.properties.__referrer;
}
if (project.types.includes('backend')) {
payload.payload.name = 'test_event';
payload.payload.properties = {};
}
const code = `curl -X POST ${context.apiUrl}/track \\
-H "Content-Type: application/json" \\
-H "openpanel-client-id: ${client.id}" \\
-H "openpanel-client-secret: ${secret}" \\
-H "User-Agent: ${typeof window !== 'undefined' ? window.navigator.userAgent : ''}" \\
-d '${JSON.stringify(payload)}'`;
return (
<div className="card">
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger
className="px-6"
onClick={() => {
clipboard(code, null);
}}
>
Try out the curl command
</AccordionTrigger>
<AccordionContent className="p-0">
<Syntax code={code} language="bash" />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import useWS from '@/hooks/use-ws';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { timeAgo } from '@/utils/date';
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
import { useState } from 'react';
import type {
IServiceClient,
IServiceEvent,
IServiceProject,
} from '@openpanel/db';
type Props = {
project: IServiceProject;
client: IServiceClient | null;
events: IServiceEvent[];
onVerified: (verified: boolean) => void;
};
const VerifyListener = ({
client,
events: _events,
onVerified,
project,
}: Props) => {
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
useWS<IServiceEvent>(
`/live/events/${client?.projectId}?type=received`,
(data) => {
setEvents((prev) => [...prev, data]);
onVerified(true);
},
);
const isConnected = events.length > 0;
const renderIcon = () => {
if (isConnected) {
return (
<CheckCircle2Icon
strokeWidth={1.2}
size={40}
className="shrink-0 text-emerald-600"
/>
);
}
return (
<Loader2 size={40} className="shrink-0 animate-spin text-highlight" />
);
};
return (
<div>
<div
className={cn(
'flex gap-6 rounded-xl p-4 md:p-6',
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10',
)}
>
{renderIcon()}
<div className="flex-1">
<div className="text-lg font-semibold leading-normal text-foreground/90">
{isConnected ? 'Success' : 'Waiting for events'}
</div>
{isConnected ? (
<div className="flex flex-col-reverse">
{events.length > 5 && (
<div className="flex items-center gap-2 ">
<CheckIcon size={14} />{' '}
<span>{events.length - 5} more events</span>
</div>
)}
{events.slice(-5).map((event) => (
<div key={event.id} className="flex items-center gap-2 ">
<CheckIcon size={14} />{' '}
<span className="font-medium">{event.name}</span>{' '}
<span className="ml-auto text-emerald-800">
{timeAgo(event.createdAt, 'round')}
</span>
</div>
))}
</div>
) : (
<div className="text-foreground/50">
Verify that your implementation works.
</div>
)}
</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">
You can{' '}
<button
type="button"
className="underline"
onClick={() => {
pushModal('OnboardingTroubleshoot', {
client,
type: 'app',
});
}}
>
troubleshoot
</button>{' '}
if you are having issues connecting your app.
</div>
</div>
);
};
export default VerifyListener;

View File

@@ -0,0 +1,71 @@
import { useLogout } from '@/hooks/use-logout';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useLocation, useNavigate } from '@tanstack/react-router';
import { ChevronLastIcon, LogInIcon } from 'lucide-react';
import { useEffect } from 'react';
const PUBLIC_SEGMENTS = [['onboarding']];
export const SkipOnboarding = () => {
const trpc = useTRPC();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const segments = location.pathname.split('/').filter(Boolean);
const isPublic = PUBLIC_SEGMENTS.some((segment) =>
segments.every((s, index) => s === segment[index]),
);
const res = useQuery(
trpc.onboarding.skipOnboardingCheck.queryOptions(undefined, {
enabled: !isPublic,
}),
);
const logout = useLogout();
useEffect(() => {
res.refetch();
}, [pathname]);
// Do not show skip onboarding for the first step (register account)
if (isPublic) {
return (
<Link
to="/login"
className="flex items-center gap-2 text-muted-foreground"
>
Login
<LogInIcon size={16} />
</Link>
);
}
if (res.isLoading || res.isError) {
return null;
}
return (
<button
type="button"
onClick={() => {
if (res.data?.canSkip) {
navigate({ to: '/' });
} else {
showConfirm({
title: 'Skip onboarding?',
text: 'Are you sure you want to skip onboarding? Since you do not have any projects, you will be logged out.',
onConfirm() {
logout.mutate();
},
});
}
}}
className="flex items-center gap-2 text-muted-foreground"
>
Skip onboarding
<ChevronLastIcon size={16} />
</button>
);
};

View File

@@ -0,0 +1,117 @@
import { cn } from '@/lib/utils';
import { useLocation } from '@tanstack/react-router';
import { CheckCheckIcon } from 'lucide-react';
type Step = {
name: string;
status: 'completed' | 'current' | 'pending';
match: string;
};
type Props = {
className?: string;
};
function useSteps(path: string) {
const steps: Step[] = [
{
name: 'Create an account',
status: 'pending',
match: '/onboarding',
},
{
name: 'Create a project',
status: 'pending',
match: '/onboarding/project',
},
{
name: 'Connect your data',
status: 'pending',
match: '/onboarding/(.+)/connect',
},
{
name: 'Verify',
status: 'pending',
match: '/onboarding/(.+)/verify',
},
];
// @ts-ignore
const matchIndex = steps.findLastIndex((step) =>
path.match(new RegExp(step.match)),
);
return steps.map((step, index) => {
if (index < matchIndex) {
return { ...step, status: 'completed' };
}
if (index === matchIndex) {
return { ...step, status: 'current' };
}
return step;
});
}
export const OnboardingSteps = ({ className }: Props) => {
const location = useLocation();
const path = location.pathname;
const steps = useSteps(path);
const currentIndex = steps.findIndex((i) => i.status === 'current');
return (
<div className="relative">
<div className="absolute bottom-4 left-4 top-4 w-px bg-def-200" />
<div
className="absolute left-4 top-4 w-px bg-highlight"
style={{
height: `calc(${((currentIndex + 1) / steps.length) * 100}% - 3.5rem)`,
}}
/>
<div
className={cn(
'relative flex gap-4 overflow-hidden md:-ml-3 md:flex-col md:gap-8',
className,
)}
>
{steps.map((step, index) => (
<div
className={cn(
'flex flex-shrink-0 items-center gap-4 self-start px-3 py-1.5',
step.status === 'current' &&
'rounded-xl border border-border bg-card',
step.status === 'completed' &&
index !== currentIndex - 1 &&
'max-md:hidden',
)}
key={step.name}
>
<div
className={cn(
'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white',
)}
>
<div
className={cn(
'absolute inset-0 z-0 rounded-full bg-highlight',
step.status === 'pending' && 'bg-def-400',
)}
/>
{step.status === 'current' && (
<div className="absolute inset-1 z-0 animate-ping-slow rounded-full bg-highlight" />
)}
<div className="relative">
{step.status === 'completed' && <CheckCheckIcon size={14} />}
{/* {step.status === 'current' && (
<ArrowRightCircleIcon size={14} />
)} */}
{(step.status === 'pending' || step.status === 'current') &&
index + 1}
</div>
</div>
<div className="font-medium">{step.name}</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,70 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Widget, WidgetHead } from '@/components/widget';
const questions = [
{
question: 'Does OpenPanel have a free tier?',
answer: [
'For our Cloud plan we offer a 14 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan.',
'OpenPanel is also open-source and you can self-host it for free!',
'',
'Why does OpenPanel not have a free tier?',
'We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
],
},
{
question: 'What happens if my site exceeds the limit?',
answer: [
"You will not see any new events in OpenPanel until your next billing period. If this happens 2 months in a row, we'll advice you to upgrade your plan.",
],
},
{
question: 'What happens if I cancel my subscription?',
answer: [
'If you cancel your subscription, you will still have access to OpenPanel until the end of your current billing period. You can reactivate your subscription at any time.',
'After your current billing period ends, you will not get access to new data.',
"NOTE: If your account has been inactive for 3 months, we'll delete your events.",
],
},
{
question: 'How do I change my billing information?',
answer: [
'You can change your billing information by clicking the "Manage your subscription" button in the billing section.',
],
},
];
export function BillingFaq() {
return (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span>
</WidgetHead>
<Accordion
type="single"
collapsible
className="w-full max-w-screen-md self-center"
>
{questions.map((q) => (
<AccordionItem value={q.question} key={q.question}>
<AccordionTrigger className="text-left px-4">
{q.question}
</AccordionTrigger>
<AccordionContent>
<div className="col gap-2 p-4 pt-2">
{q.answer.map((a) => (
<p key={a}>{a}</p>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Widget>
);
}

View File

@@ -0,0 +1,432 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/use-app-params';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { op } from '@/utils/op';
import type { IServiceOrganization } from '@openpanel/db';
import type { IPolarPrice } from '@openpanel/payments';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2Icon } from 'lucide-react';
import { useQueryState } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function Billing({ organization }: Props) {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const trpc = useTRPC();
const [customerSessionToken, setCustomerSessionToken] = useQueryState(
'customer_session_token',
);
const productsQuery = useQuery(
trpc.subscription.products.queryOptions({
organizationId: organization.id,
}),
);
useWS(`/live/organization/${organization.id}`, () => {
queryClient.invalidateQueries(trpc.organization.pathFilter());
});
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
(organization.subscriptionInterval as 'year' | 'month') || 'month',
);
const products = useMemo(() => {
return (productsQuery.data || [])
.filter((product) => product.recurringInterval === recurringInterval)
.filter((product) => product.prices.some((p) => p.amountType !== 'free'));
}, [productsQuery.data, recurringInterval]);
useEffect(() => {
if (organization.subscriptionInterval) {
setRecurringInterval(
organization.subscriptionInterval as 'year' | 'month',
);
}
}, [organization.subscriptionInterval]);
useEffect(() => {
if (customerSessionToken) {
op.track('subscription_created');
}
}, [customerSessionToken]);
const [selectedProductIndex, setSelectedProductIndex] = useState<number>(0);
// Check if organization has a custom product
const hasCustomProduct = useMemo(() => {
return products.some((product) => product.metadata?.custom === true);
}, [products]);
// Find current subscription index
const currentSubscriptionIndex = useMemo(() => {
if (!organization.subscriptionProductId) {
// Default to 100K events plan if no subscription
const defaultIndex = products.findIndex(
(product) => product.metadata?.eventsLimit === 100_000,
);
return defaultIndex >= 0 ? defaultIndex : 0;
}
return products.findIndex(
(product) => product.id === organization.subscriptionProductId,
);
}, [products, organization.subscriptionProductId]);
// Check if selected index is the "custom" option (beyond available products)
const isCustomOption = selectedProductIndex >= products.length;
// Find the highest event limit to make the custom option dynamic
const highestEventLimit = useMemo(() => {
const limits = products
.map((product) => product.metadata?.eventsLimit)
.filter((limit): limit is number => typeof limit === 'number');
return Math.max(...limits, 0);
}, [products]);
// Format the custom option label dynamically
const customOptionLabel = useMemo(() => {
if (highestEventLimit >= 1_000_000) {
return `+${(highestEventLimit / 1_000_000).toFixed(0)}M`;
}
if (highestEventLimit >= 1_000) {
return `+${(highestEventLimit / 1_000).toFixed(0)}K`;
}
return `+${highestEventLimit}`;
}, [highestEventLimit]);
// Set initial slider position to current subscription
useEffect(() => {
if (currentSubscriptionIndex >= 0) {
setSelectedProductIndex(currentSubscriptionIndex);
}
}, [currentSubscriptionIndex]);
const selectedProduct = products[selectedProductIndex];
const isUpgrade = selectedProductIndex > currentSubscriptionIndex;
const isDowngrade = selectedProductIndex < currentSubscriptionIndex;
const isCurrentPlan = selectedProductIndex === currentSubscriptionIndex;
function renderBillingSlider() {
if (productsQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productsQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
if (hasCustomProduct) {
return (
<div className="p-8 text-center">
<div className="text-muted-foreground">
Not applicable since custom product
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Select your plan</span>
<span className="text-sm text-muted-foreground">
{selectedProduct?.name || 'No plan selected'}
</span>
</div>
<Slider
value={[selectedProductIndex]}
onValueChange={([value]) => setSelectedProductIndex(value)}
min={0}
max={products.length} // +1 for the custom option
step={1}
className="w-full"
disabled={hasCustomProduct}
/>
<div className="flex justify-between text-xs text-muted-foreground">
{products.map((product, index) => {
const eventsLimit = product.metadata?.eventsLimit;
return (
<div key={product.id} className="text-center">
<div className="font-medium">
{eventsLimit && typeof eventsLimit === 'number'
? `${(eventsLimit / 1000).toFixed(0)}K`
: 'Free'}
</div>
<div className="text-xs">events</div>
</div>
);
})}
{/* Add the custom option label */}
<div className="text-center">
<div className="font-medium">{customOptionLabel}</div>
<div className="text-xs">events</div>
</div>
</div>
</div>
{(selectedProduct || isCustomOption) && (
<div className="border rounded-lg p-4 space-y-4">
{isCustomOption ? (
// Custom option content
<>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">Custom Plan</h3>
<p className="text-sm text-muted-foreground">
{customOptionLabel} events per {recurringInterval}
</p>
</div>
<div className="text-right">
<span className="text-lg font-semibold">
Custom Pricing
</span>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-center">
<p className="text-sm text-muted-foreground mb-2">
Need higher limits?
</p>
<p className="text-sm">
Reach out to{' '}
<a
className="underline font-medium"
href="mailto:hello@openpanel.dev"
>
hello@openpanel.dev
</a>{' '}
and we'll help you with a custom quota.
</p>
</div>
</>
) : (
// Regular product content
<>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">{selectedProduct.name}</h3>
<p className="text-sm text-muted-foreground">
{selectedProduct.metadata?.eventsLimit
? `${selectedProduct.metadata.eventsLimit.toLocaleString()} events per ${recurringInterval}`
: 'Free tier'}
</p>
</div>
<div className="text-right">
{selectedProduct.prices[0]?.amountType === 'free' ? (
<span className="text-lg font-semibold">Free</span>
) : (
<span className="text-lg font-semibold">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency:
selectedProduct.prices[0]?.priceCurrency || 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(
(selectedProduct.prices[0] &&
'priceAmount' in selectedProduct.prices[0]
? selectedProduct.prices[0].priceAmount
: 0) / 100,
)}
<span className="text-sm text-muted-foreground">
{' / '}
{recurringInterval === 'year' ? 'year' : 'month'}
</span>
</span>
)}
</div>
</div>
{!isCurrentPlan && selectedProduct.prices[0] && (
<div className="flex justify-end">
<CheckoutButton
disabled={selectedProduct.disabled}
key={selectedProduct.prices[0].id}
price={selectedProduct.prices[0]}
organization={organization}
projectId={projectId}
buttonText={
isUpgrade
? 'Upgrade'
: isDowngrade
? 'Downgrade'
: 'Activate'
}
/>
</div>
)}
{isCurrentPlan && (
<div className="flex justify-end">
<Button variant="outline" disabled>
Current Plan
</Button>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
return (
<>
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Billing</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{recurringInterval === 'year'
? 'Yearly (2 months free)'
: 'Monthly'}
</span>
<Switch
checked={recurringInterval === 'year'}
onCheckedChange={(checked) =>
setRecurringInterval(checked ? 'year' : 'month')
}
/>
</div>
</WidgetHead>
<WidgetBody>
<div className="-m-4">{renderBillingSlider()}</div>
</WidgetBody>
</Widget>
<Dialog
open={!!customerSessionToken}
onOpenChange={(open) => {
setCustomerSessionToken(null);
if (!open) {
queryClient.invalidateQueries(trpc.organization.pathFilter());
}
}}
>
<DialogContent>
<DialogTitle>Subscription created</DialogTitle>
<DialogDescription>
We have registered your subscription. It'll be activated within a
couple of seconds.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button>OK</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function CheckoutButton({
price,
organization,
projectId,
disabled,
buttonText,
}: {
price: IPolarPrice;
organization: IServiceOrganization;
projectId: string;
disabled?: string | null;
buttonText?: string;
}) {
const trpc = useTRPC();
const isCurrentPrice = organization.subscriptionPriceId === price.id;
const checkout = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
}),
);
const isCanceled =
organization.subscriptionStatus === 'active' &&
isCurrentPrice &&
organization.subscriptionCanceledAt;
const isActive =
organization.subscriptionStatus === 'active' && isCurrentPrice;
return (
<Tooltiper
content={disabled}
tooltipClassName="max-w-xs"
side="left"
disabled={!disabled}
>
<Button
disabled={disabled !== null || (isActive && !isCanceled)}
key={price.id}
onClick={() => {
const createCheckout = () =>
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
if (organization.subscriptionStatus === 'active') {
showConfirm({
title: 'Are you sure?',
text: `You're about the change your subscription.`,
onConfirm: () => {
op.track('subscription_change');
createCheckout();
},
});
} else {
op.track('subscription_checkout', {
product: price.productId,
});
createCheckout();
}
}}
loading={checkout.isPending}
className="w-28"
variant={isActive ? 'outline' : 'default'}
>
{buttonText ||
(isCanceled ? 'Reactivate' : isActive ? 'Active' : 'Activate')}
</Button>
</Tooltiper>
);
}

View File

@@ -0,0 +1,285 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/use-app-params';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { cn } from '@/utils/cn';
import type { IServiceOrganization } from '@openpanel/db';
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Loader2Icon } from 'lucide-react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function CurrentSubscription({ organization }: Props) {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const number = useNumber();
const trpc = useTRPC();
const productQuery = useQuery(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
const cancelSubscription = useMutation(
trpc.subscription.cancelSubscription.mutationOptions({
onSuccess() {
toast.success('Subscription cancelled', {
description: 'It might take a few seconds to update',
});
},
onError(error) {
toast.error(error.message);
},
}),
);
const portalMutation = useMutation(
trpc.subscription.portal.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
}
},
}),
);
const checkout = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
}),
);
useWS(`/live/organization/${organization.id}`, () => {
queryClient.invalidateQueries(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
});
function render() {
if (productQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
if (!productQuery.data) {
return (
<div className="center-center p-8 font-medium">
No subscription found
</div>
);
}
const product = productQuery.data;
const price = product.prices[0]!;
return (
<>
<div className="gap-4 col">
{price.amountType === 'free' && (
<Alert variant="warning">
<AlertTitle>Free plan is removed</AlertTitle>
<AlertDescription>
We've removed the free plan. You can upgrade to a paid plan to
continue using OpenPanel.
</AlertDescription>
</Alert>
)}
<div className="row justify-between">
<div>Name</div>
<div className="text-right font-medium">{product.name}</div>
</div>
{price.amountType === 'fixed' ? (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">
{number.currency(price.priceAmount / 100)}
</div>
</div>
</>
) : (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">FREE</div>
</div>
</>
)}
<div className="row justify-between">
<div>Billing Cycle</div>
<div className="text-right font-medium">
{price.recurringInterval === 'month' ? 'Monthly' : 'Yearly'}
</div>
</div>
{typeof product.metadata.eventsLimit === 'number' && (
<div className="row justify-between">
<div>Events per mount</div>
<div className="text-right font-medium font-mono">
{number.format(product.metadata.eventsLimit)}
</div>
</div>
)}
</div>
{organization.subscriptionProductId &&
!FREE_PRODUCT_IDS.includes(organization.subscriptionProductId) && (
<div className="col gap-2">
{organization.isWillBeCanceled || organization.isCanceled ? (
<Button
loading={checkout.isPending}
onClick={() => {
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
}}
>
Reactivate subscription
</Button>
) : (
<Button
variant="destructive"
loading={cancelSubscription.isPending}
onClick={() => {
showConfirm({
title: 'Cancel subscription',
text: 'Are you sure you want to cancel your subscription?',
onConfirm() {
cancelSubscription.mutate({
organizationId: organization.id,
});
},
});
}}
>
Cancel subscription
</Button>
)}
</div>
)}
</>
);
}
return (
<div className="col gap-2 md:w-72 shrink-0">
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Current Subscription</span>
<div className="flex items-center gap-2">
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
</div>
</div>
</WidgetHead>
<WidgetBody className="col gap-8">
{organization.isTrial && organization.subscriptionEndsAt && (
<Alert variant="warning">
<AlertTitle>Free trial</AlertTitle>
<AlertDescription>
Your organization is on a free trial. It ends on{' '}
{format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isExpired && organization.subscriptionEndsAt && (
<Alert variant="destructive">
<AlertTitle>Subscription expired</AlertTitle>
<AlertDescription>
Your subscription has expired. You can reactivate it by choosing
a new plan below.
</AlertDescription>
<AlertDescription>
It expired on {format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isWillBeCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
You have canceled your subscription. You can reactivate it by
choosing a new plan below.
</AlertDescription>
<AlertDescription className="font-medium">
It'll expire on{' '}
{format(organization.subscriptionEndsAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
Your subscription was canceled on{' '}
{format(organization.subscriptionCanceledAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{render()}
</WidgetBody>
</Widget>
{organization.hasSubscription && (
<button
className="text-center mt-2 w-2/3 hover:underline self-center"
type="button"
onClick={() =>
portalMutation.mutate({
organizationId: organization.id,
})
}
>
Manage your subscription with
<span className="font-medium ml-1">Polar Customer Portal</span>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/trpc/client';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { Combobox } from '@/components/ui/combobox';
import type { IServiceOrganization } from '@openpanel/db';
import { zEditOrganization } from '@openpanel/validation';
const validator = zEditOrganization;
type IForm = z.infer<typeof validator>;
interface EditOrganizationProps {
organization: IServiceOrganization;
}
export default function EditOrganization({
organization,
}: EditOrganizationProps) {
const router = useRouter();
const { register, handleSubmit, formState, reset, control } = useForm<IForm>({
defaultValues: {
id: organization.id,
name: organization.name,
timezone: organization.timezone ?? undefined,
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.organization.update.mutationOptions({
onSuccess(res: any) {
toast('Organization updated', {
description: 'Your organization has been updated.',
});
reset({
...res,
timezone: res.timezone!,
});
router.invalidate();
},
onError: handleError,
}),
);
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Details</span>
</WidgetHead>
<WidgetBody className="gap-4 col">
<InputWithLabel
className="flex-1"
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
<Controller
name="timezone"
control={control}
render={({ field }) => (
<WithLabel label="Timezone">
<Combobox
placeholder="Select timezone"
items={Intl.supportedValuesOf('timeZone').map((item) => ({
value: item,
label: item,
}))}
value={field.value}
onChange={field.onChange}
className="w-full"
/>
</WithLabel>
)}
/>
<Button
size="sm"
type="submit"
disabled={!formState.isDirty}
className="self-end"
>
Save
</Button>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
import type { IServiceOrganization } from '@openpanel/db';
import EditOrganization from './edit-organization';
interface OrganizationProps {
organization: IServiceOrganization;
}
export default function Organization({ organization }: OrganizationProps) {
return (
<section className="max-w-screen-sm col gap-8">
<EditOrganization organization={organization} />
</section>
);
}

View File

@@ -0,0 +1,374 @@
'use client';
import {
X_AXIS_STYLE_PROPS,
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import { sum } from '@openpanel/common';
import type { IServiceOrganization } from '@openpanel/db';
import { useQuery } from '@tanstack/react-query';
import { Loader2Icon } from 'lucide-react';
import { pick } from 'ramda';
import {
Bar,
BarChart,
CartesianGrid,
Tooltip as RechartTooltip,
ReferenceLine,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
type Props = {
organization: IServiceOrganization;
};
function Card({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2 p-4 flex-1 min-w-0" title={`${title}: ${value}`}>
<div className="text-muted-foreground truncate">{title}</div>
<div className="font-mono text-xl font-bold truncate">{value}</div>
</div>
);
}
export default function Usage({ organization }: Props) {
const number = useNumber();
const trpc = useTRPC();
const usageQuery = useQuery(
trpc.subscription.usage.queryOptions({
organizationId: organization.id,
}),
);
// Determine interval based on data range - use weekly if more than 30 days
const getDataInterval = () => {
if (!usageQuery.data || usageQuery.data.length === 0) return 'day';
const dates = usageQuery.data.map((item) => new Date(item.day));
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
const daysDiff = Math.ceil(
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24),
);
return daysDiff > 30 ? 'week' : 'day';
};
const interval = getDataInterval();
const useWeeklyIntervals = interval === 'week';
const xAxisProps = useXAxisProps({ interval });
const yAxisProps = useYAxisProps({});
const wrapper = (node: React.ReactNode) => (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span>
</WidgetHead>
<WidgetBody>{node}</WidgetBody>
</Widget>
);
if (usageQuery.isLoading) {
return wrapper(
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>,
);
}
if (usageQuery.isError) {
return wrapper(
<div className="center-center p-8 font-medium">
Issues loading usage data
</div>,
);
}
const subscriptionPeriodEventsLimit = organization.hasSubscription
? organization.subscriptionPeriodEventsLimit
: 0;
const subscriptionPeriodEventsCount = organization.hasSubscription
? organization.subscriptionPeriodEventsCount
: 0;
// Group daily data into weekly intervals if data spans more than 30 days
const processChartData = () => {
if (!usageQuery.data) return [];
if (useWeeklyIntervals) {
// Group daily data into weekly intervals
const weeklyData: {
[key: string]: { count: number; startDate: Date; endDate: Date };
} = {};
usageQuery.data.forEach((item) => {
const date = new Date(item.day);
// Get the start of the week (Monday)
const startOfWeek = new Date(date);
const dayOfWeek = date.getDay();
const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
const weekKey = startOfWeek.toISOString().split('T')[0];
if (!weeklyData[weekKey]) {
weeklyData[weekKey] = {
count: 0,
startDate: new Date(startOfWeek),
endDate: new Date(startOfWeek),
};
}
weeklyData[weekKey].count += item.count;
weeklyData[weekKey].endDate = new Date(date);
});
return Object.values(weeklyData).map((week) => ({
date: week.startDate.getTime(),
count: week.count,
weekRange: `${formatDate(week.startDate)} - ${formatDate(week.endDate)}`,
}));
}
// Use daily data for monthly subscriptions
return usageQuery.data.map((item) => ({
date: new Date(item.day).getTime(),
count: item.count,
}));
};
const chartData = processChartData();
const domain = [
0,
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
...chartData.map((item) => item.count),
),
] as [number, number];
domain[1] += domain[1] * 0.05;
return wrapper(
<>
<div className="border-b divide-x divide-border -m-4 mb-4 grid grid-cols-2 md:grid-cols-4">
{organization.hasSubscription ? (
<>
<Card
title="Period"
value={
organization.subscriptionCurrentPeriodStart &&
organization.subscriptionCurrentPeriodEnd
? `${formatDate(organization.subscriptionCurrentPeriodStart)}-${formatDate(organization.subscriptionCurrentPeriodEnd)}`
: '🤷‍♂️'
}
/>
<Card
title="Limit"
value={number.format(subscriptionPeriodEventsLimit)}
/>
<Card
title="Events count"
value={number.format(subscriptionPeriodEventsCount)}
/>
<Card
title="Left to use"
value={
subscriptionPeriodEventsLimit === 0
? '👀'
: number.formatWithUnit(
1 -
subscriptionPeriodEventsCount /
subscriptionPeriodEventsLimit,
'%',
)
}
/>
</>
) : (
<>
<div className="col-span-2">
<Card title="Subscription" value={'No active subscription'} />
</div>
<div className="col-span-2">
<Card
title="Events from last 30 days"
value={number.format(
sum(usageQuery.data?.map((item) => item.count) ?? []),
)}
/>
</div>
</>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Events Chart */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
{useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'}
</h3>
<div className="max-h-[300px] h-[250px] w-full p-4">
<ResponsiveContainer>
<BarChart data={chartData} barSize={useWeeklyIntervals ? 20 : 8}>
<RechartTooltip
content={<EventsTooltip useWeekly={useWeeklyIntervals} />}
/>
<Bar
dataKey="count"
isAnimationActive={false}
shape={BarShapeBlue}
/>
<XAxis {...xAxisProps} dataKey="date" />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Total Events vs Limit Chart */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
Total Events vs Limit
</h3>
<div className="max-h-[300px] h-[250px] w-full p-4">
<ResponsiveContainer>
<BarChart
data={[
{
name: 'Total Events',
count: subscriptionPeriodEventsCount,
limit: subscriptionPeriodEventsLimit,
},
]}
>
<RechartTooltip content={<TotalTooltip />} cursor={false} />
{organization.hasSubscription &&
subscriptionPeriodEventsLimit > 0 && (
<ReferenceLine
y={subscriptionPeriodEventsLimit}
stroke={getChartColor(1)}
strokeWidth={2}
strokeDasharray="3 3"
strokeOpacity={0.8}
strokeLinecap="round"
label={{
value: `Limit (${number.format(subscriptionPeriodEventsLimit)})`,
fill: getChartColor(1),
position: 'insideTopRight',
fontSize: 12,
}}
/>
)}
<Bar
dataKey="count"
isAnimationActive={false}
shape={BarShapeBlue}
/>
<XAxis {...X_AXIS_STYLE_PROPS} dataKey="name" />
<YAxis
{...yAxisProps}
domain={[
0,
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
) * 1.1,
]}
/>
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</>,
);
}
function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
const number = useNumber();
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">
{useWeekly && payload.weekRange
? payload.weekRange
: payload?.date
? formatDate(new Date(payload.date))
: 'Unknown date'}
</div>
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-0" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">
Events {useWeekly ? 'this week' : 'this day'}
</div>
<div className="text-lg font-semibold text-chart-0">
{number.format(payload.count)}
</div>
</div>
</div>
</div>
);
}
function TotalTooltip(props: any) {
const number = useNumber();
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">Total Events</div>
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-2" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Your events count</div>
<div className="text-lg font-semibold text-chart-2">
{number.format(payload.count)}
</div>
</div>
</div>
{payload.limit > 0 && (
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-1" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Your tier limit</div>
<div className="text-lg font-semibold text-chart-1">
{number.format(payload.limit)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import { GlobeIcon } from 'lucide-react';
export function OriginFilter() {
const { projectId } = useAppParams();
const [filters, setFilter] = useEventQueryFilters();
const originFilter = filters.find((item) => item.name === 'origin');
const trpc = useTRPC();
const { data } = useQuery(
trpc.event.origin.queryOptions(
{
projectId: projectId,
},
{
staleTime: 1000 * 60 * 60,
},
),
);
if (!data || data.length === 0) {
return null;
}
return (
<div className="flex flex-wrap gap-2">
{data?.map((item) => {
return (
<Button
key={item.origin}
variant="outline"
icon={GlobeIcon}
className={cn(
originFilter?.value.includes(item.origin) && 'border-foreground',
)}
onClick={() => setFilter('origin', [item.origin], 'is')}
>
{item.origin}
</Button>
);
})}
</div>
);
}

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