From 3fa1a5429ea1f738fe3794d3a70c454d007d0c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 21 Jan 2026 11:21:40 +0100 Subject: [PATCH] wip --- .../components/auth/share-enter-password.tsx | 71 ++++------ .../start/src/components/public-page-card.tsx | 51 ++++++++ apps/start/src/routeTree.gen.ts | 110 ++++++++++++++++ ...tionId.profile._tabs.email-preferences.tsx | 123 ++++++++++++++++++ ...pp.$organizationId.profile._tabs.index.tsx | 96 ++++++++++++++ .../_app.$organizationId.profile._tabs.tsx | 55 ++++++++ apps/start/src/routes/unsubscribe.tsx | 107 ++++++--------- apps/worker/src/boot-cron.ts | 2 +- apps/worker/src/boot-workers.ts | 18 ++- apps/worker/src/jobs/cron.onboarding.ts | 19 ++- packages/constants/index.ts | 6 +- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 2 +- packages/email/src/components/footer.tsx | 22 ++-- packages/email/src/components/layout.tsx | 8 +- packages/email/src/emails/email-invite.tsx | 5 +- .../email/src/emails/email-reset-password.tsx | 7 +- packages/email/src/emails/index.tsx | 3 - .../src/emails/onboarding-dashboards.tsx | 5 +- .../src/emails/onboarding-feature-request.tsx | 7 +- .../src/emails/onboarding-trial-ended.tsx | 3 +- .../src/emails/onboarding-trial-ending.tsx | 3 +- .../email/src/emails/onboarding-welcome.tsx | 7 +- .../src/emails/onboarding-what-to-track.tsx | 7 +- .../email/src/emails/trial-ending-soon.tsx | 3 +- packages/email/src/index.tsx | 12 +- packages/trpc/src/routers/email.ts | 77 ++++++++++- packages/trpc/src/routers/onboarding.ts | 2 +- 28 files changed, 661 insertions(+), 172 deletions(-) create mode 100644 apps/start/src/components/public-page-card.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.profile._tabs.tsx create mode 100644 packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql diff --git a/apps/start/src/components/auth/share-enter-password.tsx b/apps/start/src/components/auth/share-enter-password.tsx index b25dae61..9f1e7ce7 100644 --- a/apps/start/src/components/auth/share-enter-password.tsx +++ b/apps/start/src/components/auth/share-enter-password.tsx @@ -4,7 +4,7 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import { LogoSquare } from '../logo'; +import { PublicPageCard } from '../public-page-card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; @@ -43,54 +43,27 @@ export function ShareEnterPassword({ }); }); + const typeLabel = + shareType === 'dashboard' + ? 'Dashboard' + : shareType === 'report' + ? 'Report' + : 'Overview'; + return ( -
-
-
- -
- {shareType === 'dashboard' - ? 'Dashboard is locked' - : shareType === 'report' - ? 'Report is locked' - : 'Overview is locked'} -
-
- Please enter correct password to access this{' '} - {shareType === 'dashboard' - ? 'dashboard' - : shareType === 'report' - ? 'report' - : 'overview'} -
-
-
- - -
-
-
-

- Powered by{' '} - - OpenPanel.dev - -

-

- The best web and product analytics tool out there (our honest - opinion). -

-

- - Try it for free today! - -

-
-
+ +
+ + +
+
); } diff --git a/apps/start/src/components/public-page-card.tsx b/apps/start/src/components/public-page-card.tsx new file mode 100644 index 00000000..400513ec --- /dev/null +++ b/apps/start/src/components/public-page-card.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; +import { LoginNavbar } from './login-navbar'; +import { LogoSquare } from './logo'; + +interface PublicPageCardProps { + title: string; + description?: ReactNode; + children?: ReactNode; + showFooter?: boolean; +} + +export function PublicPageCard({ + title, + description, + children, + showFooter = true, +}: PublicPageCardProps) { + return ( +
+ +
+
+
+ +
{title}
+ {description && ( +
+ {description} +
+ )} +
+ {!!children &&
{children}
} +
+ {showFooter && ( +
+

+ Powered by{' '} + + OpenPanel.dev + + {' · '} + + Try it for free today! + +

+
+ )} +
+
+ ); +} diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 2cbd6bdd..705b7c2d 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -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 = diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx new file mode 100644 index 00000000..d9b9430d --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx @@ -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; + +export const Route = createFileRoute( + '/_app/$organizationId/profile/_tabs/email-preferences', +)({ + component: Component, + pendingComponent: FullPageLoadingState, +}); + +function Component() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const preferencesQuery = useSuspenseQuery( + trpc.email.getPreferences.queryOptions(), + ); + + const { control, handleSubmit, formState, reset } = useForm({ + defaultValues: { + categories: 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 ( +
{ + mutation.mutate(values); + })} + > + + + Email Preferences + + +

+ Choose which types of emails you want to receive. Uncheck a category + to stop receiving those emails. +

+ +
+ {Object.entries(emailCategories).map(([category, label]) => ( + ( +
+
+
{label}
+
+ {category === 'onboarding' && + 'Get started tips and guidance emails'} + {category === 'billing' && + 'Subscription updates and payment reminders'} +
+
+ +
+ )} + /> + ))} +
+ + +
+
+
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx new file mode 100644 index 00000000..9791abed --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx @@ -0,0 +1,96 @@ +import { InputWithLabel } from '@/components/forms/input-with-label'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { SaveIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const validator = z.object({ + firstName: z.string(), + lastName: z.string(), +}); + +type IForm = z.infer; + +export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({ + component: Component, + pendingComponent: FullPageLoadingState, +}); + +function Component() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const session = useSuspenseQuery(trpc.auth.session.queryOptions()); + const user = session.data?.user; + + const { register, handleSubmit, formState, reset } = useForm({ + defaultValues: { + firstName: user?.firstName ?? '', + lastName: user?.lastName ?? '', + }, + }); + + const mutation = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + toast('Profile updated', { + description: 'Your profile has been updated.', + }); + queryClient.invalidateQueries(trpc.auth.session.pathFilter()); + reset({ + firstName: data.firstName ?? '', + lastName: data.lastName ?? '', + }); + }, + onError: handleError, + }), + ); + + if (!user) { + return null; + } + + return ( +
{ + mutation.mutate(values); + })} + > + + + Profile + + + + + + + +
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx new file mode 100644 index 00000000..ffcbb9a3 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx @@ -0,0 +1,55 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { ProfileAvatar } from '@/components/profiles/profile-avatar'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { usePageTabs } from '@/hooks/use-page-tabs'; +import { useTRPC } from '@/integrations/trpc/react'; +import { getProfileName } from '@/utils/getters'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_app/$organizationId/profile/_tabs')({ + component: Component, + pendingComponent: FullPageLoadingState, +}); + +function Component() { + const router = useRouter(); + const { activeTab, tabs } = usePageTabs([ + { + id: '/$organizationId/profile', + label: 'Profile', + }, + { id: 'email-preferences', label: 'Email preferences' }, + ]); + + const handleTabChange = (tabId: string) => { + router.navigate({ + from: Route.fullPath, + to: tabId, + }); + }; + + return ( + + + + + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + + + + ); +} diff --git a/apps/start/src/routes/unsubscribe.tsx b/apps/start/src/routes/unsubscribe.tsx index 1fbcbdee..d33be89f 100644 --- a/apps/start/src/routes/unsubscribe.tsx +++ b/apps/start/src/routes/unsubscribe.tsx @@ -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(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 ( -
- -
-
-
-

Unsubscribed

-

- You've been unsubscribed from {categoryName} emails. -

-

- You won't receive any more {categoryName.toLowerCase()} emails from - us. -

-
-
-
+ ); } return ( -
- -
-
-
-

Unsubscribe

-

- Unsubscribe from {categoryName} emails? -

-

- You'll stop receiving {categoryName.toLowerCase()} emails sent to{' '} - {email} -

+ + Unsubscribe from {categoryName} emails? You'll stop receiving{' '} + {categoryName.toLowerCase()} emails sent to  + {email} + + } + > +
+ {error && ( +
+ {error}
- - {error && ( -
- {error} -
- )} - -
- - - Cancel - -
-
+ )} + + + Cancel +
-
+ ); } diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts index 4c0342ec..9eb558c6 100644 --- a/apps/worker/src/boot-cron.ts +++ b/apps/worker/src/boot-cron.ts @@ -42,7 +42,7 @@ export async function bootCron() { { name: 'onboarding', type: 'onboarding', - pattern: '0 10 * * *', + pattern: '0 * * * *', }, ]; diff --git a/apps/worker/src/boot-workers.ts b/apps/worker/src/boot-workers.ts index 4b739afc..6d96dd61 100644 --- a/apps/worker/src/boot-workers.ts +++ b/apps/worker/src/boot-workers.ts @@ -281,10 +281,20 @@ export async function bootWorkers() { eventName: string, evtOrExitCodeOrError: number | string | Error, ) { - logger.info('Starting graceful shutdown', { - code: evtOrExitCodeOrError, - eventName, - }); + // Log the actual error details for unhandled rejections/exceptions + if (evtOrExitCodeOrError instanceof Error) { + logger.error('Unhandled error triggered shutdown', { + eventName, + message: evtOrExitCodeOrError.message, + stack: evtOrExitCodeOrError.stack, + name: evtOrExitCodeOrError.name, + }); + } else { + logger.info('Starting graceful shutdown', { + code: evtOrExitCodeOrError, + eventName, + }); + } try { const time = performance.now(); diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts index b21eb0ee..fe195c77 100644 --- a/apps/worker/src/jobs/cron.onboarding.ts +++ b/apps/worker/src/jobs/cron.onboarding.ts @@ -135,14 +135,16 @@ const ONBOARDING_EMAILS = [ ]; export async function onboardingJob(job: Job) { + if (process.env.SELF_HOSTED === 'true') { + return null; + } + logger.info('Starting onboarding email job'); // Fetch organizations that are in onboarding (not completed) const orgs = await db.organization.findMany({ where: { - onboarding: { - not: 'completed', - }, + OR: [{ onboarding: null }, { onboarding: { notIn: ['completed'] } }], deleteAt: null, createdBy: { deletedAt: null, @@ -168,7 +170,7 @@ export async function onboardingJob(job: Job) { 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) { 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++; diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 077697a4..8169f0bd 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -3,10 +3,7 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns'; export const DEFAULT_ASPECT_RATIO = 0.5625; export const NOT_SET_VALUE = '(not set)'; -export const RESERVED_EVENT_NAMES = [ - 'session_start', - 'session_end', -] as const; +export const RESERVED_EVENT_NAMES = ['session_start', 'session_end'] as const; export const timeWindows = { '30min': { @@ -510,7 +507,6 @@ export function getCountry(code?: string) { export const emailCategories = { onboarding: 'Onboarding', - billing: 'Billing', } as const; export type EmailCategory = keyof typeof emailCategories; diff --git a/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql new file mode 100644 index 00000000..cb115cd4 --- /dev/null +++ b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a9457a09..784f94fb 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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? diff --git a/packages/email/src/components/footer.tsx b/packages/email/src/components/footer.tsx index ad4dd213..3beead85 100644 --- a/packages/email/src/components/footer.tsx +++ b/packages/email/src/components/footer.tsx @@ -11,7 +11,7 @@ import React from 'react'; const baseUrl = 'https://openpanel.dev'; -export function Footer() { +export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) { return ( <>
@@ -71,15 +71,17 @@ export function Footer() { - {/* - - Notification preferences - - */} + {unsubscribeUrl && ( + + + Notification preferences + + + )} ); diff --git a/packages/email/src/components/layout.tsx b/packages/email/src/components/layout.tsx index dbf6879c..6900b31c 100644 --- a/packages/email/src/components/layout.tsx +++ b/packages/email/src/components/layout.tsx @@ -7,15 +7,15 @@ import { Section, Tailwind, } from '@react-email/components'; -// biome-ignore lint/style/useImportType: resend needs React -import React from 'react'; +import type React from 'react'; import { Footer } from './footer'; type Props = { children: React.ReactNode; + unsubscribeUrl?: string; }; -export function Layout({ children }: Props) { +export function Layout({ children, unsubscribeUrl }: Props) { return ( @@ -57,7 +57,7 @@ export function Layout({ children }: Props) { />
{children}
-