This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-21 08:25:32 +01:00
parent 56f1c5e894
commit a58761e8d7
29 changed files with 777 additions and 298 deletions

View File

@@ -11,6 +11,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRouteImport } from './routes/__root'
import { Route as UnsubscribeRouteImport } from './routes/unsubscribe'
import { Route as StepsRouteImport } from './routes/_steps'
import { Route as PublicRouteImport } from './routes/_public'
import { Route as LoginRouteImport } from './routes/_login'
@@ -102,6 +103,11 @@ const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/profiles/$profileId',
)()
const UnsubscribeRoute = UnsubscribeRouteImport.update({
id: '/unsubscribe',
path: '/unsubscribe',
getParentRoute: () => rootRouteImport,
} as any)
const StepsRoute = StepsRouteImport.update({
id: '/_steps',
getParentRoute: () => rootRouteImport,
@@ -525,6 +531,7 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/unsubscribe': typeof UnsubscribeRoute
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
'/login': typeof LoginLoginRoute
'/reset-password': typeof LoginResetPasswordRoute
@@ -591,6 +598,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/unsubscribe': typeof UnsubscribeRoute
'/login': typeof LoginLoginRoute
'/reset-password': typeof LoginResetPasswordRoute
'/onboarding': typeof PublicOnboardingRoute
@@ -653,6 +661,7 @@ export interface FileRoutesById {
'/_login': typeof LoginRouteWithChildren
'/_public': typeof PublicRouteWithChildren
'/_steps': typeof StepsRouteWithChildren
'/unsubscribe': typeof UnsubscribeRoute
'/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren
'/_login/login': typeof LoginLoginRoute
'/_login/reset-password': typeof LoginResetPasswordRoute
@@ -728,6 +737,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/unsubscribe'
| '/$organizationId'
| '/login'
| '/reset-password'
@@ -794,6 +804,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/unsubscribe'
| '/login'
| '/reset-password'
| '/onboarding'
@@ -855,6 +866,7 @@ export interface FileRouteTypes {
| '/_login'
| '/_public'
| '/_steps'
| '/unsubscribe'
| '/_app/$organizationId'
| '/_login/login'
| '/_login/reset-password'
@@ -933,6 +945,7 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRouteWithChildren
PublicRoute: typeof PublicRouteWithChildren
StepsRoute: typeof StepsRouteWithChildren
UnsubscribeRoute: typeof UnsubscribeRoute
ApiConfigRoute: typeof ApiConfigRoute
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
WidgetCounterRoute: typeof WidgetCounterRoute
@@ -945,6 +958,13 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/unsubscribe': {
id: '/unsubscribe'
path: '/unsubscribe'
fullPath: '/unsubscribe'
preLoaderRoute: typeof UnsubscribeRouteImport
parentRoute: typeof rootRouteImport
}
'/_steps': {
id: '/_steps'
path: ''
@@ -1872,6 +1892,7 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRouteWithChildren,
PublicRoute: PublicRouteWithChildren,
StepsRoute: StepsRouteWithChildren,
UnsubscribeRoute: UnsubscribeRoute,
ApiConfigRoute: ApiConfigRoute,
ApiHealthcheckRoute: ApiHealthcheckRoute,
WidgetCounterRoute: WidgetCounterRoute,

View File

@@ -0,0 +1,112 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LoginNavbar } from '@/components/login-navbar';
import { useTRPC } from '@/integrations/trpc/react';
import { emailCategories } from '@openpanel/constants';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { useState } from 'react';
import { z } from 'zod';
const unsubscribeSearchSchema = z.object({
email: z.string().email(),
category: z.string(),
token: z.string(),
});
export const Route = createFileRoute('/unsubscribe')({
component: RouteComponent,
validateSearch: unsubscribeSearchSchema,
pendingComponent: FullPageLoadingState,
});
function RouteComponent() {
const search = useSearch({ from: '/unsubscribe' });
const { email, category, token } = search;
const trpc = useTRPC();
const [isUnsubscribing, setIsUnsubscribing] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const unsubscribeMutation = trpc.email.unsubscribe.useMutation({
onSuccess: () => {
setIsSuccess(true);
setIsUnsubscribing(false);
},
onError: (err) => {
setError(err.message || 'Failed to unsubscribe');
setIsUnsubscribing(false);
},
});
const handleUnsubscribe = () => {
setIsUnsubscribing(true);
setError(null);
unsubscribeMutation.mutate({ email, category, token });
};
const categoryName =
emailCategories[category as keyof typeof emailCategories] || category;
if (isSuccess) {
return (
<div className="min-h-screen flex flex-col">
<LoginNavbar />
<div className="flex-1 center-center px-4">
<div className="max-w-md w-full text-center space-y-4">
<div className="text-6xl mb-4"></div>
<h1 className="text-2xl font-bold">Unsubscribed</h1>
<p className="text-muted-foreground">
You've been unsubscribed from {categoryName} emails.
</p>
<p className="text-sm text-muted-foreground">
You won't receive any more {categoryName.toLowerCase()} emails from
us.
</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col">
<LoginNavbar />
<div className="flex-1 center-center px-4">
<div className="max-w-md w-full space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Unsubscribe</h1>
<p className="text-muted-foreground">
Unsubscribe from {categoryName} emails?
</p>
<p className="text-sm text-muted-foreground">
You'll stop receiving {categoryName.toLowerCase()} emails sent to{' '}
<span className="font-mono text-xs">{email}</span>
</p>
</div>
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm">
{error}
</div>
)}
<div className="space-y-3">
<button
onClick={handleUnsubscribe}
disabled={isUnsubscribing}
className="w-full bg-black text-white py-3 px-4 rounded-md font-medium hover:bg-black/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
</button>
<a
href="/"
className="block text-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</a>
</div>
</div>
</div>
</div>
);
}

View File

@@ -20,6 +20,7 @@
"@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*",
"@openpanel/importer": "workspace:*",
"@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*",
"@openpanel/redis": "workspace:*",
"bullmq": "^5.63.0",

View File

@@ -1,180 +1,265 @@
import { differenceInDays } from 'date-fns';
import type { Job } from 'bullmq';
import { differenceInDays } from 'date-fns';
import { db } from '@openpanel/db';
import { sendEmail } from '@openpanel/email';
import {
type EmailData,
type EmailTemplate,
sendEmail,
} from '@openpanel/email';
import type { CronQueuePayload } from '@openpanel/queue';
import { getRecommendedPlan } from '@openpanel/payments';
import { logger } from '../utils/logger';
const EMAIL_SCHEDULE = {
1: 0, // Welcome email - Day 0
2: 2, // What to track - Day 2
3: 6, // Dashboards - Day 6
4: 14, // Replace stack - Day 14
5: 26, // Trial ending - Day 26
// Types for the onboarding email system
const orgQuery = {
include: {
createdBy: {
select: {
id: true,
email: true,
firstName: true,
deletedAt: true,
},
},
},
} as const;
type OrgWithCreator = Awaited<
ReturnType<typeof db.organization.findMany<typeof orgQuery>>
>[number];
type OnboardingContext = {
org: OrgWithCreator;
user: NonNullable<OrgWithCreator['createdBy']>;
};
type OnboardingEmail<T extends EmailTemplate = EmailTemplate> = {
day: number;
template: T;
shouldSend?: (ctx: OnboardingContext) => Promise<boolean | 'complete'>;
data: (ctx: OnboardingContext) => EmailData<T>;
};
// Helper to create type-safe email entries with correlated template/data types
function email<T extends EmailTemplate>(config: OnboardingEmail<T>) {
return config;
}
const getters = {
firstName: (ctx: OnboardingContext) => ctx.user.firstName || undefined,
organizationName: (ctx: OnboardingContext) => ctx.org.name,
dashboardUrl: (ctx: OnboardingContext) => {
return `${process.env.DASHBOARD_URL}/${ctx.org.id}`;
},
billingUrl: (ctx: OnboardingContext) => {
return `${process.env.DASHBOARD_URL}/${ctx.org.id}/billing`;
},
recommendedPlan: (ctx: OnboardingContext) => {
return getRecommendedPlan(
ctx.org.subscriptionPeriodEventsCount,
(plan) =>
`${plan.formattedEvents} events per month for ${plan.formattedPrice}`,
);
},
} as const;
// Declarative email schedule - easy to add, remove, or reorder
const ONBOARDING_EMAILS = [
email({
day: 0,
template: 'onboarding-welcome',
data: (ctx) => ({
firstName: getters.firstName(ctx),
dashboardUrl: getters.dashboardUrl(ctx),
}),
}),
email({
day: 2,
template: 'onboarding-what-to-track',
data: (ctx) => ({
firstName: getters.firstName(ctx),
}),
}),
email({
day: 6,
template: 'onboarding-dashboards',
data: (ctx) => ({
firstName: getters.firstName(ctx),
dashboardUrl: getters.dashboardUrl(ctx),
}),
}),
email({
day: 14,
template: 'onboarding-featue-request',
data: (ctx) => ({
firstName: getters.firstName(ctx),
}),
}),
email({
day: 26,
template: 'onboarding-trial-ending',
shouldSend: async ({ org }) => {
if (org.subscriptionStatus === 'active') {
return 'complete';
}
return true;
},
data: (ctx) => {
return {
firstName: getters.firstName(ctx),
organizationName: getters.organizationName(ctx),
billingUrl: getters.billingUrl(ctx),
recommendedPlan: getters.recommendedPlan(ctx),
};
},
}),
email({
day: 30,
template: 'onboarding-trial-ended',
shouldSend: async ({ org }) => {
if (org.subscriptionStatus === 'active') {
return 'complete';
}
return true;
},
data: (ctx) => {
return {
firstName: getters.firstName(ctx),
billingUrl: getters.billingUrl(ctx),
recommendedPlan: getters.recommendedPlan(ctx),
};
},
}),
];
export async function onboardingJob(job: Job<CronQueuePayload>) {
logger.info('Starting onboarding email job');
// Fetch organizations with their creators who are in onboarding
const organizations = await db.organization.findMany({
// Fetch organizations that are in onboarding (not completed)
const orgs = await db.organization.findMany({
where: {
createdByUserId: {
not: null,
onboarding: {
not: 'completed',
},
deleteAt: null,
createdBy: {
onboarding: {
not: null,
gte: 1,
lte: 5,
},
deletedAt: null,
},
},
include: {
createdBy: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
onboarding: true,
},
},
},
...orgQuery,
});
logger.info(`Found ${organizations.length} organizations with creators in onboarding`);
logger.info(`Found ${orgs.length} organizations in onboarding`);
let emailsSent = 0;
let usersCompleted = 0;
let usersSkipped = 0;
let orgsCompleted = 0;
let orgsSkipped = 0;
for (const org of organizations) {
if (!org.createdBy || !org.createdByUserId) {
for (const org of orgs) {
// Skip if no creator or creator is deleted
if (!org.createdBy || org.createdBy.deletedAt) {
orgsSkipped++;
continue;
}
const user = org.createdBy;
// Check if organization has active subscription
if (org.subscriptionStatus === 'active') {
// Stop onboarding for users with active subscriptions
await db.user.update({
where: { id: user.id },
data: { onboarding: null },
});
usersCompleted++;
logger.info(`Stopped onboarding for user ${user.id} (active subscription)`);
continue;
}
if (!user.onboarding || user.onboarding < 1 || user.onboarding > 5) {
continue;
}
// Use organization creation date instead of user registration date
const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt);
const requiredDays = EMAIL_SCHEDULE[user.onboarding as keyof typeof EMAIL_SCHEDULE];
if (daysSinceOrgCreation < requiredDays) {
usersSkipped++;
// Find the next email to send
// If org.onboarding is empty string, they haven't received any email yet
const lastSentIndex = org.onboarding
? ONBOARDING_EMAILS.findIndex((e) => e.template === org.onboarding)
: -1;
const nextEmailIndex = lastSentIndex + 1;
// No more emails to send
if (nextEmailIndex >= ONBOARDING_EMAILS.length) {
await db.organization.update({
where: { id: org.id },
data: { onboarding: 'completed' },
});
orgsCompleted++;
logger.info(
`Completed onboarding for organization ${org.id} (all emails sent)`,
);
continue;
}
const dashboardUrl = `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL || 'https://dashboard.openpanel.dev'}/${org.id}`;
const billingUrl = `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL || 'https://dashboard.openpanel.dev'}/${org.id}/billing`;
const nextEmail = ONBOARDING_EMAILS[nextEmailIndex];
if (!nextEmail) {
continue;
}
try {
// Send appropriate email based on onboarding step
switch (user.onboarding) {
case 1: {
// Welcome email
await sendEmail('onboarding-welcome', {
to: user.email,
data: {
firstName: user.firstName || undefined,
dashboardUrl,
},
});
break;
}
case 2: {
// What to track email
await sendEmail('onboarding-what-to-track', {
to: user.email,
data: {
firstName: user.firstName || undefined,
},
});
break;
}
case 3: {
// Dashboards email
await sendEmail('onboarding-dashboards', {
to: user.email,
data: {
firstName: user.firstName || undefined,
dashboardUrl,
},
});
break;
}
case 4: {
// Replace stack email
await sendEmail('onboarding-replace-stack', {
to: user.email,
data: {
firstName: user.firstName || undefined,
},
});
break;
}
case 5: {
// Trial ending email
await sendEmail('onboarding-trial-ending', {
to: user.email,
data: {
firstName: user.firstName || undefined,
organizationName: org.name,
billingUrl,
recommendedPlan: undefined, // TODO: Calculate based on usage
},
});
break;
}
// Check if enough days have passed
if (daysSinceOrgCreation < nextEmail.day) {
orgsSkipped++;
continue;
}
// Check shouldSend callback if defined
if (nextEmail.shouldSend) {
const result = await nextEmail.shouldSend({ org, user });
if (result === 'complete') {
await db.organization.update({
where: { id: org.id },
data: { onboarding: 'completed' },
});
orgsCompleted++;
logger.info(
`Completed onboarding for organization ${org.id} (shouldSend returned complete)`,
);
continue;
}
// Increment onboarding state
const nextOnboardingState = user.onboarding + 1;
await db.user.update({
where: { id: user.id },
data: {
onboarding: nextOnboardingState > 5 ? null : nextOnboardingState,
},
if (result === false) {
orgsSkipped++;
continue;
}
}
try {
const emailData = nextEmail.data({ org, user });
await sendEmail(nextEmail.template, {
to: user.email,
data: emailData as never,
});
// Update onboarding to the template name we just sent
await db.organization.update({
where: { id: org.id },
data: { onboarding: nextEmail.template },
});
emailsSent++;
logger.info(`Sent onboarding email ${user.onboarding} to user ${user.id} for org ${org.id}`);
if (nextOnboardingState > 5) {
usersCompleted++;
}
logger.info(
`Sent onboarding email "${nextEmail.template}" to organization ${org.id} (user ${user.id})`,
);
} catch (error) {
logger.error(`Failed to send onboarding email to user ${user.id}`, {
error,
onboardingStep: user.onboarding,
organizationId: org.id,
});
logger.error(
`Failed to send onboarding email to organization ${org.id}`,
{
error,
template: nextEmail.template,
},
);
}
}
logger.info('Completed onboarding email job', {
totalOrganizations: organizations.length,
totalOrgs: orgs.length,
emailsSent,
usersCompleted,
usersSkipped,
orgsCompleted,
orgsSkipped,
});
return {
totalOrgs: orgs.length,
emailsSent,
orgsCompleted,
orgsSkipped,
};
}

View File

@@ -508,6 +508,13 @@ export function getCountry(code?: string) {
return countries[code as keyof typeof countries];
}
export const emailCategories = {
onboarding: 'Onboarding',
billing: 'Billing',
} as const;
export type EmailCategory = keyof typeof emailCategories;
export const chartColors = [
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
{ main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' },

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "public"."users" ADD COLUMN "onboarding" INTEGER;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."organizations"
ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed';

View File

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "public"."email_unsubscribes" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"email" TEXT NOT NULL,
"category" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "email_unsubscribes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "email_unsubscribes_email_category_key" ON "public"."email_unsubscribes"("email", "category");

View File

@@ -62,6 +62,7 @@ model Organization {
integrations Integration[]
invites Invite[]
timezone String?
onboarding String @default("completed") // 'completed' or template name for next email
// Subscription
subscriptionId String?
@@ -94,7 +95,6 @@ model User {
email String @unique
firstName String?
lastName String?
onboarding Int? // null = disabled/completed, 1-5 = next email step
createdOrganizations Organization[] @relation("organizationCreatedBy")
subscriptions Organization[] @relation("subscriptionCreatedBy")
membership Member[]
@@ -611,3 +611,13 @@ model InsightEvent {
@@index([insightId, createdAt])
@@map("insight_events")
}
model EmailUnsubscribe {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
email String
category String
createdAt DateTime @default(now())
@@unique([email, category])
@@map("email_unsubscribes")
}

View File

@@ -8,6 +8,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/db": "workspace:*",
"@react-email/components": "^0.5.6",
"react": "catalog:",
"react-dom": "catalog:",

View File

@@ -0,0 +1,23 @@
import { Button as EmailButton } from '@react-email/components';
export function Button({
href,
children,
style,
}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) {
return (
<EmailButton
href={href}
style={{
backgroundColor: '#000',
borderRadius: '6px',
color: '#fff',
padding: '12px 20px',
textDecoration: 'none',
...style,
}}
>
{children}
</EmailButton>
);
}

View File

@@ -0,0 +1,13 @@
import { Text } from '@react-email/components';
export function List({ items }: { items: React.ReactNode[] }) {
return (
<ul style={{ paddingLeft: 20 }}>
{items.map((node, index) => (
<li key={index.toString()}>
<Text style={{ marginBottom: 2, marginTop: 2 }}>{node}</Text>
</li>
))}
</ul>
);
}

View File

@@ -3,22 +3,23 @@ import { EmailInvite, zEmailInvite } from './email-invite';
import EmailResetPassword, {
zEmailResetPassword,
} from './email-reset-password';
import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon';
import OnboardingWelcome, {
zOnboardingWelcome,
} from './onboarding-welcome';
import OnboardingWhatToTrack, {
zOnboardingWhatToTrack,
} from './onboarding-what-to-track';
import OnboardingDashboards, {
zOnboardingDashboards,
} from './onboarding-dashboards';
import OnboardingReplaceStack, {
zOnboardingReplaceStack,
} from './onboarding-replace-stack';
import OnboardingFeatureRequest, {
zOnboardingFeatureRequest,
} from './onboarding-feature-request';
import OnboardingTrialEnded, {
zOnboardingTrialEnded,
} from './onboarding-trial-ended';
import OnboardingTrialEnding, {
zOnboardingTrialEnding,
} from './onboarding-trial-ending';
import OnboardingWelcome, { zOnboardingWelcome } from './onboarding-welcome';
import OnboardingWhatToTrack, {
zOnboardingWhatToTrack,
} from './onboarding-what-to-track';
import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon';
export const templates = {
invite: {
@@ -38,31 +39,43 @@ export const templates = {
'Your trial is ending soon',
Component: TrailEndingSoon,
schema: zTrailEndingSoon,
category: 'billing' as const,
},
'onboarding-welcome': {
subject: () => "You're in",
Component: OnboardingWelcome,
schema: zOnboardingWelcome,
category: 'onboarding' as const,
},
'onboarding-what-to-track': {
subject: () => "What's actually worth tracking",
Component: OnboardingWhatToTrack,
schema: zOnboardingWhatToTrack,
category: 'onboarding' as const,
},
'onboarding-dashboards': {
subject: () => 'The part most people skip',
Component: OnboardingDashboards,
schema: zOnboardingDashboards,
category: 'onboarding' as const,
},
'onboarding-replace-stack': {
'onboarding-featue-request': {
subject: () => 'One provider to rule them all',
Component: OnboardingReplaceStack,
schema: zOnboardingReplaceStack,
Component: OnboardingFeatureRequest,
schema: zOnboardingFeatureRequest,
category: 'onboarding' as const,
},
'onboarding-trial-ending': {
subject: () => 'Your trial ends in a few days',
Component: OnboardingTrialEnding,
schema: zOnboardingTrialEnding,
category: 'onboarding' as const,
},
'onboarding-trial-ended': {
subject: () => 'Your trial has ended',
Component: OnboardingTrialEnded,
schema: zOnboardingTrialEnded,
category: 'onboarding' as const,
},
} as const;

View File

@@ -2,6 +2,7 @@ import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
export const zOnboardingDashboards = z.object({
firstName: z.string().optional(),
@@ -30,20 +31,34 @@ export function OnboardingDashboards({
If you haven't yet, try building a simple dashboard. Pick one thing you
care about and visualize it. Could be:
</Text>
<Text>
- How many people sign up and then actually do something
</Text>
<Text>- Where users drop off in a flow (funnel)</Text>
<Text>- Which pages lead to conversions (entry page → CTA)</Text>
<List
items={[
'How many people sign up and then actually do something',
'Where users drop off in a flow (funnel)',
'Which pages lead to conversions (entry page CTA)',
]}
/>
<Text>
This is usually when people go from "I have analytics" to "I understand
what's happening." It's a different feeling.
</Text>
<Text>Takes maybe 10 minutes to set up. Worth it.</Text>
<Text>
<Link href={newUrl.toString()}>Create your first dashboard</Link>
Best regards,
<br />
Carl
</Text>
<Text>Carl</Text>
<span style={{ margin: '0 -20px', display: 'block' }}>
<img
src="https://openpanel.dev/_next/image?url=%2Fscreenshots%2Fdashboard-dark.webp&w=3840&q=75"
alt="Dashboard"
style={{
width: '100%',
height: 'auto',
borderRadius: '5px',
}}
/>
</span>
</Layout>
);
}

View File

@@ -0,0 +1,39 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingFeatureRequest = z.object({
firstName: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingFeatureRequest>;
export default OnboardingFeatureRequest;
export function OnboardingFeatureRequest({ firstName }: Props) {
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
OpenPanel aims to be the one stop shop for all your analytics needs.
</Text>
<Text>
We have already in a very short time become one of the most popular
open-source analytics platforms out there and we're working hard to add
more features to make it the best analytics platform.
</Text>
<Text>
Do you feel like you're missing a feature that's important to you? If
that's the case, please reply here or go to our feedback board and add
your request there.
</Text>
<Text>
<Link href={'https://feedback.openpanel.dev'}>Feedback board</Link>
</Text>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

View File

@@ -1,37 +0,0 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingReplaceStack = z.object({
firstName: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingReplaceStack>;
export default OnboardingReplaceStack;
export function OnboardingReplaceStack({
firstName,
}: Props) {
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
A lot of people who sign up are using multiple tools: something for
traffic, something for product analytics and something else for seeing
raw events.
</Text>
<Text>OpenPanel can replace that whole setup.</Text>
<Text>
If you're still thinking of web analytics and product analytics as
separate things, try combining them in a single dashboard. Traffic
sources on top, user behavior below. That view tends to be more useful
than either one alone.
</Text>
<Text>
OpenPanel should be able to replace all of them, you can just reach out
if you feel like something is missing.
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,55 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Button } from '../components/button';
import { Layout } from '../components/layout';
export const zOnboardingTrialEnded = z.object({
firstName: z.string().optional(),
billingUrl: z.string(),
recommendedPlan: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingTrialEnded>;
export default OnboardingTrialEnded;
export function OnboardingTrialEnded({
firstName,
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
}: Props) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ended');
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Your OpenPanel trial has ended.</Text>
<Text>
Your tracking is still running in the background, but you won't be able
to see any new data until you upgrade. All your dashboards, reports, and
event history are still there waiting for you.
</Text>
<Text>
Important: If you don't upgrade within 30 days, your workspace and
projects will be permanently deleted.
</Text>
<Text>
To keep your data and continue using OpenPanel, upgrade to a paid plan.{' '}
{recommendedPlan
? `Based on your usage we recommend upgrading to the ${recommendedPlan}`
: 'Plans start at $2.50/month'}
.
</Text>
<Text>
If you have any questions or something's holding you back, just reply to
this email.
</Text>
<Text>
<Button href={newUrl.toString()}>Upgrade Now</Button>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -1,6 +1,7 @@
import { Button, Link, Text } from '@react-email/components';
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Button } from '../components/button';
import { Layout } from '../components/layout';
export const zOnboardingTrialEnding = z.object({
@@ -33,32 +34,21 @@ export function OnboardingTrialEnding({
event history) stays intact.
</Text>
<Text>
If OpenPanel has been useful, upgrading just keeps it going. Plans
start at $2.50/month
{recommendedPlan ? ` and based on your usage we recommend ${recommendedPlan}` : ''}
To continue using OpenPanel, you'll need to upgrade to a paid plan.{' '}
{recommendedPlan
? `Based on your usage we recommend upgrading to the ${recommendedPlan} plan`
: 'Plans start at $2.50/month'}
.
</Text>
<Text>
If something's holding you back, I'd like to hear about it. Just
reply.
If something's holding you back, I'd like to hear about it. Just reply.
</Text>
<Text>
Your project will recieve events for the next 30 days, if you haven't
upgraded by then we'll remove your workspace and projects.
</Text>
<Text>
<Button
href={newUrl.toString()}
style={{
backgroundColor: '#0070f3',
color: 'white',
padding: '12px 20px',
borderRadius: '5px',
textDecoration: 'none',
}}
>
Upgrade Now
</Button>
<Button href={newUrl.toString()}>Upgrade Now</Button>
</Text>
<Text>Carl</Text>
</Layout>

View File

@@ -1,7 +1,8 @@
import { Link, Text } from '@react-email/components';
import { Heading, Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
export const zOnboardingWelcome = z.object({
firstName: z.string().optional(),
@@ -10,34 +11,42 @@ export const zOnboardingWelcome = z.object({
export type Props = z.infer<typeof zOnboardingWelcome>;
export default OnboardingWelcome;
export function OnboardingWelcome({
firstName,
dashboardUrl = 'https://dashboard.openpanel.dev',
}: Props) {
const newUrl = new URL(dashboardUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-welcome');
export function OnboardingWelcome({ firstName }: Props) {
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Thanks for trying OpenPanel.</Text>
<Text>
We built OpenPanel because most analytics tools are either too expensive,
too complicated, or both. OpenPanel is different.
We built OpenPanel because most analytics tools are either too
expensive, too complicated, or both. OpenPanel is different.
</Text>
<Text>
If you already have setup your tracking you should see your dashboard
getting filled up. If you come from another provider and want to import
your old events you can do that in our{' '}
<Link href={newUrl.toString()}>project settings</Link>.
We hope you find OpenPanel useful and if you have any questions,
regarding tracking or how to import your existing events, just reach
out. We're here to help.
</Text>
<Text>To get started, you can:</Text>
<List
items={[
<Link
key=""
href={'https://openpanel.dev/docs/get-started/install-openpanel'}
>
Install tracking script
</Link>,
<Link
key=""
href={'https://openpanel.dev/docs/get-started/track-events'}
>
Start tracking your events
</Link>,
]}
/>
<Text>
If you can't find your provider just reach out and we'll help you out.
Best regards,
<br />
Carl
</Text>
<Text>Reach out if you have any questions. I answer all emails.</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -2,6 +2,7 @@ import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
export const zOnboardingWhatToTrack = z.object({
firstName: z.string().optional(),
@@ -9,33 +10,34 @@ export const zOnboardingWhatToTrack = z.object({
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
export default OnboardingWhatToTrack;
export function OnboardingWhatToTrack({
firstName,
}: Props) {
export function OnboardingWhatToTrack({ firstName }: Props) {
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Track the moments that tell you whether your product is working. Track
things that matters to your product the most and then you can easily
create funnels or conversions reports to understand what happening.
Tracking can be overwhelming at first, and that's why its important to
focus on what's matters. For most products, that's something like:
</Text>
<Text>For most products, that's something like:</Text>
<Text>- Signups</Text>
<List
items={[
'Find good funnels to track (onboarding or checkout)',
'Conversions (how many clicks your hero CTA)',
'What did the user do after clicking the CTA',
]}
/>
<Text>
- The first meaningful action (create something, send something, buy
something)
</Text>
<Text>- Return visits</Text>
<Text>
You don't need 50 events. Five good ones will tell you more than fifty
random ones.
Start small and incrementally add more events as you go is usually the
best approach.
</Text>
<Text>
If you're not sure whether something's worth tracking, just ask. I'm
happy to look at your setup.
If you're not sure whether something's worth tracking, or have any
questions, just reply here.
</Text>
<Text>
Best regards,
<br />
Carl
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -2,34 +2,72 @@ import React from 'react';
import { Resend } from 'resend';
import type { z } from 'zod';
import { db } from '@openpanel/db';
import { type TemplateKey, type Templates, templates } from './emails';
import { getUnsubscribeUrl } from './unsubscribe';
export * from './unsubscribe';
const FROM = process.env.EMAIL_SENDER ?? 'hello@openpanel.dev';
export type EmailData<T extends TemplateKey> = z.infer<Templates[T]['schema']>;
export type EmailTemplate = keyof Templates;
export async function sendEmail<T extends TemplateKey>(
template: T,
templateKey: T,
options: {
to: string | string[];
to: string;
data: z.infer<Templates[T]['schema']>;
},
) {
const { to, data } = options;
const { subject, Component, schema } = templates[template];
const props = schema.safeParse(data);
const template = templates[templateKey];
const props = template.schema.safeParse(data);
if (!props.success) {
console.error('Failed to parse data', props.error);
return null;
}
// Check if user has unsubscribed from this category (only for non-transactional emails)
if ('category' in template && template.category) {
const unsubscribed = await db.emailUnsubscribe.findUnique({
where: {
email_category: {
email: to,
category: template.category,
},
},
});
if (unsubscribed) {
console.log(
`Skipping email to ${to} - unsubscribed from ${template.category}`,
);
return null;
}
}
if (!process.env.RESEND_API_KEY) {
console.log('No RESEND_API_KEY found, here is the data');
console.log(data);
console.log('Template:', template);
// @ts-expect-error - TODO: fix this
console.log('Subject: ', subject(props.data));
console.log('To: ', to);
console.log('Data: ', JSON.stringify(data, null, 2));
return null;
}
const resend = new Resend(process.env.RESEND_API_KEY);
// Build headers for unsubscribe (only for non-transactional emails)
const headers: Record<string, string> = {};
if ('category' in template && template.category) {
const unsubscribeUrl = getUnsubscribeUrl(to, template.category);
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}
try {
const res = await resend.emails.send({
from: FROM,
@@ -38,12 +76,11 @@ export async function sendEmail<T extends TemplateKey>(
subject: subject(props.data),
// @ts-expect-error - TODO: fix this
react: <Component {...props.data} />,
headers: Object.keys(headers).length > 0 ? headers : undefined,
});
if (res.error) {
throw new Error(res.error.message);
}
return res;
} catch (error) {
console.error('Failed to send email', error);

View File

@@ -0,0 +1,28 @@
import { createHmac } from 'crypto';
const SECRET =
process.env.UNSUBSCRIBE_SECRET ||
process.env.COOKIE_SECRET ||
process.env.SECRET ||
'default-secret-change-in-production';
export function generateUnsubscribeToken(email: string, category: string): string {
const data = `${email}:${category}`;
return createHmac('sha256', SECRET).update(data).digest('hex');
}
export function verifyUnsubscribeToken(
email: string,
category: string,
token: string,
): boolean {
const expectedToken = generateUnsubscribeToken(email, category);
return token === expectedToken;
}
export function getUnsubscribeUrl(email: string, category: string): string {
const token = generateUnsubscribeToken(email, category);
const params = new URLSearchParams({ email, category, token });
const dashboardUrl = process.env.DASHBOARD_URL || 'http://localhost:3000';
return `${dashboardUrl}/unsubscribe?${params.toString()}`;
}

View File

@@ -1,5 +1,11 @@
export type { ProductPrice } from '@polar-sh/sdk/models/components/productprice.js';
function formatEventsCount(events: number) {
return new Intl.NumberFormat('en-gb', {
notation: 'compact',
}).format(events);
}
export type IPrice = {
price: number;
events: number;
@@ -39,3 +45,29 @@ export const FREE_PRODUCT_IDS = [
'a18b4bee-d3db-4404-be6f-fba2f042d9ed', // Prod
'036efa2a-b3b4-4c75-b24a-9cac6bb8893b', // Sandbox
];
export function getRecommendedPlan<T>(
monthlyEvents: number | undefined | null,
cb: (
options: {
formattedEvents: string;
formattedPrice: string;
} & IPrice,
) => T,
): T | undefined {
if (!monthlyEvents) {
return undefined;
}
const price = PRICING.find((price) => price.events >= monthlyEvents);
if (!price) {
return undefined;
}
return cb({
...price,
formattedEvents: formatEventsCount(price.events),
formattedPrice: Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price.price),
});
}

View File

@@ -259,18 +259,3 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
},
},
);
export function addTrialEndingSoonJob(organizationId: string, delay: number) {
return miscQueue.add(
'misc',
{
type: 'trialEndingSoon',
payload: {
organizationId,
},
},
{
delay,
},
);
}

View File

@@ -3,6 +3,7 @@ import { chartRouter } from './routers/chart';
import { chatRouter } from './routers/chat';
import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard';
import { emailRouter } from './routers/email';
import { eventRouter } from './routers/event';
import { importRouter } from './routers/import';
import { insightRouter } from './routers/insight';
@@ -51,6 +52,7 @@ export const appRouter = createTRPCRouter({
chat: chatRouter,
insight: insightRouter,
widget: widgetRouter,
email: emailRouter,
});
// export type definition of API

View File

@@ -353,7 +353,6 @@ export const authRouter = createTRPCRouter({
.input(zSignInShare)
.mutation(async ({ input, ctx }) => {
const { password, shareId, shareType = 'overview' } = input;
let share: { password: string | null; public: boolean } | null = null;
let cookieName = '';

View File

@@ -0,0 +1,40 @@
import { z } from 'zod';
import { db } from '@openpanel/db';
import { verifyUnsubscribeToken } from '@openpanel/email';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const emailRouter = createTRPCRouter({
unsubscribe: publicProcedure
.input(
z.object({
email: z.string().email(),
category: z.string(),
token: z.string(),
}),
)
.mutation(async ({ input }) => {
const { email, category, token } = input;
// Verify token
if (!verifyUnsubscribeToken(email, category, token)) {
throw new Error('Invalid unsubscribe link');
}
// Upsert the unsubscribe record
await db.emailUnsubscribe.upsert({
where: {
email_category: {
email,
category,
},
},
create: {
email,
category,
},
update: {},
});
return { success: true };
}),
});

View File

@@ -8,7 +8,6 @@ import { zOnboardingProject } from '@openpanel/validation';
import { hashPassword } from '@openpanel/common/server';
import { addDays } from 'date-fns';
import { addTrialEndingSoonJob, miscQueue } from '../../../queue';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
async function createOrGetOrganization(
@@ -22,13 +21,6 @@ async function createOrGetOrganization(
const TRIAL_DURATION_IN_DAYS = 30;
if (input.organization) {
// Check if this is the user's first organization
const existingOrgCount = await db.organization.count({
where: {
createdByUserId: user.id,
},
});
const organization = await db.organization.create({
data: {
id: await getId('organization', input.organization),
@@ -37,24 +29,10 @@ async function createOrGetOrganization(
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
subscriptionStatus: 'trialing',
timezone: input.timezone,
onboarding: 'onboarding-welcome',
},
});
// Set onboarding = 1 for first organization creation
if (existingOrgCount === 0 && user.onboarding === null) {
await db.user.update({
where: { id: user.id },
data: { onboarding: 1 },
});
}
if (!process.env.SELF_HOSTED) {
await addTrialEndingSoonJob(
organization.id,
1000 * 60 * 60 * 24 * TRIAL_DURATION_IN_DAYS * 0.9,
);
}
return organization;
}

12
pnpm-lock.yaml generated
View File

@@ -37,7 +37,7 @@ overrides:
patchedDependencies:
nuqs:
hash: w6thjv3pgywfrbh4sblczc6qpy
hash: 4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e
path: patches/nuqs.patch
importers:
@@ -656,7 +656,7 @@ importers:
version: 3.0.1
nuqs:
specifier: ^2.5.2
version: 2.5.2(patch_hash=w6thjv3pgywfrbh4sblczc6qpy)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
version: 2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
prisma-error-enum:
specifier: ^0.1.3
version: 0.1.3
@@ -880,6 +880,9 @@ importers:
'@openpanel/logger':
specifier: workspace:*
version: link:../../packages/logger
'@openpanel/payments':
specifier: workspace:*
version: link:../../packages/payments
'@openpanel/queue':
specifier: workspace:*
version: link:../../packages/queue
@@ -1136,6 +1139,9 @@ importers:
packages/email:
dependencies:
'@openpanel/db':
specifier: workspace:*
version: link:../db
'@react-email/components':
specifier: ^0.5.6
version: 0.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -33178,7 +33184,7 @@ snapshots:
dependencies:
esm-env: esm-env-runtime@0.1.1
nuqs@2.5.2(patch_hash=w6thjv3pgywfrbh4sblczc6qpy)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
nuqs@2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
dependencies:
'@standard-schema/spec': 1.0.0
react: 19.2.3