This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-21 11:21:40 +01:00
parent a58761e8d7
commit 3fa1a5429e
28 changed files with 661 additions and 172 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,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>
);
}

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

@@ -37,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'
@@ -47,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'
@@ -81,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',
)()
@@ -174,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',
@@ -271,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',
@@ -335,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: '/',
@@ -347,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',
@@ -559,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
@@ -573,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
@@ -624,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
@@ -638,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
@@ -691,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
@@ -709,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
@@ -765,6 +803,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
| '/$organizationId/profile'
| '/onboarding/$projectId/connect'
| '/onboarding/$projectId/verify'
| '/$organizationId/$projectId/'
@@ -779,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'
@@ -830,6 +871,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
| '/$organizationId/profile'
| '/onboarding/$projectId/connect'
| '/onboarding/$projectId/verify'
| '/$organizationId/$projectId'
@@ -844,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'
@@ -896,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/'
@@ -914,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'
@@ -1063,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'
@@ -1182,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'
@@ -1259,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: '/'
@@ -1273,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'
@@ -1817,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
@@ -1824,6 +1932,7 @@ interface AppOrganizationIdRouteChildren {
AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute
AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren
AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren
AppOrganizationIdProfileRoute: typeof AppOrganizationIdProfileRouteWithChildren
}
const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
@@ -1834,6 +1943,7 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
AppOrganizationIdIntegrationsRoute:
AppOrganizationIdIntegrationsRouteWithChildren,
AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren,
AppOrganizationIdProfileRoute: AppOrganizationIdProfileRouteWithChildren,
}
const AppOrganizationIdRouteWithChildren =

View File

@@ -0,0 +1,123 @@
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>;
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: 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: 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 ?? true}
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

@@ -1,8 +1,9 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LoginNavbar } from '@/components/login-navbar';
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';
@@ -27,16 +28,18 @@ function RouteComponent() {
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const unsubscribeMutation = trpc.email.unsubscribe.useMutation({
onSuccess: () => {
setIsSuccess(true);
setIsUnsubscribing(false);
},
onError: (err) => {
setError(err.message || 'Failed to unsubscribe');
setIsUnsubscribing(false);
},
});
const 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);
@@ -49,64 +52,38 @@ function RouteComponent() {
if (isSuccess) {
return (
<div className="min-h-screen flex flex-col">
<LoginNavbar />
<div className="flex-1 center-center px-4">
<div className="max-w-md w-full text-center space-y-4">
<div className="text-6xl mb-4"></div>
<h1 className="text-2xl font-bold">Unsubscribed</h1>
<p className="text-muted-foreground">
You've been unsubscribed from {categoryName} emails.
</p>
<p className="text-sm text-muted-foreground">
You won't receive any more {categoryName.toLowerCase()} emails from
us.
</p>
</div>
</div>
</div>
<PublicPageCard
title="Unsubscribed"
description={`You've been unsubscribed from ${categoryName} emails. You won't receive any more ${categoryName.toLowerCase()} emails from
us.`}
/>
);
}
return (
<div className="min-h-screen flex flex-col">
<LoginNavbar />
<div className="flex-1 center-center px-4">
<div className="max-w-md w-full space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Unsubscribe</h1>
<p className="text-muted-foreground">
Unsubscribe from {categoryName} emails?
</p>
<p className="text-sm text-muted-foreground">
You'll stop receiving {categoryName.toLowerCase()} emails sent to{' '}
<span className="font-mono text-xs">{email}</span>
</p>
<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>
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm">
{error}
</div>
)}
<div className="space-y-3">
<button
onClick={handleUnsubscribe}
disabled={isUnsubscribing}
className="w-full bg-black text-white py-3 px-4 rounded-md font-medium hover:bg-black/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
</button>
<a
href="/"
className="block text-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</a>
</div>
</div>
)}
<Button onClick={handleUnsubscribe} disabled={isUnsubscribing}>
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
</Button>
<LinkButton href="/" variant="ghost">
Cancel
</LinkButton>
</div>
</div>
</PublicPageCard>
);
}

View File

@@ -42,7 +42,7 @@ export async function bootCron() {
{
name: 'onboarding',
type: 'onboarding',
pattern: '0 10 * * *',
pattern: '0 * * * *',
},
];

View File

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

View File

@@ -135,14 +135,16 @@ const ONBOARDING_EMAILS = [
];
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: {
onboarding: {
not: 'completed',
},
OR: [{ onboarding: null }, { onboarding: { notIn: ['completed'] } }],
deleteAt: null,
createdBy: {
deletedAt: null,
@@ -168,7 +170,7 @@ export async function onboardingJob(job: Job<CronQueuePayload>) {
const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt);
// Find the next email to send
// If org.onboarding is empty string, they haven't received any email yet
// 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;
@@ -192,6 +194,15 @@ export async function onboardingJob(job: Job<CronQueuePayload>) {
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++;

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': {
@@ -510,7 +507,6 @@ export function getCountry(code?: string) {
export const emailCategories = {
onboarding: 'Onboarding',
billing: 'Billing',
} as const;
export type EmailCategory = keyof typeof emailCategories;

View File

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

View File

@@ -62,7 +62,7 @@ model Organization {
integrations Integration[]
invites Invite[]
timezone String?
onboarding String @default("completed") // 'completed' or template name for next email
onboarding String? @default("completed")
// Subscription
subscriptionId String?

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>
<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>
</>
);

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

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

@@ -39,7 +39,6 @@ export const templates = {
'Your trial is ending soon',
Component: TrailEndingSoon,
schema: zTrailEndingSoon,
category: 'billing' as const,
},
'onboarding-welcome': {
subject: () => "You're in",
@@ -69,13 +68,11 @@ export const templates = {
subject: () => 'Your trial ends in a few days',
Component: OnboardingTrialEnding,
schema: zOnboardingTrialEnding,
category: 'onboarding' as const,
},
'onboarding-trial-ended': {
subject: () => 'Your trial has ended',
Component: OnboardingTrialEnded,
schema: zOnboardingTrialEnded,
category: 'onboarding' as const,
},
} as const;

View File

@@ -14,14 +14,15 @@ export default OnboardingDashboards;
export function OnboardingDashboards({
firstName,
dashboardUrl = 'https://dashboard.openpanel.dev',
}: Props) {
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(dashboardUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-dashboards');
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking events is the easy part. The value comes from actually looking

View File

@@ -9,9 +9,12 @@ export const zOnboardingFeatureRequest = z.object({
export type Props = z.infer<typeof zOnboardingFeatureRequest>;
export default OnboardingFeatureRequest;
export function OnboardingFeatureRequest({ firstName }: Props) {
export function OnboardingFeatureRequest({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
OpenPanel aims to be the one stop shop for all your analytics needs.

View File

@@ -16,7 +16,8 @@ export function OnboardingTrialEnded({
firstName,
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
}: Props) {
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');

View File

@@ -18,7 +18,8 @@ export function OnboardingTrialEnding({
organizationName = 'your organization',
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
}: Props) {
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');

View File

@@ -11,9 +11,12 @@ export const zOnboardingWelcome = z.object({
export type Props = z.infer<typeof zOnboardingWelcome>;
export default OnboardingWelcome;
export function OnboardingWelcome({ firstName }: Props) {
export function OnboardingWelcome({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Thanks for trying OpenPanel.</Text>
<Text>

View File

@@ -10,9 +10,12 @@ export const zOnboardingWhatToTrack = z.object({
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
export default OnboardingWhatToTrack;
export function OnboardingWhatToTrack({ firstName }: Props) {
export function OnboardingWhatToTrack({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout>
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking can be overwhelming at first, and that's why its important to

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

@@ -29,7 +29,6 @@ export async function sendEmail<T extends TemplateKey>(
return null;
}
// Check if user has unsubscribed from this category (only for non-transactional emails)
if ('category' in template && template.category) {
const unsubscribed = await db.emailUnsubscribe.findUnique({
where: {
@@ -51,8 +50,7 @@ export async function sendEmail<T extends TemplateKey>(
if (!process.env.RESEND_API_KEY) {
console.log('No RESEND_API_KEY found, here is the data');
console.log('Template:', template);
// @ts-expect-error - TODO: fix this
console.log('Subject: ', subject(props.data));
console.log('Subject: ', template.subject(props.data as any));
console.log('To: ', to);
console.log('Data: ', JSON.stringify(data, null, 2));
return null;
@@ -60,10 +58,10 @@ export async function sendEmail<T extends TemplateKey>(
const resend = new Resend(process.env.RESEND_API_KEY);
// Build headers for unsubscribe (only for non-transactional emails)
const headers: Record<string, string> = {};
if ('category' in template && template.category) {
const unsubscribeUrl = getUnsubscribeUrl(to, template.category);
(data as any).unsubscribeUrl = unsubscribeUrl;
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}
@@ -72,10 +70,8 @@ export async function sendEmail<T extends TemplateKey>(
const res = await resend.emails.send({
from: FROM,
to,
// @ts-expect-error - TODO: fix this
subject: subject(props.data),
// @ts-expect-error - TODO: fix this
react: <Component {...props.data} />,
subject: template.subject(props.data as any),
react: <template.Component {...(props.data as any)} />,
headers: Object.keys(headers).length > 0 ? headers : undefined,
});
if (res.error) {

View File

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

View File

@@ -29,7 +29,7 @@ async function createOrGetOrganization(
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
subscriptionStatus: 'trialing',
timezone: input.timezone,
onboarding: 'onboarding-welcome',
onboarding: '',
},
});