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
20
apps/start/.cta.json
Normal 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
@@ -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, copy‑pastable reference for file‑based routing in TanStack Router v1 (React).
|
||||
|
||||
## TL;DR
|
||||
|
||||
* **Root route** is always matched; it wraps everything.
|
||||
* **createFileRoute(path)** defines a route; the path is auto‑managed 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 (catch‑all)** 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 don’t match URL segments.
|
||||
* **Non‑nested** 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 auto‑written/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 / Catch‑All
|
||||
|
||||
```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 `_` → doesn’t 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) Non‑Nested 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 route’s `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 (file‑based routing)
|
||||
|
||||
* `__root.tsx` — root route file.
|
||||
* `index.tsx` or trailing `/` in `createFileRoute` — index under a segment.
|
||||
* `$param.tsx` — dynamic segment.
|
||||
* `$.tsx` — splat (catch‑all) 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 **file‑based** 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 non‑route code near routes.
|
||||
* Use `(group)` folders to tame big route directories.
|
||||
|
||||
---
|
||||
|
||||
### Further Reading
|
||||
|
||||
* Route Trees, Route Matching, File‑Based Routing, Outlets, Path/Search Params, Data Loading, SSR, etc.
|
||||
|
||||
Happy routing! 🚦
|
||||
12
apps/start/.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
apps/start/components.json
Normal 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
@@ -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"
|
||||
}
|
||||
}
|
||||
BIN
apps/start/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/start/public/img-1.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/start/public/img-2.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/start/public/img-3.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
apps/start/public/img-4.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/start/public/img-5.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
apps/start/public/img-6.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
6
apps/start/public/logo.svg
Normal 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 |
BIN
apps/start/public/logo192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
apps/start/public/logo512.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
25
apps/start/public/manifest.json
Normal 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"
|
||||
}
|
||||
3
apps/start/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
10
apps/start/src/app/global-middleware.ts
Normal 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()),
|
||||
// ],
|
||||
// });
|
||||
21
apps/start/src/components/animate-height.tsx
Normal 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;
|
||||
20
apps/start/src/components/animated-number.tsx
Normal 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} />;
|
||||
}
|
||||
11
apps/start/src/components/auth/or.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/start/src/components/auth/reset-password-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/start/src/components/auth/share-enter-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/start/src/components/auth/sign-in-email-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/start/src/components/auth/sign-in-github.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/start/src/components/auth/sign-in-google.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/start/src/components/auth/sign-up-email-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/start/src/components/button-container.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
48
apps/start/src/components/card.tsx
Normal 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;
|
||||
112
apps/start/src/components/chart-ssr.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/start/src/components/charts/chart-tooltip.tsx
Normal 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} />} />
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
47
apps/start/src/components/charts/common-bar.tsx
Normal 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)',
|
||||
},
|
||||
});
|
||||
70
apps/start/src/components/chat/chat-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/start/src/components/chat/chat-message.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/start/src/components/chat/chat-messages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/start/src/components/chat/chat-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/start/src/components/chat/chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/start/src/components/click-to-copy.tsx
Normal 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;
|
||||
38
apps/start/src/components/clients/create-client-success.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
apps/start/src/components/clients/table/columns.tsx
Normal 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;
|
||||
}
|
||||
30
apps/start/src/components/clients/table/index.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
17
apps/start/src/components/color-square.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/start/src/components/dot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
apps/start/src/components/events/event-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/start/src/components/events/event-list-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
apps/start/src/components/events/event-listener.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/start/src/components/events/list-properties-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
apps/start/src/components/events/table/columns.tsx
Normal 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;
|
||||
}
|
||||
298
apps/start/src/components/events/table/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
apps/start/src/components/events/table/item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/start/src/components/fade-in.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/start/src/components/feedback-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/start/src/components/forms/checkbox-item.tsx
Normal 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';
|
||||
29
apps/start/src/components/forms/copy-input.tsx
Normal 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;
|
||||
68
apps/start/src/components/forms/input-with-label.tsx
Normal 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';
|
||||
152
apps/start/src/components/forms/tag-input.tsx
Normal 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;
|
||||
39
apps/start/src/components/full-page-empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/start/src/components/full-page-error-state.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
apps/start/src/components/full-page-loading-state.tsx
Normal 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;
|
||||
21
apps/start/src/components/full-width-navbar.tsx
Normal 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;
|
||||
109
apps/start/src/components/fullscreen-toggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
87
apps/start/src/components/grid-table.tsx
Normal 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>
|
||||
);
|
||||
121
apps/start/src/components/integrations/active-integrations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/start/src/components/integrations/all-integrations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
144
apps/start/src/components/integrations/integration-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/start/src/components/integrations/integrations.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
79
apps/start/src/components/lazy-component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
apps/start/src/components/links.tsx
Normal 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>;
|
||||
}
|
||||
104
apps/start/src/components/login-left-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/start/src/components/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/start/src/components/markdown.tsx
Normal 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';
|
||||
142
apps/start/src/components/mock-event-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
16
apps/start/src/components/notifications/notifications.tsx
Normal 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} />;
|
||||
}
|
||||
133
apps/start/src/components/notifications/rule-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
apps/start/src/components/notifications/table/columns.tsx
Normal 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;
|
||||
}
|
||||
32
apps/start/src/components/notifications/table/index.tsx
Normal 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} />;
|
||||
</>
|
||||
);
|
||||
};
|
||||
132
apps/start/src/components/onboarding-left-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/start/src/components/onboarding/connect-app.tsx
Normal 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;
|
||||
86
apps/start/src/components/onboarding/connect-backend.tsx
Normal 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;
|
||||
78
apps/start/src/components/onboarding/connect-web.tsx
Normal 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;
|
||||
72
apps/start/src/components/onboarding/curl-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
71
apps/start/src/components/onboarding/skip-onboarding.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
apps/start/src/components/onboarding/steps.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
70
apps/start/src/components/organization/billing-faq.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
432
apps/start/src/components/organization/billing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
apps/start/src/components/organization/current-subscription.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/start/src/components/organization/edit-organization.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/start/src/components/organization/organization.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
374
apps/start/src/components/organization/usage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/start/src/components/overview/filters/origin-filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||