wip
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as UnsubscribeRouteImport } from './routes/unsubscribe'
|
||||||
import { Route as StepsRouteImport } from './routes/_steps'
|
import { Route as StepsRouteImport } from './routes/_steps'
|
||||||
import { Route as PublicRouteImport } from './routes/_public'
|
import { Route as PublicRouteImport } from './routes/_public'
|
||||||
import { Route as LoginRouteImport } from './routes/_login'
|
import { Route as LoginRouteImport } from './routes/_login'
|
||||||
@@ -102,6 +103,11 @@ const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
|
|||||||
'/_app/$organizationId/$projectId/profiles/$profileId',
|
'/_app/$organizationId/$projectId/profiles/$profileId',
|
||||||
)()
|
)()
|
||||||
|
|
||||||
|
const UnsubscribeRoute = UnsubscribeRouteImport.update({
|
||||||
|
id: '/unsubscribe',
|
||||||
|
path: '/unsubscribe',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const StepsRoute = StepsRouteImport.update({
|
const StepsRoute = StepsRouteImport.update({
|
||||||
id: '/_steps',
|
id: '/_steps',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
@@ -525,6 +531,7 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/unsubscribe': typeof UnsubscribeRoute
|
||||||
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
||||||
'/login': typeof LoginLoginRoute
|
'/login': typeof LoginLoginRoute
|
||||||
'/reset-password': typeof LoginResetPasswordRoute
|
'/reset-password': typeof LoginResetPasswordRoute
|
||||||
@@ -591,6 +598,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/unsubscribe': typeof UnsubscribeRoute
|
||||||
'/login': typeof LoginLoginRoute
|
'/login': typeof LoginLoginRoute
|
||||||
'/reset-password': typeof LoginResetPasswordRoute
|
'/reset-password': typeof LoginResetPasswordRoute
|
||||||
'/onboarding': typeof PublicOnboardingRoute
|
'/onboarding': typeof PublicOnboardingRoute
|
||||||
@@ -653,6 +661,7 @@ export interface FileRoutesById {
|
|||||||
'/_login': typeof LoginRouteWithChildren
|
'/_login': typeof LoginRouteWithChildren
|
||||||
'/_public': typeof PublicRouteWithChildren
|
'/_public': typeof PublicRouteWithChildren
|
||||||
'/_steps': typeof StepsRouteWithChildren
|
'/_steps': typeof StepsRouteWithChildren
|
||||||
|
'/unsubscribe': typeof UnsubscribeRoute
|
||||||
'/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
'/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
||||||
'/_login/login': typeof LoginLoginRoute
|
'/_login/login': typeof LoginLoginRoute
|
||||||
'/_login/reset-password': typeof LoginResetPasswordRoute
|
'/_login/reset-password': typeof LoginResetPasswordRoute
|
||||||
@@ -728,6 +737,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/unsubscribe'
|
||||||
| '/$organizationId'
|
| '/$organizationId'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
@@ -794,6 +804,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/unsubscribe'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
@@ -855,6 +866,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_login'
|
| '/_login'
|
||||||
| '/_public'
|
| '/_public'
|
||||||
| '/_steps'
|
| '/_steps'
|
||||||
|
| '/unsubscribe'
|
||||||
| '/_app/$organizationId'
|
| '/_app/$organizationId'
|
||||||
| '/_login/login'
|
| '/_login/login'
|
||||||
| '/_login/reset-password'
|
| '/_login/reset-password'
|
||||||
@@ -933,6 +945,7 @@ export interface RootRouteChildren {
|
|||||||
LoginRoute: typeof LoginRouteWithChildren
|
LoginRoute: typeof LoginRouteWithChildren
|
||||||
PublicRoute: typeof PublicRouteWithChildren
|
PublicRoute: typeof PublicRouteWithChildren
|
||||||
StepsRoute: typeof StepsRouteWithChildren
|
StepsRoute: typeof StepsRouteWithChildren
|
||||||
|
UnsubscribeRoute: typeof UnsubscribeRoute
|
||||||
ApiConfigRoute: typeof ApiConfigRoute
|
ApiConfigRoute: typeof ApiConfigRoute
|
||||||
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
||||||
WidgetCounterRoute: typeof WidgetCounterRoute
|
WidgetCounterRoute: typeof WidgetCounterRoute
|
||||||
@@ -945,6 +958,13 @@ export interface RootRouteChildren {
|
|||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/unsubscribe': {
|
||||||
|
id: '/unsubscribe'
|
||||||
|
path: '/unsubscribe'
|
||||||
|
fullPath: '/unsubscribe'
|
||||||
|
preLoaderRoute: typeof UnsubscribeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_steps': {
|
'/_steps': {
|
||||||
id: '/_steps'
|
id: '/_steps'
|
||||||
path: ''
|
path: ''
|
||||||
@@ -1872,6 +1892,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
LoginRoute: LoginRouteWithChildren,
|
LoginRoute: LoginRouteWithChildren,
|
||||||
PublicRoute: PublicRouteWithChildren,
|
PublicRoute: PublicRouteWithChildren,
|
||||||
StepsRoute: StepsRouteWithChildren,
|
StepsRoute: StepsRouteWithChildren,
|
||||||
|
UnsubscribeRoute: UnsubscribeRoute,
|
||||||
ApiConfigRoute: ApiConfigRoute,
|
ApiConfigRoute: ApiConfigRoute,
|
||||||
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
||||||
WidgetCounterRoute: WidgetCounterRoute,
|
WidgetCounterRoute: WidgetCounterRoute,
|
||||||
|
|||||||
112
apps/start/src/routes/unsubscribe.tsx
Normal file
112
apps/start/src/routes/unsubscribe.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
|
import { LoginNavbar } from '@/components/login-navbar';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { emailCategories } from '@openpanel/constants';
|
||||||
|
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const unsubscribeSearchSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
category: z.string(),
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/unsubscribe')({
|
||||||
|
component: RouteComponent,
|
||||||
|
validateSearch: unsubscribeSearchSchema,
|
||||||
|
pendingComponent: FullPageLoadingState,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const search = useSearch({ from: '/unsubscribe' });
|
||||||
|
const { email, category, token } = search;
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const [isUnsubscribing, setIsUnsubscribing] = useState(false);
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const unsubscribeMutation = trpc.email.unsubscribe.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsSuccess(true);
|
||||||
|
setIsUnsubscribing(false);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.message || 'Failed to unsubscribe');
|
||||||
|
setIsUnsubscribing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUnsubscribe = () => {
|
||||||
|
setIsUnsubscribing(true);
|
||||||
|
setError(null);
|
||||||
|
unsubscribeMutation.mutate({ email, category, token });
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryName =
|
||||||
|
emailCategories[category as keyof typeof emailCategories] || category;
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<LoginNavbar />
|
||||||
|
<div className="flex-1 center-center px-4">
|
||||||
|
<div className="max-w-md w-full text-center space-y-4">
|
||||||
|
<div className="text-6xl mb-4">✓</div>
|
||||||
|
<h1 className="text-2xl font-bold">Unsubscribed</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
You've been unsubscribed from {categoryName} emails.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You won't receive any more {categoryName.toLowerCase()} emails from
|
||||||
|
us.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<LoginNavbar />
|
||||||
|
<div className="flex-1 center-center px-4">
|
||||||
|
<div className="max-w-md w-full space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h1 className="text-2xl font-bold">Unsubscribe</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Unsubscribe from {categoryName} emails?
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You'll stop receiving {categoryName.toLowerCase()} emails sent to{' '}
|
||||||
|
<span className="font-mono text-xs">{email}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={handleUnsubscribe}
|
||||||
|
disabled={isUnsubscribing}
|
||||||
|
className="w-full bg-black text-white py-3 px-4 rounded-md font-medium hover:bg-black/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="block text-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"@openpanel/json": "workspace:*",
|
"@openpanel/json": "workspace:*",
|
||||||
"@openpanel/logger": "workspace:*",
|
"@openpanel/logger": "workspace:*",
|
||||||
"@openpanel/importer": "workspace:*",
|
"@openpanel/importer": "workspace:*",
|
||||||
|
"@openpanel/payments": "workspace:*",
|
||||||
"@openpanel/queue": "workspace:*",
|
"@openpanel/queue": "workspace:*",
|
||||||
"@openpanel/redis": "workspace:*",
|
"@openpanel/redis": "workspace:*",
|
||||||
"bullmq": "^5.63.0",
|
"bullmq": "^5.63.0",
|
||||||
|
|||||||
@@ -1,180 +1,265 @@
|
|||||||
import { differenceInDays } from 'date-fns';
|
|
||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
import { db } from '@openpanel/db';
|
import { db } from '@openpanel/db';
|
||||||
import { sendEmail } from '@openpanel/email';
|
import {
|
||||||
|
type EmailData,
|
||||||
|
type EmailTemplate,
|
||||||
|
sendEmail,
|
||||||
|
} from '@openpanel/email';
|
||||||
import type { CronQueuePayload } from '@openpanel/queue';
|
import type { CronQueuePayload } from '@openpanel/queue';
|
||||||
|
|
||||||
|
import { getRecommendedPlan } from '@openpanel/payments';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
const EMAIL_SCHEDULE = {
|
// Types for the onboarding email system
|
||||||
1: 0, // Welcome email - Day 0
|
const orgQuery = {
|
||||||
2: 2, // What to track - Day 2
|
include: {
|
||||||
3: 6, // Dashboards - Day 6
|
createdBy: {
|
||||||
4: 14, // Replace stack - Day 14
|
select: {
|
||||||
5: 26, // Trial ending - Day 26
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
deletedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
type OrgWithCreator = Awaited<
|
||||||
|
ReturnType<typeof db.organization.findMany<typeof orgQuery>>
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
type OnboardingContext = {
|
||||||
|
org: OrgWithCreator;
|
||||||
|
user: NonNullable<OrgWithCreator['createdBy']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OnboardingEmail<T extends EmailTemplate = EmailTemplate> = {
|
||||||
|
day: number;
|
||||||
|
template: T;
|
||||||
|
shouldSend?: (ctx: OnboardingContext) => Promise<boolean | 'complete'>;
|
||||||
|
data: (ctx: OnboardingContext) => EmailData<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to create type-safe email entries with correlated template/data types
|
||||||
|
function email<T extends EmailTemplate>(config: OnboardingEmail<T>) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getters = {
|
||||||
|
firstName: (ctx: OnboardingContext) => ctx.user.firstName || undefined,
|
||||||
|
organizationName: (ctx: OnboardingContext) => ctx.org.name,
|
||||||
|
dashboardUrl: (ctx: OnboardingContext) => {
|
||||||
|
return `${process.env.DASHBOARD_URL}/${ctx.org.id}`;
|
||||||
|
},
|
||||||
|
billingUrl: (ctx: OnboardingContext) => {
|
||||||
|
return `${process.env.DASHBOARD_URL}/${ctx.org.id}/billing`;
|
||||||
|
},
|
||||||
|
recommendedPlan: (ctx: OnboardingContext) => {
|
||||||
|
return getRecommendedPlan(
|
||||||
|
ctx.org.subscriptionPeriodEventsCount,
|
||||||
|
(plan) =>
|
||||||
|
`${plan.formattedEvents} events per month for ${plan.formattedPrice}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Declarative email schedule - easy to add, remove, or reorder
|
||||||
|
const ONBOARDING_EMAILS = [
|
||||||
|
email({
|
||||||
|
day: 0,
|
||||||
|
template: 'onboarding-welcome',
|
||||||
|
data: (ctx) => ({
|
||||||
|
firstName: getters.firstName(ctx),
|
||||||
|
dashboardUrl: getters.dashboardUrl(ctx),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
email({
|
||||||
|
day: 2,
|
||||||
|
template: 'onboarding-what-to-track',
|
||||||
|
data: (ctx) => ({
|
||||||
|
firstName: getters.firstName(ctx),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
email({
|
||||||
|
day: 6,
|
||||||
|
template: 'onboarding-dashboards',
|
||||||
|
data: (ctx) => ({
|
||||||
|
firstName: getters.firstName(ctx),
|
||||||
|
dashboardUrl: getters.dashboardUrl(ctx),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
email({
|
||||||
|
day: 14,
|
||||||
|
template: 'onboarding-featue-request',
|
||||||
|
data: (ctx) => ({
|
||||||
|
firstName: getters.firstName(ctx),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
email({
|
||||||
|
day: 26,
|
||||||
|
template: 'onboarding-trial-ending',
|
||||||
|
shouldSend: async ({ org }) => {
|
||||||
|
if (org.subscriptionStatus === 'active') {
|
||||||
|
return 'complete';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
data: (ctx) => {
|
||||||
|
return {
|
||||||
|
firstName: getters.firstName(ctx),
|
||||||
|
organizationName: getters.organizationName(ctx),
|
||||||
|
billingUrl: getters.billingUrl(ctx),
|
||||||
|
recommendedPlan: getters.recommendedPlan(ctx),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
email({
|
||||||
|
day: 30,
|
||||||
|
template: 'onboarding-trial-ended',
|
||||||
|
shouldSend: async ({ org }) => {
|
||||||
|
if (org.subscriptionStatus === 'active') {
|
||||||
|
return 'complete';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
data: (ctx) => {
|
||||||
|
return {
|
||||||
|
firstName: getters.firstName(ctx),
|
||||||
|
billingUrl: getters.billingUrl(ctx),
|
||||||
|
recommendedPlan: getters.recommendedPlan(ctx),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
export async function onboardingJob(job: Job<CronQueuePayload>) {
|
export async function onboardingJob(job: Job<CronQueuePayload>) {
|
||||||
logger.info('Starting onboarding email job');
|
logger.info('Starting onboarding email job');
|
||||||
|
|
||||||
// Fetch organizations with their creators who are in onboarding
|
// Fetch organizations that are in onboarding (not completed)
|
||||||
const organizations = await db.organization.findMany({
|
const orgs = await db.organization.findMany({
|
||||||
where: {
|
where: {
|
||||||
createdByUserId: {
|
onboarding: {
|
||||||
not: null,
|
not: 'completed',
|
||||||
},
|
},
|
||||||
|
deleteAt: null,
|
||||||
createdBy: {
|
createdBy: {
|
||||||
onboarding: {
|
|
||||||
not: null,
|
|
||||||
gte: 1,
|
|
||||||
lte: 5,
|
|
||||||
},
|
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
...orgQuery,
|
||||||
createdBy: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
onboarding: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Found ${organizations.length} organizations with creators in onboarding`);
|
logger.info(`Found ${orgs.length} organizations in onboarding`);
|
||||||
|
|
||||||
let emailsSent = 0;
|
let emailsSent = 0;
|
||||||
let usersCompleted = 0;
|
let orgsCompleted = 0;
|
||||||
let usersSkipped = 0;
|
let orgsSkipped = 0;
|
||||||
|
|
||||||
for (const org of organizations) {
|
for (const org of orgs) {
|
||||||
if (!org.createdBy || !org.createdByUserId) {
|
// Skip if no creator or creator is deleted
|
||||||
|
if (!org.createdBy || org.createdBy.deletedAt) {
|
||||||
|
orgsSkipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = org.createdBy;
|
const user = org.createdBy;
|
||||||
|
|
||||||
// Check if organization has active subscription
|
|
||||||
if (org.subscriptionStatus === 'active') {
|
|
||||||
// Stop onboarding for users with active subscriptions
|
|
||||||
await db.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: { onboarding: null },
|
|
||||||
});
|
|
||||||
usersCompleted++;
|
|
||||||
logger.info(`Stopped onboarding for user ${user.id} (active subscription)`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.onboarding || user.onboarding < 1 || user.onboarding > 5) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use organization creation date instead of user registration date
|
|
||||||
const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt);
|
const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt);
|
||||||
const requiredDays = EMAIL_SCHEDULE[user.onboarding as keyof typeof EMAIL_SCHEDULE];
|
|
||||||
|
|
||||||
if (daysSinceOrgCreation < requiredDays) {
|
// Find the next email to send
|
||||||
usersSkipped++;
|
// If org.onboarding is empty string, they haven't received any email yet
|
||||||
|
const lastSentIndex = org.onboarding
|
||||||
|
? ONBOARDING_EMAILS.findIndex((e) => e.template === org.onboarding)
|
||||||
|
: -1;
|
||||||
|
const nextEmailIndex = lastSentIndex + 1;
|
||||||
|
|
||||||
|
// No more emails to send
|
||||||
|
if (nextEmailIndex >= ONBOARDING_EMAILS.length) {
|
||||||
|
await db.organization.update({
|
||||||
|
where: { id: org.id },
|
||||||
|
data: { onboarding: 'completed' },
|
||||||
|
});
|
||||||
|
orgsCompleted++;
|
||||||
|
logger.info(
|
||||||
|
`Completed onboarding for organization ${org.id} (all emails sent)`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboardUrl = `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL || 'https://dashboard.openpanel.dev'}/${org.id}`;
|
const nextEmail = ONBOARDING_EMAILS[nextEmailIndex];
|
||||||
const billingUrl = `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL || 'https://dashboard.openpanel.dev'}/${org.id}/billing`;
|
if (!nextEmail) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Check if enough days have passed
|
||||||
// Send appropriate email based on onboarding step
|
if (daysSinceOrgCreation < nextEmail.day) {
|
||||||
switch (user.onboarding) {
|
orgsSkipped++;
|
||||||
case 1: {
|
continue;
|
||||||
// Welcome email
|
}
|
||||||
await sendEmail('onboarding-welcome', {
|
|
||||||
to: user.email,
|
// Check shouldSend callback if defined
|
||||||
data: {
|
if (nextEmail.shouldSend) {
|
||||||
firstName: user.firstName || undefined,
|
const result = await nextEmail.shouldSend({ org, user });
|
||||||
dashboardUrl,
|
|
||||||
},
|
if (result === 'complete') {
|
||||||
});
|
await db.organization.update({
|
||||||
break;
|
where: { id: org.id },
|
||||||
}
|
data: { onboarding: 'completed' },
|
||||||
case 2: {
|
});
|
||||||
// What to track email
|
orgsCompleted++;
|
||||||
await sendEmail('onboarding-what-to-track', {
|
logger.info(
|
||||||
to: user.email,
|
`Completed onboarding for organization ${org.id} (shouldSend returned complete)`,
|
||||||
data: {
|
);
|
||||||
firstName: user.firstName || undefined,
|
continue;
|
||||||
},
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 3: {
|
|
||||||
// Dashboards email
|
|
||||||
await sendEmail('onboarding-dashboards', {
|
|
||||||
to: user.email,
|
|
||||||
data: {
|
|
||||||
firstName: user.firstName || undefined,
|
|
||||||
dashboardUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 4: {
|
|
||||||
// Replace stack email
|
|
||||||
await sendEmail('onboarding-replace-stack', {
|
|
||||||
to: user.email,
|
|
||||||
data: {
|
|
||||||
firstName: user.firstName || undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 5: {
|
|
||||||
// Trial ending email
|
|
||||||
await sendEmail('onboarding-trial-ending', {
|
|
||||||
to: user.email,
|
|
||||||
data: {
|
|
||||||
firstName: user.firstName || undefined,
|
|
||||||
organizationName: org.name,
|
|
||||||
billingUrl,
|
|
||||||
recommendedPlan: undefined, // TODO: Calculate based on usage
|
|
||||||
},
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment onboarding state
|
if (result === false) {
|
||||||
const nextOnboardingState = user.onboarding + 1;
|
orgsSkipped++;
|
||||||
await db.user.update({
|
continue;
|
||||||
where: { id: user.id },
|
}
|
||||||
data: {
|
}
|
||||||
onboarding: nextOnboardingState > 5 ? null : nextOnboardingState,
|
|
||||||
},
|
try {
|
||||||
|
const emailData = nextEmail.data({ org, user });
|
||||||
|
|
||||||
|
await sendEmail(nextEmail.template, {
|
||||||
|
to: user.email,
|
||||||
|
data: emailData as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update onboarding to the template name we just sent
|
||||||
|
await db.organization.update({
|
||||||
|
where: { id: org.id },
|
||||||
|
data: { onboarding: nextEmail.template },
|
||||||
});
|
});
|
||||||
|
|
||||||
emailsSent++;
|
emailsSent++;
|
||||||
logger.info(`Sent onboarding email ${user.onboarding} to user ${user.id} for org ${org.id}`);
|
logger.info(
|
||||||
|
`Sent onboarding email "${nextEmail.template}" to organization ${org.id} (user ${user.id})`,
|
||||||
if (nextOnboardingState > 5) {
|
);
|
||||||
usersCompleted++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to send onboarding email to user ${user.id}`, {
|
logger.error(
|
||||||
error,
|
`Failed to send onboarding email to organization ${org.id}`,
|
||||||
onboardingStep: user.onboarding,
|
{
|
||||||
organizationId: org.id,
|
error,
|
||||||
});
|
template: nextEmail.template,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Completed onboarding email job', {
|
logger.info('Completed onboarding email job', {
|
||||||
totalOrganizations: organizations.length,
|
totalOrgs: orgs.length,
|
||||||
emailsSent,
|
emailsSent,
|
||||||
usersCompleted,
|
orgsCompleted,
|
||||||
usersSkipped,
|
orgsSkipped,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalOrgs: orgs.length,
|
||||||
|
emailsSent,
|
||||||
|
orgsCompleted,
|
||||||
|
orgsSkipped,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,6 +508,13 @@ export function getCountry(code?: string) {
|
|||||||
return countries[code as keyof typeof countries];
|
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 = [
|
export const chartColors = [
|
||||||
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
|
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
|
||||||
{ main: '#ff7557', translucent: 'rgba(255, 117, 87, 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[]
|
integrations Integration[]
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
timezone String?
|
timezone String?
|
||||||
|
onboarding String @default("completed") // 'completed' or template name for next email
|
||||||
|
|
||||||
// Subscription
|
// Subscription
|
||||||
subscriptionId String?
|
subscriptionId String?
|
||||||
@@ -94,7 +95,6 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
firstName String?
|
firstName String?
|
||||||
lastName String?
|
lastName String?
|
||||||
onboarding Int? // null = disabled/completed, 1-5 = next email step
|
|
||||||
createdOrganizations Organization[] @relation("organizationCreatedBy")
|
createdOrganizations Organization[] @relation("organizationCreatedBy")
|
||||||
subscriptions Organization[] @relation("subscriptionCreatedBy")
|
subscriptions Organization[] @relation("subscriptionCreatedBy")
|
||||||
membership Member[]
|
membership Member[]
|
||||||
@@ -611,3 +611,13 @@ model InsightEvent {
|
|||||||
@@index([insightId, createdAt])
|
@@index([insightId, createdAt])
|
||||||
@@map("insight_events")
|
@@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"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@openpanel/db": "workspace:*",
|
||||||
"@react-email/components": "^0.5.6",
|
"@react-email/components": "^0.5.6",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "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, {
|
import EmailResetPassword, {
|
||||||
zEmailResetPassword,
|
zEmailResetPassword,
|
||||||
} from './email-reset-password';
|
} 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, {
|
import OnboardingDashboards, {
|
||||||
zOnboardingDashboards,
|
zOnboardingDashboards,
|
||||||
} from './onboarding-dashboards';
|
} from './onboarding-dashboards';
|
||||||
import OnboardingReplaceStack, {
|
import OnboardingFeatureRequest, {
|
||||||
zOnboardingReplaceStack,
|
zOnboardingFeatureRequest,
|
||||||
} from './onboarding-replace-stack';
|
} from './onboarding-feature-request';
|
||||||
|
import OnboardingTrialEnded, {
|
||||||
|
zOnboardingTrialEnded,
|
||||||
|
} from './onboarding-trial-ended';
|
||||||
import OnboardingTrialEnding, {
|
import OnboardingTrialEnding, {
|
||||||
zOnboardingTrialEnding,
|
zOnboardingTrialEnding,
|
||||||
} from './onboarding-trial-ending';
|
} 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 = {
|
export const templates = {
|
||||||
invite: {
|
invite: {
|
||||||
@@ -38,31 +39,43 @@ export const templates = {
|
|||||||
'Your trial is ending soon',
|
'Your trial is ending soon',
|
||||||
Component: TrailEndingSoon,
|
Component: TrailEndingSoon,
|
||||||
schema: zTrailEndingSoon,
|
schema: zTrailEndingSoon,
|
||||||
|
category: 'billing' as const,
|
||||||
},
|
},
|
||||||
'onboarding-welcome': {
|
'onboarding-welcome': {
|
||||||
subject: () => "You're in",
|
subject: () => "You're in",
|
||||||
Component: OnboardingWelcome,
|
Component: OnboardingWelcome,
|
||||||
schema: zOnboardingWelcome,
|
schema: zOnboardingWelcome,
|
||||||
|
category: 'onboarding' as const,
|
||||||
},
|
},
|
||||||
'onboarding-what-to-track': {
|
'onboarding-what-to-track': {
|
||||||
subject: () => "What's actually worth tracking",
|
subject: () => "What's actually worth tracking",
|
||||||
Component: OnboardingWhatToTrack,
|
Component: OnboardingWhatToTrack,
|
||||||
schema: zOnboardingWhatToTrack,
|
schema: zOnboardingWhatToTrack,
|
||||||
|
category: 'onboarding' as const,
|
||||||
},
|
},
|
||||||
'onboarding-dashboards': {
|
'onboarding-dashboards': {
|
||||||
subject: () => 'The part most people skip',
|
subject: () => 'The part most people skip',
|
||||||
Component: OnboardingDashboards,
|
Component: OnboardingDashboards,
|
||||||
schema: zOnboardingDashboards,
|
schema: zOnboardingDashboards,
|
||||||
|
category: 'onboarding' as const,
|
||||||
},
|
},
|
||||||
'onboarding-replace-stack': {
|
'onboarding-featue-request': {
|
||||||
subject: () => 'One provider to rule them all',
|
subject: () => 'One provider to rule them all',
|
||||||
Component: OnboardingReplaceStack,
|
Component: OnboardingFeatureRequest,
|
||||||
schema: zOnboardingReplaceStack,
|
schema: zOnboardingFeatureRequest,
|
||||||
|
category: 'onboarding' as const,
|
||||||
},
|
},
|
||||||
'onboarding-trial-ending': {
|
'onboarding-trial-ending': {
|
||||||
subject: () => 'Your trial ends in a few days',
|
subject: () => 'Your trial ends in a few days',
|
||||||
Component: OnboardingTrialEnding,
|
Component: OnboardingTrialEnding,
|
||||||
schema: zOnboardingTrialEnding,
|
schema: zOnboardingTrialEnding,
|
||||||
|
category: 'onboarding' as const,
|
||||||
|
},
|
||||||
|
'onboarding-trial-ended': {
|
||||||
|
subject: () => 'Your trial has ended',
|
||||||
|
Component: OnboardingTrialEnded,
|
||||||
|
schema: zOnboardingTrialEnded,
|
||||||
|
category: 'onboarding' as const,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link, Text } from '@react-email/components';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Layout } from '../components/layout';
|
import { Layout } from '../components/layout';
|
||||||
|
import { List } from '../components/list';
|
||||||
|
|
||||||
export const zOnboardingDashboards = z.object({
|
export const zOnboardingDashboards = z.object({
|
||||||
firstName: z.string().optional(),
|
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
|
If you haven't yet, try building a simple dashboard. Pick one thing you
|
||||||
care about and visualize it. Could be:
|
care about and visualize it. Could be:
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<List
|
||||||
- How many people sign up and then actually do something
|
items={[
|
||||||
</Text>
|
'How many people sign up and then actually do something',
|
||||||
<Text>- Where users drop off in a flow (funnel)</Text>
|
'Where users drop off in a flow (funnel)',
|
||||||
<Text>- Which pages lead to conversions (entry page → CTA)</Text>
|
'Which pages lead to conversions (entry page → CTA)',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Text>
|
<Text>
|
||||||
This is usually when people go from "I have analytics" to "I understand
|
This is usually when people go from "I have analytics" to "I understand
|
||||||
what's happening." It's a different feeling.
|
what's happening." It's a different feeling.
|
||||||
</Text>
|
</Text>
|
||||||
<Text>Takes maybe 10 minutes to set up. Worth it.</Text>
|
<Text>Takes maybe 10 minutes to set up. Worth it.</Text>
|
||||||
<Text>
|
<Text>
|
||||||
<Link href={newUrl.toString()}>Create your first dashboard</Link>
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Carl
|
||||||
</Text>
|
</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>
|
</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 React from 'react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { Button } from '../components/button';
|
||||||
import { Layout } from '../components/layout';
|
import { Layout } from '../components/layout';
|
||||||
|
|
||||||
export const zOnboardingTrialEnding = z.object({
|
export const zOnboardingTrialEnding = z.object({
|
||||||
@@ -33,32 +34,21 @@ export function OnboardingTrialEnding({
|
|||||||
event history) stays intact.
|
event history) stays intact.
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
If OpenPanel has been useful, upgrading just keeps it going. Plans
|
To continue using OpenPanel, you'll need to upgrade to a paid plan.{' '}
|
||||||
start at $2.50/month
|
{recommendedPlan
|
||||||
{recommendedPlan ? ` and based on your usage we recommend ${recommendedPlan}` : ''}
|
? `Based on your usage we recommend upgrading to the ${recommendedPlan} plan`
|
||||||
|
: 'Plans start at $2.50/month'}
|
||||||
.
|
.
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
If something's holding you back, I'd like to hear about it. Just
|
If something's holding you back, I'd like to hear about it. Just reply.
|
||||||
reply.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
Your project will recieve events for the next 30 days, if you haven't
|
Your project will recieve events for the next 30 days, if you haven't
|
||||||
upgraded by then we'll remove your workspace and projects.
|
upgraded by then we'll remove your workspace and projects.
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
<Button
|
<Button href={newUrl.toString()}>Upgrade Now</Button>
|
||||||
href={newUrl.toString()}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#0070f3',
|
|
||||||
color: 'white',
|
|
||||||
padding: '12px 20px',
|
|
||||||
borderRadius: '5px',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Upgrade Now
|
|
||||||
</Button>
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text>Carl</Text>
|
<Text>Carl</Text>
|
||||||
</Layout>
|
</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 React from 'react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Layout } from '../components/layout';
|
import { Layout } from '../components/layout';
|
||||||
|
import { List } from '../components/list';
|
||||||
|
|
||||||
export const zOnboardingWelcome = z.object({
|
export const zOnboardingWelcome = z.object({
|
||||||
firstName: z.string().optional(),
|
firstName: z.string().optional(),
|
||||||
@@ -10,34 +11,42 @@ export const zOnboardingWelcome = z.object({
|
|||||||
|
|
||||||
export type Props = z.infer<typeof zOnboardingWelcome>;
|
export type Props = z.infer<typeof zOnboardingWelcome>;
|
||||||
export default OnboardingWelcome;
|
export default OnboardingWelcome;
|
||||||
export function OnboardingWelcome({
|
export function OnboardingWelcome({ firstName }: Props) {
|
||||||
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||||
<Text>Thanks for trying OpenPanel.</Text>
|
<Text>Thanks for trying OpenPanel.</Text>
|
||||||
<Text>
|
<Text>
|
||||||
We built OpenPanel because most analytics tools are either too expensive,
|
We built OpenPanel because most analytics tools are either too
|
||||||
too complicated, or both. OpenPanel is different.
|
expensive, too complicated, or both. OpenPanel is different.
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
If you already have setup your tracking you should see your dashboard
|
We hope you find OpenPanel useful and if you have any questions,
|
||||||
getting filled up. If you come from another provider and want to import
|
regarding tracking or how to import your existing events, just reach
|
||||||
your old events you can do that in our{' '}
|
out. We're here to help.
|
||||||
<Link href={newUrl.toString()}>project settings</Link>.
|
|
||||||
</Text>
|
</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>
|
<Text>
|
||||||
If you can't find your provider just reach out and we'll help you out.
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Carl
|
||||||
</Text>
|
</Text>
|
||||||
<Text>Reach out if you have any questions. I answer all emails.</Text>
|
|
||||||
<Text>Carl</Text>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Text } from '@react-email/components';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Layout } from '../components/layout';
|
import { Layout } from '../components/layout';
|
||||||
|
import { List } from '../components/list';
|
||||||
|
|
||||||
export const zOnboardingWhatToTrack = z.object({
|
export const zOnboardingWhatToTrack = z.object({
|
||||||
firstName: z.string().optional(),
|
firstName: z.string().optional(),
|
||||||
@@ -9,33 +10,34 @@ export const zOnboardingWhatToTrack = z.object({
|
|||||||
|
|
||||||
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
|
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
|
||||||
export default OnboardingWhatToTrack;
|
export default OnboardingWhatToTrack;
|
||||||
export function OnboardingWhatToTrack({
|
export function OnboardingWhatToTrack({ firstName }: Props) {
|
||||||
firstName,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||||
<Text>
|
<Text>
|
||||||
Track the moments that tell you whether your product is working. Track
|
Tracking can be overwhelming at first, and that's why its important to
|
||||||
things that matters to your product the most and then you can easily
|
focus on what's matters. For most products, that's something like:
|
||||||
create funnels or conversions reports to understand what happening.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text>For most products, that's something like:</Text>
|
<List
|
||||||
<Text>- Signups</Text>
|
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>
|
<Text>
|
||||||
- The first meaningful action (create something, send something, buy
|
Start small and incrementally add more events as you go is usually the
|
||||||
something)
|
best approach.
|
||||||
</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>
|
||||||
<Text>
|
<Text>
|
||||||
If you're not sure whether something's worth tracking, just ask. I'm
|
If you're not sure whether something's worth tracking, or have any
|
||||||
happy to look at your setup.
|
questions, just reply here.
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Carl
|
||||||
</Text>
|
</Text>
|
||||||
<Text>Carl</Text>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,34 +2,72 @@ import React from 'react';
|
|||||||
import { Resend } from 'resend';
|
import { Resend } from 'resend';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { db } from '@openpanel/db';
|
||||||
import { type TemplateKey, type Templates, templates } from './emails';
|
import { type TemplateKey, type Templates, templates } from './emails';
|
||||||
|
import { getUnsubscribeUrl } from './unsubscribe';
|
||||||
|
|
||||||
|
export * from './unsubscribe';
|
||||||
|
|
||||||
const FROM = process.env.EMAIL_SENDER ?? 'hello@openpanel.dev';
|
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>(
|
export async function sendEmail<T extends TemplateKey>(
|
||||||
template: T,
|
templateKey: T,
|
||||||
options: {
|
options: {
|
||||||
to: string | string[];
|
to: string;
|
||||||
data: z.infer<Templates[T]['schema']>;
|
data: z.infer<Templates[T]['schema']>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { to, data } = options;
|
const { to, data } = options;
|
||||||
const { subject, Component, schema } = templates[template];
|
const template = templates[templateKey];
|
||||||
const props = schema.safeParse(data);
|
const props = template.schema.safeParse(data);
|
||||||
|
|
||||||
if (!props.success) {
|
if (!props.success) {
|
||||||
console.error('Failed to parse data', props.error);
|
console.error('Failed to parse data', props.error);
|
||||||
return null;
|
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) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
console.log('No RESEND_API_KEY found, here is the data');
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
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 {
|
try {
|
||||||
const res = await resend.emails.send({
|
const res = await resend.emails.send({
|
||||||
from: FROM,
|
from: FROM,
|
||||||
@@ -38,12 +76,11 @@ export async function sendEmail<T extends TemplateKey>(
|
|||||||
subject: subject(props.data),
|
subject: subject(props.data),
|
||||||
// @ts-expect-error - TODO: fix this
|
// @ts-expect-error - TODO: fix this
|
||||||
react: <Component {...props.data} />,
|
react: <Component {...props.data} />,
|
||||||
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
throw new Error(res.error.message);
|
throw new Error(res.error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send email', 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';
|
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 = {
|
export type IPrice = {
|
||||||
price: number;
|
price: number;
|
||||||
events: number;
|
events: number;
|
||||||
@@ -39,3 +45,29 @@ export const FREE_PRODUCT_IDS = [
|
|||||||
'a18b4bee-d3db-4404-be6f-fba2f042d9ed', // Prod
|
'a18b4bee-d3db-4404-be6f-fba2f042d9ed', // Prod
|
||||||
'036efa2a-b3b4-4c75-b24a-9cac6bb8893b', // Sandbox
|
'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 { chatRouter } from './routers/chat';
|
||||||
import { clientRouter } from './routers/client';
|
import { clientRouter } from './routers/client';
|
||||||
import { dashboardRouter } from './routers/dashboard';
|
import { dashboardRouter } from './routers/dashboard';
|
||||||
|
import { emailRouter } from './routers/email';
|
||||||
import { eventRouter } from './routers/event';
|
import { eventRouter } from './routers/event';
|
||||||
import { importRouter } from './routers/import';
|
import { importRouter } from './routers/import';
|
||||||
import { insightRouter } from './routers/insight';
|
import { insightRouter } from './routers/insight';
|
||||||
@@ -51,6 +52,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
chat: chatRouter,
|
chat: chatRouter,
|
||||||
insight: insightRouter,
|
insight: insightRouter,
|
||||||
widget: widgetRouter,
|
widget: widgetRouter,
|
||||||
|
email: emailRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -353,7 +353,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
.input(zSignInShare)
|
.input(zSignInShare)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { password, shareId, shareType = 'overview' } = input;
|
const { password, shareId, shareType = 'overview' } = input;
|
||||||
|
|
||||||
let share: { password: string | null; public: boolean } | null = null;
|
let share: { password: string | null; public: boolean } | null = null;
|
||||||
let cookieName = '';
|
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 { hashPassword } from '@openpanel/common/server';
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { addTrialEndingSoonJob, miscQueue } from '../../../queue';
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
async function createOrGetOrganization(
|
async function createOrGetOrganization(
|
||||||
@@ -22,13 +21,6 @@ async function createOrGetOrganization(
|
|||||||
const TRIAL_DURATION_IN_DAYS = 30;
|
const TRIAL_DURATION_IN_DAYS = 30;
|
||||||
|
|
||||||
if (input.organization) {
|
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({
|
const organization = await db.organization.create({
|
||||||
data: {
|
data: {
|
||||||
id: await getId('organization', input.organization),
|
id: await getId('organization', input.organization),
|
||||||
@@ -37,24 +29,10 @@ async function createOrGetOrganization(
|
|||||||
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
|
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
|
||||||
subscriptionStatus: 'trialing',
|
subscriptionStatus: 'trialing',
|
||||||
timezone: input.timezone,
|
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;
|
return organization;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -37,7 +37,7 @@ overrides:
|
|||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
nuqs:
|
nuqs:
|
||||||
hash: w6thjv3pgywfrbh4sblczc6qpy
|
hash: 4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e
|
||||||
path: patches/nuqs.patch
|
path: patches/nuqs.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
@@ -656,7 +656,7 @@ importers:
|
|||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
nuqs:
|
nuqs:
|
||||||
specifier: ^2.5.2
|
specifier: ^2.5.2
|
||||||
version: 2.5.2(patch_hash=w6thjv3pgywfrbh4sblczc6qpy)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
version: 2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||||
prisma-error-enum:
|
prisma-error-enum:
|
||||||
specifier: ^0.1.3
|
specifier: ^0.1.3
|
||||||
version: 0.1.3
|
version: 0.1.3
|
||||||
@@ -880,6 +880,9 @@ importers:
|
|||||||
'@openpanel/logger':
|
'@openpanel/logger':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/logger
|
version: link:../../packages/logger
|
||||||
|
'@openpanel/payments':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/payments
|
||||||
'@openpanel/queue':
|
'@openpanel/queue':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/queue
|
version: link:../../packages/queue
|
||||||
@@ -1136,6 +1139,9 @@ importers:
|
|||||||
|
|
||||||
packages/email:
|
packages/email:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@openpanel/db':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../db
|
||||||
'@react-email/components':
|
'@react-email/components':
|
||||||
specifier: ^0.5.6
|
specifier: ^0.5.6
|
||||||
version: 0.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 0.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -33178,7 +33184,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
esm-env: esm-env-runtime@0.1.1
|
esm-env: esm-env-runtime@0.1.1
|
||||||
|
|
||||||
nuqs@2.5.2(patch_hash=w6thjv3pgywfrbh4sblczc6qpy)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
|
nuqs@2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.0.0
|
'@standard-schema/spec': 1.0.0
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
|
|||||||
Reference in New Issue
Block a user