5 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
12e8c9beaa remove template 2026-01-21 19:50:21 +01:00
Carl-Gerhard Lindesvärd
f9b1ec5038 fix coderabbit comments 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
3fa1a5429e wip 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
a58761e8d7 wip 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
56f1c5e894 wip 2026-01-21 15:31:58 +01:00
43 changed files with 1604 additions and 114 deletions

View File

@@ -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,28 +43,19 @@ export function ShareEnterPassword({
});
});
const typeLabel =
shareType === 'dashboard'
? 'Dashboard'
: shareType === 'report'
? 'Report'
: 'Overview';
return (
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">
{shareType === 'dashboard'
? 'Dashboard is locked'
: shareType === 'report'
? 'Report is locked'
: 'Overview is locked'}
</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this{' '}
{shareType === 'dashboard'
? 'dashboard'
: shareType === 'report'
? 'report'
: 'overview'}
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">
<PublicPageCard
title={`${typeLabel} is locked`}
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
>
<form onSubmit={onSubmit} className="col gap-4">
<Input
{...form.register('password')}
type="password"
@@ -73,24 +64,6 @@ export function ShareEnterPassword({
/>
<Button type="submit">Get access</Button>
</form>
</div>
<div className="p-6 text-sm max-w-sm col gap-0.5">
<p>
Powered by{' '}
<a href="https://openpanel.dev" className="font-medium">
OpenPanel.dev
</a>
</p>
<p>
The best web and product analytics tool out there (our honest
opinion).
</p>
<p>
<a href="https://dashboard.openpanel.dev/onboarding">
Try it for free today!
</a>
</p>
</div>
</div>
</PublicPageCard>
);
}

View File

@@ -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 (
<div>
<LoginNavbar />
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">{title}</div>
{description && (
<div className="text-lg text-muted-foreground leading-normal">
{description}
</div>
)}
</div>
{!!children && <div className="mt-6">{children}</div>}
</div>
{showFooter && (
<div className="p-6 text-sm max-w-sm col gap-1 text-muted-foreground">
<p>
Powered by{' '}
<a href="https://openpanel.dev" className="font-medium">
OpenPanel.dev
</a>
{' · '}
<a href="https://dashboard.openpanel.dev/onboarding">
Try it for free today!
</a>
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -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()),
);

View File

@@ -102,7 +102,7 @@ export default function CreateInvite() {
<div>
<SheetTitle>Invite a user</SheetTitle>
<SheetDescription>
Invite users to your organization. They will recieve an email
Invite users to your organization. They will receive an email
will instructions.
</SheetDescription>
</div>

View File

@@ -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,

View File

@@ -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<typeof validator>;
/**
* Build explicit boolean values for every key in emailCategories.
* Uses saved preferences when available, falling back to true (opted-in).
*/
function buildCategoryDefaults(
savedPreferences?: Record<string, boolean>,
): Record<string, boolean> {
return Object.keys(emailCategories).reduce(
(acc, category) => {
acc[category] = savedPreferences?.[category] ?? true;
return acc;
},
{} as Record<string, boolean>,
);
}
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<IForm>({
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 (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Email Preferences</span>
</WidgetHead>
<WidgetBody className="gap-4 col">
<p className="text-sm text-muted-foreground mb-4">
Choose which types of emails you want to receive. Uncheck a category
to stop receiving those emails.
</p>
<div className="space-y-4">
{Object.entries(emailCategories).map(([category, label]) => (
<Controller
key={category}
name={`categories.${category}`}
control={control}
render={({ field }) => (
<div className="flex items-center justify-between gap-4 px-4 py-4 rounded-md border border-border hover:bg-def-200 transition-colors">
<div className="flex-1">
<div className="font-medium">{label}</div>
<div className="text-sm text-muted-foreground">
{category === 'onboarding' &&
'Get started tips and guidance emails'}
{category === 'billing' &&
'Subscription updates and payment reminders'}
</div>
</div>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={mutation.isPending}
/>
</div>
)}
/>
))}
</div>
<Button
size="sm"
type="submit"
disabled={!formState.isDirty || mutation.isPending}
className="self-end mt-4"
icon={SaveIcon}
loading={mutation.isPending}
>
Save
</Button>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -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<typeof validator>;
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<IForm>({
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 (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Profile</span>
</WidgetHead>
<WidgetBody className="gap-4 col">
<InputWithLabel
label="First name"
{...register('firstName')}
defaultValue={user.firstName ?? ''}
/>
<InputWithLabel
label="Last name"
{...register('lastName')}
defaultValue={user.lastName ?? ''}
/>
<Button
size="sm"
type="submit"
disabled={!formState.isDirty || mutation.isPending}
className="self-end"
icon={SaveIcon}
loading={mutation.isPending}
>
Save
</Button>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -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 (
<PageContainer>
<PageHeader title={'Your profile'} />
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="mt-2 mb-8"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</PageContainer>
);
}

View File

@@ -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<string | null>(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 (
<PublicPageCard
title="Unsubscribed"
description={`You've been unsubscribed from ${categoryName} emails. You won't receive any more ${categoryName.toLowerCase()} emails from
us.`}
/>
);
}
return (
<PublicPageCard
title="Unsubscribe"
description={
<>
Unsubscribe from {categoryName} emails? You'll stop receiving{' '}
{categoryName.toLowerCase()} emails sent to&nbsp;
<span className="">{email}</span>
</>
}
>
<div className="col gap-3">
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm">
{error}
</div>
)}
<Button onClick={handleUnsubscribe} disabled={isUnsubscribing}>
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
</Button>
<LinkButton href="/" variant="ghost">
Cancel
</LinkButton>
</div>
</PublicPageCard>
);
}

View File

@@ -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",

View File

@@ -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') {

View File

@@ -281,10 +281,20 @@ export async function bootWorkers() {
eventName: string,
evtOrExitCodeOrError: number | string | Error,
) {
// 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();

View File

@@ -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<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-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<CronQueuePayload>) {
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,
};
}

View File

@@ -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<CronQueuePayload>) {
case 'insightsDaily': {
return await insightsDailyJob(job);
}
case 'onboarding': {
return await onboardingJob(job);
}
}
}

View File

@@ -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)' },

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."organizations"
ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed';

View File

@@ -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");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;

View File

@@ -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")
}

View File

@@ -8,6 +8,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/db": "workspace:*",
"@react-email/components": "^0.5.6",
"react": "catalog:",
"react-dom": "catalog:",

View File

@@ -0,0 +1,24 @@
import { Button as EmailButton } from '@react-email/components';
import type * as React from 'react';
export function Button({
href,
children,
style,
}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) {
return (
<EmailButton
href={href}
style={{
backgroundColor: '#000',
borderRadius: '6px',
color: '#fff',
padding: '12px 20px',
textDecoration: 'none',
...style,
}}
>
{children}
</EmailButton>
);
}

View File

@@ -11,7 +11,7 @@ import React from 'react';
const baseUrl = 'https://openpanel.dev';
export function Footer() {
export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
return (
<>
<Hr />
@@ -71,15 +71,17 @@ export function Footer() {
</Text>
</Row>
{/* <Row>
{unsubscribeUrl && (
<Row>
<Link
className="text-[#707070] text-[14px]"
href="https://dashboard.openpanel.dev/settings/notifications"
href={unsubscribeUrl}
title="Unsubscribe"
>
Notification preferences
</Link>
</Row> */}
</Row>
)}
</Section>
</>
);

View File

@@ -7,15 +7,15 @@ import {
Section,
Tailwind,
} from '@react-email/components';
// biome-ignore lint/style/useImportType: resend needs React
import React from 'react';
import type React from 'react';
import { Footer } from './footer';
type Props = {
children: React.ReactNode;
unsubscribeUrl?: string;
};
export function Layout({ children }: Props) {
export function Layout({ children, unsubscribeUrl }: Props) {
return (
<Html>
<Tailwind>
@@ -57,7 +57,7 @@ export function Layout({ children }: Props) {
/>
</Section>
<Section className="p-6">{children}</Section>
<Footer />
<Footer unsubscribeUrl={unsubscribeUrl} />
</Container>
</Body>
</Tailwind>

View 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>
);
}

View File

@@ -13,9 +13,10 @@ export default EmailInvite;
export function EmailInvite({
organizationName = 'Acme Co',
url = 'https://openpanel.dev',
}: Props) {
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>You've been invited to join {organizationName}!</Text>
<Text>
If you don't have an account yet, click the button below to create one

View File

@@ -9,9 +9,12 @@ export const zEmailResetPassword = z.object({
export type Props = z.infer<typeof zEmailResetPassword>;
export default EmailResetPassword;
export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
export function EmailResetPassword({
url = 'https://openpanel.dev',
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>
You have requested to reset your password. Follow the link below to
reset your password:

View File

@@ -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;

View File

@@ -0,0 +1,65 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
export const zOnboardingDashboards = z.object({
firstName: z.string().optional(),
dashboardUrl: z.string(),
});
export type Props = z.infer<typeof zOnboardingDashboards>;
export default OnboardingDashboards;
export function OnboardingDashboards({
firstName,
dashboardUrl = 'https://dashboard.openpanel.dev',
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(dashboardUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-dashboards');
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking events is the easy part. The value comes from actually looking
at them.
</Text>
<Text>
If you haven't yet, try building a simple dashboard. Pick one thing you
care about and visualize it. Could be:
</Text>
<List
items={[
'How many people sign up and then actually do something',
'Where users drop off in a flow (funnel)',
'Which pages lead to conversions (entry page CTA)',
]}
/>
<Text>
This is usually when people go from "I have analytics" to "I understand
what's happening." It's a different feeling.
</Text>
<Text>Takes maybe 10 minutes to set up. Worth it.</Text>
<Text>
Best regards,
<br />
Carl
</Text>
<span style={{ margin: '0 -20px', display: 'block' }}>
<img
src="https://openpanel.dev/_next/image?url=%2Fscreenshots%2Fdashboard-dark.webp&w=3840&q=75"
alt="Dashboard"
style={{
width: '100%',
height: 'auto',
borderRadius: '5px',
}}
/>
</span>
</Layout>
);
}

View File

@@ -0,0 +1,42 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingFeatureRequest = z.object({
firstName: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingFeatureRequest>;
export default OnboardingFeatureRequest;
export function OnboardingFeatureRequest({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
OpenPanel aims to be the one stop shop for all your analytics needs.
</Text>
<Text>
We have already in a very short time become one of the most popular
open-source analytics platforms out there and we're working hard to add
more features to make it the best analytics platform.
</Text>
<Text>
Do you feel like you're missing a feature that's important to you? If
that's the case, please reply here or go to our feedback board and add
your request there.
</Text>
<Text>
<Link href={'https://feedback.openpanel.dev'}>Feedback board</Link>
</Text>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

View File

@@ -0,0 +1,56 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Button } from '../components/button';
import { Layout } from '../components/layout';
export const zOnboardingTrialEnded = z.object({
firstName: z.string().optional(),
billingUrl: z.string(),
recommendedPlan: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingTrialEnded>;
export default OnboardingTrialEnded;
export function OnboardingTrialEnded({
firstName,
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ended');
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Your OpenPanel trial has ended.</Text>
<Text>
Your tracking is still running in the background, but you won't be able
to see any new data until you upgrade. All your dashboards, reports, and
event history are still there waiting for you.
</Text>
<Text>
Important: If you don't upgrade within 30 days, your workspace and
projects will be permanently deleted.
</Text>
<Text>
To keep your data and continue using OpenPanel, upgrade to a paid plan.{' '}
{recommendedPlan
? `Based on your usage we recommend upgrading to the ${recommendedPlan}`
: 'Plans start at $2.50/month'}
.
</Text>
<Text>
If you have any questions or something's holding you back, just reply to
this email.
</Text>
<Text>
<Button href={newUrl.toString()}>Upgrade Now</Button>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,57 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Button } from '../components/button';
import { Layout } from '../components/layout';
export const zOnboardingTrialEnding = z.object({
firstName: z.string().optional(),
organizationName: z.string(),
billingUrl: z.string(),
recommendedPlan: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingTrialEnding>;
export default OnboardingTrialEnding;
export function OnboardingTrialEnding({
firstName,
organizationName = 'your organization',
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ending');
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Quick heads up: your OpenPanel trial ends soon.</Text>
<Text>
Your tracking will keep working, but you won't be able to see new data
until you upgrade. Everything you've built so far (dashboards, reports,
event history) stays intact.
</Text>
<Text>
To continue using OpenPanel, you'll need to upgrade to a paid plan.{' '}
{recommendedPlan
? `Based on your usage we recommend upgrading to the ${recommendedPlan} plan`
: 'Plans start at $2.50/month'}
.
</Text>
<Text>
If something's holding you back, I'd like to hear about it. Just reply.
</Text>
<Text>
Your project will receive events for the next 30 days, if you haven't
upgraded by then we'll remove your workspace and projects.
</Text>
<Text>
<Button href={newUrl.toString()}>Upgrade Now</Button>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,55 @@
import { Heading, Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
export const zOnboardingWelcome = z.object({
firstName: z.string().optional(),
dashboardUrl: z.string(),
});
export type Props = z.infer<typeof zOnboardingWelcome>;
export default OnboardingWelcome;
export function OnboardingWelcome({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Thanks for trying OpenPanel.</Text>
<Text>
We built OpenPanel because most analytics tools are either too
expensive, too complicated, or both. OpenPanel is different.
</Text>
<Text>
We hope you find OpenPanel useful and if you have any questions,
regarding tracking or how to import your existing events, just reach
out. We're here to help.
</Text>
<Text>To get started, you can:</Text>
<List
items={[
<Link
key=""
href={'https://openpanel.dev/docs/get-started/install-openpanel'}
>
Install tracking script
</Link>,
<Link
key=""
href={'https://openpanel.dev/docs/get-started/track-events'}
>
Start tracking your events
</Link>,
]}
/>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

View File

@@ -0,0 +1,46 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
export const zOnboardingWhatToTrack = z.object({
firstName: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
export default OnboardingWhatToTrack;
export function OnboardingWhatToTrack({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking can be overwhelming at first, and that's why its important to
focus on what's matters. For most products, that's something like:
</Text>
<List
items={[
'Find good funnels to track (onboarding or checkout)',
'Conversions (how many clicks your hero CTA)',
'What did the user do after clicking the CTA',
]}
/>
<Text>
Start small and incrementally add more events as you go is usually the
best approach.
</Text>
<Text>
If you're not sure whether something's worth tracking, or have any
questions, just reply here.
</Text>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

View File

@@ -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');

View File

@@ -2,48 +2,81 @@ import React from 'react';
import { Resend } from 'resend';
import type { z } from 'zod';
import { db } from '@openpanel/db';
import { type TemplateKey, type Templates, templates } from './emails';
import { getUnsubscribeUrl } from './unsubscribe';
export * from './unsubscribe';
const FROM = process.env.EMAIL_SENDER ?? 'hello@openpanel.dev';
export type EmailData<T extends TemplateKey> = z.infer<Templates[T]['schema']>;
export type EmailTemplate = keyof Templates;
export async function sendEmail<T extends TemplateKey>(
template: T,
templateKey: T,
options: {
to: string | string[];
to: string;
data: z.infer<Templates[T]['schema']>;
},
) {
const { to, data } = options;
const { subject, Component, schema } = templates[template];
const props = schema.safeParse(data);
const template = templates[templateKey];
const props = template.schema.safeParse(data);
if (!props.success) {
console.error('Failed to parse data', props.error);
return null;
}
if ('category' in template && template.category) {
const unsubscribed = await db.emailUnsubscribe.findUnique({
where: {
email_category: {
email: to,
category: template.category,
},
},
});
if (unsubscribed) {
console.log(
`Skipping email to ${to} - unsubscribed from ${template.category}`,
);
return null;
}
}
if (!process.env.RESEND_API_KEY) {
console.log('No RESEND_API_KEY found, here is the data');
console.log(data);
console.log('Template:', template);
console.log('Subject: ', template.subject(props.data as any));
console.log('To: ', to);
console.log('Data: ', JSON.stringify(data, null, 2));
return null;
}
const resend = new Resend(process.env.RESEND_API_KEY);
const headers: Record<string, string> = {};
if ('category' in template && template.category) {
const unsubscribeUrl = getUnsubscribeUrl(to, template.category);
(props.data as any).unsubscribeUrl = unsubscribeUrl;
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}
try {
const res = await resend.emails.send({
from: FROM,
to,
// @ts-expect-error - TODO: fix this
subject: subject(props.data),
// @ts-expect-error - TODO: fix this
react: <Component {...props.data} />,
subject: template.subject(props.data as any),
react: <template.Component {...(props.data as any)} />,
headers: Object.keys(headers).length > 0 ? headers : undefined,
});
if (res.error) {
throw new Error(res.error.message);
}
return res;
} catch (error) {
console.error('Failed to send email', error);

View File

@@ -0,0 +1,39 @@
import { createHmac, timingSafeEqual } from 'crypto';
const SECRET =
process.env.UNSUBSCRIBE_SECRET ||
process.env.COOKIE_SECRET ||
process.env.SECRET ||
'default-secret-change-in-production';
export function generateUnsubscribeToken(email: string, category: string): string {
const data = `${email}:${category}`;
return createHmac('sha256', SECRET).update(data).digest('hex');
}
export function verifyUnsubscribeToken(
email: string,
category: string,
token: string,
): boolean {
const expectedToken = generateUnsubscribeToken(email, category);
const tokenBuffer = Buffer.from(token, 'hex');
const expectedBuffer = Buffer.from(expectedToken, 'hex');
// Handle length mismatch safely to avoid timing leaks
if (tokenBuffer.length !== expectedBuffer.length) {
// Compare against zero-filled buffer of same length as token to maintain constant time
const zeroBuffer = Buffer.alloc(tokenBuffer.length);
timingSafeEqual(tokenBuffer, zeroBuffer);
return false;
}
return timingSafeEqual(tokenBuffer, expectedBuffer);
}
export function getUnsubscribeUrl(email: string, category: string): string {
const token = generateUnsubscribeToken(email, category);
const params = new URLSearchParams({ email, category, token });
const dashboardUrl = process.env.DASHBOARD_URL || 'http://localhost:3000';
return `${dashboardUrl}/unsubscribe?${params.toString()}`;
}

View File

@@ -1,5 +1,11 @@
export type { ProductPrice } from '@polar-sh/sdk/models/components/productprice.js';
function formatEventsCount(events: number) {
return new Intl.NumberFormat('en-gb', {
notation: 'compact',
}).format(events);
}
export type IPrice = {
price: number;
events: number;
@@ -39,3 +45,29 @@ export const FREE_PRODUCT_IDS = [
'a18b4bee-d3db-4404-be6f-fba2f042d9ed', // Prod
'036efa2a-b3b4-4c75-b24a-9cac6bb8893b', // Sandbox
];
export function getRecommendedPlan<T>(
monthlyEvents: number | undefined | null,
cb: (
options: {
formattedEvents: string;
formattedPrice: string;
} & IPrice,
) => T,
): T | undefined {
if (!monthlyEvents) {
return undefined;
}
const price = PRICING.find((price) => price.events >= monthlyEvents);
if (!price) {
return undefined;
}
return cb({
...price,
formattedEvents: formatEventsCount(price.events),
formattedPrice: Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price.price),
});
}

View File

@@ -115,6 +115,10 @@ export type CronQueuePayloadInsightsDaily = {
type: 'insightsDaily';
payload: undefined;
};
export type CronQueuePayloadOnboarding = {
type: 'onboarding';
payload: undefined;
};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
@@ -122,7 +126,8 @@ export type CronQueuePayload =
| CronQueuePayloadFlushProfiles
| CronQueuePayloadPing
| CronQueuePayloadProject
| CronQueuePayloadInsightsDaily;
| CronQueuePayloadInsightsDaily
| CronQueuePayloadOnboarding;
export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon';
@@ -254,18 +259,3 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
},
},
);
export function addTrialEndingSoonJob(organizationId: string, delay: number) {
return miscQueue.add(
'misc',
{
type: 'trialEndingSoon',
payload: {
organizationId,
},
},
{
delay,
},
);
}

View File

@@ -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

View File

@@ -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 = '';

View File

@@ -0,0 +1,114 @@
import { emailCategories } from '@openpanel/constants';
import { db } from '@openpanel/db';
import { verifyUnsubscribeToken } from '@openpanel/email';
import { z } from 'zod';
import { TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const emailRouter = createTRPCRouter({
unsubscribe: publicProcedure
.input(
z.object({
email: z.string().email(),
category: z.string(),
token: z.string(),
}),
)
.mutation(async ({ input }) => {
const { email, category, token } = input;
// Verify token
if (!verifyUnsubscribeToken(email, category, token)) {
throw TRPCBadRequestError('Invalid unsubscribe link');
}
// Upsert the unsubscribe record
await db.emailUnsubscribe.upsert({
where: {
email_category: {
email,
category,
},
},
create: {
email,
category,
},
update: {},
});
return { success: true };
}),
getPreferences: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.session.userId || !ctx.session.user?.email) {
throw new Error('User not authenticated');
}
const email = ctx.session.user.email;
// Get all unsubscribe records for this user
const unsubscribes = await db.emailUnsubscribe.findMany({
where: {
email,
},
select: {
category: true,
},
});
const unsubscribedCategories = new Set(unsubscribes.map((u) => u.category));
// Return object with all categories, true = subscribed (not unsubscribed)
const preferences: Record<string, boolean> = {};
for (const [category] of Object.entries(emailCategories)) {
preferences[category] = !unsubscribedCategories.has(category);
}
return preferences;
}),
updatePreferences: protectedProcedure
.input(
z.object({
categories: z.record(z.string(), z.boolean()),
}),
)
.mutation(async ({ input, ctx }) => {
if (!ctx.session.userId || !ctx.session.user?.email) {
throw new Error('User not authenticated');
}
const email = ctx.session.user.email;
// Process each category
for (const [category, subscribed] of Object.entries(input.categories)) {
if (subscribed) {
// User wants to subscribe - delete unsubscribe record if exists
await db.emailUnsubscribe.deleteMany({
where: {
email,
category,
},
});
} else {
// User wants to unsubscribe - upsert unsubscribe record
await db.emailUnsubscribe.upsert({
where: {
email_category: {
email,
category,
},
},
create: {
email,
category,
},
update: {},
});
}
}
return { success: true };
}),
});

View File

@@ -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;
}

10
pnpm-lock.yaml generated
View File

@@ -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==}