12 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
1272466235 feat: add tracking code on project settings 2026-02-27 23:27:13 +01:00
Carl-Gerhard Lindesvärd
2501ee1eef chore: remove unused var 2026-02-27 23:25:45 +01:00
Carl-Gerhard Lindesvärd
10da7d3a1d fix: improve onboarding 2026-02-27 22:45:21 +01:00
Carl-Gerhard Lindesvärd
b0aa7f4196 fix: reduce noise for api errors 2026-02-27 20:20:16 +01:00
Carl-Gerhard Lindesvärd
f4602f8e56 fix: add session end event for notification funnel 2026-02-27 18:37:37 +01:00
Carl-Gerhard Lindesvärd
efb50fafdb docs: add dashboard guides 2026-02-27 13:47:59 +01:00
Carl-Gerhard Lindesvärd
cd112237e9 docs: session replay 2026-02-27 11:22:12 +01:00
Carl-Gerhard Lindesvärd
9c6c7bb037 fix: funnel notifications 2026-02-27 10:24:45 +01:00
Carl-Gerhard Lindesvärd
928c44ef6a fix: duplicate session start (race condition) + remove old device id handling 2026-02-27 09:56:51 +01:00
Carl-Gerhard Lindesvärd
a42adcdbfb fix: broken add notifications rule 2026-02-27 09:37:43 +01:00
Carl-Gerhard Lindesvärd
8b18b86deb fix: invalidate queries better 2026-02-27 09:37:29 +01:00
Carl-Gerhard Lindesvärd
8db5905fb5 public: sitemap 2026-02-26 21:59:16 +01:00
64 changed files with 2335 additions and 1135 deletions

View File

@@ -28,6 +28,7 @@ Openpanel is an open-source web and product analytics platform that combines the
## ✨ Features ## ✨ Features
- **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history - **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history
- **🎬 Session Replay**: Record and replay user sessions with privacy controls built in
- **📊 Real-time Dashboards**: Live data updates and interactive charts - **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns - **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts - **🔔 Smart Notifications**: Event and funnel-based alerts
@@ -48,6 +49,7 @@ Openpanel is an open-source web and product analytics platform that combines the
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ | | 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** | | 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ | | 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ | | 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ | | 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ | | 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
@@ -56,9 +58,10 @@ Openpanel is an open-source web and product analytics platform that combines the
| 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ | | 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ |
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ | | 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access. > ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers. > ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
> ✅*** Plausible has simple goals > ✅*** Plausible has simple goals
> ✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
## Stack ## Stack

View File

@@ -61,8 +61,6 @@ export async function postEvent(
}, },
uaInfo, uaInfo,
geo, geo,
currentDeviceId: '',
previousDeviceId: '',
deviceId, deviceId,
sessionId: sessionId ?? '', sessionId: sessionId ?? '',
}, },

View File

@@ -1,16 +1,16 @@
import { LogError } from '@/utils/errors';
import { import {
Arctic, Arctic,
type OAuth2Tokens,
createSession, createSession,
generateSessionToken, generateSessionToken,
github, github,
google, google,
type OAuth2Tokens,
setSessionTokenCookie, setSessionTokenCookie,
} from '@openpanel/auth'; } from '@openpanel/auth';
import { type Account, connectUserToOrganization, db } from '@openpanel/db'; import { type Account, connectUserToOrganization, db } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { LogError } from '@/utils/errors';
async function getGithubEmail(githubAccessToken: string) { async function getGithubEmail(githubAccessToken: string) {
const emailListRequest = new Request('https://api.github.com/user/emails'); const emailListRequest = new Request('https://api.github.com/user/emails');
@@ -74,10 +74,15 @@ async function handleExistingUser({
setSessionTokenCookie( setSessionTokenCookie(
(...args) => reply.setCookie(...args), (...args) => reply.setCookie(...args),
sessionToken, sessionToken,
session.expiresAt, session.expiresAt
); );
reply.setCookie('last-auth-provider', providerName, {
maxAge: 60 * 60 * 24 * 365,
path: '/',
sameSite: 'lax',
});
return reply.redirect( return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!, process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
); );
} }
@@ -103,7 +108,7 @@ async function handleNewUser({
existingUser, existingUser,
oauthUser, oauthUser,
providerName, providerName,
}, }
); );
} }
@@ -138,10 +143,15 @@ async function handleNewUser({
setSessionTokenCookie( setSessionTokenCookie(
(...args) => reply.setCookie(...args), (...args) => reply.setCookie(...args),
sessionToken, sessionToken,
session.expiresAt, session.expiresAt
); );
reply.setCookie('last-auth-provider', providerName, {
maxAge: 60 * 60 * 24 * 365,
path: '/',
sameSite: 'lax',
});
return reply.redirect( return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!, process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
); );
} }
@@ -219,7 +229,7 @@ interface ValidatedOAuthQuery {
async function validateOAuthCallback( async function validateOAuthCallback(
req: FastifyRequest, req: FastifyRequest,
provider: Provider, provider: Provider
): Promise<ValidatedOAuthQuery> { ): Promise<ValidatedOAuthQuery> {
const schema = z.object({ const schema = z.object({
code: z.string(), code: z.string(),
@@ -353,7 +363,7 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
function redirectWithError(reply: FastifyReply, error: LogError | unknown) { function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL( const url = new URL(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!, process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
); );
url.pathname = '/login'; url.pathname = '/login';
if (error instanceof LogError) { if (error instanceof LogError) {

View File

@@ -217,8 +217,6 @@ async function handleTrack(
geo, geo,
deviceId, deviceId,
sessionId, sessionId,
currentDeviceId: '', // TODO: Remove
previousDeviceId: '', // TODO: Remove
}, },
groupId, groupId,
jobId, jobId,

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */
process.env.TZ = 'UTC'; process.env.TZ = 'UTC';
import compress from '@fastify/compress'; import compress from '@fastify/compress';
@@ -151,7 +152,7 @@ const startServer = async () => {
validateSessionToken(req.cookies.session) validateSessionToken(req.cookies.session)
); );
req.session = session; req.session = session;
} catch (e) { } catch {
req.session = EMPTY_SESSION; req.session = EMPTY_SESSION;
} }
} else if (process.env.DEMO_USER_ID) { } else if (process.env.DEMO_USER_ID) {
@@ -160,7 +161,7 @@ const startServer = async () => {
validateSessionToken(null) validateSessionToken(null)
); );
req.session = session; req.session = session;
} catch (e) { } catch {
req.session = EMPTY_SESSION; req.session = EMPTY_SESSION;
} }
} else { } else {
@@ -220,35 +221,46 @@ const startServer = async () => {
); );
}); });
const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE'];
fastify.setErrorHandler((error, request, reply) => { fastify.setErrorHandler((error, request, reply) => {
if (error instanceof HttpError) { if (error.statusCode === 429) {
request.log.error(`${error.message}`, error); return reply.status(429).send({
if (process.env.NODE_ENV === 'production' && error.status === 500) {
request.log.error('request error', { error });
reply.status(500).send('Internal server error');
} else {
reply.status(error.status).send({
status: error.status,
error: error.error,
message: error.message,
});
}
} else if (error.statusCode === 429) {
reply.status(429).send({
status: 429, status: 429,
error: 'Too Many Requests', error: 'Too Many Requests',
message: 'You have exceeded the rate limit for this endpoint.', message: 'You have exceeded the rate limit for this endpoint.',
}); });
} else if (error.statusCode === 400) {
reply.status(400).send({
status: 400,
error,
message: 'The request was invalid.',
});
} else {
request.log.error('request error', { error });
reply.status(500).send('Internal server error');
} }
if (error instanceof HttpError) {
if (!SKIP_LOG_ERRORS.includes(error.code)) {
request.log.error('internal server error', { error });
}
if (process.env.NODE_ENV === 'production' && error.status === 500) {
return reply.status(500).send('Internal server error');
}
return reply.status(error.status).send({
status: error.status,
error: error.error,
message: error.message,
});
}
if (!SKIP_LOG_ERRORS.includes(error.code)) {
request.log.error('request error', { error });
}
const status = error?.statusCode ?? 500;
if (process.env.NODE_ENV === 'production' && status === 500) {
return reply.status(500).send('Internal server error');
}
return reply.status(status).send({
status,
error,
message: error.message,
});
}); });
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {

View File

@@ -1,3 +1,3 @@
{ {
"pages": ["sdks", "how-it-works", "..."] "pages": ["sdks", "how-it-works", "session-replay", "..."]
} }

View File

@@ -0,0 +1,152 @@
---
title: Session Replay
description: Record and replay user sessions to understand exactly what users did. Loaded asynchronously so it never bloats your analytics bundle.
---
import { Callout } from 'fumadocs-ui/components/callout';
Session replay captures a structured recording of what users do in your app or website. You can replay any session to see which elements were clicked, how forms were filled, and where users ran into friction—without guessing.
<Callout type="info">
Session replay is **not enabled by default**. You explicitly opt in per-project. When disabled, the replay script is never downloaded, keeping your analytics bundle lean.
</Callout>
## How it works
OpenPanel session replay is built on [rrweb](https://www.rrweb.io/), an open-source library for recording and replaying web sessions. It captures DOM mutations, mouse movements, scroll positions, and interactions as structured data—not video.
The replay module is loaded **asynchronously** as a separate script (`op1-replay.js`). This means:
- Your main tracking script (`op1.js`) stays lightweight even when replay is disabled
- The replay module is only downloaded for sessions that are actually recorded
- No impact on page load performance when replay is turned off
## Limits & retention
- **Unlimited replays** — no cap on the number of sessions recorded
- **30-day retention** — replays are stored and accessible for 30 days
## Setup
### Script tag
Add `sessionReplay` to your `init` call. The replay script loads automatically from the same CDN as the main script.
```html title="index.html"
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
sessionReplay: {
enabled: true,
},
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
### NPM package
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
sessionReplay: {
enabled: true,
},
});
```
With the npm package, the replay module is a dynamic import code-split by your bundler. It is never included in your main bundle when session replay is disabled.
## Options
| Option | Type | Default | Description |
|---|---|---|---|
| `enabled` | `boolean` | `false` | Enable session replay recording |
| `maskAllInputs` | `boolean` | `true` | Mask all input field values with `*` |
| `maskTextSelector` | `string` | `[data-openpanel-replay-mask]` | CSS selector for text elements to mask |
| `blockSelector` | `string` | `[data-openpanel-replay-block]` | CSS selector for elements to replace with a placeholder |
| `blockClass` | `string` | — | Class name that blocks elements from being recorded |
| `ignoreSelector` | `string` | — | CSS selector for elements excluded from interaction tracking |
| `flushIntervalMs` | `number` | `10000` | How often (ms) recorded events are sent to the server |
| `maxEventsPerChunk` | `number` | `200` | Maximum number of events per payload chunk |
| `maxPayloadBytes` | `number` | `1048576` | Maximum payload size in bytes (1 MB) |
| `scriptUrl` | `string` | — | Custom URL for the replay script (script-tag builds only) |
## Privacy controls
Session replay captures user interactions. These options protect sensitive content before it ever leaves the browser.
### Masking inputs
All input fields are masked by default (`maskAllInputs: true`). Recorded values appear as `***` in the replay. Disable this only if you have a specific reason—form field contents are almost always personal data.
### Masking specific text
Add `data-openpanel-replay-mask` to any element to replace its text with `***` in replays:
```html
<p data-openpanel-replay-mask>Sensitive text here</p>
```
Or use a custom selector:
```ts
sessionReplay: {
enabled: true,
maskTextSelector: '.pii, [data-sensitive]',
}
```
### Blocking elements
Elements matched by `blockSelector` or `blockClass` are replaced with a same-size grey placeholder in the replay. The element and all its children are never recorded.
```html
<div data-openpanel-replay-block>
This section won't appear in replays
</div>
```
Or with a custom selector and class:
```ts
sessionReplay: {
enabled: true,
blockSelector: '.payment-form, .user-avatar',
blockClass: 'no-replay',
}
```
### Ignoring interactions
Use `ignoreSelector` to exclude specific elements from interaction tracking. The element remains visible in the replay but clicks and input events on it are not recorded.
```ts
sessionReplay: {
enabled: true,
ignoreSelector: '.debug-panel',
}
```
## Self-hosting
If you self-host OpenPanel, the replay script is served from your instance automatically. You can also override the script URL if you host it separately:
```ts
sessionReplay: {
enabled: true,
scriptUrl: 'https://your-cdn.example.com/op1-replay.js',
}
```
## Related
- [Session tracking](/features/session-tracking) — understand sessions without full replay
- [Session replay feature overview](/features/session-replay) — what you get with session replay
- [Web SDK](/docs/sdks/web) — full web SDK reference
- [Script tag](/docs/sdks/script) — using OpenPanel via a script tag

View File

@@ -0,0 +1,7 @@
{
"title": "Dashboard",
"pages": [
"understand-the-overview",
"..."
]
}

View File

@@ -0,0 +1,138 @@
---
title: "How to set up notifications and integrations"
description: "Get notified in Slack, Discord, or via webhook when users complete events or funnels. Learn how to connect integrations and configure notification rules in OpenPanel."
difficulty: beginner
timeToComplete: 10
date: 2026-02-27
updated: 2026-02-27
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Create an integration"
anchor: "create-integration"
- name: "Create a notification rule"
anchor: "create-rule"
- name: "Event rules"
anchor: "event-rules"
- name: "Funnel rules"
anchor: "funnel-rules"
- name: "View notifications"
anchor: "view-notifications"
---
## How it works
There are two separate concepts to understand before you start:
- **Integrations** are connections to external services like Slack, Discord, or a custom webhook. They live at the workspace/organization level and can be reused across all your projects.
- **Notification rules** are the conditions that trigger a notification. Rules live inside individual projects and reference one or more integrations. A rule does nothing until it has an integration attached—and an integration does nothing until a rule uses it.
- **Notifications** are the messages that are sent when a rule is triggered. A notification can be sent as a json object or a template with variables.
## Step 1: Create an integration [#create-integration]
Go to your workspace settings and open the **Integrations** section. Click **Add integration** and choose the service you want to connect.
OpenPanel currently supports:
- **Slack** — authenticate via OAuth and pick a channel
- **Discord** — paste a Discord webhook URL for a channel
- **Webhook** — send an HTTP POST to any URL you control
Fill in the required details and save. The integration is now available to all projects in your workspace.
<Figure
src="/screenshots/integrations-create.webp"
caption="Create a new integration for Slack, Discord, or a custom webhook."
/>
<Callout>Soon we have integrations for S3 and GCS to export your events to your own storage.</Callout>
## Step 2: Go to your project's notification rules [#create-rule]
Integrations alone don't do anything. To start receiving alerts, open the project you want to monitor, click **Notifications** in the left sidebar, and switch to the **Rules** tab.
Click **Add Rule** to open the rule editor on the right side of the screen.
Give your rule a name, then choose a **Type**. There are two types:
| Type | When it triggers |
|------|-----------------|
| **Event** | Immediately when a matching event is received |
| **Funnel** | After a session ends and all funnel steps have been completed in order |
## Event rules [#event-rules]
Event rules fire in real time. The moment OpenPanel receives an event that matches your filters, the notification is sent.
<Figure
src="/screenshots/notifications-event-rule.webp"
caption="An event rule called 'Onboarding user' that fires when a screen_view event occurs with path filters matching the onboarding flow."
/>
In the rule editor:
1. Set **Type** to **Events**
2. Add one or more events from the **Events** list. You can filter each event by its properties (for example, only trigger when `path` starts with `/onboarding`)
3. Write a **Template** for the notification message. Use `{{property_name}}` to insert event properties dynamically—for example, `New user with their first event from {{country}}`.
4. Under **Integrations**, select which integration(s) should receive the notification
Click **Update** to save the rule.
<Callout>Templates will not be used if you have Javascript transformer in your integration.</Callout>
## Funnel rules [#funnel-rules]
Funnel rules let you track multi-step flows and notify you only when a user completes every step in the correct sequence—for example, `session_start` → `subscription_checkout` → `subscription_created`.
<Figure
src="/screenshots/notifications-funnel-rule.webp"
caption="A funnel rule called 'Subscribe funnel' that notifies when a session completes all three steps in order."
/>
In the rule editor:
1. Set **Type** to **Funnel**
2. Add each event in the funnel, in the order they must occur. You can optionally add property filters to each step
3. Write a **Template** for the notification message
4. Select your **Integration(s)**
Click **Update** to save.
<Callout type="warning">**Important:** Funnel rule notifications are sent after the session ends, not immediately when the last step fires. OpenPanel waits until the session is complete before evaluating the funnel sequence.</Callout>
<Callout>Templates will not be used if you have Javascript transformer in your integration.</Callout>
## View notifications [#view-notifications]
Switch to the **Notifications** tab (the default view) to see every notification that has been triggered for your project. Each row shows the notification title alongside the country, OS, browser, and profile of the user who triggered it.
<Figure
src="/screenshots/notifications-list.webp"
caption="The Notifications tab shows a live feed of every triggered notification, with user context like country, OS, and browser."
/>
You can filter the list by creation date or search by title to find specific events.
## Frequently asked questions
<Faqs>
<FaqItem question="Can I use the same integration across multiple projects?">
Yes. Integrations are created at the workspace level, so any project in your organization can reference them in its notification rules.
</FaqItem>
<FaqItem question="Why haven't I received any funnel notifications?">
Funnel rules trigger after the session ends, not when the last event fires. If the user's session is still active, the notification is queued until the session closes. Make sure the full funnel sequence was completed within a single session.
</FaqItem>
<FaqItem question="Can I filter event rules to only fire for specific users or properties?">
Yes. For each event in the rule, click the filter icon to add property conditions—for example, only trigger when `plan` equals `enterprise` or `country` equals `US`.
</FaqItem>
<FaqItem question="What integrations are supported?">
Currently Slack, Discord, and custom webhooks. More integrations are coming soon.
</FaqItem>
<FaqItem question="Can I have multiple integrations on one rule?">
Yes. The integrations selector on each rule allows you to pick multiple destinations. A single triggered rule will send a notification to all selected integrations simultaneously.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,193 @@
---
title: "Understand the overview"
description: "The overview is the main page of every OpenPanel project. It gives you a real-time picture of how your site or app is performing right now and over any time range you choose. This page explains every section and every number so you know exactly what you're looking at."
date: 2026-02-27
---
## Top stats
The row of metric cards at the top of the page is the fastest way to understand the health of your project. Each card shows the value for the selected time range and a comparison to the previous period of the same length.
### Unique Visitors
The number of distinct profile IDs recorded in the selected period. How accurate this is depends on whether you use [identify](/docs/get-started/identify-users):
- **Without identify**: OpenPanel generates an anonymous profile ID that rotates every 24 hours. A visitor returning on 10 different days will be counted as 10 unique visitors, because each day produces a new ID.
- **With identify**: The profile ID is tied to the user's real identity. The same person visiting on 10 different days is counted as 1 unique visitor across the entire period.
If cross-day deduplication matters to your analysis, set up [user identification](/docs/get-started/identify-users).
### Sessions
The total number of sessions in the selected period. A session begins when someone arrives on your site and ends after 30 minutes of inactivity or when they close the tab. One visitor can have many sessions across a day.
### Pageviews
The total number of page views (`screen_view` events) recorded across all sessions. Every time a visitor loads a page—including navigating between pages in a single session—it counts as one pageview.
### Pages per Session
The average number of pages viewed within a single session, calculated as `total pageviews / total sessions`. A higher number means visitors are exploring more of your site before leaving.
### Bounce Rate
The percentage of sessions where a visitor viewed only a single page and left. Calculated as `single-page sessions / total sessions × 100`. Lower is generally better—it means more visitors are engaging beyond the first page.
> A session is counted as a bounce if the visitor triggered exactly one `screen_view` event before the session ended. Sessions where visitors read one article deeply and leave still count as bounces.
### Session Duration
The average length of a session in seconds, calculated only from sessions where the visitor did something after the first page load (duration > 0). Sessions where a visitor immediately left are excluded from the average to avoid skewing the number.
### Revenue
The total monetary value tracked via `revenue` events in the selected period, displayed in your account currency. Revenue is only shown if you are tracking revenue events. See the [revenue tracking docs](/features/revenue-tracking) for setup instructions.
---
## The time-series chart
Directly below the stat cards is a line chart that shows how the selected metric changes over time. Click any stat card to switch the chart to that metric.
The chart uses the **interval** you select (hour, day, week, or month) to group data points. A faint dashed line shows the equivalent period from the previous comparison window, so you can spot trends at a glance.
When any metric other than Revenue is active, the chart also overlays revenue as green bars on a secondary Y-axis—this lets you correlate traffic patterns with revenue without switching cards.
The trailing edge of the line (the current, incomplete interval) is shown as a dashed segment to remind you that the period is still accumulating data.
---
## Insights
If you have configured [Insights](/features/insights) for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured.
---
## Sources
The Sources widget shows where your visitors came from. Switch between tabs to see different dimensions:
| Tab | What it shows |
|-----|---------------|
| **Refs** | Grouped referrer names (e.g., "Google", "Twitter", "Hacker News") |
| **Urls** | Raw referrer URLs |
| **Types** | Referrer categories: `search`, `social`, `email`, `unknown` |
| **Source** | `utm_source` query parameter values |
| **Medium** | `utm_medium` query parameter values |
| **Campaign** | `utm_campaign` query parameter values |
| **Term** | `utm_term` query parameter values |
| **Content** | `utm_content` query parameter values |
Referrer names and types are resolved automatically from the raw referrer URL using a built-in lookup table. Direct traffic (no referrer) appears as `(not set)`.
Each row shows sessions and pageviews. Clicking a row filters the entire overview page to only show data from that source.
---
## Pages
The Pages widget shows which URLs your visitors are landing on, exiting from, and spending time on.
| Tab | What it shows |
|-----|---------------|
| **Top pages** | Pages ranked by unique sessions. Each row is a `origin + path` combination. |
| **Entry pages** | The first page of each session—the page where visitors arrived. |
| **Exit pages** | The last page of each session—the page where visitors left. |
High exit rates on a page are not always bad—they can reflect a page that successfully answers a question. High bounce on an entry page is more diagnostic. Compare entry and exit distributions to understand the shape of your user journeys.
Clicking a page row filters the whole overview to sessions that included that page.
---
## Devices
The Devices widget breaks down your audience by hardware and software. Switch between tabs:
| Tab | What it shows |
|-----|---------------|
| **Device** | Device type: Desktop, Mobile, Tablet |
| **Brand** | Hardware brand (Apple, Samsung, etc.) |
| **Model** | Specific device model |
| **Browser** | Browser name (Chrome, Safari, Firefox, etc.) |
| **Browser ver.** | Browser version number |
| **OS** | Operating system (macOS, Windows, iOS, Android, etc.) |
| **OS ver.** | Operating system version |
Each row shows sessions and pageviews. Use this widget to prioritize which browsers and operating systems to test and optimize for.
---
## Events
The Events widget shows the most frequent custom events fired in the selected period, ranked by count. System events (`session_start`, `session_end`, `screen_view`) are excluded—only the events you instrument yourself appear here.
Click any event to filter the overview to sessions where that event was fired.
---
## Geo
The Geo widget shows the geographic distribution of your visitors. Switch between tabs:
| Tab | What it shows |
|-----|---------------|
| **Country** | Visitor country, derived from IP geolocation |
| **Region** | State or province |
| **City** | City level |
Below the table, a world map plots the same data as a heatmap—darker areas represent more sessions. This gives you a quick visual of where your audience is concentrated.
Clicking a country, region, or city filters the whole overview to that location.
---
## Activity heatmap
The activity heatmap at the bottom of the page shows when your visitors are most active, broken down by day of the week (Monday through Sunday) and hour of the day (00:0023:00). Each cell shows the **average** of the selected metric at that day-and-hour combination, averaged across all weeks in the selected period.
Darker cells indicate higher average values. Hover any cell to see the exact average.
You can switch the metric being visualized using the tabs above the heatmap:
- **Unique Visitors**
- **Sessions**
- **Pageviews**
- **Bounce Rate**
- **Pages / Session**
- **Session Duration**
Use the heatmap to identify peak traffic windows, plan campaigns, and schedule maintenance during quiet periods.
---
## User Journey
The User Journey (Sankey) diagram at the very bottom visualizes how visitors flow through your site within a session. It answers the question: after landing on page A, where do visitors go next?
**How it works:**
1. OpenPanel identifies the top 3 most common entry pages in the selected period.
2. From each entry page, it finds the top 3 most frequent next pages (step 2), then the top 3 from those (step 3), and so on up to the configured number of steps (default 5, adjustable to a maximum of 10).
3. Paths that represent less than 0.25% of total sessions are filtered out to reduce visual noise.
4. Consecutive duplicate pages within a session are collapsed into one step (e.g., if someone refreshed a page, it only counts once in the journey).
Each node shows the page URL. The width of the connecting flows is proportional to the number of sessions that followed that path.
Use the User Journey to find drop-off points, discover unexpected popular paths, and understand whether visitors are reaching your key conversion pages.
---
## Filters and time controls
Every widget on the overview page responds to the same set of global filters and time controls at the top of the page.
**Range**: choose a preset (Today, Last 7 days, Last 30 days, etc.) or a custom date range.
**Interval**: controls how data is grouped in the time-series chart (hour, day, week, month).
**Event filter**: narrow the entire overview to sessions that include a specific event—useful for analyzing the behavior of users who completed a particular action.
**Dimension filters**: clicking any row in any widget (a country, a source, a page) applies that value as a filter. Active filters are shown as chips below the time controls. Remove a filter by clicking the × on its chip.
**Live counter**: a green badge in the top-right corner shows the number of active visitors (visitors who fired an event in the last 5 minutes). Click it for a 30-minute session histogram.

View File

@@ -8,6 +8,7 @@ import { UserIcon,HardDriveIcon } from 'lucide-react'
## ✨ Key Features ## ✨ Key Features
- **🔍 Advanced Analytics**: [Funnels](/features/funnels), cohorts, user profiles, and session history - **🔍 Advanced Analytics**: [Funnels](/features/funnels), cohorts, user profiles, and session history
- **🎬 Session Replay**: [Record and replay user sessions](/features/session-replay) with privacy controls built in
- **📊 Real-time Dashboards**: Live data updates and interactive charts - **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns - **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts - **🔔 Smart Notifications**: Event and funnel-based alerts
@@ -28,6 +29,7 @@ import { UserIcon,HardDriveIcon } from 'lucide-react'
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ | | 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** | | 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ | | 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ | | 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ | | 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ | | 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
@@ -36,10 +38,14 @@ import { UserIcon,HardDriveIcon } from 'lucide-react'
| 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ | | 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ |
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ | | 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access. ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
✅*** Plausible has simple goals ✅*** Plausible has simple goals
✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
## 🚀 Quick Start ## 🚀 Quick Start
Before you can start tracking your events you'll need to create an account or spin up your own instance of OpenPanel. Before you can start tracking your events you'll need to create an account or spin up your own instance of OpenPanel.

View File

@@ -8,9 +8,11 @@
"...(tracking)", "...(tracking)",
"---API---", "---API---",
"...api", "...api",
"---Dashboard---",
"...dashboard",
"---Self-hosting---", "---Self-hosting---",
"...self-hosting", "...self-hosting",
"---Migration---", "---Migration---",
"...migration" "...migration"
] ]
} }

View File

@@ -0,0 +1,166 @@
{
"slug": "session-replay",
"short_name": "Session replay",
"seo": {
"title": "Session Replay - Watch Real User Sessions | OpenPanel",
"description": "Replay real user sessions to understand exactly what happened. Privacy-first session replay with masking controls, unlimited recordings, and 30-day retention.",
"keywords": [
"session replay",
"session recording",
"user session replay",
"hotjar alternative",
"privacy-first session replay"
]
},
"hero": {
"heading": "See exactly what your users did",
"subheading": "Replay any user session to see clicks, scrolls, and interactions. Privacy controls built in. Loaded async so it never slows down your site.",
"badges": [
"Unlimited replays",
"30-day retention",
"Privacy controls built in",
"Async—zero bundle bloat"
]
},
"definition": {
"title": "What is session replay?",
"text": "Session replay lets you watch a structured recording of what a real user did during a visit to your site or app. You see every click, scroll, form interaction, and page navigation—played back in order.\n\nMost analytics tools tell you **what happened in aggregate**: 40% of users dropped off at step 2. Session replay shows you **why**: you can watch someone struggle with a confusing form label, miss a button, or hit an error state you didn't know existed.\n\nOpenPanel session replay is built on [rrweb](https://www.rrweb.io/), an open-source recording library. It captures DOM mutations and user interactions as structured data—not video. This matters because:\n\n- **Privacy is easier to manage** — you control exactly what gets recorded with CSS selectors, not hoping a video blur is accurate\n- **Storage is efficient** — structured data compresses far better than video\n- **Playback is instant** — no buffering or waiting for video to load\n\nSession replay in OpenPanel is **opt-in and off by default**. When disabled, the replay script is never loaded and adds zero bytes to your page. When enabled, the recorder is fetched asynchronously as a separate script, so your main analytics bundle stays lean regardless.\n\nPrivacy controls are first-class:\n\n- **All inputs masked by default** — form field values are never recorded\n- **Block any element** with a data attribute or CSS selector\n- **Mask specific text** without blocking the surrounding layout\n- **Ignore interactions** on sensitive elements\n\nReplays are linked to sessions, events, and user profiles. When a user reports a bug, you can pull up their session in seconds and see exactly what happened—no need to ask them to reproduce it."
},
"capabilities_section": {
"title": "What you get with session replay",
"intro": "Everything you need to understand real user behavior, with privacy controls built in from the start."
},
"capabilities": [
{
"title": "Full session playback",
"description": "Replay any recorded session from start to finish. See clicks, scrolls, form interactions, and navigation in the exact order they happened."
},
{
"title": "Linked to events and profiles",
"description": "Replays are tied to the user's session, event timeline, and profile. Jump from a funnel drop-off directly to the replay for context."
},
{
"title": "Input masking by default",
"description": "All form field values are masked out of the box. You see that a user typed something—not what they typed. Disable per-field if needed."
},
{
"title": "Block and mask controls",
"description": "Block entire elements from recording with a data attribute or CSS selector. Mask specific text. Ignore interactions on sensitive areas."
},
{
"title": "Async loading—zero bundle impact",
"description": "The replay module loads as a separate async script. When replay is disabled it's never fetched. When enabled it doesn't block your main bundle."
},
{
"title": "Unlimited replays, 30-day retention",
"description": "No cap on the number of sessions recorded. Every replay is stored for 30 days and available for playback at any time."
}
],
"screenshots": [
{
"src": "/features/feature-sessions.webp",
"alt": "Session overview showing user sessions with entry pages and duration",
"caption": "Browse all sessions. Click any one to open the replay."
},
{
"src": "/features/feature-sessions-details.webp",
"alt": "Session detail view showing events in order",
"caption": "The session timeline shows every event alongside the replay."
}
],
"how_it_works": {
"title": "How session replay works",
"intro": "Enable it once and every qualifying session is recorded automatically.",
"steps": [
{
"title": "Enable replay in your init config",
"description": "Set `sessionReplay: { enabled: true }` in your OpenPanel init options. That's all the configuration required to start recording."
},
{
"title": "The replay script loads asynchronously",
"description": "When a session starts, OpenPanel fetches the replay module (`op1-replay.js`) as a separate async script. It doesn't block page load or inflate your main bundle."
},
{
"title": "Interactions are captured and sent in chunks",
"description": "The recorder captures DOM changes and user interactions and sends them to OpenPanel in small chunks every 10 seconds and on page unload."
},
{
"title": "Replay any session from the dashboard",
"description": "Open any session in the dashboard and hit play. The replay reconstructs the user's exact experience. Jump to specific events from the timeline."
}
]
},
"use_cases": {
"title": "Who uses session replay",
"intro": "Teams that need to understand real user behavior beyond what metrics alone can show.",
"items": [
{
"title": "Product teams",
"description": "When the data says users drop off at a specific step, session replay shows you exactly why. See confusion, missed CTAs, and error states you didn't know existed."
},
{
"title": "Support and success teams",
"description": "When a user reports a bug or a confusing experience, pull up their session replay in seconds. You see what they saw—no need to ask them to reproduce it."
},
{
"title": "Privacy-conscious teams",
"description": "Input masking is on by default. Block sensitive UI with a data attribute. You get the behavioral insight without recording personal data."
}
]
},
"related_features": [
{
"slug": "session-tracking",
"title": "Session tracking",
"description": "Structured session data—entry pages, event timelines, duration—without requiring replay."
},
{
"slug": "event-tracking",
"title": "Event tracking",
"description": "Custom events appear alongside replays in the session timeline."
},
{
"slug": "funnels",
"title": "Funnels",
"description": "Jump from a funnel drop-off step directly to the session replay to understand why."
}
],
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions about session replay with OpenPanel.",
"items": [
{
"question": "Is session replay enabled by default?",
"answer": "No. Session replay is opt-in. You enable it by setting `sessionReplay: { enabled: true }` in your init config. When disabled, the replay script is never fetched and adds zero overhead to your page."
},
{
"question": "Does enabling session replay slow down my site?",
"answer": "No. The replay module loads as a separate async script (`op1-replay.js`), independent of the main tracking bundle (`op1.js`). It's fetched after the page loads and does not block rendering or the main analytics script."
},
{
"question": "How is this different from Hotjar or FullStory?",
"answer": "Hotjar and FullStory record video-like streams. OpenPanel captures structured DOM events using rrweb. The result looks similar in the viewer, but structured data gives you finer-grained privacy controls (CSS-selector masking, element blocking) and is more storage-efficient. OpenPanel is also open-source and can be self-hosted."
},
{
"question": "Are form inputs recorded?",
"answer": "No. All input field values are masked by default (`maskAllInputs: true`). You see that a user interacted with a field, but not what they typed. You can disable this on a per-field basis if needed."
},
{
"question": "How long are replays stored?",
"answer": "Replays are retained for 30 days. There is no limit on the number of sessions recorded."
},
{
"question": "Can I block specific parts of my UI from being recorded?",
"answer": "Yes. Add `data-openpanel-replay-block` to any element to replace it with a placeholder in the replay. Use `data-openpanel-replay-mask` to mask specific text. Both the attribute names and the CSS selectors they target are configurable."
},
{
"question": "Does session replay work with self-hosted OpenPanel?",
"answer": "Yes. When self-hosting, the replay script is served from your instance automatically. You can also override the script URL with the `scriptUrl` option if you host it on a CDN."
}
]
},
"cta": {
"label": "Start recording sessions",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}

View File

@@ -2,31 +2,32 @@
"slug": "session-tracking", "slug": "session-tracking",
"short_name": "Session tracking", "short_name": "Session tracking",
"seo": { "seo": {
"title": "Session Tracking Without Replays - Privacy-First", "title": "Session Tracking - Understand User Journeys | OpenPanel",
"description": "Understand user sessions from entry to exit-without recordings or privacy risk. See pages visited, events fired, and session duration with privacy-first analytics.", "description": "Understand user sessions from entry to exit. See pages visited, events fired, and session duration. Optionally add session replay to watch exactly what users did.",
"keywords": [ "keywords": [
"session tracking analytics", "session tracking analytics",
"user session tracking", "user session tracking",
"session analysis without replay" "session analysis",
"session replay analytics"
] ]
}, },
"hero": { "hero": {
"heading": "What happened in the session", "heading": "What happened in the session",
"subheading": "Pages visited, events fired, time spent. No recordings, no privacy risk. You still get the full picture.", "subheading": "Pages visited, events fired, time spent. Full structured session data—and optional session replay when you need to go deeper.",
"badges": [ "badges": [
"No session recordings",
"Privacy-first by design", "Privacy-first by design",
"Entry-to-exit visibility", "Entry-to-exit visibility",
"Sessions linked to events" "Sessions linked to events",
"Optional session replay"
] ]
}, },
"definition": { "definition": {
"title": "What is session tracking?", "title": "What is session tracking?",
"text": "A session is the **window of activity** between a user arriving on your site and leaving. It starts with an entry page, includes every page view and event along the way, and ends when the user goes idle or closes the tab.\n\nMost analytics tools either give you **too little** (aggregated page-view counts with no sense of flow) or **too much** (full session recordings that raise privacy concerns and take hours to review). OpenPanel sits in the middle: you get a **structured timeline** of what happened in each session, without recording a single pixel of the user's screen.\n\nFor every session, OpenPanel captures:\n\n- **Entry page and exit page** - where the user started and where they left\n- **Pages visited in order** - the path through your site or app\n- **Events fired** - signups, clicks, feature usage, or any custom event\n- **Session duration** - how long the session lasted\n- **Referrer and UTM parameters** - how the user got there\n- **Device, browser, and location** - context without fingerprinting\n\nThis means you can answer questions like:\n\n- **What pages do users visit before signing up?**\n- **Do users from organic search behave differently than paid traffic?**\n- **How long are sessions for users who convert vs. those who don't?**\n\nUnlike session replay tools (Hotjar, FullStory, LogRocket), there are **no recordings to watch**, **no PII captured on screen**, and **no consent banners** needed for video replay. You get the analytical value of sessions without the privacy overhead.\n\nSessions in OpenPanel connect directly to **events and user profiles**. Every event belongs to a session, and every session belongs to a user. This means funnels, retention, and user timelines all have session context built in." "text": "A session is the **window of activity** between a user arriving on your site and leaving. It starts with an entry page, includes every page view and event along the way, and ends when the user goes idle or closes the tab.\n\nMost analytics tools either give you **too little** (aggregated page-view counts with no sense of flow) or **too much** (full session recordings that can be slow to review). OpenPanel gives you both options:\n\n- **Session tracking** (always on) — a structured timeline of what happened in each session: pages visited, events fired, duration, referrer, and device context\n- **[Session replay](/features/session-replay)** (opt-in) — a playable recording of the session built on [rrweb](https://www.rrweb.io/), so you can see exactly what the user clicked and where they got confused\n\nFor every session, OpenPanel captures:\n\n- **Entry page and exit page** - where the user started and where they left\n- **Pages visited in order** - the path through your site or app\n- **Events fired** - signups, clicks, feature usage, or any custom event\n- **Session duration** - how long the session lasted\n- **Referrer and UTM parameters** - how the user got there\n- **Device, browser, and location** - context without fingerprinting\n\nThis means you can answer questions like:\n\n- **What pages do users visit before signing up?**\n- **Do users from organic search behave differently than paid traffic?**\n- **How long are sessions for users who convert vs. those who don't?**\n\nSession replay is **opt-in and off by default**. When disabled, the replay script is never loaded and adds no overhead. When enabled, it loads asynchronously as a separate script so your main analytics bundle stays lean.\n\nSessions in OpenPanel connect directly to **events and user profiles**. Every event belongs to a session, and every session belongs to a user. This means funnels, retention, and user timelines all have session context built in."
}, },
"capabilities_section": { "capabilities_section": {
"title": "What you get with session tracking", "title": "What you get with session tracking",
"intro": "Structured session data that answers real questions-without the privacy cost of recordings." "intro": "Structured session data that answers real questionswith optional replay when you need to see the full picture."
}, },
"capabilities": [ "capabilities": [
{ {
@@ -35,7 +36,7 @@
}, },
{ {
"title": "Page flow per session", "title": "Page flow per session",
"description": "View the ordered sequence of pages a user visited in a session. Understand navigation patterns without watching a recording." "description": "View the ordered sequence of pages a user visited in a session. Understand navigation patterns at a glance."
}, },
{ {
"title": "Events within a session", "title": "Events within a session",
@@ -50,8 +51,8 @@
"description": "Know how users arrived-organic search, paid campaign, direct link-and compare session quality across sources." "description": "Know how users arrived-organic search, paid campaign, direct link-and compare session quality across sources."
}, },
{ {
"title": "Device and location context", "title": "Session replay (opt-in)",
"description": "Capture browser, OS, and approximate location for each session. No fingerprinting-just standard request headers." "description": "Enable session replay to record and play back real user sessions. Privacy controls built ininputs masked by default. Loads async so it never bloats your bundle."
} }
], ],
"screenshots": [ "screenshots": [
@@ -63,7 +64,7 @@
{ {
"src": "/features/feature-sessions-details.webp", "src": "/features/feature-sessions-details.webp",
"alt": "Session events timeline showing user actions in order", "alt": "Session events timeline showing user actions in order",
"caption": "Every event tied to its session. Understand user journeys without replay tools." "caption": "Every event tied to its session. Drill into the timeline or open the replay."
} }
], ],
"how_it_works": { "how_it_works": {
@@ -80,29 +81,34 @@
}, },
{ {
"title": "Sessions connect to everything", "title": "Sessions connect to everything",
"description": "Each session links to the user profile, the events fired, and the pages visited. This means funnels, retention, and user timelines all include session context." "description": "Each session links to the user profile, the events fired, and the pages visited. Enable session replay to also record a playable video of the session."
} }
] ]
}, },
"use_cases": { "use_cases": {
"title": "Who uses session tracking", "title": "Who uses session tracking",
"intro": "Teams that need to understand user journeys without the overhead of session replays.", "intro": "Teams that need to understand user journeys, from structured data to full session replay.",
"items": [ "items": [
{ {
"title": "Product teams", "title": "Product teams",
"description": "Understand how users navigate your product. See the page flow and events in a session to identify friction points-without watching hours of recordings." "description": "Understand how users navigate your product. See the page flow and events in a session, then open a replay to see exactly where users got stuck."
}, },
{ {
"title": "Support and success teams", "title": "Support and success teams",
"description": "When a user reports an issue, pull up their recent sessions to see what pages they visited and what events they triggered. Context without asking \"can you describe what you did?\"" "description": "When a user reports an issue, pull up their recent sessions to see what pages they visited and what events they triggered. Open the replay for the full picture."
}, },
{ {
"title": "Privacy-conscious teams", "title": "Privacy-conscious teams",
"description": "Get session-level insights without recording user screens. No PII in screenshots, no video consent banners, no GDPR headaches from replay data." "description": "Session tracking works without cookies or recordings. Session replay is opt-in, with inputs masked by default and granular controls to block or mask any sensitive element."
} }
] ]
}, },
"related_features": [ "related_features": [
{
"slug": "session-replay",
"title": "Session replay",
"description": "Watch real user sessions. See clicks, scrolls, and form interactions played back in the dashboard."
},
{ {
"slug": "event-tracking", "slug": "event-tracking",
"title": "Event tracking", "title": "Event tracking",
@@ -119,8 +125,8 @@
"intro": "Common questions about session tracking with OpenPanel.", "intro": "Common questions about session tracking with OpenPanel.",
"items": [ "items": [
{ {
"question": "How is this different from session replay tools like Hotjar or FullStory?", "question": "Does OpenPanel have session replay?",
"answer": "Session replay tools record a video of the user's screen. OpenPanel doesn't record anything visual-it tracks structured data: which pages were visited, which events were triggered, and how long the session lasted. You get the analytical answers without the privacy cost or the hours spent watching recordings." "answer": "Yes. Session replay is available as an opt-in feature. Enable it by setting `sessionReplay: { enabled: true }` in your init config. When disabled, the replay script is never loaded. See the [session replay docs](/docs/session-replay) for setup and privacy options."
}, },
{ {
"question": "Do I need to set up session tracking separately?", "question": "Do I need to set up session tracking separately?",
@@ -132,7 +138,7 @@
}, },
{ {
"question": "Can I see individual user sessions?", "question": "Can I see individual user sessions?",
"answer": "Yes. You can view a user's session history, including the pages they visited and events they triggered in each session. This is available in the user profile view." "answer": "Yes. You can view a user's session history, including the pages they visited and events they triggered in each session. This is available in the user profile view. If session replay is enabled, you can also play back the session."
}, },
{ {
"question": "Does session tracking require cookies?", "question": "Does session tracking require cookies?",
@@ -144,4 +150,4 @@
"label": "Start tracking sessions", "label": "Start tracking sessions",
"href": "https://dashboard.openpanel.dev/onboarding" "href": "https://dashboard.openpanel.dev/onboarding"
} }
} }

View File

@@ -0,0 +1,245 @@
---
title: "How to add session replay to your website"
description: "Add privacy-first session replay to any site in minutes using OpenPanel. See exactly what users do without recording sensitive data."
difficulty: beginner
timeToComplete: 10
date: 2026-02-27
updated: 2026-02-27
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install OpenPanel"
anchor: "install"
- name: "Enable session replay"
anchor: "enable"
- name: "Configure privacy controls"
anchor: "privacy"
- name: "View replays in the dashboard"
anchor: "view"
---
# How to add session replay to your website
This guide walks you through enabling [session replay](/features/session-replay) with OpenPanel. By the end, you'll be recording real user sessions you can play back in the dashboard to understand exactly what your users did.
Session replay captures clicks, scrolls, and interactions as structured data—not video. Privacy controls are built in, and the replay module loads asynchronously so it never slows down your main analytics.
## Prerequisites
- An OpenPanel account
- Your Client ID from the [OpenPanel dashboard](https://dashboard.openpanel.dev/onboarding)
- Either the `@openpanel/web` npm package installed, or access to add a script tag to your site
## Install OpenPanel [#install]
If you're starting fresh, add the OpenPanel script tag to your page. If you already have OpenPanel installed, skip to the next step.
```html title="index.html"
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
Or with npm:
```bash
npm install @openpanel/web
```
See the [Web SDK docs](/docs/sdks/web) or [Script tag docs](/docs/sdks/script) for a full install guide.
## Enable session replay [#enable]
Session replay is **off by default**. Enable it by adding `sessionReplay: { enabled: true }` to your init config.
### Script tag
```html title="index.html"
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
sessionReplay: {
enabled: true,
},
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
The replay script (`op1-replay.js`) is fetched automatically alongside the main script. Because it loads asynchronously, it doesn't affect page load time or the size of your main analytics bundle.
### NPM package
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
sessionReplay: {
enabled: true,
},
});
```
With the npm package, the replay module is a dynamic import resolved by your bundler. It is automatically code-split from your main bundle—if you don't enable replay, the module is never included.
### Next.js
For Next.js, enable replay in your `OpenPanelComponent`:
```tsx title="app/layout.tsx"
import { OpenPanelComponent } from '@openpanel/nextjs';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<OpenPanelComponent
clientId="YOUR_CLIENT_ID"
trackScreenViews={true}
sessionReplay={{
enabled: true,
}}
/>
{children}
</body>
</html>
);
}
```
## Configure privacy controls [#privacy]
Session replay captures real user behavior, so it's important to control what gets recorded. OpenPanel gives you several layers of control.
### Input masking (enabled by default)
All form input values are masked by default. The recorder sees that a user typed something, but not what they typed. You never need to add special attributes to password or credit card fields—they're masked automatically.
If you need to disable masking for a specific use case:
```ts
sessionReplay: {
enabled: true,
maskAllInputs: false,
}
```
### Block sensitive elements
Elements with `data-openpanel-replay-block` are replaced with a grey placeholder in the replay. The element and all its children are completely excluded from recording.
```html
<!-- This section will appear as a placeholder in replays -->
<div data-openpanel-replay-block>
<img src="user-avatar.jpg" alt="Profile photo" />
<p>Private user content</p>
</div>
```
You can also configure a CSS selector or class to block without adding data attributes to every element:
```ts
sessionReplay: {
enabled: true,
blockSelector: '.payment-form, .user-profile-card',
blockClass: 'no-replay',
}
```
### Mask specific text
To mask text within an element without blocking its layout from the replay, use `data-openpanel-replay-mask`:
```html
<p>
Account balance:
<span data-openpanel-replay-mask>$1,234.56</span>
</p>
```
The span's text appears as `***` in the replay while the surrounding layout remains visible.
Configure a custom selector to avoid adding attributes to every element:
```ts
sessionReplay: {
enabled: true,
maskTextSelector: '.balance, .account-number, [data-pii]',
}
```
### Ignore interactions
Use `ignoreSelector` to prevent interactions with specific elements from being captured. The element is still visible in the replay, but clicks and input events on it are not recorded.
```ts
sessionReplay: {
enabled: true,
ignoreSelector: '.internal-debug-toolbar',
}
```
## View replays in the dashboard [#view]
Navigate to your [OpenPanel dashboard](https://dashboard.openpanel.dev) and open the Sessions view. Any recorded session will show a replay button. Click it to play back the session from the beginning.
The replay timeline shows all events alongside the recording, so you can jump directly to a click, form submission, or page navigation.
Replays are also accessible from user profiles. Open any user's profile, find a session in their history, and click through to the replay.
## Performance considerations
The replay recorder buffers events locally and sends them to OpenPanel in chunks every 10 seconds (configurable via `flushIntervalMs`). On tab close or page hide, any remaining buffered events are flushed immediately.
The default chunk size limits are:
- **200 events per chunk** (`maxEventsPerChunk`)
- **1 MB per payload** (`maxPayloadBytes`)
These defaults work well for most sites. If you have pages with heavy DOM activity, you can lower `maxEventsPerChunk` to send smaller, more frequent chunks:
```ts
sessionReplay: {
enabled: true,
flushIntervalMs: 5000,
maxEventsPerChunk: 100,
}
```
## Next steps
- Read the [session replay docs](/docs/session-replay) for a full option reference
- Learn about [session tracking](/features/session-tracking) to understand what session data is available without replay
- See how [funnels](/features/funnels) and session replay work together to diagnose drop-offs
<Faqs>
<FaqItem question="Does session replay affect my page load speed?">
No. The replay module (`op1-replay.js`) loads as a separate async script after the page and the main analytics script. It does not block rendering or inflate your main bundle.
</FaqItem>
<FaqItem question="Is session replay enabled for all users?">
Yes, when enabled, all sessions are recorded by default. You can use the `sampleRate` option to record only a percentage of sessions if needed.
</FaqItem>
<FaqItem question="Are passwords and credit card numbers recorded?">
No. All input field values are masked by default (`maskAllInputs: true`). The recorder captures that a user typed something, but not the actual characters. Disable this only with a specific reason.
</FaqItem>
<FaqItem question="How long are replays kept?">
Replays are retained for 30 days. There is no limit on the number of sessions recorded.
</FaqItem>
<FaqItem question="Can I use session replay with self-hosted OpenPanel?">
Yes. The replay script is served from your self-hosted instance automatically. You can also use the `scriptUrl` option to load it from a custom CDN.
</FaqItem>
</Faqs>

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -3,6 +3,7 @@ import {
ChevronRightIcon, ChevronRightIcon,
DollarSignIcon, DollarSignIcon,
GlobeIcon, GlobeIcon,
PlayCircleIcon,
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { FeatureCard } from '@/components/feature-card'; import { FeatureCard } from '@/components/feature-card';
@@ -41,6 +42,16 @@ const features = [
children: 'All about tracking', children: 'All about tracking',
}, },
}, },
{
title: 'Session Replay',
description:
'Watch real user sessions to see exactly what happened. Privacy controls built in, loads async.',
icon: PlayCircleIcon,
link: {
href: '/features/session-replay',
children: 'See session replay',
},
},
]; ];
export function AnalyticsInsights() { export function AnalyticsInsights() {
@@ -68,7 +79,7 @@ export function AnalyticsInsights() {
variant="large" variant="large"
/> />
</div> </div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{features.map((feature) => ( {features.map((feature) => (
<FeatureCard <FeatureCard
description={feature.description} description={feature.description}

View File

@@ -1,4 +1,5 @@
import type { MetadataRoute } from 'next'; import type { MetadataRoute } from 'next';
import { getAllForSlugs } from '@/lib/for';
import { url } from '@/lib/layout.shared'; import { url } from '@/lib/layout.shared';
import { import {
articleSource, articleSource,
@@ -14,6 +15,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const docs = await source.getPages(); const docs = await source.getPages();
const pages = await pageSource.getPages(); const pages = await pageSource.getPages();
const guides = await guideSource.getPages(); const guides = await guideSource.getPages();
const forSlugs = await getAllForSlugs();
return [ return [
{ {
url: url('/'), url: url('/'),
@@ -119,5 +121,17 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly' as const, changeFrequency: 'monthly' as const,
priority: 0.8, priority: 0.8,
})), })),
{
url: url('/for'),
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.7,
},
...forSlugs.map((slug) => ({
url: url(`/for/${slug}`),
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
})),
]; ];
} }

View File

@@ -3,4 +3,15 @@
- `trackScreenViews` - If true, the library will automatically track screen views (default: false) - `trackScreenViews` - If true, the library will automatically track screen views (default: false)
- `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false) - `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false)
- `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false) - `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false)
- `sessionReplay` - Session replay configuration object (default: disabled). See [session replay docs](/docs/session-replay) for full options.
- `enabled` - Enable session replay recording (default: false)
- `maskAllInputs` - Mask all input field values (default: true)
- `maskTextSelector` - CSS selector for text elements to mask (default: `[data-openpanel-replay-mask]`)
- `blockSelector` - CSS selector for elements to replace with a placeholder (default: `[data-openpanel-replay-block]`)
- `blockClass` - Class name that blocks elements from being recorded
- `ignoreSelector` - CSS selector for elements excluded from interaction tracking
- `flushIntervalMs` - How often (ms) recorded events are sent to the server (default: 10000)
- `maxEventsPerChunk` - Maximum events per payload chunk (default: 200)
- `maxPayloadBytes` - Maximum payload size in bytes (default: 1048576)
- `scriptUrl` - Custom URL for the replay script (script-tag builds only)

View File

@@ -13,7 +13,7 @@ import { Button } from '../ui/button';
const validator = zSignInEmail; const validator = zSignInEmail;
type IForm = z.infer<typeof validator>; type IForm = z.infer<typeof validator>;
export function SignInEmailForm() { export function SignInEmailForm({ isLastUsed }: { isLastUsed?: boolean }) {
const trpc = useTRPC(); const trpc = useTRPC();
const mutation = useMutation( const mutation = useMutation(
trpc.auth.signInEmail.mutationOptions({ trpc.auth.signInEmail.mutationOptions({
@@ -54,9 +54,16 @@ export function SignInEmailForm() {
type="password" type="password"
className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20" className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20"
/> />
<Button type="submit" size="lg"> <div className="relative">
Sign in <Button type="submit" size="lg" className="w-full">
</Button> Sign in
</Button>
{isLastUsed && (
<span className="absolute -top-2 right-3 text-[10px] font-medium bg-highlight text-white px-1.5 py-0.5 rounded-full leading-none">
Used last time
</span>
)}
</div>
<button <button
type="button" type="button"
onClick={() => onClick={() =>

View File

@@ -5,7 +5,8 @@ import { Button } from '../ui/button';
export function SignInGithub({ export function SignInGithub({
type, type,
inviteId, inviteId,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) { isLastUsed,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string; isLastUsed?: boolean }) {
const trpc = useTRPC(); const trpc = useTRPC();
const mutation = useMutation( const mutation = useMutation(
trpc.auth.signInOAuth.mutationOptions({ trpc.auth.signInOAuth.mutationOptions({
@@ -21,27 +22,34 @@ export function SignInGithub({
if (type === 'sign-up') return 'Sign up with Github'; if (type === 'sign-up') return 'Sign up with Github';
}; };
return ( return (
<Button <div className="relative">
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0" <Button
size="lg" className="w-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0"
onClick={() => size="lg"
mutation.mutate({ onClick={() =>
provider: 'github', mutation.mutate({
inviteId: type === 'sign-up' ? inviteId : undefined, 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
</svg> className="size-4 mr-2"
{title()} xmlns="http://www.w3.org/2000/svg"
</Button> 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>
{isLastUsed && (
<span className="absolute -top-2 right-3 text-[10px] font-medium bg-highlight text-white px-1.5 py-0.5 rounded-full leading-none">
Used last time
</span>
)}
</div>
); );
} }

View File

@@ -1,11 +1,16 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { useTRPC } from '@/integrations/trpc/react';
export function SignInGoogle({ export function SignInGoogle({
type, type,
inviteId, inviteId,
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) { isLastUsed,
}: {
type: 'sign-in' | 'sign-up';
inviteId?: string;
isLastUsed?: boolean;
}) {
const trpc = useTRPC(); const trpc = useTRPC();
const mutation = useMutation( const mutation = useMutation(
trpc.auth.signInOAuth.mutationOptions({ trpc.auth.signInOAuth.mutationOptions({
@@ -14,46 +19,57 @@ export function SignInGoogle({
window.location.href = res.url; window.location.href = res.url;
} }
}, },
}), })
); );
const title = () => { const title = () => {
if (type === 'sign-in') return 'Sign in with Google'; if (type === 'sign-in') {
if (type === 'sign-up') return 'Sign up with Google'; return 'Sign in with Google';
}
if (type === 'sign-up') {
return 'Sign up with Google';
}
}; };
return ( return (
<Button <div className="relative">
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" <Button
size="lg" className="w-full border border-def-300 bg-background text-foreground shadow-sm transition-all duration-200 hover:bg-def-100 hover:shadow-md [&_svg]:shrink-0"
onClick={() => onClick={() =>
mutation.mutate({ mutation.mutate({
provider: 'google', provider: 'google',
inviteId: type === 'sign-up' ? inviteId : undefined, inviteId: type === 'sign-up' ? inviteId : undefined,
}) })
} }
> size="lg"
<svg
className="size-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
> >
<path <svg
fill="#4285F4" className="mr-2 size-4"
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" viewBox="0 0 24 24"
/> xmlns="http://www.w3.org/2000/svg"
<path >
fill="#34A853" <path
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" 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"
/> fill="#4285F4"
<path />
fill="#FBBC05" <path
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" 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"
/> fill="#34A853"
<path />
fill="#EA4335" <path
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" 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"
/> fill="#FBBC05"
</svg> />
{title()} <path
</Button> 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"
fill="#EA4335"
/>
</svg>
{title()}
</Button>
{isLastUsed && (
<span className="absolute -top-2 right-3 rounded-full bg-highlight px-1.5 py-0.5 font-medium text-[10px] text-white leading-none">
Used last time
</span>
)}
</div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { formatDateTime, formatTime } from '@/utils/date'; import { DropdownMenuSeparator } from '@radix-ui/react-dropdown-menu';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns'; import { toast } from 'sonner';
import { ColumnCreatedAt } from '@/components/column-created-at'; import { ColumnCreatedAt } from '@/components/column-created-at';
import CopyInput from '@/components/forms/copy-input'; import CopyInput from '@/components/forms/copy-input';
import { createActionColumn } from '@/components/ui/data-table/data-table-helpers'; import { createActionColumn } from '@/components/ui/data-table/data-table-helpers';
@@ -10,9 +10,6 @@ import { handleError, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals'; import { pushModal, showConfirm } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { clipboard } from '@/utils/clipboard'; 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() { export function useColumns() {
const columns: ColumnDef<RouterOutputs['client']['list'][number]>[] = [ const columns: ColumnDef<RouterOutputs['client']['list'][number]>[] = [
@@ -51,7 +48,7 @@ export function useColumns() {
queryClient.invalidateQueries(trpc.client.list.pathFilter()); queryClient.invalidateQueries(trpc.client.list.pathFilter());
}, },
onError: handleError, onError: handleError,
}), })
); );
return ( return (
<> <>
@@ -67,7 +64,6 @@ export function useColumns() {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
variant="destructive"
onClick={() => { onClick={() => {
showConfirm({ showConfirm({
title: 'Revoke client', title: 'Revoke client',
@@ -79,6 +75,7 @@ export function useColumns() {
}, },
}); });
}} }}
variant="destructive"
> >
Revoke Revoke
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -1,17 +1,16 @@
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import { PlusIcon } from 'lucide-react';
import { useColumns } from './columns';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/ui/data-table/data-table'; import { DataTable } from '@/components/ui/data-table/data-table';
import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar'; import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar';
import { useTable } from '@/components/ui/data-table/use-table'; import { useTable } from '@/components/ui/data-table/use-table';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { PlusIcon } from 'lucide-react';
import { useColumns } from './columns';
type Props = { interface Props {
query: UseQueryResult<RouterOutputs['client']['list'], unknown>; query: UseQueryResult<RouterOutputs['client']['list'], unknown>;
}; }
export const ClientsTable = ({ query }: Props) => { export const ClientsTable = ({ query }: Props) => {
const columns = useColumns(); const columns = useColumns();
@@ -30,13 +29,13 @@ export const ClientsTable = ({ query }: Props) => {
<DataTableToolbar table={table}> <DataTableToolbar table={table}>
<Button <Button
icon={PlusIcon} icon={PlusIcon}
responsive
onClick={() => pushModal('AddClient')} onClick={() => pushModal('AddClient')}
responsive
> >
Create client Create client
</Button> </Button>
</DataTableToolbar> </DataTableToolbar>
<DataTable table={table} loading={isLoading} /> <DataTable loading={isLoading} table={table} />
</> </>
); );
}; };

View File

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

View File

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

View File

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

View File

@@ -1,77 +1,86 @@
import { pushModal } from '@/modals';
import { MonitorIcon } from 'lucide-react';
import Syntax from '@/components/syntax';
import type { IServiceClient } from '@openpanel/db'; import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info'; import { frameworks } from '@openpanel/sdk-info';
import { CopyIcon, PlugIcon } from 'lucide-react';
import { Button } from '../ui/button';
import Syntax from '@/components/syntax';
import { useAppContext } from '@/hooks/use-app-context';
import { pushModal } from '@/modals';
import { clipboard } from '@/utils/clipboard';
type Props = { interface Props {
client: IServiceClient | null; client: IServiceClient | null;
}; }
const ConnectWeb = ({ client }: Props) => { const ConnectWeb = ({ client }: Props) => {
return ( const context = useAppContext();
<> const code = `<script>
<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(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }(); window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', { window.op('init', {${context.isSelfHosted ? `\n\tapiUrl: '${context.apiUrl}',` : ''}
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}', clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
trackScreenViews: true, trackScreenViews: true,
trackOutgoingLinks: true, trackOutgoingLinks: true,
trackAttributes: true, trackAttributes: true,
// sessionReplay: {
// enabled: true,
// },
}); });
</script> </script>
<script src="https://openpanel.dev/op1.js" defer async></script>`} <script src="https://openpanel.dev/op1.js" defer async></script>`;
/> return (
<div className="col gap-4">
<div className="col gap-2">
<div className="row items-center justify-between gap-4">
<div className="flex items-center gap-2 font-bold text-xl capitalize">
<PlugIcon className="size-4" />
Quick start
</div>
<div className="row gap-2">
<Button
icon={CopyIcon}
onClick={() => clipboard(code, null)}
variant="outline"
>
Copy
</Button>
</div>
</div>
<Syntax className="border" code={code} copyable={false} />
</div> </div>
<div> <div className="col gap-4">
<p className="text-muted-foreground mb-2"> <p className="text-center text-muted-foreground text-sm">
Or pick a framework below to get started. Or pick a framework below to get started.
</p> </p>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{frameworks {frameworks.map((framework) => (
.filter((framework) => framework.type.includes('website')) <button
.map((framework) => ( className="flex items-center gap-4 rounded-md border p-2 text-left"
<button key={framework.name}
type="button" onClick={() =>
onClick={() => pushModal('Instructions', {
pushModal('Instructions', { framework,
framework, client,
client, })
}) }
} type="button"
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 className="h-10 w-10 rounded-md bg-def-200 p-2"> </div>
<framework.IconComponent className="h-full w-full" /> <div className="flex-1 font-semibold">{framework.name}</div>
</div> </button>
<div className="flex-1 font-semibold">{framework.name}</div> ))}
</button>
))}
</div> </div>
<p className="mt-2 text-sm text-muted-foreground"> <p className="text-center text-muted-foreground text-sm">
Missing a framework?{' '} Missing a framework?{' '}
<a <a
href="mailto:hello@openpanel.dev"
className="text-foreground underline" className="text-foreground underline"
href="mailto:hello@openpanel.dev"
> >
Let us know! Let us know!
</a> </a>
</p> </p>
</div> </div>
</> </div>
); );
}; };

View File

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

View File

@@ -1,22 +1,20 @@
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 { import type {
IServiceClient, IServiceClient,
IServiceEvent, IServiceEvent,
IServiceProject, IServiceProject,
} from '@openpanel/db'; } from '@openpanel/db';
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
import { useState } from 'react';
import useWS from '@/hooks/use-ws';
import { cn } from '@/utils/cn';
import { timeAgo } from '@/utils/date';
type Props = { interface Props {
project: IServiceProject; project: IServiceProject;
client: IServiceClient | null; client: IServiceClient | null;
events: IServiceEvent[]; events: IServiceEvent[];
onVerified: (verified: boolean) => void; onVerified: (verified: boolean) => void;
}; }
const VerifyListener = ({ client, events: _events, onVerified }: Props) => { const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []); const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
@@ -25,7 +23,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
(data) => { (data) => {
setEvents((prev) => [...prev, data]); setEvents((prev) => [...prev, data]);
onVerified(true); onVerified(true);
}, }
); );
const isConnected = events.length > 0; const isConnected = events.length > 0;
@@ -34,15 +32,15 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
if (isConnected) { if (isConnected) {
return ( return (
<CheckCircle2Icon <CheckCircle2Icon
strokeWidth={1.2}
size={40}
className="shrink-0 text-emerald-600" className="shrink-0 text-emerald-600"
size={40}
strokeWidth={1.2}
/> />
); );
} }
return ( return (
<Loader2 size={40} className="shrink-0 animate-spin text-highlight" /> <Loader2 className="shrink-0 animate-spin text-highlight" size={40} />
); );
}; };
@@ -51,24 +49,24 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
<div <div
className={cn( className={cn(
'flex gap-6 rounded-xl p-4 md:p-6', 'flex gap-6 rounded-xl p-4 md:p-6',
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10', isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10'
)} )}
> >
{renderIcon()} {renderIcon()}
<div className="flex-1"> <div className="flex-1">
<div className="text-lg font-semibold leading-normal text-foreground/90"> <div className="font-semibold text-foreground/90 text-lg leading-normal">
{isConnected ? 'Success' : 'Waiting for events'} {isConnected ? 'Success' : 'Waiting for events'}
</div> </div>
{isConnected ? ( {isConnected ? (
<div className="flex flex-col-reverse"> <div className="flex flex-col-reverse">
{events.length > 5 && ( {events.length > 5 && (
<div className="flex items-center gap-2 "> <div className="flex items-center gap-2">
<CheckIcon size={14} />{' '} <CheckIcon size={14} />{' '}
<span>{events.length - 5} more events</span> <span>{events.length - 5} more events</span>
</div> </div>
)} )}
{events.slice(-5).map((event) => ( {events.slice(-5).map((event) => (
<div key={event.id} className="flex items-center gap-2 "> <div className="flex items-center gap-2" key={event.id}>
<CheckIcon size={14} />{' '} <CheckIcon size={14} />{' '}
<span className="font-medium">{event.name}</span>{' '} <span className="font-medium">{event.name}</span>{' '}
<span className="ml-auto text-emerald-800"> <span className="ml-auto text-emerald-800">
@@ -84,23 +82,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
)} )}
</div> </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> </div>
); );
}; };

View File

@@ -0,0 +1,190 @@
import type { IServiceProjectWithClients } from '@openpanel/db';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { GlobeIcon, KeyIcon, UserIcon } from 'lucide-react';
import { toast } from 'sonner';
import CopyInput from '../forms/copy-input';
import { WithLabel } from '../forms/input-with-label';
import TagInput from '../forms/tag-input';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { useAppContext } from '@/hooks/use-app-context';
import { useClientSecret } from '@/hooks/use-client-secret';
import { handleError, useTRPC } from '@/integrations/trpc/react';
export function VerifyFaq({
project,
}: {
project: IServiceProjectWithClients;
}) {
const context = useAppContext();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [secret] = useClientSecret();
const updateProject = useMutation(
trpc.project.update.mutationOptions({
onError: handleError,
onSuccess: () => {
queryClient.invalidateQueries(
trpc.project.getProjectWithClients.queryFilter({
projectId: project.id,
})
);
toast.success('Allowed domains updated');
},
})
);
const client = project.clients[0];
if (!client) {
return null;
}
const handleCorsChange = (newValue: string[]) => {
const normalized = newValue
.map((item: string) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return trimmed ? `https://${trimmed}` : trimmed;
})
.filter(Boolean);
updateProject.mutate({ id: project.id, cors: normalized });
};
const showSecret = secret && secret !== '[CLIENT_SECRET]';
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 collapsible type="single">
<AccordionItem value="item-1">
<AccordionTrigger className="px-6">
No events received?
</AccordionTrigger>
<AccordionContent className="col gap-4 p-6 pt-2">
<p>
Don't worry, this happens to everyone. Here are a few things you
can check:
</p>
<div className="col gap-2">
<Alert>
<UserIcon size={16} />
<AlertTitle>Ensure client ID is correct</AlertTitle>
<AlertDescription className="col gap-2">
<span>
For web tracking, the <code>clientId</code> in your snippet
must match this project. Copy it here if needed:
</span>
<CopyInput
className="[&_.font-mono]:text-sm"
label="Client ID"
value={client.id}
/>
</AlertDescription>
</Alert>
<Alert>
<GlobeIcon size={16} />
<AlertTitle>Correct domain configured</AlertTitle>
<AlertDescription className="col gap-2">
<span>
For websites it&apos;s important that the domain is
correctly configured. We authenticate requests based on the
domain. Update allowed domains below:
</span>
<WithLabel label="Allowed domains">
<TagInput
onChange={handleCorsChange}
placeholder="Accept events from these domains"
renderTag={(tag: string) =>
tag === '*' ? 'Accept events from any domains' : tag
}
value={project.cors ?? []}
/>
</WithLabel>
</AlertDescription>
</Alert>
<Alert>
<KeyIcon size={16} />
<AlertTitle>Wrong client secret</AlertTitle>
<AlertDescription className="col gap-2">
<span>
For app and backend events you need the correct{' '}
<code>clientSecret</code>. Copy it here if needed. Never use
the client secret in web or client-side code—it would expose
your credentials.
</span>
{showSecret && (
<CopyInput
className="[&_.font-mono]:text-sm"
label="Client secret"
value={secret}
/>
)}
</AlertDescription>
</Alert>
</div>
<p>
Still have issues? Join our{' '}
<a className="underline" href="https://go.openpanel.dev/discord">
discord channel
</a>{' '}
give us an email at{' '}
<a className="underline" href="mailto:hello@openpanel.dev">
hello@openpanel.dev
</a>{' '}
and we&apos;ll help you out.
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger className="px-6">
Personal curl example
</AccordionTrigger>
<AccordionContent className="p-0">
<Syntax code={code} language="bash" />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -1,18 +1,7 @@
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 { sum } from '@openpanel/common';
import type { IServiceOrganization } from '@openpanel/db'; import type { IServiceOrganization } from '@openpanel/db';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Loader2Icon } from 'lucide-react'; import { Loader2Icon } from 'lucide-react';
import { pick } from 'ramda';
import { import {
Bar, Bar,
BarChart, BarChart,
@@ -23,16 +12,24 @@ import {
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { BarShapeBlue } from '../charts/common-bar'; import { BarShapeBlue } from '../charts/common-bar';
import {
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';
type Props = { interface Props {
organization: IServiceOrganization; organization: IServiceOrganization;
}; }
function Card({ title, value }: { title: string; value: string }) { function Card({ title, value }: { title: string; value: string }) {
return ( return (
<div className="col gap-2 p-4 flex-1 min-w-0" title={`${title}: ${value}`}> <div className="col min-w-0 flex-1 gap-2 p-4" title={`${title}: ${value}`}>
<div className="text-muted-foreground truncate">{title}</div> <div className="truncate text-muted-foreground">{title}</div>
<div className="font-mono text-xl font-bold truncate">{value}</div> <div className="truncate font-bold font-mono text-xl">{value}</div>
</div> </div>
); );
} }
@@ -43,18 +40,20 @@ export default function BillingUsage({ organization }: Props) {
const usageQuery = useQuery( const usageQuery = useQuery(
trpc.subscription.usage.queryOptions({ trpc.subscription.usage.queryOptions({
organizationId: organization.id, organizationId: organization.id,
}), })
); );
// Determine interval based on data range - use weekly if more than 30 days // Determine interval based on data range - use weekly if more than 30 days
const getDataInterval = () => { const getDataInterval = () => {
if (!usageQuery.data || usageQuery.data.length === 0) return 'day'; if (!usageQuery.data || usageQuery.data.length === 0) {
return 'day';
}
const dates = usageQuery.data.map((item) => new Date(item.day)); const dates = usageQuery.data.map((item) => new Date(item.day));
const minDate = new Date(Math.min(...dates.map((d) => d.getTime()))); const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime()))); const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
const daysDiff = Math.ceil( const daysDiff = Math.ceil(
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24), (maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24)
); );
return daysDiff > 30 ? 'week' : 'day'; return daysDiff > 30 ? 'week' : 'day';
@@ -78,7 +77,7 @@ export default function BillingUsage({ organization }: Props) {
return wrapper( return wrapper(
<div className="center-center p-8"> <div className="center-center p-8">
<Loader2Icon className="animate-spin" /> <Loader2Icon className="animate-spin" />
</div>, </div>
); );
} }
@@ -86,13 +85,16 @@ export default function BillingUsage({ organization }: Props) {
return wrapper( return wrapper(
<div className="center-center p-8 font-medium"> <div className="center-center p-8 font-medium">
Issues loading usage data Issues loading usage data
</div>, </div>
); );
} }
if (usageQuery.data?.length === 0) { if (
usageQuery.data?.length === 0 ||
!usageQuery.data?.some((item) => item.count !== 0)
) {
return wrapper( return wrapper(
<div className="center-center p-8 font-medium">No usage data yet</div>, <div className="center-center p-8 font-medium">No usage data yet</div>
); );
} }
@@ -105,7 +107,9 @@ export default function BillingUsage({ organization }: Props) {
// Group daily data into weekly intervals if data spans more than 30 days // Group daily data into weekly intervals if data spans more than 30 days
const processChartData = () => { const processChartData = () => {
if (!usageQuery.data) return []; if (!usageQuery.data) {
return [];
}
if (useWeeklyIntervals) { if (useWeeklyIntervals) {
// Group daily data into weekly intervals // Group daily data into weekly intervals
@@ -157,7 +161,7 @@ export default function BillingUsage({ organization }: Props) {
Math.max( Math.max(
subscriptionPeriodEventsLimit, subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount, subscriptionPeriodEventsCount,
...chartData.map((item) => item.count), ...chartData.map((item) => item.count)
), ),
] as [number, number]; ] as [number, number];
@@ -165,7 +169,7 @@ export default function BillingUsage({ organization }: Props) {
return wrapper( return wrapper(
<> <>
<div className="-m-4 mb-4 grid grid-cols-2 [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border border-b"> <div className="-m-4 mb-4 grid grid-cols-2 border-b [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border">
{organization.hasSubscription ? ( {organization.hasSubscription ? (
<> <>
<Card <Card
@@ -186,7 +190,7 @@ export default function BillingUsage({ organization }: Props) {
1 - 1 -
subscriptionPeriodEventsCount / subscriptionPeriodEventsCount /
subscriptionPeriodEventsLimit, subscriptionPeriodEventsLimit,
'%', '%'
) )
} }
/> />
@@ -208,7 +212,7 @@ export default function BillingUsage({ organization }: Props) {
<Card <Card
title="Events from last 30 days" title="Events from last 30 days"
value={number.format( value={number.format(
sum(usageQuery.data?.map((item) => item.count) ?? []), sum(usageQuery.data?.map((item) => item.count) ?? [])
)} )}
/> />
</div> </div>
@@ -217,12 +221,12 @@ export default function BillingUsage({ organization }: Props) {
</div> </div>
{/* Events Chart */} {/* Events Chart */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground"> <h3 className="font-medium text-muted-foreground text-sm">
{useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'} {useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'}
</h3> </h3>
<div className="max-h-[300px] h-[250px] w-full p-4"> <div className="h-[250px] max-h-[300px] w-full p-4">
<ResponsiveContainer> <ResponsiveContainer>
<BarChart data={chartData} barSize={useWeeklyIntervals ? 20 : 8}> <BarChart barSize={useWeeklyIntervals ? 20 : 8} data={chartData}>
<RechartTooltip <RechartTooltip
content={<EventsTooltip useWeekly={useWeeklyIntervals} />} content={<EventsTooltip useWeekly={useWeeklyIntervals} />}
cursor={{ cursor={{
@@ -239,15 +243,15 @@ export default function BillingUsage({ organization }: Props) {
<YAxis {...yAxisProps} domain={[0, 'dataMax']} /> <YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid <CartesianGrid
horizontal={true} horizontal={true}
vertical={false}
strokeDasharray="3 3" strokeDasharray="3 3"
strokeOpacity={0.5} strokeOpacity={0.5}
vertical={false}
/> />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
</>, </>
); );
} }
@@ -261,7 +265,7 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
return ( return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl"> <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"> <div className="text-muted-foreground text-sm">
{useWeekly && payload.weekRange {useWeekly && payload.weekRange
? payload.weekRange ? payload.weekRange
: payload?.date : payload?.date
@@ -271,10 +275,10 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-0" /> <div className="h-10 w-1 rounded-full bg-chart-0" />
<div className="col gap-1"> <div className="col gap-1">
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
Events {useWeekly ? 'this week' : 'this day'} Events {useWeekly ? 'this week' : 'this day'}
</div> </div>
<div className="text-lg font-semibold text-chart-0"> <div className="font-semibold text-chart-0 text-lg">
{number.format(payload.count)} {number.format(payload.count)}
</div> </div>
</div> </div>
@@ -293,22 +297,22 @@ function TotalTooltip(props: any) {
return ( return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl"> <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="text-muted-foreground text-sm">Total Events</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-2" /> <div className="h-10 w-1 rounded-full bg-chart-2" />
<div className="col gap-1"> <div className="col gap-1">
<div className="text-sm text-muted-foreground">Your events count</div> <div className="text-muted-foreground text-sm">Your events count</div>
<div className="text-lg font-semibold text-chart-2"> <div className="font-semibold text-chart-2 text-lg">
{number.format(payload.count)} {number.format(payload.count)}
</div> </div>
</div> </div>
</div> </div>
{payload.limit > 0 && ( {payload.limit > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-1" /> <div className="h-10 w-1 rounded-full border-2 border-chart-1 border-dashed" />
<div className="col gap-1"> <div className="col gap-1">
<div className="text-sm text-muted-foreground">Your tier limit</div> <div className="text-muted-foreground text-sm">Your tier limit</div>
<div className="text-lg font-semibold text-chart-1"> <div className="font-semibold text-chart-1 text-lg">
{number.format(payload.limit)} {number.format(payload.limit)}
</div> </div>
</div> </div>

View File

@@ -1,9 +1,3 @@
import { Button } from '@/components/ui/button';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, useOnPushModal } from '@/modals';
import { formatDate } from '@/utils/date';
import type { IServiceOrganization } from '@openpanel/db'; import type { IServiceOrganization } from '@openpanel/db';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@@ -14,6 +8,12 @@ import { Progress } from '../ui/progress';
import { Widget, WidgetBody, WidgetHead } from '../widget'; import { Widget, WidgetBody, WidgetHead } from '../widget';
import { BillingFaq } from './billing-faq'; import { BillingFaq } from './billing-faq';
import BillingUsage from './billing-usage'; import BillingUsage from './billing-usage';
import { Button } from '@/components/ui/button';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, useOnPushModal } from '@/modals';
import { formatDate } from '@/utils/date';
type Props = { type Props = {
organization: IServiceOrganization; organization: IServiceOrganization;
@@ -28,13 +28,13 @@ export default function Billing({ organization }: Props) {
const productsQuery = useQuery( const productsQuery = useQuery(
trpc.subscription.products.queryOptions({ trpc.subscription.products.queryOptions({
organizationId: organization.id, organizationId: organization.id,
}), })
); );
const currentProductQuery = useQuery( const currentProductQuery = useQuery(
trpc.subscription.getCurrent.queryOptions({ trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id, organizationId: organization.id,
}), })
); );
const portalMutation = useMutation( const portalMutation = useMutation(
@@ -47,7 +47,7 @@ export default function Billing({ organization }: Props) {
onError(error) { onError(error) {
toast.error(error.message); toast.error(error.message);
}, },
}), })
); );
useWS(`/live/organization/${organization.id}`, () => { useWS(`/live/organization/${organization.id}`, () => {
@@ -55,7 +55,7 @@ export default function Billing({ organization }: Props) {
}); });
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>( const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
(organization.subscriptionInterval as 'year' | 'month') || 'month', (organization.subscriptionInterval as 'year' | 'month') || 'month'
); );
const products = useMemo(() => { const products = useMemo(() => {
@@ -66,7 +66,7 @@ export default function Billing({ organization }: Props) {
const currentProduct = currentProductQuery.data ?? null; const currentProduct = currentProductQuery.data ?? null;
const currentPrice = currentProduct?.prices.flatMap((p) => const currentPrice = currentProduct?.prices.flatMap((p) =>
p.type === 'recurring' && p.amountType === 'fixed' ? [p] : [], p.type === 'recurring' && p.amountType === 'fixed' ? [p] : []
)[0]; )[0];
const renderStatus = () => { const renderStatus = () => {
@@ -138,12 +138,12 @@ export default function Billing({ organization }: Props) {
}); });
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="col gap-8"> <div className="col gap-8">
{currentProduct && currentPrice ? ( {currentProduct && currentPrice ? (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead className="flex items-center justify-between gap-4"> <WidgetHead className="flex items-center justify-between gap-4">
<div className="flex-1 title truncate">{currentProduct.name}</div> <div className="title flex-1 truncate">{currentProduct.name}</div>
<div className="text-lg"> <div className="text-lg">
<span className="font-bold"> <span className="font-bold">
{number.currency(currentPrice.priceAmount / 100)} {number.currency(currentPrice.priceAmount / 100)}
@@ -157,58 +157,58 @@ export default function Billing({ organization }: Props) {
<WidgetBody> <WidgetBody>
{renderStatus()} {renderStatus()}
<div className="col mt-4"> <div className="col mt-4">
<div className="font-semibold mb-2"> <div className="mb-2 font-semibold">
{number.format(organization.subscriptionPeriodEventsCount)} /{' '} {number.format(organization.subscriptionPeriodEventsCount)} /{' '}
{number.format(Number(currentProduct.metadata.eventsLimit))} {number.format(Number(currentProduct.metadata.eventsLimit))}
</div> </div>
<Progress <Progress
size="sm"
value={ value={
(organization.subscriptionPeriodEventsCount / (organization.subscriptionPeriodEventsCount /
Number(currentProduct.metadata.eventsLimit)) * Number(currentProduct.metadata.eventsLimit)) *
100 100
} }
size="sm"
/> />
<div className="row justify-between mt-4"> <div className="row mt-4 justify-between">
<Button <Button
variant="outline"
size="sm"
onClick={() => onClick={() =>
portalMutation.mutate({ organizationId: organization.id }) portalMutation.mutate({ organizationId: organization.id })
} }
size="sm"
variant="outline"
> >
<svg <svg
className="size-4 mr-2" className="mr-2 size-4"
width="300" fill="none"
height="300" height="300"
viewBox="0 0 300 300" viewBox="0 0 300 300"
fill="none" width="300"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<g clip-path="url(#clip0_1_4)"> <g clip-path="url(#clip0_1_4)">
<path <path
fill-rule="evenodd"
clip-rule="evenodd" clip-rule="evenodd"
d="M66.4284 274.26C134.876 320.593 227.925 302.666 274.258 234.219C320.593 165.771 302.666 72.7222 234.218 26.3885C165.77 -19.9451 72.721 -2.0181 26.3873 66.4297C-19.9465 134.877 -2.01938 227.927 66.4284 274.26ZM47.9555 116.67C30.8375 169.263 36.5445 221.893 59.2454 256.373C18.0412 217.361 7.27564 150.307 36.9437 92.318C55.9152 55.2362 87.5665 29.3937 122.5 18.3483C90.5911 36.7105 62.5549 71.8144 47.9555 116.67ZM175.347 283.137C211.377 272.606 244.211 246.385 263.685 208.322C293.101 150.825 282.768 84.4172 242.427 45.2673C264.22 79.7626 269.473 131.542 252.631 183.287C237.615 229.421 208.385 265.239 175.347 283.137ZM183.627 266.229C207.945 245.418 228.016 210.604 236.936 168.79C251.033 102.693 232.551 41.1978 195.112 20.6768C214.97 47.3945 225.022 99.2902 218.824 157.333C214.085 201.724 200.814 240.593 183.627 266.229ZM63.7178 131.844C49.5155 198.43 68.377 260.345 106.374 280.405C85.9962 254.009 75.5969 201.514 81.8758 142.711C86.5375 99.0536 99.4504 60.737 116.225 35.0969C92.2678 55.983 72.5384 90.4892 63.7178 131.844ZM199.834 149.561C200.908 217.473 179.59 272.878 152.222 273.309C124.853 273.742 101.797 219.039 100.724 151.127C99.6511 83.2138 120.968 27.8094 148.337 27.377C175.705 26.9446 198.762 81.648 199.834 149.561Z" d="M66.4284 274.26C134.876 320.593 227.925 302.666 274.258 234.219C320.593 165.771 302.666 72.7222 234.218 26.3885C165.77 -19.9451 72.721 -2.0181 26.3873 66.4297C-19.9465 134.877 -2.01938 227.927 66.4284 274.26ZM47.9555 116.67C30.8375 169.263 36.5445 221.893 59.2454 256.373C18.0412 217.361 7.27564 150.307 36.9437 92.318C55.9152 55.2362 87.5665 29.3937 122.5 18.3483C90.5911 36.7105 62.5549 71.8144 47.9555 116.67ZM175.347 283.137C211.377 272.606 244.211 246.385 263.685 208.322C293.101 150.825 282.768 84.4172 242.427 45.2673C264.22 79.7626 269.473 131.542 252.631 183.287C237.615 229.421 208.385 265.239 175.347 283.137ZM183.627 266.229C207.945 245.418 228.016 210.604 236.936 168.79C251.033 102.693 232.551 41.1978 195.112 20.6768C214.97 47.3945 225.022 99.2902 218.824 157.333C214.085 201.724 200.814 240.593 183.627 266.229ZM63.7178 131.844C49.5155 198.43 68.377 260.345 106.374 280.405C85.9962 254.009 75.5969 201.514 81.8758 142.711C86.5375 99.0536 99.4504 60.737 116.225 35.0969C92.2678 55.983 72.5384 90.4892 63.7178 131.844ZM199.834 149.561C200.908 217.473 179.59 272.878 152.222 273.309C124.853 273.742 101.797 219.039 100.724 151.127C99.6511 83.2138 120.968 27.8094 148.337 27.377C175.705 26.9446 198.762 81.648 199.834 149.561Z"
fill="currentColor" fill="currentColor"
fill-rule="evenodd"
/> />
</g> </g>
<defs> <defs>
<clipPath id="clip0_1_4"> <clipPath id="clip0_1_4">
<rect width="300" height="300" fill="white" /> <rect fill="white" height="300" width="300" />
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>
Customer portal Customer portal
</Button> </Button>
<Button <Button
size="sm"
onClick={() => onClick={() =>
pushModal('SelectBillingPlan', { pushModal('SelectBillingPlan', {
organization, organization,
currentProduct, currentProduct,
}) })
} }
size="sm"
> >
{organization.isWillBeCanceled {organization.isWillBeCanceled
? 'Reactivate subscription' ? 'Reactivate subscription'
@@ -221,15 +221,13 @@ export default function Billing({ organization }: Props) {
) : ( ) : (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead className="flex items-center justify-between"> <WidgetHead className="flex items-center justify-between">
<div className="font-bold text-lg flex-1"> <div className="flex-1 font-bold text-lg">
{organization.isTrial {organization.isTrial
? 'Get started' ? 'Get started'
: 'No active subscription'} : 'No active subscription'}
</div> </div>
<div className="text-lg"> <div className="text-muted-foreground">
<span className=""> {organization.isTrial ? '30 days free trial' : ''}
{organization.isTrial ? '30 days free trial' : ''}
</span>
</div> </div>
</WidgetHead> </WidgetHead>
<WidgetBody> <WidgetBody>
@@ -239,7 +237,7 @@ export default function Billing({ organization }: Props) {
{formatDate(organization.subscriptionEndsAt)} ( {formatDate(organization.subscriptionEndsAt)} (
{differenceInDays( {differenceInDays(
organization.subscriptionEndsAt, organization.subscriptionEndsAt,
new Date(), new Date()
) + 1}{' '} ) + 1}{' '}
days left) days left)
</p> </p>
@@ -250,29 +248,29 @@ export default function Billing({ organization }: Props) {
</p> </p>
)} )}
<div className="col mt-4"> <div className="col mt-4">
<div className="font-semibold mb-2"> <div className="mb-2 font-semibold">
{number.format(organization.subscriptionPeriodEventsCount)} /{' '} {number.format(organization.subscriptionPeriodEventsCount)} /{' '}
{number.format( {number.format(
Number(organization.subscriptionPeriodEventsLimit), Number(organization.subscriptionPeriodEventsLimit)
)} )}
</div> </div>
<Progress <Progress
size="sm"
value={ value={
(organization.subscriptionPeriodEventsCount / (organization.subscriptionPeriodEventsCount /
Number(organization.subscriptionPeriodEventsLimit)) * Number(organization.subscriptionPeriodEventsLimit)) *
100 100
} }
size="sm"
/> />
<div className="row justify-end mt-4"> <div className="row mt-4 justify-end">
<Button <Button
size="sm"
onClick={() => onClick={() =>
pushModal('SelectBillingPlan', { pushModal('SelectBillingPlan', {
organization, organization,
currentProduct, currentProduct,
}) })
} }
size="sm"
> >
Upgrade Upgrade
</Button> </Button>

View File

@@ -1,17 +1,17 @@
export function SkeletonDashboard() { export function SkeletonDashboard() {
return ( return (
<div className="fixed inset-0 bg-gradient-to-br from-def-100 to-def-200 overflow-hidden"> <div className="fixed inset-0 overflow-hidden bg-gradient-to-br from-def-100 to-def-200">
<div className="inset-0 fixed backdrop-blur-xs bg-background/10 z-10" /> <div className="fixed inset-0 z-10 bg-background/10 backdrop-blur-xs" />
{/* Sidebar Skeleton */} {/* Sidebar Skeleton */}
<div className="fixed left-0 top-0 w-72 h-full bg-background/80 border-r border-def-300/50 backdrop-blur-sm"> <div className="fixed top-0 left-0 h-full w-72 border-def-300/50 border-r bg-background/80 backdrop-blur-sm">
{/* Logo area */} {/* Logo area */}
<div className="h-16 border-b border-def-300/50 flex items-center px-4"> <div className="flex h-16 items-center border-def-300/50 border-b px-4">
<div className="w-8 h-8 bg-def-300/60 rounded-lg" /> <div className="h-8 w-8 rounded-lg bg-def-300/60" />
<div className="ml-3 w-24 h-4 bg-def-300/60 rounded" /> <div className="ml-3 h-4 w-24 rounded bg-def-300/60" />
</div> </div>
{/* Navigation items */} {/* Navigation items */}
<div className="p-4 space-y-3"> <div className="space-y-3 p-4">
{[ {[
'Dashboard', 'Dashboard',
'Analytics', 'Analytics',
@@ -21,28 +21,28 @@ export function SkeletonDashboard() {
'Projects', 'Projects',
].map((item, i) => ( ].map((item, i) => (
<div <div
className="flex items-center space-x-3 rounded-lg p-2"
key={`nav-${item.toLowerCase()}`} key={`nav-${item.toLowerCase()}`}
className="flex items-center space-x-3 p-2 rounded-lg"
> >
<div className="w-5 h-5 bg-def-300/60 rounded" /> <div className="h-5 w-5 rounded bg-def-300/60" />
<div className="w-20 h-3 bg-def-300/60 rounded" /> <div className="h-3 w-20 rounded bg-def-300/60" />
</div> </div>
))} ))}
</div> </div>
{/* Project section */} {/* Project section */}
<div className="px-4 py-2"> <div className="px-4 py-2">
<div className="w-16 h-3 bg-def-300/60 rounded mb-3" /> <div className="mb-3 h-3 w-16 rounded bg-def-300/60" />
{['Project Alpha', 'Project Beta', 'Project Gamma'].map( {['Project Alpha', 'Project Beta', 'Project Gamma'].map(
(project, i) => ( (project, i) => (
<div <div
className="mb-2 flex items-center space-x-3 rounded-lg p-2"
key={`project-${project.toLowerCase().replace(' ', '-')}`} key={`project-${project.toLowerCase().replace(' ', '-')}`}
className="flex items-center space-x-3 p-2 rounded-lg mb-2"
> >
<div className="w-4 h-4 bg-def-300/60 rounded" /> <div className="h-4 w-4 rounded bg-def-300/60" />
<div className="w-24 h-3 bg-def-300/60 rounded" /> <div className="h-3 w-24 rounded bg-def-300/60" />
</div> </div>
), )
)} )}
</div> </div>
</div> </div>
@@ -51,17 +51,17 @@ export function SkeletonDashboard() {
<div className="ml-72 p-8"> <div className="ml-72 p-8">
{/* Header area */} {/* Header area */}
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-center mb-4"> <div className="mb-4 flex items-center justify-between">
<div className="w-48 h-6 bg-def-300/60 rounded" /> <div className="h-6 w-48 rounded bg-def-300/60" />
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="w-20 h-8 bg-def-300/60 rounded" /> <div className="h-8 w-20 rounded bg-def-300/60" />
<div className="w-20 h-8 bg-def-300/60 rounded" /> <div className="h-8 w-20 rounded bg-def-300/60" />
</div> </div>
</div> </div>
</div> </div>
{/* Dashboard grid */} {/* Dashboard grid */}
<div className="grid grid-cols-3 gap-6 mb-8"> <div className="mb-8 grid grid-cols-3 gap-6">
{[ {[
'Total Users', 'Total Users',
'Active Sessions', 'Active Sessions',
@@ -71,36 +71,36 @@ export function SkeletonDashboard() {
'Revenue', 'Revenue',
].map((metric, i) => ( ].map((metric, i) => (
<div <div
className="rounded-xl border border-def-300/50 bg-card/60 p-6"
key={`metric-${metric.toLowerCase().replace(' ', '-')}`} key={`metric-${metric.toLowerCase().replace(' ', '-')}`}
className="bg-card/60 rounded-xl p-6 border border-def-300/50"
> >
<div className="w-16 h-4 bg-def-300/60 rounded mb-3" /> <div className="mb-3 h-4 w-16 rounded bg-def-300/60" />
<div className="w-24 h-8 bg-def-300/60 rounded mb-2" /> <div className="mb-2 h-8 w-24 rounded bg-def-300/60" />
<div className="w-32 h-3 bg-def-300/60 rounded" /> <div className="h-3 w-32 rounded bg-def-300/60" />
</div> </div>
))} ))}
</div> </div>
{/* Chart area */} {/* Chart area */}
<div className="grid grid-cols-2 gap-6"> <div className="grid grid-cols-2 gap-6">
<div className="bg-card/60 rounded-xl p-6 border border-def-300/50 h-64"> <div className="h-64 rounded-xl border border-def-300/50 bg-card/60 p-6">
<div className="w-20 h-4 bg-def-300/60 rounded mb-4" /> <div className="mb-4 h-4 w-20 rounded bg-def-300/60" />
<div className="space-y-3"> <div className="space-y-3">
{['Desktop', 'Mobile', 'Tablet', 'Other'].map((device, i) => ( {['Desktop', 'Mobile', 'Tablet', 'Other'].map((device, i) => (
<div <div
key={`chart-${device.toLowerCase()}`}
className="flex items-center space-x-3" className="flex items-center space-x-3"
key={`chart-${device.toLowerCase()}`}
> >
<div className="w-3 h-3 bg-def-300/60 rounded-full" /> <div className="h-3 w-3 rounded-full bg-def-300/60" />
<div className="flex-1 h-2 bg-def-300/60 rounded" /> <div className="h-2 flex-1 rounded bg-def-300/60" />
<div className="w-8 h-3 bg-def-300/60 rounded" /> <div className="h-3 w-8 rounded bg-def-300/60" />
</div> </div>
))} ))}
</div> </div>
</div> </div>
<div className="bg-card/60 rounded-xl p-6 border border-def-300/50 h-64"> <div className="h-64 rounded-xl border border-def-300/50 bg-card/60 p-6">
<div className="w-20 h-4 bg-def-300/60 rounded mb-4" /> <div className="mb-4 h-4 w-20 rounded bg-def-300/60" />
<div className="space-y-3"> <div className="space-y-3">
{[ {[
'John Doe', 'John Doe',
@@ -110,15 +110,15 @@ export function SkeletonDashboard() {
'Charlie Wilson', 'Charlie Wilson',
].map((user, i) => ( ].map((user, i) => (
<div <div
key={`user-${user.toLowerCase().replace(' ', '-')}`}
className="flex items-center space-x-3" className="flex items-center space-x-3"
key={`user-${user.toLowerCase().replace(' ', '-')}`}
> >
<div className="w-8 h-8 bg-def-300/60 rounded-full" /> <div className="h-8 w-8 rounded-full bg-def-300/60" />
<div className="flex-1"> <div className="flex-1">
<div className="w-24 h-3 bg-def-300/60 rounded mb-1" /> <div className="mb-1 h-3 w-24 rounded bg-def-300/60" />
<div className="w-16 h-2 bg-def-300/60 rounded" /> <div className="h-2 w-16 rounded bg-def-300/60" />
</div> </div>
<div className="w-12 h-3 bg-def-300/60 rounded" /> <div className="h-3 w-12 rounded bg-def-300/60" />
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,21 +1,24 @@
import { clipboard } from '@/utils/clipboard';
import { cn } from '@/utils/cn';
import { CopyIcon } from 'lucide-react'; import { CopyIcon } from 'lucide-react';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash'; import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash';
import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json'; import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
import markdown from 'react-syntax-highlighter/dist/cjs/languages/hljs/markdown';
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript'; import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/vs2015'; import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/vs2015';
import { clipboard } from '@/utils/clipboard';
import { cn } from '@/utils/cn';
SyntaxHighlighter.registerLanguage('typescript', ts); SyntaxHighlighter.registerLanguage('typescript', ts);
SyntaxHighlighter.registerLanguage('json', json); SyntaxHighlighter.registerLanguage('json', json);
SyntaxHighlighter.registerLanguage('bash', bash); SyntaxHighlighter.registerLanguage('bash', bash);
SyntaxHighlighter.registerLanguage('markdown', markdown);
interface SyntaxProps { interface SyntaxProps {
code: string; code: string;
className?: string; className?: string;
language?: 'typescript' | 'bash' | 'json'; language?: 'typescript' | 'bash' | 'json' | 'markdown';
wrapLines?: boolean; wrapLines?: boolean;
copyable?: boolean;
} }
export default function Syntax({ export default function Syntax({
@@ -23,23 +26,23 @@ export default function Syntax({
className, className,
language = 'typescript', language = 'typescript',
wrapLines = false, wrapLines = false,
copyable = true,
}: SyntaxProps) { }: SyntaxProps) {
return ( return (
<div className={cn('group relative rounded-lg', className)}> <div className={cn('group relative rounded-lg', className)}>
<button {copyable && (
type="button" <button
className="absolute right-1 top-1 rounded bg-card p-2 opacity-0 transition-opacity group-hover:opacity-100 row items-center gap-2" className="row absolute top-1 right-1 items-center gap-2 rounded bg-card p-2 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => { onClick={() => {
clipboard(code); clipboard(code, null);
}} }}
> type="button"
<span>Copy</span> >
<CopyIcon size={12} /> <span>Copy</span>
</button> <CopyIcon size={12} />
</button>
)}
<SyntaxHighlighter <SyntaxHighlighter
wrapLongLines={wrapLines}
style={docco}
language={language}
customStyle={{ customStyle={{
borderRadius: 'var(--radius)', borderRadius: 'var(--radius)',
padding: '1rem', padding: '1rem',
@@ -48,6 +51,9 @@ export default function Syntax({
fontSize: 14, fontSize: 14,
lineHeight: 1.3, lineHeight: 1.3,
}} }}
language={language}
style={docco}
wrapLongLines={wrapLines}
> >
{code} {code}
</SyntaxHighlighter> </SyntaxHighlighter>

View File

@@ -1,4 +1,3 @@
import { cn } from '@/utils/cn';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { Link, type LinkComponentProps } from '@tanstack/react-router'; import { Link, type LinkComponentProps } from '@tanstack/react-router';
import type { VariantProps } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority';
@@ -6,9 +5,10 @@ import { cva } from 'class-variance-authority';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import * as React from 'react'; import * as React from 'react';
import { Spinner, type SpinnerProps } from './spinner'; import { Spinner, type SpinnerProps } from './spinner';
import { cn } from '@/utils/cn';
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:translate-y-[-0.5px] transition-all', 'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-all hover:translate-y-[-0.5px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{ {
variants: { variants: {
variant: { variant: {
@@ -33,7 +33,7 @@ const buttonVariants = cva(
variant: 'default', variant: 'default',
size: 'sm', size: 'sm',
}, },
}, }
); );
export interface ButtonProps export interface ButtonProps
@@ -52,7 +52,10 @@ export interface ButtonProps
function fixHeight({ function fixHeight({
autoHeight, autoHeight,
size, size,
}: { autoHeight?: boolean; size: ButtonProps['size'] }) { }: {
autoHeight?: boolean;
size: ButtonProps['size'];
}) {
if (autoHeight) { if (autoHeight) {
switch (size) { switch (size) {
case 'lg': case 'lg':
@@ -84,9 +87,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
responsive, responsive,
autoHeight, autoHeight,
loadingAbsolute, loadingAbsolute,
type = 'button',
...props ...props
}, },
ref, ref
) => { ) => {
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : 'button';
const Icon = loading ? null : (icon ?? null); const Icon = loading ? null : (icon ?? null);
@@ -99,31 +103,32 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn( className={cn(
buttonVariants({ variant, size, className }), buttonVariants({ variant, size, className }),
fixHeight({ autoHeight, size }), fixHeight({ autoHeight, size }),
loadingAbsolute && 'relative', loadingAbsolute && 'relative'
)} )}
ref={ref}
disabled={loading || disabled} disabled={loading || disabled}
ref={ref}
type={type}
{...props} {...props}
> >
{loading && ( {loading && (
<div <div
className={cn( className={cn(
loadingAbsolute && loadingAbsolute &&
'absolute top-0 left-0 right-0 bottom-0 center-center backdrop-blur bg-background/10', 'center-center absolute top-0 right-0 bottom-0 left-0 bg-background/10 backdrop-blur'
)} )}
> >
<Spinner <Spinner
type={loadingType}
size={spinnerSize}
speed={loadingSpeed}
variant={
variant === 'default' || variant === 'cta' ? 'white' : 'default'
}
className={cn( className={cn(
'flex-shrink-0', 'flex-shrink-0',
size !== 'icon' && responsive && 'mr-0 sm:mr-2', size !== 'icon' && responsive && 'mr-0 sm:mr-2',
size !== 'icon' && !responsive && 'mr-2', size !== 'icon' && !responsive && 'mr-2'
)} )}
size={spinnerSize}
speed={loadingSpeed}
type={loadingType}
variant={
variant === 'default' || variant === 'cta' ? 'white' : 'default'
}
/> />
</div> </div>
)} )}
@@ -132,7 +137,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn( className={cn(
'h-4 w-4 flex-shrink-0', 'h-4 w-4 flex-shrink-0',
size !== 'icon' && responsive && 'mr-0 sm:mr-2', size !== 'icon' && responsive && 'mr-0 sm:mr-2',
size !== 'icon' && !responsive && 'mr-2', size !== 'icon' && !responsive && 'mr-2'
)} )}
/> />
)} )}
@@ -143,7 +148,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)} )}
</Comp> </Comp>
); );
}, }
); );
Button.displayName = 'Button'; Button.displayName = 'Button';
@@ -180,24 +185,24 @@ const LinkButton = ({
<> <>
{loading && ( {loading && (
<Spinner <Spinner
type={loadingType}
size={spinnerSize}
speed={loadingSpeed}
variant={
variant === 'default' || variant === 'cta' ? 'white' : 'default'
}
className={cn( className={cn(
'flex-shrink-0', 'flex-shrink-0',
responsive && 'mr-0 sm:mr-2', responsive && 'mr-0 sm:mr-2',
!responsive && 'mr-2', !responsive && 'mr-2'
)} )}
size={spinnerSize}
speed={loadingSpeed}
type={loadingType}
variant={
variant === 'default' || variant === 'cta' ? 'white' : 'default'
}
/> />
)} )}
{Icon && ( {Icon && (
<Icon <Icon
className={cn( className={cn(
'mr-2 h-4 w-4 flex-shrink-0', 'mr-2 h-4 w-4 flex-shrink-0',
responsive && 'mr-0 sm:mr-2', responsive && 'mr-0 sm:mr-2'
)} )}
/> />
)} )}

View File

@@ -1,5 +1,5 @@
import { useRouteContext } from '@tanstack/react-router'; import { useRouteContext } from '@tanstack/react-router';
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'; import { createServerFn } from '@tanstack/react-start';
import { getCookies, setCookie } from '@tanstack/react-start/server'; import { getCookies, setCookie } from '@tanstack/react-start/server';
import { pick } from 'ramda'; import { pick } from 'ramda';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
@@ -11,6 +11,7 @@ const VALID_COOKIES = [
'range', 'range',
'supporter-prompt-closed', 'supporter-prompt-closed',
'feedback-prompt-seen', 'feedback-prompt-seen',
'last-auth-provider',
] as const; ] as const;
const COOKIE_EVENT_NAME = '__cookie-change'; const COOKIE_EVENT_NAME = '__cookie-change';
@@ -20,7 +21,7 @@ const setCookieFn = createServerFn({ method: 'POST' })
key: z.enum(VALID_COOKIES), key: z.enum(VALID_COOKIES),
value: z.string(), value: z.string(),
maxAge: z.number().optional(), maxAge: z.number().optional(),
}), })
) )
.handler(({ data: { key, value, maxAge } }) => { .handler(({ data: { key, value, maxAge } }) => {
if (!VALID_COOKIES.includes(key)) { if (!VALID_COOKIES.includes(key)) {
@@ -37,13 +38,13 @@ const setCookieFn = createServerFn({ method: 'POST' })
// Called in __root.tsx beforeLoad hook to get cookies from the server // Called in __root.tsx beforeLoad hook to get cookies from the server
// And received with useRouteContext in the client // And received with useRouteContext in the client
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() => export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
pick(VALID_COOKIES, getCookies()), pick(VALID_COOKIES, getCookies())
); );
export function useCookieStore<T>( export function useCookieStore<T>(
key: (typeof VALID_COOKIES)[number], key: (typeof VALID_COOKIES)[number],
defaultValue: T, defaultValue: T,
options?: { maxAge?: number }, options?: { maxAge?: number }
) { ) {
const { cookies } = useRouteContext({ strict: false }); const { cookies } = useRouteContext({ strict: false });
const [value, setValue] = useState<T>((cookies?.[key] ?? defaultValue) as T); const [value, setValue] = useState<T>((cookies?.[key] ?? defaultValue) as T);
@@ -51,7 +52,7 @@ export function useCookieStore<T>(
useEffect(() => { useEffect(() => {
const handleCookieChange = ( const handleCookieChange = (
event: CustomEvent<{ key: string; value: T; from: string }>, event: CustomEvent<{ key: string; value: T; from: string }>
) => { ) => {
if (event.detail.key === key && event.detail.from !== ref.current) { if (event.detail.key === key && event.detail.from !== ref.current) {
setValue(event.detail.value); setValue(event.detail.value);
@@ -60,12 +61,12 @@ export function useCookieStore<T>(
window.addEventListener( window.addEventListener(
COOKIE_EVENT_NAME, COOKIE_EVENT_NAME,
handleCookieChange as EventListener, handleCookieChange as EventListener
); );
return () => { return () => {
window.removeEventListener( window.removeEventListener(
COOKIE_EVENT_NAME, COOKIE_EVENT_NAME,
handleCookieChange as EventListener, handleCookieChange as EventListener
); );
}; };
}, [key]); }, [key]);
@@ -82,10 +83,10 @@ export function useCookieStore<T>(
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(COOKIE_EVENT_NAME, { new CustomEvent(COOKIE_EVENT_NAME, {
detail: { key, value: newValue, from: ref.current }, detail: { key, value: newValue, from: ref.current },
}), })
); );
}, },
] as const, ] as const,
[value, key, options?.maxAge], [value, key, options?.maxAge]
); );
} }

View File

@@ -47,7 +47,7 @@ export default function AddDashboard() {
toast('Success', { toast('Success', {
description: 'Dashboard created.', description: 'Dashboard created.',
}); });
queryClient.invalidateQueries(trpc.dashboard.pathFilter()); queryClient.invalidateQueries(trpc.dashboard.list.pathFilter());
popModal(); popModal();
}, },
onError: handleError, onError: handleError,

View File

@@ -1,28 +1,7 @@
import type { RouterOutputs } from '@/trpc/client';
import { SheetContent } from '@/components/ui/sheet';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { popModal } from '.';
import { ModalHeader } from './Modal/Container';
import { ColorSquare } from '@/components/color-square';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Textarea } from '@/components/ui/textarea';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventNames } from '@/hooks/use-event-names';
import { useEventProperties } from '@/hooks/use-event-properties';
import { useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { shortId } from '@openpanel/common'; import { shortId } from '@openpanel/common';
import { zCreateNotificationRule } from '@openpanel/validation'; import { zCreateNotificationRule } from '@openpanel/validation';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FilterIcon, PlusIcon, SaveIcon, TrashIcon } from 'lucide-react'; import { FilterIcon, PlusIcon, SaveIcon, TrashIcon } from 'lucide-react';
import { import {
Controller, Controller,
@@ -32,7 +11,24 @@ import {
useForm, useForm,
useWatch, useWatch,
} from 'react-hook-form'; } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod'; import type { z } from 'zod';
import { popModal } from '.';
import { ModalHeader } from './Modal/Container';
import { ColorSquare } from '@/components/color-square';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { SheetContent } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventNames } from '@/hooks/use-event-names';
import { useEventProperties } from '@/hooks/use-event-properties';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
interface Props { interface Props {
rule?: RouterOutputs['notification']['rules'][number]; rule?: RouterOutputs['notification']['rules'][number];
@@ -71,21 +67,21 @@ export default function AddNotificationRule({ rule }: Props) {
trpc.notification.createOrUpdateRule.mutationOptions({ trpc.notification.createOrUpdateRule.mutationOptions({
onSuccess() { onSuccess() {
toast.success( toast.success(
rule ? 'Notification rule updated' : 'Notification rule created', rule ? 'Notification rule updated' : 'Notification rule created'
); );
client.refetchQueries( client.refetchQueries(
trpc.notification.rules.queryFilter({ trpc.notification.rules.queryFilter({
projectId, projectId,
}), })
); );
popModal(); popModal();
}, },
}), })
); );
const integrationsQuery = useQuery( const integrationsQuery = useQuery(
trpc.integration.list.queryOptions({ trpc.integration.list.queryOptions({
organizationId: organizationId!, organizationId: organizationId!,
}), })
); );
const eventsArray = useFieldArray({ const eventsArray = useFieldArray({
@@ -106,18 +102,18 @@ export default function AddNotificationRule({ rule }: Props) {
return ( return (
<SheetContent className="[&>button.absolute]:hidden"> <SheetContent className="[&>button.absolute]:hidden">
<ModalHeader title={rule ? 'Edit rule' : 'Create rule'} /> <ModalHeader title={rule ? 'Edit rule' : 'Create rule'} />
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4"> <form className="col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<InputWithLabel <InputWithLabel
error={form.formState.errors.name?.message}
label="Rule name" label="Rule name"
placeholder="Eg. Sign ups on android" placeholder="Eg. Sign ups on android"
error={form.formState.errors.name?.message}
{...form.register('name')} {...form.register('name')}
/> />
<WithLabel <WithLabel
label="Type"
// @ts-expect-error // @ts-expect-error
error={form.formState.errors.config?.type.message} error={form.formState.errors.config?.type.message}
label="Type"
> >
<Controller <Controller
control={form.control} control={form.control}
@@ -126,7 +122,6 @@ export default function AddNotificationRule({ rule }: Props) {
<Combobox <Combobox
{...field} {...field}
className="w-full" className="w-full"
placeholder="Select type"
// @ts-expect-error // @ts-expect-error
error={form.formState.errors.config?.type.message} error={form.formState.errors.config?.type.message}
items={[ items={[
@@ -139,6 +134,7 @@ export default function AddNotificationRule({ rule }: Props) {
value: 'funnel', value: 'funnel',
}, },
]} ]}
placeholder="Select type"
/> />
)} )}
/> />
@@ -148,16 +144,15 @@ export default function AddNotificationRule({ rule }: Props) {
{eventsArray.fields.map((field, index) => { {eventsArray.fields.map((field, index) => {
return ( return (
<EventField <EventField
key={field.id}
form={form} form={form}
index={index} index={index}
key={field.id}
remove={() => eventsArray.remove(index)} remove={() => eventsArray.remove(index)}
/> />
); );
})} })}
<Button <Button
className="self-start" className="self-start"
variant={'outline'}
icon={PlusIcon} icon={PlusIcon}
onClick={() => onClick={() =>
eventsArray.append({ eventsArray.append({
@@ -166,6 +161,7 @@ export default function AddNotificationRule({ rule }: Props) {
segment: 'event', segment: 'event',
}) })
} }
variant={'outline'}
> >
Add event Add event
</Button> </Button>
@@ -173,7 +169,6 @@ export default function AddNotificationRule({ rule }: Props) {
</WithLabel> </WithLabel>
<WithLabel <WithLabel
label="Template"
info={ info={
<div className="prose dark:prose-invert"> <div className="prose dark:prose-invert">
<p> <p>
@@ -197,7 +192,7 @@ export default function AddNotificationRule({ rule }: Props) {
profile property profile property
</li> </li>
<li> <li>
<div className="flex gap-x-2 flex-wrap"> <div className="flex flex-wrap gap-x-2">
And many more... And many more...
<code>profileId</code> <code>profileId</code>
<code>createdAt</code> <code>createdAt</code>
@@ -220,6 +215,7 @@ export default function AddNotificationRule({ rule }: Props) {
</ul> </ul>
</div> </div>
} }
label="Template"
> >
<Textarea <Textarea
{...form.register('template')} {...form.register('template')}
@@ -234,19 +230,19 @@ export default function AddNotificationRule({ rule }: Props) {
<WithLabel label="Integrations"> <WithLabel label="Integrations">
<ComboboxAdvanced <ComboboxAdvanced
{...field} {...field}
value={field.value ?? []}
className="w-full" className="w-full"
placeholder="Pick integrations"
items={integrations.map((integration) => ({ items={integrations.map((integration) => ({
label: integration.name, label: integration.name,
value: integration.id, value: integration.id,
}))} }))}
placeholder="Pick integrations"
value={field.value ?? []}
/> />
</WithLabel> </WithLabel>
)} )}
/> />
<Button type="submit" icon={SaveIcon}> <Button icon={SaveIcon} type="submit">
{rule ? 'Update' : 'Create'} {rule ? 'Update' : 'Create'}
</Button> </Button>
</form> </form>
@@ -276,27 +272,24 @@ function EventField({
const properties = useEventProperties({ projectId }); const properties = useEventProperties({ projectId });
return ( return (
<div className="border bg-def-100 rounded"> <div className="rounded border bg-def-100">
<div className="row gap-2 items-center p-2"> <div className="row items-center gap-2 p-2">
<ColorSquare>{index + 1}</ColorSquare> <ColorSquare>{index + 1}</ColorSquare>
<Controller <Controller
control={form.control} control={form.control}
name={`config.events.${index}.name`} name={`config.events.${index}.name`}
render={({ field }) => ( render={({ field }) => (
<ComboboxEvents <ComboboxEvents
searchable
className="flex-1" className="flex-1"
value={field.value}
placeholder="Select event"
onChange={field.onChange}
items={eventNames} items={eventNames}
onChange={field.onChange}
placeholder="Select event"
searchable
value={field.value}
/> />
)} )}
/> />
<Combobox <Combobox
searchable
placeholder="Select a filter"
value=""
items={properties.map((item) => ({ items={properties.map((item) => ({
label: item, label: item,
value: item, value: item,
@@ -309,27 +302,33 @@ function EventField({
value: [], value: [],
}); });
}} }}
placeholder="Select a filter"
searchable
value=""
> >
<Button variant={'outline'} icon={FilterIcon} size={'icon'} /> <Button icon={FilterIcon} size={'icon'} variant={'outline'} />
</Combobox> </Combobox>
<Button <Button
className="text-destructive"
icon={TrashIcon}
onClick={() => { onClick={() => {
remove(); remove();
}} }}
variant={'outline'}
className="text-destructive"
icon={TrashIcon}
size={'icon'} size={'icon'}
variant={'outline'}
/> />
</div> </div>
{filtersArray.fields.map((filter, index) => { {filtersArray.fields.map((filter, index) => {
return ( return (
<div key={filter.id} className="p-2 border-t"> <div className="border-t p-2" key={filter.id}>
<PureFilterItem <PureFilterItem
eventName={eventName} eventName={eventName}
filter={filter} filter={filter}
onRemove={() => { onChangeOperator={(operator) => {
filtersArray.remove(index); filtersArray.update(index, {
...filter,
operator,
});
}} }}
onChangeValue={(value) => { onChangeValue={(value) => {
filtersArray.update(index, { filtersArray.update(index, {
@@ -337,11 +336,8 @@ function EventField({
value, value,
}); });
}} }}
onChangeOperator={(operator) => { onRemove={() => {
filtersArray.update(index, { filtersArray.remove(index);
...filter,
operator,
});
}} }}
/> />
</div> </div>

View File

@@ -1,9 +1,4 @@
import { createPushModal } from 'pushmodal'; import { createPushModal } from 'pushmodal';
import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal';
import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal';
import { op } from '@/utils/op';
import Instructions from './Instructions';
import AddClient from './add-client'; import AddClient from './add-client';
import AddDashboard from './add-dashboard'; import AddDashboard from './add-dashboard';
import AddImport from './add-import'; import AddImport from './add-import';
@@ -12,8 +7,8 @@ import AddNotificationRule from './add-notification-rule';
import AddProject from './add-project'; import AddProject from './add-project';
import AddReference from './add-reference'; import AddReference from './add-reference';
import BillingSuccess from './billing-success'; import BillingSuccess from './billing-success';
import Confirm from './confirm';
import type { ConfirmProps } from './confirm'; import type { ConfirmProps } from './confirm';
import Confirm from './confirm';
import CreateInvite from './create-invite'; import CreateInvite from './create-invite';
import DateRangerPicker from './date-ranger-picker'; import DateRangerPicker from './date-ranger-picker';
import DateTimePicker from './date-time-picker'; import DateTimePicker from './date-time-picker';
@@ -24,7 +19,7 @@ import EditMember from './edit-member';
import EditReference from './edit-reference'; import EditReference from './edit-reference';
import EditReport from './edit-report'; import EditReport from './edit-report';
import EventDetails from './event-details'; import EventDetails from './event-details';
import OnboardingTroubleshoot from './onboarding-troubleshoot'; import Instructions from './Instructions';
import OverviewChartDetails from './overview-chart-details'; import OverviewChartDetails from './overview-chart-details';
import OverviewFilters from './overview-filters'; import OverviewFilters from './overview-filters';
import RequestPasswordReset from './request-reset-password'; import RequestPasswordReset from './request-reset-password';
@@ -34,40 +29,42 @@ import ShareDashboardModal from './share-dashboard-modal';
import ShareOverviewModal from './share-overview-modal'; import ShareOverviewModal from './share-overview-modal';
import ShareReportModal from './share-report-modal'; import ShareReportModal from './share-report-modal';
import ViewChartUsers from './view-chart-users'; import ViewChartUsers from './view-chart-users';
import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal';
import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal';
import { op } from '@/utils/op';
const modals = { const modals = {
OverviewTopPagesModal: OverviewTopPagesModal, OverviewTopPagesModal,
OverviewTopGenericModal: OverviewTopGenericModal, OverviewTopGenericModal,
RequestPasswordReset: RequestPasswordReset, RequestPasswordReset,
EditEvent: EditEvent, EditEvent,
EditMember: EditMember, EditMember,
EventDetails: EventDetails, EventDetails,
EditClient: EditClient, EditClient,
AddProject: AddProject, AddProject,
AddClient: AddClient, AddClient,
AddImport: AddImport, AddImport,
Confirm: Confirm, Confirm,
SaveReport: SaveReport, SaveReport,
AddDashboard: AddDashboard, AddDashboard,
EditDashboard: EditDashboard, EditDashboard,
EditReport: EditReport, EditReport,
EditReference: EditReference, EditReference,
ShareOverviewModal: ShareOverviewModal, ShareOverviewModal,
ShareDashboardModal: ShareDashboardModal, ShareDashboardModal,
ShareReportModal: ShareReportModal, ShareReportModal,
AddReference: AddReference, AddReference,
ViewChartUsers: ViewChartUsers, ViewChartUsers,
Instructions: Instructions, Instructions,
OnboardingTroubleshoot: OnboardingTroubleshoot, DateRangerPicker,
DateRangerPicker: DateRangerPicker, DateTimePicker,
DateTimePicker: DateTimePicker, OverviewChartDetails,
OverviewChartDetails: OverviewChartDetails, AddIntegration,
AddIntegration: AddIntegration, AddNotificationRule,
AddNotificationRule: AddNotificationRule, OverviewFilters,
OverviewFilters: OverviewFilters, CreateInvite,
CreateInvite: CreateInvite, SelectBillingPlan,
SelectBillingPlan: SelectBillingPlan, BillingSuccess,
BillingSuccess: BillingSuccess,
}; };
export const { export const {
@@ -83,7 +80,9 @@ export const {
}); });
onPushModal('*', (open, props, name) => { onPushModal('*', (open, props, name) => {
op.screenView(`modal:${name}`, props as Record<string, unknown>); if (open) {
op.screenView(`modal:${name}`, props as Record<string, unknown>);
}
}); });
export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props); export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props);

View File

@@ -1,51 +0,0 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { GlobeIcon, KeyIcon, UserIcon } from 'lucide-react';
import { ModalContent, ModalHeader } from './Modal/Container';
export default function OnboardingTroubleshoot() {
return (
<ModalContent>
<ModalHeader
title="Troubleshoot"
text="Hmm, you have troubles? Well, let's solve them together."
/>
<div className="flex flex-col gap-4">
<Alert>
<UserIcon size={16} />
<AlertTitle>Wrong client ID</AlertTitle>
<AlertDescription>
Make sure your <code>clientId</code> is correct
</AlertDescription>
</Alert>
<Alert>
<GlobeIcon size={16} />
<AlertTitle>Wrong domain on web</AlertTitle>
<AlertDescription>
For web apps its important that the domain is correctly configured.
We authenticate the requests based on the domain.
</AlertDescription>
</Alert>
<Alert>
<KeyIcon size={16} />
<AlertTitle>Wrong client secret</AlertTitle>
<AlertDescription>
For app and backend events it&apos;s important that you have correct{' '}
<code>clientId</code> and <code>clientSecret</code>
</AlertDescription>
</Alert>
</div>
<p className="mt-4 ">
Still have issues? Join our{' '}
<a href="https://go.openpanel.dev/discord" className="underline">
discord channel
</a>{' '}
give us an email at{' '}
<a href="mailto:hello@openpanel.dev" className="underline">
hello@openpanel.dev
</a>{' '}
and we&apos;ll help you out.
</p>
</ModalContent>
);
}

View File

@@ -56,6 +56,7 @@ export default function SaveReport({
projectId, projectId,
}), }),
); );
queryClient.invalidateQueries(trpc.dashboard.list.pathFilter());
const goToReport = () => { const goToReport = () => {
router.navigate({ router.navigate({
@@ -157,6 +158,7 @@ function SelectDashboard({
projectId: string; projectId: string;
}) { }) {
const trpc = useTRPC(); const trpc = useTRPC();
const queryClient = useQueryClient();
const [isCreatingNew, setIsCreatingNew] = useState(false); const [isCreatingNew, setIsCreatingNew] = useState(false);
const [newDashboardName, setNewDashboardName] = useState(''); const [newDashboardName, setNewDashboardName] = useState('');
@@ -177,6 +179,7 @@ function SelectDashboard({
trpc.dashboard.create.mutationOptions({ trpc.dashboard.create.mutationOptions({
onError: handleError, onError: handleError,
async onSuccess(res) { async onSuccess(res) {
queryClient.invalidateQueries(trpc.dashboard.list.pathFilter());
await dashboardQuery.refetch(); await dashboardQuery.refetch();
onChange(res.id); onChange(res.id);
setIsCreatingNew(false); setIsCreatingNew(false);

View File

@@ -69,6 +69,7 @@ import { Route as AppOrganizationIdProjectIdProfilesTabsIndexRouteImport } from
import { Route as AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.index' import { Route as AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.index'
import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.index' import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.index'
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets' import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports' import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events' import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details' import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
@@ -475,6 +476,12 @@ const AppOrganizationIdProjectIdSettingsTabsWidgetsRoute =
path: '/widgets', path: '/widgets',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute, getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any) } as any)
const AppOrganizationIdProjectIdSettingsTabsTrackingRoute =
AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport.update({
id: '/tracking',
path: '/tracking',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsImportsRoute = const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
AppOrganizationIdProjectIdSettingsTabsImportsRouteImport.update({ AppOrganizationIdProjectIdSettingsTabsImportsRouteImport.update({
id: '/imports', id: '/imports',
@@ -634,6 +641,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute '/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute '/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute '/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/events/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute '/$organizationId/$projectId/events/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute '/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
@@ -701,6 +709,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute '/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute '/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute '/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute '/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
@@ -781,6 +790,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute '/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute '/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute '/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/_app/$organizationId/$projectId/events/_tabs/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute '/_app/$organizationId/$projectId/events/_tabs/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute '/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
@@ -855,6 +865,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/details' | '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events' | '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/imports' | '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets' | '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/events/' | '/$organizationId/$projectId/events/'
| '/$organizationId/$projectId/notifications/' | '/$organizationId/$projectId/notifications/'
@@ -922,6 +933,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/details' | '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events' | '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/imports' | '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets' | '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/profiles/$profileId/events' | '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions' | '/$organizationId/$projectId/profiles/$profileId/sessions'
@@ -1001,6 +1013,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/settings/_tabs/details' | '/_app/$organizationId/$projectId/settings/_tabs/details'
| '/_app/$organizationId/$projectId/settings/_tabs/events' | '/_app/$organizationId/$projectId/settings/_tabs/events'
| '/_app/$organizationId/$projectId/settings/_tabs/imports' | '/_app/$organizationId/$projectId/settings/_tabs/imports'
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
| '/_app/$organizationId/$projectId/settings/_tabs/widgets' | '/_app/$organizationId/$projectId/settings/_tabs/widgets'
| '/_app/$organizationId/$projectId/events/_tabs/' | '/_app/$organizationId/$projectId/events/_tabs/'
| '/_app/$organizationId/$projectId/notifications/_tabs/' | '/_app/$organizationId/$projectId/notifications/_tabs/'
@@ -1493,6 +1506,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
} }
'/_app/$organizationId/$projectId/settings/_tabs/tracking': {
id: '/_app/$organizationId/$projectId/settings/_tabs/tracking'
path: '/tracking'
fullPath: '/$organizationId/$projectId/settings/tracking'
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/imports': { '/_app/$organizationId/$projectId/settings/_tabs/imports': {
id: '/_app/$organizationId/$projectId/settings/_tabs/imports' id: '/_app/$organizationId/$projectId/settings/_tabs/imports'
path: '/imports' path: '/imports'
@@ -1766,6 +1786,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
AppOrganizationIdProjectIdSettingsTabsIndexRoute: typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute AppOrganizationIdProjectIdSettingsTabsIndexRoute: typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
} }
@@ -1780,6 +1801,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
AppOrganizationIdProjectIdSettingsTabsEventsRoute, AppOrganizationIdProjectIdSettingsTabsEventsRoute,
AppOrganizationIdProjectIdSettingsTabsImportsRoute: AppOrganizationIdProjectIdSettingsTabsImportsRoute:
AppOrganizationIdProjectIdSettingsTabsImportsRoute, AppOrganizationIdProjectIdSettingsTabsImportsRoute,
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
AppOrganizationIdProjectIdSettingsTabsTrackingRoute,
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: AppOrganizationIdProjectIdSettingsTabsWidgetsRoute:
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute, AppOrganizationIdProjectIdSettingsTabsWidgetsRoute,
AppOrganizationIdProjectIdSettingsTabsIndexRoute: AppOrganizationIdProjectIdSettingsTabsIndexRoute:

View File

@@ -29,7 +29,7 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container'; import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react'; import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, createFileRoute } from '@tanstack/react-router'; import { Link, createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -58,6 +58,7 @@ export const Route = createFileRoute(
function Component() { function Component() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const trpc = useTRPC(); const trpc = useTRPC();
const queryClient = useQueryClient();
const query = useQuery( const query = useQuery(
trpc.dashboard.list.queryOptions({ trpc.dashboard.list.queryOptions({
projectId, projectId,
@@ -80,6 +81,7 @@ function Component() {
})(error); })(error);
}, },
onSuccess() { onSuccess() {
queryClient.invalidateQueries(trpc.dashboard.list.pathFilter());
query.refetch(); query.refetch();
toast('Success', { toast('Success', {
description: 'Dashboard deleted.', description: 'Dashboard deleted.',

View File

@@ -35,7 +35,7 @@ import {
} from '@/components/report/report-item'; } from '@/components/report/report-item';
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react'; import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals'; import { pushModal, showConfirm } from '@/modals';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, useRouter } from '@tanstack/react-router';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@@ -85,6 +85,7 @@ function Component() {
const router = useRouter(); const router = useRouter();
const { organizationId, dashboardId, projectId } = Route.useParams(); const { organizationId, dashboardId, projectId } = Route.useParams();
const trpc = useTRPC(); const trpc = useTRPC();
const queryClient = useQueryClient();
const { range, startDate, endDate, interval } = useOverviewOptions(); const { range, startDate, endDate, interval } = useOverviewOptions();
const dashboardQuery = useQuery( const dashboardQuery = useQuery(
@@ -105,6 +106,7 @@ function Component() {
trpc.dashboard.delete.mutationOptions({ trpc.dashboard.delete.mutationOptions({
onError: handleErrorToastOptions({}), onError: handleErrorToastOptions({}),
onSuccess() { onSuccess() {
queryClient.invalidateQueries(trpc.dashboard.list.pathFilter());
toast('Dashboard deleted'); toast('Dashboard deleted');
router.navigate({ router.navigate({
to: '/$organizationId/$projectId/dashboards', to: '/$organizationId/$projectId/dashboards',
@@ -139,6 +141,7 @@ function Component() {
trpc.report.delete.mutationOptions({ trpc.report.delete.mutationOptions({
onError: handleErrorToastOptions({}), onError: handleErrorToastOptions({}),
onSuccess() { onSuccess() {
queryClient.invalidateQueries(trpc.dashboard.list.pathFilter());
reportsQuery.refetch(); reportsQuery.refetch();
toast('Report deleted'); toast('Report deleted');
}, },
@@ -149,6 +152,7 @@ function Component() {
trpc.report.duplicate.mutationOptions({ trpc.report.duplicate.mutationOptions({
onError: handleErrorToastOptions({}), onError: handleErrorToastOptions({}),
onSuccess() { onSuccess() {
queryClient.invalidateQueries(trpc.dashboard.list.pathFilter());
reportsQuery.refetch(); reportsQuery.refetch();
toast('Report duplicated'); toast('Report duplicated');
}, },

View File

@@ -0,0 +1,116 @@
import type { IServiceClient } from '@openpanel/db';
import { frameworks } from '@openpanel/sdk-info';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { CopyIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import Syntax from '@/components/syntax';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { useAppContext } from '@/hooks/use-app-context';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { clipboard } from '@/utils/clipboard';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/settings/_tabs/tracking'
)({
component: Component,
});
function Component() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(trpc.client.list.queryOptions({ projectId }));
return <ConnectWeb clients={query.data ?? []} />;
}
interface Props {
clients: IServiceClient[];
}
const ConnectWeb = ({ clients }: Props) => {
const [client, setClient] = useState<IServiceClient | null>(null);
useEffect(() => {
if (!client && clients && clients.length > 0) {
setClient(clients[0]);
}
}, [clients]);
const context = useAppContext();
const code = `<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {${context.isSelfHosted ? `\n\tapiUrl: '${context.apiUrl}',` : ''}
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
// sessionReplay: {
// enabled: true,
// },
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>`;
return (
<div className="col gap-4">
<div className="col gap-2">
<div className="row items-center justify-between gap-4">
<Combobox
items={clients.map((c) => ({
value: c.id,
label: c.name,
}))}
onChange={(id) =>
setClient(clients.find((c) => c.id === id) ?? null)
}
placeholder="Select client"
searchable
value={client?.id ?? null}
/>
<Button
icon={CopyIcon}
onClick={() => clipboard(code, null)}
variant="outline"
>
Copy
</Button>
</div>
<Syntax className="border" code={code} copyable={false} />
</div>
<div className="col gap-4">
<p className="text-center text-muted-foreground text-sm">
Or pick a framework below to get started.
</p>
<div className="grid gap-4 md:grid-cols-2">
{frameworks.map((framework) => (
<button
className="flex items-center gap-4 rounded-md border p-2 text-left"
key={framework.name}
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
type="button"
>
<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="text-center text-muted-foreground text-sm">
Missing a framework?{' '}
<a
className="text-foreground underline"
href="mailto:hello@openpanel.dev"
>
Let us know!
</a>
</p>
</div>
</div>
);
};

View File

@@ -1,16 +1,16 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageHeader } from '@/components/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { import {
Outlet,
createFileRoute, createFileRoute,
Outlet,
useLocation, useLocation,
useRouter, useRouter,
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageHeader } from '@/components/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId/settings/_tabs', '/_app/$organizationId/$projectId/settings/_tabs'
)({ )({
component: ProjectDashboard, component: ProjectDashboard,
head: () => { head: () => {
@@ -27,7 +27,7 @@ export const Route = createFileRoute(
await queryClient.prefetchQuery( await queryClient.prefetchQuery(
trpc.project.getProjectWithClients.queryOptions({ trpc.project.getProjectWithClients.queryOptions({
projectId: params.projectId, projectId: params.projectId,
}), })
); );
}, },
pendingComponent: FullPageLoadingState, pendingComponent: FullPageLoadingState,
@@ -42,6 +42,7 @@ function ProjectDashboard() {
{ id: 'details', label: 'Details' }, { id: 'details', label: 'Details' },
{ id: 'events', label: 'Events' }, { id: 'events', label: 'Events' },
{ id: 'clients', label: 'Clients' }, { id: 'clients', label: 'Clients' },
{ id: 'tracking', label: 'Tracking script' },
{ id: 'widgets', label: 'Widgets' }, { id: 'widgets', label: 'Widgets' },
{ id: 'imports', label: 'Imports' }, { id: 'imports', label: 'Imports' },
]; ];
@@ -56,11 +57,11 @@ function ProjectDashboard() {
return ( return (
<div className="container p-8"> <div className="container p-8">
<PageHeader <PageHeader
title="Project settings"
description="Manage your project settings here" description="Manage your project settings here"
title="Project settings"
/> />
<Tabs value={tab} onValueChange={handleTabChange} className="mt-2 mb-8"> <Tabs className="mt-2 mb-8" onValueChange={handleTabChange} value={tab}>
<TabsList> <TabsList>
{settingsTabs.map((tab) => ( {settingsTabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}> <TabsTrigger key={tab.id} value={tab.id}>

View File

@@ -1,13 +1,13 @@
import { createFileRoute } from '@tanstack/react-router';
import { AlertCircle } from 'lucide-react';
import { z } from 'zod';
import { Or } from '@/components/auth/or'; import { Or } from '@/components/auth/or';
import { SignInEmailForm } from '@/components/auth/sign-in-email-form'; import { SignInEmailForm } from '@/components/auth/sign-in-email-form';
import { SignInGithub } from '@/components/auth/sign-in-github'; import { SignInGithub } from '@/components/auth/sign-in-github';
import { SignInGoogle } from '@/components/auth/sign-in-google'; import { SignInGoogle } from '@/components/auth/sign-in-google';
import { LogoSquare } from '@/components/logo';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { PAGE_TITLES, createTitle } from '@/utils/title'; import { useCookieStore } from '@/hooks/use-cookie-store';
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createTitle, PAGE_TITLES } from '@/utils/title';
import { AlertCircle } from 'lucide-react';
import { z } from 'zod';
export const Route = createFileRoute('/_login/login')({ export const Route = createFileRoute('/_login/login')({
component: LoginPage, component: LoginPage,
@@ -25,16 +25,20 @@ export const Route = createFileRoute('/_login/login')({
function LoginPage() { function LoginPage() {
const { error, correlationId } = Route.useSearch(); const { error, correlationId } = Route.useSearch();
const [lastProvider] = useCookieStore<null | string>(
'last-auth-provider',
null
);
return ( return (
<div className="col gap-8 w-full text-left"> <div className="col w-full gap-8 text-left">
<div> <div>
<h1 className="text-3xl font-bold text-foreground mb-2">Sign in</h1> <h1 className="mb-2 font-bold text-3xl text-foreground">Sign in</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Don't have an account?{' '} Don't have an account?{' '}
<a <a
className="font-medium text-foreground underline"
href="/onboarding" href="/onboarding"
className="underline font-medium text-foreground"
> >
Create one today Create one today
</a> </a>
@@ -42,8 +46,8 @@ function LoginPage() {
</div> </div>
{error && ( {error && (
<Alert <Alert
className="mb-6 border-destructive/20 bg-destructive/10 text-left"
variant="destructive" variant="destructive"
className="text-left bg-destructive/10 border-destructive/20 mb-6"
> >
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
@@ -55,7 +59,7 @@ function LoginPage() {
<p className="mt-2"> <p className="mt-2">
Contact us if you have any issues.{' '} Contact us if you have any issues.{' '}
<a <a
className="underline font-medium" className="font-medium underline"
href={`mailto:hello@openpanel.dev?subject=Login%20Issue%20-%20Correlation%20ID%3A%20${correlationId}`} href={`mailto:hello@openpanel.dev?subject=Login%20Issue%20-%20Correlation%20ID%3A%20${correlationId}`}
> >
hello[at]openpanel.dev hello[at]openpanel.dev
@@ -68,11 +72,11 @@ function LoginPage() {
)} )}
<div className="space-y-4"> <div className="space-y-4">
<SignInGoogle type="sign-in" /> <SignInGoogle isLastUsed={lastProvider === 'google'} type="sign-in" />
<SignInGithub type="sign-in" /> <SignInGithub isLastUsed={lastProvider === 'github'} type="sign-in" />
</div> </div>
<Or /> <Or />
<SignInEmailForm /> <SignInEmailForm isLastUsed={lastProvider === 'email'} />
</div> </div>
); );
} }

View File

@@ -1,16 +1,15 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
import { LockIcon, XIcon } from 'lucide-react'; import { CopyIcon, DownloadIcon, LockIcon, XIcon } from 'lucide-react';
import { ButtonContainer } from '@/components/button-container'; import { ButtonContainer } from '@/components/button-container';
import CopyInput from '@/components/forms/copy-input';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
import ConnectApp from '@/components/onboarding/connect-app';
import ConnectBackend from '@/components/onboarding/connect-backend';
import ConnectWeb from '@/components/onboarding/connect-web'; import ConnectWeb from '@/components/onboarding/connect-web';
import { LinkButton } from '@/components/ui/button'; import Syntax from '@/components/syntax';
import { Button, LinkButton } from '@/components/ui/button';
import { useClientSecret } from '@/hooks/use-client-secret'; import { useClientSecret } from '@/hooks/use-client-secret';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { clipboard } from '@/utils/clipboard';
import { createEntityTitle, PAGE_TITLES } from '@/utils/title'; import { createEntityTitle, PAGE_TITLES } from '@/utils/title';
export const Route = createFileRoute('/_steps/onboarding/$projectId/connect')({ export const Route = createFileRoute('/_steps/onboarding/$projectId/connect')({
@@ -19,7 +18,7 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/connect')({
{ title: createEntityTitle('Connect data', PAGE_TITLES.ONBOARDING) }, { title: createEntityTitle('Connect data', PAGE_TITLES.ONBOARDING) },
], ],
}), }),
beforeLoad: async ({ context }) => { beforeLoad: ({ context }) => {
if (!context.session?.session) { if (!context.session?.session) {
throw redirect({ to: '/onboarding' }); throw redirect({ to: '/onboarding' });
} }
@@ -54,27 +53,55 @@ function Component() {
); );
} }
return ( const credentials = `CLIENT_ID=${client.id}\nCLIENT_SECRET=${secret}`;
<div className="col gap-8 p-4"> const download = () => {
<div className="flex flex-col gap-4"> const blob = new Blob([credentials], { type: 'text/plain' });
<div className="flex items-center gap-2 font-bold text-xl capitalize"> const url = URL.createObjectURL(blob);
<LockIcon className="size-4" /> const a = document.createElement('a');
Credentials a.href = url;
</div> a.download = 'credentials.txt';
<CopyInput label="Client ID" value={client.id} /> a.click();
<CopyInput label="Secret" value={secret} /> };
</div>
<div className="-mx-4 h-px bg-muted" />
{project?.types?.map((type) => {
const Component = {
website: ConnectWeb,
app: ConnectApp,
backend: ConnectBackend,
}[type];
return <Component client={{ ...client, secret }} key={type} />; return (
})} <div className="flex min-h-0 flex-1 flex-col">
<ButtonContainer> <div className="scrollbar-thin flex-1 overflow-y-auto">
<div className="col gap-4 p-4">
<div className="col gap-2">
<div className="row items-center justify-between gap-4">
<div className="flex items-center gap-2 font-bold text-xl capitalize">
<LockIcon className="size-4" />
Client credentials
</div>
<div className="row gap-2">
<Button
icon={CopyIcon}
onClick={() => clipboard(credentials)}
variant="outline"
>
Copy
</Button>
<Button
icon={DownloadIcon}
onClick={() => download()}
variant="outline"
>
Save
</Button>
</div>
</div>
<Syntax
className="border"
code={`CLIENT_ID=${client.id}\nCLIENT_SECRET=${secret}`}
copyable={false}
language="bash"
/>
</div>
<div className="-mx-4 h-px bg-muted" />
<ConnectWeb client={{ ...client, secret }} />
</div>
</div>
<ButtonContainer className="mt-0 flex-shrink-0 border-t bg-background p-4">
<div /> <div />
<LinkButton <LinkButton
className="min-w-28 self-start" className="min-w-28 self-start"

View File

@@ -5,8 +5,8 @@ import { useEffect, useState } from 'react';
import { ButtonContainer } from '@/components/button-container'; import { ButtonContainer } from '@/components/button-container';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
import { CurlPreview } from '@/components/onboarding/curl-preview';
import VerifyListener from '@/components/onboarding/onboarding-verify-listener'; import VerifyListener from '@/components/onboarding/onboarding-verify-listener';
import { VerifyFaq } from '@/components/onboarding/verify-faq';
import { LinkButton } from '@/components/ui/button'; import { LinkButton } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -16,7 +16,7 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
head: () => ({ head: () => ({
meta: [{ title: createEntityTitle('Verify', PAGE_TITLES.ONBOARDING) }], meta: [{ title: createEntityTitle('Verify', PAGE_TITLES.ONBOARDING) }],
}), }),
beforeLoad: async ({ context }) => { beforeLoad: ({ context }) => {
if (!context.session?.session) { if (!context.session?.session) {
throw redirect({ to: '/onboarding' }); throw redirect({ to: '/onboarding' });
} }
@@ -61,20 +61,23 @@ function Component() {
} }
return ( return (
<div className="col gap-8 p-4"> <div className="flex min-h-0 flex-1 flex-col">
<VerifyListener <div className="scrollbar-thin flex-1 overflow-y-auto">
client={client} <div className="col gap-8 p-4">
events={events?.data ?? []} <VerifyListener
onVerified={() => { client={client}
refetch(); events={events?.data ?? []}
setIsVerified(true); onVerified={() => {
}} refetch();
project={project} setIsVerified(true);
/> }}
project={project}
/>
<CurlPreview project={project} /> <VerifyFaq project={project} />
</div>
<ButtonContainer> </div>
<ButtonContainer className="mt-0 flex-shrink-0 border-t bg-background p-4">
<LinkButton <LinkButton
className="min-w-28 self-start" className="min-w-28 self-start"
href={`/onboarding/${project.id}/connect`} href={`/onboarding/${project.id}/connect`}

View File

@@ -8,7 +8,7 @@ import {
ServerIcon, ServerIcon,
SmartphoneIcon, SmartphoneIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { import {
Controller, Controller,
type SubmitHandler, type SubmitHandler,
@@ -18,7 +18,6 @@ import {
import { z } from 'zod'; import { z } from 'zod';
import AnimateHeight from '@/components/animate-height'; import AnimateHeight from '@/components/animate-height';
import { ButtonContainer } from '@/components/button-container'; import { ButtonContainer } from '@/components/button-container';
import { CheckboxItem } from '@/components/forms/checkbox-item';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label'; import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input'; import TagInput from '@/components/forms/tag-input';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
@@ -27,6 +26,7 @@ import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useClientSecret } from '@/hooks/use-client-secret'; import { useClientSecret } from '@/hooks/use-client-secret';
import { handleError, useTRPC } from '@/integrations/trpc/react'; import { handleError, useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
const validateSearch = z.object({ const validateSearch = z.object({
inviteId: z.string().optional(), inviteId: z.string().optional(),
@@ -34,7 +34,7 @@ const validateSearch = z.object({
export const Route = createFileRoute('/_steps/onboarding/project')({ export const Route = createFileRoute('/_steps/onboarding/project')({
component: Component, component: Component,
validateSearch, validateSearch,
beforeLoad: async ({ context }) => { beforeLoad: ({ context }) => {
if (!context.session?.session) { if (!context.session?.session) {
throw redirect({ to: '/onboarding' }); throw redirect({ to: '/onboarding' });
} }
@@ -105,10 +105,18 @@ function Component() {
control: form.control, control: form.control,
}); });
const domain = useWatch({
name: 'domain',
control: form.control,
});
const [showCorsInput, setShowCorsInput] = useState(false);
useEffect(() => { useEffect(() => {
if (!isWebsite) { if (!isWebsite) {
form.setValue('domain', null); form.setValue('domain', null);
form.setValue('cors', []); form.setValue('cors', []);
setShowCorsInput(false);
} }
}, [isWebsite, form]); }, [isWebsite, form]);
@@ -121,8 +129,11 @@ function Component() {
}, [isWebsite, isApp, isBackend]); }, [isWebsite, isApp, isBackend]);
return ( return (
<form onSubmit={form.handleSubmit(onSubmit)}> <form
<div className="p-4"> className="flex min-h-0 flex-1 flex-col"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{organizations.length > 0 ? ( {organizations.length > 0 ? (
<Controller <Controller
@@ -174,6 +185,7 @@ function Component() {
}))} }))}
onChange={field.onChange} onChange={field.onChange}
placeholder="Select timezone" placeholder="Select timezone"
searchable
value={field.value} value={field.value}
/> />
</WithLabel> </WithLabel>
@@ -183,115 +195,142 @@ function Component() {
)} )}
<InputWithLabel <InputWithLabel
error={form.formState.errors.project?.message} error={form.formState.errors.project?.message}
label="Project name" label="Your first project name"
placeholder="Eg. The Music App" placeholder="Eg. The Music App"
{...form.register('project')} {...form.register('project')}
className="col-span-2" className="col-span-2"
/> />
</div> </div>
<div className="mt-4 flex flex-col divide-y"> <div className="mt-4">
<Controller <Label className="mb-2">What are you tracking?</Label>
control={form.control} <div className="grid grid-cols-3 gap-3">
name="website" {[
render={({ field }) => ( {
<CheckboxItem key: 'website' as const,
description="Track events and conversion for your website" label: 'Website',
disabled={isApp} Icon: MonitorIcon,
error={form.formState.errors.website?.message} active: isWebsite,
Icon={MonitorIcon} },
label="Website" {
{...field} key: 'app' as const,
label: 'App',
Icon: SmartphoneIcon,
active: isApp,
},
{
key: 'backend' as const,
label: 'Backend / API',
Icon: ServerIcon,
active: isBackend,
},
].map(({ key, label, Icon, active }) => (
<button
className={cn(
'flex flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors',
active
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-muted-foreground hover:border-primary/40'
)}
key={key}
onClick={() => {
form.setValue(key, !active, { shouldValidate: true });
}}
type="button"
> >
<AnimateHeight open={isWebsite && !isApp}> <Icon size={24} />
<div className="p-4 pl-14"> <span className="font-medium text-sm">{label}</span>
<InputWithLabel </button>
label="Domain" ))}
placeholder="Your website address" </div>
{...form.register('domain')} {(form.formState.errors.website?.message ||
className="mb-4" form.formState.errors.app?.message ||
error={form.formState.errors.domain?.message} form.formState.errors.backend?.message) && (
onBlur={(e) => { <p className="mt-2 text-destructive text-sm">
const value = e.target.value.trim(); At least one type must be selected
if ( </p>
value.includes('.') && )}
form.getValues().cors.length === 0 && <AnimateHeight open={isWebsite}>
!form.formState.errors.domain <div className="mt-4">
) { <InputWithLabel
form.setValue('cors', [value]); label="Domain"
} placeholder="example.com"
}} {...form.register('domain')}
/> error={form.formState.errors.domain?.message}
onBlur={(e) => {
const raw = e.target.value.trim();
if (!raw) {
return;
}
<Controller const hasProtocol =
control={form.control} raw.startsWith('http://') || raw.startsWith('https://');
name="cors" const value = hasProtocol ? raw : `https://${raw}`;
render={({ field }) => (
<WithLabel label="Allowed domains"> form.setValue('domain', value, { shouldValidate: true });
<TagInput if (form.getValues().cors.length === 0) {
{...field} form.setValue('cors', [value]);
error={form.formState.errors.cors?.message} }
onChange={(newValue) => { }}
field.onChange(
newValue.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
})
);
}}
placeholder="Accept events from these domains"
renderTag={(tag) =>
tag === '*'
? 'Accept events from any domains'
: tag
}
value={field.value ?? []}
/>
</WithLabel>
)}
/>
</div>
</AnimateHeight>
</CheckboxItem>
)}
/>
<Controller
control={form.control}
name="app"
render={({ field }) => (
<CheckboxItem
description="Track events and conversion for your app"
disabled={isWebsite}
error={form.formState.errors.app?.message}
Icon={SmartphoneIcon}
label="App"
{...field}
/> />
)} {domain && (
/> <>
<Controller <button
control={form.control} className="mt-2 text-muted-foreground text-sm hover:text-foreground"
name="backend" onClick={() => setShowCorsInput((open) => !open)}
render={({ field }) => ( type="button"
<CheckboxItem >
description="Track events and conversion for your backend / API" All events from{' '}
error={form.formState.errors.backend?.message} <span className="font-medium text-foreground">
Icon={ServerIcon} {domain}
label="Backend / API" </span>{' '}
{...field} will be allowed. Do you want to allow any other?
/> </button>
)} <AnimateHeight open={showCorsInput}>
/> <div className="mt-3">
<Controller
control={form.control}
name="cors"
render={({ field }) => (
<WithLabel label="Allowed domains">
<TagInput
{...field}
error={form.formState.errors.cors?.message}
onChange={(newValue: string[]) => {
field.onChange(
newValue.map((item: string) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
})
);
}}
placeholder="Accept events from these domains"
renderTag={(tag: string) =>
tag === '*'
? 'Accept events from any domains'
: tag
}
value={field.value ?? []}
/>
</WithLabel>
)}
/>
</div>
</AnimateHeight>
</>
)}
</div>
</AnimateHeight>
</div> </div>
</div> </div>
<ButtonContainer className="border-t p-4"> <ButtonContainer className="mt-0 flex-shrink-0 border-t bg-background p-4">
<div /> <div />
<Button <Button
className="min-w-28 self-start" className="min-w-28 self-start"

View File

@@ -1,14 +1,7 @@
import { OnboardingLeftPanel } from '@/components/onboarding-left-panel'; import { createFileRoute, Outlet, useMatchRoute } from '@tanstack/react-router';
import { SkeletonDashboard } from '@/components/skeleton-dashboard'; import { SkeletonDashboard } from '@/components/skeleton-dashboard';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { PAGE_TITLES, createEntityTitle } from '@/utils/title'; import { createEntityTitle, PAGE_TITLES } from '@/utils/title';
import {
Outlet,
createFileRoute,
redirect,
useLocation,
useMatchRoute,
} from '@tanstack/react-router';
export const Route = createFileRoute('/_steps')({ export const Route = createFileRoute('/_steps')({
component: OnboardingLayout, component: OnboardingLayout,
@@ -19,13 +12,18 @@ export const Route = createFileRoute('/_steps')({
function OnboardingLayout() { function OnboardingLayout() {
return ( return (
<div className="relative min-h-screen pt-32 pb-8"> <div className="relative flex min-h-screen items-center justify-center p-4">
<div className="fixed inset-0 hidden md:block"> <div className="fixed inset-0 hidden md:block">
<SkeletonDashboard /> <SkeletonDashboard />
<div className="fixed inset-0 z-10 bg-def-100/50" />
</div> </div>
<div className="relative z-10 border bg-background rounded-lg shadow-xl shadow-muted/50 max-w-xl mx-auto"> <div className="relative z-10 flex max-h-[calc(100vh-2rem)] w-full max-w-xl flex-col overflow-hidden rounded-lg border bg-background shadow-muted/50 shadow-xl">
<Progress /> <div className="sticky top-0 z-10 flex-shrink-0 border-b bg-background">
<Outlet /> <Progress />
</div>
<div className="flex min-h-0 flex-1 flex-col">
<Outlet />
</div>
</div> </div>
</div> </div>
); );
@@ -53,18 +51,18 @@ function Progress() {
// @ts-expect-error // @ts-expect-error
from: step.match, from: step.match,
fuzzy: false, fuzzy: false,
}), })
); );
return ( return (
<div className="row gap-4 p-4 border-b justify-between items-center flex-1 w-full"> <div className="row w-full flex-shrink-0 items-center justify-between gap-4 p-4">
<div className="font-bold">{currentStep?.name ?? 'Onboarding'}</div> <div className="font-bold">{currentStep?.name ?? 'Onboarding'}</div>
<div className="row gap-4"> <div className="row gap-4">
{steps.map((step) => ( {steps.map((step) => (
<div <div
className={cn( className={cn(
'w-10 h-2 rounded-full bg-muted', 'h-2 w-10 rounded-full bg-muted',
currentStep === step && 'w-20 bg-primary', currentStep === step && 'w-20 bg-primary'
)} )}
key={step.match} key={step.match}
/> />

View File

@@ -277,6 +277,30 @@ button {
display: none; display: none;
} }
/* Thin scrollbar, visible on hover - "the small little nice thingy" */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.scrollbar-thin:hover {
scrollbar-color: var(--color-border) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 9999px;
transition: background 0.15s ease;
}
.scrollbar-thin:hover::-webkit-scrollbar-thumb,
.scrollbar-thin:active::-webkit-scrollbar-thumb {
background: var(--color-border);
}
/* Hide scrollbar for IE, Edge and Firefox */ /* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar { .hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */

View File

@@ -1,24 +1,23 @@
import type { Job } from 'bullmq';
import { logger as baseLogger } from '@/utils/logger';
import { import {
type IClickhouseSession,
type IServiceCreateEventPayload,
type IServiceEvent,
TABLE_NAMES,
checkNotificationRulesForSessionEnd, checkNotificationRulesForSessionEnd,
convertClickhouseDateToJs, convertClickhouseDateToJs,
createEvent, createEvent,
eventBuffer,
formatClickhouseDate, formatClickhouseDate,
getEvents, getEvents,
getHasFunnelRules, getHasFunnelRules,
getNotificationRulesByProjectId, getNotificationRulesByProjectId,
type IClickhouseSession,
type IServiceCreateEventPayload,
type IServiceEvent,
profileBackfillBuffer, profileBackfillBuffer,
sessionBuffer, sessionBuffer,
TABLE_NAMES,
transformEvent,
transformSessionToEvent, transformSessionToEvent,
} from '@openpanel/db'; } from '@openpanel/db';
import type { EventsQueuePayloadCreateSessionEnd } from '@openpanel/queue'; import type { EventsQueuePayloadCreateSessionEnd } from '@openpanel/queue';
import type { Job } from 'bullmq';
import { logger as baseLogger } from '@/utils/logger';
const MAX_SESSION_EVENTS = 500; const MAX_SESSION_EVENTS = 500;
@@ -39,7 +38,7 @@ async function getSessionEvents({
WHERE WHERE
session_id = '${sessionId}' session_id = '${sessionId}'
AND project_id = '${projectId}' AND project_id = '${projectId}'
AND created_at BETWEEN '${formatClickhouseDate(startAt)}' AND '${formatClickhouseDate(endAt)}' AND created_at BETWEEN '${formatClickhouseDate(new Date(startAt.getTime() - 1000))}' AND '${formatClickhouseDate(new Date(endAt.getTime() + 1000))}'
ORDER BY created_at DESC LIMIT ${MAX_SESSION_EVENTS}; ORDER BY created_at DESC LIMIT ${MAX_SESSION_EVENTS};
`; `;
@@ -58,12 +57,12 @@ async function getSessionEvents({
.flatMap((event) => (event ? [event] : [])) .flatMap((event) => (event ? [event] : []))
.sort( .sort(
(a, b) => (a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
); );
} }
export async function createSessionEnd( export async function createSessionEnd(
job: Job<EventsQueuePayloadCreateSessionEnd>, job: Job<EventsQueuePayloadCreateSessionEnd>
) { ) {
const { payload } = job.data; const { payload } = job.data;
const logger = baseLogger.child({ const logger = baseLogger.child({
@@ -81,35 +80,30 @@ export async function createSessionEnd(
throw new Error('Session not found'); throw new Error('Session not found');
} }
try { const profileId = session.profile_id || payload.profileId;
await handleSessionEndNotifications({
session,
payload,
});
} catch (error) {
logger.error('Creating notificatios for session end failed', {
error,
});
}
const profileId = session.profile_id || payload.profileId
if ( if (
profileId !== session.device_id profileId !== session.device_id &&
&& process.env.EXPERIMENTAL_PROFILE_BACKFILL === '1' process.env.EXPERIMENTAL_PROFILE_BACKFILL === '1'
) { ) {
const runOnProjects = process.env.EXPERIMENTAL_PROFILE_BACKFILL_PROJECTS?.split(',').filter(Boolean) ?? [] const runOnProjects =
if(runOnProjects.length === 0 || runOnProjects.includes(payload.projectId)) { process.env.EXPERIMENTAL_PROFILE_BACKFILL_PROJECTS?.split(',').filter(
Boolean
) ?? [];
if (
runOnProjects.length === 0 ||
runOnProjects.includes(payload.projectId)
) {
await profileBackfillBuffer.add({ await profileBackfillBuffer.add({
projectId: payload.projectId, projectId: payload.projectId,
sessionId: payload.sessionId, sessionId: payload.sessionId,
profileId: profileId, profileId,
}); });
} }
} }
// Create session end event // Create session end event
return createEvent({ const { document: sessionEndEvent } = await createEvent({
...payload, ...payload,
properties: { properties: {
...payload.properties, ...payload.properties,
@@ -119,21 +113,37 @@ export async function createSessionEnd(
duration: session.duration ?? 0, duration: session.duration ?? 0,
path: session.exit_path ?? '', path: session.exit_path ?? '',
createdAt: new Date( createdAt: new Date(
convertClickhouseDateToJs(session.ended_at).getTime() + 1000, convertClickhouseDateToJs(session.ended_at).getTime() + 1000
), ),
profileId: profileId, profileId,
}); });
try {
await handleSessionEndNotifications({
session,
payload,
sessionEndEvent: transformEvent(sessionEndEvent),
});
} catch (error) {
logger.error('Creating notificatios for session end failed', {
error,
});
}
return sessionEndEvent;
} }
async function handleSessionEndNotifications({ async function handleSessionEndNotifications({
session, session,
payload, payload,
sessionEndEvent,
}: { }: {
session: IClickhouseSession; session: IClickhouseSession;
payload: IServiceCreateEventPayload; payload: IServiceCreateEventPayload;
sessionEndEvent: IServiceEvent;
}) { }) {
const notificationRules = await getNotificationRulesByProjectId( const notificationRules = await getNotificationRulesByProjectId(
payload.projectId, payload.projectId
); );
const hasFunnelRules = getHasFunnelRules(notificationRules); const hasFunnelRules = getHasFunnelRules(notificationRules);
const isEventCountReasonable = const isEventCountReasonable =
@@ -143,12 +153,12 @@ async function handleSessionEndNotifications({
const events = await getSessionEvents({ const events = await getSessionEvents({
projectId: payload.projectId, projectId: payload.projectId,
sessionId: payload.sessionId, sessionId: payload.sessionId,
startAt: new Date(session.created_at), startAt: convertClickhouseDateToJs(session.created_at),
endAt: new Date(session.ended_at), endAt: convertClickhouseDateToJs(session.ended_at),
}); });
if (events.length > 0) { if (events.length > 0) {
await checkNotificationRulesForSessionEnd(events); await checkNotificationRulesForSessionEnd([...events, sessionEndEvent]);
} }
} }
} }

View File

@@ -14,7 +14,8 @@ import {
} from '@openpanel/db'; } from '@openpanel/db';
import type { ILogger } from '@openpanel/logger'; import type { ILogger } from '@openpanel/logger';
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue'; import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
import * as R from 'ramda'; import { getLock } from '@openpanel/redis';
import { anyPass, isEmpty, isNil, mergeDeepRight, omit, reject } from 'ramda';
import { logger as baseLogger } from '@/utils/logger'; import { logger as baseLogger } from '@/utils/logger';
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler'; import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
@@ -24,7 +25,22 @@ const GLOBAL_PROPERTIES = ['__path', '__referrer', '__timestamp', '__revenue'];
// First it will strip '' and undefined/null from B // First it will strip '' and undefined/null from B
// Then it will merge the two objects with a standard ramda merge function // Then it will merge the two objects with a standard ramda merge function
const merge = <A, B>(a: Partial<A>, b: Partial<B>): A & B => const merge = <A, B>(a: Partial<A>, b: Partial<B>): A & B =>
R.mergeDeepRight(a, R.reject(R.anyPass([R.isEmpty, R.isNil]))(b)) as A & B; mergeDeepRight(a, reject(anyPass([isEmpty, isNil]))(b)) as A & B;
/** Check if payload matches project-level event exclude filters */
async function isEventExcludedByProjectFilter(
payload: IServiceCreateEventPayload,
projectId: string
): Promise<boolean> {
const project = await getProjectByIdCached(projectId);
const eventExcludeFilters = (project?.filters ?? []).filter(
(f) => f.type === 'event'
);
if (eventExcludeFilters.length === 0) {
return false;
}
return eventExcludeFilters.some((filter) => matchEvent(payload, filter));
}
async function createEventAndNotify( async function createEventAndNotify(
payload: IServiceCreateEventPayload, payload: IServiceCreateEventPayload,
@@ -32,21 +48,13 @@ async function createEventAndNotify(
projectId: string projectId: string
) { ) {
// Check project-level event exclude filters // Check project-level event exclude filters
const project = await getProjectByIdCached(projectId); const isExcluded = await isEventExcludedByProjectFilter(payload, projectId);
const eventExcludeFilters = (project?.filters ?? []).filter( if (isExcluded) {
(f) => f.type === 'event' logger.info('Event excluded by project filter', {
); event: payload.name,
if (eventExcludeFilters.length > 0) { projectId,
const isExcluded = eventExcludeFilters.some((filter) => });
matchEvent(payload, filter) return null;
);
if (isExcluded) {
logger.info('Event excluded by project filter', {
event: payload.name,
projectId,
});
return null;
}
} }
logger.info('Creating event', { event: payload }); logger.info('Creating event', { event: payload });
@@ -83,8 +91,6 @@ export async function incomingEvent(
event: body, event: body,
headers, headers,
projectId, projectId,
currentDeviceId,
previousDeviceId,
deviceId, deviceId,
sessionId, sessionId,
uaInfo: _uaInfo, uaInfo: _uaInfo,
@@ -125,7 +131,7 @@ export async function incomingEvent(
name: body.name, name: body.name,
profileId, profileId,
projectId, projectId,
properties: R.omit(GLOBAL_PROPERTIES, { properties: omit(GLOBAL_PROPERTIES, {
...properties, ...properties,
__hash: hash, __hash: hash,
__query: query, __query: query,
@@ -194,8 +200,6 @@ export async function incomingEvent(
const sessionEnd = await getSessionEnd({ const sessionEnd = await getSessionEnd({
projectId, projectId,
currentDeviceId,
previousDeviceId,
deviceId, deviceId,
profileId, profileId,
}); });
@@ -216,20 +220,44 @@ export async function incomingEvent(
origin: baseEvent.origin || activeSession?.exit_origin || '', origin: baseEvent.origin || activeSession?.exit_origin || '',
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload; } as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
if (!sessionEnd) { // If the triggering event is filtered, do not create session_start or the event (issue #2)
logger.info('Creating session start event', { event: payload }); const isExcluded = await isEventExcludedByProjectFilter(payload, projectId);
await createEventAndNotify( if (isExcluded) {
logger.info(
'Skipping session_start and event (excluded by project filter)',
{ {
...payload, event: payload.name,
name: 'session_start', projectId,
createdAt: new Date(getTime(payload.createdAt) - 100), }
}, );
logger, return null;
projectId }
).catch((error) => {
logger.error('Error creating session start event', { event: payload }); if (!sessionEnd) {
throw error; const locked = await getLock(
}); `session_start:${projectId}:${sessionId}`,
'1',
1000
);
if (locked) {
logger.info('Creating session start event', { event: payload });
await createEventAndNotify(
{
...payload,
name: 'session_start',
createdAt: new Date(getTime(payload.createdAt) - 100),
},
logger,
projectId
).catch((error) => {
logger.error('Error creating session start event', { event: payload });
throw error;
});
} else {
logger.info('Session start already claimed by another worker', {
event: payload,
});
}
} }
const event = await createEventAndNotify(payload, logger, projectId); const event = await createEventAndNotify(payload, logger, projectId);

View File

@@ -30,8 +30,7 @@ vi.mock('@openpanel/db', async () => {
// 30 minutes // 30 minutes
const SESSION_TIMEOUT = 30 * 60 * 1000; const SESSION_TIMEOUT = 30 * 60 * 1000;
const projectId = 'test-project'; const projectId = 'test-project';
const currentDeviceId = 'device-123'; const deviceId = 'device-123';
const previousDeviceId = 'device-456';
// Valid UUID used when creating a new session in tests // Valid UUID used when creating a new session in tests
const newSessionId = 'a1b2c3d4-e5f6-4789-a012-345678901234'; const newSessionId = 'a1b2c3d4-e5f6-4789-a012-345678901234';
const geo = { const geo = {
@@ -70,7 +69,9 @@ describe('incomingEvent', () => {
}); });
it('should create a session start and an event', async () => { it('should create a session start and an event', async () => {
const spySessionsQueueAdd = vi.spyOn(sessionsQueue, 'add'); const spySessionsQueueAdd = vi
.spyOn(sessionsQueue, 'add')
.mockResolvedValue({} as Job);
const timestamp = new Date(); const timestamp = new Date();
// Mock job data // Mock job data
const jobData: EventsQueuePayloadIncomingEvent['payload'] = { const jobData: EventsQueuePayloadIncomingEvent['payload'] = {
@@ -90,14 +91,12 @@ describe('incomingEvent', () => {
'openpanel-sdk-version': '1.0.0', 'openpanel-sdk-version': '1.0.0',
}, },
projectId, projectId,
currentDeviceId, deviceId,
previousDeviceId,
deviceId: currentDeviceId,
sessionId: newSessionId, sessionId: newSessionId,
}; };
const event = { const event = {
name: 'test_event', name: 'test_event',
deviceId: currentDeviceId, deviceId,
profileId: '', profileId: '',
sessionId: expect.stringMatching( sessionId: expect.stringMatching(
// biome-ignore lint/performance/useTopLevelRegex: test // biome-ignore lint/performance/useTopLevelRegex: test
@@ -145,7 +144,7 @@ describe('incomingEvent', () => {
}, },
{ {
delay: SESSION_TIMEOUT, delay: SESSION_TIMEOUT,
jobId: `sessionEnd:${projectId}:${currentDeviceId}`, jobId: `sessionEnd:${projectId}:${deviceId}`,
attempts: 3, attempts: 3,
backoff: { backoff: {
delay: 200, delay: 200,
@@ -185,9 +184,7 @@ describe('incomingEvent', () => {
}, },
uaInfo, uaInfo,
projectId, projectId,
currentDeviceId, deviceId,
previousDeviceId,
deviceId: currentDeviceId,
sessionId: 'session-123', sessionId: 'session-123',
}; };
@@ -201,9 +198,7 @@ describe('incomingEvent', () => {
type: 'createSessionEnd', type: 'createSessionEnd',
payload: { payload: {
sessionId: 'session-123', sessionId: 'session-123',
deviceId: currentDeviceId, deviceId,
profileId: currentDeviceId,
projectId,
}, },
}, },
} as Partial<Job> as Job); } as Partial<Job> as Job);
@@ -212,7 +207,7 @@ describe('incomingEvent', () => {
const event = { const event = {
name: 'test_event', name: 'test_event',
deviceId: currentDeviceId, deviceId,
profileId: '', profileId: '',
sessionId: 'session-123', sessionId: 'session-123',
projectId, projectId,
@@ -268,8 +263,6 @@ describe('incomingEvent', () => {
'request-id': '123', 'request-id': '123',
}, },
projectId, projectId,
currentDeviceId: '',
previousDeviceId: '',
deviceId: '', deviceId: '',
sessionId: '', sessionId: '',
uaInfo: uaInfoServer, uaInfo: uaInfoServer,
@@ -374,8 +367,6 @@ describe('incomingEvent', () => {
'request-id': '123', 'request-id': '123',
}, },
projectId, projectId,
currentDeviceId: '',
previousDeviceId: '',
deviceId: '', deviceId: '',
sessionId: '', sessionId: '',
uaInfo: uaInfoServer, uaInfo: uaInfoServer,

View File

@@ -1,5 +1,4 @@
import { getTime } from '@openpanel/common'; import type { IServiceCreateEventPayload } from '@openpanel/db';
import { type IServiceCreateEventPayload, createEvent } from '@openpanel/db';
import { import {
type EventsQueuePayloadCreateSessionEnd, type EventsQueuePayloadCreateSessionEnd,
sessionsQueue, sessionsQueue,
@@ -12,7 +11,7 @@ export const SESSION_TIMEOUT = 1000 * 60 * 30;
const getSessionEndJobId = (projectId: string, deviceId: string) => const getSessionEndJobId = (projectId: string, deviceId: string) =>
`sessionEnd:${projectId}:${deviceId}`; `sessionEnd:${projectId}:${deviceId}`;
export async function createSessionEndJob({ export function createSessionEndJob({
payload, payload,
}: { }: {
payload: IServiceCreateEventPayload; payload: IServiceCreateEventPayload;
@@ -31,27 +30,21 @@ export async function createSessionEndJob({
type: 'exponential', type: 'exponential',
delay: 200, delay: 200,
}, },
}, }
); );
} }
export async function getSessionEnd({ export async function getSessionEnd({
projectId, projectId,
currentDeviceId,
previousDeviceId,
deviceId, deviceId,
profileId, profileId,
}: { }: {
projectId: string; projectId: string;
currentDeviceId: string;
previousDeviceId: string;
deviceId: string; deviceId: string;
profileId: string; profileId: string;
}) { }) {
const sessionEnd = await getSessionEndJob({ const sessionEnd = await getSessionEndJob({
projectId, projectId,
currentDeviceId,
previousDeviceId,
deviceId, deviceId,
}); });
@@ -82,8 +75,6 @@ export async function getSessionEnd({
export async function getSessionEndJob(args: { export async function getSessionEndJob(args: {
projectId: string; projectId: string;
currentDeviceId: string;
previousDeviceId: string;
deviceId: string; deviceId: string;
retryCount?: number; retryCount?: number;
}): Promise<{ }): Promise<{
@@ -98,7 +89,7 @@ export async function getSessionEndJob(args: {
async function handleJobStates( async function handleJobStates(
job: Job<EventsQueuePayloadCreateSessionEnd>, job: Job<EventsQueuePayloadCreateSessionEnd>,
deviceId: string, deviceId: string
): Promise<{ ): Promise<{
deviceId: string; deviceId: string;
job: Job<EventsQueuePayloadCreateSessionEnd>; job: Job<EventsQueuePayloadCreateSessionEnd>;
@@ -134,28 +125,9 @@ export async function getSessionEndJob(args: {
return null; return null;
} }
// TODO: Remove this when migrated to deviceId
if (args.currentDeviceId && args.previousDeviceId) {
// Check current device job
const currentJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.currentDeviceId),
);
if (currentJob) {
return await handleJobStates(currentJob, args.currentDeviceId);
}
// Check previous device job
const previousJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.previousDeviceId),
);
if (previousJob) {
return await handleJobStates(previousJob, args.previousDeviceId);
}
}
// Check current device job // Check current device job
const currentJob = await sessionsQueue.getJob( const currentJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.deviceId), getSessionEndJobId(args.projectId, args.deviceId)
); );
if (currentJob) { if (currentJob) {
return await handleJobStates(currentJob, args.deviceId); return await handleJobStates(currentJob, args.deviceId);

View File

@@ -4,10 +4,10 @@ import { cacheable } from '@openpanel/redis';
import type { IChartEvent, IChartEventFilter } from '@openpanel/validation'; import type { IChartEvent, IChartEventFilter } from '@openpanel/validation';
import { pathOr } from 'ramda'; import { pathOr } from 'ramda';
import { import {
db,
type Integration, type Integration,
type Notification, type Notification,
Prisma, type Prisma,
db,
} from '../prisma-client'; } from '../prisma-client';
import type { import type {
IServiceCreateEventPayload, IServiceCreateEventPayload,
@@ -90,7 +90,7 @@ export const getNotificationRulesByProjectId = cacheable(
}, },
}); });
}, },
60 * 24, 60 * 24
); );
function getIntegration(integrationId: string | null) { function getIntegration(integrationId: string | null) {
@@ -117,12 +117,30 @@ function getIntegration(integrationId: string | null) {
}; };
} }
function stripNullChars<T>(value: T): T {
if (typeof value === 'string') {
return value.split('\u0000').join('') as T;
}
if (value instanceof Date) {
return value;
}
if (Array.isArray(value)) {
return value.map(stripNullChars) as T;
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, stripNullChars(v)])
) as T;
}
return value;
}
export async function createNotification(notification: ICreateNotification) { export async function createNotification(notification: ICreateNotification) {
const data: Prisma.NotificationUncheckedCreateInput = { const data: Prisma.NotificationUncheckedCreateInput = {
title: notification.title, title: notification.title,
message: notification.message, message: notification.message,
projectId: notification.projectId, projectId: notification.projectId,
payload: notification.payload || Prisma.DbNull, payload: stripNullChars(notification.payload) || undefined,
...getIntegration(notification.integrationId), ...getIntegration(notification.integrationId),
notificationRuleId: notification.notificationRuleId, notificationRuleId: notification.notificationRuleId,
}; };
@@ -138,7 +156,7 @@ export async function createNotification(notification: ICreateNotification) {
} }
export function triggerNotification( export function triggerNotification(
notification: Prisma.NotificationUncheckedCreateInput, notification: Prisma.NotificationUncheckedCreateInput
) { ) {
return notificationQueue.add('sendNotification', { return notificationQueue.add('sendNotification', {
type: 'sendNotification', type: 'sendNotification',
@@ -150,12 +168,14 @@ export function triggerNotification(
function matchEventFilters( function matchEventFilters(
payload: IServiceCreateEventPayload, payload: IServiceCreateEventPayload,
filters: IChartEventFilter[], filters: IChartEventFilter[]
) { ) {
return filters.every((filter) => { return filters.every((filter) => {
const { name, value, operator } = filter; const { name, value, operator } = filter;
if (value.length === 0) return true; if (value.length === 0) {
return true;
}
if (name === 'has_profile') { if (name === 'has_profile') {
if (value.includes('true')) { if (value.includes('true')) {
@@ -214,7 +234,7 @@ function matchEventFilters(
export function matchEvent( export function matchEvent(
payload: IServiceCreateEventPayload, payload: IServiceCreateEventPayload,
chartEvent: IChartEvent, chartEvent: IChartEvent
) { ) {
if (payload.name !== chartEvent.name && chartEvent.name !== '*') { if (payload.name !== chartEvent.name && chartEvent.name !== '*') {
return false; return false;
@@ -234,7 +254,9 @@ function notificationTemplateEvent({
payload: IServiceCreateEventPayload; payload: IServiceCreateEventPayload;
rule: INotificationRuleCached; rule: INotificationRuleCached;
}) { }) {
if (!rule.template) return `You received a new "${payload.name}" event`; if (!rule.template) {
return `You received a new "${payload.name}" event`;
}
let template = rule.template let template = rule.template
.replaceAll('$EVENT_NAME', payload.name) .replaceAll('$EVENT_NAME', payload.name)
.replaceAll('$RULE_NAME', rule.name) .replaceAll('$RULE_NAME', rule.name)
@@ -249,7 +271,7 @@ function notificationTemplateEvent({
if (value) { if (value) {
template = template.replaceAll( template = template.replaceAll(
match, match,
typeof value === 'object' ? JSON.stringify(value) : value, typeof value === 'object' ? JSON.stringify(value) : value
); );
} }
} }
@@ -264,14 +286,17 @@ function notificationTemplateFunnel({
events: IServiceEvent[]; events: IServiceEvent[];
rule: INotificationRuleCached; rule: INotificationRuleCached;
}) { }) {
if (!rule.template) return `Funnel "${rule.name}" completed`; if (!rule.template) {
return `Funnel "${rule.name}" completed`;
}
return rule.template return rule.template
.replaceAll('$EVENT_NAME', events.map((e) => e.name).join(' -> ')) .replaceAll('$EVENT_NAME', events.map((e) => e.name).join(' -> '))
.replaceAll('$RULE_NAME', rule.name); .replaceAll('$RULE_NAME', rule.name);
} }
const PROFILE_TEMPLATE_REGEX = /{{profile\.[^}]*}}/;
export async function checkNotificationRulesForEvent( export async function checkNotificationRulesForEvent(
payload: IServiceCreateEventPayload, payload: IServiceCreateEventPayload
) { ) {
const project = await getProjectByIdCached(payload.projectId); const project = await getProjectByIdCached(payload.projectId);
const rules = await getNotificationRulesByProjectId(payload.projectId); const rules = await getNotificationRulesByProjectId(payload.projectId);
@@ -280,7 +305,7 @@ export async function checkNotificationRulesForEvent(
// so we can use it in the template // so we can use it in the template
if ( if (
payload.profileId && payload.profileId &&
rules.some((rule) => rule.template?.match(/{{profile\.[^}]*}}/)) rules.some((rule) => rule.template?.match(PROFILE_TEMPLATE_REGEX))
) { ) {
const profile = await getProfileById(payload.profileId, payload.projectId); const profile = await getProfileById(payload.profileId, payload.projectId);
if (profile) { if (profile) {
@@ -317,7 +342,7 @@ export async function checkNotificationRulesForEvent(
...notification, ...notification,
integrationId: integration.id, integrationId: integration.id,
notificationRuleId: rule.id, notificationRuleId: rule.id,
}), })
); );
if (rule.sendToApp) { if (rule.sendToApp) {
@@ -326,7 +351,7 @@ export async function checkNotificationRulesForEvent(
...notification, ...notification,
integrationId: APP_NOTIFICATION_INTEGRATION_ID, integrationId: APP_NOTIFICATION_INTEGRATION_ID,
notificationRuleId: rule.id, notificationRuleId: rule.id,
}), })
); );
} }
@@ -336,13 +361,15 @@ export async function checkNotificationRulesForEvent(
...notification, ...notification,
integrationId: EMAIL_NOTIFICATION_INTEGRATION_ID, integrationId: EMAIL_NOTIFICATION_INTEGRATION_ID,
notificationRuleId: rule.id, notificationRuleId: rule.id,
}), })
); );
} }
return promises; return promises;
} }
}),
return [];
})
); );
} }
@@ -358,13 +385,15 @@ export function getFunnelRules(rules: INotificationRuleCached[]) {
} }
export async function checkNotificationRulesForSessionEnd( export async function checkNotificationRulesForSessionEnd(
events: IServiceEvent[], events: IServiceEvent[]
) { ) {
const sortedEvents = events.sort( const sortedEvents = events.sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(), (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
); );
const projectId = sortedEvents[0]?.projectId; const projectId = sortedEvents[0]?.projectId;
if (!projectId) return null; if (!projectId) {
return null;
}
const [project, rules] = await Promise.all([ const [project, rules] = await Promise.all([
getProjectByIdCached(projectId), getProjectByIdCached(projectId),
@@ -380,12 +409,16 @@ export async function checkNotificationRulesForSessionEnd(
if (matchEvent(event, rule.config.events[funnelIndex]!)) { if (matchEvent(event, rule.config.events[funnelIndex]!)) {
matchedEvents.push(event); matchedEvents.push(event);
funnelIndex++; funnelIndex++;
if (funnelIndex === rule.config.events.length) break; if (funnelIndex === rule.config.events.length) {
break;
}
} }
} }
// If funnel not completed, skip this rule // If funnel not completed, skip this rule
if (funnelIndex < rule.config.events.length) return []; if (funnelIndex < rule.config.events.length) {
return [];
}
// Create notification object // Create notification object
const notification = { const notification = {
@@ -405,7 +438,7 @@ export async function checkNotificationRulesForSessionEnd(
...notification, ...notification,
integrationId: integration.id, integrationId: integration.id,
notificationRuleId: rule.id, notificationRuleId: rule.id,
}), })
), ),
...(rule.sendToApp ...(rule.sendToApp
? [ ? [

View File

@@ -1,5 +1,3 @@
import { Queue, QueueEvents } from 'bullmq';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { import type {
IServiceCreateEventPayload, IServiceCreateEventPayload,
@@ -8,12 +6,13 @@ import type {
} from '@openpanel/db'; } from '@openpanel/db';
import { createLogger } from '@openpanel/logger'; import { createLogger } from '@openpanel/logger';
import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis'; import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis';
import { Queue, QueueEvents } from 'bullmq';
import { Queue as GroupQueue } from 'groupmq'; import { Queue as GroupQueue } from 'groupmq';
import type { ITrackPayload } from '../../validation'; import type { ITrackPayload } from '../../validation';
export const EVENTS_GROUP_QUEUES_SHARDS = Number.parseInt( export const EVENTS_GROUP_QUEUES_SHARDS = Number.parseInt(
process.env.EVENTS_GROUP_QUEUES_SHARDS || '1', process.env.EVENTS_GROUP_QUEUES_SHARDS || '1',
10, 10
); );
export const getQueueName = (name: string) => export const getQueueName = (name: string) =>
@@ -65,8 +64,6 @@ export interface EventsQueuePayloadIncomingEvent {
latitude: number | undefined; latitude: number | undefined;
}; };
headers: Record<string, string | undefined>; headers: Record<string, string | undefined>;
currentDeviceId: string; // TODO: Remove
previousDeviceId: string; // TODO: Remove
deviceId: string; deviceId: string;
sessionId: string; sessionId: string;
}; };
@@ -154,12 +151,12 @@ export type CronQueueType = CronQueuePayload['type'];
const orderingDelayMs = Number.parseInt( const orderingDelayMs = Number.parseInt(
process.env.ORDERING_DELAY_MS || '100', process.env.ORDERING_DELAY_MS || '100',
10, 10
); );
const autoBatchMaxWaitMs = Number.parseInt( const autoBatchMaxWaitMs = Number.parseInt(
process.env.AUTO_BATCH_MAX_WAIT_MS || '0', process.env.AUTO_BATCH_MAX_WAIT_MS || '0',
10, 10
); );
const autoBatchSize = Number.parseInt(process.env.AUTO_BATCH_SIZE || '0', 10); const autoBatchSize = Number.parseInt(process.env.AUTO_BATCH_SIZE || '0', 10);
@@ -170,12 +167,12 @@ export const eventsGroupQueues = Array.from({
new GroupQueue<EventsQueuePayloadIncomingEvent['payload']>({ new GroupQueue<EventsQueuePayloadIncomingEvent['payload']>({
logger: process.env.NODE_ENV === 'production' ? queueLogger : undefined, logger: process.env.NODE_ENV === 'production' ? queueLogger : undefined,
namespace: getQueueName( namespace: getQueueName(
list.length === 1 ? 'group_events' : `group_events_${index}`, list.length === 1 ? 'group_events' : `group_events_${index}`
), ),
redis: getRedisGroupQueue(), redis: getRedisGroupQueue(),
keepCompleted: 1_000, keepCompleted: 1000,
keepFailed: 10_000, keepFailed: 10_000,
orderingDelayMs: orderingDelayMs, orderingDelayMs,
autoBatch: autoBatch:
autoBatchMaxWaitMs && autoBatchSize autoBatchMaxWaitMs && autoBatchSize
? { ? {
@@ -183,7 +180,7 @@ export const eventsGroupQueues = Array.from({
size: autoBatchSize, size: autoBatchSize,
} }
: undefined, : undefined,
}), })
); );
export const getEventsGroupQueueShard = (groupId: string) => { export const getEventsGroupQueueShard = (groupId: string) => {
@@ -202,7 +199,7 @@ export const sessionsQueue = new Queue<SessionsQueuePayload>(
defaultJobOptions: { defaultJobOptions: {
removeOnComplete: 10, removeOnComplete: 10,
}, },
}, }
); );
export const sessionsQueueEvents = new QueueEvents(getQueueName('sessions'), { export const sessionsQueueEvents = new QueueEvents(getQueueName('sessions'), {
connection: getRedisQueue(), connection: getRedisQueue(),
@@ -236,7 +233,7 @@ export const notificationQueue = new Queue<NotificationQueuePayload>(
defaultJobOptions: { defaultJobOptions: {
removeOnComplete: 10, removeOnComplete: 10,
}, },
}, }
); );
export type ImportQueuePayload = { export type ImportQueuePayload = {
@@ -254,7 +251,7 @@ export const importQueue = new Queue<ImportQueuePayload>(
removeOnComplete: 10, removeOnComplete: 10,
removeOnFail: 50, removeOnFail: 50,
}, },
}, }
); );
export type InsightsQueuePayloadProject = { export type InsightsQueuePayloadProject = {
@@ -269,5 +266,5 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
defaultJobOptions: { defaultJobOptions: {
removeOnComplete: 100, removeOnComplete: 100,
}, },
}, }
); );

View File

@@ -20,7 +20,6 @@ import {
getUserAccount, getUserAccount,
} from '@openpanel/db'; } from '@openpanel/db';
import { sendEmail } from '@openpanel/email'; import { sendEmail } from '@openpanel/email';
import { deleteCache } from '@openpanel/redis';
import { import {
zRequestResetPassword, zRequestResetPassword,
zResetPassword, zResetPassword,
@@ -81,7 +80,7 @@ export const authRouter = createTRPCRouter({
.input(z.object({ provider: zProvider, inviteId: z.string().nullish() })) .input(z.object({ provider: zProvider, inviteId: z.string().nullish() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const isRegistrationAllowed = await getIsRegistrationAllowed( const isRegistrationAllowed = await getIsRegistrationAllowed(
input.inviteId, input.inviteId
); );
if (!isRegistrationAllowed) { if (!isRegistrationAllowed) {
@@ -137,7 +136,7 @@ export const authRouter = createTRPCRouter({
.input(zSignUpEmail) .input(zSignUpEmail)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const isRegistrationAllowed = await getIsRegistrationAllowed( const isRegistrationAllowed = await getIsRegistrationAllowed(
input.inviteId, input.inviteId
); );
if (!isRegistrationAllowed) { if (!isRegistrationAllowed) {
@@ -187,7 +186,7 @@ export const authRouter = createTRPCRouter({
rateLimitMiddleware({ rateLimitMiddleware({
max: 3, max: 3,
windowMs: 30_000, windowMs: 30_000,
}), })
) )
.input(zSignInEmail) .input(zSignInEmail)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -210,7 +209,7 @@ export const authRouter = createTRPCRouter({
if (user.account.password?.startsWith('$argon2')) { if (user.account.password?.startsWith('$argon2')) {
const validPassword = await verifyPasswordHash( const validPassword = await verifyPasswordHash(
user.account.password ?? '', user.account.password ?? '',
password, password
); );
if (!validPassword) { if (!validPassword) {
@@ -218,7 +217,7 @@ export const authRouter = createTRPCRouter({
} }
} else { } else {
throw TRPCAccessError( throw TRPCAccessError(
'Reset your password, old password has expired', 'Reset your password, old password has expired'
); );
} }
} }
@@ -226,6 +225,11 @@ export const authRouter = createTRPCRouter({
const token = generateSessionToken(); const token = generateSessionToken();
const session = await createSession(token, user.id); const session = await createSession(token, user.id);
setSessionTokenCookie(ctx.setCookie, token, session.expiresAt); setSessionTokenCookie(ctx.setCookie, token, session.expiresAt);
ctx.setCookie('last-auth-provider', 'email', {
maxAge: 60 * 60 * 24 * 365,
path: '/',
sameSite: 'lax',
});
return { return {
type: 'email', type: 'email',
}; };
@@ -237,7 +241,7 @@ export const authRouter = createTRPCRouter({
rateLimitMiddleware({ rateLimitMiddleware({
max: 3, max: 3,
windowMs: 60_000, windowMs: 60_000,
}), })
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { token, password } = input; const { token, password } = input;
@@ -275,7 +279,7 @@ export const authRouter = createTRPCRouter({
rateLimitMiddleware({ rateLimitMiddleware({
max: 3, max: 3,
windowMs: 60_000, windowMs: 60_000,
}), })
) )
.input(zRequestResetPassword) .input(zRequestResetPassword)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -324,7 +328,7 @@ export const authRouter = createTRPCRouter({
}), }),
extendSession: publicProcedure.mutation(async ({ ctx }) => { extendSession: publicProcedure.mutation(async ({ ctx }) => {
if (!ctx.session.session || !ctx.cookies.session) { if (!(ctx.session.session && ctx.cookies.session)) {
return { extended: false }; return { extended: false };
} }
@@ -348,7 +352,7 @@ export const authRouter = createTRPCRouter({
rateLimitMiddleware({ rateLimitMiddleware({
max: 3, max: 3,
windowMs: 30_000, windowMs: 30_000,
}), })
) )
.input(zSignInShare) .input(zSignInShare)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@@ -1,5 +1,4 @@
NODE_ENV="production" NODE_ENV="production"
VITE_SELF_HOSTED="true"
SELF_HOSTED="true" SELF_HOSTED="true"
BATCH_SIZE="5000" BATCH_SIZE="5000"
BATCH_INTERVAL="10000" BATCH_INTERVAL="10000"