wip
This commit is contained in:
@@ -4,7 +4,7 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { LogoSquare } from '../logo';
|
||||
import { PublicPageCard } from '../public-page-card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
@@ -43,54 +43,27 @@ export function ShareEnterPassword({
|
||||
});
|
||||
});
|
||||
|
||||
const typeLabel =
|
||||
shareType === 'dashboard'
|
||||
? 'Dashboard'
|
||||
: shareType === 'report'
|
||||
? 'Report'
|
||||
: 'Overview';
|
||||
|
||||
return (
|
||||
<div className="center-center h-screen w-screen p-4 col">
|
||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||
<div className="col mt-1 flex-1 gap-2">
|
||||
<LogoSquare className="size-12 mb-4" />
|
||||
<div className="text-xl font-semibold">
|
||||
{shareType === 'dashboard'
|
||||
? 'Dashboard is locked'
|
||||
: shareType === 'report'
|
||||
? 'Report is locked'
|
||||
: 'Overview is locked'}
|
||||
</div>
|
||||
<div className="text-lg text-muted-foreground leading-normal">
|
||||
Please enter correct password to access this{' '}
|
||||
{shareType === 'dashboard'
|
||||
? 'dashboard'
|
||||
: shareType === 'report'
|
||||
? 'report'
|
||||
: 'overview'}
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="col gap-4 mt-6">
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-6 text-sm max-w-sm col gap-0.5">
|
||||
<p>
|
||||
Powered by{' '}
|
||||
<a href="https://openpanel.dev" className="font-medium">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
The best web and product analytics tool out there (our honest
|
||||
opinion).
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free today!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PublicPageCard
|
||||
title={`${typeLabel} is locked`}
|
||||
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
|
||||
>
|
||||
<form onSubmit={onSubmit} className="col gap-4">
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
</PublicPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
51
apps/start/src/components/public-page-card.tsx
Normal file
51
apps/start/src/components/public-page-card.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { LoginNavbar } from './login-navbar';
|
||||
import { LogoSquare } from './logo';
|
||||
|
||||
interface PublicPageCardProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
showFooter?: boolean;
|
||||
}
|
||||
|
||||
export function PublicPageCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
showFooter = true,
|
||||
}: PublicPageCardProps) {
|
||||
return (
|
||||
<div>
|
||||
<LoginNavbar />
|
||||
<div className="center-center h-screen w-screen p-4 col">
|
||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||
<div className="col mt-1 flex-1 gap-2">
|
||||
<LogoSquare className="size-12 mb-4" />
|
||||
<div className="text-xl font-semibold">{title}</div>
|
||||
{description && (
|
||||
<div className="text-lg text-muted-foreground leading-normal">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!children && <div className="mt-6">{children}</div>}
|
||||
</div>
|
||||
{showFooter && (
|
||||
<div className="p-6 text-sm max-w-sm col gap-1 text-muted-foreground">
|
||||
<p>
|
||||
Powered by{' '}
|
||||
<a href="https://openpanel.dev" className="font-medium">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
{' · '}
|
||||
<a href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free today!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({
|
||||
component: Component,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const session = useSuspenseQuery(trpc.auth.session.queryOptions());
|
||||
const user = session.data?.user;
|
||||
|
||||
const { register, handleSubmit, formState, reset } = useForm<IForm>({
|
||||
defaultValues: {
|
||||
firstName: user?.firstName ?? '',
|
||||
lastName: user?.lastName ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.user.update.mutationOptions({
|
||||
onSuccess: (data) => {
|
||||
toast('Profile updated', {
|
||||
description: 'Your profile has been updated.',
|
||||
});
|
||||
queryClient.invalidateQueries(trpc.auth.session.pathFilter());
|
||||
reset({
|
||||
firstName: data.firstName ?? '',
|
||||
lastName: data.lastName ?? '',
|
||||
});
|
||||
},
|
||||
onError: handleError,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Profile</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="gap-4 col">
|
||||
<InputWithLabel
|
||||
label="First name"
|
||||
{...register('firstName')}
|
||||
defaultValue={user.firstName ?? ''}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Last name"
|
||||
{...register('lastName')}
|
||||
defaultValue={user.lastName ?? ''}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={!formState.isDirty || mutation.isPending}
|
||||
className="self-end"
|
||||
icon={SaveIcon}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
55
apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
Normal file
55
apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { usePageTabs } from '@/hooks/use-page-tabs';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs')({
|
||||
component: Component,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const router = useRouter();
|
||||
const { activeTab, tabs } = usePageTabs([
|
||||
{
|
||||
id: '/$organizationId/profile',
|
||||
label: 'Profile',
|
||||
},
|
||||
{ id: 'email-preferences', label: 'Email preferences' },
|
||||
]);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
router.navigate({
|
||||
from: Route.fullPath,
|
||||
to: tabId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title={'Your profile'} />
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="mt-2 mb-8"
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function bootCron() {
|
||||
{
|
||||
name: 'onboarding',
|
||||
type: 'onboarding',
|
||||
pattern: '0 10 * * *',
|
||||
pattern: '0 * * * *',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;
|
||||
@@ -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?
|
||||
|
||||
@@ -11,7 +11,7 @@ import React from 'react';
|
||||
|
||||
const baseUrl = 'https://openpanel.dev';
|
||||
|
||||
export function Footer() {
|
||||
export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<>
|
||||
<Hr />
|
||||
@@ -71,15 +71,17 @@ export function Footer() {
|
||||
</Text>
|
||||
</Row>
|
||||
|
||||
{/* <Row>
|
||||
<Link
|
||||
className="text-[#707070] text-[14px]"
|
||||
href="https://dashboard.openpanel.dev/settings/notifications"
|
||||
title="Unsubscribe"
|
||||
>
|
||||
Notification preferences
|
||||
</Link>
|
||||
</Row> */}
|
||||
{unsubscribeUrl && (
|
||||
<Row>
|
||||
<Link
|
||||
className="text-[#707070] text-[14px]"
|
||||
href={unsubscribeUrl}
|
||||
title="Unsubscribe"
|
||||
>
|
||||
Notification preferences
|
||||
</Link>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
// biome-ignore lint/style/useImportType: resend needs React
|
||||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { Footer } from './footer';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
unsubscribeUrl?: string;
|
||||
};
|
||||
|
||||
export function Layout({ children }: Props) {
|
||||
export function Layout({ children, unsubscribeUrl }: Props) {
|
||||
return (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
@@ -57,7 +57,7 @@ export function Layout({ children }: Props) {
|
||||
/>
|
||||
</Section>
|
||||
<Section className="p-6">{children}</Section>
|
||||
<Footer />
|
||||
<Footer unsubscribeUrl={unsubscribeUrl} />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -13,9 +13,10 @@ export default EmailInvite;
|
||||
export function EmailInvite({
|
||||
organizationName = 'Acme Co',
|
||||
url = 'https://openpanel.dev',
|
||||
}: Props) {
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>You've been invited to join {organizationName}!</Text>
|
||||
<Text>
|
||||
If you don't have an account yet, click the button below to create one
|
||||
|
||||
@@ -9,9 +9,12 @@ export const zEmailResetPassword = z.object({
|
||||
|
||||
export type Props = z.infer<typeof zEmailResetPassword>;
|
||||
export default EmailResetPassword;
|
||||
export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
|
||||
export function EmailResetPassword({
|
||||
url = 'https://openpanel.dev',
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>
|
||||
You have requested to reset your password. Follow the link below to
|
||||
reset your password:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ async function createOrGetOrganization(
|
||||
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
|
||||
subscriptionStatus: 'trialing',
|
||||
timezone: input.timezone,
|
||||
onboarding: 'onboarding-welcome',
|
||||
onboarding: '',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user