Compare commits
301
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b4dac1c83 | ||
|
|
8abf39357b | ||
|
|
73fbef9f51 | ||
|
|
dc36c24cd5 | ||
|
|
6fadbfa212 | ||
|
|
daf8987141 | ||
|
|
f9266fc611 | ||
|
|
b8a4265edf | ||
|
|
4e86e2a8d9 | ||
|
|
1f5366e152 | ||
|
|
cca9d12eca | ||
|
|
4698748803 | ||
|
|
9b99839e51 | ||
|
|
2eba40a4c5 | ||
|
|
103a11e04f | ||
|
|
4d8856a92e | ||
|
|
ed336579e8 | ||
|
|
b0a4fbe17b | ||
|
|
2489d0c05e | ||
|
|
3ab376b522 | ||
|
|
6cd1820ce7 | ||
|
|
010aa4a4d1 | ||
|
|
4e856d351f | ||
|
|
0831a047e2 | ||
|
|
0999c6bd41 | ||
|
|
81c3a38148 | ||
|
|
46574bfdd6 | ||
|
|
a0f70fb05e | ||
|
|
bc84b2b84e | ||
|
|
7e0647f9f7 | ||
|
|
b71592aafa | ||
|
|
33899e693f | ||
|
|
6170b20205 | ||
|
|
96414bca5a | ||
|
|
3027d7e579 | ||
|
|
2060cca3a4 | ||
|
|
992216bb20 | ||
|
|
f8bf8aa6f6 | ||
|
|
914b7dd4c5 | ||
|
|
a6a52c4844 | ||
|
|
66d66ee515 | ||
|
|
cf9c3909ff | ||
|
|
5dc02cf6a5 | ||
|
|
6142c5746e | ||
|
|
7c9c7bcaf7 | ||
|
|
82ef46848e | ||
|
|
0b37bb9e27 | ||
|
|
9c02ad2223 | ||
|
|
ca9dc9b2b1 | ||
|
|
7eed8deb32 | ||
|
|
6d38f304b8 | ||
|
|
8c002304d8 | ||
|
|
fd05a86f50 | ||
|
|
2ccb23ddf6 | ||
|
|
0c618998b6 | ||
|
|
87c649f7f0 | ||
|
|
4fd7369dba | ||
|
|
8b87f23225 | ||
|
|
fa2257e564 | ||
|
|
16398bbe32 | ||
|
|
e842a56dae | ||
|
|
3c6fae3e53 | ||
|
|
635d39a5ea | ||
|
|
19de3594bc | ||
|
|
0f977e2fb2 | ||
|
|
37f0b15ae9 | ||
|
|
e55e40d5fe | ||
|
|
2bfc0238e2 | ||
|
|
4dd874c198 | ||
|
|
32661529e7 | ||
|
|
63062819eb | ||
|
|
ace2ed5a4a | ||
|
|
952da2c258 | ||
|
|
890d69b2a5 | ||
|
|
b42cc06989 | ||
|
|
9a908d9965 | ||
|
|
b22234a28f | ||
|
|
dbb6d9d520 | ||
|
|
a4913820c1 | ||
|
|
070ab6608b | ||
|
|
8311543423 | ||
|
|
1be79bbbfe | ||
|
|
4ac1e36788 | ||
|
|
610257a320 | ||
|
|
6e6122861a | ||
|
|
7e7f9359ef | ||
|
|
2cec85ad35 | ||
|
|
4c02f435b7 | ||
|
|
c000d99710 | ||
|
|
ad5c166938 | ||
|
|
0445962742 | ||
|
|
a6e9e50fcc | ||
|
|
b5cc6e15e1 | ||
|
|
d8c6660fa1 | ||
|
|
558835261f | ||
|
|
9eb480059e | ||
|
|
8236ae1e89 | ||
|
|
635e42a4f8 | ||
|
|
ddda4ef454 | ||
|
|
f93b083d92 | ||
|
|
db3d07369f | ||
|
|
7702eb3ca6 | ||
|
|
e6349f9db9 | ||
|
|
8679378e88 | ||
|
|
9428bb2fd2 | ||
|
|
33f60b143a | ||
|
|
e6d6dcdbaa | ||
|
|
f6290e6ab2 | ||
|
|
61b96fe360 | ||
|
|
0c77418265 | ||
|
|
d50d31a31b | ||
|
|
50bef1e894 | ||
|
|
e26609769c | ||
|
|
4bdf311cc0 | ||
|
|
f5c2403c57 | ||
|
|
22a1d1a171 | ||
|
|
3e087e6bb1 | ||
|
|
a4d96145b6 | ||
|
|
1a44cb7e71 | ||
|
|
c7450cae35 | ||
|
|
ead8f8a195 | ||
|
|
f3107d172d | ||
|
|
8b534abef9 | ||
|
|
ae07c72f20 | ||
|
|
e536082051 | ||
|
|
9c8d7caa45 | ||
|
|
72fc18d1b0 | ||
|
|
ef3b1a23e4 | ||
|
|
3d2500b109 | ||
|
|
b3c8b11295 | ||
|
|
5cf3b33d96 | ||
|
|
e954014f7f | ||
|
|
297a81326e | ||
|
|
8c08d6c99c | ||
|
|
bd45eddadb | ||
|
|
ec31082790 | ||
|
|
a6d0553cf5 | ||
|
|
0774f29438 | ||
|
|
5c80c410df | ||
|
|
9d8f0e5998 | ||
|
|
bebe96dd5c | ||
|
|
efb7ba9e4f | ||
|
|
0590bab122 | ||
|
|
d014bdfefa | ||
|
|
faf99e9ef3 | ||
|
|
2a791d0ca3 | ||
|
|
18bfebb11e | ||
|
|
294efb89dd | ||
|
|
d926083097 | ||
|
|
fbee53aeb2 | ||
|
|
d18cb16461 | ||
|
|
05cda54338 | ||
|
|
ac74bd2be0 | ||
|
|
d5df006e64 | ||
|
|
4637d71bb2 | ||
|
|
7fa983977b | ||
|
|
15dd0eff53 | ||
|
|
c6f08be03d | ||
|
|
8be2fa5366 | ||
|
|
ffc99b7e67 | ||
|
|
42d05ed647 | ||
|
|
2852de5572 | ||
|
|
e9bbaac5a0 | ||
|
|
c1f49e855e | ||
|
|
9dcd9b68af | ||
|
|
daefbde9bf | ||
|
|
6f92160d57 | ||
|
|
8ec18ea0c3 | ||
|
|
95c548f144 | ||
|
|
78554ded87 | ||
|
|
d335c7f1f7 | ||
|
|
67550162f2 | ||
|
|
e7e01dc6f0 | ||
|
|
6a604d7a13 | ||
|
|
615ac0220f | ||
|
|
029d765f5d | ||
|
|
0cdc74258f | ||
|
|
c2182a6042 | ||
|
|
e219c53739 | ||
|
|
1bb7e79a37 | ||
|
|
bf7c9ff5d4 | ||
|
|
c652ad607b | ||
|
|
141def2c54 | ||
|
|
31e221f175 | ||
|
|
ad04e8859f | ||
|
|
90aba22628 | ||
|
|
cf704906b4 | ||
|
|
fdcc36d961 | ||
|
|
a923ce6dfb | ||
|
|
0e92372907 | ||
|
|
7de964e4f0 | ||
|
|
f386f275e6 | ||
|
|
46d8922f59 | ||
|
|
44292347f1 | ||
|
|
c6fc0b824b | ||
|
|
eb70302dd2 | ||
|
|
0f3aeb5ea7 | ||
|
|
0dfdd2565e | ||
|
|
a8560359fd | ||
|
|
a4cb2e4e72 | ||
|
|
9c8841620a | ||
|
|
0df6b96d68 | ||
|
|
9226ba5a70 | ||
|
|
927796c0cd | ||
|
|
3988e6af49 | ||
|
|
f962b97729 | ||
|
|
2b918234d8 | ||
|
|
07c2821d4e | ||
|
|
6fe27092d4 | ||
|
|
09e1d58d07 | ||
|
|
b66dd7f3d1 | ||
|
|
179a93c31b | ||
|
|
9d50baec54 | ||
|
|
afc210d9cb | ||
|
|
cb05dd0656 | ||
|
|
ea59fdbdc5 | ||
|
|
116a903489 | ||
|
|
73798ce172 | ||
|
|
be8d482efc | ||
|
|
1a9b75a1a4 | ||
|
|
e1803c1f01 | ||
|
|
ff3ee45c14 | ||
|
|
3f3e8c8377 | ||
|
|
be52b37f3d | ||
|
|
1afc0832fc | ||
|
|
fcf5be17e6 | ||
|
|
9d81120017 | ||
|
|
30e8c6e8cf | ||
|
|
47b91488cb | ||
|
|
d28a2a257c | ||
|
|
428c0fbd2d | ||
|
|
fb5e4ef47f | ||
|
|
e404919c45 | ||
|
|
589902deb1 | ||
|
|
31d44dac27 | ||
|
|
15e8d1c7d7 | ||
|
|
20f423c3c1 | ||
|
|
7b8bd28ca5 | ||
|
|
13266da720 | ||
|
|
bfe0cdc963 | ||
|
|
bc1671f063 | ||
|
|
e99accc2ec | ||
|
|
59f27b3afd | ||
|
|
bf246ff863 | ||
|
|
2a1d1465cc | ||
|
|
b8cbf94b4d | ||
|
|
adc450c899 | ||
|
|
0d55dba60d | ||
|
|
f0d08fe2a4 | ||
|
|
b0fbddbd60 | ||
|
|
3d722e18ec | ||
|
|
4c6d90ac5d | ||
|
|
e4601bbd41 | ||
|
|
b437a4ba04 | ||
|
|
95c60ae564 | ||
|
|
c0da96f123 | ||
|
|
7198b4c205 | ||
|
|
a13c7d9a77 | ||
|
|
680fd69f4d | ||
|
|
f4d21c64e4 | ||
|
|
7144f46152 | ||
|
|
2da5c31de7 | ||
|
|
3844d87d6e | ||
|
|
373a15672c | ||
|
|
d023c95272 | ||
|
|
e6c8b4b80f | ||
|
|
6e6595b709 | ||
|
|
132c9815fa | ||
|
|
48f443bb97 | ||
|
|
4d77cfe342 | ||
|
|
c64a83400b | ||
|
|
3e54f057a0 | ||
|
|
ef1a00d712 | ||
|
|
a65049fe8d | ||
|
|
29236ae6b6 | ||
|
|
603b9bd3e7 | ||
|
|
1eb0e56f67 | ||
|
|
9c29192245 | ||
|
|
d7440bfe5e | ||
|
|
aad1e31d17 | ||
|
|
1545332b8b | ||
|
|
03f79d3f57 | ||
|
|
3c4a4a254d | ||
|
|
ab73dbc66f | ||
|
|
daf2d9b4a2 | ||
|
|
bdd67017b8 | ||
|
|
0e4d45a28a | ||
|
|
e1e7831513 | ||
|
|
d761f78873 | ||
|
|
13f3de029d | ||
|
|
b57487b9a3 | ||
|
|
84e206e484 | ||
|
|
2705c580f3 | ||
|
|
e5a1c11a80 | ||
|
|
f0d79474e0 | ||
|
|
950f7e12e3 | ||
|
|
0503d904ac | ||
|
|
185d699cad | ||
|
|
7385393342 | ||
|
|
f70b1c06e0 | ||
|
|
e569677358 |
+11
@@ -0,0 +1,11 @@
|
||||
# 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.
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
@@ -145,9 +144,7 @@ export default function Login() {
|
||||
signIn("google", {
|
||||
...(next && next.length > 0 ? { callbackUrl: next } : {}),
|
||||
}).then((res) => {
|
||||
if (res?.status) {
|
||||
setClickedMethod(undefined);
|
||||
}
|
||||
setClickedMethod(undefined);
|
||||
});
|
||||
}}
|
||||
loading={clickedMethod === "google"}
|
||||
@@ -169,9 +166,7 @@ export default function Login() {
|
||||
signIn("linkedin", {
|
||||
...(next && next.length > 0 ? { callbackUrl: next } : {}),
|
||||
}).then((res) => {
|
||||
if (res?.status) {
|
||||
setClickedMethod(undefined);
|
||||
}
|
||||
setClickedMethod(undefined);
|
||||
});
|
||||
}}
|
||||
loading={clickedMethod === "linkedin"}
|
||||
@@ -192,6 +187,8 @@ export default function Login() {
|
||||
setClickedMethod("passkey");
|
||||
signInWithPasskey({
|
||||
tenantId: process.env.NEXT_PUBLIC_HANKO_TENANT_ID as string,
|
||||
}).then(() => {
|
||||
setClickedMethod(undefined);
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Metadata } from "next";
|
||||
|
||||
import { GTMComponent } from "@/components/gtm-component";
|
||||
|
||||
import LoginClient from "./page-client";
|
||||
|
||||
const data = {
|
||||
@@ -37,5 +39,10 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginClient />;
|
||||
return (
|
||||
<>
|
||||
<GTMComponent />
|
||||
<LoginClient />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,8 @@ export async function POST(req: Request) {
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: {
|
||||
slug: {
|
||||
// exclude domains that belong to us
|
||||
not: {
|
||||
contains: "papermark.io",
|
||||
in: ["papermark.io", "papermark.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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`);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -16,6 +17,10 @@ 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";
|
||||
@@ -98,6 +103,8 @@ export async function POST(request: NextRequest) {
|
||||
id: linkId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
documentId: true,
|
||||
dataroomId: true,
|
||||
emailProtected: true,
|
||||
@@ -286,6 +293,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
if (globalBlockCheck.isBlocked) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
|
||||
|
||||
return NextResponse.json({ message: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -298,6 +307,8 @@ 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 },
|
||||
@@ -314,6 +325,8 @@ 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 },
|
||||
@@ -356,6 +369,7 @@ export async function POST(request: NextRequest) {
|
||||
: false;
|
||||
|
||||
if (!isMember && !hasDomainAccess) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -647,6 +661,24 @@ 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 =
|
||||
@@ -758,6 +790,25 @@ 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
|
||||
|
||||
+25
-1
@@ -1,5 +1,6 @@
|
||||
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]";
|
||||
@@ -13,13 +14,14 @@ 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 { extractEmailDomain, isEmailMatched } from "@/lib/utils/email-domain";
|
||||
import { 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";
|
||||
@@ -84,6 +86,9 @@ export async function POST(request: NextRequest) {
|
||||
id: linkId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
documentId: true,
|
||||
emailProtected: true,
|
||||
enableNotification: true,
|
||||
emailAuthenticated: true,
|
||||
@@ -221,6 +226,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
if (globalBlockCheck.isBlocked) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
|
||||
|
||||
return NextResponse.json({ message: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -233,6 +240,8 @@ 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 },
|
||||
@@ -249,6 +258,8 @@ 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 },
|
||||
@@ -626,6 +637,19 @@ 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 = {
|
||||
|
||||
@@ -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,20 +221,15 @@ export default function DocumentsTable({
|
||||
};
|
||||
|
||||
const UpgradeOrExportButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (isFree && !isTrial) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Upgrade to Export
|
||||
</Button>
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger="dashboard_documents_export"
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</>
|
||||
<UpgradeButton
|
||||
text="Export"
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger="dashboard_documents_export"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
@@ -251,7 +246,7 @@ export default function DocumentsTable({
|
||||
<div className="flex justify-end">
|
||||
<UpgradeOrExportButton />
|
||||
</div>
|
||||
<div className="rounded-xl border overflow-x-auto">
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -43,7 +43,7 @@ import { cn, timeAgo } from "@/lib/utils";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { downloadCSV } from "@/lib/utils/csv";
|
||||
|
||||
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
|
||||
import { UpgradeButton } from "../ui/upgrade-button";
|
||||
|
||||
interface Link {
|
||||
id: string;
|
||||
@@ -285,20 +285,15 @@ export default function LinksTable({
|
||||
};
|
||||
|
||||
const UpgradeOrExportButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (isFree && !isTrial) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Upgrade to Export
|
||||
</Button>
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger="dashboard_links_export"
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</>
|
||||
<UpgradeButton
|
||||
text="Export"
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger="dashboard_links_export"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
@@ -315,7 +310,7 @@ export default function LinksTable({
|
||||
<div className="flex justify-end">
|
||||
<UpgradeOrExportButton />
|
||||
</div>
|
||||
<div className="rounded-xl border overflow-x-auto">
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { PlanEnum } from "@/ee/stripe/constants";
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
Download,
|
||||
DownloadCloudIcon,
|
||||
FileBadgeIcon,
|
||||
FileDigitIcon,
|
||||
MoreHorizontalIcon,
|
||||
ServerIcon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
@@ -38,11 +36,6 @@ 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,
|
||||
@@ -55,13 +48,7 @@ import {
|
||||
import { BadgeTooltip } from "@/components/ui/tooltip";
|
||||
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
|
||||
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
|
||||
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";
|
||||
import { UpgradeButton } from "../ui/upgrade-button";
|
||||
|
||||
interface View {
|
||||
id: string;
|
||||
@@ -341,20 +328,15 @@ export default function ViewsTable({
|
||||
};
|
||||
|
||||
const UpgradeOrExportButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (isFree && !isTrial) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Upgrade to Export
|
||||
</Button>
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger="dashboard_views_export"
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</>
|
||||
<UpgradeButton
|
||||
text="Export"
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger="dashboard_views_export"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,6 @@ import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Gauge } from "@/components/ui/gauge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -42,8 +41,7 @@ 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 { UpgradePlanModal } from "../billing/upgrade-plan-modal";
|
||||
import { UpgradeButton } from "../ui/upgrade-button";
|
||||
|
||||
interface Visitor {
|
||||
email: string;
|
||||
@@ -269,20 +267,15 @@ export default function VisitorsTable({
|
||||
};
|
||||
|
||||
const UpgradeOrExportButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (isFree && !isTrial) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Upgrade to Export
|
||||
</Button>
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger="dashboard_visitors_export"
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</>
|
||||
<UpgradeButton
|
||||
text="Export"
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger="dashboard_visitors_export"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
@@ -299,7 +292,7 @@ export default function VisitorsTable({
|
||||
<div className="flex justify-end">
|
||||
<UpgradeOrExportButton />
|
||||
</div>
|
||||
<div className="rounded-xl border overflow-x-auto">
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -37,17 +37,18 @@ export function AddSeatModal({
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
const analytics = useAnalytics();
|
||||
const { plan: userPlan, planName, isAnnualPlan } = usePlan();
|
||||
const { plan: userPlan, planName, isAnnualPlan, isOldAccount } = 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(
|
||||
planName,
|
||||
isAnnualPlan ? "yearly" : "monthly",
|
||||
);
|
||||
const priceId = getPriceIdFromPlan({
|
||||
planSlug: userPlan,
|
||||
isOld: isOldAccount,
|
||||
period: isAnnualPlan ? "yearly" : "monthly",
|
||||
});
|
||||
const minQuantity = getQuantityFromPriceId(priceId);
|
||||
|
||||
// Set initial quantity to 1 (adding one seat)
|
||||
|
||||
@@ -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,7 +63,11 @@ export default function ProAnnualBanner({
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId: getPriceIdFromPlan("Pro", "yearly"),
|
||||
priceId: getPriceIdFromPlan({
|
||||
planSlug: "pro",
|
||||
isOld: isOldAccount,
|
||||
period: "yearly",
|
||||
}),
|
||||
upgradePlan: true,
|
||||
proAnnualBanner: true,
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
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 {
|
||||
@@ -14,27 +21,55 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { UpgradeButton } from "@/components/ui/upgrade-button";
|
||||
|
||||
export default function UpgradePlanContainer() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const teamInfo = useTeam();
|
||||
const { plan, isFree, isDataroomsPlus } = usePlan();
|
||||
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 manageSubscription = async () => {
|
||||
if (!teamInfo?.currentTeam?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
const manageSubscription = async ({
|
||||
type,
|
||||
}: {
|
||||
type:
|
||||
| "manage"
|
||||
| "invoices"
|
||||
| "subscription_update"
|
||||
| "payment_method_update";
|
||||
}) => {
|
||||
if (!currentTeamId) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
fetch(`/api/teams/${teamId}/billing/manage`, {
|
||||
fetch(`/api/teams/${currentTeamId}/billing/manage`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ type }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(async (res) => {
|
||||
const url = await res.json();
|
||||
@@ -53,48 +88,275 @@ 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 (
|
||||
<Link href="/settings/upgrade">
|
||||
<Button>Upgrade Plan</Button>
|
||||
</Link>
|
||||
<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>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button onClick={manageSubscription} loading={loading}>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<>
|
||||
<div className="rounded-lg">
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isDataroomsPlus
|
||||
? "Datarooms+"
|
||||
: 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>
|
||||
: 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ 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, Users2Icon, CircleHelpIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { CheckIcon, CircleHelpIcon, Users2Icon, XIcon } from "lucide-react";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
@@ -20,11 +19,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
|
||||
@@ -32,7 +31,7 @@ const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleStartTrial = () => {
|
||||
router.push('/welcome?type=dataroom-trial');
|
||||
router.push("/welcome?type=dataroom-trial");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -53,7 +52,11 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
|
||||
return (
|
||||
<div className={cn("justify-between gap-x-8", baseClasses)}>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
|
||||
{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]" />
|
||||
)}
|
||||
<span>{feature.text}</span>
|
||||
</div>
|
||||
{feature.tooltip && (
|
||||
@@ -76,7 +79,11 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
|
||||
|
||||
return (
|
||||
<div className={cn("text-sm", baseClasses)}>
|
||||
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
|
||||
{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]" />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{feature.text}</span>
|
||||
{feature.tooltip && (
|
||||
@@ -313,10 +320,11 @@ export function UpgradePlanModal({
|
||||
loading={selectedPlan === planOption}
|
||||
disabled={selectedPlan !== null}
|
||||
onClick={() => {
|
||||
const priceId = getPriceIdFromPlan(
|
||||
displayPlanName,
|
||||
const priceId = getPriceIdFromPlan({
|
||||
planName: displayPlanName,
|
||||
period,
|
||||
);
|
||||
isOld: isOldAccount,
|
||||
});
|
||||
|
||||
setSelectedPlan(planOption);
|
||||
if (isCustomer && teamPlan !== "free") {
|
||||
@@ -382,8 +390,8 @@ export function UpgradePlanModal({
|
||||
>
|
||||
See all plans
|
||||
</Link>
|
||||
{trigger === "sidebar_datarooms" &&
|
||||
((teamPlan === "free" && !isTrial) || (teamPlan === "pro" && !isTrial)) && (
|
||||
{((teamPlan === "free" && !isTrial) ||
|
||||
(teamPlan === "pro" && !isTrial)) && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<StartDataRoomTrialButton teamId={teamId} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ConversationListItem as ConversationListItemEE } from "@/ee/features/conversations/components/conversation-list-item";
|
||||
import { ConversationMessage as ConversationMessageEE } from "@/ee/features/conversations/components/conversation-message";
|
||||
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";
|
||||
|
||||
export function ConversationListItem(props: any) {
|
||||
return <ConversationListItemEE {...props} />;
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useState } from "react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonTooltip } from "@/components/ui/tooltip";
|
||||
import { ResponsiveButton } from "@/components/ui/responsive-button";
|
||||
|
||||
export default function DownloadDataroomButton({
|
||||
teamId,
|
||||
@@ -57,16 +56,13 @@ export default function DownloadDataroomButton({
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ButtonTooltip content="Download Dataroom" sideOffset={4}>
|
||||
<Button
|
||||
onClick={downloadDataroom}
|
||||
variant="outline"
|
||||
className=""
|
||||
size="sm"
|
||||
loading={isLoading}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
</ButtonTooltip>
|
||||
<ResponsiveButton
|
||||
icon={<DownloadIcon className="h-4 w-4" />}
|
||||
text="Download"
|
||||
onClick={downloadDataroom}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveButton } from "@/components/ui/responsive-button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -135,10 +136,13 @@ export default function GenerateIndexDialog({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={disabled}>
|
||||
<FileSlidersIcon />
|
||||
Generate Index File
|
||||
</Button>
|
||||
<ResponsiveButton
|
||||
icon={<FileSlidersIcon className="h-4 w-4" />}
|
||||
text="Generate Index"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
useDataroom,
|
||||
useDataroomFolderWithParents,
|
||||
} from "@/lib/swr/use-dataroom";
|
||||
import {
|
||||
HIERARCHICAL_DISPLAY_STYLE,
|
||||
useHierarchicalDisplayName,
|
||||
} from "@/lib/utils/hierarchical-display";
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -25,6 +29,70 @@ 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,
|
||||
@@ -69,54 +137,41 @@ function BreadcrumbComponentBase({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{folders.slice(0, -2).map((folder, index) => (
|
||||
<DropdownMenuItem key={index}>
|
||||
<Link
|
||||
href={`/datarooms/${dataroomId}/documents${folder.path}`}
|
||||
className="w-full"
|
||||
>
|
||||
{folder.name}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<BreadcrumbDropdownItem
|
||||
key={index}
|
||||
folder={folder}
|
||||
dataroomId={dataroomId}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/datarooms/${dataroomId}/documents${folders[folders.length - 2].path}`}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{folders[folders.length - 2].name}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbFolderItem
|
||||
folder={folders[folders.length - 2]}
|
||||
dataroomId={dataroomId}
|
||||
isLast={false}
|
||||
/>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="max-w-[200px] truncate">
|
||||
{folders[folders.length - 1].name}
|
||||
</BreadcrumbPage>
|
||||
<BreadcrumbFolderItem
|
||||
folder={folders[folders.length - 1]}
|
||||
dataroomId={dataroomId}
|
||||
isLast={true}
|
||||
/>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
) : (
|
||||
folders?.map((folder, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<BreadcrumbItem>
|
||||
{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>
|
||||
)}
|
||||
<BreadcrumbFolderItem
|
||||
folder={folder}
|
||||
dataroomId={dataroomId}
|
||||
isLast={index === folders.length - 1}
|
||||
/>
|
||||
</BreadcrumbItem>
|
||||
{index < folders.length - 1 && <BreadcrumbSeparator />}
|
||||
</React.Fragment>
|
||||
@@ -141,4 +196,4 @@ const BreadcrumbComponent = () => {
|
||||
return MemoizedBreadcrumbComponent;
|
||||
};
|
||||
|
||||
export { BreadcrumbComponent };
|
||||
export { BreadcrumbComponent };
|
||||
|
||||
@@ -20,6 +20,10 @@ 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";
|
||||
@@ -61,6 +65,12 @@ 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);
|
||||
@@ -205,8 +215,11 @@ 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">
|
||||
{dataroomDocument.document.name}
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
|
||||
|
||||
@@ -26,7 +26,7 @@ export const DataroomNavigation = ({ dataroomId }: { dataroomId?: string }) => {
|
||||
segment: "analytics",
|
||||
},
|
||||
{
|
||||
label: "Q&A Conversations",
|
||||
label: "Q&A",
|
||||
href: `/datarooms/${dataroomId}/conversations`,
|
||||
segment: "conversations",
|
||||
limited: !limits?.conversationsInDataroom,
|
||||
|
||||
@@ -3,6 +3,10 @@ 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;
|
||||
@@ -31,6 +35,9 @@ 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);
|
||||
|
||||
@@ -43,18 +50,45 @@ export function ExportVisitsModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startExport = useCallback(async () => {
|
||||
// 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 () => {
|
||||
// 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(
|
||||
@@ -72,10 +106,11 @@ export function ExportVisitsModal({
|
||||
}
|
||||
|
||||
// Trigger the background export job
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}${groupId ? `/groups/${groupId}` : ""}/export-visits`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
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" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
@@ -174,11 +209,6 @@ 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;
|
||||
@@ -201,6 +231,72 @@ 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) {
|
||||
@@ -209,6 +305,10 @@ 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();
|
||||
};
|
||||
@@ -224,7 +324,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">
|
||||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Export Visits</h3>
|
||||
<button
|
||||
@@ -247,48 +347,141 @@ export function ExportVisitsModal({
|
||||
</button>
|
||||
</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>
|
||||
{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>
|
||||
|
||||
<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
|
||||
) : 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 && 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>
|
||||
<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>
|
||||
) : (
|
||||
// Show existing exports and new export option
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Exports for: {displayName}
|
||||
</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">
|
||||
<button
|
||||
onClick={sendExportEmail}
|
||||
onClick={startNewExport}
|
||||
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}
|
||||
Start New Export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!viewCount || viewCount <= 10) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Your export will be ready shortly...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,10 @@ 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";
|
||||
@@ -19,6 +23,31 @@ 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,
|
||||
@@ -29,6 +58,12 @@ 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) => ({
|
||||
@@ -57,11 +92,7 @@ const FolderComponent = memo(
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<FileTree.File
|
||||
key={item.id}
|
||||
name={item.document.name}
|
||||
onToggle={() => router.push(`/documents/${item.document.id}`)}
|
||||
/>
|
||||
<DocumentFileItem key={item.id} document={item} router={router} />
|
||||
);
|
||||
}
|
||||
}),
|
||||
@@ -87,7 +118,7 @@ const FolderComponent = memo(
|
||||
|
||||
return (
|
||||
<FileTree.Folder
|
||||
name={folder.name}
|
||||
name={folderDisplayName}
|
||||
key={folder.id}
|
||||
active={isActive}
|
||||
childActive={isChildActive}
|
||||
|
||||
@@ -61,6 +61,7 @@ type DataroomDocumentWithVersion = {
|
||||
folderId: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
hierarchicalIndex: string | null;
|
||||
versions: {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
@@ -75,6 +76,7 @@ type DataroomFolderWithDocumentsNew = DataroomFolder & {
|
||||
folderId: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
hierarchicalIndex: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
|
||||
@@ -4,17 +4,47 @@ 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;
|
||||
@@ -29,6 +59,7 @@ type DataroomFolderWithDocuments = DataroomFolder & {
|
||||
folderId: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
hierarchicalIndex: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -59,25 +90,37 @@ 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) => (
|
||||
<FileTree.File
|
||||
<ViewerDocumentFileItem
|
||||
key={doc.id}
|
||||
name={doc.name}
|
||||
// onToggle={() => router.push(`/documents/${doc.id}`)}
|
||||
document={{
|
||||
...doc,
|
||||
versions: [], // Not needed for display
|
||||
}}
|
||||
dataroomIndexEnabled={dataroomIndexEnabled}
|
||||
/>
|
||||
)),
|
||||
[folder.documents, router.query.name],
|
||||
[folder.documents, dataroomIndexEnabled],
|
||||
);
|
||||
|
||||
// Recursively render child folders if they exist
|
||||
@@ -90,9 +133,16 @@ const FolderComponent = memo(
|
||||
folderId={folderId}
|
||||
setFolderId={setFolderId}
|
||||
folderPath={folderPath}
|
||||
dataroomIndexEnabled={dataroomIndexEnabled}
|
||||
/>
|
||||
)),
|
||||
[folder.childFolders, folderId, setFolderId],
|
||||
[
|
||||
folder.childFolders,
|
||||
folderId,
|
||||
setFolderId,
|
||||
folderPath,
|
||||
dataroomIndexEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
const isActive = folder.id === folderId;
|
||||
@@ -109,7 +159,7 @@ const FolderComponent = memo(
|
||||
}}
|
||||
>
|
||||
<FileTree.Folder
|
||||
name={folder.name}
|
||||
name={folderDisplayName}
|
||||
key={folder.id}
|
||||
active={isActive}
|
||||
childActive={isChildActive}
|
||||
@@ -165,11 +215,13 @@ 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) {
|
||||
@@ -203,6 +255,7 @@ const SidebarFolders = ({
|
||||
folderId={folderId}
|
||||
setFolderId={setFolderId}
|
||||
folderPath={folderPath}
|
||||
dataroomIndexEnabled={dataroomIndexEnabled}
|
||||
/>
|
||||
))}
|
||||
</FileTree>
|
||||
@@ -214,11 +267,13 @@ 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;
|
||||
|
||||
@@ -228,6 +283,7 @@ export function ViewFolderTree({
|
||||
documents={documents}
|
||||
setFolderId={setFolderId}
|
||||
folderId={folderId}
|
||||
dataroomIndexEnabled={dataroomIndexEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,13 @@ 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";
|
||||
@@ -38,10 +43,35 @@ 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;
|
||||
@@ -85,16 +115,7 @@ const createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
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>
|
||||
),
|
||||
cell: ({ row }) => <PermissionItemName item={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -189,6 +210,7 @@ const buildTree = (
|
||||
id: doc.id,
|
||||
documentId: doc.document.id,
|
||||
name: doc.document.name,
|
||||
hierarchicalIndex: doc.hierarchicalIndex,
|
||||
permissions: getPermissions(doc.id),
|
||||
itemType: ItemType.DATAROOM_DOCUMENT,
|
||||
}));
|
||||
@@ -230,6 +252,7 @@ const buildTree = (
|
||||
result.push({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
hierarchicalIndex: folder.hierarchicalIndex,
|
||||
subItems: allSubItems,
|
||||
permissions: folderPermissions,
|
||||
itemType: ItemType.DATAROOM_FOLDER,
|
||||
@@ -248,6 +271,7 @@ 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,6 +5,9 @@ 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 {
|
||||
@@ -16,9 +19,6 @@ 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-y-scroll">
|
||||
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
|
||||
<SidebarFolderTreeSelection
|
||||
dataroomId={dataroomId}
|
||||
selectedFolder={selectedFolder}
|
||||
@@ -133,7 +133,9 @@ export function MoveToDataroomFolderModal({
|
||||
) : (
|
||||
<>
|
||||
Move to{" "}
|
||||
<span className="font-medium">{selectedFolder.name}</span>
|
||||
<span className="max-w-[200px] truncate font-medium">
|
||||
{selectedFolder.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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' 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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="datarooms-plus" />
|
||||
<PlanBadge plan="data rooms plus" />
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { BellIcon, CogIcon, ShieldIcon } from "lucide-react";
|
||||
import { BellIcon, CogIcon, DownloadIcon, ShieldIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -39,6 +39,18 @@ 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("Index saved successfully");
|
||||
toast.success("Folder order saved successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to save new order:", error);
|
||||
toast.error("Failed to save index");
|
||||
toast.error("Failed to save order");
|
||||
// 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 index
|
||||
Save order
|
||||
</Button>
|
||||
</Portal>
|
||||
<DeleteFolderModal />
|
||||
|
||||
@@ -7,7 +7,11 @@ import { useTheme } from "next-themes";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "@/lib/constants";
|
||||
import {
|
||||
FREE_PLAN_ACCEPTED_FILE_TYPES,
|
||||
FULL_PLAN_ACCEPTED_FILE_TYPES,
|
||||
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";
|
||||
@@ -46,49 +50,8 @@ export default function DocumentUpload({
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept:
|
||||
isFree && !isTrial
|
||||
? {
|
||||
"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"
|
||||
},
|
||||
? FREE_PLAN_ACCEPTED_FILE_TYPES
|
||||
: FULL_PLAN_ACCEPTED_FILE_TYPES,
|
||||
multiple: false,
|
||||
onDropAccepted: (acceptedFiles) => {
|
||||
if (acceptedFiles.length === 0) {
|
||||
@@ -229,7 +192,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, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}
|
||||
: `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`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 {
|
||||
@@ -18,6 +19,7 @@ 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";
|
||||
@@ -387,17 +389,36 @@ 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
|
||||
}
|
||||
if (validateNotionPageURL === null || !isValidURL.test(notionLink)) {
|
||||
|
||||
// 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) {
|
||||
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="sm:max-w-[425px]">
|
||||
<DialogContent className="max-w-[90vw] sm:max-w-[425px]">
|
||||
<DialogHeader className="text-start">
|
||||
<DialogTitle>
|
||||
<span className="font-bold">{documentName}</span>
|
||||
@@ -98,18 +98,21 @@ export function AddToDataroomModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Select onValueChange={(value) => setSelectedDataroom(value)}>
|
||||
<SelectTrigger className="min-w-fit">
|
||||
<SelectTrigger className="w-[380px] max-w-full [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:truncate [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
|
||||
<SelectValue placeholder="Select a dataroom" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="w-[380px] max-w-[90vw]">
|
||||
{datarooms?.map((dataroom) => (
|
||||
<SelectItem
|
||||
key={dataroom.id}
|
||||
value={dataroom.id}
|
||||
disabled={dataroom.id === dataroomId}
|
||||
className="break-words"
|
||||
>
|
||||
{dataroom.name}
|
||||
{dataroom.id === dataroomId ? " (current)" : ""}
|
||||
<span className="line-clamp-1 break-words">
|
||||
{dataroom.name}
|
||||
{dataroom.id === dataroomId ? " (current)" : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -127,15 +130,15 @@ export function AddToDataroomModal({
|
||||
{!selectedDataroom ? (
|
||||
"Select a dataroom"
|
||||
) : (
|
||||
<>
|
||||
Add to{" "}
|
||||
<span className="font-medium">
|
||||
<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">
|
||||
{
|
||||
datarooms?.filter((d) => d.id === selectedDataroom)[0]
|
||||
.name
|
||||
}
|
||||
</span>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -114,18 +114,21 @@ export function AddFolderToDataroomModal({
|
||||
<DialogDescription>Add your folder to a dataroom.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Select onValueChange={(value) => setSelectedDataroom(value)}>
|
||||
<SelectTrigger className="min-w-fit">
|
||||
<SelectTrigger className="w-[380px] max-w-full [&>span]:truncate [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
|
||||
<SelectValue placeholder="Select a dataroom" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="w-[380px] max-w-[90vw]">
|
||||
{datarooms?.map((dataroom) => (
|
||||
<SelectItem
|
||||
key={dataroom.id}
|
||||
value={dataroom.id}
|
||||
disabled={dataroom.id === dataroomId}
|
||||
className="break-words"
|
||||
>
|
||||
{dataroom.name}
|
||||
{dataroom.id === dataroomId ? " (current)" : ""}
|
||||
<span className="break-words line-clamp-1">
|
||||
{dataroom.name}
|
||||
{dataroom.id === dataroomId ? " (current)" : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -143,15 +146,15 @@ export function AddFolderToDataroomModal({
|
||||
{!selectedDataroom ? (
|
||||
"Select a dataroom"
|
||||
) : (
|
||||
<>
|
||||
Add to{" "}
|
||||
<span className="font-medium">
|
||||
<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">
|
||||
{
|
||||
datarooms?.filter((d) => d.id === selectedDataroom)[0]
|
||||
.name
|
||||
}
|
||||
</span>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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-4 md:flex-nowrap md:gap-x-2"
|
||||
className="flex flex-wrap items-center justify-end gap-x-2 md:flex-nowrap md:gap-x-1"
|
||||
>
|
||||
{row.map((action, i) => (
|
||||
<li key={i}>{action}</li>
|
||||
|
||||
@@ -4,6 +4,10 @@ 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;
|
||||
@@ -26,6 +30,9 @@ 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);
|
||||
|
||||
@@ -38,18 +45,44 @@ export function ExportVisitsModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startExport = useCallback(async () => {
|
||||
// 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 () => {
|
||||
// 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(
|
||||
@@ -167,12 +200,7 @@ export function ExportVisitsModal({
|
||||
);
|
||||
handleClose();
|
||||
}
|
||||
}, [document.id, teamId]);
|
||||
|
||||
// Start export immediately when component mounts
|
||||
useEffect(() => {
|
||||
startExport();
|
||||
}, [startExport]);
|
||||
}, [document.id, teamId, document.name]);
|
||||
|
||||
// Send export via email
|
||||
const sendExportEmail = async () => {
|
||||
@@ -196,6 +224,72 @@ 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) {
|
||||
@@ -204,6 +298,10 @@ 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();
|
||||
};
|
||||
@@ -215,7 +313,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">
|
||||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Export Visits</h3>
|
||||
<button
|
||||
@@ -238,44 +336,132 @@ export function ExportVisitsModal({
|
||||
</button>
|
||||
</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>
|
||||
{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>
|
||||
|
||||
{viewCount !== null && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Found {viewCount} view{viewCount !== 1 ? "s" : ""} to export
|
||||
) : 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 && 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>
|
||||
{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">
|
||||
<div className="text-sm text-gray-600">
|
||||
Exports for: {document.name}
|
||||
</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">
|
||||
<button
|
||||
onClick={sendExportEmail}
|
||||
onClick={startNewExport}
|
||||
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}
|
||||
Start New Export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!viewCount || viewCount <= 10) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Your export will be ready shortly...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,10 @@ 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 {
|
||||
@@ -66,6 +70,14 @@ 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}`
|
||||
@@ -84,8 +96,6 @@ export default function FolderCard({
|
||||
}
|
||||
}, [openFolder, addDataroomOpen]);
|
||||
|
||||
|
||||
|
||||
const handleCreateDataroom = (e: any, folderId: string) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@@ -160,8 +170,11 @@ 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">
|
||||
{folder.name}
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
|
||||
|
||||
@@ -5,6 +5,9 @@ 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 {
|
||||
@@ -16,9 +19,6 @@ 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="w-[376px] truncate font-bold">
|
||||
<div className="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-y-scroll">
|
||||
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
|
||||
<SidebarFolderTreeSelection
|
||||
selectedFolder={selectedFolder}
|
||||
setSelectedFolder={setSelectedFolder}
|
||||
@@ -133,7 +133,9 @@ export function MoveToFolderModal({
|
||||
) : (
|
||||
<>
|
||||
Move to{" "}
|
||||
<span className="font-medium">{selectedFolder.name}</span>
|
||||
<span className="max-w-[200px] truncate font-medium">
|
||||
{selectedFolder.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ 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,
|
||||
@@ -116,17 +117,16 @@ export function AddDomainModal({
|
||||
) {
|
||||
if (children) {
|
||||
return (
|
||||
<UpgradePlanModal
|
||||
<UpgradeButton
|
||||
text="Add Domain"
|
||||
clickedPlan={
|
||||
linkType === "DATAROOM_LINK"
|
||||
? PlanEnum.DataRooms
|
||||
: PlanEnum.Business
|
||||
}
|
||||
trigger={"add_domain_overview"}
|
||||
highlightItem={["custom-domain"]}
|
||||
>
|
||||
<Button>Upgrade to Add Domain</Button>
|
||||
</UpgradePlanModal>
|
||||
trigger="add_domain_overview"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { motion } from "motion/react";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -24,8 +26,6 @@ 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,16 +99,8 @@ export default function DomainCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<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>
|
||||
<div className="flex items-center gap-1.5 truncate text-sm font-medium sm:gap-2.5">
|
||||
{domain}
|
||||
|
||||
{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">
|
||||
|
||||
@@ -6,6 +6,7 @@ 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({
|
||||
@@ -19,30 +20,6 @@ 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">
|
||||
@@ -95,6 +72,11 @@ 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">
|
||||
@@ -116,15 +98,39 @@ export default function DomainConfiguration({
|
||||
recordType === "A" ? "apex domain" : "subdomain"
|
||||
} <code>${
|
||||
recordType === "A" ? domainJson.apexName : domainJson.name
|
||||
}</code>, set the following ${recordType} record on your DNS provider:`}
|
||||
}</code>, set the following ${txtVerification ? "records" : `${recordType} record`} on your DNS provider:`}
|
||||
records={[
|
||||
{
|
||||
type: recordType,
|
||||
name: recordType === "A" ? "@" : (subdomain ?? "www"),
|
||||
value: recordType === "A" ? `76.76.21.21` : `cname.vercel-dns.com`,
|
||||
value:
|
||||
recordType === "A"
|
||||
? (configJson?.recommendedIPv4?.[0]?.value?.[0] ??
|
||||
"76.76.21.21")
|
||||
: (configJson?.recommendedCNAME?.[0]?.value ??
|
||||
"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>
|
||||
);
|
||||
@@ -151,16 +157,16 @@ const DnsRecord = ({
|
||||
const hasTtl = records.some((x) => x.ttl);
|
||||
|
||||
return (
|
||||
<div className="mt-3 text-left text-gray-600">
|
||||
<div className="my-5 text-gray-600 dark:text-gray-400">
|
||||
<div className="mt-3 text-left text-gray-600 dark:text-gray-400">
|
||||
<div className="my-5">
|
||||
<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,min-content)]"
|
||||
: "grid-cols-[repeat(3,min-content)]",
|
||||
? "grid-cols-[repeat(4,max-content)]"
|
||||
: "grid-cols-[repeat(3,max-content)]",
|
||||
)}
|
||||
>
|
||||
{["Type", "Name", "Value"].concat(hasTtl ? "TTL" : []).map((s) => (
|
||||
@@ -179,11 +185,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">
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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;
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
} from "@react-email/components";
|
||||
|
||||
export default function DataroomNotification({
|
||||
dataroomName,
|
||||
documentName,
|
||||
senderEmail,
|
||||
url,
|
||||
unsubscribeUrl,
|
||||
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: 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="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
|
||||
<Text className="mb-8 mt-4 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">
|
||||
<Text className="font-seminbold mb-8 mt-4 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="mb-[32px] mt-[32px] text-center">
|
||||
<Section className="my-8 text-center">
|
||||
<Button
|
||||
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
|
||||
href={`${url}`}
|
||||
@@ -59,17 +59,12 @@ export default function DataroomNotification({
|
||||
{`${url}`}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-400">Papermark</Text>
|
||||
|
||||
<Hr />
|
||||
<Section className="mt-8 text-gray-400">
|
||||
<Section className="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>
|
||||
© {new Date().getFullYear()} Papermark, Inc. All rights
|
||||
reserved.
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
You received this email from{" "}
|
||||
@@ -81,7 +76,7 @@ export default function DataroomNotification({
|
||||
this dataroom,{" "}
|
||||
<a
|
||||
href={unsubscribeUrl}
|
||||
className="text-gray-400 underline underline-offset-2 hover:text-gray-400"
|
||||
className="text-gray-400 underline underline-offset-2"
|
||||
>
|
||||
click here
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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'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;
|
||||
@@ -14,53 +14,58 @@ 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>{previewText}</Preview>
|
||||
<Preview>Upgrade to continue using data rooms</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">
|
||||
<Text className="mb-8 mt-4 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 dataroom trial has expired
|
||||
<Text className="font-seminbold mb-8 mt-4 text-center text-xl">
|
||||
Your Data Room plan 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 dataroom trial has expired.{" "}
|
||||
<Link href={`https://app.papermark.com/settings/billing`}>
|
||||
Your Papermark Data Room trial has expired.
|
||||
<br />
|
||||
<Link
|
||||
href={`https://app.papermark.com/settings/billing`}
|
||||
className="underline"
|
||||
>
|
||||
Upgrade now
|
||||
</Link>{" "}
|
||||
to:
|
||||
</Text>
|
||||
<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">
|
||||
<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">
|
||||
<Button
|
||||
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
|
||||
href={`https://app.papermark.com/settings/billing`}
|
||||
@@ -74,23 +79,7 @@ const DataroomTrialEnd = ({ name }: DataroomTrialEnd) => {
|
||||
with advanced access controls have been{" "}
|
||||
<span className="underline">disabled</span>.
|
||||
</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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -12,7 +12,7 @@ const DataroomTrialWelcomeEmail = ({ name }: WelcomeEmailProps) => {
|
||||
<Head />
|
||||
<Tailwind>
|
||||
<Body className="font-sans text-sm">
|
||||
<Text>Hi {name},</Text>
|
||||
<Text>Hi{name && ` ${name}`},</Text>
|
||||
<Text>
|
||||
I am Marc, founder of Papermark. Thanks for creating a trial. Do you
|
||||
need any help with Data Rooms setup?
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
export default function DataroomViewerInvitation({
|
||||
dataroomName,
|
||||
senderEmail,
|
||||
url,
|
||||
dataroomName = "Example Data Room",
|
||||
senderEmail = "sender@example.com",
|
||||
url = "https://app.papermark.com/datarooms/123",
|
||||
}: {
|
||||
dataroomName: string;
|
||||
senderEmail: string;
|
||||
@@ -58,23 +60,10 @@ export default function DataroomViewerInvitation({
|
||||
{`${url}`}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-400">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.
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer
|
||||
footerText="If you have any feedback or questions about this email, simply
|
||||
reply to it."
|
||||
/>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
export default function DomainDeleted({
|
||||
domain = "papermark.com",
|
||||
}: {
|
||||
@@ -53,23 +55,7 @@ export default function DomainDeleted({
|
||||
If you did not want to keep using this domain on Papermark anyway,
|
||||
you can simply ignore this email.
|
||||
</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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
export function EmailUpdated({
|
||||
oldEmail = "name@example.com",
|
||||
newEmail = "name@example.com",
|
||||
oldEmail = "old@example.com",
|
||||
newEmail = "new@example.com",
|
||||
}: {
|
||||
oldEmail: string;
|
||||
newEmail: string;
|
||||
@@ -47,19 +49,7 @@ export function EmailUpdated({
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
This message is being sent to your old email address only.
|
||||
</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>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
export default function ExportReady({
|
||||
resourceName = "Export",
|
||||
downloadUrl,
|
||||
email,
|
||||
downloadUrl = "https://app.papermark.com/datarooms/123",
|
||||
email = "email@example.com",
|
||||
}: {
|
||||
resourceName?: string;
|
||||
downloadUrl: string;
|
||||
@@ -48,37 +50,27 @@ export default function ExportReady({
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
Export details:
|
||||
</Text>
|
||||
<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>
|
||||
<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="text-sm leading-6 text-black">
|
||||
Best,
|
||||
<br />
|
||||
The Papermark Team
|
||||
</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, 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>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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'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;
|
||||
@@ -0,0 +1,84 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
export default function InvalidDomain({
|
||||
domain = "papermark.com",
|
||||
invalidDays = 14,
|
||||
@@ -77,23 +79,7 @@ export default function InvalidDomain({
|
||||
}`
|
||||
: ""}
|
||||
</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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
(All Notion changes are instantly reflected in shared documents)
|
||||
</Text>
|
||||
<Text className="text-sm">
|
||||
You can also use <strong>Bulk upload 💫</strong> Just drop many
|
||||
documents at once
|
||||
You can also use{" "}
|
||||
<span className="font-semibold">Bulk upload 💫</span> Just drop
|
||||
multiple documents at once
|
||||
</Text>
|
||||
<Section className="mb-[32px] mt-[32px] text-center">
|
||||
<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/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
|
||||
@@ -63,16 +63,10 @@ const Onboarding1Email = () => {
|
||||
After sharing start tracking document activity on each page
|
||||
</Text>
|
||||
<Hr />
|
||||
<Section className="mt-8 text-gray-400">
|
||||
<Section className="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>
|
||||
© {new Date().getFullYear()} Papermark, Inc. All rights
|
||||
reserved.
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
If you have any feedback or questions about this email, simply
|
||||
|
||||
@@ -14,12 +14,10 @@ import {
|
||||
} from "@react-email/components";
|
||||
|
||||
const Onboarding2Email = () => {
|
||||
const previewText = `The document sharing infrastructure for the modern web`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Preview>The document sharing infrastructure for the modern web</Preview>
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Container className="mx-auto my-10 w-[465px] p-5">
|
||||
@@ -36,19 +34,16 @@ const Onboarding2Email = () => {
|
||||
With Papermark you can use different link settings for shared
|
||||
documents and data rooms:
|
||||
</Text>
|
||||
<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">
|
||||
<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">
|
||||
<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`}
|
||||
@@ -62,20 +57,14 @@ const Onboarding2Email = () => {
|
||||
feedback, and cta settings
|
||||
</Text>
|
||||
<Hr />
|
||||
<Section className="mt-8 text-gray-400">
|
||||
<Section className="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>
|
||||
© {new Date().getFullYear()} Papermark, Inc. All rights
|
||||
reserved.
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
If you have any feedback or questions about this email, simply
|
||||
reply to it. I'd love to hear from you!{" "}
|
||||
reply to it. I'd love to hear from you!
|
||||
</Text>
|
||||
|
||||
<Text className="text-xs">Stop this onboarding sequence</Text>
|
||||
|
||||
@@ -14,12 +14,10 @@ import {
|
||||
} from "@react-email/components";
|
||||
|
||||
const Onboarding3Email = () => {
|
||||
const previewText = `The document sharing infrastructure for the modern web`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Preview>The document sharing infrastructure for the modern web</Preview>
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Container className="mx-auto my-10 w-[465px] p-5">
|
||||
@@ -36,22 +34,17 @@ const Onboarding3Email = () => {
|
||||
With Papermark you can track progress on each page of your
|
||||
document and other analytics:
|
||||
</Text>
|
||||
<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">
|
||||
<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">
|
||||
<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`}
|
||||
@@ -66,14 +59,8 @@ const Onboarding3Email = () => {
|
||||
<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>
|
||||
© {new Date().getFullYear()} Papermark, Inc. All rights
|
||||
reserved.
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
If you have any feedback or questions about this email, simply
|
||||
|
||||
@@ -33,22 +33,19 @@ const Onboarding4Email = () => {
|
||||
Look professional with custom branding!
|
||||
</Text>
|
||||
<Text className="text-sm">With Papermark you can:</Text>
|
||||
<Text className="text-sm">
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
<li>
|
||||
Share documnets with your{" "}
|
||||
<strong>custom domain💫</strong>{" "}
|
||||
</li>
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
<li>
|
||||
Share documnets with your <strong>custom domain💫</strong>{" "}
|
||||
</li>
|
||||
|
||||
<li>Remove "powered by Papermark"</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>
|
||||
<li>Remove "powered by Papermark"</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 className="text-sm">You can also use Bulk upload</Text> */}
|
||||
<Section className="mb-[32px] mt-[32px] text-center">
|
||||
@@ -76,7 +73,7 @@ const Onboarding4Email = () => {
|
||||
© {new Date().getFullYear()}{" "}
|
||||
<a
|
||||
href="https://www.papermark.com"
|
||||
className="text-gray-400 no-underline hover:text-gray-400"
|
||||
className="text-gray-400 no-underline"
|
||||
target="_blank"
|
||||
>
|
||||
papermark.com
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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'd love to
|
||||
hear from you!{" "}
|
||||
</Text>
|
||||
|
||||
{/* <Text className="text-xs">Stop this onboarding sequence. </Text> */}
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding3Email;
|
||||
@@ -0,0 +1,27 @@
|
||||
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'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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
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'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'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;
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
export default function TeamInvitation({
|
||||
senderName,
|
||||
senderEmail,
|
||||
teamName,
|
||||
url,
|
||||
url = "https://app.papermark.com",
|
||||
}: {
|
||||
senderName: string;
|
||||
senderEmail: string;
|
||||
@@ -59,23 +61,7 @@ export default function TeamInvitation({
|
||||
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
|
||||
{url.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">
|
||||
If you have any feedback or questions about this email, simply
|
||||
reply to it.
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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'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;
|
||||
@@ -1,98 +0,0 @@
|
||||
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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrialEndFinalReminderEmail;
|
||||
@@ -1,97 +0,0 @@
|
||||
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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrialEndReminderEmail;
|
||||
@@ -0,0 +1,39 @@
|
||||
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's working well and what could we improve?
|
||||
</Text>
|
||||
<Text>Marc</Text>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeCheckinEmail;
|
||||
@@ -0,0 +1,42 @@
|
||||
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'm Iuliia, co-founder of Papermark. Thanks for upgrading!
|
||||
I'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;
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
interface UpgradePlanEmailProps {
|
||||
name: string | null | undefined;
|
||||
planType: string;
|
||||
@@ -21,10 +23,18 @@ interface UpgradePlanEmailProps {
|
||||
|
||||
const UpgradePlanEmail = ({
|
||||
name,
|
||||
planType = "pro",
|
||||
planType = "datarooms-plus",
|
||||
}: 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",
|
||||
@@ -52,8 +62,7 @@ const UpgradePlanEmail = ({
|
||||
],
|
||||
};
|
||||
|
||||
const planTypeText = planType.toLowerCase();
|
||||
const planFeatures = features[planTypeText];
|
||||
const planFeatures = features[planType];
|
||||
|
||||
return (
|
||||
<Html>
|
||||
@@ -66,30 +75,18 @@ 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 {planType}!
|
||||
Thanks for upgrading to Papermark {planTypeText}!
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
Hey{name && ` ${name}`}!
|
||||
</Text>
|
||||
<Text className="text-sm">
|
||||
My name is Marc, and I'm the founder of Papermark. I wanted
|
||||
to personally reach out to thank you for upgrading to Papermark{" "}
|
||||
{planType}!
|
||||
Marc is here. I wanted to personally reach out to thank you for
|
||||
upgrading to Papermark {planTypeText}!
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
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:
|
||||
On the {planTypeText} plan, you now have access to:
|
||||
</Text>
|
||||
{planFeatures?.map(
|
||||
(feature: string, index: number) => (
|
||||
@@ -108,9 +105,7 @@ const UpgradePlanEmail = ({
|
||||
style={{ padding: "12px 20px" }}
|
||||
>
|
||||
Share your{" "}
|
||||
{planTypeText.includes("datarooms")
|
||||
? "data rooms"
|
||||
: "documents"}
|
||||
{planType.includes("datarooms") ? "data rooms" : "documents"}
|
||||
</Button>
|
||||
</Section>
|
||||
<Section>
|
||||
@@ -120,23 +115,7 @@ const UpgradePlanEmail = ({
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
|
||||
</Section>
|
||||
<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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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's been your biggest win using Papermark?</Text>
|
||||
<Text>
|
||||
Marc here. It'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;
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
@@ -12,6 +11,8 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
interface ConfirmEmailChangeProps {
|
||||
email: string;
|
||||
newEmail: string;
|
||||
@@ -19,8 +20,8 @@ interface ConfirmEmailChangeProps {
|
||||
}
|
||||
|
||||
export function ConfirmEmailChange({
|
||||
email,
|
||||
newEmail,
|
||||
email = "email@example.com",
|
||||
newEmail = "new@example.com",
|
||||
confirmUrl = "https://www.papermark.com",
|
||||
}: ConfirmEmailChangeProps) {
|
||||
return (
|
||||
@@ -60,25 +61,17 @@ export function ConfirmEmailChange({
|
||||
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
|
||||
{confirmUrl.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>
|
||||
<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>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
const VerificationLinkEmail = ({
|
||||
url = "https://www.papermark.com",
|
||||
}: {
|
||||
@@ -50,23 +51,7 @@ const VerificationLinkEmail = ({
|
||||
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
|
||||
{url.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">
|
||||
If you have any feedback or questions about this email, simply
|
||||
reply to it. I'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
export default function ViewedDataroom({
|
||||
dataroomId = "123",
|
||||
dataroomName = "Example Dataroom",
|
||||
@@ -67,30 +68,19 @@ export default function ViewedDataroom({
|
||||
See my dataroom insights
|
||||
</Button>
|
||||
</Section>
|
||||
<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'd love to hear from you!
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
To stop email notifications for this link, edit the link and
|
||||
uncheck "Receive email notification".
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Footer
|
||||
footerText={
|
||||
<>
|
||||
If you have any feedback or questions about this email, simply
|
||||
reply to it. I'd love to hear from you!
|
||||
<br />
|
||||
<br />
|
||||
To stop email notifications for this link, edit the link and
|
||||
uncheck "Receive email notification".
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
export default function ViewedDocument({
|
||||
documentId = "123",
|
||||
documentName = "Pitchdeck",
|
||||
@@ -67,30 +68,18 @@ export default function ViewedDocument({
|
||||
See my document insights
|
||||
</Button>
|
||||
</Section>
|
||||
<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'd love to hear from you!
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
To stop email notifications for this link, edit the link and
|
||||
uncheck "Receive email notification".
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer
|
||||
footerText={
|
||||
<>
|
||||
If you have any feedback or questions about this email, simply
|
||||
reply to it. I'd love to hear from you!
|
||||
<br />
|
||||
<br />
|
||||
To stop email notifications for this link, edit the link and
|
||||
uncheck "Receive email notification".
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./shared/footer";
|
||||
|
||||
interface WelcomeEmailProps {
|
||||
name: string | null | undefined;
|
||||
}
|
||||
@@ -36,25 +38,23 @@ const WelcomeEmail = ({ name }: WelcomeEmailProps) => {
|
||||
Thanks for signing up{name && `, ${name}`}!
|
||||
</Text>
|
||||
<Text className="text-sm">
|
||||
My name is Marc, and I'm the creator of Papermark – the
|
||||
open-source DocSend alternative! I'm excited to have you on
|
||||
board!
|
||||
My name is Marc, and I'm the founder of Papermark – the
|
||||
secure way to share documents and data rooms. I'm excited to
|
||||
have you on board!
|
||||
</Text>
|
||||
<Text className="text-sm">
|
||||
Here are a few things you can do to get started:
|
||||
</Text>
|
||||
<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">
|
||||
<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">
|
||||
<Button
|
||||
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
|
||||
href={`${process.env.NEXT_PUBLIC_BASE_URL}/welcome`}
|
||||
@@ -63,62 +63,49 @@ 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. I'm always happy to help!
|
||||
email.{" "}
|
||||
<Link
|
||||
href="https://cal.link/papermark"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Book a call
|
||||
</Link>{" "}
|
||||
to discuss your enterprise needs. I'm always happy to help!
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
|
||||
</Section>
|
||||
<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>
|
||||
<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>
|
||||
</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 hover:text-gray-400"
|
||||
className="text-gray-400 no-underline"
|
||||
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 hover:text-gray-400"
|
||||
className="text-gray-400 underline underline-offset-2"
|
||||
>
|
||||
click here
|
||||
</a>
|
||||
|
||||
@@ -142,6 +142,7 @@ export function AddFolderModal({
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger={"add_folder_button"}
|
||||
highlightItem={["folder", "multi-file"]}
|
||||
>
|
||||
{children}
|
||||
</UpgradePlanModal>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -165,7 +165,10 @@ 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";
|
||||
@@ -220,6 +223,8 @@ 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":
|
||||
|
||||
@@ -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, group permissions,
|
||||
and data room. <br />
|
||||
trial, you have access to advanced access controls, granular file
|
||||
permissions, and data room. <br />
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.DataRooms}
|
||||
trigger={"trial_navbar"}
|
||||
@@ -94,4 +94,4 @@ function TrialBannerComponent({
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ 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";
|
||||
|
||||
@@ -50,7 +52,7 @@ export default function AgreementSheet({
|
||||
isOnlyView = false,
|
||||
onClose,
|
||||
}: {
|
||||
defaultData?: { name: string; link: string; requireName: boolean } | null;
|
||||
defaultData?: { name: string; link: string; requireName: boolean; contentType?: string; textContent?: string } | null;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
isOnlyView?: boolean;
|
||||
@@ -58,7 +60,13 @@ export default function AgreementSheet({
|
||||
}) {
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
const [data, setData] = useState({ name: "", link: "", requireName: true });
|
||||
const [data, setData] = useState({
|
||||
name: "",
|
||||
link: "",
|
||||
textContent: "",
|
||||
contentType: "LINK",
|
||||
requireName: true
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
||||
|
||||
@@ -71,6 +79,8 @@ export default function AgreementSheet({
|
||||
setData({
|
||||
name: defaultData?.name || "",
|
||||
link: defaultData?.link || "",
|
||||
textContent: defaultData?.textContent || "",
|
||||
contentType: defaultData?.contentType || "LINK",
|
||||
requireName: defaultData?.requireName || true,
|
||||
});
|
||||
}
|
||||
@@ -78,7 +88,13 @@ export default function AgreementSheet({
|
||||
|
||||
const handleClose = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
setData({ name: "", link: "", requireName: true });
|
||||
setData({
|
||||
name: "",
|
||||
link: "",
|
||||
textContent: "",
|
||||
contentType: "LINK",
|
||||
requireName: true
|
||||
});
|
||||
setCurrentFile(null);
|
||||
setIsLoading(false);
|
||||
if (onClose) {
|
||||
@@ -184,13 +200,22 @@ export default function AgreementSheet({
|
||||
return;
|
||||
}
|
||||
|
||||
// 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");
|
||||
// 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");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -198,14 +223,19 @@ 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({
|
||||
...data,
|
||||
}),
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -220,7 +250,13 @@ export default function AgreementSheet({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsOpen(false);
|
||||
setData({ name: "", link: "", requireName: true });
|
||||
setData({
|
||||
name: "",
|
||||
link: "",
|
||||
textContent: "",
|
||||
contentType: "LINK",
|
||||
requireName: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -281,56 +317,102 @@ export default function AgreementSheet({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Content Type Selection */}
|
||||
<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" // 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);
|
||||
}}
|
||||
<Label>Agreement Content Type</Label>
|
||||
<RadioGroup
|
||||
value={data.contentType}
|
||||
onValueChange={(value) => setData({...data, contentType: value})}
|
||||
disabled={isOnlyView}
|
||||
/>
|
||||
{/* Display validation error */}
|
||||
{urlError && (
|
||||
<p className="mt-1 text-sm text-red-500">{urlError}</p>
|
||||
)}
|
||||
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>
|
||||
</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}
|
||||
/>
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter
|
||||
@@ -345,7 +427,10 @@ export default function AgreementSheet({
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
disabled={!isUrlValid && data.link.trim() !== ""}
|
||||
disabled={
|
||||
(data.contentType === "LINK" && !isUrlValid && data.link.trim() !== "") ||
|
||||
(data.contentType === "TEXT" && !data.textContent.trim())
|
||||
}
|
||||
>
|
||||
Create Agreement
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import ConversationSection from "@/ee/features/conversations/components/link-option-conversation-section";
|
||||
import ConversationSection from "@/ee/features/conversations/components/dashboard/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,
|
||||
"PHONE_NUMBER" | "CHECKBOX" | "SELECT" | "MULTI_SELECT"
|
||||
"CHECKBOX" | "SELECT" | "MULTI_SELECT"
|
||||
>;
|
||||
};
|
||||
|
||||
|
||||
@@ -235,6 +235,8 @@ export default function LinkSheet({
|
||||
if (!preset) return;
|
||||
|
||||
setData((prev) => {
|
||||
const isGroupLink = prev.audienceType === LinkAudienceType.GROUP;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
name: prev.name, // Keep existing name
|
||||
@@ -243,8 +245,13 @@ export default function LinkSheet({
|
||||
emailProtected: preset.emailProtected ?? prev.emailProtected,
|
||||
emailAuthenticated:
|
||||
preset.emailAuthenticated ?? prev.emailAuthenticated,
|
||||
allowList: preset.allowList || prev.allowList,
|
||||
denyList: preset.denyList || prev.denyList,
|
||||
// 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,
|
||||
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-y-scroll">
|
||||
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
|
||||
{dataroomId && dataroomId !== "all_documents" ? (
|
||||
<DataroomFolderTree
|
||||
dataroomId={dataroomId}
|
||||
@@ -115,7 +115,9 @@ function FolderSelectionModal({
|
||||
) : (
|
||||
<>
|
||||
Select{" "}
|
||||
<span className="font-medium">{selectedFolder.name}</span>
|
||||
<span className="max-w-[200px] truncate font-medium">
|
||||
{selectedFolder.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -18,6 +18,7 @@ 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";
|
||||
@@ -105,7 +106,7 @@ export default function LinksTable({
|
||||
const now = Date.now();
|
||||
const router = useRouter();
|
||||
const { isFree, isTrial } = usePlan();
|
||||
const teamInfo = useTeam();
|
||||
const { currentTeamId } = useTeam();
|
||||
const { id: targetId, groupId } = router.query as {
|
||||
id: string;
|
||||
groupId?: string;
|
||||
@@ -267,7 +268,7 @@ export default function LinksTable({
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
teamId: teamInfo?.currentTeam?.id,
|
||||
teamId: currentTeamId,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -280,7 +281,7 @@ export default function LinksTable({
|
||||
|
||||
// Update the duplicated link in the list of links
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
||||
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
|
||||
link.documentId ?? link.dataroomId ?? "",
|
||||
)}/links`,
|
||||
(links || []).concat(duplicatedLink),
|
||||
@@ -292,7 +293,7 @@ export default function LinksTable({
|
||||
const groupLinks =
|
||||
links?.filter((link) => link.groupId === groupId) || [];
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
||||
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
|
||||
duplicatedLink.documentId ?? duplicatedLink.dataroomId ?? "",
|
||||
)}/groups/${duplicatedLink.groupId}/links`,
|
||||
groupLinks.concat(duplicatedLink),
|
||||
@@ -316,8 +317,15 @@ 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/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
|
||||
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
@@ -331,8 +339,8 @@ export default function LinksTable({
|
||||
// Refresh the links cache
|
||||
const endpointTargetType = `${targetType.toLowerCase()}s`;
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
||||
targetId,
|
||||
`/api/teams/${teamIdParsed}/${endpointTargetType}/${encodeURIComponent(
|
||||
targetIdParsed,
|
||||
)}/links`,
|
||||
(currentLinks: LinkWithViews[] | undefined) =>
|
||||
(currentLinks || []).map((link: LinkWithViews) =>
|
||||
@@ -345,7 +353,7 @@ export default function LinksTable({
|
||||
|
||||
// Invalidate the permission group cache
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
|
||||
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
|
||||
);
|
||||
|
||||
setShowPermissionsSheet(false);
|
||||
@@ -361,8 +369,10 @@ 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/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups`,
|
||||
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -386,7 +396,7 @@ export default function LinksTable({
|
||||
// Refresh the links cache
|
||||
const endpointTargetType = `${targetType.toLowerCase()}s`;
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
||||
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
|
||||
targetId,
|
||||
)}/links`,
|
||||
);
|
||||
@@ -394,7 +404,7 @@ export default function LinksTable({
|
||||
// Cache the new permission group data
|
||||
if (newPermissionGroup?.id) {
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,
|
||||
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,
|
||||
newPermissionGroup,
|
||||
false,
|
||||
);
|
||||
@@ -412,8 +422,15 @@ 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/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
|
||||
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -434,7 +451,7 @@ export default function LinksTable({
|
||||
// Refresh the links cache
|
||||
const endpointTargetType = `${targetType.toLowerCase()}s`;
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
||||
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
|
||||
targetId,
|
||||
)}/links`,
|
||||
);
|
||||
@@ -442,7 +459,7 @@ export default function LinksTable({
|
||||
// Invalidate the permission group cache
|
||||
if (editPermissionLink.permissionGroupId) {
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
|
||||
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -502,7 +519,7 @@ export default function LinksTable({
|
||||
|
||||
// Update the archived link in the list of links
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
||||
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
|
||||
targetId,
|
||||
)}/links`,
|
||||
(links || []).map((link) => (link.id === linkId ? archivedLink : link)),
|
||||
@@ -514,7 +531,7 @@ export default function LinksTable({
|
||||
const groupLinks =
|
||||
links?.filter((link) => link.groupId === groupId) || [];
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
||||
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
|
||||
archivedLink.documentId ?? archivedLink.dataroomId ?? "",
|
||||
)}/groups/${groupId}/links`,
|
||||
groupLinks.map((link) => (link.id === linkId ? archivedLink : link)),
|
||||
@@ -969,4 +986,4 @@ export default function LinksTable({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,12 +137,15 @@ 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,
|
||||
},
|
||||
undefined,
|
||||
isCustomDomain ? `/${router.query.slug}` : undefined, // Preserve custom domain URL
|
||||
{ shallow: true },
|
||||
);
|
||||
}, [debouncedValue]);
|
||||
@@ -164,4 +167,4 @@ export function SearchBoxPersisted({
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export function SettingsHeader() {
|
||||
const { data: features } = useSWR<{
|
||||
tokens: boolean;
|
||||
incomingWebhooks: boolean;
|
||||
slack: boolean;
|
||||
}>(
|
||||
teamInfo?.currentTeam?.id
|
||||
? `/api/feature-flags?teamId=${teamInfo.currentTeam.id}`
|
||||
@@ -67,6 +68,12 @@ export function SettingsHeader() {
|
||||
href: `/settings/webhooks`,
|
||||
segment: "webhooks",
|
||||
},
|
||||
{
|
||||
label: "Slack",
|
||||
href: `/settings/slack`,
|
||||
segment: "slack",
|
||||
disabled: !features?.slack,
|
||||
},
|
||||
{
|
||||
label: "Tokens",
|
||||
href: `/settings/tokens`,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -150,6 +150,11 @@ 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,12 +1,13 @@
|
||||
"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 { buttonVariants } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
@@ -134,7 +135,63 @@ 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
Reference in New Issue
Block a user