feature: onboarding emails
* wip * wip * wip * fix coderabbit comments * remove template
This commit is contained in:
committed by
GitHub
parent
67301d928c
commit
e645c094b2
@@ -4,7 +4,7 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { LogoSquare } from '../logo';
|
||||
import { PublicPageCard } from '../public-page-card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
@@ -43,54 +43,27 @@ export function ShareEnterPassword({
|
||||
});
|
||||
});
|
||||
|
||||
const typeLabel =
|
||||
shareType === 'dashboard'
|
||||
? 'Dashboard'
|
||||
: shareType === 'report'
|
||||
? 'Report'
|
||||
: 'Overview';
|
||||
|
||||
return (
|
||||
<div className="center-center h-screen w-screen p-4 col">
|
||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||
<div className="col mt-1 flex-1 gap-2">
|
||||
<LogoSquare className="size-12 mb-4" />
|
||||
<div className="text-xl font-semibold">
|
||||
{shareType === 'dashboard'
|
||||
? 'Dashboard is locked'
|
||||
: shareType === 'report'
|
||||
? 'Report is locked'
|
||||
: 'Overview is locked'}
|
||||
</div>
|
||||
<div className="text-lg text-muted-foreground leading-normal">
|
||||
Please enter correct password to access this{' '}
|
||||
{shareType === 'dashboard'
|
||||
? 'dashboard'
|
||||
: shareType === 'report'
|
||||
? 'report'
|
||||
: 'overview'}
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="col gap-4 mt-6">
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-6 text-sm max-w-sm col gap-0.5">
|
||||
<p>
|
||||
Powered by{' '}
|
||||
<a href="https://openpanel.dev" className="font-medium">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
The best web and product analytics tool out there (our honest
|
||||
opinion).
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free today!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PublicPageCard
|
||||
title={`${typeLabel} is locked`}
|
||||
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
|
||||
>
|
||||
<form onSubmit={onSubmit} className="col gap-4">
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
</PublicPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
51
apps/start/src/components/public-page-card.tsx
Normal file
51
apps/start/src/components/public-page-card.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { LoginNavbar } from './login-navbar';
|
||||
import { LogoSquare } from './logo';
|
||||
|
||||
interface PublicPageCardProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
showFooter?: boolean;
|
||||
}
|
||||
|
||||
export function PublicPageCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
showFooter = true,
|
||||
}: PublicPageCardProps) {
|
||||
return (
|
||||
<div>
|
||||
<LoginNavbar />
|
||||
<div className="center-center h-screen w-screen p-4 col">
|
||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||
<div className="col mt-1 flex-1 gap-2">
|
||||
<LogoSquare className="size-12 mb-4" />
|
||||
<div className="text-xl font-semibold">{title}</div>
|
||||
{description && (
|
||||
<div className="text-lg text-muted-foreground leading-normal">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!children && <div className="mt-6">{children}</div>}
|
||||
</div>
|
||||
{showFooter && (
|
||||
<div className="p-6 text-sm max-w-sm col gap-1 text-muted-foreground">
|
||||
<p>
|
||||
Powered by{' '}
|
||||
<a href="https://openpanel.dev" className="font-medium">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
{' · '}
|
||||
<a href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free today!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const setCookieFn = createServerFn({ method: 'POST' })
|
||||
});
|
||||
|
||||
// Called in __root.tsx beforeLoad hook to get cookies from the server
|
||||
// And recieved with useRouteContext in the client
|
||||
// And received with useRouteContext in the client
|
||||
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
|
||||
pick(VALID_COOKIES, getCookies()),
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function CreateInvite() {
|
||||
<div>
|
||||
<SheetTitle>Invite a user</SheetTitle>
|
||||
<SheetDescription>
|
||||
Invite users to your organization. They will recieve an email
|
||||
Invite users to your organization. They will receive an email
|
||||
will instructions.
|
||||
</SheetDescription>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as UnsubscribeRouteImport } from './routes/unsubscribe'
|
||||
import { Route as StepsRouteImport } from './routes/_steps'
|
||||
import { Route as PublicRouteImport } from './routes/_public'
|
||||
import { Route as LoginRouteImport } from './routes/_login'
|
||||
@@ -37,6 +38,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 +49,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 +85,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',
|
||||
)()
|
||||
@@ -103,6 +110,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,
|
||||
@@ -174,6 +186,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 +289,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 +358,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 +376,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',
|
||||
@@ -531,6 +566,7 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/unsubscribe': typeof UnsubscribeRoute
|
||||
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
||||
'/login': typeof LoginLoginRoute
|
||||
'/reset-password': typeof LoginResetPasswordRoute
|
||||
@@ -559,6 +595,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 +610,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
|
||||
@@ -598,6 +637,7 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/unsubscribe': typeof UnsubscribeRoute
|
||||
'/login': typeof LoginLoginRoute
|
||||
'/reset-password': typeof LoginResetPasswordRoute
|
||||
'/onboarding': typeof PublicOnboardingRoute
|
||||
@@ -624,6 +664,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 +679,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
|
||||
@@ -661,6 +703,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
|
||||
@@ -691,6 +734,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 +754,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
|
||||
@@ -737,6 +784,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/unsubscribe'
|
||||
| '/$organizationId'
|
||||
| '/login'
|
||||
| '/reset-password'
|
||||
@@ -765,6 +813,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/sessions'
|
||||
| '/$organizationId/integrations'
|
||||
| '/$organizationId/members'
|
||||
| '/$organizationId/profile'
|
||||
| '/onboarding/$projectId/connect'
|
||||
| '/onboarding/$projectId/verify'
|
||||
| '/$organizationId/$projectId/'
|
||||
@@ -779,8 +828,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'
|
||||
@@ -804,6 +855,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/unsubscribe'
|
||||
| '/login'
|
||||
| '/reset-password'
|
||||
| '/onboarding'
|
||||
@@ -830,6 +882,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/sessions'
|
||||
| '/$organizationId/integrations'
|
||||
| '/$organizationId/members'
|
||||
| '/$organizationId/profile'
|
||||
| '/onboarding/$projectId/connect'
|
||||
| '/onboarding/$projectId/verify'
|
||||
| '/$organizationId/$projectId'
|
||||
@@ -844,6 +897,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'
|
||||
@@ -866,6 +920,7 @@ export interface FileRouteTypes {
|
||||
| '/_login'
|
||||
| '/_public'
|
||||
| '/_steps'
|
||||
| '/unsubscribe'
|
||||
| '/_app/$organizationId'
|
||||
| '/_login/login'
|
||||
| '/_login/reset-password'
|
||||
@@ -896,6 +951,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 +971,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'
|
||||
@@ -945,6 +1004,7 @@ export interface RootRouteChildren {
|
||||
LoginRoute: typeof LoginRouteWithChildren
|
||||
PublicRoute: typeof PublicRouteWithChildren
|
||||
StepsRoute: typeof StepsRouteWithChildren
|
||||
UnsubscribeRoute: typeof UnsubscribeRoute
|
||||
ApiConfigRoute: typeof ApiConfigRoute
|
||||
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
||||
WidgetBadgeRoute: typeof WidgetBadgeRoute
|
||||
@@ -958,6 +1018,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: ''
|
||||
@@ -1063,6 +1130,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 +1256,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 +1340,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 +1361,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 +1912,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 +1952,7 @@ interface AppOrganizationIdRouteChildren {
|
||||
AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute
|
||||
AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren
|
||||
AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren
|
||||
AppOrganizationIdProfileRoute: typeof AppOrganizationIdProfileRouteWithChildren
|
||||
}
|
||||
|
||||
const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
|
||||
@@ -1834,6 +1963,7 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
|
||||
AppOrganizationIdIntegrationsRoute:
|
||||
AppOrganizationIdIntegrationsRouteWithChildren,
|
||||
AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren,
|
||||
AppOrganizationIdProfileRoute: AppOrganizationIdProfileRouteWithChildren,
|
||||
}
|
||||
|
||||
const AppOrganizationIdRouteWithChildren =
|
||||
@@ -1892,6 +2022,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
LoginRoute: LoginRouteWithChildren,
|
||||
PublicRoute: PublicRouteWithChildren,
|
||||
StepsRoute: StepsRouteWithChildren,
|
||||
UnsubscribeRoute: UnsubscribeRoute,
|
||||
ApiConfigRoute: ApiConfigRoute,
|
||||
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
||||
WidgetBadgeRoute: WidgetBadgeRoute,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { WithLabel } from '@/components/forms/input-with-label';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { handleError } from '@/integrations/trpc/react';
|
||||
import { emailCategories } from '@openpanel/constants';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
categories: z.record(z.string(), z.boolean()),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
/**
|
||||
* Build explicit boolean values for every key in emailCategories.
|
||||
* Uses saved preferences when available, falling back to true (opted-in).
|
||||
*/
|
||||
function buildCategoryDefaults(
|
||||
savedPreferences?: Record<string, boolean>,
|
||||
): Record<string, boolean> {
|
||||
return Object.keys(emailCategories).reduce(
|
||||
(acc, category) => {
|
||||
acc[category] = savedPreferences?.[category] ?? true;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/profile/_tabs/email-preferences',
|
||||
)({
|
||||
component: Component,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const preferencesQuery = useSuspenseQuery(
|
||||
trpc.email.getPreferences.queryOptions(),
|
||||
);
|
||||
|
||||
const { control, handleSubmit, formState, reset } = useForm<IForm>({
|
||||
defaultValues: {
|
||||
categories: buildCategoryDefaults(preferencesQuery.data),
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.email.updatePreferences.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
toast('Email preferences updated', {
|
||||
description: 'Your email preferences have been saved.',
|
||||
});
|
||||
await queryClient.invalidateQueries(
|
||||
trpc.email.getPreferences.pathFilter(),
|
||||
);
|
||||
// Reset form with fresh data after refetch
|
||||
const freshData = await queryClient.fetchQuery(
|
||||
trpc.email.getPreferences.queryOptions(),
|
||||
);
|
||||
reset({
|
||||
categories: buildCategoryDefaults(freshData),
|
||||
});
|
||||
},
|
||||
onError: handleError,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Email Preferences</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="gap-4 col">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Choose which types of emails you want to receive. Uncheck a category
|
||||
to stop receiving those emails.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(emailCategories).map(([category, label]) => (
|
||||
<Controller
|
||||
key={category}
|
||||
name={`categories.${category}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-4 rounded-md border border-border hover:bg-def-200 transition-colors">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{category === 'onboarding' &&
|
||||
'Get started tips and guidance emails'}
|
||||
{category === 'billing' &&
|
||||
'Subscription updates and payment reminders'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={!formState.isDirty || mutation.isPending}
|
||||
className="self-end mt-4"
|
||||
icon={SaveIcon}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({
|
||||
component: Component,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const session = useSuspenseQuery(trpc.auth.session.queryOptions());
|
||||
const user = session.data?.user;
|
||||
|
||||
const { register, handleSubmit, formState, reset } = useForm<IForm>({
|
||||
defaultValues: {
|
||||
firstName: user?.firstName ?? '',
|
||||
lastName: user?.lastName ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.user.update.mutationOptions({
|
||||
onSuccess: (data) => {
|
||||
toast('Profile updated', {
|
||||
description: 'Your profile has been updated.',
|
||||
});
|
||||
queryClient.invalidateQueries(trpc.auth.session.pathFilter());
|
||||
reset({
|
||||
firstName: data.firstName ?? '',
|
||||
lastName: data.lastName ?? '',
|
||||
});
|
||||
},
|
||||
onError: handleError,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Profile</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="gap-4 col">
|
||||
<InputWithLabel
|
||||
label="First name"
|
||||
{...register('firstName')}
|
||||
defaultValue={user.firstName ?? ''}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Last name"
|
||||
{...register('lastName')}
|
||||
defaultValue={user.lastName ?? ''}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={!formState.isDirty || mutation.isPending}
|
||||
className="self-end"
|
||||
icon={SaveIcon}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
55
apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
Normal file
55
apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { usePageTabs } from '@/hooks/use-page-tabs';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs')({
|
||||
component: Component,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const router = useRouter();
|
||||
const { activeTab, tabs } = usePageTabs([
|
||||
{
|
||||
id: '/$organizationId/profile',
|
||||
label: 'Profile',
|
||||
},
|
||||
{ id: 'email-preferences', label: 'Email preferences' },
|
||||
]);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
router.navigate({
|
||||
from: Route.fullPath,
|
||||
to: tabId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title={'Your profile'} />
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="mt-2 mb-8"
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
89
apps/start/src/routes/unsubscribe.tsx
Normal file
89
apps/start/src/routes/unsubscribe.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PublicPageCard } from '@/components/public-page-card';
|
||||
import { Button, LinkButton } from '@/components/ui/button';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { emailCategories } from '@openpanel/constants';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const unsubscribeSearchSchema = z.object({
|
||||
email: z.string().email(),
|
||||
category: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/unsubscribe')({
|
||||
component: RouteComponent,
|
||||
validateSearch: unsubscribeSearchSchema,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const search = useSearch({ from: '/unsubscribe' });
|
||||
const { email, category, token } = search;
|
||||
const trpc = useTRPC();
|
||||
const [isUnsubscribing, setIsUnsubscribing] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const unsubscribeMutation = useMutation(
|
||||
trpc.email.unsubscribe.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setIsSuccess(true);
|
||||
setIsUnsubscribing(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message || 'Failed to unsubscribe');
|
||||
setIsUnsubscribing(false);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleUnsubscribe = () => {
|
||||
setIsUnsubscribing(true);
|
||||
setError(null);
|
||||
unsubscribeMutation.mutate({ email, category, token });
|
||||
};
|
||||
|
||||
const categoryName =
|
||||
emailCategories[category as keyof typeof emailCategories] || category;
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<PublicPageCard
|
||||
title="Unsubscribed"
|
||||
description={`You've been unsubscribed from ${categoryName} emails. You won't receive any more ${categoryName.toLowerCase()} emails from
|
||||
us.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicPageCard
|
||||
title="Unsubscribe"
|
||||
description={
|
||||
<>
|
||||
Unsubscribe from {categoryName} emails? You'll stop receiving{' '}
|
||||
{categoryName.toLowerCase()} emails sent to
|
||||
<span className="">{email}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="col gap-3">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleUnsubscribe} disabled={isUnsubscribing}>
|
||||
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
|
||||
</Button>
|
||||
<LinkButton href="/" variant="ghost">
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
</PublicPageCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user