wip
This commit is contained in:
@@ -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)' },
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."users" ADD COLUMN "onboarding" INTEGER;
|
||||
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@react-email/components": "^0.5.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
23
packages/email/src/components/button.tsx
Normal file
23
packages/email/src/components/button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
39
packages/email/src/emails/onboarding-feature-request.tsx
Normal file
39
packages/email/src/emails/onboarding-feature-request.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
55
packages/email/src/emails/onboarding-trial-ended.tsx
Normal file
55
packages/email/src/emails/onboarding-trial-ended.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
28
packages/email/src/unsubscribe.ts
Normal file
28
packages/email/src/unsubscribe.ts
Normal 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()}`;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
40
packages/trpc/src/routers/email.ts
Normal file
40
packages/trpc/src/routers/email.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user