Compare commits

..
Author SHA1 Message Date
Marc Seitz 4e720bb2b6 chore: update deps 2025-07-21 22:38:09 +02:00
357 changed files with 6115 additions and 23845 deletions
-11
View File
@@ -1,11 +0,0 @@
# Security Policy
## Supported Versions
The latest version of Papermark is currently being supported with security updates.
## Reporting a Vulnerability
To report a vulnerability, send an email to security@papermark.com.
We will respond within 48 hours acknowledging your report with details about next steps and potential rewards/compensation for responsible disclosure.
+7 -4
View File
@@ -1,5 +1,6 @@
"use client";
import { Metadata } from "next";
import Link from "next/link";
import { useParams } from "next/navigation";
@@ -144,7 +145,9 @@ export default function Login() {
signIn("google", {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
setClickedMethod(undefined);
if (res?.status) {
setClickedMethod(undefined);
}
});
}}
loading={clickedMethod === "google"}
@@ -166,7 +169,9 @@ export default function Login() {
signIn("linkedin", {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
setClickedMethod(undefined);
if (res?.status) {
setClickedMethod(undefined);
}
});
}}
loading={clickedMethod === "linkedin"}
@@ -187,8 +192,6 @@ export default function Login() {
setClickedMethod("passkey");
signInWithPasskey({
tenantId: process.env.NEXT_PUBLIC_HANKO_TENANT_ID as string,
}).then(() => {
setClickedMethod(undefined);
});
}}
variant="outline"
+1 -8
View File
@@ -1,7 +1,5 @@
import { Metadata } from "next";
import { GTMComponent } from "@/components/gtm-component";
import LoginClient from "./page-client";
const data = {
@@ -39,10 +37,5 @@ export const metadata: Metadata = {
};
export default function LoginPage() {
return (
<>
<GTMComponent />
<LoginClient />
</>
);
return <LoginClient />;
}
-133
View File
@@ -1,133 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
import prisma from "@/lib/prisma";
// Validation schema for query parameters
const visitorFAQParamsSchema = z.object({
linkId: z.string().cuid("Invalid link ID format"),
dataroomId: z.string().cuid("Invalid dataroom ID format"),
documentId: z.string().cuid("Invalid document ID format").nullish(), // This is actually dataroomDocumentId
});
export interface VisitorFAQResponse {
id: string;
editedQuestion: string;
answer: string;
documentPageNumber?: number;
documentVersionNumber?: number;
createdAt: string;
document?: {
name: string;
};
}
// GET /api/faqs?linkId=xxx&dataroomId=xxx - List published FAQs for visitors
export async function GET(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams;
// Validate query parameters
const paramValidation = visitorFAQParamsSchema.safeParse({
linkId: searchParams.get("linkId"),
dataroomId: searchParams.get("dataroomId"),
documentId: searchParams.get("documentId"), // This is actually dataroomDocumentId
});
if (!paramValidation.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: paramValidation.error.errors[0]?.message,
},
{ status: 400 },
);
}
const { linkId, dataroomId, documentId } = paramValidation.data;
// Verify dataroom session
const session = await verifyDataroomSession(req, linkId, dataroomId);
if (!session) {
return NextResponse.json(
{ error: "Unauthorized - invalid or expired session" },
{ status: 401 },
);
}
// Build where clause based on visibility filters
const whereClause: any = {
dataroomId,
status: "PUBLISHED",
};
// Apply visibility filters
const visibilityFilters: any[] = [
{ visibilityMode: "PUBLIC_DATAROOM" }, // Always include dataroom-wide FAQs
];
if (linkId) {
visibilityFilters.push({
visibilityMode: "PUBLIC_LINK",
linkId: linkId,
});
}
if (documentId) {
visibilityFilters.push({
visibilityMode: "PUBLIC_DOCUMENT",
dataroomDocumentId: documentId,
});
}
whereClause.OR = visibilityFilters;
// Fetch published FAQs
const faqs = await prisma.dataroomFaqItem.findMany({
where: whereClause,
select: {
id: true,
editedQuestion: true,
answer: true,
documentPageNumber: true,
documentVersionNumber: true,
createdAt: true,
dataroomDocument: {
select: {
document: {
select: {
name: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
});
// Format response
const response: VisitorFAQResponse[] = faqs.map((faq: any) => ({
id: faq.id,
editedQuestion: faq.editedQuestion,
answer: faq.answer,
documentPageNumber: faq.documentPageNumber || undefined,
documentVersionNumber: faq.documentVersionNumber || undefined,
createdAt: faq.createdAt.toISOString(),
document: faq.dataroomDocument?.document
? {
name: faq.dataroomDocument.document.name,
}
: undefined,
}));
return NextResponse.json(response);
} catch (error) {
console.error("Error fetching visitor FAQs:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+2 -1
View File
@@ -37,8 +37,9 @@ export async function POST(req: Request) {
const domains = await prisma.domain.findMany({
where: {
slug: {
// exclude domains that belong to us
not: {
in: ["papermark.io", "papermark.com"],
contains: "papermark.io",
},
},
},
+158
View File
@@ -0,0 +1,158 @@
import { NextResponse } from "next/server";
import { limiter, receiver } from "@/lib/cron";
import { sendTrialEndFinalReminderEmail } from "@/lib/emails/send-trial-end-final-reminder";
import { sendTrialEndReminderEmail } from "@/lib/emails/send-trial-end-reminder";
import prisma from "@/lib/prisma";
import { calculateDaysLeft, log } from "@/lib/utils";
/**
* Cron to check if trial has expired.
* If a user is not on pro plan and has 5 days left on trial, we send a reminder email to upgrade plan.
* If a user is not on pro plan and has 1 day left on trial, we send a final reminder email to upgrade plan.
**/
// Runs once per day at 12pm (0 12 * * *)
export const maxDuration = 300; // 5 minutes in seconds
export async function POST(req: Request) {
const body = await req.json();
if (process.env.VERCEL === "1") {
const isValid = await receiver.verify({
signature: req.headers.get("Upstash-Signature") || "",
body: JSON.stringify(body),
});
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
}
try {
const teams = await prisma.team.findMany({
where: {
plan: {
// exclude users who are on pro or free plan
in: ["trial"],
},
},
select: {
id: true,
createdAt: true,
users: {
where: { role: "ADMIN" },
select: {
user: {
select: { email: true, name: true },
},
},
},
sentEmails: {
where: {
type: {
in: [
"FIRST_TRIAL_END_REMINDER_EMAIL",
"FINAL_TRIAL_END_REMINDER_EMAIL",
],
},
},
select: { type: true },
},
},
});
const results = await Promise.allSettled(
teams.map(async (team) => {
const { id, users, createdAt } = team as {
id: string;
createdAt: Date;
users: {
user: { email: string; name: string | null };
}[];
};
const sentEmails = team.sentEmails.map((email) => email.type);
const userEmail = users[0].user.email;
const userName = users[0].user.name;
const teamCreatedAt = createdAt;
let teamDaysLeft = calculateDaysLeft(new Date(teamCreatedAt));
// send first reminder email if team has 5 days left on trial
if (teamDaysLeft == 5) {
const sentFirstTrialEndReminderEmail = sentEmails.includes(
"FIRST_TRIAL_END_REMINDER_EMAIL",
);
if (!sentFirstTrialEndReminderEmail) {
return await Promise.allSettled([
log({
message: `Trial End Reminder for team: *${id}* is expiring in ${teamDaysLeft} days, email sent.`,
type: "cron",
}),
limiter.schedule(() =>
sendTrialEndReminderEmail(userEmail, userName),
),
prisma.sentEmail.create({
data: {
type: "FIRST_TRIAL_END_REMINDER_EMAIL",
teamId: id,
recipient: userEmail,
},
}),
]);
}
}
// send final reminder email if team has 1 day left on trial
if (teamDaysLeft <= 1) {
const sentFinalTrialEndReminderEmail = sentEmails.includes(
"FINAL_TRIAL_END_REMINDER_EMAIL",
);
if (!sentFinalTrialEndReminderEmail) {
return await Promise.allSettled([
log({
message: `Final Trial End Reminder for team: *${id}* is expiring in ${teamDaysLeft} days, email sent.`,
type: "cron",
}),
limiter.schedule(() =>
sendTrialEndFinalReminderEmail(userEmail, userName),
),
prisma.sentEmail.create({
data: {
type: "FINAL_TRIAL_END_REMINDER_EMAIL",
teamId: id,
recipient: userEmail,
},
}),
]);
}
}
// downgrade the user to free if team has 0 day left on trial
if (teamDaysLeft <= 0) {
return await Promise.allSettled([
log({
message: `Downgrade to free for user: *${id}* is expiring in ${teamDaysLeft} days, downgraded.`,
type: "cron",
}),
prisma.user.update({
where: { email: userEmail },
data: { plan: "free" },
}),
prisma.team.update({
where: { id },
data: { plan: "free" },
}),
]);
}
}),
);
return NextResponse.json(results);
} catch (error) {
await log({
message: `Trial end reminder cron failed. Error: " + ${(error as Error).message}`,
type: "cron",
mention: true,
});
return NextResponse.json({ error: (error as Error).message });
}
}
@@ -1,53 +0,0 @@
import { NextResponse } from "next/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";
import { z } from "zod";
import { getSlackInstallationUrl } from "@/lib/integrations/slack/install";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { getSearchParams } from "@/lib/utils/get-search-params";
const oAuthAuthorizeSchema = z.object({
teamId: z.string().cuid(),
});
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { teamId } = oAuthAuthorizeSchema.parse(getSearchParams(req.url));
console.log("teamId", teamId);
const userId = (session.user as CustomUser).id;
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const oauthUrl = await getSlackInstallationUrl(teamId);
console.log("oauthUrl", oauthUrl);
return NextResponse.json({
oauthUrl,
});
} catch (error) {
console.error("Slack OAuth authorization error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -1,96 +0,0 @@
import { redirect } from "next/navigation";
import { NextResponse } from "next/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { Team } from "@prisma/client";
import { getServerSession } from "next-auth";
import z from "zod";
import { installIntegration } from "@/lib/integrations/install";
import { getSlackEnv } from "@/lib/integrations/slack/env";
import { SlackCredential } from "@/lib/integrations/slack/types";
import { encryptSlackToken } from "@/lib/integrations/slack/utils";
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { CustomUser } from "@/lib/types";
import { getSearchParams } from "@/lib/utils/get-search-params";
export const dynamic = "force-dynamic";
const oAuthCallbackSchema = z.object({
code: z.string(),
state: z.string(),
});
export const GET = async (req: Request) => {
const env = getSlackEnv();
let workspace: Pick<Team, "id" | "plan"> | null = null;
try {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const userId = (session.user as CustomUser).id;
const { code, state } = oAuthCallbackSchema.parse(getSearchParams(req.url));
// Find workspace that initiated the Stripe app install
const teamId = await redis.get<string>(`slack:install:state:${state}`);
if (!teamId) {
throw new Error("Unknown state");
}
workspace = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
},
select: {
id: true,
plan: true,
},
});
const formData = new FormData();
formData.append("code", code);
formData.append("client_id", env.SLACK_CLIENT_ID);
formData.append("client_secret", env.SLACK_CLIENT_SECRET);
formData.append(
"redirect_uri",
`${process.env.NEXT_PUBLIC_BASE_URL}/api/integrations/slack/oauth/callback`,
);
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: formData,
});
const data = await response.json();
console.log("data", data);
const credentials: SlackCredential = {
appId: data.app_id,
botUserId: data.bot_user_id,
scope: data.scope,
accessToken: encryptSlackToken(data.access_token),
tokenType: data.token_type,
authUser: data.authed_user,
team: data.team,
};
await installIntegration({
integrationId: env.SLACK_INTEGRATION_ID,
userId,
teamId,
credentials,
});
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
redirect(`/settings/slack`);
};
-51
View File
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { ItemType, LinkAudienceType } from "@prisma/client";
@@ -17,10 +16,6 @@ import { PreviewSession, verifyPreviewSession } from "@/lib/auth/preview-auth";
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
import { getFile } from "@/lib/files/get-file";
import { newId } from "@/lib/id-helper";
import {
notifyDataroomAccess,
notifyDocumentView,
} from "@/lib/integrations/slack/events";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { parseSheet } from "@/lib/sheet";
@@ -103,8 +98,6 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
dataroomId: true,
emailProtected: true,
@@ -293,8 +286,6 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -307,8 +298,6 @@ export async function POST(request: NextRequest) {
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -325,8 +314,6 @@ export async function POST(request: NextRequest) {
// Deny access if the email is denied
if (isDenied) {
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -369,7 +356,6 @@ export async function POST(request: NextRequest) {
: false;
if (!isMember && !hasDomainAccess) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -661,24 +647,6 @@ export async function POST(request: NextRequest) {
enableNotification: link.enableNotification,
}),
);
if (link.teamId && !isPreview) {
waitUntil(
(async () => {
try {
await notifyDataroomAccess({
teamId: link.teamId!,
dataroomId,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
});
} catch (error) {
console.error("Error sending Slack notification:", error);
}
})(),
);
}
}
const dataroomViewId =
@@ -790,25 +758,6 @@ export async function POST(request: NextRequest) {
select: { id: true },
});
console.timeEnd("create-view");
// Only send Slack notifications for non-preview views
if (link.teamId && !isPreview) {
waitUntil(
(async () => {
try {
await notifyDocumentView({
teamId: link.teamId!,
documentId,
dataroomId,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
});
} catch (error) {
console.error("Error sending Slack notification:", error);
}
})(),
);
}
}
// if document version has pages, then return pages
+1 -25
View File
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
// Import authOptions directly from the source
import { authOptions } from "@/pages/api/auth/[...nextauth]";
@@ -14,14 +13,13 @@ import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verificati
import { getFeatureFlags } from "@/lib/featureFlags";
import { getFile } from "@/lib/files/get-file";
import { newId } from "@/lib/id-helper";
import { notifyDocumentView } from "@/lib/integrations/slack/events";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { parseSheet } from "@/lib/sheet";
import { recordLinkView } from "@/lib/tracking/record-link-view";
import { CustomUser, WatermarkConfigSchema } from "@/lib/types";
import { checkPassword, decryptEncrpytedPassword, log } from "@/lib/utils";
import { isEmailMatched } from "@/lib/utils/email-domain";
import { extractEmailDomain, isEmailMatched } from "@/lib/utils/email-domain";
import { generateOTP } from "@/lib/utils/generate-otp";
import { LOCALHOST_IP } from "@/lib/utils/geo";
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
@@ -86,9 +84,6 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
emailProtected: true,
enableNotification: true,
emailAuthenticated: true,
@@ -226,8 +221,6 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -240,8 +233,6 @@ export async function POST(request: NextRequest) {
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -258,8 +249,6 @@ export async function POST(request: NextRequest) {
// Deny access if the email is denied
if (isDenied) {
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -637,19 +626,6 @@ export async function POST(request: NextRequest) {
enableNotification: link.enableNotification,
}),
);
if (!isPreview) {
waitUntil(
notifyDocumentView({
teamId: link.teamId!,
documentId,
linkId,
viewerEmail: email ?? undefined,
viewerId: viewer?.id ?? undefined,
}).catch((error) => {
console.error("Error sending Slack notification:", error);
}),
);
}
}
const returnObject = {
+14 -9
View File
@@ -39,8 +39,8 @@ import { DataTablePagination } from "@/components/visitors/data-table-pagination
import { usePlan } from "@/lib/swr/use-billing";
import { fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradeButton } from "../ui/upgrade-button";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface Document {
id: string;
@@ -221,15 +221,20 @@ export default function DocumentsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_documents_export"
variant="outline"
size="sm"
/>
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_documents_export"
open={open}
setOpen={setOpen}
/>
</>
);
} else {
return (
@@ -246,7 +251,7 @@ export default function DocumentsTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="overflow-x-auto rounded-xl border">
<div className="rounded-xl border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+14 -9
View File
@@ -43,7 +43,7 @@ import { cn, timeAgo } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradeButton } from "../ui/upgrade-button";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface Link {
id: string;
@@ -285,15 +285,20 @@ export default function LinksTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_links_export"
variant="outline"
size="sm"
/>
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_links_export"
open={open}
setOpen={setOpen}
/>
</>
);
} else {
return (
@@ -310,7 +315,7 @@ export default function LinksTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="overflow-x-auto rounded-xl border">
<div className="rounded-xl border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+27 -9
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
@@ -24,6 +24,8 @@ import {
Download,
DownloadCloudIcon,
FileBadgeIcon,
FileDigitIcon,
MoreHorizontalIcon,
ServerIcon,
ThumbsDownIcon,
ThumbsUpIcon,
@@ -36,6 +38,11 @@ import { cn, durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Gauge } from "@/components/ui/gauge";
import {
Table,
@@ -48,7 +55,13 @@ import {
import { BadgeTooltip } from "@/components/ui/tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import { UpgradeButton } from "../ui/upgrade-button";
import VisitorChart from "@/components/visitors/visitor-chart";
import VisitorClicks from "@/components/visitors/visitor-clicks";
import VisitorCustomFields from "@/components/visitors/visitor-custom-fields";
import VisitorUserAgent from "@/components/visitors/visitor-useragent";
import VisitorVideoChart from "@/components/visitors/visitor-video-chart";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface View {
id: string;
@@ -328,15 +341,20 @@ export default function ViewsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_views_export"
variant="outline"
size="sm"
/>
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_views_export"
open={open}
setOpen={setOpen}
/>
</>
);
} else {
return (
+16 -9
View File
@@ -26,6 +26,7 @@ import { toast } from "sonner";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import { Gauge } from "@/components/ui/gauge";
import {
Table,
TableBody,
@@ -41,7 +42,8 @@ import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import { usePlan } from "@/lib/swr/use-billing";
import { durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradeButton } from "../ui/upgrade-button";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface Visitor {
email: string;
@@ -267,15 +269,20 @@ export default function VisitorsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_visitors_export"
variant="outline"
size="sm"
/>
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_visitors_export"
open={open}
setOpen={setOpen}
/>
</>
);
} else {
return (
@@ -292,7 +299,7 @@ export default function VisitorsTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="overflow-x-auto rounded-xl border">
<div className="rounded-xl border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+5 -6
View File
@@ -37,18 +37,17 @@ export function AddSeatModal({
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const analytics = useAnalytics();
const { plan: userPlan, planName, isAnnualPlan, isOldAccount } = usePlan();
const { plan: userPlan, planName, isAnnualPlan } = usePlan();
const { limits } = useLimits();
const [quantity, setQuantity] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(false);
// Get the minimum quantity for the current plan
const priceId = getPriceIdFromPlan({
planSlug: userPlan,
isOld: isOldAccount,
period: isAnnualPlan ? "yearly" : "monthly",
});
const priceId = getPriceIdFromPlan(
planName,
isAnnualPlan ? "yearly" : "monthly",
);
const minQuantity = getQuantityFromPriceId(priceId);
// Set initial quantity to 1 (adding one seat)
+3 -7
View File
@@ -8,11 +8,11 @@ import Cookies from "js-cookie";
import { usePlausible } from "next-plausible";
import { toast } from "sonner";
import { usePlan } from "@/lib/swr/use-billing";
import X from "@/components/shared/icons/x";
import { Button } from "@/components/ui/button";
import { usePlan } from "@/lib/swr/use-billing";
export default function ProAnnualBanner({
setShowProAnnualBanner,
}: {
@@ -63,11 +63,7 @@ export default function ProAnnualBanner({
"Content-Type": "application/json",
},
body: JSON.stringify({
priceId: getPriceIdFromPlan({
planSlug: "pro",
isOld: isOldAccount,
period: "yearly",
}),
priceId: getPriceIdFromPlan("Pro", "yearly"),
upgradePlan: true,
proAnnualBanner: true,
}),
+42 -304
View File
@@ -1,16 +1,9 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { CancellationModal } from "@/ee/features/billing/cancellation/components";
import { PlanEnum } from "@/ee/stripe/constants";
import { CreditCardIcon, MoreVertical, ReceiptTextIcon } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
import {
@@ -21,55 +14,27 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { UpgradeButton } from "@/components/ui/upgrade-button";
import { usePlan } from "@/lib/swr/use-billing";
export default function UpgradePlanContainer() {
const router = useRouter();
const [loading, setLoading] = useState<boolean>(false);
const [unpauseLoading, setUnpauseLoading] = useState<boolean>(false);
const [cancellationModalOpen, setCancellationModalOpen] =
useState<boolean>(false);
const { currentTeamId } = useTeam();
const {
plan,
isFree,
isDataroomsPlus,
isPaused,
isCancelled,
isCustomer,
startsAt,
endsAt,
discount,
mutate: mutatePlan,
} = usePlan({ withDiscount: true });
const analytics = useAnalytics();
const teamInfo = useTeam();
const { plan, isFree, isDataroomsPlus } = usePlan();
const manageSubscription = async ({
type,
}: {
type:
| "manage"
| "invoices"
| "subscription_update"
| "payment_method_update";
}) => {
if (!currentTeamId) return;
const manageSubscription = async () => {
if (!teamInfo?.currentTeam?.id) {
return;
}
const teamId = teamInfo?.currentTeam?.id;
setLoading(true);
try {
fetch(`/api/teams/${currentTeamId}/billing/manage`, {
fetch(`/api/teams/${teamId}/billing/manage`, {
method: "POST",
body: JSON.stringify({ type }),
headers: {
"Content-Type": "application/json",
},
})
.then(async (res) => {
const url = await res.json();
@@ -88,275 +53,48 @@ export default function UpgradePlanContainer() {
}
};
const handleUnpauseSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/unpause`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to unpause subscription");
}
// Track the unpause event for analytics
analytics.capture("Subscription Unpaused", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription unpaused successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const handleReactivateSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/reactivate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to reactivate subscription");
}
// Track the reactivation event for analytics
analytics.capture("Subscription Reactivated", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription reactivated successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const isBillingCycleCurrent = () => {
if (!startsAt || !endsAt) return false;
const currentDate = new Date();
return currentDate >= new Date(startsAt) && currentDate <= new Date(endsAt);
};
const getDiscountText = () => {
if (!discount || !discount.valid) return null;
let discountText = "";
if (discount.percentOff) {
discountText = `${discount.percentOff}% off`;
} else if (discount.amountOff) {
discountText = `$${(discount.amountOff / 100).toFixed(2)} off`;
}
if (discount.duration === "repeating" && discount.durationInMonths) {
discountText += ` for ${discount.durationInMonths} month${discount.durationInMonths > 1 ? "s" : ""}`;
} else if (discount.duration === "once") {
discountText += " (one-time)";
}
return discountText;
};
const ButtonList = () => {
if (isFree) {
return (
<div className="flex items-center gap-3">
<UpgradeButton
text=""
customText="Upgrade"
clickedPlan={PlanEnum.Business}
trigger="upgrade_plan"
useModal={false}
onClick={() => router.push("/settings/upgrade")}
/>
{isCustomer && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "invoices" })}
>
<ReceiptTextIcon className="h-4 w-4" />
View invoices
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
} else if (isCancelled) {
return (
<Button onClick={handleReactivateSubscription} loading={unpauseLoading}>
Reactivate subscription
</Button>
<Link href="/settings/upgrade">
<Button>Upgrade Plan</Button>
</Link>
);
} else {
return (
<div className="flex items-center gap-3">
{isPaused ? (
<Button
onClick={handleUnpauseSubscription}
loading={unpauseLoading}
>
Unpause subscription
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setCancellationModalOpen(true)}
>
Cancel subscription
</Button>
<Button
variant="outline"
onClick={() =>
manageSubscription({ type: "subscription_update" })
}
loading={loading}
>
Change plan
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "manage" })}
>
<CreditCardIcon className="h-4 w-4" />
Change billing information
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => manageSubscription({ type: "invoices" })}
>
<ReceiptTextIcon className="h-4 w-4" />
View invoices
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
<Button onClick={manageSubscription} loading={loading}>
Manage Subscription
</Button>
);
}
};
return (
<>
<div className="rounded-lg">
<Card className="bg-transparent">
<CardHeader>
<CardTitle>
<div className="rounded-lg">
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Billing</CardTitle>
<CardDescription>
You are currently on the{" "}
<span className="mx-0.5 rounded-full bg-background px-2.5 py-1 text-xs font-bold tracking-normal text-foreground ring-1 ring-gray-800 dark:ring-gray-400">
{isDataroomsPlus
? "Datarooms+"
: plan.charAt(0).toUpperCase() + plan.slice(1)}{" "}
Plan
</CardTitle>
{!isCancelled && startsAt && endsAt && isBillingCycleCurrent() && (
<CardDescription>
<span className="font-medium text-foreground">
Current billing cycle:{" "}
</span>
<span className="text-foreground">
{new Date(startsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{" - "}
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isPaused && endsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription will pause on:{" "}
</span>
<span className="text-foreground">
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isCancelled && endsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription cancels on:{" "}
</span>
<span className="text-foreground">
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{discount && discount.valid && getDiscountText() && (
<CardDescription>
<div className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/30">
🎉 {getDiscountText()} applied
</div>
</CardDescription>
)}
</CardHeader>
<CardContent></CardContent>
<CardFooter className="flex items-center justify-end rounded-b-lg border-t px-6 py-3">
<div className="shrink-0">{ButtonList()}</div>
</CardFooter>
</Card>
</div>
<CancellationModal
open={cancellationModalOpen}
onOpenChange={setCancellationModalOpen}
/>
</>
: plan.charAt(0).toUpperCase() + plan.slice(1)}
</span>{" "}
plan.{" "}
<Link
href="/settings/upgrade"
className="text-sm underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
</CardDescription>
</CardHeader>
<CardContent></CardContent>
<CardFooter className="flex items-center justify-end rounded-b-lg border-t px-6 py-3">
<div className="shrink-0">{ButtonList()}</div>
</CardFooter>
</Card>
</div>
);
}
+11 -19
View File
@@ -9,7 +9,8 @@ import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import { CheckIcon, CircleHelpIcon, Users2Icon, XIcon } from "lucide-react";
import { CheckIcon, Users2Icon, CircleHelpIcon } from "lucide-react";
import { toast } from "sonner";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
@@ -19,11 +20,11 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import {
BadgeTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
BadgeTooltip,
} from "@/components/ui/tooltip";
// Start Data Room Trial Button Component
@@ -31,7 +32,7 @@ const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
const router = useRouter();
const handleStartTrial = () => {
router.push("/welcome?type=dataroom-trial");
router.push('/welcome?type=dataroom-trial');
};
return (
@@ -52,11 +53,7 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
return (
<div className={cn("justify-between gap-x-8", baseClasses)}>
<div className="flex items-center gap-x-3">
{feature.isNotIncluded ? (
<XIcon className="h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
<span>{feature.text}</span>
</div>
{feature.tooltip && (
@@ -79,11 +76,7 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
return (
<div className={cn("text-sm", baseClasses)}>
{feature.isNotIncluded ? (
<XIcon className="mr-3 h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
<div className="flex items-center gap-2">
<span>{feature.text}</span>
{feature.tooltip && (
@@ -320,11 +313,10 @@ export function UpgradePlanModal({
loading={selectedPlan === planOption}
disabled={selectedPlan !== null}
onClick={() => {
const priceId = getPriceIdFromPlan({
planName: displayPlanName,
const priceId = getPriceIdFromPlan(
displayPlanName,
period,
isOld: isOldAccount,
});
);
setSelectedPlan(planOption);
if (isCustomer && teamPlan !== "free") {
@@ -390,8 +382,8 @@ export function UpgradePlanModal({
>
See all plans
</Link>
{((teamPlan === "free" && !isTrial) ||
(teamPlan === "pro" && !isTrial)) && (
{trigger === "sidebar_datarooms" &&
((teamPlan === "free" && !isTrial) || (teamPlan === "pro" && !isTrial)) && (
<>
<span>|</span>
<StartDataRoomTrialButton teamId={teamId} />
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { ConversationListItem as ConversationListItemEE } from "@/ee/features/conversations/components/dashboard/conversation-list-item";
import { ConversationMessage as ConversationMessageEE } from "@/ee/features/conversations/components/shared/conversation-message";
import { ConversationListItem as ConversationListItemEE } from "@/ee/features/conversations/components/conversation-list-item";
import { ConversationMessage as ConversationMessageEE } from "@/ee/features/conversations/components/conversation-message";
export function ConversationListItem(props: any) {
return <ConversationListItemEE {...props} />;
@@ -3,7 +3,8 @@ import { useState } from "react";
import { DownloadIcon } from "lucide-react";
import { toast } from "sonner";
import { ResponsiveButton } from "@/components/ui/responsive-button";
import { Button } from "@/components/ui/button";
import { ButtonTooltip } from "@/components/ui/tooltip";
export default function DownloadDataroomButton({
teamId,
@@ -56,13 +57,16 @@ export default function DownloadDataroomButton({
}
};
return (
<ResponsiveButton
icon={<DownloadIcon className="h-4 w-4" />}
text="Download"
onClick={downloadDataroom}
variant="outline"
size="sm"
loading={isLoading}
/>
<ButtonTooltip content="Download Dataroom" sideOffset={4}>
<Button
onClick={downloadDataroom}
variant="outline"
className=""
size="sm"
loading={isLoading}
>
<DownloadIcon />
</Button>
</ButtonTooltip>
);
}
@@ -25,7 +25,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveButton } from "@/components/ui/responsive-button";
import {
Select,
SelectContent,
@@ -136,13 +135,10 @@ export default function GenerateIndexDialog({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<ResponsiveButton
icon={<FileSlidersIcon className="h-4 w-4" />}
text="Generate Index"
variant="outline"
size="sm"
disabled={disabled}
/>
<Button variant="outline" size="sm" disabled={disabled}>
<FileSlidersIcon />
Generate Index File
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
+34 -89
View File
@@ -7,10 +7,6 @@ import {
useDataroom,
useDataroomFolderWithParents,
} from "@/lib/swr/use-dataroom";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import {
Breadcrumb,
@@ -29,70 +25,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { TruncatedBreadcrumbLink } from "../layouts/breadcrumb";
const BreadcrumbFolderItem = ({
folder,
dataroomId,
isLast,
}: {
folder: any;
dataroomId: string;
isLast: boolean;
}) => {
const displayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
if (isLast) {
return (
<BreadcrumbPage
className="max-w-[200px] truncate"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</BreadcrumbPage>
);
}
return (
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="max-w-[200px] truncate"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</Link>
</BreadcrumbLink>
);
};
const BreadcrumbDropdownItem = ({
folder,
dataroomId,
}: {
folder: any;
dataroomId: string;
}) => {
const displayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
return (
<DropdownMenuItem>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="w-full"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</Link>
</DropdownMenuItem>
);
};
function BreadcrumbComponentBase({
name,
dataroomId,
@@ -137,41 +69,54 @@ function BreadcrumbComponentBase({
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{folders.slice(0, -2).map((folder, index) => (
<BreadcrumbDropdownItem
key={index}
folder={folder}
dataroomId={dataroomId}
/>
<DropdownMenuItem key={index}>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="w-full"
>
{folder.name}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbFolderItem
folder={folders[folders.length - 2]}
dataroomId={dataroomId}
isLast={false}
/>
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folders[folders.length - 2].path}`}
className="max-w-[200px] truncate"
>
{folders[folders.length - 2].name}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbFolderItem
folder={folders[folders.length - 1]}
dataroomId={dataroomId}
isLast={true}
/>
<BreadcrumbPage className="max-w-[200px] truncate">
{folders[folders.length - 1].name}
</BreadcrumbPage>
</BreadcrumbItem>
</>
) : (
folders?.map((folder, index) => (
<React.Fragment key={index}>
<BreadcrumbItem>
<BreadcrumbFolderItem
folder={folder}
dataroomId={dataroomId}
isLast={index === folders.length - 1}
/>
{index === folders.length - 1 ? (
<BreadcrumbPage className="max-w-[200px] truncate">
{folder.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="max-w-[200px] truncate"
>
{folder.name}
</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{index < folders.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
@@ -196,4 +141,4 @@ const BreadcrumbComponent = () => {
return MemoizedBreadcrumbComponent;
};
export { BreadcrumbComponent };
export { BreadcrumbComponent };
@@ -20,10 +20,6 @@ import { type DataroomFolderDocument } from "@/lib/swr/use-dataroom";
import { type DocumentWithLinksAndLinkCountAndViewCount } from "@/lib/types";
import { cn, nFormatter, timeAgo } from "@/lib/utils";
import { fileIcon } from "@/lib/utils/get-file-icon";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import BarChart from "@/components/shared/icons/bar-chart";
import { Button } from "@/components/ui/button";
@@ -65,12 +61,6 @@ export default function DataroomDocumentCard({
theme === "light" || (theme === "system" && systemTheme === "light");
const router = useRouter();
// Get hierarchical display name
const displayName = useHierarchicalDisplayName(
dataroomDocument.document.name,
dataroomDocument.hierarchicalIndex,
);
const [isFirstClick, setIsFirstClick] = useState<boolean>(false);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);
@@ -215,11 +205,8 @@ export default function DataroomDocumentCard({
<div className="flex-col">
<div className="flex items-center">
<h2
className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
<h2 className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md">
{dataroomDocument.document.name}
</h2>
</div>
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
+1 -1
View File
@@ -26,7 +26,7 @@ export const DataroomNavigation = ({ dataroomId }: { dataroomId?: string }) => {
segment: "analytics",
},
{
label: "Q&A",
label: "Q&A Conversations",
href: `/datarooms/${dataroomId}/conversations`,
segment: "conversations",
limited: !limits?.conversationsInDataroom,
+45 -238
View File
@@ -3,10 +3,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { ExportJob } from "@/lib/redis-job-store";
import { Button } from "../ui/button";
interface ExportStatus {
status: string;
progress?: string;
@@ -35,9 +31,6 @@ export function ExportVisitsModal({
const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);
const [showModal, setShowModal] = useState(false);
const [viewCount, setViewCount] = useState<number | null>(null);
const [existingExports, setExistingExports] = useState<ExportJob[]>([]);
const [showNewExport, setShowNewExport] = useState(false);
const [loading, setLoading] = useState(true);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const exportStartedRef = useRef<boolean>(false);
@@ -50,45 +43,18 @@ export function ExportVisitsModal({
};
}, []);
// Fetch existing exports when modal opens
useEffect(() => {
const fetchExistingExports = async () => {
try {
setLoading(true);
setShowModal(true);
const endpoint = groupId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
const response = await fetch(endpoint, { method: "GET" });
if (response.ok) {
const exports = await response.json();
setExistingExports(exports);
} else {
console.error("Failed to fetch existing exports");
}
} catch (error) {
console.error("Error fetching existing exports:", error);
} finally {
setLoading(false);
}
};
fetchExistingExports();
}, [teamId, dataroomId, groupId]);
const startNewExport = useCallback(async () => {
const startExport = useCallback(async () => {
// Prevent double triggering
if (exportStartedRef.current) {
console.warn("Export already started, skipping duplicate request");
return;
}
exportStartedRef.current = true;
setShowNewExport(true);
try {
// Show modal immediately
setShowModal(true);
// Get view count first
try {
const viewCountResponse = await fetch(
@@ -106,11 +72,10 @@ export function ExportVisitsModal({
}
// Trigger the background export job
const endpoint = groupId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
const response = await fetch(endpoint, { method: "POST" });
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}${groupId ? `/groups/${groupId}` : ""}/export-visits`,
{ method: "POST" },
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -209,6 +174,11 @@ export function ExportVisitsModal({
}
}, [teamId, dataroomId, groupId, dataroomName, groupName]);
// Start export immediately when component mounts
useEffect(() => {
startExport();
}, [startExport]);
// Send export via email
const sendExportEmail = async () => {
if (!exportStatus?.exportId || !session?.user?.email) return;
@@ -231,72 +201,6 @@ export function ExportVisitsModal({
}
};
// Cancel export
const cancelExport = async () => {
if (!exportStatus?.exportId) return;
try {
const response = await fetch(
`/api/teams/${teamId}/export-jobs/${exportStatus.exportId}`,
{ method: "PATCH" },
);
if (response.ok) {
toast.success("Export cancelled successfully");
handleClose();
} else {
const errorData = await response.json();
toast.error(errorData.error || "Failed to cancel export");
}
} catch (error) {
console.error("Error cancelling export:", error);
toast.error("Failed to cancel export");
}
};
// Download existing export
const downloadExport = async (exportId: string, resourceName: string) => {
try {
const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;
const link = window.document.createElement("a");
link.href = downloadUrl;
link.setAttribute(
"download",
`${resourceName || dataroomName}_${groupName ? `${groupName}_` : ""}visits_${new Date().toISOString().split("T")[0]}.csv`,
);
link.rel = "noopener noreferrer";
link.style.display = "none";
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
} catch (error) {
console.error("Download failed:", error);
toast.error("Failed to download export");
}
};
// Format date for display
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case "COMPLETED":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "PROCESSING":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "PENDING":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
case "FAILED":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
// Handle close - cleanup and call parent onClose
const handleClose = () => {
if (pollIntervalRef.current) {
@@ -305,10 +209,6 @@ export function ExportVisitsModal({
}
setShowModal(false);
setExportStatus(null);
setShowNewExport(false);
setExistingExports([]); // Reset existing exports
setViewCount(null); // Reset view count
setLoading(true); // Reset loading state
exportStartedRef.current = false; // Reset for potential reuse
onClose();
};
@@ -324,7 +224,7 @@ export function ExportVisitsModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Export Visits</h3>
<button
@@ -347,141 +247,48 @@ export function ExportVisitsModal({
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
</div>
) : showNewExport ? (
// Show export progress
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
</div>
<div className="text-sm text-gray-600">
Exporting visits for: {displayName}
</div>
{viewCount !== null && (
<div className="text-sm text-gray-600">
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
</div>
)}
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} visits)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<button
onClick={sendExportEmail}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Email to {session.user.email}
</button>
</div>
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
{/* Cancel button - only show if export is in progress */}
{exportStatus?.exportId && (
<div className="flex gap-2 px-3">
<Button
onClick={cancelExport}
variant="outline"
size="sm"
className="flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950"
>
Cancel Export
</Button>
</div>
)}
<div className="text-sm text-gray-600">
Exporting visits for: {displayName}
</div>
) : (
// Show existing exports and new export option
<div className="space-y-4">
{viewCount !== null && (
<div className="text-sm text-gray-600">
Exports for: {displayName}
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
</div>
)}
{existingExports.length > 0 ? (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Recent Exports
</h4>
<div className="max-h-48 space-y-2 overflow-y-auto">
{existingExports.map((exportJob) => (
<div
key={exportJob.id}
className="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}
>
{exportJob.status}
</span>
<span className="text-xs text-gray-500">
{formatDate(exportJob.createdAt)}
</span>
</div>
{exportJob.groupId && (
<div className="mt-1 text-xs text-gray-500">
Group: {exportJob.groupId}
</div>
)}
{exportJob.error && (
<p className="mt-1 text-xs text-red-600">
{exportJob.error}
</p>
)}
</div>
{exportJob.status === "COMPLETED" && exportJob.result && (
<button
onClick={() =>
downloadExport(
exportJob.id,
exportJob.resourceName || dataroomName,
)
}
className="ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80"
>
Download
</button>
)}
</div>
))}
</div>
</div>
) : (
<div className="text-center text-sm text-gray-500">
No previous exports found
</div>
)}
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} visits)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<button
onClick={startNewExport}
onClick={sendExportEmail}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Start New Export
Email to {session.user.email}
</button>
</div>
</div>
)}
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
</div>
</div>
</div>
);
+6 -37
View File
@@ -9,10 +9,6 @@ import {
useDataroomFoldersTree,
} from "@/lib/swr/use-dataroom";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { sortByIndexThenName } from "@/lib/utils/sort-items-by-index-name";
import { FileTree } from "@/components/ui/nextra-filetree";
@@ -23,31 +19,6 @@ type MixedItem =
| (DataroomFolderWithDocuments & { itemType: "folder" })
| (DataroomFolderWithDocuments["documents"][0] & { itemType: "document" });
const DocumentFileItem = memo(
({
document,
router,
}: {
document: DataroomFolderWithDocuments["documents"][0] & {
itemType: "document";
};
router: any;
}) => {
const documentDisplayName = useHierarchicalDisplayName(
document.document.name,
document.hierarchicalIndex,
);
return (
<FileTree.File
name={documentDisplayName}
onToggle={() => router.push(`/documents/${document.document.id}`)}
/>
);
},
);
DocumentFileItem.displayName = "DocumentFileItem";
const FolderComponent = memo(
({
dataroomId,
@@ -58,12 +29,6 @@ const FolderComponent = memo(
}) => {
const router = useRouter();
// Get hierarchical display names
const folderDisplayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
const mixedItems = useMemo(() => {
const allItems: MixedItem[] = [
...(folder.childFolders || []).map((f) => ({
@@ -92,7 +57,11 @@ const FolderComponent = memo(
);
} else {
return (
<DocumentFileItem key={item.id} document={item} router={router} />
<FileTree.File
key={item.id}
name={item.document.name}
onToggle={() => router.push(`/documents/${item.document.id}`)}
/>
);
}
}),
@@ -118,7 +87,7 @@ const FolderComponent = memo(
return (
<FileTree.Folder
name={folderDisplayName}
name={folder.name}
key={folder.id}
active={isActive}
childActive={isChildActive}
-2
View File
@@ -61,7 +61,6 @@ type DataroomDocumentWithVersion = {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
versions: {
id: string;
versionNumber: number;
@@ -76,7 +75,6 @@ type DataroomFolderWithDocumentsNew = DataroomFolder & {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
}[];
};
+6 -62
View File
@@ -4,47 +4,17 @@ import { memo, useMemo } from "react";
import { DataroomFolder } from "@prisma/client";
import { HomeIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
getHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { FileTree } from "@/components/ui/nextra-filetree";
import { buildNestedFolderStructureWithDocs } from "./utils";
const ViewerDocumentFileItem = memo(
({
document,
dataroomIndexEnabled,
}: {
document: DataroomDocumentWithVersion;
dataroomIndexEnabled?: boolean;
}) => {
const documentDisplayName = getHierarchicalDisplayName(
document.name,
document.hierarchicalIndex,
dataroomIndexEnabled || false,
);
return (
<FileTree.File
name={documentDisplayName}
// onToggle={() => router.push(`/documents/${document.id}`)}
/>
);
},
);
ViewerDocumentFileItem.displayName = "ViewerDocumentFileItem";
type DataroomDocumentWithVersion = {
dataroomDocumentId: string;
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
versions: {
id: string;
versionNumber: number;
@@ -59,7 +29,6 @@ type DataroomFolderWithDocuments = DataroomFolder & {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
}[];
};
@@ -90,37 +59,25 @@ const FolderComponent = memo(
folderId,
setFolderId,
folderPath,
dataroomIndexEnabled,
}: {
folder: DataroomFolderWithDocuments;
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
folderPath: Set<string> | null;
dataroomIndexEnabled?: boolean;
}) => {
const router = useRouter();
// Get hierarchical display name for the folder
const folderDisplayName = getHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
dataroomIndexEnabled || false,
);
// Memoize the rendering of the current folder's documents
const documents = useMemo(
() =>
folder.documents.map((doc) => (
<ViewerDocumentFileItem
<FileTree.File
key={doc.id}
document={{
...doc,
versions: [], // Not needed for display
}}
dataroomIndexEnabled={dataroomIndexEnabled}
name={doc.name}
// onToggle={() => router.push(`/documents/${doc.id}`)}
/>
)),
[folder.documents, dataroomIndexEnabled],
[folder.documents, router.query.name],
);
// Recursively render child folders if they exist
@@ -133,16 +90,9 @@ const FolderComponent = memo(
folderId={folderId}
setFolderId={setFolderId}
folderPath={folderPath}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
)),
[
folder.childFolders,
folderId,
setFolderId,
folderPath,
dataroomIndexEnabled,
],
[folder.childFolders, folderId, setFolderId],
);
const isActive = folder.id === folderId;
@@ -159,7 +109,7 @@ const FolderComponent = memo(
}}
>
<FileTree.Folder
name={folderDisplayName}
name={folder.name}
key={folder.id}
active={isActive}
childActive={isChildActive}
@@ -215,13 +165,11 @@ const SidebarFolders = ({
documents,
folderId,
setFolderId,
dataroomIndexEnabled,
}: {
folders: DataroomFolder[];
documents: DataroomDocumentWithVersion[];
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
dataroomIndexEnabled?: boolean;
}) => {
const nestedFolders = useMemo(() => {
if (folders) {
@@ -255,7 +203,6 @@ const SidebarFolders = ({
folderId={folderId}
setFolderId={setFolderId}
folderPath={folderPath}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
))}
</FileTree>
@@ -267,13 +214,11 @@ export function ViewFolderTree({
documents,
setFolderId,
folderId,
dataroomIndexEnabled,
}: {
folders: DataroomFolder[];
documents: DataroomDocumentWithVersion[];
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
folderId: string | null;
dataroomIndexEnabled?: boolean;
}) {
if (!folders) return null;
@@ -283,7 +228,6 @@ export function ViewFolderTree({
documents={documents}
setFolderId={setFolderId}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
);
}
@@ -23,13 +23,8 @@ import {
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { useDataroomFoldersTree } from "@/lib/swr/use-dataroom";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
getHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import CloudDownloadOff from "@/components/shared/icons/cloud-download-off";
import { Button } from "@/components/ui/button";
@@ -43,35 +38,10 @@ import {
} from "@/components/ui/table";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const PermissionItemName = ({ item }: { item: FileOrFolder }) => {
const { isFeatureEnabled } = useFeatureFlags();
const isDataroomIndexEnabled = isFeatureEnabled("dataroomIndex");
const displayName = getHierarchicalDisplayName(
item.name,
item.hierarchicalIndex,
isDataroomIndexEnabled,
);
return (
<div className="flex items-center text-foreground">
{item.itemType === ItemType.DATAROOM_FOLDER ? (
<Folder className="mr-2 h-5 w-5" />
) : (
<File className="mr-2 h-5 w-5" />
)}
<span className="truncate" style={HIERARCHICAL_DISPLAY_STYLE}>
{displayName}
</span>
</div>
);
};
// Update the FileOrFolder type to include permissions
type FileOrFolder = {
id: string;
name: string;
hierarchicalIndex?: string | null;
subItems?: FileOrFolder[];
permissions: {
view: boolean;
@@ -115,7 +85,16 @@ const createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <PermissionItemName item={row.original} />,
cell: ({ row }) => (
<div className="flex items-center text-foreground">
{row.original.itemType === ItemType.DATAROOM_FOLDER ? (
<Folder className="mr-2 h-5 w-5" />
) : (
<File className="mr-2 h-5 w-5" />
)}
<span className="truncate">{row.original.name}</span>
</div>
),
},
{
id: "actions",
@@ -210,7 +189,6 @@ const buildTree = (
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
hierarchicalIndex: doc.hierarchicalIndex,
permissions: getPermissions(doc.id),
itemType: ItemType.DATAROOM_DOCUMENT,
}));
@@ -252,7 +230,6 @@ const buildTree = (
result.push({
id: folder.id,
name: folder.name,
hierarchicalIndex: folder.hierarchicalIndex,
subItems: allSubItems,
permissions: folderPermissions,
itemType: ItemType.DATAROOM_FOLDER,
@@ -271,7 +248,6 @@ const buildTree = (
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
hierarchicalIndex: doc.hierarchicalIndex,
permissions: getPermissions(doc.id),
itemType: ItemType.DATAROOM_DOCUMENT,
});
@@ -5,9 +5,6 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
import { SidebarFolderTreeSelection } from "@/components/datarooms/folders";
import { Button } from "@/components/ui/button";
import {
@@ -19,6 +16,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
import { TSelectedFolder } from "../documents/move-folder-modal";
export function MoveToDataroomFolderModal({
@@ -110,7 +110,7 @@ export function MoveToDataroomFolderModal({
<DialogDescription>Move your item to a folder.</DialogDescription>
</DialogHeader>
<form>
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
<div className="mb-2 max-h-[75vh] overflow-y-scroll">
<SidebarFolderTreeSelection
dataroomId={dataroomId}
selectedFolder={selectedFolder}
@@ -133,9 +133,7 @@ export function MoveToDataroomFolderModal({
) : (
<>
Move to{" "}
<span className="max-w-[200px] truncate font-medium">
{selectedFolder.name}
</span>
<span className="font-medium">{selectedFolder.name}</span>
</>
)}
</Button>
@@ -1,135 +0,0 @@
import { useState } from "react";
import { Hash, ListOrderedIcon, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveButton } from "@/components/ui/responsive-button";
interface RebuildIndexButtonProps {
teamId: string;
dataroomId: string;
disabled?: boolean;
}
export default function RebuildIndexButton({
teamId,
dataroomId,
disabled = false,
}: RebuildIndexButtonProps) {
const { isFeatureEnabled } = useFeatureFlags();
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const isDataroomIndexEnabled = isFeatureEnabled("dataroomIndex");
// Don't render if feature is not enabled
if (!isDataroomIndexEnabled) {
return null;
}
const handleRebuildIndex = async () => {
try {
setIsLoading(true);
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/calculate-indexes`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Failed to rebuild indexes");
}
const result = await response.json();
toast.success(
`Hierarchical indexes rebuilt successfully! Updated ${result.totalUpdated} items (${result.foldersUpdated} folders, ${result.documentsUpdated} documents).`,
);
setIsOpen(false);
// Trigger a page refresh to show updated indexes
window.location.reload();
} catch (error) {
console.error("Error rebuilding indexes:", error);
toast.error(
error instanceof Error ? error.message : "Failed to rebuild indexes",
);
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<ResponsiveButton
icon={<ListOrderedIcon className="h-4 w-4" />}
text="Rebuild Index"
variant="outline"
size="sm"
disabled={disabled}
/>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center">
Rebuild Hierarchical Index
</DialogTitle>
<DialogDescription>
Recalculate the hierarchical numbering based on the dataroom
items&apos; current order.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="rounded-lg bg-muted p-4">
<div className="flex items-start gap-3">
<div className="text-sm text-muted-foreground">
<p className="mb-1 font-medium">What this does:</p>
<ul className="list-inside list-disc space-y-1">
<li>
Analyzes the current folder structure and document order
</li>
<li>
Assigns hierarchical numbers (1, 1.1, 1.1.1, 2, 2.1, etc.)
</li>
<li>
Updates the display to show these numbers alongside names
</li>
<li>Maintains the existing order and hierarchy</li>
</ul>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button onClick={handleRebuildIndex} loading={isLoading}>
<ListOrderedIcon className="h-4 w-4" />
Rebuild Index
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,104 +0,0 @@
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface BulkDownloadSettingsProps {
dataroomId: string;
}
export default function BulkDownloadSettings({
dataroomId,
}: BulkDownloadSettingsProps) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { data: dataroomData, mutate: mutateDataroom } = useSWR<{
id: string;
name: string;
pId: string;
allowBulkDownload: boolean;
}>(
dataroomId ? `/api/teams/${teamId}/datarooms/${dataroomId}` : null,
fetcher,
);
const [isUpdating, setIsUpdating] = useState(false);
const handleBulkDownloadToggle = async (checked: boolean) => {
if (!dataroomId || !teamId || isUpdating) return;
setIsUpdating(true);
toast.promise(
fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
allowBulkDownload: checked,
}),
}).then(async (res) => {
if (!res.ok) {
throw new Error("Failed to update bulk download settings");
}
await mutateDataroom();
}),
{
loading: "Updating bulk download settings...",
success: "Bulk download settings updated successfully",
error: "Failed to update bulk download settings",
},
);
setIsUpdating(false);
};
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center">Dataroom Download</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label
htmlFor="bulk-download-toggle"
className="text-sm font-medium"
>
Allow bulk download of entire dataroom
</Label>
</div>
<Switch
id="bulk-download-toggle"
checked={dataroomData?.allowBulkDownload ?? true}
onCheckedChange={handleBulkDownloadToggle}
disabled={isUpdating}
/>
</div>
</CardContent>
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-6">
<p className="text-sm text-muted-foreground transition-colors">
When enabled, visitors can download all dataroom contents as a single
ZIP file. Individual document and folder downloads will still work
regardless of this setting.
</p>
</CardFooter>
</Card>
);
}
@@ -83,7 +83,7 @@ export default function NotificationSettings({
<CardTitle>
Notifications{" "}
{!isDataroomsPlus && !features?.roomChangeNotifications ? (
<PlanBadge plan="data rooms plus" />
<PlanBadge plan="datarooms-plus" />
) : null}
</CardTitle>
<CardDescription>
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { BellIcon, CogIcon, DownloadIcon, ShieldIcon } from "lucide-react";
import { BellIcon, CogIcon, ShieldIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -39,18 +39,6 @@ export default function SettingsTabs({ dataroomId }: SettingsTabsProps) {
<BellIcon className="h-4 w-4" />
Notifications
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/downloads`}
className={cn(
"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted",
{
"bg-muted font-medium": router.pathname.includes("downloads"),
},
)}
>
<DownloadIcon className="h-4 w-4" />
Downloads
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/file-permissions`}
className={cn(
@@ -155,10 +155,10 @@ export function DataroomSortableList({
mutate(`${baseKey}/folders`);
mutate(`${baseKey}/folders?include_documents=true`);
setIsReordering(false);
toast.success("Folder order saved successfully");
toast.success("Index saved successfully");
} catch (error) {
console.error("Failed to save new order:", error);
toast.error("Failed to save order");
toast.error("Failed to save index");
// Optionally, show an error message to the user
} finally {
setIsReordering(false);
@@ -247,7 +247,7 @@ export function DataroomSortableList({
className="gap-x-1"
>
<CheckIcon className="size-4" />
Save order
Save index
</Button>
</Portal>
<DeleteFolderModal />
+45 -8
View File
@@ -7,11 +7,7 @@ import { useTheme } from "next-themes";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import {
FREE_PLAN_ACCEPTED_FILE_TYPES,
FULL_PLAN_ACCEPTED_FILE_TYPES,
SUPPORTED_DOCUMENT_MIME_TYPES,
} from "@/lib/constants";
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "@/lib/constants";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { bytesToSize } from "@/lib/utils";
@@ -50,8 +46,49 @@ export default function DocumentUpload({
const { getRootProps, getInputProps } = useDropzone({
accept:
isFree && !isTrial
? FREE_PLAN_ACCEPTED_FILE_TYPES
: FULL_PLAN_ACCEPTED_FILE_TYPES,
? {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
[], // ".xlsx"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
}
: {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
[], // ".xlsx"
"application/vnd.ms-excel.sheet.macroEnabled.12": [".xlsm"], // ".xlsm"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"application/vnd.ms-powerpoint": [], // ".ppt"
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
[], // ".pptx"
"application/vnd.oasis.opendocument.presentation": [], // ".odp"
"application/msword": [], // ".doc"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[], // ".docx"
"application/vnd.oasis.opendocument.text": [], // ".odt"
"image/vnd.dwg": [".dwg"], // ".dwg"
"image/vnd.dxf": [".dxf"], // ".dxf"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
"application/zip": [], // ".zip"
"application/x-zip-compressed": [], // ".zip"
"video/mp4": [], // ".mp4"
"video/quicktime": [], // ".mov"
"video/x-msvideo": [], // ".avi"
"video/webm": [], // ".webm"
"video/ogg": [], // ".ogg"
"application/vnd.google-earth.kml+xml": [".kml"], // ".kml"
"application/vnd.google-earth.kmz": [".kmz"], // ".kmz"
"application/vnd.ms-outlook": [".msg"], // ".msg"
},
multiple: false,
onDropAccepted: (acceptedFiles) => {
if (acceptedFiles.length === 0) {
@@ -192,7 +229,7 @@ export default function DocumentUpload({
? "Replace file?"
: isFree && !isTrial
? `Only *.pdf, *.xls, *.xlsx, *.csv, *.ods, *.png, *.jpeg, *.jpg`
: `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.xlsm, *.csv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.rtf, *txt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}
: `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.xlsm, *.csv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}
</p>
</div>
</div>
+6 -27
View File
@@ -9,7 +9,6 @@ import { DefaultPermissionStrategy } from "@prisma/client";
import { parsePageId } from "notion-utils";
import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
import { useAnalytics } from "@/lib/analytics";
import {
@@ -19,7 +18,6 @@ import {
} from "@/lib/documents/create-document";
import { putFile } from "@/lib/files/put-file";
import { useDataroomPermissions } from "@/lib/hooks/use-dataroom-permissions";
import { getNotionPageIdFromSlug } from "@/lib/notion/utils";
import { usePlan } from "@/lib/swr/use-billing";
import { useDataroom } from "@/lib/swr/use-dataroom";
import useLimits from "@/lib/swr/use-limits";
@@ -389,36 +387,17 @@ export function AddDocumentModal({
return;
}
const validateNotionPageURL = parsePageId(notionLink);
// Check if it's a valid URL or not by Regx
const isValidURL =
/^(https?:\/\/)?([a-zA-Z0-9-]+\.){1,}[a-zA-Z]{2,}([a-zA-Z0-9-._~:/?#[\]@!$&'()*+,;=]+)?$/;
// Check if the field is empty or not
if (!notionLink) {
toast.error("Please enter a Notion link to proceed.");
return; // prevent form from submitting
}
// Validate URL format with Zod
const urlSchema = z.string().url();
const urlValidation = urlSchema.safeParse(notionLink);
if (!urlValidation.success) {
toast.error("Please enter a valid URL format.");
return;
}
// Try to validate the Notion page URL
let validateNotionPageId = parsePageId(notionLink);
// If parsePageId fails, try to get page ID from slug
if (validateNotionPageId === null) {
try {
const pageId = await getNotionPageIdFromSlug(notionLink);
validateNotionPageId = pageId || undefined;
} catch (slugError) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
}
if (!validateNotionPageId) {
if (validateNotionPageURL === null || !isValidURL.test(notionLink)) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
@@ -88,7 +88,7 @@ export function AddToDataroomModal({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[90vw] sm:max-w-[425px]">
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>
<span className="font-bold">{documentName}</span>
@@ -98,21 +98,18 @@ export function AddToDataroomModal({
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedDataroom(value)}>
<SelectTrigger className="w-[380px] max-w-full [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:truncate [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
<SelectTrigger className="min-w-fit">
<SelectValue placeholder="Select a dataroom" />
</SelectTrigger>
<SelectContent className="w-[380px] max-w-[90vw]">
<SelectContent>
{datarooms?.map((dataroom) => (
<SelectItem
key={dataroom.id}
value={dataroom.id}
disabled={dataroom.id === dataroomId}
className="break-words"
>
<span className="line-clamp-1 break-words">
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</span>
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</SelectItem>
))}
</SelectContent>
@@ -130,15 +127,15 @@ export function AddToDataroomModal({
{!selectedDataroom ? (
"Select a dataroom"
) : (
<span className="flex w-full max-w-[350px] items-center justify-center truncate">
Add to
<span className="ml-1 line-clamp-1 truncate font-medium">
<>
Add to{" "}
<span className="font-medium">
{
datarooms?.filter((d) => d.id === selectedDataroom)[0]
.name
}
</span>
</span>
</>
)}
</Button>
</DialogFooter>
@@ -114,21 +114,18 @@ export function AddFolderToDataroomModal({
<DialogDescription>Add your folder to a dataroom.</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedDataroom(value)}>
<SelectTrigger className="w-[380px] max-w-full [&>span]:truncate [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
<SelectTrigger className="min-w-fit">
<SelectValue placeholder="Select a dataroom" />
</SelectTrigger>
<SelectContent className="w-[380px] max-w-[90vw]">
<SelectContent>
{datarooms?.map((dataroom) => (
<SelectItem
key={dataroom.id}
value={dataroom.id}
disabled={dataroom.id === dataroomId}
className="break-words"
>
<span className="break-words line-clamp-1">
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</span>
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</SelectItem>
))}
</SelectContent>
@@ -146,15 +143,15 @@ export function AddFolderToDataroomModal({
{!selectedDataroom ? (
"Select a dataroom"
) : (
<span className="flex items-center justify-center w-full max-w-[350px] truncate">
Add to
<span className="font-medium truncate line-clamp-1 ml-1">
<>
Add to{" "}
<span className="font-medium">
{
datarooms?.filter((d) => d.id === selectedDataroom)[0]
.name
}
</span>
</span>
</>
)}
</Button>
</DialogFooter>
+1 -1
View File
@@ -608,7 +608,7 @@ export default function DocumentHeader({
{actionRows.map((row, i) => (
<ul
key={i.toString()}
className="flex flex-wrap items-center justify-end gap-x-2 md:flex-nowrap md:gap-x-1"
className="flex flex-wrap items-center justify-end gap-x-4 md:flex-nowrap md:gap-x-2"
>
{row.map((action, i) => (
<li key={i}>{action}</li>
+39 -225
View File
@@ -4,10 +4,6 @@ import { Document } from "@prisma/client";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { ExportJob } from "@/lib/redis-job-store";
import { Button } from "../ui/button";
interface ExportStatus {
status: string;
progress?: string;
@@ -30,9 +26,6 @@ export function ExportVisitsModal({
const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);
const [showModal, setShowModal] = useState(false);
const [viewCount, setViewCount] = useState<number | null>(null);
const [existingExports, setExistingExports] = useState<ExportJob[]>([]);
const [showNewExport, setShowNewExport] = useState(false);
const [loading, setLoading] = useState(true);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const exportStartedRef = useRef<boolean>(false);
@@ -45,44 +38,18 @@ export function ExportVisitsModal({
};
}, []);
// Fetch existing exports when modal opens
useEffect(() => {
const fetchExistingExports = async () => {
try {
setLoading(true);
setShowModal(true);
const response = await fetch(
`/api/teams/${teamId}/documents/${document.id}/export-visits`,
{ method: "GET" },
);
if (response.ok) {
const exports = await response.json();
setExistingExports(exports);
} else {
console.error("Failed to fetch existing exports");
}
} catch (error) {
console.error("Error fetching existing exports:", error);
} finally {
setLoading(false);
}
};
fetchExistingExports();
}, [teamId, document.id]);
const startNewExport = useCallback(async () => {
const startExport = useCallback(async () => {
// Prevent double triggering
if (exportStartedRef.current) {
console.warn("Export already started, skipping duplicate request");
return;
}
exportStartedRef.current = true;
setShowNewExport(true);
try {
// Show modal immediately
setShowModal(true);
// Get view count first
try {
const viewCountResponse = await fetch(
@@ -200,7 +167,12 @@ export function ExportVisitsModal({
);
handleClose();
}
}, [document.id, teamId, document.name]);
}, [document.id, teamId]);
// Start export immediately when component mounts
useEffect(() => {
startExport();
}, [startExport]);
// Send export via email
const sendExportEmail = async () => {
@@ -224,72 +196,6 @@ export function ExportVisitsModal({
}
};
// Cancel export
const cancelExport = async () => {
if (!exportStatus?.exportId) return;
try {
const response = await fetch(
`/api/teams/${teamId}/export-jobs/${exportStatus.exportId}`,
{ method: "PATCH" },
);
if (response.ok) {
toast.success("Export cancelled successfully");
handleClose();
} else {
const errorData = await response.json();
toast.error(errorData.error || "Failed to cancel export");
}
} catch (error) {
console.error("Error cancelling export:", error);
toast.error("Failed to cancel export");
}
};
// Download existing export
const downloadExport = async (exportId: string, resourceName: string) => {
try {
const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;
const link = window.document.createElement("a");
link.href = downloadUrl;
link.setAttribute(
"download",
`${resourceName || document.name}_visits_${new Date().toISOString().split("T")[0]}.csv`,
);
link.rel = "noopener noreferrer";
link.style.display = "none";
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
} catch (error) {
console.error("Download failed:", error);
toast.error("Failed to download export");
}
};
// Format date for display
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case "COMPLETED":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "PROCESSING":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "PENDING":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
case "FAILED":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
// Handle close - cleanup and call parent onClose
const handleClose = () => {
if (pollIntervalRef.current) {
@@ -298,10 +204,6 @@ export function ExportVisitsModal({
}
setShowModal(false);
setExportStatus(null);
setShowNewExport(false);
setExistingExports([]); // Reset existing exports
setViewCount(null); // Reset view count
setLoading(true); // Reset loading state
exportStartedRef.current = false; // Reset for potential reuse
onClose();
};
@@ -313,7 +215,7 @@ export function ExportVisitsModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Export Visits</h3>
<button
@@ -336,132 +238,44 @@ export function ExportVisitsModal({
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
</div>
) : showNewExport ? (
// Show export progress
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
</div>
{viewCount !== null && (
<div className="text-sm text-gray-600">
Found {viewCount} view{viewCount !== 1 ? "s" : ""} to export
</div>
)}
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} views)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<button
onClick={sendExportEmail}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Email to {session.user.email}
</button>
</div>
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
{/* Cancel button - only show if export is in progress */}
{exportStatus?.exportId && (
<div className="flex gap-2 px-3">
<Button
onClick={cancelExport}
variant="outline"
size="sm"
className="flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950"
>
Cancel Export
</Button>
</div>
)}
</div>
) : (
// Show existing exports and new export option
<div className="space-y-4">
{viewCount !== null && (
<div className="text-sm text-gray-600">
Exports for: {document.name}
Found {viewCount} view{viewCount !== 1 ? "s" : ""} to export
</div>
)}
{existingExports.length > 0 ? (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Recent Exports
</h4>
<div className="max-h-48 space-y-2 overflow-y-auto">
{existingExports.map((exportJob) => (
<div
key={exportJob.id}
className="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}
>
{exportJob.status}
</span>
<span className="text-xs text-gray-500">
{formatDate(exportJob.createdAt)}
</span>
</div>
{exportJob.error && (
<p className="mt-1 text-xs text-red-600">
{exportJob.error}
</p>
)}
</div>
{exportJob.status === "COMPLETED" && exportJob.result && (
<button
onClick={() =>
downloadExport(
exportJob.id,
exportJob.resourceName || document.name,
)
}
className="ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80"
>
Download
</button>
)}
</div>
))}
</div>
</div>
) : (
<div className="text-center text-sm text-gray-500">
No previous exports found
</div>
)}
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} views)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<button
onClick={startNewExport}
onClick={sendExportEmail}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Start New Export
Email to {session.user.email}
</button>
</div>
</div>
)}
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
</div>
</div>
</div>
);
+4 -17
View File
@@ -20,10 +20,6 @@ import { mutate } from "swr";
import { DataroomFolderWithCount } from "@/lib/swr/use-dataroom";
import { FolderWithCount } from "@/lib/swr/use-documents";
import { timeAgo } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { Button } from "@/components/ui/button";
import {
@@ -70,14 +66,6 @@ export default function FolderCard({
const [addDataroomOpen, setAddDataroomOpen] = useState<boolean>(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
// Get hierarchical display name for dataroom folders
const displayName = useHierarchicalDisplayName(
folder.name,
isDataroom && "hierarchicalIndex" in folder
? folder.hierarchicalIndex
: undefined,
);
const folderPath =
isDataroom && dataroomId
? `/datarooms/${dataroomId}/documents${folder.path}`
@@ -96,6 +84,8 @@ export default function FolderCard({
}
}, [openFolder, addDataroomOpen]);
const handleCreateDataroom = (e: any, folderId: string) => {
e.stopPropagation();
e.preventDefault();
@@ -170,11 +160,8 @@ export default function FolderCard({
<div className="flex-col">
<div className="flex items-center">
<h2
className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
<h2 className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md">
{folder.name}
</h2>
</div>
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
+6 -8
View File
@@ -5,9 +5,6 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { moveDocumentToFolder } from "@/lib/documents/move-documents";
import { moveFolderToFolder } from "@/lib/documents/move-folder";
import { SidebarFolderTreeSelection } from "@/components/sidebar-folders";
import { Button } from "@/components/ui/button";
import {
@@ -19,6 +16,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { moveDocumentToFolder } from "@/lib/documents/move-documents";
import { moveFolderToFolder } from "@/lib/documents/move-folder";
export type TSelectedFolder = {
id: string | null;
name: string;
@@ -104,14 +104,14 @@ export function MoveToFolderModal({
<DialogHeader className="text-start">
<DialogTitle>
Move
<div className="truncate font-bold">
<div className="w-[376px] truncate font-bold">
{`${(documentIds?.length ?? 0) + (folderIds?.length ?? 0)} items`}
</div>
</DialogTitle>
<DialogDescription>Move your item to a folder.</DialogDescription>
</DialogHeader>
<form>
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
<div className="mb-2 max-h-[75vh] overflow-y-scroll">
<SidebarFolderTreeSelection
selectedFolder={selectedFolder}
setSelectedFolder={setSelectedFolder}
@@ -133,9 +133,7 @@ export function MoveToFolderModal({
) : (
<>
Move to{" "}
<span className="max-w-[200px] truncate font-medium">
{selectedFolder.name}
</span>
<span className="font-medium">{selectedFolder.name}</span>
</>
)}
</Button>
@@ -1,305 +0,0 @@
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { DocumentVersion } from "@prisma/client";
import {
CircleCheckIcon,
CircleXIcon,
Edit,
ExternalLink,
RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { ButtonTooltip } from "@/components/ui/tooltip";
interface NotionAccessibilityIndicatorProps {
documentId: string;
primaryVersion: DocumentVersion;
onUrlUpdate?: () => void;
}
interface AccessibilityStatus {
isAccessible: boolean;
url: string;
statusCode?: number;
lastChecked: string;
error?: string;
}
export default function NotionAccessibilityIndicator({
documentId,
primaryVersion,
onUrlUpdate,
}: NotionAccessibilityIndicatorProps) {
const teamInfo = useTeam();
const [status, setStatus] = useState<AccessibilityStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isMainDialogOpen, setIsMainDialogOpen] = useState(false);
const [isUrlDialogOpen, setIsUrlDialogOpen] = useState(false);
const [newUrl, setNewUrl] = useState("");
const [isUpdating, setIsUpdating] = useState(false);
const checkAccessibility = async () => {
if (!teamInfo?.currentTeam?.id) return;
setIsLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo.currentTeam.id}/documents/${documentId}/check-notion-accessibility`,
);
const data = await response.json();
if (response.ok) {
setStatus(data);
} else {
toast.error("Failed to check accessibility");
}
} catch (error) {
console.error("Error checking accessibility:", error);
toast.error("Failed to check accessibility");
} finally {
setIsLoading(false);
}
};
const updateNotionUrl = async () => {
if (!teamInfo?.currentTeam?.id || !newUrl.trim()) return;
setIsUpdating(true);
try {
const response = await fetch(
`/api/teams/${teamInfo.currentTeam.id}/documents/${documentId}/update-notion-url`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ notionUrl: newUrl.trim() }),
},
);
const data = await response.json();
if (response.ok) {
toast.success("Notion URL updated successfully");
setIsUrlDialogOpen(false);
setNewUrl("");
// Refresh accessibility status
await checkAccessibility();
// Trigger parent component update if provided
if (onUrlUpdate) {
onUrlUpdate();
}
} else {
toast.error(data.message || "Failed to update URL");
}
} catch (error) {
console.error("Error updating URL:", error);
toast.error("Failed to update URL");
} finally {
setIsUpdating(false);
}
};
const openNotionPage = () => {
if (status?.url) {
window.open(status.url, "_blank");
}
};
// Check accessibility on component mount
useEffect(() => {
if (primaryVersion.type === "notion") {
checkAccessibility();
}
}, []);
// Only render for Notion documents
if (primaryVersion.type !== "notion") {
return null;
}
const getStatusIcon = () => {
if (isLoading) {
return <LoadingSpinner className="h-4 w-4" />;
}
if (status?.isAccessible) {
return (
<CircleCheckIcon className="h-4 w-4 rounded-full bg-green-500 text-white" />
);
} else {
return (
<CircleXIcon className="h-4 w-4 rounded-full bg-red-500 text-white" />
);
}
};
const getTooltipText = () => {
if (isLoading) {
return "Checking accessibility...";
}
if (status?.isAccessible) {
return "Notion page is publicly accessible";
} else if (status?.error) {
return "Unable to check accessibility";
} else {
return "Notion page is not publicly accessible";
}
};
return (
<>
{/* Compact Icon Trigger */}
<Dialog open={isMainDialogOpen} onOpenChange={setIsMainDialogOpen}>
<ButtonTooltip content={getTooltipText()}>
<DialogTrigger asChild>
<Button
variant="outline"
size="default"
className="h-8 w-8 p-0 lg:h-9 lg:w-9"
>
{getStatusIcon()}
</Button>
</DialogTrigger>
</ButtonTooltip>
{/* Main Status Dialog */}
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Notion Page Status</DialogTitle>
<DialogDescription>
Check and manage your Notion page accessibility.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Status Display */}
<div className="flex items-center gap-3 rounded-lg border p-3">
{getStatusIcon()}
<div className="flex-1">
<div className="font-medium">
{isLoading
? "Checking..."
: status?.isAccessible
? "Publicly accessible"
: status?.error
? "Unable to check"
: "Not publicly accessible"}
</div>
{status?.lastChecked && (
<div className="text-sm text-gray-500">
Last checked:{" "}
{new Date(status.lastChecked).toLocaleTimeString()}
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={checkAccessibility}
disabled={isLoading}
className="flex-1"
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
{status?.url && (
<Button
variant="outline"
size="sm"
onClick={openNotionPage}
className="flex-1"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open Page
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
setNewUrl(status?.url || "");
setIsUrlDialogOpen(true);
}}
className="flex-1"
>
<Edit className="mr-2 h-4 w-4" />
Edit URL
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* URL Edit Dialog */}
<Dialog open={isUrlDialogOpen} onOpenChange={setIsUrlDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update Notion URL</DialogTitle>
<DialogDescription>
Change the URL of the Notion page for this document.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="notion-url">Notion URL</Label>
<Input
id="notion-url"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="https://www.notion.so/your-page-url"
className="w-full"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsUrlDialogOpen(false)}
disabled={isUpdating}
>
Cancel
</Button>
<Button
onClick={updateNotionUrl}
disabled={isUpdating || !newUrl.trim()}
>
{isUpdating ? (
<>
<LoadingSpinner className="mr-2 h-4 w-4" />
Updating...
</>
) : (
"Update URL"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+5 -5
View File
@@ -23,7 +23,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
export function AddDomainModal({
open,
@@ -117,16 +116,17 @@ export function AddDomainModal({
) {
if (children) {
return (
<UpgradeButton
text="Add Domain"
<UpgradePlanModal
clickedPlan={
linkType === "DATAROOM_LINK"
? PlanEnum.DataRooms
: PlanEnum.Business
}
trigger={"add_domain_overview"}
highlightItem={["custom-domain"]}
trigger="add_domain_overview"
/>
>
<Button>Upgrade to Add Domain</Button>
</UpgradePlanModal>
);
} else {
return (
+12 -4
View File
@@ -14,8 +14,6 @@ import {
import { motion } from "motion/react";
import { mutate } from "swr";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -26,6 +24,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { StatusBadge } from "@/components/ui/status-badge";
import { cn } from "@/lib/utils";
import { useDeleteDomainModal } from "./delete-domain-modal";
import DomainConfiguration from "./domain-configuration";
import { useDomainStatus } from "./use-domain-status";
@@ -99,8 +99,16 @@ export default function DomainCard({
</div>
</div>
<div className="overflow-hidden">
<div className="flex items-center gap-1.5 truncate text-sm font-medium sm:gap-2.5">
{domain}
<div className="flex items-center gap-1.5 sm:gap-2.5">
<a
href={`http://${domain}`}
target="_blank"
rel="noreferrer"
className="truncate text-sm font-medium"
title={domain}
>
{domain}
</a>
{isDefault ? (
<span className="xs:px-3 xs:py-1 flex items-center gap-1 rounded-full bg-sky-400/[.15] px-1.5 py-0.5 text-xs font-medium text-sky-600">
+32 -38
View File
@@ -6,7 +6,6 @@ import { getSubdomain } from "@/lib/domains";
import { DomainVerificationStatusProps } from "@/lib/types";
import { cn } from "@/lib/utils";
import { CopyButton } from "../ui/copy-button";
import { TabSelect } from "../ui/tab-select";
export default function DomainConfiguration({
@@ -20,6 +19,30 @@ export default function DomainConfiguration({
const subdomain = getSubdomain(domainJson.name, domainJson.apexName);
const [recordType, setRecordType] = useState(!!subdomain ? "CNAME" : "A");
if (status === "Pending Verification") {
const txtVerification = domainJson.verification.find(
(x: any) => x.type === "TXT",
);
return (
<div>
<DnsRecord
instructions={`Please set the following TXT record on <code>${domainJson.apexName}</code> to prove ownership of <code>${domainJson.name}</code>:`}
records={[
{
type: txtVerification.type,
name: txtVerification.domain.slice(
0,
txtVerification.domain.length - domainJson.apexName.length - 1,
),
value: txtVerification.value,
},
]}
warning="Warning: if you are using this domain for another site, setting this TXT record will transfer domain ownership away from that site and break it. Please exercise caution when setting this record; make sure that the domain that is shown in the TXT verification value is actually the <b><i>domain you want to use on Papermark.io</i></b> <b><i>not your production site</i></b>."
/>
</div>
);
}
if (status === "Conflicting DNS Records") {
return (
<div className="pt-5">
@@ -72,11 +95,6 @@ export default function DomainConfiguration({
);
}
const txtVerification =
status === "Pending Verification"
? domainJson.verification.find((x: any) => x.type === "TXT")
: undefined;
return (
<div className="pt-2">
<div className="-ml-1.5 border-b border-gray-200 dark:border-gray-400">
@@ -98,39 +116,15 @@ export default function DomainConfiguration({
recordType === "A" ? "apex domain" : "subdomain"
} <code>${
recordType === "A" ? domainJson.apexName : domainJson.name
}</code>, set the following ${txtVerification ? "records" : `${recordType} record`} on your DNS provider:`}
}</code>, set the following ${recordType} record on your DNS provider:`}
records={[
{
type: recordType,
name: recordType === "A" ? "@" : (subdomain ?? "www"),
value:
recordType === "A"
? (configJson?.recommendedIPv4?.[0]?.value?.[0] ??
"76.76.21.21")
: (configJson?.recommendedCNAME?.[0]?.value ??
"cname.vercel-dns.com"),
value: recordType === "A" ? `76.76.21.21` : `cname.vercel-dns.com`,
ttl: "86400",
},
...(txtVerification
? [
{
type: txtVerification.type,
name: txtVerification.domain.slice(
0,
txtVerification.domain.length -
domainJson.apexName.length -
1,
),
value: txtVerification.value,
},
]
: []),
]}
warning={
txtVerification
? "Warning: if you are using this domain for another site, setting this TXT record will transfer domain ownership away from that site and break it. Please exercise caution when setting this record; make sure that the domain that is shown in the TXT verification value is actually the <b><i>domain you want to use on Papermark</i></b> <b><i>not your production site</i></b>."
: undefined
}
/>
</div>
);
@@ -157,16 +151,16 @@ const DnsRecord = ({
const hasTtl = records.some((x) => x.ttl);
return (
<div className="mt-3 text-left text-gray-600 dark:text-gray-400">
<div className="my-5">
<div className="mt-3 text-left text-gray-600">
<div className="my-5 text-gray-600 dark:text-gray-400">
<MarkdownText text={instructions} />
</div>
<div
className={cn(
"grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-gray-100/80 p-4 text-sm scrollbar-hide",
hasTtl
? "grid-cols-[repeat(4,max-content)]"
: "grid-cols-[repeat(3,max-content)]",
? "grid-cols-[repeat(4,min-content)]"
: "grid-cols-[repeat(3,min-content)]",
)}
>
{["Type", "Name", "Value"].concat(hasTtl ? "TTL" : []).map((s) => (
@@ -185,11 +179,11 @@ const DnsRecord = ({
</p>
<p key={record.value} className="flex items-end gap-1 font-mono">
{record.value}{" "}
<CopyButton
{/* <CopyButton
variant="neutral"
className="-mb-0.5"
value={record.value}
/>
/> */}
</p>
{hasTtl && (
<p key={record.ttl} className="font-mono">
-143
View File
@@ -1,143 +0,0 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function CustomDomainSetup({
name = "there",
currentPlan = "Free",
hasAccess = false,
}: {
name?: string;
currentPlan?: string;
hasAccess?: boolean;
}) {
const getPlanInfo = () => {
if (hasAccess) {
return {
title: "Your custom domain is ready to set up! 🎉",
subtitle: `Great news! Your ${currentPlan} plan includes custom domain access.`,
};
} else {
return {
title: "Interested in custom domains? 🚀",
subtitle:
"Learn how custom domains can enhance your document sharing experience.",
};
}
};
const { title, subtitle } = getPlanInfo();
return (
<Html>
<Head />
<Preview>Your Papermark custom domain set up</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
{title}
</Text>
<Text className="text-sm leading-6 text-black">Hi {name},</Text>
<Text className="text-sm leading-6 text-black">{subtitle}</Text>
{!hasAccess && (
<>
<Text className="text-sm leading-6 text-black">
<strong>Custom domains are available: </strong>
</Text>
<ul className="list-inside list-disc text-sm">
<li>
<strong>Business:</strong> Custom domain for documents
</li>
<li>
<strong>Data Rooms:</strong> Custom domain for data rooms
</li>
<li>
<strong>Data Rooms Plus:</strong> Unlimited custom domains
for data rooms and documents
</li>
</ul>
</>
)}
<Text className="text-sm leading-6 text-black">
<strong>Setting up your custom domain:</strong>
</Text>
<ol className="list-inside list-decimal text-sm">
<li>Choose your subdomain (e.g. docs.yourcompany.com)</li>
<li>Add a CNAME record pointing to papermark.com</li>
<li>Configure the domain in your Papermark settings</li>
<li>Start sharing with your branded domain!</li>
</ol>
<Section className="my-8 text-center">
{hasAccess ? (
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/domains`}
style={{ padding: "12px 20px" }}
>
Set up your custom domain
</Button>
) : (
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/upgrade`}
style={{ padding: "12px 20px" }}
>
Upgrade to use custom domains
</Button>
)}
</Section>
<Text className="text-sm leading-6 text-black">
{hasAccess ? (
<>
Need help? Check out our{" "}
<Link
href="https://docs.papermark.com/custom-domains"
className="font-medium text-blue-600 no-underline"
>
custom domain documentation
</Link>{" "}
or reply to this email - we&apos;re here to help!
</>
) : (
<>
Want to learn more about our plans?{" "}
<Link
href="https://app.papermark.com/settings/upgrade"
className="font-medium text-blue-600 no-underline"
>
View pricing
</Link>{" "}
or reply to this email if you have any questions!
</>
)}
</Text>
<Footer footerText="If you have any questions about setting up your custom domain, simply reply to this email. We'd love to help you get started!" />
</Container>
</Body>
</Tailwind>
</Html>
);
}
@@ -1,87 +0,0 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
const DataRoomsInformationEmail = () => {
const previewText = `The document sharing infrastructure for the modern web`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
Virtual Data Rooms
</Text>
<Text className="text-sm">Unlimited branded data rooms!</Text>
<Text className="text-sm">
With Papermark Data Rooms plan you can:
</Text>
<ul className="list-inside list-disc text-sm">
<li>Share data rooms with one link</li>
<li>Upload unlimited files</li>
<li>Create unlimited folders and subfolders</li>
<li>
Connect your <strong>custom domain 💫</strong>{" "}
</li>
<li>Create fully branded experience </li>
<li>Use advanced link settings</li>
</ul>
<Text className="text-sm">
All about Papermark{" "}
<a
href="https://www.papermark.com/data-room"
className="text-blue-500 underline"
>
Data Rooms
</a>{" "}
features and plans
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/datarooms?utm_source=dataroom-info&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
style={{ padding: "12px 20px" }}
>
Create new data room
</Button>
</Section>
<Text className="text-sm">
If you require a fully customizable experience,{" "}
<a
href="https://cal.com/marcseitz/papermark"
className="text-blue-500 underline"
>
book a call
</a>{" "}
with us.
</Text>
<Footer
withAddress={false}
footerText="This is a last onboarding email. If you have any feedback or questions about this email, simply reply to it. I'd love to hear from you!"
/>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default DataRoomsInformationEmail;
+18 -13
View File
@@ -14,11 +14,11 @@ import {
} from "@react-email/components";
export default function DataroomNotification({
dataroomName = "Example Data Room",
documentName = "Example Document",
senderEmail = "example@example.com",
url = "https://app.papermark.com/datarooms/123",
unsubscribeUrl = "https://app.papermark.com/datarooms/123/unsubscribe",
dataroomName,
documentName,
senderEmail,
url,
unsubscribeUrl,
}: {
dataroomName: string;
documentName: string | undefined;
@@ -33,10 +33,10 @@ export default function DataroomNotification({
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mb-8 mt-4 text-center text-2xl font-normal">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-seminbold mb-8 mt-4 text-center text-xl">
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
{`New document available for ${dataroomName}`}
</Text>
<Text className="text-sm leading-6 text-black">
@@ -45,7 +45,7 @@ export default function DataroomNotification({
added to <span className="font-semibold">{dataroomName}</span>{" "}
dataroom on Papermark.
</Text>
<Section className="my-8 text-center">
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${url}`}
@@ -59,12 +59,17 @@ export default function DataroomNotification({
{`${url}`}
</Text>
<Text className="text-sm text-gray-400">Papermark</Text>
<Hr />
<Section className="text-gray-400">
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc. All rights
reserved.
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
You received this email from{" "}
@@ -76,7 +81,7 @@ export default function DataroomNotification({
this dataroom,{" "}
<a
href={unsubscribeUrl}
className="text-gray-400 underline underline-offset-2"
className="text-gray-400 underline underline-offset-2 hover:text-gray-400"
>
click here
</a>
-92
View File
@@ -1,92 +0,0 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface TrialEndReminderEmail {
name: string | null | undefined;
}
const DataroomTrial24hReminderEmail = ({ name }: TrialEndReminderEmail) => {
return (
<Html>
<Head />
<Preview>Upgrade to Papermark Data Rooms Plan</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Your Data Room plan trial expires in 24 hours
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm leading-6 text-black">
Your Papermark Data Room plan trial expires in 24 hours.
Don&apos;t lose access to these features -{" "}
<Link href={`https://app.papermark.com/settings/billing`}>
upgrade today
</Link>
:
</Text>
<ul className="list-inside list-disc text-sm">
<li>
Build unlimited <strong>data rooms</strong>
</li>
<li>
Upload files of any <strong>size</strong>
</li>
<li>
Collaborate with your <strong>team</strong>
</li>
<li>
Set up <strong>secure link permissions</strong> and controls
</li>
<li>
Brand everything with your <strong>custom domain</strong>
</li>
<li>
Track detailed <strong>analytics</strong> and user activity
</li>
</ul>
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/billing`}
style={{ padding: "12px 20px" }}
>
Upgrade now
</Button>
</Section>
<Text className="text-sm font-semibold">
<span className="text-red-500"></span> Dataroom links and links
with advanced access controls will be{" "}
<span className="text-red-500 underline">disabled</span> in 24
hours.
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default DataroomTrial24hReminderEmail;
+42 -31
View File
@@ -14,58 +14,53 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface DataroomTrialEnd {
name: string | null | undefined;
}
const DataroomTrialEnd = ({ name }: DataroomTrialEnd) => {
const previewText = `Upgrade to continue using data rooms`;
return (
<Html>
<Head />
<Preview>Upgrade to continue using data rooms</Preview>
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mb-8 mt-4 text-center text-2xl font-normal">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-seminbold mb-8 mt-4 text-center text-xl">
Your Data Room plan trial has expired
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Your dataroom trial has expired
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm leading-6 text-black">
Your Papermark Data Room trial has expired.
<br />
<Link
href={`https://app.papermark.com/settings/billing`}
className="underline"
>
Your Papermark dataroom trial has expired.{" "}
<Link href={`https://app.papermark.com/settings/billing`}>
Upgrade now
</Link>{" "}
to:
</Text>
<ul className="list-inside list-disc text-sm">
<li>Create new datarooms</li>
<li>
Upload <strong>large</strong> files
</li>
<li>
Invite your <strong>team members</strong>
</li>
<li>
Protect documents with <strong>advanced access controls</strong>
</li>
<li>
Share documents and data rooms with{" "}
<strong>custom domain</strong>
</li>
<li>Access advanced analytics and audit logs</li>
</ul>
<Section className="my-8 text-center">
<Text className="ml-1 text-sm leading-4 text-black">
Create new datarooms
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Share datarooms with your{" "}
<span className="font-bold">custom domain</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Invite your <span className="font-bold">team members</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Share <span className="font-bold">unlimited documents</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Upload <span className="font-bold">large</span> files
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/billing`}
@@ -79,7 +74,23 @@ const DataroomTrialEnd = ({ name }: DataroomTrialEnd) => {
with advanced access controls have been{" "}
<span className="underline">disabled</span>.
</Text>
<Footer />
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
+1 -1
View File
@@ -12,7 +12,7 @@ const DataroomTrialWelcomeEmail = ({ name }: WelcomeEmailProps) => {
<Head />
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>Hi {name},</Text>
<Text>
I am Marc, founder of Papermark. Thanks for creating a trial. Do you
need any help with Data Rooms setup?
@@ -13,12 +13,10 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function DataroomViewerInvitation({
dataroomName = "Example Data Room",
senderEmail = "sender@example.com",
url = "https://app.papermark.com/datarooms/123",
dataroomName,
senderEmail,
url,
}: {
dataroomName: string;
senderEmail: string;
@@ -60,10 +58,23 @@ export default function DataroomViewerInvitation({
{`${url}`}
</Text>
<Text className="text-sm text-gray-400">Papermark</Text>
<Footer
footerText="If you have any feedback or questions about this email, simply
reply to it."
/>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
+17 -3
View File
@@ -13,8 +13,6 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function DomainDeleted({
domain = "papermark.com",
}: {
@@ -55,7 +53,23 @@ export default function DomainDeleted({
If you did not want to keep using this domain on Papermark anyway,
you can simply ignore this email.
</Text>
<Footer />
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
+15 -5
View File
@@ -11,11 +11,9 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export function EmailUpdated({
oldEmail = "old@example.com",
newEmail = "new@example.com",
oldEmail = "name@example.com",
newEmail = "name@example.com",
}: {
oldEmail: string;
newEmail: string;
@@ -49,7 +47,19 @@ export function EmailUpdated({
<Text className="text-sm leading-6 text-black">
This message is being sent to your old email address only.
</Text>
<Footer />
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
</Section>
</Container>
</Body>
</Tailwind>
+81
View File
@@ -0,0 +1,81 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export default function EmailVerification({
verificationURL = "papermark.com",
email = "test@test.com",
isDataroom = false,
}: {
verificationURL: string;
email: string;
isDataroom: boolean;
}) {
return (
<Html>
<Head />
<Preview>Verify your email to view the document</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
Please verify your email
</Text>
<Text className="text-sm leading-6 text-black">
Please click the verification link below to view the{" "}
{isDataroom ? "dataroom" : "document"}.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={verificationURL}
style={{ padding: "12px 20px" }}
>
Verify Email
</Button>
</Section>
<Text className="text-sm leading-6 text-black">
or copy and paste this URL into your browser:
</Text>
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
{verificationURL.replace(/^https?:\/\//, "")}
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have any
feedback or questions about this email, simply reply to it.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
+28 -20
View File
@@ -11,12 +11,10 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function ExportReady({
resourceName = "Export",
downloadUrl = "https://app.papermark.com/datarooms/123",
email = "email@example.com",
downloadUrl,
email,
}: {
resourceName?: string;
downloadUrl: string;
@@ -50,27 +48,37 @@ export default function ExportReady({
<Text className="text-sm leading-6 text-black">
Export details:
</Text>
<ul className="break-all text-sm leading-6 text-black">
<li className="text-sm leading-6 text-black">
Export type: {resourceName}
</li>
</ul>
<Text className="break-all text-sm leading-6 text-black">
<ul>
<li className="text-sm leading-6 text-black">
Export type: {resourceName}
</li>
</ul>
</Text>
<Text className="text-sm leading-6 text-black">
Best,
<br />
The Papermark Team
</Text>
<Footer
footerText={
<>
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have
any feedback or questions about this email, simply reply to
it.
</>
}
/>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
Papermark, Inc.
</a>
</Text>
<Text className="text-xs">
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have any
feedback or questions about this email, simply reply to it.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
@@ -1,49 +0,0 @@
import {
Body,
Head,
Html,
Link,
Tailwind,
Text,
} from "@react-email/components";
interface HundredViewsCongratsEmailProps {
name: string | null | undefined;
}
const HundredViewsCongratsEmail = ({
name,
}: HundredViewsCongratsEmailProps) => {
return (
<Html>
<Head />
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
I&apos;m Marc, founder of Papermark. Congrats on 100 views on your
documents.
</Text>
<Text>Would you help others discover us too?</Text>
<Text>
<Link
href="https://www.g2.com/products/papermark/reviews"
target="_blank"
className="text-blue-500 underline"
>
Leave a review on G2
</Link>
</Text>
<Text>Small gift from us inside.</Text>
<Text>
Thanks so much,
<br />
Marc
</Text>
</Body>
</Tailwind>
</Html>
);
};
export default HundredViewsCongratsEmail;
@@ -1,84 +0,0 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function SlackIntegrationNotification({
email = "panic@thedis.co",
team = {
name: "Acme, Inc",
},
integration = {
name: "Slack",
slug: "slack",
},
}: {
email: string;
team: {
name: string;
};
integration: {
name: string;
slug: string;
};
}) {
return (
<Html>
<Head />
<Preview>An integration has been added to your team</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
An integration has been added to your team
</Text>
<Text className="text-sm leading-6 text-black">
The <strong>{integration.name}</strong> integration has been added
to your team {team.name} on Papermark.
</Text>
<Text className="text-sm leading-6 text-black">
You can now receive notifications about document views, dataroom
access and downloads directly in your Slack channels.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/settings/integrations/${integration.slug}`}
style={{ padding: "12px 20px" }}
>
View installed integration
</Button>
</Section>
<Footer
footerText={
<>
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have
any feedback or questions about this email, simply reply to
it.
</>
}
/>
</Container>
</Body>
</Tailwind>
</Html>
);
}
+17 -3
View File
@@ -14,8 +14,6 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function InvalidDomain({
domain = "papermark.com",
invalidDays = 14,
@@ -79,7 +77,23 @@ export default function InvalidDomain({
}`
: ""}
</Text>
<Footer />
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
+20 -14
View File
@@ -36,21 +36,21 @@ const Onboarding1Email = () => {
With Papermark you can upload different kind of documents and turn
them into shareable links:
</Text>
<ul className="list-inside list-disc text-sm">
<li>PDFs</li>
<li>Microsoft Office files</li>
<li>Excel, CSV files</li>
<li>Notion via link</li>
</ul>
<Text className="text-sm">
(All Notion changes are instantly reflected in shared documents)
<ul className="list-inside list-disc text-sm">
<li>PDFs</li>
<li>Excel, CSV files</li>
<li>Notion via link</li>
</ul>
<Text className="text-sm">
(All Notion changes are instantly reflected in shared documents)
</Text>
</Text>
<Text className="text-sm">
You can also use{" "}
<span className="font-semibold">Bulk upload 💫</span> Just drop
multiple documents at once
You can also use <strong>Bulk upload 💫</strong> Just drop many
documents at once
</Text>
<Section className="my-8 text-center">
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
@@ -63,10 +63,16 @@ const Onboarding1Email = () => {
After sharing start tracking document activity on each page
</Text>
<Hr />
<Section className="text-gray-400">
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc. All rights
reserved.
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
+26 -15
View File
@@ -14,10 +14,12 @@ import {
} from "@react-email/components";
const Onboarding2Email = () => {
const previewText = `The document sharing infrastructure for the modern web`;
return (
<Html>
<Head />
<Preview>The document sharing infrastructure for the modern web</Preview>
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
@@ -34,16 +36,19 @@ const Onboarding2Email = () => {
With Papermark you can use different link settings for shared
documents and data rooms:
</Text>
<ul className="list-inside list-disc text-sm">
<li>Require email to view</li>
<li>Expiration date</li>
<li>Allow & block list 🌟</li>
<li>Email verification</li>
<li>Password protection</li>
<li>NDA and other agreements</li>
<li>Screenshot protection</li>
</ul>
<Section className="my-8 text-center">
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>Require email to view</li>
<li>Expiration date</li>
<li>Allow & block list 🌟</li>
<li>Email verification</li>
<li>Password protection</li>
<li>NDA and other agreements</li>
<li>Screenshot protection</li>
</ul>
</Text>
{/* <Text className="text-sm">You can also use Bulk upload</Text> */}
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
@@ -57,14 +62,20 @@ const Onboarding2Email = () => {
feedback, and cta settings
</Text>
<Hr />
<Section className="text-gray-400">
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc. All rights
reserved.
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
reply to it. I&apos;d love to hear from you!{" "}
</Text>
<Text className="text-xs">Stop this onboarding sequence</Text>
+27 -14
View File
@@ -14,10 +14,12 @@ import {
} from "@react-email/components";
const Onboarding3Email = () => {
const previewText = `The document sharing infrastructure for the modern web`;
return (
<Html>
<Head />
<Preview>The document sharing infrastructure for the modern web</Preview>
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
@@ -34,17 +36,22 @@ const Onboarding3Email = () => {
With Papermark you can track progress on each page of your
document and other analytics:
</Text>
<ul className="list-inside list-disc text-sm">
<li>
Track time on{" "}
<span className="font-semibold">each page 💫</span>
</li>
<li>See who viewed your documents</li>
<li>Capture email </li>
<li>Receive feedback</li>
<li>Ask questions and get answers</li>
</ul>
<Section className="my-8 text-center">
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>
Track time on <strong>each page 💫</strong>{" "}
</li>
<li>See who viewed your documents</li>
<li>Capture email </li>
<li>Receive feedback</li>
<li>Ask questions and get answers</li>
</ul>
{/* <Text className="text-sm">
(All Notion changes are instantly reflected in shared documents)
</Text> */}
</Text>
{/* <Text className="text-sm">You can also use Bulk upload</Text> */}
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
@@ -59,8 +66,14 @@ const Onboarding3Email = () => {
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc. All rights
reserved.
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
+16 -13
View File
@@ -33,19 +33,22 @@ const Onboarding4Email = () => {
Look professional with custom branding!
</Text>
<Text className="text-sm">With Papermark you can:</Text>
<ul className="list-inside list-disc text-sm">
<li>
Share documnets with your <strong>custom domain💫</strong>{" "}
</li>
<li>Remove &quot;powered by Papermark&quot;</li>
<li>Add logo and custom colors</li>
<li>Share data room with custom domain</li>
<li>Add banner and custom brand to data rooms</li>
</ul>
<Text className="text-sm">
(Customization for data rooms is seaprate and available in each
data room you create)
<ul className="list-inside list-disc text-sm">
<li>
Share documnets with your{" "}
<strong>custom domain💫</strong>{" "}
</li>
<li>Remove &quot;powered by Papermark&quot;</li>
<li>Add logo and custom colors</li>
<li>Share data room with custom domain</li>
<li>Add banner and custom brand to data rooms</li>
</ul>
<Text className="text-sm">
(Customization for data rooms is seaprate and available in each
data room you create)
</Text>
</Text>
{/* <Text className="text-sm">You can also use Bulk upload</Text> */}
<Section className="mb-[32px] mt-[32px] text-center">
@@ -73,7 +76,7 @@ const Onboarding4Email = () => {
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
+106
View File
@@ -0,0 +1,106 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
const Onboarding3Email = () => {
const previewText = `The document sharing infrastructure for the modern web`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
Virtual Data Rooms
</Text>
<Text className="text-sm">Unlimited branded data rooms!</Text>
<Text className="text-sm">With Papermark Data Rooms you can:</Text>
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>Share data rooms with one link</li>
<li>Upload unlimited files</li>
<li>Create unlimited folders and subfolders</li>
<li>
Connect your <strong>custom domain 💫</strong>{" "}
</li>
<li>Create fully branded experience </li>
<li>Use advanced link settings</li>
<li>Create full whitelabeling</li>
<li>Build self-hosted expereince</li>
</ul>
<Text className="text-sm">
All about Papermark{" "}
<a
href="https://www.papermark.com/data-room"
className="text-blue-500 underline"
>
Data Rooms
</a>{" "}
features and plans
</Text>
</Text>
{/* <Text className="text-sm">You can also use Bulk upload</Text> */}
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/datarooms?utm_source=dataroom-info&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
style={{ padding: "12px 20px" }}
>
Create new data room
</Button>
</Section>
<Text className="text-sm">
If you want to self-host Papermark, and build fully customizable
experience{" "}
<a
href="https://cal.com/marcseitz/papermark"
className="text-blue-500 underline"
>
book a call
</a>{" "}
with us.
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
This is a last onboarding email. If you have any feedback or
questions about this email, simply reply to it. I&apos;d love to
hear from you!{" "}
</Text>
{/* <Text className="text-xs">Stop this onboarding sequence. </Text> */}
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default Onboarding3Email;
-27
View File
@@ -1,27 +0,0 @@
import { Hr, Section, Text } from "@react-email/components";
export const Footer = ({
withAddress = false,
footerText = "If you have any feedback or questions about this email, simply reply to it. I&apos;d love to hear from you!",
}: {
withAddress?: boolean;
footerText?: string | React.ReactNode;
}) => {
return (
<>
<Hr />
<Section className="text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc. All rights reserved.{" "}
{withAddress && (
<>
<br />
1111B S Governors Ave #28117, Dover, DE 19904
</>
)}
</Text>
<Text className="text-xs">{footerText}</Text>
</Section>
</>
);
};
-84
View File
@@ -1,84 +0,0 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface SlackIntegrationEmailProps {
name: string | null | undefined;
}
const SlackIntegrationEmail = ({ name }: SlackIntegrationEmailProps) => {
const previewText = `See who viewed your documents in slack in 2 clicks`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
Connect Slack in 2 clicks
</Text>
<Text className="text-sm">Hi{name && ` ${name}`}!</Text>
<Text className="text-sm">
We offer direct integration to Slack, and it&apos;s free for all
users for 30 days.
</Text>
<Text className="text-sm">
With our Slack integration, you can get real-time notifications
about document and data roomviews directly in your Slack channels
!
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/settings/integrations`}
style={{ padding: "12px 20px" }}
>
See who viewed your documents in Slack
</Button>
</Section>
<Text className="text-sm">
If you have any questions or need help setting it up, just respond
to this email. I&apos;m always happy to help!
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
Feel free to always reach out to me or our support team.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default SlackIntegrationEmail;
+18 -4
View File
@@ -13,13 +13,11 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function TeamInvitation({
senderName,
senderEmail,
teamName,
url = "https://app.papermark.com",
url,
}: {
senderName: string;
senderEmail: string;
@@ -61,7 +59,23 @@ export default function TeamInvitation({
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
{url.replace(/^https?:\/\//, "")}
</Text>
<Footer />
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
@@ -1,54 +0,0 @@
import React from "react";
import {
Body,
Head,
Html,
Link,
Preview,
Tailwind,
Text,
} from "@react-email/components";
interface ThousandViewsCongratsEmailProps {
name: string | null | undefined;
}
const ThousandViewsCongratsEmail = ({
name,
}: ThousandViewsCongratsEmailProps) => {
return (
<Html>
<Head />
<Preview>1000 views on Papermark.</Preview>
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
I&apos;m Marc, founder of Papermark. Congrats on 1000 views on your
documents.
</Text>
<Text>How is your experience so far?</Text>
<Text>
Thanks so much,
<br />
Marc
</Text>
<Text>
<Link
href="https://www.g2.com/products/papermark/reviews"
target="_blank"
className="text-blue-500 underline"
rel="noopener noreferrer"
>
Leave us a G2 review
</Link>
</Text>
</Body>
</Tailwind>
</Html>
);
};
export default ThousandViewsCongratsEmail;
@@ -0,0 +1,98 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface TrialEndFinalReminderEmail {
name: string | null | undefined;
}
const TrialEndFinalReminderEmail = ({ name }: TrialEndFinalReminderEmail) => {
const previewText = `Upgrade to Papermark Pro`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Your pro trial expires in 24 hours
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm leading-6 text-black">
Your Papermark Pro trial expires in 24 hours.{" "}
<Link href={`https://app.papermark.com/settings/billing`}>
Upgrade now
</Link>{" "}
to:
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Send documents with your{" "}
<span className="font-bold">custom domain</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Invite your <span className="font-bold">team members</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Share <span className="font-bold">unlimited documents</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Upload <span className="font-bold">large</span> files
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/billing`}
style={{ padding: "12px 20px" }}
>
Upgrade now
</Button>
</Section>
<Text className="text-sm font-semibold">
<span className="text-red-500"></span> Links with custom domains
will be <span className="underline">disabled</span> after your
trial.
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default TrialEndFinalReminderEmail;
+97
View File
@@ -0,0 +1,97 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface TrialEndReminderEmail {
name: string | null | undefined;
}
const TrialEndReminderEmail = ({ name }: TrialEndReminderEmail) => {
const previewText = `Upgrade to Papermark Pro`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Your pro trial is almost over
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm leading-6 text-black">
Your Papermark Pro trial is almost over. If you want to continue
enjoying the Pro features, please consider upgrading your plan.
</Text>
<Text className="text-sm leading-6 text-black">
On the Pro plan, you have access to:
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Custom domains
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Team members
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Unlimited documents
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Large file uploads
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/billing`}
style={{ padding: "12px 20px" }}
>
Upgrade your plan
</Button>
</Section>
<Text className="text-sm font-semibold">
<span className="text-red-500"></span> Links with custom domains
will be <span className="text-red-500 underline">disabled</span>{" "}
after your trial.
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default TrialEndReminderEmail;
@@ -1,39 +0,0 @@
import React from "react";
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
} from "@react-email/components";
interface UpgradeCheckinEmailProps {
name: string | null | undefined;
}
const UpgradeCheckinEmail = ({ name }: UpgradeCheckinEmailProps) => {
return (
<Html>
<Head />
<Preview>Check-in from Marc</Preview>
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
It is Marc here. How has your experience been so far? Are you
getting the value you expected from the advanced features?
</Text>
<Text>
Any feedback - what&apos;s working well and what could we improve?
</Text>
<Text>Marc</Text>
</Body>
</Tailwind>
</Html>
);
};
export default UpgradeCheckinEmail;
@@ -1,42 +0,0 @@
import React from "react";
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
} from "@react-email/components";
interface UpgradePersonalEmailProps {
name: string | null | undefined;
planName?: string;
}
const UpgradePersonalEmail = ({
name,
planName = "Pro",
}: UpgradePersonalEmailProps) => {
return (
<Html>
<Head />
<Preview>Welcome to {planName}</Preview>
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
I&apos;m Iuliia, co-founder of Papermark. Thanks for upgrading!
I&apos;m thrilled to have you on our {planName} plan.
</Text>
<Text>
You now have access to advanced features. Any questions so far??
</Text>
<Text>Iuliia</Text>
</Body>
</Tailwind>
</Html>
);
};
export default UpgradePersonalEmail;
+40 -19
View File
@@ -14,8 +14,6 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface UpgradePlanEmailProps {
name: string | null | undefined;
planType: string;
@@ -23,18 +21,10 @@ interface UpgradePlanEmailProps {
const UpgradePlanEmail = ({
name,
planType = "datarooms-plus",
planType = "pro",
}: UpgradePlanEmailProps) => {
const previewText = `The document sharing infrastructure for the modern web`;
const PLAN_TYPE_MAP = {
pro: "Pro",
business: "Business",
datarooms: "Data Rooms",
"datarooms-plus": "Data Rooms Plus",
};
const planTypeText = PLAN_TYPE_MAP[planType as keyof typeof PLAN_TYPE_MAP];
const features: any = {
pro: [
"Custom branding",
@@ -62,7 +52,8 @@ const UpgradePlanEmail = ({
],
};
const planFeatures = features[planType];
const planTypeText = planType.toLowerCase();
const planFeatures = features[planTypeText];
return (
<Html>
@@ -75,18 +66,30 @@ const UpgradePlanEmail = ({
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Thanks for upgrading to Papermark {planTypeText}!
Thanks for upgrading to Papermark {planType}!
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm">
Marc is here. I wanted to personally reach out to thank you for
upgrading to Papermark {planTypeText}!
My name is Marc, and I&apos;m the founder of Papermark. I wanted
to personally reach out to thank you for upgrading to Papermark{" "}
{planType}!
</Text>
<Text className="text-sm leading-6 text-black">
On the {planTypeText} plan, you now have access to:
As you might already know, we are a bootstrapped and{" "}
<Link
href="https://github.com/mfts/papermark"
target="_blank"
className="font-medium text-emerald-500 no-underline"
>
open-source
</Link>{" "}
business. Your support means the world to us and helps us continue
to build and improve Papermark.
</Text>
<Text className="text-sm leading-6 text-black">
On the {planType} plan, you now have access to:
</Text>
{planFeatures?.map(
(feature: string, index: number) => (
@@ -105,7 +108,9 @@ const UpgradePlanEmail = ({
style={{ padding: "12px 20px" }}
>
Share your{" "}
{planType.includes("datarooms") ? "data rooms" : "documents"}
{planTypeText.includes("datarooms")
? "data rooms"
: "documents"}
</Button>
</Section>
<Section>
@@ -115,7 +120,23 @@ const UpgradePlanEmail = ({
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
</Section>
<Footer />
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
@@ -1,41 +0,0 @@
import React from "react";
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
} from "@react-email/components";
interface SixMonthMilestoneEmailProps {
name: string | null | undefined;
planName?: string;
}
const SixMonthMilestoneEmail = ({
name,
planName = "Pro",
}: SixMonthMilestoneEmailProps) => {
return (
<Html>
<Head />
<Preview>6 months with Papermark</Preview>
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi {name},</Text>
<Text>What&apos;s been your biggest win using Papermark?</Text>
<Text>
Marc here. It&apos;s been 6 months since you using advanced
Papermark features! Excited to hear your story and feedback for us.
</Text>
<Text>Marc</Text>
</Body>
</Tailwind>
</Html>
);
};
export default SixMonthMilestoneEmail;
+22 -15
View File
@@ -3,6 +3,7 @@ import {
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
@@ -11,8 +12,6 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface ConfirmEmailChangeProps {
email: string;
newEmail: string;
@@ -20,8 +19,8 @@ interface ConfirmEmailChangeProps {
}
export function ConfirmEmailChange({
email = "email@example.com",
newEmail = "new@example.com",
email,
newEmail,
confirmUrl = "https://www.papermark.com",
}: ConfirmEmailChangeProps) {
return (
@@ -61,17 +60,25 @@ export function ConfirmEmailChange({
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
{confirmUrl.replace(/^https?:\/\//, "")}
</Text>
<Footer
footerText={
<>
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have
any feedback or questions about this email, simply reply to
it.
</>
}
/>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have any
feedback or questions about this email, simply reply to it.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
+18 -3
View File
@@ -5,6 +5,7 @@ import {
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
@@ -12,8 +13,6 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
const VerificationLinkEmail = ({
url = "https://www.papermark.com",
}: {
@@ -51,7 +50,23 @@ const VerificationLinkEmail = ({
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
{url.replace(/^https?:\/\//, "")}
</Text>
<Footer />
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
+25 -15
View File
@@ -5,6 +5,7 @@ import {
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
@@ -12,8 +13,6 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function ViewedDataroom({
dataroomId = "123",
dataroomName = "Example Dataroom",
@@ -68,19 +67,30 @@ export default function ViewedDataroom({
See my dataroom insights
</Button>
</Section>
<Footer
footerText={
<>
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
<br />
<br />
To stop email notifications for this link, edit the link and
uncheck &quot;Receive email notification&quot;.
</>
}
/>
<Text className="text-sm leading-6 text-black">
Stay informed, stay ahead with Papermark.
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
<Text className="text-xs">
To stop email notifications for this link, edit the link and
uncheck &quot;Receive email notification&quot;.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
+25 -14
View File
@@ -5,6 +5,7 @@ import {
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
@@ -12,8 +13,6 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function ViewedDocument({
documentId = "123",
documentName = "Pitchdeck",
@@ -68,18 +67,30 @@ export default function ViewedDocument({
See my document insights
</Button>
</Section>
<Footer
footerText={
<>
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
<br />
<br />
To stop email notifications for this link, edit the link and
uncheck &quot;Receive email notification&quot;.
</>
}
/>
<Text className="text-sm leading-6 text-black">
Stay informed, stay ahead with Papermark.
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
<Text className="text-xs">
To stop email notifications for this link, edit the link and
uncheck &quot;Receive email notification&quot;.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
+65 -52
View File
@@ -14,8 +14,6 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface WelcomeEmailProps {
name: string | null | undefined;
}
@@ -38,23 +36,25 @@ const WelcomeEmail = ({ name }: WelcomeEmailProps) => {
Thanks for signing up{name && `, ${name}`}!
</Text>
<Text className="text-sm">
My name is Marc, and I&apos;m the founder of Papermark the
secure way to share documents and data rooms. I&apos;m excited to
have you on board!
My name is Marc, and I&apos;m the creator of Papermark the
open-source DocSend alternative! I&apos;m excited to have you on
board!
</Text>
<Text className="text-sm">
Here are a few things you can do to get started:
</Text>
<ul className="list-inside list-disc text-sm">
<li>Turn your documents into shareable links</li>
<li>Create secure virtual data rooms</li>
<li>
Share your documents{" "}
<span className="italic">(with custom domain)</span>
</li>
<li>Watch the page-by-page insights in real-time</li>
</ul>
<Section className="my-8 text-center">
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>Upload a document</li>
<li>Create a virtual data room</li>
<li>
Share a link{" "}
<span className="italic">(with your custom domain)</span>
</li>
<li>Watch the views come in real-time</li>
</ul>
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/welcome`}
@@ -63,49 +63,62 @@ const WelcomeEmail = ({ name }: WelcomeEmailProps) => {
Get Started
</Button>
</Section>
<Section>
<Text className="text-sm">
If you would like to keep up to date, you can:
</Text>
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>
Star the repo on{" "}
<Link
href="https://github.com/mfts/papermark"
target="_blank"
>
GitHub
</Link>
</li>
<li>
Follow the journey on{" "}
<Link href="https://x.com/papermarkio" target="_blank">
Twitter
</Link>
</li>
<li>
Have a call to talk enterprise{" "}
<Link
href="https://cal.com/marcseitz/papermark"
target="_blank"
>
Book
</Link>
</li>
</ul>
</Text>
</Section>
<Section className="mt-4">
<Text className="text-sm">
If you have any questions or feedback just respond to this
email.{" "}
<Link
href="https://cal.link/papermark"
target="_blank"
rel="noopener noreferrer"
>
Book a call
</Link>{" "}
to discuss your enterprise needs. I&apos;m always happy to help!
email. I&apos;m always happy to help!
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
</Section>
<Footer />
<Text className="flex gap-x-1 text-xs">
<Link
href="https://www.papermark.com/customers"
target="_blank"
className="text-xs text-gray-400"
>
Customer stories
</Link>
<Link
href="https://x.com/papermarkio"
target="_blank"
className="text-xs text-gray-400"
rel="noopener noreferrer"
>
· X/Twitter
</Link>
<Link
href="https://www.linkedin.com/company/papermarkio"
target="_blank"
className="text-xs text-gray-400"
rel="noopener noreferrer"
>
· LinkedIn
</Link>
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
You will shortly receive the intro to Papermark. Stay tuned.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
@@ -266,7 +266,7 @@ export default function PapermarkYearInReviewEmail({
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
@@ -279,7 +279,7 @@ export default function PapermarkYearInReviewEmail({
from future Year in Review emails,{" "}
<a
href={unsubscribeUrl}
className="text-gray-400 underline underline-offset-2"
className="text-gray-400 underline underline-offset-2 hover:text-gray-400"
>
click here
</a>
-1
View File
@@ -142,7 +142,6 @@ export function AddFolderModal({
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger={"add_folder_button"}
highlightItem={["folder", "multi-file"]}
>
{children}
</UpgradePlanModal>
-11
View File
@@ -1,11 +0,0 @@
import { GoogleTagManager } from "@next/third-parties/google";
const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID;
export function GTMComponent() {
if (!GTM_ID) {
return null;
}
return <GoogleTagManager gtmId={GTM_ID} />;
}
-5
View File
@@ -165,10 +165,7 @@ const SingleDataroomBreadcrumb = ({ path }: { path: string }) => {
return "Permissions";
case "/datarooms/[id]/analytics":
return "Analytics";
case "/datarooms/[id]/conversations/faqs":
return "FAQ";
case "/datarooms/[id]/conversations":
case "/datarooms/[id]/conversations/[conversationId]":
return "Conversations";
case "/datarooms/[id]/settings/notifications":
return "Notifications";
@@ -223,8 +220,6 @@ const SettingsBreadcrumb = () => {
return "API Tokens";
case "/settings/webhooks":
return "Webhooks";
case "/settings/slack":
return "Slack";
case "/settings/incoming-webhooks":
return "Incoming Webhooks";
case "/settings/branding":
+3 -3
View File
@@ -75,8 +75,8 @@ function TrialBannerComponent({
</AlertTitle>
<AlertDescription>
You are on the <span className="font-bold">Data Rooms</span> plan
trial, you have access to advanced access controls, granular file
permissions, and data room. <br />
trial, you have access to advanced access controls, group permissions,
and data room. <br />
<UpgradePlanModal
clickedPlan={PlanEnum.DataRooms}
trigger={"trial_navbar"}
@@ -94,4 +94,4 @@ function TrialBannerComponent({
</Alert>
</div>
);
}
}
@@ -31,8 +31,6 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import LinkItem from "../link-item";
@@ -52,7 +50,7 @@ export default function AgreementSheet({
isOnlyView = false,
onClose,
}: {
defaultData?: { name: string; link: string; requireName: boolean; contentType?: string; textContent?: string } | null;
defaultData?: { name: string; link: string; requireName: boolean } | null;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
isOnlyView?: boolean;
@@ -60,13 +58,7 @@ export default function AgreementSheet({
}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const [data, setData] = useState({
name: "",
link: "",
textContent: "",
contentType: "LINK",
requireName: true
});
const [data, setData] = useState({ name: "", link: "", requireName: true });
const [isLoading, setIsLoading] = useState<boolean>(false);
const [currentFile, setCurrentFile] = useState<File | null>(null);
@@ -79,8 +71,6 @@ export default function AgreementSheet({
setData({
name: defaultData?.name || "",
link: defaultData?.link || "",
textContent: defaultData?.textContent || "",
contentType: defaultData?.contentType || "LINK",
requireName: defaultData?.requireName || true,
});
}
@@ -88,13 +78,7 @@ export default function AgreementSheet({
const handleClose = (open: boolean) => {
setIsOpen(open);
setData({
name: "",
link: "",
textContent: "",
contentType: "LINK",
requireName: true
});
setData({ name: "", link: "", requireName: true });
setCurrentFile(null);
setIsLoading(false);
if (onClose) {
@@ -200,22 +184,13 @@ export default function AgreementSheet({
return;
}
// Validate based on content type
if (data.contentType === "LINK") {
// Validate URL
try {
agreementUrlSchema.parse(data.link);
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0];
toast.error(firstError?.message || "Please enter a valid URL");
return;
}
}
} else if (data.contentType === "TEXT") {
// Validate text content
if (!data.textContent.trim()) {
toast.error("Please enter agreement text content");
// Validate URL before submitting
try {
agreementUrlSchema.parse(data.link);
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0];
toast.error(firstError?.message || "Please enter a valid URL");
return;
}
}
@@ -223,19 +198,14 @@ export default function AgreementSheet({
setIsLoading(true);
try {
const submitData = {
name: data.name,
contentType: data.contentType,
content: data.contentType === "LINK" ? data.link : data.textContent,
requireName: data.requireName,
};
const response = await fetch(`/api/teams/${teamId}/agreements`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(submitData),
body: JSON.stringify({
...data,
}),
});
if (!response.ok) {
@@ -250,13 +220,7 @@ export default function AgreementSheet({
} finally {
setIsLoading(false);
setIsOpen(false);
setData({
name: "",
link: "",
textContent: "",
contentType: "LINK",
requireName: true
});
setData({ name: "", link: "", requireName: true });
}
};
@@ -317,102 +281,56 @@ export default function AgreementSheet({
</div>
<div className="space-y-4">
{/* Content Type Selection */}
<div className="w-full space-y-2">
<Label>Agreement Content Type</Label>
<RadioGroup
value={data.contentType}
onValueChange={(value) => setData({...data, contentType: value})}
<Label htmlFor="link">Link to an agreement</Label>
<Input
className={`flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset placeholder:text-muted-foreground focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 ${
!isUrlValid
? "ring-red-500 focus:ring-red-500"
: "ring-input focus:ring-gray-400"
}`}
id="link"
type="text" // Changed from "url" to avoid browser validation conflicts
name="link"
required
autoComplete="off"
data-1p-ignore
placeholder="https://www.papermark.com/nda"
value={data.link || ""}
onChange={(e) => {
const newValue = e.target.value;
setData({
...data,
link: newValue,
});
// Validate on change with debouncing
validateUrl(newValue);
}}
onBlur={(e) => {
// Validate on blur for immediate feedback
validateUrl(e.target.value);
}}
disabled={isOnlyView}
className="flex flex-col space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="LINK" id="link-type" />
<Label htmlFor="link-type">Link to agreement document</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="TEXT" id="text-type" />
<Label htmlFor="text-type">Text content</Label>
</div>
</RadioGroup>
/>
{/* Display validation error */}
{urlError && (
<p className="mt-1 text-sm text-red-500">{urlError}</p>
)}
</div>
{/* Link Content */}
{data.contentType === "LINK" && (
<div className="w-full space-y-2">
<Label htmlFor="link">Link to an agreement</Label>
<Input
className={`flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset placeholder:text-muted-foreground focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 ${
!isUrlValid
? "ring-red-500 focus:ring-red-500"
: "ring-input focus:ring-gray-400"
}`}
id="link"
type="text"
name="link"
required={data.contentType === "LINK"}
autoComplete="off"
data-1p-ignore
placeholder="https://www.papermark.com/nda"
value={data.link || ""}
onChange={(e) => {
const newValue = e.target.value;
setData({
...data,
link: newValue,
});
validateUrl(newValue);
}}
onBlur={(e) => {
validateUrl(e.target.value);
}}
disabled={isOnlyView}
/>
{urlError && (
<p className="mt-1 text-sm text-red-500">{urlError}</p>
)}
{!isOnlyView && (
<div className="space-y-12">
<div className="space-y-2 pb-6">
<Label>Or upload an agreement</Label>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<DocumentUpload
currentFile={currentFile}
setCurrentFile={setCurrentFile}
/>
</div>
</div>
{!isOnlyView ? (
<div className="space-y-12">
<div className="space-y-2 pb-6">
<Label>Or upload an agreement</Label>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<DocumentUpload
currentFile={currentFile}
setCurrentFile={setCurrentFile}
/>
</div>
)}
</div>
</div>
)}
{/* Text Content */}
{data.contentType === "TEXT" && (
<div className="w-full space-y-2">
<Label htmlFor="textContent">Agreement Text</Label>
<Textarea
className="flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6"
id="textContent"
name="textContent"
required={data.contentType === "TEXT"}
placeholder="By accessing this document, you agree to maintain confidentiality of all information contained herein and not to share, copy, or distribute any content without prior written consent..."
value={data.textContent || ""}
onChange={(e) =>
setData({
...data,
textContent: e.target.value,
})
}
disabled={isOnlyView}
rows={6}
/>
<p className="text-xs text-muted-foreground">
This text will be displayed to users as a compliance agreement before they can access the content.
</p>
</div>
)}
) : null}
</div>
</div>
<SheetFooter
@@ -427,10 +345,7 @@ export default function AgreementSheet({
<Button
type="submit"
loading={isLoading}
disabled={
(data.contentType === "LINK" && !isUrlValid && data.link.trim() !== "") ||
(data.contentType === "TEXT" && !data.textContent.trim())
}
disabled={!isUrlValid && data.link.trim() !== ""}
>
Create Agreement
</Button>
@@ -1,3 +1,3 @@
import ConversationSection from "@/ee/features/conversations/components/dashboard/link-option-conversation-section";
import ConversationSection from "@/ee/features/conversations/components/link-option-conversation-section";
export default ConversationSection;
@@ -100,7 +100,7 @@ export default memo(function CustomField({
<SelectItem value="SHORT_TEXT">Short Text</SelectItem>
<SelectItem value="LONG_TEXT">Long Text</SelectItem>
<SelectItem value="NUMBER">Number</SelectItem>
<SelectItem value="PHONE_NUMBER">Phone</SelectItem>
{/* <SelectItem value="PHONE_NUMBER">Phone</SelectItem> */}
<SelectItem value="URL">URL</SelectItem>
</SelectContent>
</Select>
@@ -24,7 +24,7 @@ export type CustomFieldData = Omit<
> & {
type: Omit<
CustomFieldType,
"CHECKBOX" | "SELECT" | "MULTI_SELECT"
"PHONE_NUMBER" | "CHECKBOX" | "SELECT" | "MULTI_SELECT"
>;
};
+2 -9
View File
@@ -235,8 +235,6 @@ export default function LinkSheet({
if (!preset) return;
setData((prev) => {
const isGroupLink = prev.audienceType === LinkAudienceType.GROUP;
return {
...prev,
name: prev.name, // Keep existing name
@@ -245,13 +243,8 @@ export default function LinkSheet({
emailProtected: preset.emailProtected ?? prev.emailProtected,
emailAuthenticated:
preset.emailAuthenticated ?? prev.emailAuthenticated,
// For group links, ignore allow/deny lists from presets as access is controlled by group membership
allowList: isGroupLink
? prev.allowList
: preset.allowList || prev.allowList,
denyList: isGroupLink
? prev.denyList
: preset.denyList || prev.denyList,
allowList: preset.allowList || prev.allowList,
denyList: preset.denyList || prev.denyList,
password: preset.password || prev.password,
enableCustomMetatag:
preset.enableCustomMetaTag ?? prev.enableCustomMetatag,
@@ -89,7 +89,7 @@ function FolderSelectionModal({
</DialogDescription>
</DialogHeader>
<form>
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
<div className="mb-2 max-h-[75vh] overflow-y-scroll">
{dataroomId && dataroomId !== "all_documents" ? (
<DataroomFolderTree
dataroomId={dataroomId}
@@ -115,9 +115,7 @@ function FolderSelectionModal({
) : (
<>
Select{" "}
<span className="max-w-[200px] truncate font-medium">
{selectedFolder.name}
</span>
<span className="font-medium">{selectedFolder.name}</span>
</>
)}
</Button>
+17 -34
View File
@@ -18,7 +18,6 @@ import {
import { useQueryState } from "nuqs";
import { toast } from "sonner";
import useSWR, { mutate } from "swr";
import z from "zod";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
@@ -106,7 +105,7 @@ export default function LinksTable({
const now = Date.now();
const router = useRouter();
const { isFree, isTrial } = usePlan();
const { currentTeamId } = useTeam();
const teamInfo = useTeam();
const { id: targetId, groupId } = router.query as {
id: string;
groupId?: string;
@@ -268,7 +267,7 @@ export default function LinksTable({
"Content-Type": "application/json",
},
body: JSON.stringify({
teamId: currentTeamId,
teamId: teamInfo?.currentTeam?.id,
}),
});
@@ -281,7 +280,7 @@ export default function LinksTable({
// Update the duplicated link in the list of links
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
link.documentId ?? link.dataroomId ?? "",
)}/links`,
(links || []).concat(duplicatedLink),
@@ -293,7 +292,7 @@ export default function LinksTable({
const groupLinks =
links?.filter((link) => link.groupId === groupId) || [];
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
duplicatedLink.documentId ?? duplicatedLink.dataroomId ?? "",
)}/groups/${duplicatedLink.groupId}/links`,
groupLinks.concat(duplicatedLink),
@@ -317,15 +316,8 @@ export default function LinksTable({
if (permissions === null && editPermissionLink.permissionGroupId) {
// Delete the permission group - database will set permissionGroupId to null automatically
try {
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const permissionGroupIdParsed = z
.string()
.cuid()
.parse(editPermissionLink.permissionGroupId);
const deleteResponse = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
{
method: "DELETE",
},
@@ -339,8 +331,8 @@ export default function LinksTable({
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${teamIdParsed}/${endpointTargetType}/${encodeURIComponent(
targetIdParsed,
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
(currentLinks: LinkWithViews[] | undefined) =>
(currentLinks || []).map((link: LinkWithViews) =>
@@ -353,7 +345,7 @@ export default function LinksTable({
// Invalidate the permission group cache
mutate(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
);
setShowPermissionsSheet(false);
@@ -369,10 +361,8 @@ export default function LinksTable({
if (!editPermissionLink.permissionGroupId) {
setIsLoading(true);
try {
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const response = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups`,
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups`,
{
method: "POST",
headers: {
@@ -396,7 +386,7 @@ export default function LinksTable({
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
);
@@ -404,7 +394,7 @@ export default function LinksTable({
// Cache the new permission group data
if (newPermissionGroup?.id) {
mutate(
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,
newPermissionGroup,
false,
);
@@ -422,15 +412,8 @@ export default function LinksTable({
} else {
try {
// Update the permissions for the existing link
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const permissionGroupIdParsed = z
.string()
.cuid()
.parse(editPermissionLink.permissionGroupId);
const res = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
{
method: "PUT",
headers: {
@@ -451,7 +434,7 @@ export default function LinksTable({
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
);
@@ -459,7 +442,7 @@ export default function LinksTable({
// Invalidate the permission group cache
if (editPermissionLink.permissionGroupId) {
mutate(
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
);
}
@@ -519,7 +502,7 @@ export default function LinksTable({
// Update the archived link in the list of links
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
(links || []).map((link) => (link.id === linkId ? archivedLink : link)),
@@ -531,7 +514,7 @@ export default function LinksTable({
const groupLinks =
links?.filter((link) => link.groupId === groupId) || [];
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
archivedLink.documentId ?? archivedLink.dataroomId ?? "",
)}/groups/${groupId}/links`,
groupLinks.map((link) => (link.id === linkId ? archivedLink : link)),
@@ -986,4 +969,4 @@ export default function LinksTable({
</div>
</>
);
}
}
+2 -5
View File
@@ -137,15 +137,12 @@ export function SearchBoxPersisted({
currentQuery[urlParam] = debouncedValue;
}
// For custom domains, preserve the clean URL structure
const isCustomDomain = !!(router.query.domain && router.query.slug);
router.push(
{
pathname: router.pathname,
query: currentQuery,
},
isCustomDomain ? `/${router.query.slug}` : undefined, // Preserve custom domain URL
undefined,
{ shallow: true },
);
}, [debouncedValue]);
@@ -167,4 +164,4 @@ export function SearchBoxPersisted({
{...props}
/>
);
}
}
-7
View File
@@ -10,7 +10,6 @@ export function SettingsHeader() {
const { data: features } = useSWR<{
tokens: boolean;
incomingWebhooks: boolean;
slack: boolean;
}>(
teamInfo?.currentTeam?.id
? `/api/feature-flags?teamId=${teamInfo.currentTeam.id}`
@@ -68,12 +67,6 @@ export function SettingsHeader() {
href: `/settings/webhooks`,
segment: "webhooks",
},
{
label: "Slack",
href: `/settings/slack`,
segment: "slack",
disabled: !features?.slack,
},
{
label: "Tokens",
href: `/settings/tokens`,
@@ -1,82 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function SlackSettingsSkeleton() {
return (
<div className="space-y-6">
{/* Header Skeleton */}
<div className="mb-4 flex items-center justify-between md:mb-8 lg:mb-12">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
<Skeleton className="h-10 w-32" />
</div>
{/* Main Content Skeleton */}
<div className="space-y-6">
{/* Card 1 */}
<div className="rounded-lg border bg-card p-6">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-60" />
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-6 w-11" />
</div>
<div className="space-y-3">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-64" />
</div>
<div className="flex items-center rounded-lg border border-border bg-background">
<div className="flex flex-1 items-center gap-2 px-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-24" />
</div>
<Skeleton className="h-6 w-px" />
<Skeleton className="h-8 w-32 shrink-0" />
</div>
</div>
</div>
</div>
</div>
{/* Card 2 */}
<div className="rounded-lg border bg-card p-6">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-4 w-72" />
</div>
<div className="space-y-4">
<div className="flex space-x-6">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center space-x-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
-36
View File
@@ -1,36 +0,0 @@
import React from "react";
interface SlackIconProps {
className?: string;
size?: number;
}
export function SlackIcon({ className = "", size = 24 }: SlackIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className={className}
>
{/* Slack logo with proper colors */}
<path
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"
fill="#E01E5A"
/>
<path
d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.527 2.527 0 0 1 8.834 0a2.527 2.527 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.52 2.527 2.527 0 0 1-2.521 2.521H2.522A2.527 2.527 0 0 1 0 8.833a2.528 2.528 0 0 1 2.522-2.52h6.312z"
fill="#36C5F0"
/>
<path
d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.527 2.527 0 0 1 24 8.834a2.527 2.527 0 0 1-2.522 2.52h-2.522V8.834zM17.688 8.834a2.527 2.527 0 0 1-2.52 2.52 2.526 2.526 0 0 1-2.52-2.52V2.522A2.527 2.527 0 0 1 15.166 0a2.527 2.527 0 0 1 2.522 2.522v6.312z"
fill="#2EB67D"
/>
<path
d="M15.166 18.956a2.528 2.528 0 0 1 2.52 2.522A2.527 2.527 0 0 1 15.166 24a2.527 2.527 0 0 1-2.52-2.522v-2.52h2.52zM15.166 17.688a2.527 2.527 0 0 1-2.52-2.52 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.168a2.528 2.528 0 0 1-2.522 2.52h-6.312z"
fill="#ECB22E"
/>
</svg>
);
}
-5
View File
@@ -150,11 +150,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: "/settings/webhooks",
current: router.pathname.includes("settings/webhooks"),
},
{
title: "Slack",
url: "/settings/slack",
current: router.pathname.includes("settings/slack"),
},
{
title: "Billing",
url: "/settings/billing",
+1 -58
View File
@@ -1,13 +1,12 @@
"use client";
import * as React from "react";
import { useState } from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
@@ -135,63 +134,7 @@ const AlertDialogCancel = React.forwardRef<
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
interface SingleAlertDialogProps {
title: string;
description: string;
action: string;
onAction?: () => Promise<void> | void;
actionUpdate?: string;
disabled?: boolean;
loading?: boolean;
}
const CommonAlertDialog = ({
title,
description,
action,
onAction,
actionUpdate,
disabled = false,
loading = false,
}: SingleAlertDialogProps) => {
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
const handleDisconnect = async () => {
if (onAction) await onAction();
setShowDisconnectDialog(false);
};
return (
<AlertDialog
open={showDisconnectDialog}
onOpenChange={setShowDisconnectDialog}
>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={disabled || loading}>
{loading ? `${actionUpdate}...` : action}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> {title} </AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDisconnect}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{action}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export {
CommonAlertDialog,
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,

Some files were not shown because too many files have changed in this diff Show More