mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-02 02:31:53 +02:00
♻️ Move disable notifications api (#1490)
This commit is contained in:
parent
26e4d5e3e6
commit
1ecb9f6b7b
12 changed files with 106 additions and 341 deletions
|
@ -51,6 +51,11 @@ const nextConfig = {
|
||||||
destination: "/settings/profile",
|
destination: "/settings/profile",
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/auth/disable-notifications",
|
||||||
|
destination: "/api/notifications/unsubscribe",
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
|
|
|
@ -198,16 +198,12 @@
|
||||||
"requireParticipantEmailLabel": "Make email address required for participants",
|
"requireParticipantEmailLabel": "Make email address required for participants",
|
||||||
"hideParticipantsLabel": "Hide participant list from other participants",
|
"hideParticipantsLabel": "Hide participant list from other participants",
|
||||||
"hideScoresLabel": "Hide scores until after a participant has voted",
|
"hideScoresLabel": "Hide scores until after a participant has voted",
|
||||||
"authErrorTitle": "Login Error",
|
|
||||||
"authErrorDescription": "There was an error logging you in. Please try again.",
|
|
||||||
"authErrorCta": "Go to login page",
|
|
||||||
"continueAs": "Continue as",
|
"continueAs": "Continue as",
|
||||||
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
|
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
|
||||||
"notRegistered": "Don't have an account? <a>Register</a>",
|
"notRegistered": "Don't have an account? <a>Register</a>",
|
||||||
"unlockFeatures": "Unlock all Pro features.",
|
"unlockFeatures": "Unlock all Pro features.",
|
||||||
"pollStatusFinalized": "Finalized",
|
"pollStatusFinalized": "Finalized",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"pageXOfY": "Page {currentPage} of {pageCount}",
|
|
||||||
"noParticipants": "No participants",
|
"noParticipants": "No participants",
|
||||||
"userId": "User ID",
|
"userId": "User ID",
|
||||||
"aboutGuest": "Guest User",
|
"aboutGuest": "Guest User",
|
||||||
|
@ -281,5 +277,7 @@
|
||||||
"savePercentage": "Save {percentage}%",
|
"savePercentage": "Save {percentage}%",
|
||||||
"1month": "1 month",
|
"1month": "1 month",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"cancelAnytime": "Cancel anytime from your <a>billing page</a>."
|
"cancelAnytime": "Cancel anytime from your <a>billing page</a>.",
|
||||||
|
"unsubscribeToastTitle": "You have disabled notifications",
|
||||||
|
"unsubscribeToastDescription": "You will no longer receive notifications for this poll"
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,13 @@ import { useTouchBeacon } from "@/components/poll/use-touch-beacon";
|
||||||
import { VotingForm } from "@/components/poll/voting-form";
|
import { VotingForm } from "@/components/poll/voting-form";
|
||||||
|
|
||||||
import { GuestPollAlert } from "./guest-poll-alert";
|
import { GuestPollAlert } from "./guest-poll-alert";
|
||||||
|
import { UnsubscribeAlert } from "./unsubscribe-alert";
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
useTouchBeacon();
|
useTouchBeacon();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 lg:space-y-4">
|
<div className="space-y-3 lg:space-y-4">
|
||||||
|
<UnsubscribeAlert />
|
||||||
<PollHeader />
|
<PollHeader />
|
||||||
<GuestPollAlert />
|
<GuestPollAlert />
|
||||||
<EventCard />
|
<EventCard />
|
||||||
|
|
34
apps/web/src/app/[locale]/poll/[urlId]/unsubscribe-alert.tsx
Normal file
34
apps/web/src/app/[locale]/poll/[urlId]/unsubscribe-alert.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function UnsubscribeAlert() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useTranslation("app");
|
||||||
|
|
||||||
|
const urlId = useParams<{ urlId: string }>()?.urlId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!urlId) return;
|
||||||
|
const cookieName = `notifications-unsubscribed-${urlId}`;
|
||||||
|
const unsubscribed = Cookies.get(cookieName);
|
||||||
|
if (unsubscribed) {
|
||||||
|
Cookies.remove(cookieName);
|
||||||
|
toast({
|
||||||
|
title: t("unsubscribeToastTitle", {
|
||||||
|
defaultValue: "You have disabled notifications",
|
||||||
|
}),
|
||||||
|
description: t("unsubscribeToastDescription", {
|
||||||
|
defaultValue:
|
||||||
|
"You will no longer receive notifications for this poll",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [t, toast, urlId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
60
apps/web/src/app/api/notifications/unsubscribe/route.ts
Normal file
60
apps/web/src/app/api/notifications/unsubscribe/route.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getServerSession } from "@/auth";
|
||||||
|
import type { DisableNotificationsPayload } from "@/trpc/types";
|
||||||
|
import { decryptToken } from "@/utils/session";
|
||||||
|
|
||||||
|
export const GET = async (req: NextRequest) => {
|
||||||
|
const token = req.nextUrl.searchParams.get("token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getServerSession();
|
||||||
|
|
||||||
|
if (!session || !session.user.email) {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await decryptToken<DisableNotificationsPayload>(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const watcher = await prisma.watcher.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
pollId: payload.pollId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watcher) {
|
||||||
|
await prisma.watcher.delete({
|
||||||
|
where: {
|
||||||
|
id: watcher.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
pollId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a session cookie to indicate that the user has unsubscribed
|
||||||
|
cookies().set(`notifications-unsubscribed-${watcher.pollId}`, "1", {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: false,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// redirect to poll
|
||||||
|
return NextResponse.redirect(new URL(`/poll/${watcher.pollId}`, req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL(`/poll/${payload.pollId}`, req.url));
|
||||||
|
};
|
|
@ -1,59 +0,0 @@
|
||||||
import { prisma } from "@rallly/database";
|
|
||||||
import type { GetServerSideProps } from "next";
|
|
||||||
|
|
||||||
import { getServerSession } from "@/auth";
|
|
||||||
import type { DisableNotificationsPayload } from "@/trpc/types";
|
|
||||||
import { decryptToken } from "@/utils/session";
|
|
||||||
|
|
||||||
const Page = () => {
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
|
||||||
const token = ctx.query.token as string;
|
|
||||||
const session = await getServerSession(ctx.req, ctx.res);
|
|
||||||
|
|
||||||
if (!session || session.user.email === null) {
|
|
||||||
return {
|
|
||||||
props: {},
|
|
||||||
redirect: {
|
|
||||||
destination:
|
|
||||||
"/login?callbackUrl=" + encodeURIComponent(ctx.req.url ?? "/"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session && token) {
|
|
||||||
const payload = await decryptToken<DisableNotificationsPayload>(token);
|
|
||||||
if (payload) {
|
|
||||||
const watcher = await prisma.watcher.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: session.user.id,
|
|
||||||
pollId: payload.pollId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (watcher) {
|
|
||||||
await prisma.watcher.delete({
|
|
||||||
where: {
|
|
||||||
id: watcher.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {},
|
|
||||||
redirect: {
|
|
||||||
destination: `/poll/${payload.pollId}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {},
|
|
||||||
notFound: true,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,39 +0,0 @@
|
||||||
import { Button } from "@rallly/ui/button";
|
|
||||||
import { XCircle } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import {
|
|
||||||
PageDialog,
|
|
||||||
PageDialogDescription,
|
|
||||||
PageDialogFooter,
|
|
||||||
PageDialogHeader,
|
|
||||||
PageDialogTitle,
|
|
||||||
} from "@/components/page-dialog";
|
|
||||||
import { Trans } from "@/components/trans";
|
|
||||||
|
|
||||||
const Page = () => {
|
|
||||||
return (
|
|
||||||
<PageDialog icon={XCircle}>
|
|
||||||
<PageDialogHeader>
|
|
||||||
<PageDialogTitle>
|
|
||||||
<Trans i18nKey="authErrorTitle" defaults="Login Error" />
|
|
||||||
</PageDialogTitle>
|
|
||||||
<PageDialogDescription>
|
|
||||||
<Trans
|
|
||||||
i18nKey="authErrorDescription"
|
|
||||||
defaults="There was an error logging you in. Please try again."
|
|
||||||
/>
|
|
||||||
</PageDialogDescription>
|
|
||||||
</PageDialogHeader>
|
|
||||||
<PageDialogFooter>
|
|
||||||
<Button asChild variant="primary">
|
|
||||||
<Link href="/login">
|
|
||||||
<Trans i18nKey="authErrorCta" defaults="Go to login page" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</PageDialogFooter>
|
|
||||||
</PageDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
|
@ -1,88 +0,0 @@
|
||||||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
|
||||||
import "tailwindcss/tailwind.css";
|
|
||||||
import "../style.css";
|
|
||||||
|
|
||||||
import { TooltipProvider } from "@rallly/ui/tooltip";
|
|
||||||
import { domMax, LazyMotion } from "framer-motion";
|
|
||||||
import type { NextPage } from "next";
|
|
||||||
import type { AppProps } from "next/app";
|
|
||||||
import { Inter } from "next/font/google";
|
|
||||||
import Head from "next/head";
|
|
||||||
import { SessionProvider, signIn, useSession } from "next-auth/react";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import Maintenance from "@/components/maintenance";
|
|
||||||
import { UserProvider } from "@/components/user-provider";
|
|
||||||
import { I18nProvider } from "@/i18n/client";
|
|
||||||
import { trpc } from "@/trpc/client";
|
|
||||||
import { ConnectedDayjsProvider } from "@/utils/dayjs";
|
|
||||||
|
|
||||||
import type { NextPageWithLayout } from "../types";
|
|
||||||
|
|
||||||
const inter = Inter({
|
|
||||||
subsets: ["latin"],
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
type AppPropsWithLayout = AppProps & {
|
|
||||||
Component: NextPageWithLayout;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Auth = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const session = useSession();
|
|
||||||
const isAuthenticated = !!session.data?.user.email;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
signIn();
|
|
||||||
}
|
|
||||||
}, [isAuthenticated]);
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MyApp: NextPage<AppPropsWithLayout> = ({ Component, pageProps }) => {
|
|
||||||
if (process.env.NEXT_PUBLIC_MAINTENANCE_MODE === "1") {
|
|
||||||
return <Maintenance />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLayout = Component.getLayout ?? ((page) => page);
|
|
||||||
const children = <Component {...pageProps} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SessionProvider>
|
|
||||||
<LazyMotion features={domMax}>
|
|
||||||
<Head>
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, user-scalable=yes"
|
|
||||||
/>
|
|
||||||
</Head>
|
|
||||||
<style jsx global>{`
|
|
||||||
html {
|
|
||||||
--font-inter: ${inter.style.fontFamily};
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<I18nProvider>
|
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<UserProvider>
|
|
||||||
<ConnectedDayjsProvider>
|
|
||||||
{Component.isAuthRequired ? (
|
|
||||||
<Auth>{getLayout(children)}</Auth>
|
|
||||||
) : (
|
|
||||||
getLayout(children)
|
|
||||||
)}
|
|
||||||
</ConnectedDayjsProvider>
|
|
||||||
</UserProvider>
|
|
||||||
</TooltipProvider>
|
|
||||||
</I18nProvider>
|
|
||||||
</LazyMotion>
|
|
||||||
</SessionProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default trpc.withTRPC(MyApp);
|
|
|
@ -1,110 +0,0 @@
|
||||||
import { Head, Html, Main, NextScript } from "next/document";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export default function Document() {
|
|
||||||
return (
|
|
||||||
<Html>
|
|
||||||
<Head>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon-precomposed"
|
|
||||||
sizes="57x57"
|
|
||||||
href="/apple-touch-icon-57x57.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon-precomposed"
|
|
||||||
sizes="114x114"
|
|
||||||
href="/apple-touch-icon-114x114.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon-precomposed"
|
|
||||||
sizes="72x72"
|
|
||||||
href="/apple-touch-icon-72x72.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon-precomposed"
|
|
||||||
sizes="144x144"
|
|
||||||
href="/apple-touch-icon-144x144.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon-precomposed"
|
|
||||||
sizes="60x60"
|
|
||||||
href="/apple-touch-icon-60x60.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon-precomposed"
|
|
||||||
sizes="120x120"
|
|
||||||
href="/apple-touch-icon-120x120.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon-precomposed"
|
|
||||||
sizes="76x76"
|
|
||||||
href="/apple-touch-icon-76x76.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon-precomposed"
|
|
||||||
sizes="152x152"
|
|
||||||
href="/apple-touch-icon-152x152.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
href="/favicon-196x196.png"
|
|
||||||
sizes="196x196"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
href="/favicon-96x96.png"
|
|
||||||
sizes="96x96"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
href="/favicon-32x32.png"
|
|
||||||
sizes="32x32"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
href="/favicon-16x16.png"
|
|
||||||
sizes="16x16"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
href="/favicon-128x128.png"
|
|
||||||
sizes="128x128"
|
|
||||||
/>
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<meta name="robots" content="noindex,nofollow" />
|
|
||||||
<meta name="application-name" content="Rallly" />
|
|
||||||
<meta name="msapplication-TileColor" content="#FFFFFF" />
|
|
||||||
<meta name="msapplication-TileImage" content="/mstile-144x144.png" />
|
|
||||||
<meta
|
|
||||||
name="msapplication-square70x70logo"
|
|
||||||
content="/mstile-70x70.png"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="msapplication-square150x150logo"
|
|
||||||
content="/mstile-150x150.png"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="msapplication-wide310x150logo"
|
|
||||||
content="/mstile-310x150.png"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="msapplication-square310x310logo"
|
|
||||||
content="/mstile-310x310.png"
|
|
||||||
/>
|
|
||||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
|
||||||
<meta name="theme-color" content="#F3F4F6" />
|
|
||||||
</Head>
|
|
||||||
<body>
|
|
||||||
<Main />
|
|
||||||
<NextScript />
|
|
||||||
<div id="portal"></div>
|
|
||||||
</body>
|
|
||||||
</Html>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
/**
|
|
||||||
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
|
|
||||||
*
|
|
||||||
* This page is loaded by Nextjs:
|
|
||||||
* - on the server, when data-fetching methods throw or reject
|
|
||||||
* - on the client, when `getInitialProps` throws or rejects
|
|
||||||
* - on the client, when a React lifecycle method throws or rejects, and it's
|
|
||||||
* caught by the built-in Nextjs error boundary
|
|
||||||
*
|
|
||||||
* See:
|
|
||||||
* - https://nextjs.org/docs/basic-features/data-fetching/overview
|
|
||||||
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
|
|
||||||
* - https://reactjs.org/docs/error-boundaries.html
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import type { NextPage } from "next";
|
|
||||||
import type { ErrorProps } from "next/error";
|
|
||||||
import NextErrorComponent from "next/error";
|
|
||||||
|
|
||||||
const CustomErrorComponent: NextPage<ErrorProps> = (props) => {
|
|
||||||
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
|
|
||||||
// compensate for https://github.com/vercel/next.js/issues/8592
|
|
||||||
// Sentry.captureUnderscoreErrorException(props);
|
|
||||||
|
|
||||||
return <NextErrorComponent statusCode={props.statusCode} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
CustomErrorComponent.getInitialProps = async (contextData) => {
|
|
||||||
// In case this is running in a serverless function, await this in order to give Sentry
|
|
||||||
// time to send the error before the lambda exits
|
|
||||||
await Sentry.captureUnderscoreErrorException(contextData);
|
|
||||||
|
|
||||||
// This will contain the status code of the response
|
|
||||||
return NextErrorComponent.getInitialProps(contextData);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomErrorComponent;
|
|
|
@ -93,7 +93,7 @@ export const comments = router({
|
||||||
authorName,
|
authorName,
|
||||||
pollUrl: absoluteUrl(`/poll/${poll.id}`),
|
pollUrl: absoluteUrl(`/poll/${poll.id}`),
|
||||||
disableNotificationsUrl: absoluteUrl(
|
disableNotificationsUrl: absoluteUrl(
|
||||||
`/auth/disable-notifications?token=${token}`,
|
`/api/notifications/unsubscribe?token=${token}`,
|
||||||
),
|
),
|
||||||
title: poll.title,
|
title: poll.title,
|
||||||
},
|
},
|
||||||
|
|
|
@ -169,7 +169,7 @@ export const participants = router({
|
||||||
participantName: participant.name,
|
participantName: participant.name,
|
||||||
pollUrl: absoluteUrl(`/poll/${participant.poll.id}`),
|
pollUrl: absoluteUrl(`/poll/${participant.poll.id}`),
|
||||||
disableNotificationsUrl: absoluteUrl(
|
disableNotificationsUrl: absoluteUrl(
|
||||||
`/auth/disable-notifications?token=${token}`,
|
`/api/notifications/unsubscribe?token=${token}`,
|
||||||
),
|
),
|
||||||
title: participant.poll.title,
|
title: participant.poll.title,
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue