wip
This commit is contained in:
@@ -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': {
|
||||
@@ -510,7 +507,6 @@ export function getCountry(code?: string) {
|
||||
|
||||
export const emailCategories = {
|
||||
onboarding: 'Onboarding',
|
||||
billing: 'Billing',
|
||||
} as const;
|
||||
|
||||
export type EmailCategory = keyof typeof emailCategories;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;
|
||||
@@ -62,7 +62,7 @@ model Organization {
|
||||
integrations Integration[]
|
||||
invites Invite[]
|
||||
timezone String?
|
||||
onboarding String @default("completed") // 'completed' or template name for next email
|
||||
onboarding String? @default("completed")
|
||||
|
||||
// Subscription
|
||||
subscriptionId String?
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -39,7 +39,6 @@ export const templates = {
|
||||
'Your trial is ending soon',
|
||||
Component: TrailEndingSoon,
|
||||
schema: zTrailEndingSoon,
|
||||
category: 'billing' as const,
|
||||
},
|
||||
'onboarding-welcome': {
|
||||
subject: () => "You're in",
|
||||
@@ -69,13 +68,11 @@ export const templates = {
|
||||
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;
|
||||
|
||||
|
||||
@@ -14,14 +14,15 @@ export default OnboardingDashboards;
|
||||
export function OnboardingDashboards({
|
||||
firstName,
|
||||
dashboardUrl = 'https://dashboard.openpanel.dev',
|
||||
}: Props) {
|
||||
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>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>
|
||||
Tracking events is the easy part. The value comes from actually looking
|
||||
|
||||
@@ -9,9 +9,12 @@ export const zOnboardingFeatureRequest = z.object({
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingFeatureRequest>;
|
||||
export default OnboardingFeatureRequest;
|
||||
export function OnboardingFeatureRequest({ firstName }: Props) {
|
||||
export function OnboardingFeatureRequest({
|
||||
firstName,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>
|
||||
OpenPanel aims to be the one stop shop for all your analytics needs.
|
||||
|
||||
@@ -16,7 +16,8 @@ export function OnboardingTrialEnded({
|
||||
firstName,
|
||||
billingUrl = 'https://dashboard.openpanel.dev',
|
||||
recommendedPlan,
|
||||
}: Props) {
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
const newUrl = new URL(billingUrl);
|
||||
newUrl.searchParams.set('utm_source', 'email');
|
||||
newUrl.searchParams.set('utm_medium', 'email');
|
||||
|
||||
@@ -18,7 +18,8 @@ export function OnboardingTrialEnding({
|
||||
organizationName = 'your organization',
|
||||
billingUrl = 'https://dashboard.openpanel.dev',
|
||||
recommendedPlan,
|
||||
}: Props) {
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
const newUrl = new URL(billingUrl);
|
||||
newUrl.searchParams.set('utm_source', 'email');
|
||||
newUrl.searchParams.set('utm_medium', 'email');
|
||||
|
||||
@@ -11,9 +11,12 @@ export const zOnboardingWelcome = z.object({
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingWelcome>;
|
||||
export default OnboardingWelcome;
|
||||
export function OnboardingWelcome({ firstName }: Props) {
|
||||
export function OnboardingWelcome({
|
||||
firstName,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>Thanks for trying OpenPanel.</Text>
|
||||
<Text>
|
||||
|
||||
@@ -10,9 +10,12 @@ export const zOnboardingWhatToTrack = z.object({
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
|
||||
export default OnboardingWhatToTrack;
|
||||
export function OnboardingWhatToTrack({ firstName }: Props) {
|
||||
export function OnboardingWhatToTrack({
|
||||
firstName,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>
|
||||
Tracking can be overwhelming at first, and that's why its important to
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -29,7 +29,6 @@ export async function sendEmail<T extends TemplateKey>(
|
||||
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: {
|
||||
@@ -51,8 +50,7 @@ export async function sendEmail<T extends TemplateKey>(
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.log('No RESEND_API_KEY found, here is the data');
|
||||
console.log('Template:', template);
|
||||
// @ts-expect-error - TODO: fix this
|
||||
console.log('Subject: ', subject(props.data));
|
||||
console.log('Subject: ', template.subject(props.data as any));
|
||||
console.log('To: ', to);
|
||||
console.log('Data: ', JSON.stringify(data, null, 2));
|
||||
return null;
|
||||
@@ -60,10 +58,10 @@ export async function sendEmail<T extends TemplateKey>(
|
||||
|
||||
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);
|
||||
(data as any).unsubscribeUrl = unsubscribeUrl;
|
||||
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
|
||||
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
|
||||
}
|
||||
@@ -72,10 +70,8 @@ export async function sendEmail<T extends TemplateKey>(
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import { emailCategories } from '@openpanel/constants';
|
||||
import { db } from '@openpanel/db';
|
||||
import { verifyUnsubscribeToken } from '@openpanel/email';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { z } from 'zod';
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
|
||||
export const emailRouter = createTRPCRouter({
|
||||
unsubscribe: publicProcedure
|
||||
@@ -35,6 +36,78 @@ export const emailRouter = createTRPCRouter({
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ async function createOrGetOrganization(
|
||||
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
|
||||
subscriptionStatus: 'trialing',
|
||||
timezone: input.timezone,
|
||||
onboarding: 'onboarding-welcome',
|
||||
onboarding: '',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user