diff --git a/apps/start/src/components/auth/share-enter-password.tsx b/apps/start/src/components/auth/share-enter-password.tsx
index b25dae612..9f1e7ce7a 100644
--- a/apps/start/src/components/auth/share-enter-password.tsx
+++ b/apps/start/src/components/auth/share-enter-password.tsx
@@ -4,7 +4,7 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
-import { LogoSquare } from '../logo';
+import { PublicPageCard } from '../public-page-card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
@@ -43,54 +43,27 @@ export function ShareEnterPassword({
});
});
+ const typeLabel =
+ shareType === 'dashboard'
+ ? 'Dashboard'
+ : shareType === 'report'
+ ? 'Report'
+ : 'Overview';
+
return (
-
-
-
-
-
- {shareType === 'dashboard'
- ? 'Dashboard is locked'
- : shareType === 'report'
- ? 'Report is locked'
- : 'Overview is locked'}
-
-
- Please enter correct password to access this{' '}
- {shareType === 'dashboard'
- ? 'dashboard'
- : shareType === 'report'
- ? 'report'
- : 'overview'}
-
-
-
-
-
-
+
+
+
);
}
diff --git a/apps/start/src/components/public-page-card.tsx b/apps/start/src/components/public-page-card.tsx
new file mode 100644
index 000000000..400513ec0
--- /dev/null
+++ b/apps/start/src/components/public-page-card.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from 'react';
+import { LoginNavbar } from './login-navbar';
+import { LogoSquare } from './logo';
+
+interface PublicPageCardProps {
+ title: string;
+ description?: ReactNode;
+ children?: ReactNode;
+ showFooter?: boolean;
+}
+
+export function PublicPageCard({
+ title,
+ description,
+ children,
+ showFooter = true,
+}: PublicPageCardProps) {
+ return (
+
+
+
+
+
+
+
{title}
+ {description && (
+
+ {description}
+
+ )}
+
+ {!!children &&
{children}
}
+
+ {showFooter && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/start/src/hooks/use-cookie-store.tsx b/apps/start/src/hooks/use-cookie-store.tsx
index 43f8b6ea8..366bba15d 100644
--- a/apps/start/src/hooks/use-cookie-store.tsx
+++ b/apps/start/src/hooks/use-cookie-store.tsx
@@ -35,7 +35,7 @@ const setCookieFn = createServerFn({ method: 'POST' })
});
// Called in __root.tsx beforeLoad hook to get cookies from the server
-// And recieved with useRouteContext in the client
+// And received with useRouteContext in the client
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
pick(VALID_COOKIES, getCookies()),
);
diff --git a/apps/start/src/modals/create-invite.tsx b/apps/start/src/modals/create-invite.tsx
index 83572f5be..e05e228a6 100644
--- a/apps/start/src/modals/create-invite.tsx
+++ b/apps/start/src/modals/create-invite.tsx
@@ -102,7 +102,7 @@ export default function CreateInvite() {
Invite a user
- Invite users to your organization. They will recieve an email
+ Invite users to your organization. They will receive an email
will instructions.
diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts
index 5d5144014..705b7c2dc 100644
--- a/apps/start/src/routeTree.gen.ts
+++ b/apps/start/src/routeTree.gen.ts
@@ -11,6 +11,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRouteImport } from './routes/__root'
+import { Route as UnsubscribeRouteImport } from './routes/unsubscribe'
import { Route as StepsRouteImport } from './routes/_steps'
import { Route as PublicRouteImport } from './routes/_public'
import { Route as LoginRouteImport } from './routes/_login'
@@ -36,6 +37,7 @@ import { Route as AppOrganizationIdProjectIdRouteImport } from './routes/_app.$o
import { Route as AppOrganizationIdProjectIdIndexRouteImport } from './routes/_app.$organizationId.$projectId.index'
import { Route as StepsOnboardingProjectIdVerifyRouteImport } from './routes/_steps.onboarding.$projectId.verify'
import { Route as StepsOnboardingProjectIdConnectRouteImport } from './routes/_steps.onboarding.$projectId.connect'
+import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.$organizationId.profile._tabs'
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
@@ -46,8 +48,10 @@ import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_a
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
+import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index'
import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index'
import { Route as AppOrganizationIdIntegrationsTabsIndexRouteImport } from './routes/_app.$organizationId.integrations._tabs.index'
+import { Route as AppOrganizationIdProfileTabsEmailPreferencesRouteImport } from './routes/_app.$organizationId.profile._tabs.email-preferences'
import { Route as AppOrganizationIdMembersTabsMembersRouteImport } from './routes/_app.$organizationId.members._tabs.members'
import { Route as AppOrganizationIdMembersTabsInvitationsRouteImport } from './routes/_app.$organizationId.members._tabs.invitations'
import { Route as AppOrganizationIdIntegrationsTabsInstalledRouteImport } from './routes/_app.$organizationId.integrations._tabs.installed'
@@ -80,6 +84,9 @@ import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } f
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
+const AppOrganizationIdProfileRouteImport = createFileRoute(
+ '/_app/$organizationId/profile',
+)()
const AppOrganizationIdMembersRouteImport = createFileRoute(
'/_app/$organizationId/members',
)()
@@ -102,6 +109,11 @@ const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/profiles/$profileId',
)()
+const UnsubscribeRoute = UnsubscribeRouteImport.update({
+ id: '/unsubscribe',
+ path: '/unsubscribe',
+ getParentRoute: () => rootRouteImport,
+} as any)
const StepsRoute = StepsRouteImport.update({
id: '/_steps',
getParentRoute: () => rootRouteImport,
@@ -168,6 +180,12 @@ const AppOrganizationIdRoute = AppOrganizationIdRouteImport.update({
path: '/$organizationId',
getParentRoute: () => AppRoute,
} as any)
+const AppOrganizationIdProfileRoute =
+ AppOrganizationIdProfileRouteImport.update({
+ id: '/profile',
+ path: '/profile',
+ getParentRoute: () => AppOrganizationIdRoute,
+ } as any)
const AppOrganizationIdMembersRoute =
AppOrganizationIdMembersRouteImport.update({
id: '/members',
@@ -265,6 +283,11 @@ const StepsOnboardingProjectIdConnectRoute =
path: '/onboarding/$projectId/connect',
getParentRoute: () => StepsRoute,
} as any)
+const AppOrganizationIdProfileTabsRoute =
+ AppOrganizationIdProfileTabsRouteImport.update({
+ id: '/_tabs',
+ getParentRoute: () => AppOrganizationIdProfileRoute,
+ } as any)
const AppOrganizationIdMembersTabsRoute =
AppOrganizationIdMembersTabsRouteImport.update({
id: '/_tabs',
@@ -329,6 +352,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute =
path: '/$profileId',
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
} as any)
+const AppOrganizationIdProfileTabsIndexRoute =
+ AppOrganizationIdProfileTabsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => AppOrganizationIdProfileTabsRoute,
+ } as any)
const AppOrganizationIdMembersTabsIndexRoute =
AppOrganizationIdMembersTabsIndexRouteImport.update({
id: '/',
@@ -341,6 +370,12 @@ const AppOrganizationIdIntegrationsTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute,
} as any)
+const AppOrganizationIdProfileTabsEmailPreferencesRoute =
+ AppOrganizationIdProfileTabsEmailPreferencesRouteImport.update({
+ id: '/email-preferences',
+ path: '/email-preferences',
+ getParentRoute: () => AppOrganizationIdProfileTabsRoute,
+ } as any)
const AppOrganizationIdMembersTabsMembersRoute =
AppOrganizationIdMembersTabsMembersRouteImport.update({
id: '/members',
@@ -525,6 +560,7 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/unsubscribe': typeof UnsubscribeRoute
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
'/login': typeof LoginLoginRoute
'/reset-password': typeof LoginResetPasswordRoute
@@ -552,6 +588,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
+ '/$organizationId/profile': typeof AppOrganizationIdProfileTabsRouteWithChildren
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
@@ -566,8 +603,10 @@ export interface FileRoutesByFullPath {
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
+ '/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
'/$organizationId/integrations/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members/': typeof AppOrganizationIdMembersTabsIndexRoute
+ '/$organizationId/profile/': typeof AppOrganizationIdProfileTabsIndexRoute
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
@@ -591,6 +630,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/unsubscribe': typeof UnsubscribeRoute
'/login': typeof LoginLoginRoute
'/reset-password': typeof LoginResetPasswordRoute
'/onboarding': typeof PublicOnboardingRoute
@@ -616,6 +656,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
+ '/$organizationId/profile': typeof AppOrganizationIdProfileTabsIndexRoute
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
@@ -630,6 +671,7 @@ export interface FileRoutesByTo {
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
+ '/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
@@ -653,6 +695,7 @@ export interface FileRoutesById {
'/_login': typeof LoginRouteWithChildren
'/_public': typeof PublicRouteWithChildren
'/_steps': typeof StepsRouteWithChildren
+ '/unsubscribe': typeof UnsubscribeRoute
'/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren
'/_login/login': typeof LoginLoginRoute
'/_login/reset-password': typeof LoginResetPasswordRoute
@@ -682,6 +725,8 @@ export interface FileRoutesById {
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/_app/$organizationId/members': typeof AppOrganizationIdMembersRouteWithChildren
'/_app/$organizationId/members/_tabs': typeof AppOrganizationIdMembersTabsRouteWithChildren
+ '/_app/$organizationId/profile': typeof AppOrganizationIdProfileRouteWithChildren
+ '/_app/$organizationId/profile/_tabs': typeof AppOrganizationIdProfileTabsRouteWithChildren
'/_steps/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
'/_steps/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
'/_app/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
@@ -700,8 +745,10 @@ export interface FileRoutesById {
'/_app/$organizationId/integrations/_tabs/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
'/_app/$organizationId/members/_tabs/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
'/_app/$organizationId/members/_tabs/members': typeof AppOrganizationIdMembersTabsMembersRoute
+ '/_app/$organizationId/profile/_tabs/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
'/_app/$organizationId/integrations/_tabs/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/_app/$organizationId/members/_tabs/': typeof AppOrganizationIdMembersTabsIndexRoute
+ '/_app/$organizationId/profile/_tabs/': typeof AppOrganizationIdProfileTabsIndexRoute
'/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
@@ -728,6 +775,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/unsubscribe'
| '/$organizationId'
| '/login'
| '/reset-password'
@@ -755,6 +803,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
+ | '/$organizationId/profile'
| '/onboarding/$projectId/connect'
| '/onboarding/$projectId/verify'
| '/$organizationId/$projectId/'
@@ -769,8 +818,10 @@ export interface FileRouteTypes {
| '/$organizationId/integrations/installed'
| '/$organizationId/members/invitations'
| '/$organizationId/members/members'
+ | '/$organizationId/profile/email-preferences'
| '/$organizationId/integrations/'
| '/$organizationId/members/'
+ | '/$organizationId/profile/'
| '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
@@ -794,6 +845,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/unsubscribe'
| '/login'
| '/reset-password'
| '/onboarding'
@@ -819,6 +871,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
+ | '/$organizationId/profile'
| '/onboarding/$projectId/connect'
| '/onboarding/$projectId/verify'
| '/$organizationId/$projectId'
@@ -833,6 +886,7 @@ export interface FileRouteTypes {
| '/$organizationId/integrations/installed'
| '/$organizationId/members/invitations'
| '/$organizationId/members/members'
+ | '/$organizationId/profile/email-preferences'
| '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
@@ -855,6 +909,7 @@ export interface FileRouteTypes {
| '/_login'
| '/_public'
| '/_steps'
+ | '/unsubscribe'
| '/_app/$organizationId'
| '/_login/login'
| '/_login/reset-password'
@@ -884,6 +939,8 @@ export interface FileRouteTypes {
| '/_app/$organizationId/integrations/_tabs'
| '/_app/$organizationId/members'
| '/_app/$organizationId/members/_tabs'
+ | '/_app/$organizationId/profile'
+ | '/_app/$organizationId/profile/_tabs'
| '/_steps/onboarding/$projectId/connect'
| '/_steps/onboarding/$projectId/verify'
| '/_app/$organizationId/$projectId/'
@@ -902,8 +959,10 @@ export interface FileRouteTypes {
| '/_app/$organizationId/integrations/_tabs/installed'
| '/_app/$organizationId/members/_tabs/invitations'
| '/_app/$organizationId/members/_tabs/members'
+ | '/_app/$organizationId/profile/_tabs/email-preferences'
| '/_app/$organizationId/integrations/_tabs/'
| '/_app/$organizationId/members/_tabs/'
+ | '/_app/$organizationId/profile/_tabs/'
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
| '/_app/$organizationId/$projectId/events/_tabs/events'
| '/_app/$organizationId/$projectId/events/_tabs/stats'
@@ -933,6 +992,7 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRouteWithChildren
PublicRoute: typeof PublicRouteWithChildren
StepsRoute: typeof StepsRouteWithChildren
+ UnsubscribeRoute: typeof UnsubscribeRoute
ApiConfigRoute: typeof ApiConfigRoute
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
WidgetCounterRoute: typeof WidgetCounterRoute
@@ -945,6 +1005,13 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/unsubscribe': {
+ id: '/unsubscribe'
+ path: '/unsubscribe'
+ fullPath: '/unsubscribe'
+ preLoaderRoute: typeof UnsubscribeRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/_steps': {
id: '/_steps'
path: ''
@@ -1043,6 +1110,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdRouteImport
parentRoute: typeof AppRoute
}
+ '/_app/$organizationId/profile': {
+ id: '/_app/$organizationId/profile'
+ path: '/profile'
+ fullPath: '/$organizationId/profile'
+ preLoaderRoute: typeof AppOrganizationIdProfileRouteImport
+ parentRoute: typeof AppOrganizationIdRoute
+ }
'/_app/$organizationId/members': {
id: '/_app/$organizationId/members'
path: '/members'
@@ -1162,6 +1236,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StepsOnboardingProjectIdConnectRouteImport
parentRoute: typeof StepsRoute
}
+ '/_app/$organizationId/profile/_tabs': {
+ id: '/_app/$organizationId/profile/_tabs'
+ path: '/profile'
+ fullPath: '/$organizationId/profile'
+ preLoaderRoute: typeof AppOrganizationIdProfileTabsRouteImport
+ parentRoute: typeof AppOrganizationIdProfileRoute
+ }
'/_app/$organizationId/members/_tabs': {
id: '/_app/$organizationId/members/_tabs'
path: '/members'
@@ -1239,6 +1320,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
}
+ '/_app/$organizationId/profile/_tabs/': {
+ id: '/_app/$organizationId/profile/_tabs/'
+ path: '/'
+ fullPath: '/$organizationId/profile/'
+ preLoaderRoute: typeof AppOrganizationIdProfileTabsIndexRouteImport
+ parentRoute: typeof AppOrganizationIdProfileTabsRoute
+ }
'/_app/$organizationId/members/_tabs/': {
id: '/_app/$organizationId/members/_tabs/'
path: '/'
@@ -1253,6 +1341,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute
}
+ '/_app/$organizationId/profile/_tabs/email-preferences': {
+ id: '/_app/$organizationId/profile/_tabs/email-preferences'
+ path: '/email-preferences'
+ fullPath: '/$organizationId/profile/email-preferences'
+ preLoaderRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRouteImport
+ parentRoute: typeof AppOrganizationIdProfileTabsRoute
+ }
'/_app/$organizationId/members/_tabs/members': {
id: '/_app/$organizationId/members/_tabs/members'
path: '/members'
@@ -1797,6 +1892,39 @@ const AppOrganizationIdMembersRouteWithChildren =
AppOrganizationIdMembersRouteChildren,
)
+interface AppOrganizationIdProfileTabsRouteChildren {
+ AppOrganizationIdProfileTabsEmailPreferencesRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
+ AppOrganizationIdProfileTabsIndexRoute: typeof AppOrganizationIdProfileTabsIndexRoute
+}
+
+const AppOrganizationIdProfileTabsRouteChildren: AppOrganizationIdProfileTabsRouteChildren =
+ {
+ AppOrganizationIdProfileTabsEmailPreferencesRoute:
+ AppOrganizationIdProfileTabsEmailPreferencesRoute,
+ AppOrganizationIdProfileTabsIndexRoute:
+ AppOrganizationIdProfileTabsIndexRoute,
+ }
+
+const AppOrganizationIdProfileTabsRouteWithChildren =
+ AppOrganizationIdProfileTabsRoute._addFileChildren(
+ AppOrganizationIdProfileTabsRouteChildren,
+ )
+
+interface AppOrganizationIdProfileRouteChildren {
+ AppOrganizationIdProfileTabsRoute: typeof AppOrganizationIdProfileTabsRouteWithChildren
+}
+
+const AppOrganizationIdProfileRouteChildren: AppOrganizationIdProfileRouteChildren =
+ {
+ AppOrganizationIdProfileTabsRoute:
+ AppOrganizationIdProfileTabsRouteWithChildren,
+ }
+
+const AppOrganizationIdProfileRouteWithChildren =
+ AppOrganizationIdProfileRoute._addFileChildren(
+ AppOrganizationIdProfileRouteChildren,
+ )
+
interface AppOrganizationIdRouteChildren {
AppOrganizationIdProjectIdRoute: typeof AppOrganizationIdProjectIdRouteWithChildren
AppOrganizationIdBillingRoute: typeof AppOrganizationIdBillingRoute
@@ -1804,6 +1932,7 @@ interface AppOrganizationIdRouteChildren {
AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute
AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren
AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren
+ AppOrganizationIdProfileRoute: typeof AppOrganizationIdProfileRouteWithChildren
}
const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
@@ -1814,6 +1943,7 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
AppOrganizationIdIntegrationsRoute:
AppOrganizationIdIntegrationsRouteWithChildren,
AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren,
+ AppOrganizationIdProfileRoute: AppOrganizationIdProfileRouteWithChildren,
}
const AppOrganizationIdRouteWithChildren =
@@ -1872,6 +2002,7 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRouteWithChildren,
PublicRoute: PublicRouteWithChildren,
StepsRoute: StepsRouteWithChildren,
+ UnsubscribeRoute: UnsubscribeRoute,
ApiConfigRoute: ApiConfigRoute,
ApiHealthcheckRoute: ApiHealthcheckRoute,
WidgetCounterRoute: WidgetCounterRoute,
diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx
new file mode 100644
index 000000000..f4fbb29e6
--- /dev/null
+++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx
@@ -0,0 +1,139 @@
+import { WithLabel } from '@/components/forms/input-with-label';
+import FullPageLoadingState from '@/components/full-page-loading-state';
+import { Button } from '@/components/ui/button';
+import { Switch } from '@/components/ui/switch';
+import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
+import { useTRPC } from '@/integrations/trpc/react';
+import { handleError } from '@/integrations/trpc/react';
+import { emailCategories } from '@openpanel/constants';
+import { useSuspenseQuery } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
+import { SaveIcon } from 'lucide-react';
+import { Controller, useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import { z } from 'zod';
+
+const validator = z.object({
+ categories: z.record(z.string(), z.boolean()),
+});
+
+type IForm = z.infer;
+
+/**
+ * Build explicit boolean values for every key in emailCategories.
+ * Uses saved preferences when available, falling back to true (opted-in).
+ */
+function buildCategoryDefaults(
+ savedPreferences?: Record,
+): Record {
+ return Object.keys(emailCategories).reduce(
+ (acc, category) => {
+ acc[category] = savedPreferences?.[category] ?? true;
+ return acc;
+ },
+ {} as Record,
+ );
+}
+
+export const Route = createFileRoute(
+ '/_app/$organizationId/profile/_tabs/email-preferences',
+)({
+ component: Component,
+ pendingComponent: FullPageLoadingState,
+});
+
+function Component() {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+
+ const preferencesQuery = useSuspenseQuery(
+ trpc.email.getPreferences.queryOptions(),
+ );
+
+ const { control, handleSubmit, formState, reset } = useForm({
+ defaultValues: {
+ categories: buildCategoryDefaults(preferencesQuery.data),
+ },
+ });
+
+ const mutation = useMutation(
+ trpc.email.updatePreferences.mutationOptions({
+ onSuccess: async () => {
+ toast('Email preferences updated', {
+ description: 'Your email preferences have been saved.',
+ });
+ await queryClient.invalidateQueries(
+ trpc.email.getPreferences.pathFilter(),
+ );
+ // Reset form with fresh data after refetch
+ const freshData = await queryClient.fetchQuery(
+ trpc.email.getPreferences.queryOptions(),
+ );
+ reset({
+ categories: buildCategoryDefaults(freshData),
+ });
+ },
+ onError: handleError,
+ }),
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx
new file mode 100644
index 000000000..9791abed6
--- /dev/null
+++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx
@@ -0,0 +1,96 @@
+import { InputWithLabel } from '@/components/forms/input-with-label';
+import FullPageLoadingState from '@/components/full-page-loading-state';
+import { PageContainer } from '@/components/page-container';
+import { PageHeader } from '@/components/page-header';
+import { Button } from '@/components/ui/button';
+import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
+import { handleError, useTRPC } from '@/integrations/trpc/react';
+import { useSuspenseQuery } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
+import { SaveIcon } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import { z } from 'zod';
+
+const validator = z.object({
+ firstName: z.string(),
+ lastName: z.string(),
+});
+
+type IForm = z.infer;
+
+export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({
+ component: Component,
+ pendingComponent: FullPageLoadingState,
+});
+
+function Component() {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const session = useSuspenseQuery(trpc.auth.session.queryOptions());
+ const user = session.data?.user;
+
+ const { register, handleSubmit, formState, reset } = useForm({
+ defaultValues: {
+ firstName: user?.firstName ?? '',
+ lastName: user?.lastName ?? '',
+ },
+ });
+
+ const mutation = useMutation(
+ trpc.user.update.mutationOptions({
+ onSuccess: (data) => {
+ toast('Profile updated', {
+ description: 'Your profile has been updated.',
+ });
+ queryClient.invalidateQueries(trpc.auth.session.pathFilter());
+ reset({
+ firstName: data.firstName ?? '',
+ lastName: data.lastName ?? '',
+ });
+ },
+ onError: handleError,
+ }),
+ );
+
+ if (!user) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
new file mode 100644
index 000000000..ffcbb9a37
--- /dev/null
+++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
@@ -0,0 +1,55 @@
+import FullPageLoadingState from '@/components/full-page-loading-state';
+import { PageContainer } from '@/components/page-container';
+import { PageHeader } from '@/components/page-header';
+import { ProfileAvatar } from '@/components/profiles/profile-avatar';
+import { SerieIcon } from '@/components/report-chart/common/serie-icon';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { usePageTabs } from '@/hooks/use-page-tabs';
+import { useTRPC } from '@/integrations/trpc/react';
+import { getProfileName } from '@/utils/getters';
+import { useSuspenseQuery } from '@tanstack/react-query';
+import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app/$organizationId/profile/_tabs')({
+ component: Component,
+ pendingComponent: FullPageLoadingState,
+});
+
+function Component() {
+ const router = useRouter();
+ const { activeTab, tabs } = usePageTabs([
+ {
+ id: '/$organizationId/profile',
+ label: 'Profile',
+ },
+ { id: 'email-preferences', label: 'Email preferences' },
+ ]);
+
+ const handleTabChange = (tabId: string) => {
+ router.navigate({
+ from: Route.fullPath,
+ to: tabId,
+ });
+ };
+
+ return (
+
+
+
+
+
+ {tabs.map((tab) => (
+
+ {tab.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/start/src/routes/unsubscribe.tsx b/apps/start/src/routes/unsubscribe.tsx
new file mode 100644
index 000000000..d33be89f6
--- /dev/null
+++ b/apps/start/src/routes/unsubscribe.tsx
@@ -0,0 +1,89 @@
+import FullPageLoadingState from '@/components/full-page-loading-state';
+import { PublicPageCard } from '@/components/public-page-card';
+import { Button, LinkButton } from '@/components/ui/button';
+import { useTRPC } from '@/integrations/trpc/react';
+import { emailCategories } from '@openpanel/constants';
+import { useMutation } from '@tanstack/react-query';
+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(null);
+
+ const unsubscribeMutation = useMutation(
+ trpc.email.unsubscribe.mutationOptions({
+ 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 (
+
+ );
+ }
+
+ return (
+
+ Unsubscribe from {categoryName} emails? You'll stop receiving{' '}
+ {categoryName.toLowerCase()} emails sent to
+ {email}
+ >
+ }
+ >
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
+
+
+ Cancel
+
+
+
+ );
+}
diff --git a/apps/worker/package.json b/apps/worker/package.json
index c8ae019e3..7c3c15722 100644
--- a/apps/worker/package.json
+++ b/apps/worker/package.json
@@ -20,9 +20,11 @@
"@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*",
"@openpanel/importer": "workspace:*",
+ "@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*",
"@openpanel/redis": "workspace:*",
"bullmq": "^5.63.0",
+ "date-fns": "^3.3.1",
"express": "^4.18.2",
"groupmq": "catalog:",
"prom-client": "^15.1.3",
diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts
index a67c1e7f7..9eb558c68 100644
--- a/apps/worker/src/boot-cron.ts
+++ b/apps/worker/src/boot-cron.ts
@@ -39,6 +39,11 @@ export async function bootCron() {
type: 'insightsDaily',
pattern: '0 2 * * *',
},
+ {
+ name: 'onboarding',
+ type: 'onboarding',
+ pattern: '0 * * * *',
+ },
];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
diff --git a/apps/worker/src/boot-workers.ts b/apps/worker/src/boot-workers.ts
index 4b739afcf..6d96dd61f 100644
--- a/apps/worker/src/boot-workers.ts
+++ b/apps/worker/src/boot-workers.ts
@@ -281,10 +281,20 @@ export async function bootWorkers() {
eventName: string,
evtOrExitCodeOrError: number | string | Error,
) {
- logger.info('Starting graceful shutdown', {
- code: evtOrExitCodeOrError,
- eventName,
- });
+ // Log the actual error details for unhandled rejections/exceptions
+ if (evtOrExitCodeOrError instanceof Error) {
+ logger.error('Unhandled error triggered shutdown', {
+ eventName,
+ message: evtOrExitCodeOrError.message,
+ stack: evtOrExitCodeOrError.stack,
+ name: evtOrExitCodeOrError.name,
+ });
+ } else {
+ logger.info('Starting graceful shutdown', {
+ code: evtOrExitCodeOrError,
+ eventName,
+ });
+ }
try {
const time = performance.now();
diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts
new file mode 100644
index 000000000..d2e61aaaa
--- /dev/null
+++ b/apps/worker/src/jobs/cron.onboarding.ts
@@ -0,0 +1,276 @@
+import type { Job } from 'bullmq';
+import { differenceInDays } from 'date-fns';
+
+import { db } from '@openpanel/db';
+import {
+ type EmailData,
+ type EmailTemplate,
+ sendEmail,
+} from '@openpanel/email';
+import type { CronQueuePayload } from '@openpanel/queue';
+
+import { getRecommendedPlan } from '@openpanel/payments';
+import { logger } from '../utils/logger';
+
+// Types for the onboarding email system
+const orgQuery = {
+ include: {
+ createdBy: {
+ select: {
+ id: true,
+ email: true,
+ firstName: true,
+ deletedAt: true,
+ },
+ },
+ },
+} as const;
+
+type OrgWithCreator = Awaited<
+ ReturnType>
+>[number];
+
+type OnboardingContext = {
+ org: OrgWithCreator;
+ user: NonNullable;
+};
+
+type OnboardingEmail = {
+ day: number;
+ template: T;
+ shouldSend?: (ctx: OnboardingContext) => Promise;
+ data: (ctx: OnboardingContext) => EmailData;
+};
+
+// Helper to create type-safe email entries with correlated template/data types
+function email(config: OnboardingEmail) {
+ 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-feature-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) {
+ if (process.env.SELF_HOSTED === 'true') {
+ return null;
+ }
+
+ logger.info('Starting onboarding email job');
+
+ // Fetch organizations that are in onboarding (not completed)
+ const orgs = await db.organization.findMany({
+ where: {
+ OR: [{ onboarding: null }, { onboarding: { notIn: ['completed'] } }],
+ deleteAt: null,
+ createdBy: {
+ deletedAt: null,
+ },
+ },
+ ...orgQuery,
+ });
+
+ logger.info(`Found ${orgs.length} organizations in onboarding`);
+
+ let emailsSent = 0;
+ let orgsCompleted = 0;
+ let orgsSkipped = 0;
+
+ for (const org of orgs) {
+ // Skip if no creator or creator is deleted
+ if (!org.createdBy || org.createdBy.deletedAt) {
+ orgsSkipped++;
+ continue;
+ }
+
+ const user = org.createdBy;
+ const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt);
+
+ // Find the next email to send
+ // If org.onboarding is null or 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;
+ }
+
+ const nextEmail = ONBOARDING_EMAILS[nextEmailIndex];
+ if (!nextEmail) {
+ continue;
+ }
+
+ logger.info(
+ `Checking if enough days have passed for organization ${org.id}`,
+ {
+ daysSinceOrgCreation,
+ nextEmailDay: nextEmail.day,
+ orgCreatedAt: org.createdAt,
+ today: new Date(),
+ },
+ );
+ // Check if enough days have passed
+ if (daysSinceOrgCreation < nextEmail.day) {
+ orgsSkipped++;
+ continue;
+ }
+
+ // Check shouldSend callback if defined
+ if (nextEmail.shouldSend) {
+ const result = await nextEmail.shouldSend({ org, user });
+
+ if (result === 'complete') {
+ await db.organization.update({
+ where: { id: org.id },
+ data: { onboarding: 'completed' },
+ });
+ orgsCompleted++;
+ logger.info(
+ `Completed onboarding for organization ${org.id} (shouldSend returned complete)`,
+ );
+ continue;
+ }
+
+ if (result === false) {
+ orgsSkipped++;
+ continue;
+ }
+ }
+
+ 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++;
+ logger.info(
+ `Sent onboarding email "${nextEmail.template}" to organization ${org.id} (user ${user.id})`,
+ );
+ } catch (error) {
+ logger.error(
+ `Failed to send onboarding email to organization ${org.id}`,
+ {
+ error,
+ template: nextEmail.template,
+ },
+ );
+ }
+ }
+
+ logger.info('Completed onboarding email job', {
+ totalOrgs: orgs.length,
+ emailsSent,
+ orgsCompleted,
+ orgsSkipped,
+ });
+
+ return {
+ totalOrgs: orgs.length,
+ emailsSent,
+ orgsCompleted,
+ orgsSkipped,
+ };
+}
diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts
index eee51b161..135e174df 100644
--- a/apps/worker/src/jobs/cron.ts
+++ b/apps/worker/src/jobs/cron.ts
@@ -4,6 +4,7 @@ import { eventBuffer, profileBuffer, sessionBuffer } from '@openpanel/db';
import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects';
+import { onboardingJob } from './cron.onboarding';
import { ping } from './cron.ping';
import { salt } from './cron.salt';
import { insightsDailyJob } from './insights';
@@ -31,5 +32,8 @@ export async function cronJob(job: Job) {
case 'insightsDaily': {
return await insightsDailyJob(job);
}
+ case 'onboarding': {
+ return await onboardingJob(job);
+ }
}
}
diff --git a/packages/constants/index.ts b/packages/constants/index.ts
index 27cf4119f..8169f0bd3 100644
--- a/packages/constants/index.ts
+++ b/packages/constants/index.ts
@@ -3,10 +3,7 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns';
export const DEFAULT_ASPECT_RATIO = 0.5625;
export const NOT_SET_VALUE = '(not set)';
-export const RESERVED_EVENT_NAMES = [
- 'session_start',
- 'session_end',
-] as const;
+export const RESERVED_EVENT_NAMES = ['session_start', 'session_end'] as const;
export const timeWindows = {
'30min': {
@@ -508,6 +505,12 @@ export function getCountry(code?: string) {
return countries[code as keyof typeof countries];
}
+export const emailCategories = {
+ onboarding: 'Onboarding',
+} as const;
+
+export type EmailCategory = keyof typeof emailCategories;
+
export const chartColors = [
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
{ main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' },
diff --git a/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql b/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql
new file mode 100644
index 000000000..bcd49719a
--- /dev/null
+++ b/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "public"."organizations"
+ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed';
\ No newline at end of file
diff --git a/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql b/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql
new file mode 100644
index 000000000..b2c9b5847
--- /dev/null
+++ b/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql
@@ -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");
diff --git a/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql
new file mode 100644
index 000000000..cb115cd4d
--- /dev/null
+++ b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 7131231e6..784f94fb0 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -62,6 +62,7 @@ model Organization {
integrations Integration[]
invites Invite[]
timezone String?
+ onboarding String? @default("completed")
// Subscription
subscriptionId String?
@@ -610,3 +611,13 @@ model InsightEvent {
@@index([insightId, createdAt])
@@map("insight_events")
}
+
+model EmailUnsubscribe {
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
+ email String
+ category String
+ createdAt DateTime @default(now())
+
+ @@unique([email, category])
+ @@map("email_unsubscribes")
+}
diff --git a/packages/email/package.json b/packages/email/package.json
index a5664bcb0..27ae2e2ea 100644
--- a/packages/email/package.json
+++ b/packages/email/package.json
@@ -8,6 +8,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@openpanel/db": "workspace:*",
"@react-email/components": "^0.5.6",
"react": "catalog:",
"react-dom": "catalog:",
diff --git a/packages/email/src/components/button.tsx b/packages/email/src/components/button.tsx
new file mode 100644
index 000000000..563844d64
--- /dev/null
+++ b/packages/email/src/components/button.tsx
@@ -0,0 +1,24 @@
+import { Button as EmailButton } from '@react-email/components';
+import type * as React from 'react';
+
+export function Button({
+ href,
+ children,
+ style,
+}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/email/src/components/footer.tsx b/packages/email/src/components/footer.tsx
index ad4dd2136..3beead858 100644
--- a/packages/email/src/components/footer.tsx
+++ b/packages/email/src/components/footer.tsx
@@ -11,7 +11,7 @@ import React from 'react';
const baseUrl = 'https://openpanel.dev';
-export function Footer() {
+export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
return (
<>
@@ -71,15 +71,17 @@ export function Footer() {
- {/*
-
- Notification preferences
-
-
*/}
+ {unsubscribeUrl && (
+
+
+ Notification preferences
+
+
+ )}
>
);
diff --git a/packages/email/src/components/layout.tsx b/packages/email/src/components/layout.tsx
index dbf6879c0..6900b31c4 100644
--- a/packages/email/src/components/layout.tsx
+++ b/packages/email/src/components/layout.tsx
@@ -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 (
@@ -57,7 +57,7 @@ export function Layout({ children }: Props) {
/>
-
+
diff --git a/packages/email/src/components/list.tsx b/packages/email/src/components/list.tsx
new file mode 100644
index 000000000..dbbd9afd9
--- /dev/null
+++ b/packages/email/src/components/list.tsx
@@ -0,0 +1,13 @@
+import { Text } from '@react-email/components';
+
+export function List({ items }: { items: React.ReactNode[] }) {
+ return (
+
+ {items.map((node, index) => (
+
+ {node}
+
+ ))}
+
+ );
+}
diff --git a/packages/email/src/emails/email-invite.tsx b/packages/email/src/emails/email-invite.tsx
index f3e662621..472de8706 100644
--- a/packages/email/src/emails/email-invite.tsx
+++ b/packages/email/src/emails/email-invite.tsx
@@ -13,9 +13,10 @@ export default EmailInvite;
export function EmailInvite({
organizationName = 'Acme Co',
url = 'https://openpanel.dev',
-}: Props) {
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
return (
-
+
You've been invited to join {organizationName}!
If you don't have an account yet, click the button below to create one
diff --git a/packages/email/src/emails/email-reset-password.tsx b/packages/email/src/emails/email-reset-password.tsx
index 56a6fe762..cf6136f12 100644
--- a/packages/email/src/emails/email-reset-password.tsx
+++ b/packages/email/src/emails/email-reset-password.tsx
@@ -9,9 +9,12 @@ export const zEmailResetPassword = z.object({
export type Props = z.infer;
export default EmailResetPassword;
-export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
+export function EmailResetPassword({
+ url = 'https://openpanel.dev',
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
return (
-
+
You have requested to reset your password. Follow the link below to
reset your password:
diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx
index f2c47b9ff..a5d435591 100644
--- a/packages/email/src/emails/index.tsx
+++ b/packages/email/src/emails/index.tsx
@@ -3,6 +3,22 @@ import { EmailInvite, zEmailInvite } from './email-invite';
import EmailResetPassword, {
zEmailResetPassword,
} from './email-reset-password';
+import OnboardingDashboards, {
+ zOnboardingDashboards,
+} from './onboarding-dashboards';
+import OnboardingFeatureRequest, {
+ zOnboardingFeatureRequest,
+} from './onboarding-feature-request';
+import OnboardingTrialEnded, {
+ zOnboardingTrialEnded,
+} from './onboarding-trial-ended';
+import OnboardingTrialEnding, {
+ zOnboardingTrialEnding,
+} from './onboarding-trial-ending';
+import OnboardingWelcome, { zOnboardingWelcome } from './onboarding-welcome';
+import OnboardingWhatToTrack, {
+ zOnboardingWhatToTrack,
+} from './onboarding-what-to-track';
import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon';
export const templates = {
@@ -24,6 +40,40 @@ export const templates = {
Component: TrailEndingSoon,
schema: zTrailEndingSoon,
},
+ 'onboarding-welcome': {
+ subject: () => "You're in",
+ Component: OnboardingWelcome,
+ schema: zOnboardingWelcome,
+ category: 'onboarding' as const,
+ },
+ 'onboarding-what-to-track': {
+ subject: () => "What's actually worth tracking",
+ Component: OnboardingWhatToTrack,
+ schema: zOnboardingWhatToTrack,
+ category: 'onboarding' as const,
+ },
+ 'onboarding-dashboards': {
+ subject: () => 'The part most people skip',
+ Component: OnboardingDashboards,
+ schema: zOnboardingDashboards,
+ category: 'onboarding' as const,
+ },
+ 'onboarding-feature-request': {
+ subject: () => 'One provider to rule them all',
+ Component: OnboardingFeatureRequest,
+ schema: zOnboardingFeatureRequest,
+ category: 'onboarding' as const,
+ },
+ 'onboarding-trial-ending': {
+ subject: () => 'Your trial ends in a few days',
+ Component: OnboardingTrialEnding,
+ schema: zOnboardingTrialEnding,
+ },
+ 'onboarding-trial-ended': {
+ subject: () => 'Your trial has ended',
+ Component: OnboardingTrialEnded,
+ schema: zOnboardingTrialEnded,
+ },
} as const;
export type Templates = typeof templates;
diff --git a/packages/email/src/emails/onboarding-dashboards.tsx b/packages/email/src/emails/onboarding-dashboards.tsx
new file mode 100644
index 000000000..02ae98a48
--- /dev/null
+++ b/packages/email/src/emails/onboarding-dashboards.tsx
@@ -0,0 +1,65 @@
+import { Link, Text } from '@react-email/components';
+import React from 'react';
+import { z } from 'zod';
+import { Layout } from '../components/layout';
+import { List } from '../components/list';
+
+export const zOnboardingDashboards = z.object({
+ firstName: z.string().optional(),
+ dashboardUrl: z.string(),
+});
+
+export type Props = z.infer;
+export default OnboardingDashboards;
+export function OnboardingDashboards({
+ firstName,
+ dashboardUrl = 'https://dashboard.openpanel.dev',
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
+ const newUrl = new URL(dashboardUrl);
+ newUrl.searchParams.set('utm_source', 'email');
+ newUrl.searchParams.set('utm_medium', 'email');
+ newUrl.searchParams.set('utm_campaign', 'onboarding-dashboards');
+
+ return (
+
+ Hi{firstName ? ` ${firstName}` : ''},
+
+ Tracking events is the easy part. The value comes from actually looking
+ at them.
+
+
+ If you haven't yet, try building a simple dashboard. Pick one thing you
+ care about and visualize it. Could be:
+
+
+
+ This is usually when people go from "I have analytics" to "I understand
+ what's happening." It's a different feeling.
+
+ Takes maybe 10 minutes to set up. Worth it.
+
+ Best regards,
+
+ Carl
+
+
+
+
+
+ );
+}
diff --git a/packages/email/src/emails/onboarding-feature-request.tsx b/packages/email/src/emails/onboarding-feature-request.tsx
new file mode 100644
index 000000000..6cc10c20c
--- /dev/null
+++ b/packages/email/src/emails/onboarding-feature-request.tsx
@@ -0,0 +1,42 @@
+import { Link, Text } from '@react-email/components';
+import React from 'react';
+import { z } from 'zod';
+import { Layout } from '../components/layout';
+
+export const zOnboardingFeatureRequest = z.object({
+ firstName: z.string().optional(),
+});
+
+export type Props = z.infer;
+export default OnboardingFeatureRequest;
+export function OnboardingFeatureRequest({
+ firstName,
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
+ return (
+
+ Hi{firstName ? ` ${firstName}` : ''},
+
+ OpenPanel aims to be the one stop shop for all your analytics needs.
+
+
+ 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.
+
+
+ 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.
+
+
+ Feedback board
+
+
+ Best regards,
+
+ Carl
+
+
+ );
+}
diff --git a/packages/email/src/emails/onboarding-trial-ended.tsx b/packages/email/src/emails/onboarding-trial-ended.tsx
new file mode 100644
index 000000000..14be5b802
--- /dev/null
+++ b/packages/email/src/emails/onboarding-trial-ended.tsx
@@ -0,0 +1,56 @@
+import { Text } from '@react-email/components';
+import React from 'react';
+import { z } from 'zod';
+import { Button } from '../components/button';
+import { Layout } from '../components/layout';
+
+export const zOnboardingTrialEnded = z.object({
+ firstName: z.string().optional(),
+ billingUrl: z.string(),
+ recommendedPlan: z.string().optional(),
+});
+
+export type Props = z.infer;
+export default OnboardingTrialEnded;
+export function OnboardingTrialEnded({
+ firstName,
+ billingUrl = 'https://dashboard.openpanel.dev',
+ recommendedPlan,
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
+ const newUrl = new URL(billingUrl);
+ newUrl.searchParams.set('utm_source', 'email');
+ newUrl.searchParams.set('utm_medium', 'email');
+ newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ended');
+
+ return (
+
+ Hi{firstName ? ` ${firstName}` : ''},
+ Your OpenPanel trial has ended.
+
+ 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.
+
+
+ Important: If you don't upgrade within 30 days, your workspace and
+ projects will be permanently deleted.
+
+
+ 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'}
+ .
+
+
+ If you have any questions or something's holding you back, just reply to
+ this email.
+
+
+ Upgrade Now
+
+ Carl
+
+ );
+}
diff --git a/packages/email/src/emails/onboarding-trial-ending.tsx b/packages/email/src/emails/onboarding-trial-ending.tsx
new file mode 100644
index 000000000..236d3c883
--- /dev/null
+++ b/packages/email/src/emails/onboarding-trial-ending.tsx
@@ -0,0 +1,57 @@
+import { Text } from '@react-email/components';
+import React from 'react';
+import { z } from 'zod';
+import { Button } from '../components/button';
+import { Layout } from '../components/layout';
+
+export const zOnboardingTrialEnding = z.object({
+ firstName: z.string().optional(),
+ organizationName: z.string(),
+ billingUrl: z.string(),
+ recommendedPlan: z.string().optional(),
+});
+
+export type Props = z.infer;
+export default OnboardingTrialEnding;
+export function OnboardingTrialEnding({
+ firstName,
+ organizationName = 'your organization',
+ billingUrl = 'https://dashboard.openpanel.dev',
+ recommendedPlan,
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
+ const newUrl = new URL(billingUrl);
+ newUrl.searchParams.set('utm_source', 'email');
+ newUrl.searchParams.set('utm_medium', 'email');
+ newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ending');
+
+ return (
+
+ Hi{firstName ? ` ${firstName}` : ''},
+ Quick heads up: your OpenPanel trial ends soon.
+
+ Your tracking will keep working, but you won't be able to see new data
+ until you upgrade. Everything you've built so far (dashboards, reports,
+ event history) stays intact.
+
+
+ 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'}
+ .
+
+
+ If something's holding you back, I'd like to hear about it. Just reply.
+
+
+ Your project will receive events for the next 30 days, if you haven't
+ upgraded by then we'll remove your workspace and projects.
+
+
+ Upgrade Now
+
+ Carl
+
+ );
+}
diff --git a/packages/email/src/emails/onboarding-welcome.tsx b/packages/email/src/emails/onboarding-welcome.tsx
new file mode 100644
index 000000000..b2faf278e
--- /dev/null
+++ b/packages/email/src/emails/onboarding-welcome.tsx
@@ -0,0 +1,55 @@
+import { Heading, Link, Text } from '@react-email/components';
+import React from 'react';
+import { z } from 'zod';
+import { Layout } from '../components/layout';
+import { List } from '../components/list';
+
+export const zOnboardingWelcome = z.object({
+ firstName: z.string().optional(),
+ dashboardUrl: z.string(),
+});
+
+export type Props = z.infer;
+export default OnboardingWelcome;
+export function OnboardingWelcome({
+ firstName,
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
+ return (
+
+ Hi{firstName ? ` ${firstName}` : ''},
+ Thanks for trying OpenPanel.
+
+ We built OpenPanel because most analytics tools are either too
+ expensive, too complicated, or both. OpenPanel is different.
+
+
+ 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.
+
+ To get started, you can:
+
+ Install tracking script
+ ,
+
+ Start tracking your events
+ ,
+ ]}
+ />
+
+ Best regards,
+
+ Carl
+
+
+ );
+}
diff --git a/packages/email/src/emails/onboarding-what-to-track.tsx b/packages/email/src/emails/onboarding-what-to-track.tsx
new file mode 100644
index 000000000..ce3f8ecba
--- /dev/null
+++ b/packages/email/src/emails/onboarding-what-to-track.tsx
@@ -0,0 +1,46 @@
+import { Text } from '@react-email/components';
+import React from 'react';
+import { z } from 'zod';
+import { Layout } from '../components/layout';
+import { List } from '../components/list';
+
+export const zOnboardingWhatToTrack = z.object({
+ firstName: z.string().optional(),
+});
+
+export type Props = z.infer;
+export default OnboardingWhatToTrack;
+export function OnboardingWhatToTrack({
+ firstName,
+ unsubscribeUrl,
+}: Props & { unsubscribeUrl?: string }) {
+ return (
+
+ Hi{firstName ? ` ${firstName}` : ''},
+
+ 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:
+
+
+
+ Start small and incrementally add more events as you go is usually the
+ best approach.
+
+
+ If you're not sure whether something's worth tracking, or have any
+ questions, just reply here.
+
+
+ Best regards,
+
+ Carl
+
+
+ );
+}
diff --git a/packages/email/src/emails/trial-ending-soon.tsx b/packages/email/src/emails/trial-ending-soon.tsx
index 77e7e3ae9..bf6e7034a 100644
--- a/packages/email/src/emails/trial-ending-soon.tsx
+++ b/packages/email/src/emails/trial-ending-soon.tsx
@@ -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');
diff --git a/packages/email/src/index.tsx b/packages/email/src/index.tsx
index e61bcd58e..4367408ad 100644
--- a/packages/email/src/index.tsx
+++ b/packages/email/src/index.tsx
@@ -2,48 +2,81 @@ import React from 'react';
import { Resend } from 'resend';
import type { z } from 'zod';
+import { db } from '@openpanel/db';
import { type TemplateKey, type Templates, templates } from './emails';
+import { getUnsubscribeUrl } from './unsubscribe';
+
+export * from './unsubscribe';
const FROM = process.env.EMAIL_SENDER ?? 'hello@openpanel.dev';
+export type EmailData = z.infer;
+export type EmailTemplate = keyof Templates;
+
export async function sendEmail(
- template: T,
+ templateKey: T,
options: {
- to: string | string[];
+ to: string;
data: z.infer;
},
) {
const { to, data } = options;
- const { subject, Component, schema } = templates[template];
- const props = schema.safeParse(data);
+ const template = templates[templateKey];
+ const props = template.schema.safeParse(data);
if (!props.success) {
console.error('Failed to parse data', props.error);
return null;
}
+ if ('category' in template && template.category) {
+ const unsubscribed = await db.emailUnsubscribe.findUnique({
+ where: {
+ email_category: {
+ email: to,
+ category: template.category,
+ },
+ },
+ });
+
+ if (unsubscribed) {
+ console.log(
+ `Skipping email to ${to} - unsubscribed from ${template.category}`,
+ );
+ return null;
+ }
+ }
+
if (!process.env.RESEND_API_KEY) {
console.log('No RESEND_API_KEY found, here is the data');
- console.log(data);
+ console.log('Template:', template);
+ console.log('Subject: ', template.subject(props.data as any));
+ console.log('To: ', to);
+ console.log('Data: ', JSON.stringify(data, null, 2));
return null;
}
const resend = new Resend(process.env.RESEND_API_KEY);
+ const headers: Record = {};
+ if ('category' in template && template.category) {
+ const unsubscribeUrl = getUnsubscribeUrl(to, template.category);
+ (props.data as any).unsubscribeUrl = unsubscribeUrl;
+ headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
+ headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
+ }
+
try {
const res = await resend.emails.send({
from: FROM,
to,
- // @ts-expect-error - TODO: fix this
- subject: subject(props.data),
- // @ts-expect-error - TODO: fix this
- react: ,
+ subject: template.subject(props.data as any),
+ react: ,
+ 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);
diff --git a/packages/email/src/unsubscribe.ts b/packages/email/src/unsubscribe.ts
new file mode 100644
index 000000000..1a6ced129
--- /dev/null
+++ b/packages/email/src/unsubscribe.ts
@@ -0,0 +1,39 @@
+import { createHmac, timingSafeEqual } from 'crypto';
+
+const SECRET =
+ process.env.UNSUBSCRIBE_SECRET ||
+ process.env.COOKIE_SECRET ||
+ process.env.SECRET ||
+ 'default-secret-change-in-production';
+
+export function generateUnsubscribeToken(email: string, category: string): string {
+ const data = `${email}:${category}`;
+ return createHmac('sha256', SECRET).update(data).digest('hex');
+}
+
+export function verifyUnsubscribeToken(
+ email: string,
+ category: string,
+ token: string,
+): boolean {
+ const expectedToken = generateUnsubscribeToken(email, category);
+ const tokenBuffer = Buffer.from(token, 'hex');
+ const expectedBuffer = Buffer.from(expectedToken, 'hex');
+
+ // Handle length mismatch safely to avoid timing leaks
+ if (tokenBuffer.length !== expectedBuffer.length) {
+ // Compare against zero-filled buffer of same length as token to maintain constant time
+ const zeroBuffer = Buffer.alloc(tokenBuffer.length);
+ timingSafeEqual(tokenBuffer, zeroBuffer);
+ return false;
+ }
+
+ return timingSafeEqual(tokenBuffer, expectedBuffer);
+}
+
+export function getUnsubscribeUrl(email: string, category: string): string {
+ const token = generateUnsubscribeToken(email, category);
+ const params = new URLSearchParams({ email, category, token });
+ const dashboardUrl = process.env.DASHBOARD_URL || 'http://localhost:3000';
+ return `${dashboardUrl}/unsubscribe?${params.toString()}`;
+}
diff --git a/packages/payments/src/prices.ts b/packages/payments/src/prices.ts
index dbe728b4f..6e3fb719f 100644
--- a/packages/payments/src/prices.ts
+++ b/packages/payments/src/prices.ts
@@ -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(
+ 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),
+ });
+}
diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts
index 19076abc7..32b21a4cf 100644
--- a/packages/queue/src/queues.ts
+++ b/packages/queue/src/queues.ts
@@ -115,6 +115,10 @@ export type CronQueuePayloadInsightsDaily = {
type: 'insightsDaily';
payload: undefined;
};
+export type CronQueuePayloadOnboarding = {
+ type: 'onboarding';
+ payload: undefined;
+};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
@@ -122,7 +126,8 @@ export type CronQueuePayload =
| CronQueuePayloadFlushProfiles
| CronQueuePayloadPing
| CronQueuePayloadProject
- | CronQueuePayloadInsightsDaily;
+ | CronQueuePayloadInsightsDaily
+ | CronQueuePayloadOnboarding;
export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon';
@@ -254,18 +259,3 @@ export const insightsQueue = new Queue(
},
},
);
-
-export function addTrialEndingSoonJob(organizationId: string, delay: number) {
- return miscQueue.add(
- 'misc',
- {
- type: 'trialEndingSoon',
- payload: {
- organizationId,
- },
- },
- {
- delay,
- },
- );
-}
diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts
index 88c19045c..068a321dd 100644
--- a/packages/trpc/src/root.ts
+++ b/packages/trpc/src/root.ts
@@ -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
diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts
index fa22019ad..912b6a87f 100644
--- a/packages/trpc/src/routers/auth.ts
+++ b/packages/trpc/src/routers/auth.ts
@@ -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 = '';
diff --git a/packages/trpc/src/routers/email.ts b/packages/trpc/src/routers/email.ts
new file mode 100644
index 000000000..d912cb0a5
--- /dev/null
+++ b/packages/trpc/src/routers/email.ts
@@ -0,0 +1,114 @@
+import { emailCategories } from '@openpanel/constants';
+import { db } from '@openpanel/db';
+import { verifyUnsubscribeToken } from '@openpanel/email';
+import { z } from 'zod';
+import { TRPCBadRequestError } from '../errors';
+import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
+
+export const emailRouter = createTRPCRouter({
+ unsubscribe: publicProcedure
+ .input(
+ z.object({
+ email: z.string().email(),
+ category: z.string(),
+ token: z.string(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { email, category, token } = input;
+
+ // Verify token
+ if (!verifyUnsubscribeToken(email, category, token)) {
+ throw TRPCBadRequestError('Invalid unsubscribe link');
+ }
+
+ // Upsert the unsubscribe record
+ await db.emailUnsubscribe.upsert({
+ where: {
+ email_category: {
+ email,
+ category,
+ },
+ },
+ create: {
+ email,
+ category,
+ },
+ update: {},
+ });
+
+ return { success: true };
+ }),
+
+ getPreferences: protectedProcedure.query(async ({ ctx }) => {
+ if (!ctx.session.userId || !ctx.session.user?.email) {
+ throw new Error('User not authenticated');
+ }
+
+ const email = ctx.session.user.email;
+
+ // Get all unsubscribe records for this user
+ const unsubscribes = await db.emailUnsubscribe.findMany({
+ where: {
+ email,
+ },
+ select: {
+ category: true,
+ },
+ });
+
+ const unsubscribedCategories = new Set(unsubscribes.map((u) => u.category));
+
+ // Return object with all categories, true = subscribed (not unsubscribed)
+ const preferences: Record = {};
+ 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 };
+ }),
+});
diff --git a/packages/trpc/src/routers/onboarding.ts b/packages/trpc/src/routers/onboarding.ts
index 071b5a7ec..12494bc5e 100644
--- a/packages/trpc/src/routers/onboarding.ts
+++ b/packages/trpc/src/routers/onboarding.ts
@@ -8,7 +8,6 @@ import { zOnboardingProject } from '@openpanel/validation';
import { hashPassword } from '@openpanel/common/server';
import { addDays } from 'date-fns';
-import { addTrialEndingSoonJob, miscQueue } from '../../../queue';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
async function createOrGetOrganization(
@@ -30,16 +29,10 @@ async function createOrGetOrganization(
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
subscriptionStatus: 'trialing',
timezone: input.timezone,
+ onboarding: '',
},
});
- if (!process.env.SELF_HOSTED) {
- await addTrialEndingSoonJob(
- organization.id,
- 1000 * 60 * 60 * 24 * TRIAL_DURATION_IN_DAYS * 0.9,
- );
- }
-
return organization;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e3106017d..6fb2d4d7b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -880,6 +880,9 @@ importers:
'@openpanel/logger':
specifier: workspace:*
version: link:../../packages/logger
+ '@openpanel/payments':
+ specifier: workspace:*
+ version: link:../../packages/payments
'@openpanel/queue':
specifier: workspace:*
version: link:../../packages/queue
@@ -889,6 +892,9 @@ importers:
bullmq:
specifier: ^5.63.0
version: 5.63.0
+ date-fns:
+ specifier: ^3.3.1
+ version: 3.3.1
express:
specifier: ^4.18.2
version: 4.18.2
@@ -1133,6 +1139,9 @@ importers:
packages/email:
dependencies:
+ '@openpanel/db':
+ specifier: workspace:*
+ version: link:../db
'@react-email/components':
specifier: ^0.5.6
version: 0.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -16527,6 +16536,7 @@ packages:
tar@6.2.0:
resolution: {integrity: sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==}
engines: {node: '>=10'}
+ deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}