Compare commits
162
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d052a66bd | ||
|
|
a856420483 | ||
|
|
b797cb982e | ||
|
|
37f0a0c260 | ||
|
|
35b66ffcbf | ||
|
|
297a81326e | ||
|
|
8c08d6c99c | ||
|
|
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 | ||
|
|
78554ded87 | ||
|
|
d335c7f1f7 | ||
|
|
67550162f2 | ||
|
|
e7e01dc6f0 | ||
|
|
6a604d7a13 | ||
|
|
615ac0220f | ||
|
|
029d765f5d | ||
|
|
0cdc74258f | ||
|
|
c2182a6042 | ||
|
|
e219c53739 | ||
|
|
1bb7e79a37 | ||
|
|
bf7c9ff5d4 | ||
|
|
c652ad607b | ||
|
|
141def2c54 | ||
|
|
a923ce6dfb | ||
|
|
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
|
||||
|
||||
All versions of Papermark are 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -98,6 +99,8 @@ export async function POST(request: NextRequest) {
|
||||
id: linkId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
documentId: true,
|
||||
dataroomId: true,
|
||||
emailProtected: true,
|
||||
@@ -286,6 +289,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 +303,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 +321,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 +365,7 @@ export async function POST(request: NextRequest) {
|
||||
: false;
|
||||
|
||||
if (!isMember && !hasDomainAccess) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
|
||||
+11
-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]";
|
||||
@@ -19,7 +20,7 @@ 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 +85,9 @@ export async function POST(request: NextRequest) {
|
||||
id: linkId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
documentId: true,
|
||||
emailProtected: true,
|
||||
enableNotification: true,
|
||||
emailAuthenticated: true,
|
||||
@@ -221,6 +225,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 +239,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 +257,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 },
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -7,30 +7,39 @@ import { getColorForVersion, timeFormatter } from "./utils";
|
||||
const CustomTooltip = ({
|
||||
payload,
|
||||
active,
|
||||
documentId: propDocumentId,
|
||||
}: {
|
||||
payload: any;
|
||||
active: boolean | undefined;
|
||||
documentId?: string;
|
||||
}) => {
|
||||
const router = useRouter(); // Call useRouter at the top level
|
||||
const documentId = router.query.id as string;
|
||||
// Use prop documentId if provided, otherwise fall back to router
|
||||
const documentId = propDocumentId || (router.query.id as string);
|
||||
|
||||
// Default pageNumber to 0 or a sensible default if payload is not available
|
||||
const pageNumber =
|
||||
payload && payload.length > 0 ? parseInt(payload[0].payload.pageNumber) : 0;
|
||||
// Safely extract pageNumber and versionNumber with validation
|
||||
const pageNumberStr =
|
||||
payload && payload.length > 0 ? payload[0]?.payload?.pageNumber : null;
|
||||
|
||||
// Default versionNumber to 0 or a sensible default if payload is not available
|
||||
const versionNumber =
|
||||
payload && payload.length > 0
|
||||
? parseInt(payload[0].payload.versionNumber)
|
||||
: 1;
|
||||
const { data, error } = useDocumentThumbnail(
|
||||
pageNumber,
|
||||
documentId,
|
||||
versionNumber,
|
||||
const pageNumber = pageNumberStr ? parseInt(pageNumberStr, 10) : 0;
|
||||
// For thumbnails, don't specify version number - let the API use the primary/latest version
|
||||
const versionNumber = undefined;
|
||||
|
||||
// Only fetch thumbnail if we have valid documentId and pageNumber
|
||||
const shouldFetchThumbnail =
|
||||
documentId && pageNumber > 0 && !isNaN(pageNumber);
|
||||
|
||||
// Always call the hook - this is required by React rules of hooks
|
||||
const { data, error, loading } = useDocumentThumbnail(
|
||||
shouldFetchThumbnail ? pageNumber : 0, // Pass 0 to disable the hook if invalid
|
||||
documentId || "",
|
||||
versionNumber, // undefined means use primary/latest version
|
||||
);
|
||||
|
||||
const imageUrl = data && !error ? data.imageUrl : null; // Always called, regardless of `active` or `payload`
|
||||
const imageUrl =
|
||||
shouldFetchThumbnail && data && !error ? data.imageUrl : null;
|
||||
|
||||
// Early return after hooks are called
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -38,34 +47,64 @@ const CustomTooltip = ({
|
||||
<div className="w-52 rounded-md border border-tremor-border bg-tremor-background text-sm leading-6 dark:border-dark-tremor-border dark:bg-dark-tremor-background">
|
||||
<div className="rounded-t-md border-b border-tremor-border bg-tremor-background px-2.5 py-2 dark:border-dark-tremor-border dark:bg-dark-tremor-background">
|
||||
<p className="font-medium text-tremor-content dark:text-dark-tremor-content">
|
||||
Page {payload[0].payload.pageNumber}
|
||||
Page {pageNumber || "Unknown"}
|
||||
</p>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`Page ${payload[0].payload.pageNumber} Thumbnail`}
|
||||
/>
|
||||
) : null}
|
||||
{shouldFetchThumbnail && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="mt-2 flex items-center justify-center p-4">
|
||||
<div className="text-xs text-tremor-content-subtle dark:text-dark-tremor-content-subtle">
|
||||
Loading thumbnail...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{imageUrl && !loading && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`Page ${pageNumber} Thumbnail`}
|
||||
className="mt-2 max-w-full"
|
||||
onError={(e) => {
|
||||
// Hide image if it fails to load
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="mt-2 text-xs text-tremor-content-subtle dark:text-dark-tremor-content-subtle">
|
||||
Thumbnail unavailable
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{payload.map((item: any, idx: number) => (
|
||||
<div
|
||||
className="flex w-full items-center justify-between space-x-4 px-2.5 py-2"
|
||||
key={idx}
|
||||
>
|
||||
<div className="text-overflow-ellipsis flex items-center space-x-2 overflow-hidden whitespace-nowrap">
|
||||
<span
|
||||
className={`bg-${getColorForVersion(item.dataKey)}-500 h-2.5 w-2.5 flex-shrink-0 rounded-full`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<p className="text-overflow-ellipsis overflow-hidden whitespace-nowrap text-tremor-content dark:text-dark-tremor-content">
|
||||
{item.dataKey}
|
||||
{payload
|
||||
.filter(
|
||||
(item: any) =>
|
||||
// Only show items that are not metadata fields
|
||||
item.dataKey !== "versionNumber" &&
|
||||
item.dataKey !== "pageNumber" &&
|
||||
typeof item.value === "number" &&
|
||||
item.value > 0,
|
||||
)
|
||||
.map((item: any, idx: number) => (
|
||||
<div
|
||||
className="flex w-full items-center justify-between space-x-4 px-2.5 py-2"
|
||||
key={idx}
|
||||
>
|
||||
<div className="text-overflow-ellipsis flex items-center space-x-2 overflow-hidden whitespace-nowrap">
|
||||
<span
|
||||
className={`bg-${getColorForVersion(item.dataKey)}-500 h-2.5 w-2.5 flex-shrink-0 rounded-full`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<p className="text-overflow-ellipsis overflow-hidden whitespace-nowrap text-tremor-content dark:text-dark-tremor-content">
|
||||
{item.dataKey}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-tremor-content-emphasis dark:text-dark-tremor-content-emphasis">
|
||||
{timeFormatter(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-tremor-content-emphasis dark:text-dark-tremor-content-emphasis">
|
||||
{timeFormatter(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -64,14 +64,21 @@ export default function BarChartComponent({
|
||||
isSum = false,
|
||||
isDummy = false,
|
||||
versionNumber,
|
||||
documentId,
|
||||
}: {
|
||||
data: any;
|
||||
isSum?: boolean;
|
||||
isDummy?: boolean;
|
||||
versionNumber?: number;
|
||||
documentId?: string;
|
||||
}) {
|
||||
const [, setValue] = useState<any>(null);
|
||||
|
||||
// Create a tooltip component that has access to documentId
|
||||
const TooltipWithDocumentId = (props: any) => (
|
||||
<CustomTooltip {...props} documentId={documentId} />
|
||||
);
|
||||
|
||||
if (isSum) {
|
||||
const renamedData = renameSumDurationKey(data, versionNumber);
|
||||
|
||||
@@ -86,7 +93,7 @@ export default function BarChartComponent({
|
||||
yAxisWidth={50}
|
||||
showGridLines={false}
|
||||
onValueChange={(v) => setValue(v)}
|
||||
customTooltip={isDummy ? undefined : CustomTooltip}
|
||||
customTooltip={TooltipWithDocumentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +119,7 @@ export default function BarChartComponent({
|
||||
yAxisWidth={50}
|
||||
showGridLines={false}
|
||||
onValueChange={(v) => setValue(v)}
|
||||
customTooltip={isDummy ? undefined : CustomTooltip}
|
||||
customTooltip={CustomTooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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,35 @@ 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 validateNotionPageURL = parsePageId(notionLink);
|
||||
|
||||
// If parsePageId fails, try to get page ID from slug
|
||||
if (validateNotionPageURL === null) {
|
||||
try {
|
||||
validateNotionPageURL = await getNotionPageIdFromSlug(notionLink);
|
||||
} catch (slugError) {
|
||||
toast.error("Please enter a valid Notion link to proceed.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateNotionPageURL) {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import BarChartComponent from "../charts/bar-chart";
|
||||
|
||||
export default function StatsChartDummy({
|
||||
totalPagesMax = 0,
|
||||
}: {
|
||||
totalPagesMax?: number;
|
||||
}) {
|
||||
let durationData = Array.from({ length: totalPagesMax }, (_, i) => ({
|
||||
pageNumber: (i + 1).toString(),
|
||||
data: [
|
||||
{
|
||||
versionNumber: 1,
|
||||
avg_duration: 16000 / (i + 1),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="rounded-bl-lg border-b border-l pb-0.5 pl-0.5 md:pb-1 md:pl-1">
|
||||
<BarChartComponent data={durationData} isDummy />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import ErrorPage from "next/error";
|
||||
import { TStatsData } from "@/lib/swr/use-stats";
|
||||
|
||||
import BarChartComponent from "../charts/bar-chart";
|
||||
import StatsChartDummy from "./stats-chart-dummy";
|
||||
import StatsChartSkeleton from "./stats-chart-skeleton";
|
||||
|
||||
export default function StatsChart({
|
||||
@@ -36,8 +35,9 @@ export default function StatsChart({
|
||||
}));
|
||||
|
||||
const swrData = stats?.duration;
|
||||
const hasRealData = stats && stats.views.length > 0;
|
||||
|
||||
if (swrData) {
|
||||
if (swrData && hasRealData) {
|
||||
swrData.data.forEach((dataItem) => {
|
||||
const pageIndex = durationData.findIndex(
|
||||
(item) => item.pageNumber === dataItem.pageNumber,
|
||||
@@ -81,11 +81,28 @@ export default function StatsChart({
|
||||
);
|
||||
}
|
||||
|
||||
return stats && stats.views.length > 0 ? (
|
||||
// If no real data, generate dummy data with some sample durations
|
||||
if (!hasRealData) {
|
||||
durationData = Array.from({ length: totalPagesMax }, (_, i) => ({
|
||||
pageNumber: (i + 1).toString(),
|
||||
data: [
|
||||
{
|
||||
versionNumber: 1,
|
||||
avg_duration: 16000 / (i + 1), // Decreasing duration like the original dummy
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
const isDummy = !hasRealData;
|
||||
|
||||
return (
|
||||
<div className="rounded-bl-lg border-b border-l pb-0.5 pl-0.5 md:pb-1 md:pl-1">
|
||||
<BarChartComponent data={durationData} />
|
||||
<BarChartComponent
|
||||
data={durationData}
|
||||
documentId={documentId}
|
||||
isDummy={isDummy}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<StatsChartDummy totalPagesMax={totalPagesMax} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { LockIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { UpgradeButton } from "@/components/ui/upgrade-button";
|
||||
|
||||
interface FeaturePreviewProps {
|
||||
/**
|
||||
@@ -68,7 +67,7 @@ export function FeaturePreview({
|
||||
trigger,
|
||||
children,
|
||||
className,
|
||||
upgradeButtonText = "Upgrade to unlock",
|
||||
upgradeButtonText = "Unlock",
|
||||
}: FeaturePreviewProps) {
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
@@ -91,11 +90,13 @@ export function FeaturePreview({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<UpgradePlanModal clickedPlan={requiredPlan} trigger={trigger}>
|
||||
<Button size="lg" className="w-full">
|
||||
{upgradeButtonText}
|
||||
</Button>
|
||||
</UpgradePlanModal>
|
||||
<UpgradeButton
|
||||
text={upgradeButtonText}
|
||||
clickedPlan={requiredPlan}
|
||||
trigger={trigger}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -120,7 +120,7 @@ const Folder = memo<FolderProps>(
|
||||
<div
|
||||
title={name}
|
||||
className={cn(
|
||||
"inline-flex w-full cursor-pointer items-center",
|
||||
"inline-flex w-full cursor-pointer items-center overflow-hidden",
|
||||
"rounded-md text-foreground duration-100 hover:bg-gray-100 hover:dark:bg-muted",
|
||||
"px-3 py-1.5 leading-6",
|
||||
active && "bg-gray-100 font-semibold dark:bg-muted",
|
||||
@@ -147,7 +147,10 @@ const Folder = memo<FolderProps>(
|
||||
<FolderIcon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<span
|
||||
className="ml-2 w-fit truncate"
|
||||
className="ml-2 truncate whitespace-nowrap"
|
||||
style={{
|
||||
maxWidth: `${Math.max(150, 300 - indent * 30)}px`,
|
||||
}}
|
||||
title={(label ?? name) as string}
|
||||
>
|
||||
{label ?? name}
|
||||
@@ -165,6 +168,7 @@ const Folder = memo<FolderProps>(
|
||||
Folder.displayName = "Folder";
|
||||
|
||||
const File = memo<FileProps>(({ label, name, active, onToggle }) => {
|
||||
const indent = useIndent();
|
||||
const toggle = useCallback(() => {
|
||||
onToggle?.(!active);
|
||||
}, [onToggle]);
|
||||
@@ -179,12 +183,18 @@ const File = memo<FileProps>(({ label, name, active, onToggle }) => {
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="ml-5 inline-flex cursor-default items-center"
|
||||
className="ml-5 inline-flex w-full cursor-default items-center overflow-hidden"
|
||||
onClick={toggle}
|
||||
>
|
||||
<Ident />
|
||||
<FileIcon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<span className="ml-2 w-fit truncate" title={(label ?? name) as string}>
|
||||
<span
|
||||
className="ml-2 truncate whitespace-nowrap"
|
||||
style={{
|
||||
maxWidth: `${Math.max(150, 280 - indent * 30)}px`,
|
||||
}}
|
||||
title={(label ?? name) as string}
|
||||
>
|
||||
{label ?? name}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { E164Number } from "libphonenumber-js";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { ChevronDownIcon, PhoneIcon } from "lucide-react";
|
||||
import * as RPNInput from "react-phone-number-input";
|
||||
import flags from "react-phone-number-input/flags";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Input, InputProps } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ScrollArea } from "./scroll-area";
|
||||
import { Input, InputProps } from "@/components/ui/input";
|
||||
|
||||
type PhoneInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
@@ -39,7 +23,8 @@ const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
|
||||
return (
|
||||
<RPNInput.default
|
||||
ref={ref}
|
||||
className={cn("flex", className)}
|
||||
className={cn("shadow-xs flex rounded-md", className)}
|
||||
international
|
||||
flagComponent={FlagComponent}
|
||||
countrySelectComponent={CountrySelect}
|
||||
inputComponent={InputComponent}
|
||||
@@ -63,7 +48,17 @@ PhoneInput.displayName = "PhoneInput";
|
||||
const InputComponent = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<Input
|
||||
className={cn("rounded-e-lg rounded-s-none", className)}
|
||||
data-slot="phone-input"
|
||||
className={cn(
|
||||
"-ms-px rounded-s-none border-0 bg-transparent py-1.5 shadow-sm ring-1 ring-inset ring-gray-600 focus:ring-2 focus:ring-inset focus:ring-gray-300 focus-visible:z-10",
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
backgroundColor: "var(--phone-input-bg)",
|
||||
color: "var(--phone-input-color)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -71,13 +66,11 @@ const InputComponent = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
);
|
||||
InputComponent.displayName = "InputComponent";
|
||||
|
||||
type CountrySelectOption = { label: string; value: RPNInput.Country };
|
||||
|
||||
type CountrySelectProps = {
|
||||
disabled?: boolean;
|
||||
value: RPNInput.Country;
|
||||
onChange: (value: RPNInput.Country) => void;
|
||||
options: CountrySelectOption[];
|
||||
options: { label: string; value: RPNInput.Country | undefined }[];
|
||||
};
|
||||
|
||||
const CountrySelect = ({
|
||||
@@ -86,70 +79,47 @@ const CountrySelect = ({
|
||||
onChange,
|
||||
options,
|
||||
}: CountrySelectProps) => {
|
||||
const handleSelect = React.useCallback(
|
||||
(country: RPNInput.Country) => {
|
||||
onChange(country);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
const handleSelect = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onChange(event.target.value as RPNInput.Country);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"outline"}
|
||||
className={cn("flex gap-1 rounded-e-none rounded-s-lg px-3")}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FlagComponent country={value} countryName={value} />
|
||||
<ChevronsUpDown
|
||||
className={cn(
|
||||
"-mr-2 h-4 w-4 opacity-50",
|
||||
disabled ? "hidden" : "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<ScrollArea className="h-72">
|
||||
<CommandInput placeholder="Search country..." />
|
||||
<CommandEmpty>No country found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options
|
||||
.filter((x) => x.value)
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
className="gap-2"
|
||||
key={option.value}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
>
|
||||
<FlagComponent
|
||||
country={option.value}
|
||||
countryName={option.label}
|
||||
/>
|
||||
<span className="flex-1 text-sm">{option.label}</span>
|
||||
{option.value && (
|
||||
<span className="text-sm text-foreground/50">
|
||||
{`+${RPNInput.getCountryCallingCode(option.value)}`}
|
||||
</span>
|
||||
)}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
option.value === value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div
|
||||
className="has-aria-invalid:border-destructive/60 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-disabled:pointer-events-none has-disabled:opacity-50 relative inline-flex h-9 items-center self-stretch rounded-s-md border-0 bg-transparent py-2 pe-2 ps-3 text-muted-foreground shadow-sm outline-none ring-1 ring-inset ring-gray-600 transition-[color,box-shadow] focus-within:z-10 focus-within:ring-2 focus-within:ring-inset focus-within:ring-gray-300 hover:text-foreground"
|
||||
style={
|
||||
{
|
||||
backgroundColor: "var(--phone-input-bg)",
|
||||
color: "var(--phone-input-color)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1" aria-hidden="true">
|
||||
<FlagComponent country={value} countryName={value} aria-hidden="true" />
|
||||
<span className="text-muted-foreground/80">
|
||||
<ChevronDownIcon size={16} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={handleSelect}
|
||||
className="absolute inset-0 text-sm opacity-0"
|
||||
aria-label="Select country"
|
||||
>
|
||||
<option key="default" value="">
|
||||
Select a country
|
||||
</option>
|
||||
{options
|
||||
.filter((x) => x.value)
|
||||
.map((option, i) => (
|
||||
<option key={option.value ?? `empty-${i}`} value={option.value}>
|
||||
{option.label}{" "}
|
||||
{option.value &&
|
||||
`+${RPNInput.getCountryCallingCode(option.value)}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -157,8 +127,12 @@ const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
|
||||
const Flag = flags[country];
|
||||
|
||||
return (
|
||||
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20">
|
||||
{Flag && <Flag title={countryName} />}
|
||||
<span className="w-5 overflow-hidden rounded-sm">
|
||||
{Flag ? (
|
||||
<Flag title={countryName} />
|
||||
) : (
|
||||
<PhoneIcon size={16} aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { cloneElement } from "react";
|
||||
|
||||
import { useBreakpoint } from "@/lib/hooks/use-breakpoint";
|
||||
|
||||
import { Button, ButtonProps } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface ResponsiveButtonProps extends ButtonProps {
|
||||
icon: React.ReactElement;
|
||||
text: string;
|
||||
breakpoint?: number;
|
||||
}
|
||||
|
||||
export const ResponsiveButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ResponsiveButtonProps
|
||||
>(({ icon, text, breakpoint = 1024, ...props }, ref) => {
|
||||
const isSmaller = useBreakpoint(breakpoint);
|
||||
|
||||
if (isSmaller) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button ref={ref} {...props}>
|
||||
{icon}
|
||||
<span className="sr-only">{text}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{text}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const iconWithAriaHidden = cloneElement(icon, {
|
||||
"aria-hidden": "true",
|
||||
});
|
||||
|
||||
return (
|
||||
<Button ref={ref} {...props}>
|
||||
{iconWithAriaHidden}
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
ResponsiveButton.displayName = "ResponsiveButton";
|
||||
+16
-15
@@ -106,22 +106,21 @@ export const BadgeTooltip = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const ButtonTooltip = ({
|
||||
content,
|
||||
sideOffset = 0,
|
||||
className,
|
||||
children,
|
||||
link,
|
||||
}: {
|
||||
content: string;
|
||||
sideOffset?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
link?: string;
|
||||
}) => {
|
||||
export const ButtonTooltip = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
content: string;
|
||||
sideOffset?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
link?: string;
|
||||
}
|
||||
>(({ content, sideOffset = 0, className, children, link }, ref) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipTrigger asChild ref={ref}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
sideOffset={sideOffset}
|
||||
@@ -149,7 +148,9 @@ export const ButtonTooltip = ({
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
ButtonTooltip.displayName = "ButtonTooltip";
|
||||
|
||||
export {
|
||||
Tooltip,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { ButtonHTMLAttributes, useState } from "react";
|
||||
|
||||
import { PlanEnum } from "@/ee/stripe/constants";
|
||||
import { CrownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface UpgradeButtonProps {
|
||||
text: string;
|
||||
clickedPlan: PlanEnum;
|
||||
trigger: string;
|
||||
variant?: "default" | "outline" | "ghost";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
className?: string;
|
||||
highlightItem?: string[];
|
||||
onClick?: () => void;
|
||||
useModal?: boolean;
|
||||
customText?: string;
|
||||
type?: ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function UpgradeButton({
|
||||
text,
|
||||
clickedPlan,
|
||||
trigger,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
className,
|
||||
highlightItem,
|
||||
onClick,
|
||||
useModal = true,
|
||||
customText,
|
||||
type = "button",
|
||||
disabled,
|
||||
}: UpgradeButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const buttonContent = (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn("gap-1.5", className)}
|
||||
onClick={onClick ? onClick : useModal ? () => setOpen(true) : undefined}
|
||||
>
|
||||
{customText ? (
|
||||
<>
|
||||
<CrownIcon className="h-4 w-4" /> {customText}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CrownIcon className="h-4 w-4" />
|
||||
Upgrade to {text}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!useModal || onClick || disabled) {
|
||||
return buttonContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{buttonContent}
|
||||
<UpgradePlanModal
|
||||
clickedPlan={clickedPlan}
|
||||
trigger={trigger}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
highlightItem={highlightItem}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function createUpgradeButton(
|
||||
text: string,
|
||||
clickedPlan: PlanEnum,
|
||||
trigger: string,
|
||||
options?: Partial<UpgradeButtonProps>,
|
||||
) {
|
||||
return function UpgradeButtonComponent(props?: Partial<UpgradeButtonProps>) {
|
||||
return (
|
||||
<UpgradeButton
|
||||
text={text}
|
||||
clickedPlan={clickedPlan}
|
||||
trigger={trigger}
|
||||
{...options}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
+10
-45
@@ -1,6 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { DocumentStorageType } from "@prisma/client";
|
||||
@@ -10,7 +10,11 @@ import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
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 { DocumentData, createDocument } from "@/lib/documents/create-document";
|
||||
import { resumableUpload } from "@/lib/files/tus-upload";
|
||||
import {
|
||||
@@ -33,46 +37,9 @@ import { getPagesCount } from "@/lib/utils/get-page-number-count";
|
||||
// Originally these mime values were directly used in the dropzone hook.
|
||||
// There was a solid reason to take them out of the scope, primarily to solve a browser compatibility issue to determine the file type when user dropped a folder.
|
||||
// you will figure out how this change helped to fix the compatibility issue once you have went through reading of `getFilesFromDropEvent` and `traverseFolder`
|
||||
const acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrail = {
|
||||
"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"
|
||||
};
|
||||
const allAcceptableDropZoneMimeTypes = {
|
||||
"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.openxmlformats-officedocument.wordprocessingml.document": [], // ".docx"
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
[], // ".pptx"
|
||||
"application/vnd.ms-powerpoint": [], // ".ppt"
|
||||
"application/msword": [], // ".doc"
|
||||
"application/vnd.oasis.opendocument.text": [], // ".odt"
|
||||
"application/vnd.oasis.opendocument.presentation": [], // ".odp"
|
||||
"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/webm": [], // ".webm"
|
||||
"video/quicktime": [], // ".mov"
|
||||
"video/x-msvideo": [], // ".avi"
|
||||
"video/ogg": [], // ".ogg"
|
||||
"application/vnd.google-earth.kml+xml": [".kml"], // ".kml"
|
||||
"application/vnd.google-earth.kmz": [".kmz"], // ".kmz"
|
||||
"application/vnd.ms-outlook": [".msg"], // ".msg"
|
||||
};
|
||||
const acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrial =
|
||||
FREE_PLAN_ACCEPTED_FILE_TYPES;
|
||||
const allAcceptableDropZoneMimeTypes = FULL_PLAN_ACCEPTED_FILE_TYPES;
|
||||
|
||||
interface FileWithPaths extends File {
|
||||
path?: string;
|
||||
@@ -134,8 +101,6 @@ export default function UploadZone({
|
||||
? limits?.documents - limits?.usage?.documents
|
||||
: 0;
|
||||
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
const uploadProgress = useRef<number[]>([]);
|
||||
|
||||
const fileSizeLimits = useMemo(
|
||||
@@ -150,7 +115,7 @@ export default function UploadZone({
|
||||
|
||||
const acceptableDropZoneFileTypes =
|
||||
isFree && !isTrial
|
||||
? acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrail
|
||||
? acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrial
|
||||
: allAcceptableDropZoneMimeTypes;
|
||||
|
||||
// this var will help to determine the correct api endpoint to request folder creation (If needed).
|
||||
|
||||
@@ -8,11 +8,77 @@ import { Button } from "@/components/ui/button";
|
||||
export const ScreenProtector = () => {
|
||||
const [blockScreen, setBlockScreen] = useState<boolean>(false);
|
||||
|
||||
// Key combinations for screenshot in Windows, Linux, and macOS
|
||||
useHotkeys("meta+shift, alt, printscreen", (event) => {
|
||||
// Comprehensive screenshot prevention for all major platforms
|
||||
const handleScreenshotAttempt = (event: KeyboardEvent) => {
|
||||
setBlockScreen(true);
|
||||
event.preventDefault();
|
||||
});
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// Windows screenshot shortcuts
|
||||
useHotkeys(
|
||||
[
|
||||
"printscreen", // PrintScreen key
|
||||
"alt+printscreen", // Alt + PrintScreen (active window)
|
||||
"meta+printscreen", // Win + PrintScreen (save to file)
|
||||
"meta+shift+s", // Win + Shift + S (Snipping Tool)
|
||||
"meta+g", // Win + G (Game Bar)
|
||||
],
|
||||
handleScreenshotAttempt,
|
||||
{
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
},
|
||||
);
|
||||
|
||||
// macOS screenshot shortcuts
|
||||
useHotkeys(
|
||||
[
|
||||
"meta+shift",
|
||||
"meta+shift+3", // Cmd + Shift + 3 (full screen)
|
||||
"meta+shift+4", // Cmd + Shift + 4 (selection)
|
||||
"meta+shift+5", // Cmd + Shift + 5 (screenshot utility)
|
||||
"meta+shift+4+space", // Cmd + Shift + 4 + Space (window)
|
||||
],
|
||||
handleScreenshotAttempt,
|
||||
{
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Linux screenshot shortcuts
|
||||
useHotkeys(
|
||||
[
|
||||
"shift+printscreen", // Shift + PrintScreen (selection in some Linux distros)
|
||||
"ctrl+alt+printscreen", // Ctrl + Alt + PrintScreen (some Linux distros)
|
||||
],
|
||||
handleScreenshotAttempt,
|
||||
{
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Developer tools that could be used for screenshots
|
||||
useHotkeys(
|
||||
[
|
||||
"f12", // F12 (Developer Tools)
|
||||
"ctrl+shift+i", // Ctrl + Shift + I (Developer Tools)
|
||||
"meta+alt+i", // Cmd + Option + I (macOS Developer Tools)
|
||||
"ctrl+shift+c", // Ctrl + Shift + C (Inspect Element)
|
||||
"meta+alt+c", // Cmd + Option + C (macOS Inspect Element)
|
||||
],
|
||||
handleScreenshotAttempt,
|
||||
{
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (blockScreen) {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Brand, CustomField, DataroomBrand } from "@prisma/client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { E164Number } from "libphonenumber-js";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PhoneInput } from "@/components/ui/phone-input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export default function CustomFieldsSection({
|
||||
fields,
|
||||
data,
|
||||
@@ -24,6 +26,10 @@ export default function CustomFieldsSection({
|
||||
setData({ ...data, [identifier]: e.target.value });
|
||||
};
|
||||
|
||||
const handlePhoneChange = (value: E164Number | null, identifier: string) => {
|
||||
setData({ ...data, [identifier]: value || "" });
|
||||
};
|
||||
|
||||
if (!fields?.length) return null;
|
||||
|
||||
return (
|
||||
@@ -33,7 +39,7 @@ export default function CustomFieldsSection({
|
||||
|
||||
const value = data[field.identifier] || "";
|
||||
const isLongText = field.type === "LONG_TEXT";
|
||||
const InputComponent = isLongText ? Textarea : Input;
|
||||
const isPhoneNumber = field.type === "PHONE_NUMBER";
|
||||
|
||||
return (
|
||||
<div key={field.identifier} className="relative space-y-2">
|
||||
@@ -46,42 +52,74 @@ export default function CustomFieldsSection({
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<InputComponent
|
||||
name={field.identifier || ""}
|
||||
id={field.id || ""}
|
||||
type={
|
||||
field.type === "NUMBER"
|
||||
? "number"
|
||||
: field.type === "URL"
|
||||
? "url"
|
||||
: "text"
|
||||
}
|
||||
pattern={field.type === "URL" ? "https://.*" : undefined}
|
||||
onInvalid={(e) => {
|
||||
if (field.type === "URL") {
|
||||
e.currentTarget.setCustomValidity(
|
||||
"Please enter a valid URL starting with https://",
|
||||
);
|
||||
{isPhoneNumber ? (
|
||||
<PhoneInput
|
||||
id={field.identifier}
|
||||
value={value as E164Number}
|
||||
onChange={(phoneValue) =>
|
||||
handlePhoneChange(phoneValue, field.identifier!)
|
||||
}
|
||||
}}
|
||||
onInput={(e) => e.currentTarget.setCustomValidity("")}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
className={cn(
|
||||
"flex w-full rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6",
|
||||
isLongText && "min-h-[100px] resize-none",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
brand && brand.accentColor ? brand.accentColor : "black",
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
value={value}
|
||||
placeholder={field.placeholder || ""}
|
||||
onChange={(e) => handleInputChange(e, field.identifier!)}
|
||||
/>
|
||||
placeholder={field.placeholder || "+1 123 456 7890"}
|
||||
defaultCountry="US"
|
||||
disabled={field.disabled}
|
||||
className={cn(
|
||||
"flex w-full rounded-md border-0 bg-black text-gray-500 placeholder:text-gray-400 sm:text-sm sm:leading-6",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--phone-input-bg":
|
||||
brand && brand.accentColor ? brand.accentColor : "black",
|
||||
"--phone-input-color": determineTextColor(
|
||||
brand?.accentColor,
|
||||
),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const InputComponent = isLongText ? Textarea : Input;
|
||||
return (
|
||||
<InputComponent
|
||||
name={field.identifier || ""}
|
||||
id={field.id || ""}
|
||||
type={
|
||||
field.type === "NUMBER"
|
||||
? "number"
|
||||
: field.type === "URL"
|
||||
? "url"
|
||||
: "text"
|
||||
}
|
||||
pattern={field.type === "URL" ? "https://.*" : undefined}
|
||||
onInvalid={(e) => {
|
||||
if (field.type === "URL") {
|
||||
e.currentTarget.setCustomValidity(
|
||||
"Please enter a valid URL starting with https://",
|
||||
);
|
||||
}
|
||||
}}
|
||||
onInput={(e) => e.currentTarget.setCustomValidity("")}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
className={cn(
|
||||
"flex w-full rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6",
|
||||
isLongText && "min-h-[100px] resize-none",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
brand && brand.accentColor
|
||||
? brand.accentColor
|
||||
: "black",
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
value={value}
|
||||
placeholder={field.placeholder || ""}
|
||||
onChange={(e) => handleInputChange(e, field.identifier!)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -132,7 +132,9 @@ export default function AccessForm({
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
>
|
||||
Your action is requested to continue
|
||||
{brand && "welcomeMessage" in brand && brand.welcomeMessage
|
||||
? brand.welcomeMessage
|
||||
: "Your action is requested to continue"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {
|
||||
ConversationSidebarProps,
|
||||
ConversationViewSidebar as ConversationViewSidebarEE,
|
||||
} from "@/ee/features/conversations/components/conversation-view-sidebar";
|
||||
} from "@/ee/features/conversations/components/viewer/conversation-view-sidebar";
|
||||
|
||||
export function ConversationSidebar(props: ConversationSidebarProps) {
|
||||
return <ConversationViewSidebarEE {...props} />;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { SUPPORTED_DOCUMENT_SIMPLE_TYPES } from "@/lib/constants";
|
||||
import { useDisablePrint } from "@/lib/hooks/use-disable-print";
|
||||
import { LinkWithDataroomDocument, NotionTheme } from "@/lib/types";
|
||||
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||
@@ -16,7 +17,6 @@ import AccessForm, {
|
||||
DEFAULT_ACCESS_FORM_DATA,
|
||||
DEFAULT_ACCESS_FORM_TYPE,
|
||||
} from "@/components/view/access-form";
|
||||
import { useDisablePrint } from "@/lib/hooks/use-disable-print";
|
||||
|
||||
import EmailVerificationMessage from "../access-form/email-verification-form";
|
||||
import ViewData, { TViewDocumentData } from "../view-data";
|
||||
@@ -193,6 +193,7 @@ export default function DataroomDocumentView({
|
||||
viewerEmail: viewerEmail ?? data.email ?? verifiedEmail ?? userEmail,
|
||||
isEmbedded,
|
||||
isTeamMember,
|
||||
teamId: link.teamId,
|
||||
});
|
||||
|
||||
// set the verification token to the cookie
|
||||
|
||||
@@ -8,8 +8,8 @@ import { toast } from "sonner";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { SUPPORTED_DOCUMENT_SIMPLE_TYPES } from "@/lib/constants";
|
||||
import { LinkWithDataroom } from "@/lib/types";
|
||||
import { useDisablePrint } from "@/lib/hooks/use-disable-print";
|
||||
import { LinkWithDataroom } from "@/lib/types";
|
||||
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||
import AccessForm, {
|
||||
@@ -157,6 +157,7 @@ export default function DataroomView({
|
||||
viewerEmail: viewerEmail ?? data.email ?? verifiedEmail ?? userEmail,
|
||||
isEmbedded,
|
||||
isTeamMember,
|
||||
teamId: link.teamId,
|
||||
});
|
||||
|
||||
// set the verification token to the cookie
|
||||
|
||||
@@ -23,6 +23,7 @@ const DEFAULT_BANNER_IMAGE = "/_static/papermark-banner.png";
|
||||
|
||||
export default function DataroomNav({
|
||||
allowDownload,
|
||||
allowBulkDownload,
|
||||
brand,
|
||||
viewId,
|
||||
linkId,
|
||||
@@ -34,6 +35,7 @@ export default function DataroomNav({
|
||||
isTeamMember,
|
||||
}: {
|
||||
allowDownload?: boolean;
|
||||
allowBulkDownload?: boolean;
|
||||
brand?: Partial<DataroomBrand>;
|
||||
viewId?: string;
|
||||
linkId?: string;
|
||||
@@ -187,7 +189,7 @@ export default function DataroomNav({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{allowDownload ? (
|
||||
{allowDownload && allowBulkDownload ? (
|
||||
<ButtonTooltip content="Download Dataroom">
|
||||
<Button
|
||||
onClick={downloadDataroom}
|
||||
|
||||
@@ -171,6 +171,7 @@ export default function DocumentView({
|
||||
viewerEmail: data.email ?? verifiedEmail ?? userEmail,
|
||||
isEmbedded,
|
||||
isTeamMember,
|
||||
teamId: link.teamId,
|
||||
});
|
||||
|
||||
// set the verification token to the cookie
|
||||
|
||||
+16
-1
@@ -135,10 +135,24 @@ export default function Nav({
|
||||
const pdfBlob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(pdfBlob);
|
||||
|
||||
// Extract filename from Content-Disposition header
|
||||
const contentDisposition = response.headers.get("content-disposition");
|
||||
let filename = "document.pdf";
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(
|
||||
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/,
|
||||
);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
filename = decodeURIComponent(
|
||||
filenameMatch[1].replace(/['"]/g, ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.rel = "noopener noreferrer";
|
||||
link.download = "watermarked_document.pdf";
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -422,6 +436,7 @@ export default function Nav({
|
||||
<ConversationSidebar
|
||||
dataroomId={dataroomId}
|
||||
documentId={documentId}
|
||||
pageNumber={pageNumber}
|
||||
viewId={viewId || ""}
|
||||
viewerId={viewerId}
|
||||
linkId={linkId!}
|
||||
|
||||
@@ -113,9 +113,10 @@ export default function DataroomViewer({
|
||||
isEmbedded?: boolean;
|
||||
viewerEmail?: string;
|
||||
}) {
|
||||
const { documents, folders } = dataroom as {
|
||||
const { documents, folders, allowBulkDownload } = dataroom as {
|
||||
documents: DataroomDocument[];
|
||||
folders: DataroomFolder[];
|
||||
allowBulkDownload: boolean;
|
||||
};
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
@@ -242,7 +243,7 @@ export default function DataroomViewer({
|
||||
new Date(folder.updatedAt);
|
||||
|
||||
const allDocumentsCanDownload =
|
||||
folderDocuments.length > 0 &&
|
||||
folderDocuments.length === 0 || // Allow download for empty folders
|
||||
folderDocuments.every((doc) => {
|
||||
const accessControl = accessControls.find(
|
||||
(access) => access.itemId === doc.dataroomDocumentId,
|
||||
@@ -329,6 +330,7 @@ export default function DataroomViewer({
|
||||
viewId={viewId}
|
||||
dataroom={dataroom}
|
||||
allowDownload={allDocumentsCanDownload}
|
||||
allowBulkDownload={allowBulkDownload}
|
||||
isPreview={isPreview}
|
||||
dataroomId={dataroom?.id}
|
||||
viewerId={viewerId}
|
||||
|
||||
@@ -37,6 +37,10 @@ const Collection = dynamic(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const Code = dynamic(() =>
|
||||
import("react-notion-x/build/third-party/code").then((m) => m.Code),
|
||||
);
|
||||
|
||||
export const NotionPage = ({
|
||||
recordMap,
|
||||
versionNumber,
|
||||
@@ -432,6 +436,7 @@ export const NotionPage = ({
|
||||
disableHeader={true}
|
||||
components={{
|
||||
Collection,
|
||||
Code,
|
||||
PageLink: (props: {
|
||||
className: string;
|
||||
href: any;
|
||||
@@ -467,4 +472,4 @@ export const NotionPage = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import Handlebars from "handlebars";
|
||||
|
||||
import { WatermarkConfig } from "@/lib/types";
|
||||
import { safeTemplateReplace } from "@/lib/utils";
|
||||
|
||||
export const SVGWatermark = ({
|
||||
config,
|
||||
@@ -16,8 +15,7 @@ export const SVGWatermark = ({
|
||||
pageIndex: number;
|
||||
}) => {
|
||||
const watermarkText = useMemo(() => {
|
||||
const template = Handlebars.compile(config.text);
|
||||
return template(viewerData);
|
||||
return safeTemplateReplace(config.text, viewerData);
|
||||
}, [config.text, viewerData]);
|
||||
|
||||
const { width, height } = documentDimensions;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DocumentStorageType } from "@prisma/client";
|
||||
import { FileRejection, useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { VIEWER_ACCEPTED_FILE_TYPES } from "@/lib/constants";
|
||||
import { DocumentData } from "@/lib/documents/create-document";
|
||||
import { viewerUpload } from "@/lib/files/viewer-tus-upload";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -11,16 +12,7 @@ import { getSupportedContentType } from "@/lib/utils/get-content-type";
|
||||
import { getPagesCount } from "@/lib/utils/get-page-number-count";
|
||||
|
||||
// File types allowed for viewer uploads
|
||||
const acceptableViewerFileTypes = {
|
||||
"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/jpeg": [], // ".jpg"
|
||||
"image/png": [], // ".png"
|
||||
"image/jpg": [], // ".jpg"
|
||||
};
|
||||
const acceptableViewerFileTypes = VIEWER_ACCEPTED_FILE_TYPES;
|
||||
|
||||
export default function ViewerUploadZone({
|
||||
children,
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, useState } from "react";
|
||||
import { PropsWithChildren, useState, useEffect } from "react";
|
||||
|
||||
import { CircleCheck, CircleXIcon, CopyIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import {
|
||||
materialDark,
|
||||
materialLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { createHighlighter, type Highlighter } from "shiki";
|
||||
|
||||
import { useCopyToClipboard } from "@/lib/utils/use-copy-to-clipboard";
|
||||
import { useMediaQuery } from "@/lib/utils/use-media-query";
|
||||
@@ -28,21 +24,88 @@ export type EventListProps = PropsWithChildren<{
|
||||
events: any[];
|
||||
}>;
|
||||
|
||||
// Shiki Code Highlighter Component
|
||||
const CodeHighlighter = ({
|
||||
code,
|
||||
language = "json",
|
||||
isDark = false
|
||||
}: {
|
||||
code: string;
|
||||
language?: string;
|
||||
isDark?: boolean;
|
||||
}) => {
|
||||
const [highlighter, setHighlighter] = useState<Highlighter | null>(null);
|
||||
const [highlightedCode, setHighlightedCode] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const initHighlighter = async () => {
|
||||
const shiki = await createHighlighter({
|
||||
themes: ['material-theme-lighter', 'material-theme-darker'],
|
||||
langs: ['json']
|
||||
});
|
||||
setHighlighter(shiki);
|
||||
};
|
||||
|
||||
initHighlighter();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlighter && code) {
|
||||
const theme = isDark ? 'material-theme-darker' : 'material-theme-lighter';
|
||||
const html = highlighter.codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: theme,
|
||||
transformers: [
|
||||
{
|
||||
pre(node) {
|
||||
// Add custom styling to the pre element
|
||||
node.properties.style = [
|
||||
node.properties.style,
|
||||
'margin: 0',
|
||||
'padding: 0.5rem',
|
||||
'font-size: 0.875rem',
|
||||
'border-radius: 0.375rem',
|
||||
'overflow-x: auto'
|
||||
].filter(Boolean).join('; ');
|
||||
},
|
||||
code(node) {
|
||||
// Ensure proper styling for the code element
|
||||
node.properties.style = [
|
||||
node.properties.style,
|
||||
'display: block',
|
||||
'line-height: 1.5'
|
||||
].filter(Boolean).join('; ');
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
setHighlightedCode(html);
|
||||
}
|
||||
}, [highlighter, code, language, isDark]);
|
||||
|
||||
if (!highlighter || !highlightedCode) {
|
||||
return (
|
||||
<pre className="rounded-md bg-gray-100 p-2 text-sm dark:bg-gray-800">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-x-auto rounded-md"
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const WebhookEvent = ({ event }: { event: any }) => {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 2000 });
|
||||
const isSuccess = event.http_status >= 200 && event.http_status < 300;
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { theme, systemTheme } = useTheme();
|
||||
const isLight =
|
||||
theme === "light" || (theme === "system" && systemTheme === "light");
|
||||
|
||||
// Custom style overrides for react-syntax-highlighter
|
||||
const customStyle = {
|
||||
margin: 0,
|
||||
padding: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
};
|
||||
const isDark = theme === "dark" || (theme === "system" && systemTheme === "dark");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -112,29 +175,21 @@ const WebhookEvent = ({ event }: { event: any }) => {
|
||||
<p className="text-sm text-gray-700">{event.http_status}</p>
|
||||
</div>
|
||||
<div className="overflow-y-scroll">
|
||||
<SyntaxHighlighter
|
||||
<CodeHighlighter
|
||||
code={JSON.stringify(event.response_body, null, 2)}
|
||||
language="json"
|
||||
showLineNumbers
|
||||
customStyle={customStyle}
|
||||
style={isLight ? materialLight : materialDark}
|
||||
lineNumberStyle={{ textAlign: "right", minWidth: "3.25em" }}
|
||||
>
|
||||
{JSON.stringify(event.response_body, null, 2)}
|
||||
</SyntaxHighlighter>
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 border-t border-gray-200 bg-transparent py-4">
|
||||
<h4 className="font-semibold">Request</h4>
|
||||
<div className="overflow-y-scroll">
|
||||
<SyntaxHighlighter
|
||||
<CodeHighlighter
|
||||
code={JSON.stringify(event.request_body, null, 2)}
|
||||
language="json"
|
||||
showLineNumbers
|
||||
style={isLight ? materialLight : materialDark}
|
||||
customStyle={customStyle}
|
||||
lineNumberStyle={{ textAlign: "right", minWidth: "3.25em" }}
|
||||
>
|
||||
{JSON.stringify(event.request_body, null, 2)}
|
||||
</SyntaxHighlighter>
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { motion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { STAGGER_CHILD_VARIANTS } from "@/lib/constants";
|
||||
@@ -34,8 +35,7 @@ export function UploadContainer({
|
||||
const analytics = useAnalytics();
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id as string;
|
||||
const { currentTeamId: teamId } = useTeam();
|
||||
|
||||
const handleBrowserUpload = async (event: any) => {
|
||||
event.preventDefault();
|
||||
@@ -62,7 +62,7 @@ export function UploadContainer({
|
||||
|
||||
const { type, data, numPages, fileSize } = await putFile({
|
||||
file: currentFile,
|
||||
teamId,
|
||||
teamId: teamId as string,
|
||||
});
|
||||
|
||||
setCurrentFile(null);
|
||||
@@ -79,7 +79,7 @@ export function UploadContainer({
|
||||
// create a document in the database
|
||||
const response = await createDocument({
|
||||
documentData,
|
||||
teamId,
|
||||
teamId: teamId as string,
|
||||
numPages,
|
||||
createLink: dataroomId ? false : true, // don't create a link if the document is being added to a dataroom
|
||||
});
|
||||
@@ -89,55 +89,64 @@ export function UploadContainer({
|
||||
|
||||
if (dataroomId) {
|
||||
// add document to dataroom
|
||||
await fetch(
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}/documents`,
|
||||
{
|
||||
try {
|
||||
const dataroomIdParsed = z.string().cuid().parse(dataroomId);
|
||||
|
||||
await fetch(
|
||||
`/api/teams/${teamId}/datarooms/${dataroomIdParsed}/documents`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ documentId: document.id }),
|
||||
},
|
||||
);
|
||||
|
||||
analytics.capture("Document Added to Dataroom", {
|
||||
documentId: document.id,
|
||||
name: document.name,
|
||||
numPages: document.numPages,
|
||||
path: router.asPath,
|
||||
type: document.type,
|
||||
teamId: teamId,
|
||||
dataroomId: dataroomId,
|
||||
});
|
||||
|
||||
// create link to dataroom
|
||||
const newLinkResponse = await fetch(`/api/links`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ documentId: document.id }),
|
||||
},
|
||||
);
|
||||
|
||||
analytics.capture("Document Added to Dataroom", {
|
||||
documentId: document.id,
|
||||
name: document.name,
|
||||
numPages: document.numPages,
|
||||
path: router.asPath,
|
||||
type: document.type,
|
||||
teamId: teamInfo?.currentTeam?.id,
|
||||
dataroomId: dataroomId,
|
||||
});
|
||||
|
||||
// create link to dataroom
|
||||
const newLinkResponse = await fetch(`/api/links`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
targetId: dataroomId,
|
||||
linkType: "DATAROOM_LINK",
|
||||
teamId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (newLinkResponse.ok) {
|
||||
const link = await newLinkResponse.json();
|
||||
setCurrentLinkId(link.id);
|
||||
|
||||
analytics.capture("Link Added", {
|
||||
linkId: link.id,
|
||||
dataroomId: dataroomId,
|
||||
customDomain: null,
|
||||
teamId: teamInfo?.currentTeam?.id,
|
||||
body: JSON.stringify({
|
||||
targetId: dataroomId,
|
||||
linkType: "DATAROOM_LINK",
|
||||
teamId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (newLinkResponse.ok) {
|
||||
const link = await newLinkResponse.json();
|
||||
setCurrentLinkId(link.id);
|
||||
|
||||
analytics.capture("Link Added", {
|
||||
linkId: link.id,
|
||||
dataroomId: dataroomId,
|
||||
customDomain: null,
|
||||
teamId: teamId,
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setUploading(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Error adding document to dataroom:", error);
|
||||
toast.error("Failed to add document to dataroom");
|
||||
setUploading(false);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const linkId = document.links[0].id;
|
||||
|
||||
@@ -149,13 +158,13 @@ export function UploadContainer({
|
||||
path: router.asPath,
|
||||
type: document.type,
|
||||
contentType: document.contentType,
|
||||
teamId: teamInfo?.currentTeam?.id,
|
||||
teamId: teamId,
|
||||
});
|
||||
analytics.capture("Link Added", {
|
||||
linkId: linkId,
|
||||
documentId: document.id,
|
||||
customDomain: null,
|
||||
teamId: teamInfo?.currentTeam?.id,
|
||||
teamId: teamId,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface PauseResumeReminderEmailProps {
|
||||
teamName?: string;
|
||||
userName?: string;
|
||||
resumeDate?: string;
|
||||
plan?: string;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_MARKETING_URL || "https://www.papermark.com";
|
||||
|
||||
export default function PauseResumeReminderEmail({
|
||||
teamName = "Your Team",
|
||||
userName = "Team Member",
|
||||
resumeDate = "March 15, 2024",
|
||||
plan = "Pro",
|
||||
userRole = "Admin",
|
||||
}: PauseResumeReminderEmailProps) {
|
||||
const previewText = `Your ${teamName} subscription will resume billing in 3 days`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Container className="mx-auto my-[40px] w-[465px] rounded border border-solid border-[#eaeaea] p-[20px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={`${baseUrl}/_static/papermark-logo.png`}
|
||||
width="160"
|
||||
height="48"
|
||||
alt="Papermark"
|
||||
className="mx-auto my-0"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black">
|
||||
Subscription Resume Reminder
|
||||
</Heading>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
Hello {userName},
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
This is a friendly reminder that your <strong>{teamName}</strong>{" "}
|
||||
team's paused subscription will automatically resume billing in{" "}
|
||||
<strong>3 days</strong>.
|
||||
</Text>
|
||||
|
||||
<Section className="my-[32px] rounded-lg border border-solid border-[#e5e7eb] bg-[#f9fafb] p-[24px]">
|
||||
<Text className="m-0 text-[14px] font-semibold leading-[24px] text-black">
|
||||
📅 Resume Details:
|
||||
</Text>
|
||||
<Text className="mb-0 mt-[12px] text-[14px] leading-[20px] text-[#6b7280]">
|
||||
<strong>Team:</strong> {teamName}
|
||||
<br />
|
||||
<strong>Plan:</strong> {plan}
|
||||
<br />
|
||||
<strong>Resume Date:</strong> {resumeDate}
|
||||
<br />
|
||||
<strong>Your Role:</strong> {userRole}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<strong>What happens next?</strong>
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[20px] text-black">
|
||||
• Your subscription will automatically resume on{" "}
|
||||
<strong>{resumeDate}</strong>
|
||||
<br />
|
||||
• Billing will restart at your regular plan rate
|
||||
<br />
|
||||
• All features will be fully restored
|
||||
<br />• Your existing data and links remain unchanged
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<strong>Need to make changes?</strong>
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
If you'd like to cancel your subscription instead of resuming, or
|
||||
need to update your billing information, you can manage your
|
||||
subscription in your account settings.
|
||||
</Text>
|
||||
|
||||
<Section className="my-[32px] text-center">
|
||||
<Link
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center text-[12px] font-semibold text-white no-underline"
|
||||
href={`${baseUrl}/settings/billing`}
|
||||
>
|
||||
Manage Subscription
|
||||
</Link>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
If you have any questions or concerns, please don't hesitate to
|
||||
reach out to our support team.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
Best regards,
|
||||
<br />
|
||||
The Papermark Team
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[32px] border-t border-solid border-[#eaeaea] pt-[20px]">
|
||||
<Text className="text-[12px] leading-[16px] text-[#666]">
|
||||
This email was sent to you as an admin/manager of the {teamName}{" "}
|
||||
team on Papermark. If you believe this was sent in error, please
|
||||
contact our support team.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[16px] text-[#666]">
|
||||
Papermark - The secure document sharing platform
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
export default function BlockedEmailAttempt({
|
||||
blockedEmail,
|
||||
linkName,
|
||||
resourceName,
|
||||
resourceType = "document",
|
||||
timestamp,
|
||||
locationString,
|
||||
accessType,
|
||||
}: {
|
||||
blockedEmail: string;
|
||||
linkName: string;
|
||||
resourceName: string;
|
||||
resourceType?: "document" | "dataroom";
|
||||
timestamp?: string;
|
||||
locationString?: string;
|
||||
accessType?: "global" | "allow" | "deny";
|
||||
}) {
|
||||
const accessTypeTexts = {
|
||||
global:
|
||||
"This email is on your global block list and was denied access. No further action is required.",
|
||||
allow:
|
||||
"This email is not on your link's allow list and was denied access. No further action is required.",
|
||||
deny: "This email is on your link's block list and was denied access. No further action is required.",
|
||||
default: "This email was denied access. No further action is required.",
|
||||
};
|
||||
|
||||
const accessTypeText = accessType
|
||||
? accessTypeTexts[accessType]
|
||||
: accessTypeTexts.default;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Blocked email attempted to access your {resourceType}</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">
|
||||
Blocked Email Attempted Access
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
A blocked email attempted to access your {resourceType}:
|
||||
</Text>
|
||||
<Text className="break-all text-sm leading-6 text-black">
|
||||
<ul>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
<span className="font-semibold">Email address:</span>{" "}
|
||||
{blockedEmail}
|
||||
</li>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
<span className="font-semibold">Time:</span>{" "}
|
||||
{timestamp || new Date().toLocaleString()}
|
||||
</li>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
<span className="font-semibold">
|
||||
{resourceType === "dataroom" ? "Dataroom" : "Document"}{" "}
|
||||
name:
|
||||
</span>{" "}
|
||||
{resourceName}
|
||||
</li>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
<span className="font-semibold">Link name:</span> {linkName}
|
||||
</li>
|
||||
</ul>
|
||||
</Text>
|
||||
<Text className="mt-4 text-sm leading-6 text-black">
|
||||
{accessTypeText}
|
||||
</Text>
|
||||
<Hr />
|
||||
<Section className="mt-8 text-gray-400">
|
||||
<Text className="text-xs">
|
||||
© {new Date().getFullYear()} Papermark, Inc.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { reportDeniedAccessAttempt } from "./lib/report-denied-access-attempt";
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Link } from "@prisma/client";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
import { sendBlockedEmailAttemptNotification } from "./send-blocked-email-attempt";
|
||||
|
||||
export async function reportDeniedAccessAttempt(
|
||||
link: Partial<Link>,
|
||||
email: string,
|
||||
accessType: "global" | "allow" | "deny" = "global",
|
||||
) {
|
||||
if (!link || !link.teamId) return;
|
||||
|
||||
// Get all admin and manager emails
|
||||
const users = await prisma.userTeam.findMany({
|
||||
where: {
|
||||
role: { in: ["ADMIN", "MANAGER"] },
|
||||
status: "ACTIVE",
|
||||
teamId: link.teamId,
|
||||
},
|
||||
select: {
|
||||
user: { select: { email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const adminManagerEmails = users
|
||||
.map((u) => u.user?.email)
|
||||
.filter((e): e is string => !!e);
|
||||
|
||||
// Get resource info and owner email
|
||||
let resourceType: "dataroom" | "document" = "dataroom";
|
||||
let resourceName = "Dataroom";
|
||||
let ownerEmail: string | undefined;
|
||||
|
||||
if (link.documentId) {
|
||||
resourceType = "document";
|
||||
const document = await prisma.document.findUnique({
|
||||
where: { id: link.documentId },
|
||||
select: { name: true, ownerId: true },
|
||||
});
|
||||
resourceName = document?.name || "Document";
|
||||
|
||||
if (document?.ownerId) {
|
||||
const owner = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: document.ownerId,
|
||||
teamId: link.teamId,
|
||||
},
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: { user: { select: { email: true } } },
|
||||
});
|
||||
ownerEmail = owner?.user?.email || undefined;
|
||||
}
|
||||
} else if (link.dataroomId) {
|
||||
const dataroom = await prisma.dataroom.findUnique({
|
||||
where: { id: link.dataroomId },
|
||||
select: { name: true },
|
||||
});
|
||||
resourceName = dataroom?.name || "Dataroom";
|
||||
}
|
||||
|
||||
// Combine all recipients and remove duplicates
|
||||
const allRecipients = [...adminManagerEmails];
|
||||
if (ownerEmail && !allRecipients.includes(ownerEmail)) {
|
||||
allRecipients.push(ownerEmail);
|
||||
}
|
||||
|
||||
// Send email to all recipients
|
||||
if (allRecipients.length > 0) {
|
||||
const [to, ...cc] = allRecipients;
|
||||
await sendBlockedEmailAttemptNotification({
|
||||
to,
|
||||
cc: cc.length > 0 ? cc : undefined,
|
||||
blockedEmail: email,
|
||||
linkName: link.name || `Link #${link.id?.slice(-5)}`,
|
||||
resourceName,
|
||||
resourceType,
|
||||
timestamp: new Date().toLocaleString(),
|
||||
accessType,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { sendEmail } from "@/lib/resend";
|
||||
|
||||
import BlockedEmailAttempt from "../components/blocked-email-attempt";
|
||||
|
||||
export const sendBlockedEmailAttemptNotification = async ({
|
||||
to,
|
||||
cc,
|
||||
blockedEmail,
|
||||
linkName,
|
||||
resourceName,
|
||||
resourceType = "document",
|
||||
timestamp,
|
||||
locationString,
|
||||
accessType,
|
||||
}: {
|
||||
to: string;
|
||||
cc?: string[];
|
||||
blockedEmail: string;
|
||||
linkName: string;
|
||||
resourceName: string;
|
||||
resourceType?: "document" | "dataroom";
|
||||
timestamp?: string;
|
||||
locationString?: string;
|
||||
accessType: "global" | "allow" | "deny";
|
||||
}) => {
|
||||
const emailTemplate = BlockedEmailAttempt({
|
||||
blockedEmail,
|
||||
linkName,
|
||||
resourceName,
|
||||
resourceType,
|
||||
timestamp,
|
||||
locationString,
|
||||
accessType,
|
||||
});
|
||||
try {
|
||||
await sendEmail({
|
||||
to,
|
||||
cc,
|
||||
subject: `Blocked access attempt to ${resourceType}: ${resourceName}`,
|
||||
react: emailTemplate,
|
||||
system: true,
|
||||
test: process.env.NODE_ENV === "development",
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { stripeInstance } from "@/ee/stripe";
|
||||
import { isOldAccount } from "@/ee/stripe/utils";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
// POST /api/teams/:teamId/billing/cancel – cancel a user's subscription
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
res.status(401).end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
const { teamId } = req.query as { teamId: string };
|
||||
|
||||
try {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
role: {
|
||||
in: ["ADMIN", "MANAGER"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stripeId: true,
|
||||
subscriptionId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(400).json({ error: "Team does not exist" });
|
||||
}
|
||||
|
||||
if (!team.stripeId) {
|
||||
return res.status(400).json({ error: "No Stripe customer ID" });
|
||||
}
|
||||
|
||||
if (!team.subscriptionId) {
|
||||
return res.status(400).json({ error: "No subscription ID" });
|
||||
}
|
||||
|
||||
const stripe = stripeInstance(isOldAccount(team.plan));
|
||||
|
||||
waitUntil(
|
||||
Promise.all([
|
||||
stripe.subscriptions.update(team.subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
}),
|
||||
stripe.subscriptions.deleteDiscount(team.subscriptionId),
|
||||
prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
cancelledAt: new Date(),
|
||||
},
|
||||
}),
|
||||
log({
|
||||
message: `Team ${teamId} cancelled their subscription.`,
|
||||
type: "info",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error cancelling subscription:", error);
|
||||
await log({
|
||||
message: `Error cancelling subscription for team ${teamId}: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to cancel subscription" });
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { CancellationReason } from "@/ee/features/billing/cancellation/lib/constants";
|
||||
import { stripeInstance } from "@/ee/stripe";
|
||||
import { isOldAccount } from "@/ee/stripe/utils";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
export const config = {
|
||||
// in order to enable `waitUntil` function
|
||||
supportsResponseStreaming: true,
|
||||
};
|
||||
|
||||
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
// POST /api/teams/:teamId/billing/cancellation-feedback – submit cancellation feedback
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
res.status(401).end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
const { teamId } = req.query as { teamId: string };
|
||||
const { reason, feedback } = req.body as {
|
||||
reason: CancellationReason;
|
||||
feedback: string;
|
||||
};
|
||||
|
||||
try {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
role: {
|
||||
in: ["ADMIN", "MANAGER"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stripeId: true,
|
||||
subscriptionId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(400).json({ error: "Team does not exist" });
|
||||
}
|
||||
|
||||
const reasonLabels: Record<CancellationReason, string> = {
|
||||
too_expensive: "Too expensive",
|
||||
unused: "Not used enough",
|
||||
missing_features: "Missing features",
|
||||
switched_service: "Switched to another service",
|
||||
other: "Other reason",
|
||||
};
|
||||
|
||||
// Prepare feedback data for logging and analytics
|
||||
const feedbackData = {
|
||||
reason,
|
||||
reasonLabel: reasonLabels[reason],
|
||||
feedback: feedback || "",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update the subscription cancellation details
|
||||
if (team.stripeId && team.subscriptionId) {
|
||||
const stripe = stripeInstance(isOldAccount(team.plan));
|
||||
|
||||
waitUntil(
|
||||
stripe.subscriptions.update(team.subscriptionId, {
|
||||
cancellation_details: {
|
||||
feedback: reason,
|
||||
comment: feedback || "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Log to Slack
|
||||
waitUntil(
|
||||
log({
|
||||
message: `💔 **Cancellation Feedback Received**\n\n**Team:** ${teamId} (${team.plan})\n**Reason:** ${reasonLabels[reason]}\n**Feedback:** ${feedback || "No additional feedback provided"}\n\nTime: ${new Date().toLocaleString()}`,
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
feedbackData, // Return this for PostHog tracking in the frontend
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error submitting cancellation feedback:", error);
|
||||
await log({
|
||||
message: `Error submitting cancellation feedback for team ${teamId}: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to submit feedback" });
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { sendPauseResumeNotificationTask } from "@/ee/features/billing/cancellation/lib/trigger/pause-resume-notification";
|
||||
import { stripeInstance } from "@/ee/stripe";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
export const config = {
|
||||
// in order to enable `waitUntil` function
|
||||
supportsResponseStreaming: true,
|
||||
};
|
||||
|
||||
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
// POST /api/teams/:teamId/billing/pause – pause a user's subscription
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
res.status(401).end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
const { teamId } = req.query as { teamId: string };
|
||||
|
||||
try {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stripeId: true,
|
||||
subscriptionId: true,
|
||||
endsAt: true,
|
||||
plan: true,
|
||||
limits: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(400).json({ error: "Team does not exist" });
|
||||
}
|
||||
|
||||
if (!team.stripeId) {
|
||||
return res.status(400).json({ error: "No Stripe customer ID" });
|
||||
}
|
||||
|
||||
if (!team.subscriptionId) {
|
||||
return res.status(400).json({ error: "No subscription ID" });
|
||||
}
|
||||
|
||||
const isOldAccount = team.plan.includes("+old");
|
||||
const stripe = stripeInstance(isOldAccount);
|
||||
|
||||
const pauseStartsAt = team.endsAt ? new Date(team.endsAt) : new Date();
|
||||
const pauseEndsAt = new Date(pauseStartsAt);
|
||||
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
|
||||
const reminderAt = new Date(pauseEndsAt);
|
||||
reminderAt.setDate(pauseEndsAt.getDate() - 3);
|
||||
|
||||
// Pause the subscription in Stripe
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
pause_collection: {
|
||||
behavior: "mark_uncollectible",
|
||||
resumes_at: Math.floor(pauseEndsAt.getTime() / 1000), // seconds (int)
|
||||
},
|
||||
metadata: {
|
||||
pause_starts_at: pauseStartsAt.toISOString(),
|
||||
pause_ends_at: pauseEndsAt.toISOString(),
|
||||
paused_reason: "user_request",
|
||||
original_plan: team.plan,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
pausedAt: new Date(),
|
||||
pauseStartsAt,
|
||||
pauseEndsAt,
|
||||
},
|
||||
});
|
||||
|
||||
waitUntil(
|
||||
Promise.all([
|
||||
// Schedule the pause resume notifications
|
||||
sendPauseResumeNotificationTask.trigger(
|
||||
{ teamId },
|
||||
{
|
||||
delay: reminderAt, // 3 days before pause ends
|
||||
tags: [`team_${teamId}`],
|
||||
idempotencyKey: `pause-resume-${teamId}-${new Date().getTime()}`,
|
||||
},
|
||||
),
|
||||
// Remove the existing discounts from the subscription
|
||||
stripe.subscriptions.deleteDiscount(team.subscriptionId),
|
||||
log({
|
||||
message: `Team ${teamId} (${team.plan}) paused their subscription for 3 months.`,
|
||||
type: "info",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Subscription paused successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error pausing subscription:", error);
|
||||
await log({
|
||||
message: `Error pausing subscription for team ${teamId}: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to pause subscription" });
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { stripeInstance } from "@/ee/stripe";
|
||||
import { isOldAccount } from "@/ee/stripe/utils";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
// POST /api/teams/:teamId/billing/reactivate – reactivate a user's subscription
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
res.status(401).end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
const { teamId } = req.query as { teamId: string };
|
||||
|
||||
try {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
role: {
|
||||
in: ["ADMIN", "MANAGER"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stripeId: true,
|
||||
subscriptionId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(400).json({ error: "Team does not exist" });
|
||||
}
|
||||
|
||||
if (!team.stripeId) {
|
||||
return res.status(400).json({ error: "No Stripe customer ID" });
|
||||
}
|
||||
|
||||
if (!team.subscriptionId) {
|
||||
return res.status(400).json({ error: "No subscription ID" });
|
||||
}
|
||||
|
||||
const stripe = stripeInstance(isOldAccount(team.plan));
|
||||
|
||||
const subscription = await stripe.subscriptions.update(
|
||||
team.subscriptionId,
|
||||
{
|
||||
cancel_at_period_end: false,
|
||||
},
|
||||
);
|
||||
|
||||
await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
cancelledAt: null,
|
||||
pauseStartsAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
waitUntil(
|
||||
log({
|
||||
message: `Team ${teamId} reactivated their subscription.`,
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error reactivating subscription:", error);
|
||||
await log({
|
||||
message: `Error reactivating subscription for team ${teamId}: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to reactivate subscription" });
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { stripeInstance } from "@/ee/stripe";
|
||||
import { getCouponFromPlan } from "@/ee/stripe/functions/get-coupon-from-plan";
|
||||
import { isOldAccount } from "@/ee/stripe/utils";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
// POST /api/teams/:teamId/billing/retention-offer – apply retention offer
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
res.status(401).end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
const { teamId } = req.query as { teamId: string };
|
||||
|
||||
try {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
role: {
|
||||
in: ["ADMIN", "MANAGER"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stripeId: true,
|
||||
subscriptionId: true,
|
||||
plan: true,
|
||||
pausedAt: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
limits: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(400).json({ error: "Team does not exist" });
|
||||
}
|
||||
|
||||
if (!team.stripeId) {
|
||||
return res.status(400).json({ error: "No Stripe customer ID" });
|
||||
}
|
||||
|
||||
if (!team.subscriptionId) {
|
||||
return res.status(400).json({ error: "No subscription ID" });
|
||||
}
|
||||
|
||||
let isAnnualPlan = false;
|
||||
if (team?.startsAt && team?.endsAt) {
|
||||
const durationInDays = Math.round(
|
||||
(team.endsAt.getTime() - team.startsAt.getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
);
|
||||
// If duration is more than 31 days, consider it yearly
|
||||
isAnnualPlan = durationInDays > 31;
|
||||
}
|
||||
|
||||
const stripe = stripeInstance(isOldAccount(team.plan));
|
||||
const couponId = getCouponFromPlan(team.plan, isAnnualPlan);
|
||||
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
discounts: [
|
||||
{
|
||||
coupon: couponId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
waitUntil(
|
||||
log({
|
||||
message: `Retention offer applied to team ${teamId}: 50% off for ${
|
||||
isAnnualPlan ? "12 months" : "3 months"
|
||||
}`,
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error applying retention offer:", error);
|
||||
await log({
|
||||
message: `Error applying retention offer for team ${teamId}: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to apply retention offer" });
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getDisplayNameFromPlan } from "@/ee/stripe/functions/get-display-name-from-plan";
|
||||
import { z } from "zod";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
import { sendEmailPauseResumeReminder } from "../emails/lib/send-email-pause-resume-reminder";
|
||||
|
||||
export default async function handle(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
// We only allow POST requests
|
||||
if (req.method !== "POST") {
|
||||
res.status(405).json({ message: "Method Not Allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the API Key from the Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.split(" ")[1]; // Assuming the format is "Bearer [token]"
|
||||
|
||||
// Check if the API Key matches
|
||||
if (token !== process.env.INTERNAL_API_KEY) {
|
||||
res.status(401).json({ message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Define validation schema
|
||||
const requestSchema = z.object({
|
||||
teamId: z
|
||||
.string()
|
||||
.min(1, "teamId is required and must be a non-empty string"),
|
||||
});
|
||||
|
||||
// Validate request body
|
||||
const validationResult = requestSchema.safeParse(req.body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const firstError = validationResult.error.errors[0];
|
||||
res.status(400).json({
|
||||
message: firstError.message,
|
||||
field: firstError.path[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = validationResult.data;
|
||||
|
||||
try {
|
||||
// Get all team members (ADMIN/MANAGER) for this team
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
name: true,
|
||||
plan: true,
|
||||
pauseEndsAt: true,
|
||||
users: {
|
||||
where: {
|
||||
role: {
|
||||
in: ["ADMIN", "MANAGER"],
|
||||
},
|
||||
blockedAt: null, // Only active team members (not blocked)
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const teamMembers = team?.users;
|
||||
|
||||
if (!teamMembers || teamMembers.length === 0) {
|
||||
res.status(200).json({
|
||||
message: "No team members found for notifications",
|
||||
notified: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!team.pauseEndsAt) {
|
||||
res.status(200).json({
|
||||
message: "Team is not paused",
|
||||
notified: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all team member emails
|
||||
const teamMemberEmails = teamMembers
|
||||
.map((tm) => tm.user.email)
|
||||
.filter((email): email is string => !!email);
|
||||
|
||||
// Send email to all team members at once
|
||||
await sendEmailPauseResumeReminder({
|
||||
teamName: team.name,
|
||||
plan: getDisplayNameFromPlan(team.plan),
|
||||
resumeDate: team.pauseEndsAt.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
teamMemberEmails,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
message: "Successfully sent pause resume reminder to team members",
|
||||
notified: teamMemberEmails.length,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
log({
|
||||
message: `Failed to send pause resume reminder for team ${teamId} to team members. \n\n Error: ${error} \n\n*Metadata*: \`{teamId: ${teamId}}\``,
|
||||
type: "error",
|
||||
mention: true,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to send pause resume reminder" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { stripeInstance } from "@/ee/stripe";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
export const config = {
|
||||
// in order to enable `waitUntil` function
|
||||
supportsResponseStreaming: true,
|
||||
};
|
||||
|
||||
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
// POST /api/teams/:teamId/billing/unpause – unpause a user's subscription
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
res.status(401).end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
const { teamId } = req.query as { teamId: string };
|
||||
|
||||
try {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stripeId: true,
|
||||
subscriptionId: true,
|
||||
endsAt: true,
|
||||
plan: true,
|
||||
pauseStartsAt: true,
|
||||
pauseEndsAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(400).json({ error: "Team does not exist" });
|
||||
}
|
||||
|
||||
if (!team.stripeId) {
|
||||
return res.status(400).json({ error: "No Stripe customer ID" });
|
||||
}
|
||||
|
||||
if (!team.subscriptionId) {
|
||||
return res.status(400).json({ error: "No subscription ID" });
|
||||
}
|
||||
|
||||
if (!team.pauseStartsAt || !team.pauseEndsAt) {
|
||||
return res.status(400).json({ error: "Subscription is not paused" });
|
||||
}
|
||||
|
||||
const isOldAccount = team.plan.includes("+old");
|
||||
const stripe = stripeInstance(isOldAccount);
|
||||
|
||||
// Unpause the subscription in Stripe
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
pause_collection: "",
|
||||
});
|
||||
|
||||
await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
pausedAt: null,
|
||||
pauseStartsAt: null,
|
||||
pauseEndsAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Get all delayed and queued runs for this dataroom
|
||||
const allRuns = await runs.list({
|
||||
taskIdentifier: ["send-pause-resume-notification"],
|
||||
tag: [`team_${teamId}`],
|
||||
status: ["DELAYED", "QUEUED"],
|
||||
period: "90d",
|
||||
});
|
||||
|
||||
// Cancel any existing unsent notification runs for this dataroom
|
||||
waitUntil(
|
||||
Promise.all([
|
||||
allRuns.data.map((run) => runs.cancel(run.id)),
|
||||
log({
|
||||
message: `Team ${teamId} (${team.plan}) unpaused their subscription. Next billing date: ${team.endsAt}`,
|
||||
type: "info",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Subscription unpaused successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error unpausing subscription:", error);
|
||||
await log({
|
||||
message: `Error unpausing subscription for team ${teamId}: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unpause subscription" });
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
import { type CancellationReason } from "../lib/constants";
|
||||
import { ConfirmCancellationModal } from "./confirm-cancellation-modal";
|
||||
import { FeedbackModal } from "./feedback-modal";
|
||||
import { PauseSubscriptionModal } from "./pause-subscription-modal";
|
||||
import { CancellationBaseModal } from "./reason-base-modal";
|
||||
import { RetentionOfferModal } from "./retention-offer-modal";
|
||||
import { ScheduleCallModal } from "./schedule-call-modal";
|
||||
|
||||
type CancellationStep =
|
||||
| "reason"
|
||||
| "retention-offer"
|
||||
| "pause-offer"
|
||||
| "feedback"
|
||||
| "confirm"
|
||||
| "schedule-call"
|
||||
| "final-feedback";
|
||||
|
||||
interface CancellationModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CancellationModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CancellationModalProps) {
|
||||
// Start with pause offer - this should be the first thing users see
|
||||
const [currentStep, setCurrentStep] =
|
||||
useState<CancellationStep>("pause-offer");
|
||||
const [selectedReason, setSelectedReason] =
|
||||
useState<CancellationReason | null>(null);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { currentTeamId } = useTeam();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const reasons: { value: CancellationReason; label: string }[] = [
|
||||
{ value: "too_expensive", label: "It's too expensive" },
|
||||
{ value: "unused", label: "I don't use the service enough" },
|
||||
{ value: "missing_features", label: "Some features are missing" },
|
||||
{
|
||||
value: "switched_service",
|
||||
label: "I'm switching to a different service",
|
||||
},
|
||||
{ value: "other", label: "Other reason" },
|
||||
];
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setCurrentStep("pause-offer");
|
||||
setSelectedReason(null);
|
||||
setFeedback("");
|
||||
setLoading(false);
|
||||
}, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleReasonClick = (reason: CancellationReason) => {
|
||||
setSelectedReason(reason);
|
||||
// Route based on reason - only "other" goes to feedback first
|
||||
switch (reason) {
|
||||
case "too_expensive":
|
||||
setCurrentStep("retention-offer");
|
||||
break;
|
||||
case "unused":
|
||||
setCurrentStep("confirm"); // Go directly to cancellation flow for unused
|
||||
break;
|
||||
case "missing_features":
|
||||
setCurrentStep("schedule-call");
|
||||
break;
|
||||
case "switched_service":
|
||||
setCurrentStep("retention-offer");
|
||||
break;
|
||||
case "other":
|
||||
setCurrentStep("feedback"); // Only "other" goes to feedback first
|
||||
break;
|
||||
default:
|
||||
setCurrentStep("confirm");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
switch (currentStep) {
|
||||
case "reason":
|
||||
// From reason selection, go back to pause offer
|
||||
setCurrentStep("pause-offer");
|
||||
break;
|
||||
case "retention-offer":
|
||||
case "schedule-call":
|
||||
setCurrentStep("reason");
|
||||
break;
|
||||
case "feedback":
|
||||
// From "other" reason feedback, go back to reason selection
|
||||
setCurrentStep("reason");
|
||||
break;
|
||||
case "confirm":
|
||||
// Go back to the appropriate step based on reason
|
||||
if (selectedReason === "missing_features") {
|
||||
setCurrentStep("schedule-call");
|
||||
} else if (selectedReason === "unused") {
|
||||
setCurrentStep("reason"); // Go back to reason selection for unused
|
||||
} else if (selectedReason === "other") {
|
||||
setCurrentStep("feedback"); // Go back to "other" feedback
|
||||
} else {
|
||||
setCurrentStep("retention-offer");
|
||||
}
|
||||
break;
|
||||
case "final-feedback":
|
||||
// From final feedback, close modal
|
||||
onOpenChange(false);
|
||||
break;
|
||||
case "pause-offer":
|
||||
default:
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleFinalFeedbackSubmit = async () => {
|
||||
if (!currentTeamId || !selectedReason) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${currentTeamId}/billing/cancellation-feedback`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reason: selectedReason,
|
||||
feedback: feedback,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Track in PostHog
|
||||
analytics.capture("Cancellation Feedback Submitted", {
|
||||
teamId: currentTeamId,
|
||||
reason: selectedReason,
|
||||
reasonLabel: data.feedbackData?.reasonLabel || selectedReason,
|
||||
feedback: feedback || "",
|
||||
hasCustomFeedback: !!feedback,
|
||||
});
|
||||
|
||||
toast.success("Thank you for your feedback!");
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan`);
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
throw new Error("Failed to submit feedback");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting feedback:", error);
|
||||
toast.error("Sorry, we couldn't submit your feedback. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentStep === "pause-offer") {
|
||||
return (
|
||||
<PauseSubscriptionModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onBack={handleBack}
|
||||
onDecline={() => setCurrentStep("reason")} // Declining pause leads to reason selection
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "reason" || !selectedReason) {
|
||||
return (
|
||||
<CancellationBaseModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Why do you want to cancel?"
|
||||
description="Help us understand what we could improve"
|
||||
showKeepButton={true}
|
||||
onBack={handleBack}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{reasons.map((reason) => (
|
||||
<button
|
||||
key={reason.value}
|
||||
onClick={() => handleReasonClick(reason.value)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:ring-1 hover:ring-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:hover:ring-gray-200"
|
||||
>
|
||||
<div className="text-sm font-medium">{reason.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CancellationBaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "retention-offer") {
|
||||
return (
|
||||
<RetentionOfferModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
reason={selectedReason}
|
||||
onBack={handleBack}
|
||||
onDecline={() => setCurrentStep("confirm")}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "schedule-call") {
|
||||
return (
|
||||
<ScheduleCallModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
reason={selectedReason}
|
||||
onDecline={() => setCurrentStep("confirm")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "feedback") {
|
||||
return (
|
||||
<FeedbackModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
reason={selectedReason}
|
||||
feedback={feedback}
|
||||
onFeedbackChange={setFeedback}
|
||||
onBack={handleBack}
|
||||
onContinue={() => {
|
||||
// For "other" reason, continue to confirmation
|
||||
setCurrentStep("confirm");
|
||||
}}
|
||||
isFinalStep={false} // This is the "other" reason feedback, not final
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "confirm") {
|
||||
return (
|
||||
<ConfirmCancellationModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirmCancellation={() => {
|
||||
// After cancellation is confirmed, show feedback modal
|
||||
setCurrentStep("final-feedback");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "final-feedback") {
|
||||
return (
|
||||
<FeedbackModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
reason={selectedReason}
|
||||
feedback={feedback}
|
||||
onFeedbackChange={setFeedback}
|
||||
onBack={handleBack}
|
||||
onContinue={handleFinalFeedbackSubmit}
|
||||
isFinalStep={true} // This is the final feedback
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
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 { CancellationBaseModal } from "./reason-base-modal";
|
||||
|
||||
interface ConfirmCancellationModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirmCancellation: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmCancellationModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirmCancellation,
|
||||
}: ConfirmCancellationModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { currentTeamId } = useTeam();
|
||||
const { plan: currentPlan, endsAt } = usePlan();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const handleConfirmCancellation = async () => {
|
||||
if (!currentTeamId) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// If we have a callback, call it instead of redirecting
|
||||
|
||||
try {
|
||||
// Still make the API call to get the cancellation URL for future use
|
||||
const response = await fetch(
|
||||
`/api/teams/${currentTeamId}/billing/cancel`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
// track the event
|
||||
analytics.capture("Subscription Cancelled", {
|
||||
teamId: currentTeamId,
|
||||
plan: currentPlan,
|
||||
endsAt: endsAt,
|
||||
});
|
||||
|
||||
// Call the callback instead of redirecting
|
||||
onConfirmCancellation();
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan`);
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
|
||||
} else {
|
||||
toast.error("Something went wrong");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDescription = `Your subscription will end on ${new Date(
|
||||
endsAt!,
|
||||
).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<CancellationBaseModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Confirm cancellation"
|
||||
description={getDescription}
|
||||
showKeepButton={true}
|
||||
confirmButton={
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmCancellation}
|
||||
loading={loading}
|
||||
>
|
||||
Confirm cancellation
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
After cancellation, you'll lose access to:
|
||||
</h3>
|
||||
<ul className="space-y-2 text-muted-foreground">
|
||||
<li className="flex items-center">
|
||||
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
|
||||
All premium features
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
|
||||
Priority customer support
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
|
||||
Advanced analytics
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
|
||||
Team collaboration tools
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CancellationBaseModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { CancellationReason } from "../lib/constants";
|
||||
import { CancellationBaseModal } from "./reason-base-modal";
|
||||
|
||||
interface FeedbackModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
reason: CancellationReason;
|
||||
feedback: string;
|
||||
onFeedbackChange: (feedback: string) => void;
|
||||
onBack?: () => void;
|
||||
onContinue: () => void;
|
||||
isFinalStep?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
reason,
|
||||
feedback,
|
||||
onFeedbackChange,
|
||||
onBack,
|
||||
onContinue,
|
||||
isFinalStep = false,
|
||||
loading = false,
|
||||
}: FeedbackModalProps) {
|
||||
const getTitle = () => {
|
||||
if (isFinalStep) {
|
||||
return "Sorry to see you go!";
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case "too_expensive":
|
||||
return "Tell us about pricing";
|
||||
case "unused":
|
||||
return "Help us understand your usage";
|
||||
case "missing_features":
|
||||
return "What features do you need?";
|
||||
case "switched_service":
|
||||
return "What's driving your decision?";
|
||||
case "other":
|
||||
return "Tell us more";
|
||||
default:
|
||||
return "Help us understand what we could improve";
|
||||
}
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (isFinalStep) {
|
||||
return "Please share your feedback to help us improve";
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case "too_expensive":
|
||||
return "What would make the pricing work better for your budget?";
|
||||
case "unused":
|
||||
return "Help us understand how we can make Papermark more valuable for your workflow.";
|
||||
case "missing_features":
|
||||
return "What features are you missing? This helps us prioritize our roadmap.";
|
||||
case "switched_service":
|
||||
return "What does the competitor offer that we don't? This helps us improve.";
|
||||
case "other":
|
||||
return "We'd love to hear your specific feedback.";
|
||||
default:
|
||||
return "Help us understand what we could improve";
|
||||
}
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (isFinalStep) {
|
||||
return "Help us understand what we could improve...";
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case "too_expensive":
|
||||
return "Tell us about your budget constraints or what pricing would work...";
|
||||
case "unused":
|
||||
return "Share how you use Papermark and what would make it more valuable...";
|
||||
case "missing_features":
|
||||
return "Tell us about the features you need...";
|
||||
case "switched_service":
|
||||
return "Tell us about the competitor and what attracted you to them...";
|
||||
case "other":
|
||||
return "Share your thoughts...";
|
||||
default:
|
||||
return "Help us understand what we could improve...";
|
||||
}
|
||||
};
|
||||
|
||||
const cancelButton = (isFinalStep: boolean) => {
|
||||
if (isFinalStep) {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const proceedButton = (isFinalStep: boolean) => {
|
||||
if (isFinalStep) {
|
||||
return (
|
||||
<Button className="ml-auto" onClick={onContinue} loading={loading}>
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return <Button onClick={onContinue}>Submit</Button>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CancellationBaseModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={getTitle()}
|
||||
description={getSubtitle()}
|
||||
cancelButton={cancelButton(isFinalStep)}
|
||||
proceedButton={proceedButton(isFinalStep)}
|
||||
>
|
||||
<Textarea
|
||||
placeholder={getPlaceholder()}
|
||||
value={feedback}
|
||||
onChange={(e) => onFeedbackChange(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-none bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</CancellationBaseModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { PauseSubscriptionModal } from "./pause-subscription-modal";
|
||||
export { RetentionOfferModal } from "./retention-offer-modal";
|
||||
export { FeedbackModal } from "./feedback-modal";
|
||||
export { ConfirmCancellationModal } from "./confirm-cancellation-modal";
|
||||
export { ScheduleCallModal } from "./schedule-call-modal";
|
||||
export { CancellationModal } from "./cancellation-modal";
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { ArrowLeft, CheckCircle2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import { timeIn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
|
||||
interface PauseSubscriptionModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onBack: () => void;
|
||||
onDecline: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PauseSubscriptionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onBack,
|
||||
onDecline,
|
||||
onClose,
|
||||
}: PauseSubscriptionModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { currentTeamId } = useTeam();
|
||||
const { endsAt, plan } = usePlan();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const handlePauseSubscription = async () => {
|
||||
if (!currentTeamId) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${currentTeamId}/billing/pause`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to pause subscription");
|
||||
}
|
||||
|
||||
const pauseStartsAt = endsAt ? new Date(endsAt) : new Date();
|
||||
const pauseEndsAt = new Date(pauseStartsAt);
|
||||
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
|
||||
|
||||
// Track the pause event for analytics
|
||||
analytics.capture("Subscription Paused", {
|
||||
teamId: currentTeamId,
|
||||
plan: plan,
|
||||
pauseStartsAt: pauseStartsAt.toISOString(),
|
||||
pauseEndsAt: pauseEndsAt.toISOString(),
|
||||
pauseDurationDays: 90,
|
||||
});
|
||||
|
||||
toast.success("Subscription paused successfully!");
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan`);
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error pausing subscription:", error);
|
||||
toast.error("Failed to pause subscription. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pauseStartsAt = endsAt ? new Date(endsAt) : new Date();
|
||||
const pauseEndsAt = new Date(pauseStartsAt);
|
||||
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
|
||||
|
||||
const pauseStartsAtString = new Date(pauseStartsAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year:
|
||||
new Date(pauseStartsAt).getFullYear() === new Date().getFullYear()
|
||||
? undefined
|
||||
: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
const pauseEndsAtString = new Date(pauseEndsAt).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year:
|
||||
new Date(pauseEndsAt).getFullYear() === new Date().getFullYear()
|
||||
? undefined
|
||||
: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
showModal={open}
|
||||
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
|
||||
if (typeof show === "function") {
|
||||
onOpenChange(show(open));
|
||||
} else {
|
||||
onOpenChange(show);
|
||||
}
|
||||
}}
|
||||
className="max-w-lg"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
|
||||
<DialogTitle className="text-2xl font-semibold">
|
||||
Pause your subscription
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center text-base text-muted-foreground">
|
||||
Take a break for 3 months and resume when you're ready
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4 dark:bg-gray-900 sm:px-8">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-900/10">
|
||||
<div className="mb-4 text-base font-medium text-green-800 dark:text-green-700">
|
||||
What pausing means for you?
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-500" />
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-700">
|
||||
You pay $0 for 3 months
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-500" />
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-700">
|
||||
All your links continue to work
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-500" />
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-700">
|
||||
All your datarooms are accessible
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePauseSubscription}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
>
|
||||
Pause for 3 months
|
||||
</Button>
|
||||
|
||||
{/* Timeline information */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Pause starts:</span>
|
||||
<span className="font-medium">
|
||||
{pauseStartsAtString}{" "}
|
||||
<span className="italic">({timeIn(pauseStartsAt)})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Reminder email:</span>
|
||||
<span className="font-medium">3 days before resume</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Auto-resume date:</span>
|
||||
<span className="font-medium">{pauseEndsAtString}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onDecline}>
|
||||
Continue to cancellation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
|
||||
export function CancellationBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
onBack,
|
||||
onDecline,
|
||||
onConfirm,
|
||||
confirmButton,
|
||||
showKeepButton,
|
||||
cancelButton,
|
||||
proceedButton,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onBack?: () => void;
|
||||
onDecline?: () => void;
|
||||
onConfirm?: () => void;
|
||||
confirmButton?: React.ReactNode;
|
||||
showKeepButton?: boolean;
|
||||
cancelButton?: React.ReactNode;
|
||||
proceedButton?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
showModal={open}
|
||||
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
|
||||
if (typeof show === "function") {
|
||||
onOpenChange(show(open));
|
||||
} else {
|
||||
onOpenChange(show);
|
||||
}
|
||||
}}
|
||||
className="max-w-lg"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center space-y-2 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
|
||||
<DialogTitle className="text-2xl font-semibold">{title}</DialogTitle>
|
||||
<DialogDescription className="max-w-md text-center text-base text-muted-foreground">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="space-y-6 bg-white px-4 py-6 dark:bg-gray-900 sm:px-8">
|
||||
<div className="space-y-6">{children}</div>
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
{cancelButton}
|
||||
{proceedButton}
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{showKeepButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Stay on Papermark
|
||||
</Button>
|
||||
)}
|
||||
{onDecline && (
|
||||
<Button variant="outline" onClick={onDecline}>
|
||||
Decline offer
|
||||
</Button>
|
||||
)}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { PLANS } from "@/ee/stripe/utils";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import useLimits from "@/lib/swr/use-limits";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { CancellationReason } from "../lib/constants";
|
||||
import { CancellationBaseModal } from "./reason-base-modal";
|
||||
|
||||
interface RetentionOfferModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
reason: CancellationReason;
|
||||
onBack: () => void;
|
||||
onDecline: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RetentionOfferModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
reason,
|
||||
onBack,
|
||||
onDecline,
|
||||
onClose,
|
||||
}: RetentionOfferModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { currentTeamId } = useTeam();
|
||||
const router = useRouter();
|
||||
const { plan: userPlan, isAnnualPlan } = usePlan();
|
||||
const { limits } = useLimits();
|
||||
|
||||
const currentQuantity = limits?.users ?? 1;
|
||||
|
||||
const calculateSavings = () => {
|
||||
// Find current plan pricing
|
||||
const currentPlan = PLANS.find((p) => p.slug === userPlan);
|
||||
if (!currentPlan) return { savings: "€0" };
|
||||
|
||||
const monthlyPrice = currentPlan.price.monthly.unitPrice;
|
||||
const yearlyPrice = currentPlan.price.yearly.unitPrice;
|
||||
|
||||
// Simple logic: 50% discount for 3 months (monthly) or 12 months (annual)
|
||||
const discountPercent = 0.5;
|
||||
const durationMonths = isAnnualPlan ? 12 : 3;
|
||||
const basePrice = isAnnualPlan ? yearlyPrice : monthlyPrice;
|
||||
|
||||
// Calculate savings
|
||||
const totalSavings = Math.round(
|
||||
(basePrice * durationMonths * discountPercent * currentQuantity) / 100,
|
||||
);
|
||||
|
||||
return { savings: `€${totalSavings}` };
|
||||
};
|
||||
|
||||
const getOfferDetails = () => {
|
||||
const { savings } = calculateSavings();
|
||||
|
||||
if (isAnnualPlan) {
|
||||
return {
|
||||
title: "Special offer just for you",
|
||||
subtitle: "Let us make this work for your budget",
|
||||
discount: "50% off your next year",
|
||||
savings,
|
||||
duration: "12 months",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
title: "Special offer just for you",
|
||||
subtitle: "Let us make this work for your budget",
|
||||
discount: "50% off for the next 3 months",
|
||||
savings,
|
||||
duration: "3 months",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const offerDetails = getOfferDetails();
|
||||
|
||||
const handleAcceptOffer = async () => {
|
||||
if (!currentTeamId) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
await fetch(`/api/teams/${currentTeamId}/billing/retention-offer`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
isAnnualPlan,
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan`);
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
|
||||
onClose();
|
||||
toast.success(
|
||||
`50% discount applied for ${isAnnualPlan ? "12 months" : "3 months"}!`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Something went wrong");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CancellationBaseModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={offerDetails.title}
|
||||
description={offerDetails.subtitle}
|
||||
showKeepButton={true}
|
||||
// onBack={onBack}
|
||||
onDecline={onDecline}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border-blue-200 bg-blue-50 text-blue-700"
|
||||
>
|
||||
Special offer
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">Limited time</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="mb-2 text-xl font-semibold">{offerDetails.discount}</h3>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
|
||||
<div className="mb-2 text-2xl font-bold text-green-800">
|
||||
You save {offerDetails.savings}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-green-700">
|
||||
Over {offerDetails.duration}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAcceptOffer}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
>
|
||||
Accept this offer
|
||||
</Button>
|
||||
</CancellationBaseModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { CancellationReason } from "../lib/constants";
|
||||
import { CancellationBaseModal } from "./reason-base-modal";
|
||||
|
||||
interface ScheduleCallModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
reason: CancellationReason;
|
||||
onDecline: () => void;
|
||||
}
|
||||
|
||||
export function ScheduleCallModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
reason,
|
||||
onDecline,
|
||||
}: ScheduleCallModalProps) {
|
||||
const [calLoaded, setCalLoaded] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
(async function () {
|
||||
try {
|
||||
const cal = await getCalApi({ namespace: "papermark-support" });
|
||||
cal("ui", { hideEventTypeDetails: true, layout: "month_view" });
|
||||
setCalLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("Error loading Cal.com:", error);
|
||||
setCalLoaded(true); // Set to true anyway to show fallback
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const getTitle = () => {
|
||||
switch (reason) {
|
||||
case "missing_features":
|
||||
return "Let's talk about what you need";
|
||||
default:
|
||||
return "Schedule a consultation call";
|
||||
}
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
switch (reason) {
|
||||
case "missing_features":
|
||||
return "Our team will reach out to understand your requirements";
|
||||
default:
|
||||
return "We'd love to understand how we can better serve you";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CancellationBaseModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={getTitle()}
|
||||
description={getSubtitle()}
|
||||
showKeepButton={true}
|
||||
onDecline={onDecline}
|
||||
>
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<div className="mb-2 text-lg font-semibold text-green-800">
|
||||
Free consultation
|
||||
</div>
|
||||
<div className="text-sm text-green-700">
|
||||
30-minute call • within 24 hours
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cal.com embed container */}
|
||||
<div className="max-h-[540px] rounded-lg border bg-white dark:bg-gray-800">
|
||||
{calLoaded ? (
|
||||
<Cal
|
||||
namespace="papermark-support"
|
||||
calLink={`marcseitz/papermark-support?email=${session?.user?.email || ""}&name=${session?.user?.name || ""}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "540px",
|
||||
overflow: "scroll",
|
||||
}}
|
||||
config={{ layout: "month_view" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[500px] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg font-semibold">
|
||||
Loading calendar...
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Please wait while we load the booking calendar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CancellationBaseModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface PauseResumeReminderEmailProps {
|
||||
teamName?: string;
|
||||
userName?: string;
|
||||
resumeDate?: string;
|
||||
plan?: string;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://app.papermark.com";
|
||||
|
||||
export default function PauseResumeReminderEmail({
|
||||
teamName = "Your Team",
|
||||
resumeDate = "March 15, 2024",
|
||||
plan = "Pro",
|
||||
}: PauseResumeReminderEmailProps) {
|
||||
const previewText = `${teamName}'s paused subscription will resume billing soon`;
|
||||
|
||||
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-xl font-semibold">
|
||||
Subscription Resume Reminder
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
This is a friendly reminder that your{" "}
|
||||
<span className="font-semibold">{teamName}</span> team's paused
|
||||
subscription will automatically resume billing in{" "}
|
||||
<span className="font-semibold">3 days</span>.
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-semibold leading-6 text-black">
|
||||
What happens next?
|
||||
</Text>
|
||||
<Text className="break-all text-sm leading-6 text-black">
|
||||
<ul>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
Your subscription will resume on{" "}
|
||||
<span className="font-semibold">{resumeDate}</span>
|
||||
</li>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
Billing will restart at your{" "}
|
||||
<span className="font-semibold">{plan}</span> plan rate
|
||||
</li>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
All features will be fully restored
|
||||
</li>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
Your existing data and links remain unchanged
|
||||
</li>
|
||||
</ul>
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-semibold leading-6 text-black">
|
||||
Need to make changes?
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
If you'd like to cancel your subscription or need to update your
|
||||
billing information, you can manage your subscription in your{" "}
|
||||
<span className="font-semibold">account settings</span>.
|
||||
</Text>
|
||||
|
||||
<Section className="my-8 text-center">
|
||||
<Button
|
||||
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
|
||||
href={`${baseUrl}/settings/billing`}
|
||||
style={{ padding: "12px 20px" }}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Hr />
|
||||
<Section className="mt-8 text-gray-400">
|
||||
<Text className="text-xs">
|
||||
© {new Date().getFullYear()} Papermark, Inc.
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
If you have any feedback or questions about this email, simply
|
||||
reply to it.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { sendEmail } from "@/lib/resend";
|
||||
|
||||
import PauseResumeReminderEmail from "../components/pause-resume-reminder";
|
||||
|
||||
export const sendEmailPauseResumeReminder = async ({
|
||||
teamName,
|
||||
plan,
|
||||
resumeDate,
|
||||
teamMemberEmails,
|
||||
}: {
|
||||
teamName: string;
|
||||
plan: string;
|
||||
resumeDate: string;
|
||||
teamMemberEmails: string[];
|
||||
}) => {
|
||||
try {
|
||||
if (!teamMemberEmails || teamMemberEmails.length === 0) {
|
||||
console.log("No team member emails provided");
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
to: teamMemberEmails[0], // Send to first team member
|
||||
cc: teamMemberEmails.slice(1).join(","), // Send to all other team members
|
||||
subject: "Your Papermark subscription will resume in 3 days",
|
||||
react: PauseResumeReminderEmail({
|
||||
teamName,
|
||||
plan,
|
||||
resumeDate,
|
||||
}),
|
||||
test: process.env.NODE_ENV === "development",
|
||||
system: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send team member notification:", e);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type CancellationReason =
|
||||
| "too_expensive"
|
||||
| "unused"
|
||||
| "missing_features"
|
||||
| "switched_service"
|
||||
| "other";
|
||||
@@ -0,0 +1,109 @@
|
||||
import { logger, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
type PauseResumeNotificationPayload = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export const sendPauseResumeNotificationTask = task({
|
||||
id: "send-pause-resume-notification",
|
||||
retry: { maxAttempts: 3 },
|
||||
run: async (payload: PauseResumeNotificationPayload) => {
|
||||
logger.info("Starting pause resume notification", {
|
||||
teamId: payload.teamId,
|
||||
});
|
||||
|
||||
// Verify the team is still paused and get team details
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: payload.teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
pauseEndsAt: true,
|
||||
pausedAt: true,
|
||||
users: {
|
||||
where: {
|
||||
role: {
|
||||
in: ["ADMIN", "MANAGER"],
|
||||
},
|
||||
// Only active team members (not blocked)
|
||||
blockedAt: null,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
logger.error("Team not found for pause resume notification", {
|
||||
teamId: payload.teamId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if team is still paused
|
||||
if (!team.pausedAt || !team.pauseEndsAt) {
|
||||
logger.info("Team is no longer paused, skipping notification", {
|
||||
teamId: payload.teamId,
|
||||
pausedAt: team.pausedAt,
|
||||
pauseEndsAt: team.pauseEndsAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (team.users.length === 0) {
|
||||
logger.info("No admin/manager users found for team", {
|
||||
teamId: payload.teamId,
|
||||
teamName: team.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-pause-resume-notification`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
teamId: payload.teamId,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error("Failed to send pause resume notification", {
|
||||
teamId: payload.teamId,
|
||||
error: await response.text(),
|
||||
});
|
||||
}
|
||||
|
||||
const { message } = (await response.json()) as { message: string };
|
||||
logger.info("Pause resume notification sent successfully", {
|
||||
teamId: payload.teamId,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error sending pause resume notification", {
|
||||
teamId: payload.teamId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("Completed pause resume notifications", {
|
||||
teamId: payload.teamId,
|
||||
});
|
||||
|
||||
return;
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
import {
|
||||
@@ -8,6 +11,7 @@ import {
|
||||
} from "../lib/api/conversations";
|
||||
import { messageService } from "../lib/api/messages";
|
||||
import { notificationService } from "../lib/api/notifications";
|
||||
import { sendConversationTeamMemberNotificationTask } from "../lib/trigger/conversation-message-notification";
|
||||
|
||||
// Route mapping object to handle different paths
|
||||
const routeHandlers = {
|
||||
@@ -39,7 +43,11 @@ const routeHandlers = {
|
||||
},
|
||||
},
|
||||
dataroom: true,
|
||||
dataroomDocument: true,
|
||||
dataroomDocument: {
|
||||
include: {
|
||||
document: true,
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
where: {
|
||||
viewerId,
|
||||
@@ -67,11 +75,13 @@ const routeHandlers = {
|
||||
|
||||
// POST /api/conversations
|
||||
"POST /": async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { dataroomId, viewId, viewerId, ...data } =
|
||||
const { dataroomId, viewId, viewerId, documentId, pageNumber, ...data } =
|
||||
req.body as CreateConversationInput & {
|
||||
dataroomId: string;
|
||||
viewId: string;
|
||||
viewerId?: string;
|
||||
documentId?: string;
|
||||
pageNumber?: number;
|
||||
};
|
||||
|
||||
// Check if conversations are allowed
|
||||
@@ -108,15 +118,83 @@ const routeHandlers = {
|
||||
return res.status(400).json({ error: "Team not found" });
|
||||
}
|
||||
|
||||
// Map documentId to dataroomDocumentId and get version info if provided
|
||||
let enhancedData = { ...data };
|
||||
if (documentId) {
|
||||
const dataroomDocument = await prisma.dataroomDocument.findFirst({
|
||||
where: {
|
||||
dataroomId,
|
||||
documentId,
|
||||
},
|
||||
include: {
|
||||
document: {
|
||||
include: {
|
||||
versions: {
|
||||
where: { isPrimary: true },
|
||||
select: { versionNumber: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (dataroomDocument) {
|
||||
enhancedData.dataroomDocumentId = dataroomDocument.id;
|
||||
|
||||
// Set page number if provided
|
||||
if (pageNumber) {
|
||||
enhancedData.documentPageNumber = pageNumber;
|
||||
}
|
||||
|
||||
// Set document version number from the primary version
|
||||
if (dataroomDocument.document.versions[0]?.versionNumber) {
|
||||
enhancedData.documentVersionNumber =
|
||||
dataroomDocument.document.versions[0].versionNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the conversation
|
||||
const conversation = await conversationService.createConversation({
|
||||
dataroomId,
|
||||
viewerId,
|
||||
viewId,
|
||||
teamId: team.id,
|
||||
data,
|
||||
data: enhancedData,
|
||||
});
|
||||
|
||||
// Get all delayed and queued runs for this dataroom
|
||||
const allRuns = await runs.list({
|
||||
taskIdentifier: ["send-conversation-team-member-notification"],
|
||||
tag: [`conversation_${conversation.id}`],
|
||||
status: ["DELAYED", "QUEUED"],
|
||||
period: "5m",
|
||||
});
|
||||
|
||||
// Cancel any existing unsent notification runs for this dataroom
|
||||
await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));
|
||||
|
||||
waitUntil(
|
||||
sendConversationTeamMemberNotificationTask.trigger(
|
||||
{
|
||||
dataroomId,
|
||||
messageId: conversation.messages[0].id,
|
||||
conversationId: conversation.id,
|
||||
senderUserId: viewerId,
|
||||
teamId: team.id,
|
||||
},
|
||||
{
|
||||
idempotencyKey: `conversation-notification-${team.id}-${dataroomId}-${conversation.id}-${conversation.messages[0].id}`,
|
||||
tags: [
|
||||
`team_${team.id}`,
|
||||
`dataroom_${dataroomId}`,
|
||||
`conversation_${conversation.id}`,
|
||||
],
|
||||
delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return res.status(201).json(conversation);
|
||||
},
|
||||
|
||||
@@ -125,7 +203,7 @@ const routeHandlers = {
|
||||
const { content, viewId, viewerId, conversationId } = req.body as {
|
||||
content: string;
|
||||
viewId: string;
|
||||
viewerId?: string;
|
||||
viewerId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
@@ -141,6 +219,38 @@ const routeHandlers = {
|
||||
viewerId,
|
||||
});
|
||||
|
||||
// Get all delayed and queued runs for this dataroom
|
||||
const allRuns = await runs.list({
|
||||
taskIdentifier: ["send-conversation-team-member-notification"],
|
||||
tag: [`conversation_${message.conversationId}`],
|
||||
status: ["DELAYED", "QUEUED"],
|
||||
period: "5m",
|
||||
});
|
||||
|
||||
// Cancel any existing unsent notification runs for this dataroom
|
||||
await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));
|
||||
|
||||
waitUntil(
|
||||
sendConversationTeamMemberNotificationTask.trigger(
|
||||
{
|
||||
dataroomId: message.conversation.dataroomId,
|
||||
messageId: message.id,
|
||||
conversationId: message.conversationId,
|
||||
senderUserId: viewerId,
|
||||
teamId: message.conversation.teamId,
|
||||
},
|
||||
{
|
||||
idempotencyKey: `conversation-notification-${message.conversation.teamId}-${message.conversation.dataroomId}-${message.conversationId}-${message.id}`,
|
||||
tags: [
|
||||
`team_${message.conversation.teamId}`,
|
||||
`dataroom_${message.conversation.dataroomId}`,
|
||||
`conversation_${message.conversationId}`,
|
||||
],
|
||||
delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return res.status(201).json(message);
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { sendConversationTeamNotification } from "@/ee/features/conversations/emails/lib/send-conversation-team-notification";
|
||||
import { z } from "zod";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
export default async function handle(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
// We only allow POST requests
|
||||
if (req.method !== "POST") {
|
||||
res.status(405).json({ message: "Method Not Allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the API Key from the Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.split(" ")[1]; // Assuming the format is "Bearer [token]"
|
||||
|
||||
// Check if the API Key matches
|
||||
if (token !== process.env.INTERNAL_API_KEY) {
|
||||
res.status(401).json({ message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Define validation schema
|
||||
const requestSchema = z.object({
|
||||
conversationId: z
|
||||
.string()
|
||||
.min(1, "conversationId is required and must be a non-empty string"),
|
||||
dataroomId: z
|
||||
.string()
|
||||
.min(1, "dataroomId is required and must be a non-empty string"),
|
||||
senderUserId: z
|
||||
.string()
|
||||
.min(1, "senderUserId is required and must be a non-empty string"),
|
||||
teamId: z
|
||||
.string()
|
||||
.min(1, "teamId is required and must be a non-empty string"),
|
||||
});
|
||||
|
||||
// Validate request body
|
||||
const validationResult = requestSchema.safeParse(req.body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const firstError = validationResult.error.errors[0];
|
||||
res.status(400).json({
|
||||
message: firstError.message,
|
||||
field: firstError.path[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { conversationId, dataroomId, senderUserId, teamId } =
|
||||
validationResult.data;
|
||||
|
||||
try {
|
||||
// Get all team members (ADMIN/MANAGER) for this team
|
||||
const teamMembers = await prisma.userTeam.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
role: {
|
||||
in: ["ADMIN", "MANAGER"],
|
||||
},
|
||||
// Only active team members (not blocked)
|
||||
blockedAt: null,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
notificationPreferences: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamMembers || teamMembers.length === 0) {
|
||||
res.status(200).json({
|
||||
message: "No team members found for notifications",
|
||||
notified: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out team members who have disabled conversation notifications
|
||||
const eligibleTeamMembers = teamMembers.filter((teamMember) => {
|
||||
if (teamMember.notificationPreferences) {
|
||||
try {
|
||||
const preferences = teamMember.notificationPreferences as {
|
||||
conversations?: boolean;
|
||||
};
|
||||
if (preferences.conversations === false) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
// If preferences parsing fails, include the team member
|
||||
}
|
||||
}
|
||||
return teamMember.user.email; // Only include if they have an email
|
||||
});
|
||||
|
||||
if (eligibleTeamMembers.length === 0) {
|
||||
res.status(200).json({
|
||||
message: "No eligible team members for notifications",
|
||||
notified: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the conversation and dataroom information
|
||||
const conversation = await prisma.conversation.findUnique({
|
||||
where: {
|
||||
id: conversationId,
|
||||
dataroomId: dataroomId,
|
||||
teamId: teamId,
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
dataroom: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
res.status(404).json({ message: "Conversation not found." });
|
||||
return;
|
||||
}
|
||||
|
||||
// First try to find as a user
|
||||
const userSender = await prisma.viewer.findUnique({
|
||||
where: { id: senderUserId },
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
let senderEmail = "";
|
||||
if (userSender) {
|
||||
senderEmail = userSender.email;
|
||||
} else {
|
||||
senderEmail = "Unknown sender";
|
||||
}
|
||||
|
||||
// Generate the URL for team members to access the conversation
|
||||
const conversationUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/datarooms/${dataroomId}/conversations/${conversationId}`;
|
||||
|
||||
// For team members, provide a generic unsubscribe URL to team settings
|
||||
|
||||
// Get all team member emails
|
||||
const teamMemberEmails = eligibleTeamMembers
|
||||
.map((tm) => tm.user.email)
|
||||
.filter((email): email is string => !!email);
|
||||
|
||||
// Send email to all team members at once
|
||||
await sendConversationTeamNotification({
|
||||
conversationTitle: conversation.title || "Untitled Conversation",
|
||||
dataroomName: conversation.dataroom?.name || "Untitled Dataroom",
|
||||
senderEmail,
|
||||
teamMemberEmails,
|
||||
url: conversationUrl,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
message: "Successfully sent conversation notification to team members",
|
||||
notified: teamMemberEmails.length,
|
||||
teamMemberEmails,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
log({
|
||||
message: `Failed to send conversation notification for dataroom ${dataroomId} to team members. \n\n Error: ${error} \n\n*Metadata*: \`{dataroomId: ${dataroomId}, teamId: ${teamId}}\``,
|
||||
type: "error",
|
||||
mention: true,
|
||||
});
|
||||
return res.status(500).json({ message: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,11 @@ const routeHandlers = {
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
dataroomDocument: {
|
||||
include: {
|
||||
document: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
messages: {
|
||||
@@ -117,6 +122,15 @@ const routeHandlers = {
|
||||
viewerEmail: viewer?.email,
|
||||
unreadCount: conversation._count.messages,
|
||||
lastMessage: conversation.messages[0] || null,
|
||||
dataroomDocumentName: conversation.dataroomDocumentId
|
||||
? conversation.dataroomDocument.document.name
|
||||
: undefined,
|
||||
documentPageNumber: conversation.dataroomDocumentId
|
||||
? conversation.documentPageNumber
|
||||
: undefined,
|
||||
documentVersionNumber: conversation.dataroomDocumentId
|
||||
? conversation.documentVersionNumber
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -373,6 +387,11 @@ const routeHandlers = {
|
||||
viewer: true,
|
||||
},
|
||||
},
|
||||
dataroomDocument: {
|
||||
include: {
|
||||
document: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BookOpenIcon, FileTextIcon } from "lucide-react";
|
||||
|
||||
interface ConversationDocumentContextProps {
|
||||
dataroomDocument?: {
|
||||
document: {
|
||||
name: string;
|
||||
type?: string;
|
||||
};
|
||||
};
|
||||
documentPageNumber?: number | null;
|
||||
documentVersionNumber?: number | null;
|
||||
className?: string;
|
||||
showVersionNumber?: boolean; // Controls whether to show version info
|
||||
}
|
||||
|
||||
export function ConversationDocumentContext({
|
||||
dataroomDocument,
|
||||
documentPageNumber,
|
||||
documentVersionNumber,
|
||||
className = "",
|
||||
showVersionNumber = false,
|
||||
}: ConversationDocumentContextProps) {
|
||||
if (!dataroomDocument) return null;
|
||||
|
||||
const hasPageOrVersion =
|
||||
documentPageNumber || (showVersionNumber && documentVersionNumber);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm ${className}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<FileTextIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium">
|
||||
{dataroomDocument.document.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasPageOrVersion && (
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{documentPageNumber && (
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpenIcon className="h-3 w-3" />
|
||||
Page {documentPageNumber}
|
||||
</span>
|
||||
)}
|
||||
{documentPageNumber && showVersionNumber && documentVersionNumber && (
|
||||
<span>•</span>
|
||||
)}
|
||||
{showVersionNumber && documentVersionNumber && (
|
||||
<span>v{documentVersionNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+23
-8
@@ -8,11 +8,33 @@ export function ConversationListItem({
|
||||
navigateToConversation,
|
||||
conversation,
|
||||
isActive,
|
||||
showVersionNumber = false,
|
||||
}: {
|
||||
navigateToConversation: (id: string) => void;
|
||||
conversation: any;
|
||||
isActive: boolean;
|
||||
showVersionNumber?: boolean;
|
||||
}) {
|
||||
// Helper function to format document reference
|
||||
const formatDocumentReference = () => {
|
||||
if (!conversation.dataroomDocumentName) return "Untitled conversation";
|
||||
|
||||
const documentName = conversation.dataroomDocumentName;
|
||||
const parts = [];
|
||||
|
||||
if (conversation.documentPageNumber) {
|
||||
parts.push(`Page ${conversation.documentPageNumber}`);
|
||||
}
|
||||
|
||||
// Only show version number if showVersionNumber is true (admin/team member view)
|
||||
if (showVersionNumber && conversation.documentVersionNumber) {
|
||||
parts.push(`v${conversation.documentVersionNumber}`);
|
||||
}
|
||||
|
||||
const reference = parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
||||
return `${documentName}${reference}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
@@ -40,14 +62,7 @@ export function ConversationListItem({
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-medium">
|
||||
{conversation.title ||
|
||||
(conversation.dataroomDocument
|
||||
? `${conversation.dataroomDocument.document.name}${
|
||||
conversation.documentPageNumber
|
||||
? ` (Page ${conversation.documentPageNumber})`
|
||||
: ""
|
||||
}`
|
||||
: "Untitled conversation")}
|
||||
{formatDocumentReference() || conversation.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-1
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -74,8 +75,11 @@ export function ConversationsNotEnabledBanner({
|
||||
const handleToggleConversations = async (newValue: boolean) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const dataroomIdParsed = z.string().cuid().parse(dataroomId);
|
||||
const teamIdParsed = z.string().cuid().parse(teamId);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}/conversations/toggle-conversations`,
|
||||
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/toggle-conversations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BookOpenIcon, FileTextIcon } from "lucide-react";
|
||||
|
||||
interface ConversationDocumentContextProps {
|
||||
dataroomDocument?: {
|
||||
document: {
|
||||
name: string;
|
||||
type?: string;
|
||||
};
|
||||
};
|
||||
documentPageNumber?: number | null;
|
||||
documentVersionNumber?: number | null;
|
||||
className?: string;
|
||||
showVersionNumber?: boolean; // Controls whether to show version info
|
||||
}
|
||||
|
||||
export function ConversationDocumentContext({
|
||||
dataroomDocument,
|
||||
documentPageNumber,
|
||||
documentVersionNumber,
|
||||
className = "",
|
||||
showVersionNumber = false,
|
||||
}: ConversationDocumentContextProps) {
|
||||
if (!dataroomDocument) return null;
|
||||
|
||||
const hasPageOrVersion =
|
||||
documentPageNumber || (showVersionNumber && documentVersionNumber);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm ${className}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<FileTextIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium">
|
||||
{dataroomDocument.document.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasPageOrVersion && (
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{documentPageNumber && (
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpenIcon className="h-3 w-3" />
|
||||
Page {documentPageNumber}
|
||||
</span>
|
||||
)}
|
||||
{documentPageNumber && showVersionNumber && documentVersionNumber && (
|
||||
<span>•</span>
|
||||
)}
|
||||
{showVersionNumber && documentVersionNumber && (
|
||||
<span>v{documentVersionNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+24
-14
@@ -2,23 +2,19 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ConversationMessage } from "@/ee/features/conversations/components/conversation-message";
|
||||
import { ConversationDocumentContext } from "@/ee/features/conversations/components/shared/conversation-document-context";
|
||||
import { ConversationMessage } from "@/ee/features/conversations/components/shared/conversation-message";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeftIcon, BellIcon, BellOffIcon, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
|
||||
// Type definitions
|
||||
interface Message {
|
||||
@@ -40,6 +36,7 @@ interface Conversation {
|
||||
userId: string | null;
|
||||
viewerId: string | null;
|
||||
documentPageNumber: number | null;
|
||||
documentVersionNumber: number | null;
|
||||
dataroomDocument?: {
|
||||
document: {
|
||||
name: string;
|
||||
@@ -223,19 +220,20 @@ export function ConversationViewSidebar({
|
||||
{/* Conversation Header */}
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setActiveConversation(null)}
|
||||
className="-ml-2 mr-2"
|
||||
className="-ml-2 mr-2 shrink-0"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
<h3 className="truncate font-medium">
|
||||
{activeConversation.title ||
|
||||
"Untitled conversation"}
|
||||
</h3>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate font-medium">
|
||||
{activeConversation.title || "Conversation"}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={
|
||||
@@ -302,6 +300,18 @@ export function ConversationViewSidebar({
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{/* Document Context */}
|
||||
<ConversationDocumentContext
|
||||
dataroomDocument={activeConversation.dataroomDocument}
|
||||
documentPageNumber={
|
||||
activeConversation.documentPageNumber
|
||||
}
|
||||
documentVersionNumber={
|
||||
activeConversation.documentVersionNumber
|
||||
}
|
||||
showVersionNumber={false} // Viewers see simplified context
|
||||
className="mb-2"
|
||||
/>
|
||||
{activeConversation.messages?.map((message) => (
|
||||
<ConversationMessage
|
||||
key={message.id}
|
||||
@@ -62,14 +62,7 @@ export default function ConversationNotification({
|
||||
<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.
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
You received this email from{" "}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
export default function ConversationTeamNotification({
|
||||
conversationTitle,
|
||||
dataroomName,
|
||||
senderEmail,
|
||||
url,
|
||||
}: {
|
||||
conversationTitle: string;
|
||||
dataroomName: string;
|
||||
senderEmail: string;
|
||||
url: string;
|
||||
}) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>New message from visitor in your dataroom</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-xl font-semibold">
|
||||
{`New message in ${dataroomName}`}
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
A visitor (<span className="font-semibold">{senderEmail}</span>)
|
||||
has sent a new message in the conversation{" "}
|
||||
<span className="font-semibold">{conversationTitle}</span> in your
|
||||
dataroom <span className="font-semibold">{dataroomName}</span> on
|
||||
Papermark.
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
As a manager, you're receiving this notification to stay
|
||||
informed about visitor interactions in your dataroom.
|
||||
</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={`${url}`}
|
||||
style={{ padding: "12px 20px" }}
|
||||
>
|
||||
View the conversation
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-sm text-black">
|
||||
or copy and paste this URL into your browser: <br />
|
||||
{`${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()} Papermark, Inc.
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
If you have any feedback or questions about this email, simply
|
||||
reply to it.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user