Files
dataroom/pages/notification-preferences.tsx
zeekay 60163bce2e rebrand: Papermark → Hanzo Dataroom across user-facing surfaces
108 files: README, marketing copy, page titles, meta tags, footer
text, email templates, .env.example, UI strings. URLs swapped
from papermark.com/papermark.io to dataroom.hanzo.ai.

Internal identifiers left intact per repo policy: localStorage
keys (papermark.email/papermark.name/last_papermark_login —
changing them logs users out), type field names
(papermarkUserId), internal helper symbols (PAPERMARK_HEADERS,
PapermarkSparkle component, isPapermarkUrl helper),
internal-only filenames. LLM.md retains 'Upstream: Papermark'
attribution line. Per-locale .po translations regenerate from
source on the next lingui-extract CI run.

DB migrations untouched (append-only history; rewriting them
breaks installed schemas). LICENSE stays AGPL-3.0.
2026-06-07 12:23:40 -07:00

338 lines
11 KiB
TypeScript

import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import Head from "next/head";
import Image from "next/image";
import { useCallback, useState } from "react";
import {
BellOffIcon,
BellRingIcon,
CalendarClockIcon,
CheckIcon,
ClockIcon,
XIcon,
} from "lucide-react";
import { motion } from "motion/react";
import HanzoLogo from "@/public/_static/hanzo-dataroom-logo.svg";
import { Button } from "@/components/ui/button";
import prisma from "@/lib/prisma";
import { verifyUnsubscribeToken } from "@/lib/utils/unsubscribe";
import { ZViewerNotificationPreferencesSchema } from "@/lib/zod/schemas/notifications";
type FrequencyOption = "instant" | "daily" | "weekly" | "disabled";
const FREQUENCY_OPTIONS: {
value: FrequencyOption;
label: string;
description: string;
icon: typeof BellRingIcon;
}[] = [
{
value: "instant",
label: "Every update",
description: "Get notified immediately when new documents are added",
icon: BellRingIcon,
},
{
value: "daily",
label: "Daily digest",
description: "Receive a summary of changes once per day at 9 AM UTC",
icon: ClockIcon,
},
{
value: "weekly",
label: "Weekly digest",
description: "Receive a summary of changes every Monday at 9 AM UTC",
icon: CalendarClockIcon,
},
{
value: "disabled",
label: "Unsubscribed",
description: "Stop receiving notifications for this dataroom",
icon: BellOffIcon,
},
];
export async function getServerSideProps(context: GetServerSidePropsContext) {
const token = context.query.token as string | undefined;
if (!token) {
return { props: { error: "Token is required", token: "", data: null } };
}
const payload = verifyUnsubscribeToken(token);
if (!payload || !payload.dataroomId) {
return {
props: { error: "Invalid or expired token", token, data: null },
};
}
if (payload.exp && payload.exp < Date.now() / 1000) {
return { props: { error: "Token expired", token, data: null } };
}
const { viewerId, dataroomId, teamId } = payload;
try {
const [viewer, dataroom] = await Promise.all([
prisma.viewer.findUnique({
where: { id: viewerId, teamId },
select: { notificationPreferences: true },
}),
prisma.dataroom.findUnique({
where: { id: dataroomId, teamId },
select: { name: true },
}),
]);
if (!viewer) {
return { props: { error: "Viewer not found", token, data: null } };
}
const parsedPreferences = ZViewerNotificationPreferencesSchema.safeParse(
viewer.notificationPreferences,
);
const dataroomPrefs = parsedPreferences.success
? parsedPreferences.data.dataroom[dataroomId]
: undefined;
let currentFrequency: FrequencyOption;
if (dataroomPrefs?.enabled === false) {
currentFrequency = "disabled";
} else {
currentFrequency = dataroomPrefs?.frequency ?? "instant";
}
return {
props: {
error: null,
token,
data: {
dataroomName: dataroom?.name ?? "Unknown Dataroom",
currentFrequency,
},
},
};
} catch {
return {
props: { error: "Failed to load preferences", token, data: null },
};
}
}
export default function NotificationPreferencesPage({
error: serverError,
token,
data,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const [saving, setSaving] = useState(false);
const [selected, setSelected] = useState<FrequencyOption>(
data?.currentFrequency ?? "instant",
);
const [status, setStatus] = useState<"idle" | "success" | "error">(
serverError ? "error" : "idle",
);
const [errorMsg, setErrorMsg] = useState(serverError ?? "");
const handleSave = useCallback(async () => {
if (!token) return;
setSaving(true);
try {
const res = await fetch(
`/api/notification-preferences/dataroom?token=${token}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ frequency: selected }),
},
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || "Failed to save preferences");
}
setStatus("success");
} catch (err) {
setErrorMsg((err as Error).message);
setStatus("error");
} finally {
setSaving(false);
}
}, [token, selected]);
const hasChanged = data ? selected !== data?.currentFrequency : false;
return (
<>
<Head>
<title>Notification Preferences | Hanzo Dataroom</title>
</Head>
<div className="flex min-h-screen flex-col bg-gray-50">
<header className="px-6 py-5">
<a
href="https://dataroom.hanzo.ai"
target="_blank"
rel="noopener noreferrer"
>
<Image
src={HanzoLogo}
width={119}
height={32}
alt="Hanzo Dataroom"
/>
</a>
</header>
<div className="flex flex-1 items-start justify-center px-4 pt-[calc(10vh)]">
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
className="w-full max-w-md"
>
<div className="overflow-hidden rounded-lg border border-border bg-white shadow-sm">
<div className="px-6 pb-4 pt-6">
<h1 className="text-lg font-semibold text-foreground">
Notification Preferences
</h1>
{data ? (
<p className="mt-1 text-sm text-muted-foreground">
Choose how often you want to hear about updates to{" "}
<span className="font-medium text-foreground">
{data.dataroomName}
</span>
</p>
) : null}
</div>
<div className="px-6 pb-6">
{status === "error" && !data ? (
<div className="py-12 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10">
<XIcon className="h-5 w-5 text-destructive" />
</div>
<p className="text-sm font-medium text-foreground">
Something went wrong
</p>
<p className="mt-1 text-sm text-muted-foreground">
{errorMsg}
</p>
</div>
) : status === "success" ? (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.25 }}
className="py-12 text-center"
>
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
<CheckIcon className="h-5 w-5 text-emerald-600" />
</div>
<p className="text-sm font-medium text-foreground">
Preferences saved
</p>
<p className="mx-auto mt-1.5 max-w-xs text-sm text-muted-foreground">
{selected === "disabled"
? "You've unsubscribed from notifications for this dataroom."
: `You'll receive ${selected === "instant" ? "instant" : `${selected} digest`} notifications.`}
</p>
<p className="mt-6 text-xs text-muted-foreground/60">
You can close this window now.
</p>
</motion.div>
) : (
<>
<div className="space-y-2">
{FREQUENCY_OPTIONS.map((option) => {
const isSelected = selected === option.value;
const Icon = option.icon;
return (
<button
key={option.value}
onClick={() => {
setSelected(option.value);
setStatus("idle");
}}
className={`group flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors ${
isSelected
? "border-foreground bg-foreground/[0.03]"
: "border-border bg-white hover:bg-muted/50"
}`}
>
<Icon
className={`h-4 w-4 flex-shrink-0 ${
isSelected
? "text-foreground"
: "text-muted-foreground"
}`}
/>
<div className="min-w-0 flex-1">
<div
className={`text-sm font-medium ${
isSelected
? "text-foreground"
: "text-foreground"
}`}
>
{option.label}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{option.description}
</div>
</div>
<div
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border transition-colors ${
isSelected
? "border-foreground bg-foreground"
: "border-muted-foreground/40 bg-white"
}`}
>
{isSelected ? (
<CheckIcon className="h-2.5 w-2.5 text-white" />
) : null}
</div>
</button>
);
})}
</div>
{status === "error" && data ? (
<p className="mt-3 text-center text-sm text-destructive">
{errorMsg}
</p>
) : null}
<Button
onClick={handleSave}
disabled={saving || !hasChanged}
loading={saving}
className="mt-4 w-full"
>
Save preferences
</Button>
</>
)}
</div>
</div>
<p className="mt-4 text-center text-xs text-muted-foreground/60">
Powered by{" "}
<a
href="https://dataroom.hanzo.ai"
className="underline underline-offset-2 transition-colors hover:text-muted-foreground"
target="_blank"
rel="noopener noreferrer"
>
Hanzo Dataroom
</a>
</p>
</motion.div>
</div>
</div>
</>
);
}