Compare commits
5 Commits
feature/re
...
feature/on
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e8c9beaa | ||
|
|
f9b1ec5038 | ||
|
|
3fa1a5429e | ||
|
|
a58761e8d7 | ||
|
|
56f1c5e894 |
@@ -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 (
|
||||
<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">
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<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
|
||||
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"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
</PublicPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
51
apps/start/src/components/public-page-card.tsx
Normal file
51
apps/start/src/components/public-page-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
55
apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
Normal file
55
apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/start/src/routes/unsubscribe.tsx
Normal file
89
apps/start/src/routes/unsubscribe.tsx
Normal 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
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
276
apps/worker/src/jobs/cron.onboarding.ts
Normal file
276
apps/worker/src/jobs/cron.onboarding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."organizations"
|
||||
ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."email_unsubscribes" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"email" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "email_unsubscribes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "email_unsubscribes_email_category_key" ON "public"."email_unsubscribes"("email", "category");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@react-email/components": "^0.5.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
24
packages/email/src/components/button.tsx
Normal file
24
packages/email/src/components/button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import React from 'react';
|
||||
|
||||
const baseUrl = 'https://openpanel.dev';
|
||||
|
||||
export function Footer() {
|
||||
export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<>
|
||||
<Hr />
|
||||
@@ -71,15 +71,17 @@ export function Footer() {
|
||||
</Text>
|
||||
</Row>
|
||||
|
||||
{/* <Row>
|
||||
<Link
|
||||
className="text-[#707070] text-[14px]"
|
||||
href="https://dashboard.openpanel.dev/settings/notifications"
|
||||
title="Unsubscribe"
|
||||
>
|
||||
Notification preferences
|
||||
</Link>
|
||||
</Row> */}
|
||||
{unsubscribeUrl && (
|
||||
<Row>
|
||||
<Link
|
||||
className="text-[#707070] text-[14px]"
|
||||
href={unsubscribeUrl}
|
||||
title="Unsubscribe"
|
||||
>
|
||||
Notification preferences
|
||||
</Link>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
// biome-ignore lint/style/useImportType: resend needs React
|
||||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { Footer } from './footer';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
unsubscribeUrl?: string;
|
||||
};
|
||||
|
||||
export function Layout({ children }: Props) {
|
||||
export function Layout({ children, unsubscribeUrl }: Props) {
|
||||
return (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
@@ -57,7 +57,7 @@ export function Layout({ children }: Props) {
|
||||
/>
|
||||
</Section>
|
||||
<Section className="p-6">{children}</Section>
|
||||
<Footer />
|
||||
<Footer unsubscribeUrl={unsubscribeUrl} />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
13
packages/email/src/components/list.tsx
Normal file
13
packages/email/src/components/list.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Text } from '@react-email/components';
|
||||
|
||||
export function List({ items }: { items: React.ReactNode[] }) {
|
||||
return (
|
||||
<ul style={{ paddingLeft: 20 }}>
|
||||
{items.map((node, index) => (
|
||||
<li key={index.toString()}>
|
||||
<Text style={{ marginBottom: 2, marginTop: 2 }}>{node}</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,10 @@ export default EmailInvite;
|
||||
export function EmailInvite({
|
||||
organizationName = 'Acme Co',
|
||||
url = 'https://openpanel.dev',
|
||||
}: Props) {
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>You've been invited to join {organizationName}!</Text>
|
||||
<Text>
|
||||
If you don't have an account yet, click the button below to create one
|
||||
|
||||
@@ -9,9 +9,12 @@ export const zEmailResetPassword = z.object({
|
||||
|
||||
export type Props = z.infer<typeof zEmailResetPassword>;
|
||||
export default EmailResetPassword;
|
||||
export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
|
||||
export function EmailResetPassword({
|
||||
url = 'https://openpanel.dev',
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>
|
||||
You have requested to reset your password. Follow the link below to
|
||||
reset your password:
|
||||
|
||||
@@ -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;
|
||||
|
||||
65
packages/email/src/emails/onboarding-dashboards.tsx
Normal file
65
packages/email/src/emails/onboarding-dashboards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
packages/email/src/emails/onboarding-feature-request.tsx
Normal file
42
packages/email/src/emails/onboarding-feature-request.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
packages/email/src/emails/onboarding-trial-ended.tsx
Normal file
56
packages/email/src/emails/onboarding-trial-ended.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
packages/email/src/emails/onboarding-trial-ending.tsx
Normal file
57
packages/email/src/emails/onboarding-trial-ending.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
packages/email/src/emails/onboarding-welcome.tsx
Normal file
55
packages/email/src/emails/onboarding-welcome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
packages/email/src/emails/onboarding-what-to-track.tsx
Normal file
46
packages/email/src/emails/onboarding-what-to-track.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
39
packages/email/src/unsubscribe.ts
Normal file
39
packages/email/src/unsubscribe.ts
Normal 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()}`;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { chartRouter } from './routers/chart';
|
||||
import { chatRouter } from './routers/chat';
|
||||
import { clientRouter } from './routers/client';
|
||||
import { dashboardRouter } from './routers/dashboard';
|
||||
import { emailRouter } from './routers/email';
|
||||
import { eventRouter } from './routers/event';
|
||||
import { importRouter } from './routers/import';
|
||||
import { insightRouter } from './routers/insight';
|
||||
@@ -51,6 +52,7 @@ export const appRouter = createTRPCRouter({
|
||||
chat: chatRouter,
|
||||
insight: insightRouter,
|
||||
widget: widgetRouter,
|
||||
email: emailRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -353,7 +353,6 @@ export const authRouter = createTRPCRouter({
|
||||
.input(zSignInShare)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { password, shareId, shareType = 'overview' } = input;
|
||||
|
||||
let share: { password: string | null; public: boolean } | null = null;
|
||||
let cookieName = '';
|
||||
|
||||
|
||||
114
packages/trpc/src/routers/email.ts
Normal file
114
packages/trpc/src/routers/email.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
||||
Reference in New Issue
Block a user