feature: onboarding emails
* wip * wip * wip * fix coderabbit comments * remove template
This commit is contained in:
committed by
GitHub
parent
67301d928c
commit
e645c094b2
@@ -3,10 +3,7 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns';
|
||||
export const DEFAULT_ASPECT_RATIO = 0.5625;
|
||||
export const NOT_SET_VALUE = '(not set)';
|
||||
|
||||
export const RESERVED_EVENT_NAMES = [
|
||||
'session_start',
|
||||
'session_end',
|
||||
] as const;
|
||||
export const RESERVED_EVENT_NAMES = ['session_start', 'session_end'] as const;
|
||||
|
||||
export const timeWindows = {
|
||||
'30min': {
|
||||
@@ -508,6 +505,12 @@ export function getCountry(code?: string) {
|
||||
return countries[code as keyof typeof countries];
|
||||
}
|
||||
|
||||
export const emailCategories = {
|
||||
onboarding: 'Onboarding',
|
||||
} 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)' },
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."organizations"
|
||||
ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed';
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;
|
||||
@@ -62,6 +62,7 @@ model Organization {
|
||||
integrations Integration[]
|
||||
invites Invite[]
|
||||
timezone String?
|
||||
onboarding String? @default("completed")
|
||||
|
||||
// Subscription
|
||||
subscriptionId String?
|
||||
@@ -610,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")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@react-email/components": "^0.5.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
24
packages/email/src/components/button.tsx
Normal file
24
packages/email/src/components/button.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Button as EmailButton } from '@react-email/components';
|
||||
import type * as React from 'react';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import React from 'react';
|
||||
|
||||
const baseUrl = 'https://openpanel.dev';
|
||||
|
||||
export function Footer() {
|
||||
export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<>
|
||||
<Hr />
|
||||
@@ -71,15 +71,17 @@ export function Footer() {
|
||||
</Text>
|
||||
</Row>
|
||||
|
||||
{/* <Row>
|
||||
<Link
|
||||
className="text-[#707070] text-[14px]"
|
||||
href="https://dashboard.openpanel.dev/settings/notifications"
|
||||
title="Unsubscribe"
|
||||
>
|
||||
Notification preferences
|
||||
</Link>
|
||||
</Row> */}
|
||||
{unsubscribeUrl && (
|
||||
<Row>
|
||||
<Link
|
||||
className="text-[#707070] text-[14px]"
|
||||
href={unsubscribeUrl}
|
||||
title="Unsubscribe"
|
||||
>
|
||||
Notification preferences
|
||||
</Link>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
// biome-ignore lint/style/useImportType: resend needs React
|
||||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { Footer } from './footer';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
unsubscribeUrl?: string;
|
||||
};
|
||||
|
||||
export function Layout({ children }: Props) {
|
||||
export function Layout({ children, unsubscribeUrl }: Props) {
|
||||
return (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
@@ -57,7 +57,7 @@ export function Layout({ children }: Props) {
|
||||
/>
|
||||
</Section>
|
||||
<Section className="p-6">{children}</Section>
|
||||
<Footer />
|
||||
<Footer unsubscribeUrl={unsubscribeUrl} />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
13
packages/email/src/components/list.tsx
Normal file
13
packages/email/src/components/list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,10 @@ export default EmailInvite;
|
||||
export function EmailInvite({
|
||||
organizationName = 'Acme Co',
|
||||
url = 'https://openpanel.dev',
|
||||
}: Props) {
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>You've been invited to join {organizationName}!</Text>
|
||||
<Text>
|
||||
If you don't have an account yet, click the button below to create one
|
||||
|
||||
@@ -9,9 +9,12 @@ export const zEmailResetPassword = z.object({
|
||||
|
||||
export type Props = z.infer<typeof zEmailResetPassword>;
|
||||
export default EmailResetPassword;
|
||||
export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
|
||||
export function EmailResetPassword({
|
||||
url = 'https://openpanel.dev',
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>
|
||||
You have requested to reset your password. Follow the link below to
|
||||
reset your password:
|
||||
|
||||
@@ -3,6 +3,22 @@ import { EmailInvite, zEmailInvite } from './email-invite';
|
||||
import EmailResetPassword, {
|
||||
zEmailResetPassword,
|
||||
} from './email-reset-password';
|
||||
import OnboardingDashboards, {
|
||||
zOnboardingDashboards,
|
||||
} from './onboarding-dashboards';
|
||||
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 = {
|
||||
@@ -24,6 +40,40 @@ export const templates = {
|
||||
Component: TrailEndingSoon,
|
||||
schema: zTrailEndingSoon,
|
||||
},
|
||||
'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-feature-request': {
|
||||
subject: () => 'One provider to rule them all',
|
||||
Component: OnboardingFeatureRequest,
|
||||
schema: zOnboardingFeatureRequest,
|
||||
category: 'onboarding' as const,
|
||||
},
|
||||
'onboarding-trial-ending': {
|
||||
subject: () => 'Your trial ends in a few days',
|
||||
Component: OnboardingTrialEnding,
|
||||
schema: zOnboardingTrialEnding,
|
||||
},
|
||||
'onboarding-trial-ended': {
|
||||
subject: () => 'Your trial has ended',
|
||||
Component: OnboardingTrialEnded,
|
||||
schema: zOnboardingTrialEnded,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Templates = typeof templates;
|
||||
|
||||
65
packages/email/src/emails/onboarding-dashboards.tsx
Normal file
65
packages/email/src/emails/onboarding-dashboards.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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(),
|
||||
dashboardUrl: z.string(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingDashboards>;
|
||||
export default OnboardingDashboards;
|
||||
export function OnboardingDashboards({
|
||||
firstName,
|
||||
dashboardUrl = 'https://dashboard.openpanel.dev',
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
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 unsubscribeUrl={unsubscribeUrl}>
|
||||
<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>
|
||||
<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>
|
||||
Best regards,
|
||||
<br />
|
||||
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>
|
||||
);
|
||||
}
|
||||
42
packages/email/src/emails/onboarding-feature-request.tsx
Normal file
42
packages/email/src/emails/onboarding-feature-request.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
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,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
56
packages/email/src/emails/onboarding-trial-ended.tsx
Normal file
56
packages/email/src/emails/onboarding-trial-ended.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
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 unsubscribeUrl={unsubscribeUrl}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
57
packages/email/src/emails/onboarding-trial-ending.tsx
Normal file
57
packages/email/src/emails/onboarding-trial-ending.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
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({
|
||||
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,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
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 unsubscribeUrl={unsubscribeUrl}>
|
||||
<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>
|
||||
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.
|
||||
</Text>
|
||||
<Text>
|
||||
Your project will receive 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()}>Upgrade Now</Button>
|
||||
</Text>
|
||||
<Text>Carl</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
55
packages/email/src/emails/onboarding-welcome.tsx
Normal file
55
packages/email/src/emails/onboarding-welcome.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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(),
|
||||
dashboardUrl: z.string(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingWelcome>;
|
||||
export default OnboardingWelcome;
|
||||
export function OnboardingWelcome({
|
||||
firstName,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<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>
|
||||
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>
|
||||
Best regards,
|
||||
<br />
|
||||
Carl
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
46
packages/email/src/emails/onboarding-what-to-track.tsx
Normal file
46
packages/email/src/emails/onboarding-what-to-track.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
|
||||
export default OnboardingWhatToTrack;
|
||||
export function OnboardingWhatToTrack({
|
||||
firstName,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>
|
||||
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>
|
||||
<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>
|
||||
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, or have any
|
||||
questions, just reply here.
|
||||
</Text>
|
||||
<Text>
|
||||
Best regards,
|
||||
<br />
|
||||
Carl
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ export default TrailEndingSoon;
|
||||
export function TrailEndingSoon({
|
||||
organizationName = 'Acme Co',
|
||||
url = 'https://openpanel.dev',
|
||||
}: Props) {
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
const newUrl = new URL(url);
|
||||
newUrl.searchParams.set('utm_source', 'email');
|
||||
newUrl.searchParams.set('utm_medium', 'email');
|
||||
|
||||
@@ -2,48 +2,81 @@ 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;
|
||||
}
|
||||
|
||||
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);
|
||||
console.log('Subject: ', template.subject(props.data as any));
|
||||
console.log('To: ', to);
|
||||
console.log('Data: ', JSON.stringify(data, null, 2));
|
||||
return null;
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if ('category' in template && template.category) {
|
||||
const unsubscribeUrl = getUnsubscribeUrl(to, template.category);
|
||||
(props.data as any).unsubscribeUrl = unsubscribeUrl;
|
||||
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
|
||||
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await resend.emails.send({
|
||||
from: FROM,
|
||||
to,
|
||||
// @ts-expect-error - TODO: fix this
|
||||
subject: subject(props.data),
|
||||
// @ts-expect-error - TODO: fix this
|
||||
react: <Component {...props.data} />,
|
||||
subject: template.subject(props.data as any),
|
||||
react: <template.Component {...(props.data as any)} />,
|
||||
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);
|
||||
|
||||
39
packages/email/src/unsubscribe.ts
Normal file
39
packages/email/src/unsubscribe.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createHmac, timingSafeEqual } 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);
|
||||
const tokenBuffer = Buffer.from(token, 'hex');
|
||||
const expectedBuffer = Buffer.from(expectedToken, 'hex');
|
||||
|
||||
// Handle length mismatch safely to avoid timing leaks
|
||||
if (tokenBuffer.length !== expectedBuffer.length) {
|
||||
// Compare against zero-filled buffer of same length as token to maintain constant time
|
||||
const zeroBuffer = Buffer.alloc(tokenBuffer.length);
|
||||
timingSafeEqual(tokenBuffer, zeroBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(tokenBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -254,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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
114
packages/trpc/src/routers/email.ts
Normal file
114
packages/trpc/src/routers/email.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { emailCategories } from '@openpanel/constants';
|
||||
import { db } from '@openpanel/db';
|
||||
import { verifyUnsubscribeToken } from '@openpanel/email';
|
||||
import { z } from 'zod';
|
||||
import { TRPCBadRequestError } from '../errors';
|
||||
import { createTRPCRouter, protectedProcedure, 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 TRPCBadRequestError('Invalid unsubscribe link');
|
||||
}
|
||||
|
||||
// Upsert the unsubscribe record
|
||||
await db.emailUnsubscribe.upsert({
|
||||
where: {
|
||||
email_category: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getPreferences: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (!ctx.session.userId || !ctx.session.user?.email) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const email = ctx.session.user.email;
|
||||
|
||||
// Get all unsubscribe records for this user
|
||||
const unsubscribes = await db.emailUnsubscribe.findMany({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
category: true,
|
||||
},
|
||||
});
|
||||
|
||||
const unsubscribedCategories = new Set(unsubscribes.map((u) => u.category));
|
||||
|
||||
// Return object with all categories, true = subscribed (not unsubscribed)
|
||||
const preferences: Record<string, boolean> = {};
|
||||
for (const [category] of Object.entries(emailCategories)) {
|
||||
preferences[category] = !unsubscribedCategories.has(category);
|
||||
}
|
||||
|
||||
return preferences;
|
||||
}),
|
||||
|
||||
updatePreferences: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
categories: z.record(z.string(), z.boolean()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session.userId || !ctx.session.user?.email) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const email = ctx.session.user.email;
|
||||
|
||||
// Process each category
|
||||
for (const [category, subscribed] of Object.entries(input.categories)) {
|
||||
if (subscribed) {
|
||||
// User wants to subscribe - delete unsubscribe record if exists
|
||||
await db.emailUnsubscribe.deleteMany({
|
||||
where: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// User wants to unsubscribe - upsert unsubscribe record
|
||||
await db.emailUnsubscribe.upsert({
|
||||
where: {
|
||||
email_category: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -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(
|
||||
@@ -30,16 +29,10 @@ async function createOrGetOrganization(
|
||||
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
|
||||
subscriptionStatus: 'trialing',
|
||||
timezone: input.timezone,
|
||||
onboarding: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!process.env.SELF_HOSTED) {
|
||||
await addTrialEndingSoonJob(
|
||||
organization.id,
|
||||
1000 * 60 * 60 * 24 * TRIAL_DURATION_IN_DAYS * 0.9,
|
||||
);
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user