diff --git a/apps/start/src/components/auth/share-enter-password.tsx b/apps/start/src/components/auth/share-enter-password.tsx index b25dae612..9f1e7ce7a 100644 --- a/apps/start/src/components/auth/share-enter-password.tsx +++ b/apps/start/src/components/auth/share-enter-password.tsx @@ -4,7 +4,7 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import { LogoSquare } from '../logo'; +import { PublicPageCard } from '../public-page-card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; @@ -43,54 +43,27 @@ export function ShareEnterPassword({ }); }); + const typeLabel = + shareType === 'dashboard' + ? 'Dashboard' + : shareType === 'report' + ? 'Report' + : 'Overview'; + return ( -
-
-
- -
- {shareType === 'dashboard' - ? 'Dashboard is locked' - : shareType === 'report' - ? 'Report is locked' - : 'Overview is locked'} -
-
- Please enter correct password to access this{' '} - {shareType === 'dashboard' - ? 'dashboard' - : shareType === 'report' - ? 'report' - : 'overview'} -
-
-
- - -
-
-
-

- 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 000000000..400513ec0 --- /dev/null +++ b/apps/start/src/components/public-page-card.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; +import { LoginNavbar } from './login-navbar'; +import { LogoSquare } from './logo'; + +interface PublicPageCardProps { + title: string; + description?: ReactNode; + children?: ReactNode; + showFooter?: boolean; +} + +export function PublicPageCard({ + title, + description, + children, + showFooter = true, +}: PublicPageCardProps) { + return ( +
+ +
+
+
+ +
{title}
+ {description && ( +
+ {description} +
+ )} +
+ {!!children &&
{children}
} +
+ {showFooter && ( +
+

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

+
+ )} +
+
+ ); +} diff --git a/apps/start/src/hooks/use-cookie-store.tsx b/apps/start/src/hooks/use-cookie-store.tsx index 43f8b6ea8..366bba15d 100644 --- a/apps/start/src/hooks/use-cookie-store.tsx +++ b/apps/start/src/hooks/use-cookie-store.tsx @@ -35,7 +35,7 @@ const setCookieFn = createServerFn({ method: 'POST' }) }); // Called in __root.tsx beforeLoad hook to get cookies from the server -// And recieved with useRouteContext in the client +// And received with useRouteContext in the client export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() => pick(VALID_COOKIES, getCookies()), ); diff --git a/apps/start/src/modals/create-invite.tsx b/apps/start/src/modals/create-invite.tsx index 83572f5be..e05e228a6 100644 --- a/apps/start/src/modals/create-invite.tsx +++ b/apps/start/src/modals/create-invite.tsx @@ -102,7 +102,7 @@ export default function CreateInvite() {
Invite a user - Invite users to your organization. They will recieve an email + Invite users to your organization. They will receive an email will instructions.
diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 5d5144014..705b7c2dc 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { createFileRoute } from '@tanstack/react-router' import { Route as rootRouteImport } from './routes/__root' +import { Route as UnsubscribeRouteImport } from './routes/unsubscribe' import { Route as StepsRouteImport } from './routes/_steps' import { Route as PublicRouteImport } from './routes/_public' import { Route as LoginRouteImport } from './routes/_login' @@ -36,6 +37,7 @@ import { Route as AppOrganizationIdProjectIdRouteImport } from './routes/_app.$o import { Route as AppOrganizationIdProjectIdIndexRouteImport } from './routes/_app.$organizationId.$projectId.index' import { Route as StepsOnboardingProjectIdVerifyRouteImport } from './routes/_steps.onboarding.$projectId.verify' import { Route as StepsOnboardingProjectIdConnectRouteImport } from './routes/_steps.onboarding.$projectId.connect' +import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.$organizationId.profile._tabs' import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs' import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs' import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions' @@ -46,8 +48,10 @@ import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_a import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights' import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards' import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat' +import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index' import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index' import { Route as AppOrganizationIdIntegrationsTabsIndexRouteImport } from './routes/_app.$organizationId.integrations._tabs.index' +import { Route as AppOrganizationIdProfileTabsEmailPreferencesRouteImport } from './routes/_app.$organizationId.profile._tabs.email-preferences' import { Route as AppOrganizationIdMembersTabsMembersRouteImport } from './routes/_app.$organizationId.members._tabs.members' import { Route as AppOrganizationIdMembersTabsInvitationsRouteImport } from './routes/_app.$organizationId.members._tabs.invitations' import { Route as AppOrganizationIdIntegrationsTabsInstalledRouteImport } from './routes/_app.$organizationId.integrations._tabs.installed' @@ -80,6 +84,9 @@ import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } f import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events' +const AppOrganizationIdProfileRouteImport = createFileRoute( + '/_app/$organizationId/profile', +)() const AppOrganizationIdMembersRouteImport = createFileRoute( '/_app/$organizationId/members', )() @@ -102,6 +109,11 @@ const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute( '/_app/$organizationId/$projectId/profiles/$profileId', )() +const UnsubscribeRoute = UnsubscribeRouteImport.update({ + id: '/unsubscribe', + path: '/unsubscribe', + getParentRoute: () => rootRouteImport, +} as any) const StepsRoute = StepsRouteImport.update({ id: '/_steps', getParentRoute: () => rootRouteImport, @@ -168,6 +180,12 @@ const AppOrganizationIdRoute = AppOrganizationIdRouteImport.update({ path: '/$organizationId', getParentRoute: () => AppRoute, } as any) +const AppOrganizationIdProfileRoute = + AppOrganizationIdProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => AppOrganizationIdRoute, + } as any) const AppOrganizationIdMembersRoute = AppOrganizationIdMembersRouteImport.update({ id: '/members', @@ -265,6 +283,11 @@ const StepsOnboardingProjectIdConnectRoute = path: '/onboarding/$projectId/connect', getParentRoute: () => StepsRoute, } as any) +const AppOrganizationIdProfileTabsRoute = + AppOrganizationIdProfileTabsRouteImport.update({ + id: '/_tabs', + getParentRoute: () => AppOrganizationIdProfileRoute, + } as any) const AppOrganizationIdMembersTabsRoute = AppOrganizationIdMembersTabsRouteImport.update({ id: '/_tabs', @@ -329,6 +352,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute = path: '/$profileId', getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute, } as any) +const AppOrganizationIdProfileTabsIndexRoute = + AppOrganizationIdProfileTabsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppOrganizationIdProfileTabsRoute, + } as any) const AppOrganizationIdMembersTabsIndexRoute = AppOrganizationIdMembersTabsIndexRouteImport.update({ id: '/', @@ -341,6 +370,12 @@ const AppOrganizationIdIntegrationsTabsIndexRoute = path: '/', getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute, } as any) +const AppOrganizationIdProfileTabsEmailPreferencesRoute = + AppOrganizationIdProfileTabsEmailPreferencesRouteImport.update({ + id: '/email-preferences', + path: '/email-preferences', + getParentRoute: () => AppOrganizationIdProfileTabsRoute, + } as any) const AppOrganizationIdMembersTabsMembersRoute = AppOrganizationIdMembersTabsMembersRouteImport.update({ id: '/members', @@ -525,6 +560,7 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/unsubscribe': typeof UnsubscribeRoute '/$organizationId': typeof AppOrganizationIdRouteWithChildren '/login': typeof LoginLoginRoute '/reset-password': typeof LoginResetPasswordRoute @@ -552,6 +588,7 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren '/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren + '/$organizationId/profile': typeof AppOrganizationIdProfileTabsRouteWithChildren '/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute '/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute '/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute @@ -566,8 +603,10 @@ export interface FileRoutesByFullPath { '/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute '/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute '/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute + '/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute '/$organizationId/integrations/': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/$organizationId/members/': typeof AppOrganizationIdMembersTabsIndexRoute + '/$organizationId/profile/': typeof AppOrganizationIdProfileTabsIndexRoute '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute @@ -591,6 +630,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/unsubscribe': typeof UnsubscribeRoute '/login': typeof LoginLoginRoute '/reset-password': typeof LoginResetPasswordRoute '/onboarding': typeof PublicOnboardingRoute @@ -616,6 +656,7 @@ export interface FileRoutesByTo { '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute + '/$organizationId/profile': typeof AppOrganizationIdProfileTabsIndexRoute '/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute '/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute '/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute @@ -630,6 +671,7 @@ export interface FileRoutesByTo { '/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute '/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute '/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute + '/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute @@ -653,6 +695,7 @@ export interface FileRoutesById { '/_login': typeof LoginRouteWithChildren '/_public': typeof PublicRouteWithChildren '/_steps': typeof StepsRouteWithChildren + '/unsubscribe': typeof UnsubscribeRoute '/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren '/_login/login': typeof LoginLoginRoute '/_login/reset-password': typeof LoginResetPasswordRoute @@ -682,6 +725,8 @@ export interface FileRoutesById { '/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren '/_app/$organizationId/members': typeof AppOrganizationIdMembersRouteWithChildren '/_app/$organizationId/members/_tabs': typeof AppOrganizationIdMembersTabsRouteWithChildren + '/_app/$organizationId/profile': typeof AppOrganizationIdProfileRouteWithChildren + '/_app/$organizationId/profile/_tabs': typeof AppOrganizationIdProfileTabsRouteWithChildren '/_steps/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute '/_steps/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute '/_app/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute @@ -700,8 +745,10 @@ export interface FileRoutesById { '/_app/$organizationId/integrations/_tabs/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute '/_app/$organizationId/members/_tabs/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute '/_app/$organizationId/members/_tabs/members': typeof AppOrganizationIdMembersTabsMembersRoute + '/_app/$organizationId/profile/_tabs/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute '/_app/$organizationId/integrations/_tabs/': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/_app/$organizationId/members/_tabs/': typeof AppOrganizationIdMembersTabsIndexRoute + '/_app/$organizationId/profile/_tabs/': typeof AppOrganizationIdProfileTabsIndexRoute '/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute @@ -728,6 +775,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/unsubscribe' | '/$organizationId' | '/login' | '/reset-password' @@ -755,6 +803,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/sessions' | '/$organizationId/integrations' | '/$organizationId/members' + | '/$organizationId/profile' | '/onboarding/$projectId/connect' | '/onboarding/$projectId/verify' | '/$organizationId/$projectId/' @@ -769,8 +818,10 @@ export interface FileRouteTypes { | '/$organizationId/integrations/installed' | '/$organizationId/members/invitations' | '/$organizationId/members/members' + | '/$organizationId/profile/email-preferences' | '/$organizationId/integrations/' | '/$organizationId/members/' + | '/$organizationId/profile/' | '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/events' | '/$organizationId/$projectId/events/stats' @@ -794,6 +845,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/unsubscribe' | '/login' | '/reset-password' | '/onboarding' @@ -819,6 +871,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/sessions' | '/$organizationId/integrations' | '/$organizationId/members' + | '/$organizationId/profile' | '/onboarding/$projectId/connect' | '/onboarding/$projectId/verify' | '/$organizationId/$projectId' @@ -833,6 +886,7 @@ export interface FileRouteTypes { | '/$organizationId/integrations/installed' | '/$organizationId/members/invitations' | '/$organizationId/members/members' + | '/$organizationId/profile/email-preferences' | '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/events' | '/$organizationId/$projectId/events/stats' @@ -855,6 +909,7 @@ export interface FileRouteTypes { | '/_login' | '/_public' | '/_steps' + | '/unsubscribe' | '/_app/$organizationId' | '/_login/login' | '/_login/reset-password' @@ -884,6 +939,8 @@ export interface FileRouteTypes { | '/_app/$organizationId/integrations/_tabs' | '/_app/$organizationId/members' | '/_app/$organizationId/members/_tabs' + | '/_app/$organizationId/profile' + | '/_app/$organizationId/profile/_tabs' | '/_steps/onboarding/$projectId/connect' | '/_steps/onboarding/$projectId/verify' | '/_app/$organizationId/$projectId/' @@ -902,8 +959,10 @@ export interface FileRouteTypes { | '/_app/$organizationId/integrations/_tabs/installed' | '/_app/$organizationId/members/_tabs/invitations' | '/_app/$organizationId/members/_tabs/members' + | '/_app/$organizationId/profile/_tabs/email-preferences' | '/_app/$organizationId/integrations/_tabs/' | '/_app/$organizationId/members/_tabs/' + | '/_app/$organizationId/profile/_tabs/' | '/_app/$organizationId/$projectId/events/_tabs/conversions' | '/_app/$organizationId/$projectId/events/_tabs/events' | '/_app/$organizationId/$projectId/events/_tabs/stats' @@ -933,6 +992,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRouteWithChildren PublicRoute: typeof PublicRouteWithChildren StepsRoute: typeof StepsRouteWithChildren + UnsubscribeRoute: typeof UnsubscribeRoute ApiConfigRoute: typeof ApiConfigRoute ApiHealthcheckRoute: typeof ApiHealthcheckRoute WidgetCounterRoute: typeof WidgetCounterRoute @@ -945,6 +1005,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/unsubscribe': { + id: '/unsubscribe' + path: '/unsubscribe' + fullPath: '/unsubscribe' + preLoaderRoute: typeof UnsubscribeRouteImport + parentRoute: typeof rootRouteImport + } '/_steps': { id: '/_steps' path: '' @@ -1043,6 +1110,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdRouteImport parentRoute: typeof AppRoute } + '/_app/$organizationId/profile': { + id: '/_app/$organizationId/profile' + path: '/profile' + fullPath: '/$organizationId/profile' + preLoaderRoute: typeof AppOrganizationIdProfileRouteImport + parentRoute: typeof AppOrganizationIdRoute + } '/_app/$organizationId/members': { id: '/_app/$organizationId/members' path: '/members' @@ -1162,6 +1236,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StepsOnboardingProjectIdConnectRouteImport parentRoute: typeof StepsRoute } + '/_app/$organizationId/profile/_tabs': { + id: '/_app/$organizationId/profile/_tabs' + path: '/profile' + fullPath: '/$organizationId/profile' + preLoaderRoute: typeof AppOrganizationIdProfileTabsRouteImport + parentRoute: typeof AppOrganizationIdProfileRoute + } '/_app/$organizationId/members/_tabs': { id: '/_app/$organizationId/members/_tabs' path: '/members' @@ -1239,6 +1320,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute } + '/_app/$organizationId/profile/_tabs/': { + id: '/_app/$organizationId/profile/_tabs/' + path: '/' + fullPath: '/$organizationId/profile/' + preLoaderRoute: typeof AppOrganizationIdProfileTabsIndexRouteImport + parentRoute: typeof AppOrganizationIdProfileTabsRoute + } '/_app/$organizationId/members/_tabs/': { id: '/_app/$organizationId/members/_tabs/' path: '/' @@ -1253,6 +1341,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsIndexRouteImport parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute } + '/_app/$organizationId/profile/_tabs/email-preferences': { + id: '/_app/$organizationId/profile/_tabs/email-preferences' + path: '/email-preferences' + fullPath: '/$organizationId/profile/email-preferences' + preLoaderRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRouteImport + parentRoute: typeof AppOrganizationIdProfileTabsRoute + } '/_app/$organizationId/members/_tabs/members': { id: '/_app/$organizationId/members/_tabs/members' path: '/members' @@ -1797,6 +1892,39 @@ const AppOrganizationIdMembersRouteWithChildren = AppOrganizationIdMembersRouteChildren, ) +interface AppOrganizationIdProfileTabsRouteChildren { + AppOrganizationIdProfileTabsEmailPreferencesRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRoute + AppOrganizationIdProfileTabsIndexRoute: typeof AppOrganizationIdProfileTabsIndexRoute +} + +const AppOrganizationIdProfileTabsRouteChildren: AppOrganizationIdProfileTabsRouteChildren = + { + AppOrganizationIdProfileTabsEmailPreferencesRoute: + AppOrganizationIdProfileTabsEmailPreferencesRoute, + AppOrganizationIdProfileTabsIndexRoute: + AppOrganizationIdProfileTabsIndexRoute, + } + +const AppOrganizationIdProfileTabsRouteWithChildren = + AppOrganizationIdProfileTabsRoute._addFileChildren( + AppOrganizationIdProfileTabsRouteChildren, + ) + +interface AppOrganizationIdProfileRouteChildren { + AppOrganizationIdProfileTabsRoute: typeof AppOrganizationIdProfileTabsRouteWithChildren +} + +const AppOrganizationIdProfileRouteChildren: AppOrganizationIdProfileRouteChildren = + { + AppOrganizationIdProfileTabsRoute: + AppOrganizationIdProfileTabsRouteWithChildren, + } + +const AppOrganizationIdProfileRouteWithChildren = + AppOrganizationIdProfileRoute._addFileChildren( + AppOrganizationIdProfileRouteChildren, + ) + interface AppOrganizationIdRouteChildren { AppOrganizationIdProjectIdRoute: typeof AppOrganizationIdProjectIdRouteWithChildren AppOrganizationIdBillingRoute: typeof AppOrganizationIdBillingRoute @@ -1804,6 +1932,7 @@ interface AppOrganizationIdRouteChildren { AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren + AppOrganizationIdProfileRoute: typeof AppOrganizationIdProfileRouteWithChildren } const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = { @@ -1814,6 +1943,7 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = { AppOrganizationIdIntegrationsRoute: AppOrganizationIdIntegrationsRouteWithChildren, AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren, + AppOrganizationIdProfileRoute: AppOrganizationIdProfileRouteWithChildren, } const AppOrganizationIdRouteWithChildren = @@ -1872,6 +2002,7 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRouteWithChildren, PublicRoute: PublicRouteWithChildren, StepsRoute: StepsRouteWithChildren, + UnsubscribeRoute: UnsubscribeRoute, ApiConfigRoute: ApiConfigRoute, ApiHealthcheckRoute: ApiHealthcheckRoute, WidgetCounterRoute: WidgetCounterRoute, diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx new file mode 100644 index 000000000..f4fbb29e6 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx @@ -0,0 +1,139 @@ +import { WithLabel } from '@/components/forms/input-with-label'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; +import { useTRPC } from '@/integrations/trpc/react'; +import { handleError } from '@/integrations/trpc/react'; +import { emailCategories } from '@openpanel/constants'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { SaveIcon } from 'lucide-react'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const validator = z.object({ + categories: z.record(z.string(), z.boolean()), +}); + +type IForm = z.infer; + +/** + * Build explicit boolean values for every key in emailCategories. + * Uses saved preferences when available, falling back to true (opted-in). + */ +function buildCategoryDefaults( + savedPreferences?: Record, +): Record { + return Object.keys(emailCategories).reduce( + (acc, category) => { + acc[category] = savedPreferences?.[category] ?? true; + return acc; + }, + {} as Record, + ); +} + +export const Route = createFileRoute( + '/_app/$organizationId/profile/_tabs/email-preferences', +)({ + component: Component, + pendingComponent: FullPageLoadingState, +}); + +function Component() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const preferencesQuery = useSuspenseQuery( + trpc.email.getPreferences.queryOptions(), + ); + + const { control, handleSubmit, formState, reset } = useForm({ + defaultValues: { + categories: buildCategoryDefaults(preferencesQuery.data), + }, + }); + + const mutation = useMutation( + trpc.email.updatePreferences.mutationOptions({ + onSuccess: async () => { + toast('Email preferences updated', { + description: 'Your email preferences have been saved.', + }); + await queryClient.invalidateQueries( + trpc.email.getPreferences.pathFilter(), + ); + // Reset form with fresh data after refetch + const freshData = await queryClient.fetchQuery( + trpc.email.getPreferences.queryOptions(), + ); + reset({ + categories: buildCategoryDefaults(freshData), + }); + }, + onError: handleError, + }), + ); + + return ( +
{ + 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 000000000..9791abed6 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx @@ -0,0 +1,96 @@ +import { InputWithLabel } from '@/components/forms/input-with-label'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { SaveIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const validator = z.object({ + firstName: z.string(), + lastName: z.string(), +}); + +type IForm = z.infer; + +export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({ + component: Component, + pendingComponent: FullPageLoadingState, +}); + +function Component() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const session = useSuspenseQuery(trpc.auth.session.queryOptions()); + const user = session.data?.user; + + const { register, handleSubmit, formState, reset } = useForm({ + defaultValues: { + firstName: user?.firstName ?? '', + lastName: user?.lastName ?? '', + }, + }); + + const mutation = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + toast('Profile updated', { + description: 'Your profile has been updated.', + }); + queryClient.invalidateQueries(trpc.auth.session.pathFilter()); + reset({ + firstName: data.firstName ?? '', + lastName: data.lastName ?? '', + }); + }, + onError: handleError, + }), + ); + + if (!user) { + return null; + } + + return ( +
{ + 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 000000000..ffcbb9a37 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx @@ -0,0 +1,55 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { ProfileAvatar } from '@/components/profiles/profile-avatar'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { usePageTabs } from '@/hooks/use-page-tabs'; +import { useTRPC } from '@/integrations/trpc/react'; +import { getProfileName } from '@/utils/getters'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_app/$organizationId/profile/_tabs')({ + component: Component, + pendingComponent: FullPageLoadingState, +}); + +function Component() { + const router = useRouter(); + const { activeTab, tabs } = usePageTabs([ + { + id: '/$organizationId/profile', + label: 'Profile', + }, + { id: 'email-preferences', label: 'Email preferences' }, + ]); + + const handleTabChange = (tabId: string) => { + router.navigate({ + from: Route.fullPath, + to: tabId, + }); + }; + + return ( + + + + + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + + + + ); +} diff --git a/apps/start/src/routes/unsubscribe.tsx b/apps/start/src/routes/unsubscribe.tsx new file mode 100644 index 000000000..d33be89f6 --- /dev/null +++ b/apps/start/src/routes/unsubscribe.tsx @@ -0,0 +1,89 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { PublicPageCard } from '@/components/public-page-card'; +import { Button, LinkButton } from '@/components/ui/button'; +import { useTRPC } from '@/integrations/trpc/react'; +import { emailCategories } from '@openpanel/constants'; +import { useMutation } from '@tanstack/react-query'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { useState } from 'react'; +import { z } from 'zod'; + +const unsubscribeSearchSchema = z.object({ + email: z.string().email(), + category: z.string(), + token: z.string(), +}); + +export const Route = createFileRoute('/unsubscribe')({ + component: RouteComponent, + validateSearch: unsubscribeSearchSchema, + pendingComponent: FullPageLoadingState, +}); + +function RouteComponent() { + const search = useSearch({ from: '/unsubscribe' }); + const { email, category, token } = search; + const trpc = useTRPC(); + const [isUnsubscribing, setIsUnsubscribing] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + + const unsubscribeMutation = useMutation( + trpc.email.unsubscribe.mutationOptions({ + onSuccess: () => { + setIsSuccess(true); + setIsUnsubscribing(false); + }, + onError: (err) => { + setError(err.message || 'Failed to unsubscribe'); + setIsUnsubscribing(false); + }, + }), + ); + + const handleUnsubscribe = () => { + setIsUnsubscribing(true); + setError(null); + unsubscribeMutation.mutate({ email, category, token }); + }; + + const categoryName = + emailCategories[category as keyof typeof emailCategories] || category; + + if (isSuccess) { + return ( + + ); + } + + return ( + + Unsubscribe from {categoryName} emails? You'll stop receiving{' '} + {categoryName.toLowerCase()} emails sent to  + {email} + + } + > +
+ {error && ( +
+ {error} +
+ )} + + + Cancel + +
+
+ ); +} diff --git a/apps/worker/package.json b/apps/worker/package.json index c8ae019e3..7c3c15722 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -20,9 +20,11 @@ "@openpanel/json": "workspace:*", "@openpanel/logger": "workspace:*", "@openpanel/importer": "workspace:*", + "@openpanel/payments": "workspace:*", "@openpanel/queue": "workspace:*", "@openpanel/redis": "workspace:*", "bullmq": "^5.63.0", + "date-fns": "^3.3.1", "express": "^4.18.2", "groupmq": "catalog:", "prom-client": "^15.1.3", diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts index a67c1e7f7..9eb558c68 100644 --- a/apps/worker/src/boot-cron.ts +++ b/apps/worker/src/boot-cron.ts @@ -39,6 +39,11 @@ export async function bootCron() { type: 'insightsDaily', pattern: '0 2 * * *', }, + { + name: 'onboarding', + type: 'onboarding', + pattern: '0 * * * *', + }, ]; if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') { diff --git a/apps/worker/src/boot-workers.ts b/apps/worker/src/boot-workers.ts index 4b739afcf..6d96dd61f 100644 --- a/apps/worker/src/boot-workers.ts +++ b/apps/worker/src/boot-workers.ts @@ -281,10 +281,20 @@ export async function bootWorkers() { eventName: string, evtOrExitCodeOrError: number | string | Error, ) { - logger.info('Starting graceful shutdown', { - code: evtOrExitCodeOrError, - eventName, - }); + // Log the actual error details for unhandled rejections/exceptions + if (evtOrExitCodeOrError instanceof Error) { + logger.error('Unhandled error triggered shutdown', { + eventName, + message: evtOrExitCodeOrError.message, + stack: evtOrExitCodeOrError.stack, + name: evtOrExitCodeOrError.name, + }); + } else { + logger.info('Starting graceful shutdown', { + code: evtOrExitCodeOrError, + eventName, + }); + } try { const time = performance.now(); diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts new file mode 100644 index 000000000..d2e61aaaa --- /dev/null +++ b/apps/worker/src/jobs/cron.onboarding.ts @@ -0,0 +1,276 @@ +import type { Job } from 'bullmq'; +import { differenceInDays } from 'date-fns'; + +import { db } from '@openpanel/db'; +import { + type EmailData, + type EmailTemplate, + sendEmail, +} from '@openpanel/email'; +import type { CronQueuePayload } from '@openpanel/queue'; + +import { getRecommendedPlan } from '@openpanel/payments'; +import { logger } from '../utils/logger'; + +// Types for the onboarding email system +const orgQuery = { + include: { + createdBy: { + select: { + id: true, + email: true, + firstName: true, + deletedAt: true, + }, + }, + }, +} as const; + +type OrgWithCreator = Awaited< + ReturnType> +>[number]; + +type OnboardingContext = { + org: OrgWithCreator; + user: NonNullable; +}; + +type OnboardingEmail = { + day: number; + template: T; + shouldSend?: (ctx: OnboardingContext) => Promise; + data: (ctx: OnboardingContext) => EmailData; +}; + +// Helper to create type-safe email entries with correlated template/data types +function email(config: OnboardingEmail) { + return config; +} + +const getters = { + firstName: (ctx: OnboardingContext) => ctx.user.firstName || undefined, + organizationName: (ctx: OnboardingContext) => ctx.org.name, + dashboardUrl: (ctx: OnboardingContext) => { + return `${process.env.DASHBOARD_URL}/${ctx.org.id}`; + }, + billingUrl: (ctx: OnboardingContext) => { + return `${process.env.DASHBOARD_URL}/${ctx.org.id}/billing`; + }, + recommendedPlan: (ctx: OnboardingContext) => { + return getRecommendedPlan( + ctx.org.subscriptionPeriodEventsCount, + (plan) => + `${plan.formattedEvents} events per month for ${plan.formattedPrice}`, + ); + }, +} as const; + +// Declarative email schedule - easy to add, remove, or reorder +const ONBOARDING_EMAILS = [ + email({ + day: 0, + template: 'onboarding-welcome', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + dashboardUrl: getters.dashboardUrl(ctx), + }), + }), + email({ + day: 2, + template: 'onboarding-what-to-track', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + }), + }), + email({ + day: 6, + template: 'onboarding-dashboards', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + dashboardUrl: getters.dashboardUrl(ctx), + }), + }), + email({ + day: 14, + template: 'onboarding-feature-request', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + }), + }), + email({ + day: 26, + template: 'onboarding-trial-ending', + shouldSend: async ({ org }) => { + if (org.subscriptionStatus === 'active') { + return 'complete'; + } + return true; + }, + data: (ctx) => { + return { + firstName: getters.firstName(ctx), + organizationName: getters.organizationName(ctx), + billingUrl: getters.billingUrl(ctx), + recommendedPlan: getters.recommendedPlan(ctx), + }; + }, + }), + email({ + day: 30, + template: 'onboarding-trial-ended', + shouldSend: async ({ org }) => { + if (org.subscriptionStatus === 'active') { + return 'complete'; + } + return true; + }, + data: (ctx) => { + return { + firstName: getters.firstName(ctx), + billingUrl: getters.billingUrl(ctx), + recommendedPlan: getters.recommendedPlan(ctx), + }; + }, + }), +]; + +export async function onboardingJob(job: Job) { + if (process.env.SELF_HOSTED === 'true') { + return null; + } + + logger.info('Starting onboarding email job'); + + // Fetch organizations that are in onboarding (not completed) + const orgs = await db.organization.findMany({ + where: { + OR: [{ onboarding: null }, { onboarding: { notIn: ['completed'] } }], + deleteAt: null, + createdBy: { + deletedAt: null, + }, + }, + ...orgQuery, + }); + + logger.info(`Found ${orgs.length} organizations in onboarding`); + + let emailsSent = 0; + let orgsCompleted = 0; + let orgsSkipped = 0; + + for (const org of orgs) { + // Skip if no creator or creator is deleted + if (!org.createdBy || org.createdBy.deletedAt) { + orgsSkipped++; + continue; + } + + const user = org.createdBy; + const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt); + + // Find the next email to send + // If org.onboarding is null or empty string, they haven't received any email yet + const lastSentIndex = org.onboarding + ? ONBOARDING_EMAILS.findIndex((e) => e.template === org.onboarding) + : -1; + const nextEmailIndex = lastSentIndex + 1; + + // No more emails to send + if (nextEmailIndex >= ONBOARDING_EMAILS.length) { + await db.organization.update({ + where: { id: org.id }, + data: { onboarding: 'completed' }, + }); + orgsCompleted++; + logger.info( + `Completed onboarding for organization ${org.id} (all emails sent)`, + ); + continue; + } + + const nextEmail = ONBOARDING_EMAILS[nextEmailIndex]; + if (!nextEmail) { + continue; + } + + logger.info( + `Checking if enough days have passed for organization ${org.id}`, + { + daysSinceOrgCreation, + nextEmailDay: nextEmail.day, + orgCreatedAt: org.createdAt, + today: new Date(), + }, + ); + // Check if enough days have passed + if (daysSinceOrgCreation < nextEmail.day) { + orgsSkipped++; + continue; + } + + // Check shouldSend callback if defined + if (nextEmail.shouldSend) { + const result = await nextEmail.shouldSend({ org, user }); + + if (result === 'complete') { + await db.organization.update({ + where: { id: org.id }, + data: { onboarding: 'completed' }, + }); + orgsCompleted++; + logger.info( + `Completed onboarding for organization ${org.id} (shouldSend returned complete)`, + ); + continue; + } + + if (result === false) { + orgsSkipped++; + continue; + } + } + + try { + const emailData = nextEmail.data({ org, user }); + + await sendEmail(nextEmail.template, { + to: user.email, + data: emailData as never, + }); + + // Update onboarding to the template name we just sent + await db.organization.update({ + where: { id: org.id }, + data: { onboarding: nextEmail.template }, + }); + + emailsSent++; + logger.info( + `Sent onboarding email "${nextEmail.template}" to organization ${org.id} (user ${user.id})`, + ); + } catch (error) { + logger.error( + `Failed to send onboarding email to organization ${org.id}`, + { + error, + template: nextEmail.template, + }, + ); + } + } + + logger.info('Completed onboarding email job', { + totalOrgs: orgs.length, + emailsSent, + orgsCompleted, + orgsSkipped, + }); + + return { + totalOrgs: orgs.length, + emailsSent, + orgsCompleted, + orgsSkipped, + }; +} diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts index eee51b161..135e174df 100644 --- a/apps/worker/src/jobs/cron.ts +++ b/apps/worker/src/jobs/cron.ts @@ -4,6 +4,7 @@ import { eventBuffer, profileBuffer, sessionBuffer } from '@openpanel/db'; import type { CronQueuePayload } from '@openpanel/queue'; import { jobdeleteProjects } from './cron.delete-projects'; +import { onboardingJob } from './cron.onboarding'; import { ping } from './cron.ping'; import { salt } from './cron.salt'; import { insightsDailyJob } from './insights'; @@ -31,5 +32,8 @@ export async function cronJob(job: Job) { case 'insightsDaily': { return await insightsDailyJob(job); } + case 'onboarding': { + return await onboardingJob(job); + } } } diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 27cf4119f..8169f0bd3 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -3,10 +3,7 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns'; export const DEFAULT_ASPECT_RATIO = 0.5625; export const NOT_SET_VALUE = '(not set)'; -export const RESERVED_EVENT_NAMES = [ - 'session_start', - 'session_end', -] as const; +export const RESERVED_EVENT_NAMES = ['session_start', 'session_end'] as const; export const timeWindows = { '30min': { @@ -508,6 +505,12 @@ export function getCountry(code?: string) { return countries[code as keyof typeof countries]; } +export const emailCategories = { + onboarding: 'Onboarding', +} as const; + +export type EmailCategory = keyof typeof emailCategories; + export const chartColors = [ { main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' }, { main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' }, diff --git a/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql b/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql new file mode 100644 index 000000000..bcd49719a --- /dev/null +++ b/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."organizations" +ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed'; \ No newline at end of file diff --git a/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql b/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql new file mode 100644 index 000000000..b2c9b5847 --- /dev/null +++ b/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "public"."email_unsubscribes" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT NOT NULL, + "category" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "email_unsubscribes_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "email_unsubscribes_email_category_key" ON "public"."email_unsubscribes"("email", "category"); diff --git a/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql new file mode 100644 index 000000000..cb115cd4d --- /dev/null +++ b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7131231e6..784f94fb0 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -62,6 +62,7 @@ model Organization { integrations Integration[] invites Invite[] timezone String? + onboarding String? @default("completed") // Subscription subscriptionId String? @@ -610,3 +611,13 @@ model InsightEvent { @@index([insightId, createdAt]) @@map("insight_events") } + +model EmailUnsubscribe { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + email String + category String + createdAt DateTime @default(now()) + + @@unique([email, category]) + @@map("email_unsubscribes") +} diff --git a/packages/email/package.json b/packages/email/package.json index a5664bcb0..27ae2e2ea 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@openpanel/db": "workspace:*", "@react-email/components": "^0.5.6", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/email/src/components/button.tsx b/packages/email/src/components/button.tsx new file mode 100644 index 000000000..563844d64 --- /dev/null +++ b/packages/email/src/components/button.tsx @@ -0,0 +1,24 @@ +import { Button as EmailButton } from '@react-email/components'; +import type * as React from 'react'; + +export function Button({ + href, + children, + style, +}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) { + return ( + + {children} + + ); +} diff --git a/packages/email/src/components/footer.tsx b/packages/email/src/components/footer.tsx index ad4dd2136..3beead858 100644 --- a/packages/email/src/components/footer.tsx +++ b/packages/email/src/components/footer.tsx @@ -11,7 +11,7 @@ import React from 'react'; const baseUrl = 'https://openpanel.dev'; -export function Footer() { +export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) { return ( <>
@@ -71,15 +71,17 @@ export function Footer() { - {/* - - Notification preferences - - */} + {unsubscribeUrl && ( + + + Notification preferences + + + )} ); diff --git a/packages/email/src/components/layout.tsx b/packages/email/src/components/layout.tsx index dbf6879c0..6900b31c4 100644 --- a/packages/email/src/components/layout.tsx +++ b/packages/email/src/components/layout.tsx @@ -7,15 +7,15 @@ import { Section, Tailwind, } from '@react-email/components'; -// biome-ignore lint/style/useImportType: resend needs React -import React from 'react'; +import type React from 'react'; import { Footer } from './footer'; type Props = { children: React.ReactNode; + unsubscribeUrl?: string; }; -export function Layout({ children }: Props) { +export function Layout({ children, unsubscribeUrl }: Props) { return ( @@ -57,7 +57,7 @@ export function Layout({ children }: Props) { />
{children}
-