This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-20 12:34:56 +01:00
parent 6e997e62f1
commit 56f1c5e894
16 changed files with 601 additions and 4 deletions

View File

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

View File

@@ -94,6 +94,7 @@ 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[]

View File

@@ -0,0 +1,104 @@
These emails have good bones but yeah, they have that ChatGPT sheen. The structure is too neat, the bullet points feel mechanical, and phrases like "really opens up" and "usually clicks" are dead giveaways.
Here's my pass at them:
---
**Email 1 - Welcome (Day 0)**
Subject: You're in
Hi,
Thanks for trying OpenPanel.
We built OpenPanel because most analytics tools are either too expensive, too complicated, or both. OpenPanel is different.
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 project settings.
If you can't find your provider just reach out and we'll help you out.
Reach out if you have any questions. I answer all emails.
Carl
---
**Email 2 - What to track (Day 2)**
Subject: What's actually worth tracking
Hi,
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.
For most products, that's something like:
- Signups
- The first meaningful action (create something, send something, buy something)
- Return visits
You don't need 50 events. Five good ones will tell you more than fifty random ones.
If you're not sure whether something's worth tracking, just ask. I'm happy to look at your setup.
Carl
---
**Email 3 - Dashboards (Day 6)**
Subject: The part most people skip
Hi,
Tracking events is the easy part. The value comes from actually looking at them.
If you haven't yet, try building a simple dashboard. Pick one thing you care about and visualize it. Could be:
- 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)
This is usually when people go from "I have analytics" to "I understand what's happening." It's a different feeling.
Takes maybe 10 minutes to set up. Worth it.
Carl
---
**Email 4 - Replace the stack (Day 14)**
Subject: One provider to rule them all
Hi,
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.
OpenPanel can replace that whole setup.
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.
OpenPanel should be able to replace all of them, you can just reach out if you feel like something is missing.
Carl
---
**Email 5 - Trial ending (Day 26)**
Subject: Your trial ends in a few days
Hi,
Quick heads up: your OpenPanel trial ends soon.
Your tracking will keep working, but you won't be able to see new data until you upgrade. Everything you've built so far (dashboards, reports, event history) stays intact.
If OpenPanel has been useful, upgrading just keeps it going. Plans start at $2.50/month and based on your usage we recommend {xxxx}.
If something's holding you back, I'd like to hear about it. Just reply.
Your project will recieve events for the next 30 days, if you haven't upgraded by then we'll remove your workspace and projects.
Carl

View File

@@ -4,6 +4,21 @@ 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 OnboardingTrialEnding, {
zOnboardingTrialEnding,
} from './onboarding-trial-ending';
export const templates = {
invite: {
@@ -24,6 +39,31 @@ export const templates = {
Component: TrailEndingSoon,
schema: zTrailEndingSoon,
},
'onboarding-welcome': {
subject: () => "You're in",
Component: OnboardingWelcome,
schema: zOnboardingWelcome,
},
'onboarding-what-to-track': {
subject: () => "What's actually worth tracking",
Component: OnboardingWhatToTrack,
schema: zOnboardingWhatToTrack,
},
'onboarding-dashboards': {
subject: () => 'The part most people skip',
Component: OnboardingDashboards,
schema: zOnboardingDashboards,
},
'onboarding-replace-stack': {
subject: () => 'One provider to rule them all',
Component: OnboardingReplaceStack,
schema: zOnboardingReplaceStack,
},
'onboarding-trial-ending': {
subject: () => 'Your trial ends in a few days',
Component: OnboardingTrialEnding,
schema: zOnboardingTrialEnding,
},
} as const;
export type Templates = typeof templates;

View File

@@ -0,0 +1,49 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingDashboards = z.object({
firstName: z.string().optional(),
dashboardUrl: z.string(),
});
export type Props = z.infer<typeof zOnboardingDashboards>;
export default OnboardingDashboards;
export function OnboardingDashboards({
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-dashboards');
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking events is the easy part. The value comes from actually looking
at them.
</Text>
<Text>
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>
<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>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,37 @@
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,66 @@
import { Button, Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingTrialEnding = z.object({
firstName: z.string().optional(),
organizationName: z.string(),
billingUrl: z.string(),
recommendedPlan: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingTrialEnding>;
export default OnboardingTrialEnding;
export function OnboardingTrialEnding({
firstName,
organizationName = 'your organization',
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-ending');
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Quick heads up: your OpenPanel trial ends soon.</Text>
<Text>
Your tracking will keep working, but you won't be able to see new data
until you upgrade. Everything you've built so far (dashboards, reports,
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}` : ''}
.
</Text>
<Text>
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>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,43 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingWelcome = z.object({
firstName: z.string().optional(),
dashboardUrl: z.string(),
});
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');
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.
</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>.
</Text>
<Text>
If you can't find your provider just reach out and we'll help you out.
</Text>
<Text>Reach out if you have any questions. I answer all emails.</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,41 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingWhatToTrack = z.object({
firstName: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
export default OnboardingWhatToTrack;
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.
</Text>
<Text>For most products, that's something like:</Text>
<Text>- Signups</Text>
<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.
</Text>
<Text>
If you're not sure whether something's worth tracking, just ask. I'm
happy to look at your setup.
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -115,6 +115,10 @@ export type CronQueuePayloadInsightsDaily = {
type: 'insightsDaily';
payload: undefined;
};
export type CronQueuePayloadOnboarding = {
type: 'onboarding';
payload: undefined;
};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
@@ -122,7 +126,8 @@ export type CronQueuePayload =
| CronQueuePayloadFlushProfiles
| CronQueuePayloadPing
| CronQueuePayloadProject
| CronQueuePayloadInsightsDaily;
| CronQueuePayloadInsightsDaily
| CronQueuePayloadOnboarding;
export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon';

View File

@@ -22,6 +22,13 @@ 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),
@@ -33,6 +40,14 @@ async function createOrGetOrganization(
},
});
// 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,