This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-21 08:25:32 +01:00
parent 56f1c5e894
commit a58761e8d7
29 changed files with 777 additions and 298 deletions

View File

@@ -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'
@@ -102,6 +103,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,
@@ -525,6 +531,7 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/unsubscribe': typeof UnsubscribeRoute
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
'/login': typeof LoginLoginRoute
'/reset-password': typeof LoginResetPasswordRoute
@@ -591,6 +598,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/unsubscribe': typeof UnsubscribeRoute
'/login': typeof LoginLoginRoute
'/reset-password': typeof LoginResetPasswordRoute
'/onboarding': typeof PublicOnboardingRoute
@@ -653,6 +661,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
@@ -728,6 +737,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/unsubscribe'
| '/$organizationId'
| '/login'
| '/reset-password'
@@ -794,6 +804,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/unsubscribe'
| '/login'
| '/reset-password'
| '/onboarding'
@@ -855,6 +866,7 @@ export interface FileRouteTypes {
| '/_login'
| '/_public'
| '/_steps'
| '/unsubscribe'
| '/_app/$organizationId'
| '/_login/login'
| '/_login/reset-password'
@@ -933,6 +945,7 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRouteWithChildren
PublicRoute: typeof PublicRouteWithChildren
StepsRoute: typeof StepsRouteWithChildren
UnsubscribeRoute: typeof UnsubscribeRoute
ApiConfigRoute: typeof ApiConfigRoute
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
WidgetCounterRoute: typeof WidgetCounterRoute
@@ -945,6 +958,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: ''
@@ -1872,6 +1892,7 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRouteWithChildren,
PublicRoute: PublicRouteWithChildren,
StepsRoute: StepsRouteWithChildren,
UnsubscribeRoute: UnsubscribeRoute,
ApiConfigRoute: ApiConfigRoute,
ApiHealthcheckRoute: ApiHealthcheckRoute,
WidgetCounterRoute: WidgetCounterRoute,

View File

@@ -0,0 +1,112 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LoginNavbar } from '@/components/login-navbar';
import { useTRPC } from '@/integrations/trpc/react';
import { emailCategories } from '@openpanel/constants';
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 = trpc.email.unsubscribe.useMutation({
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 (
<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>
);
}
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>
</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>
</div>
</div>
);
}