diff --git a/app/api/views-dataroom/route.ts b/app/api/views-dataroom/route.ts index 633af43a5..b9c1a4e69 100644 --- a/app/api/views-dataroom/route.ts +++ b/app/api/views-dataroom/route.ts @@ -120,6 +120,18 @@ export async function POST(request: NextRequest) { allowDownload: true, enableConversation: true, teamId: true, + document: { + select: { + versions: { + where: { + id: documentVersionId, + }, + select: { + file: true, + }, + }, + }, + }, team: { select: { plan: true, @@ -821,6 +833,11 @@ export async function POST(request: NextRequest) { type: documentVersion.storageType, }); } + if (documentVersion.type === "link") { + if (link.document && link.document.versions.length > 0) { + documentVersion.file = link.document.versions[0].file; + } + } if (documentVersion.type === "sheet") { const document = await prisma.document.findUnique({ where: { id: documentId }, @@ -917,7 +934,8 @@ export async function POST(request: NextRequest) { (documentVersion.type === "pdf" || documentVersion.type === "image" || documentVersion.type === "zip" || - documentVersion.type === "video")) || + documentVersion.type === "video" || + documentVersion.type === "link")) || (documentVersion && useAdvancedExcelViewer) ? documentVersion.file : undefined, diff --git a/app/api/views/route.ts b/app/api/views/route.ts index 2ae77def6..12e6b91b4 100644 --- a/app/api/views/route.ts +++ b/app/api/views/route.ts @@ -98,6 +98,18 @@ export async function POST(request: NextRequest) { enableWatermark: true, watermarkConfig: true, teamId: true, + document: { + select: { + versions: { + where: { + id: documentVersionId, + }, + select: { + file: true, + }, + }, + }, + }, team: { select: { plan: true, @@ -498,24 +510,24 @@ export async function POST(request: NextRequest) { ...(link.enableAgreement && link.agreementId && hasConfirmedAgreement && { - agreementResponse: { - create: { - agreementId: link.agreementId, - }, + agreementResponse: { + create: { + agreementId: link.agreementId, }, - }), + }, + }), ...(customFields && link.customFields.length > 0 && { - customFieldResponse: { - create: { - data: link.customFields.map((field) => ({ - identifier: field.identifier, - label: field.label, - response: customFields[field.identifier] || "", - })), - }, + customFieldResponse: { + create: { + data: link.customFields.map((field) => ({ + identifier: field.identifier, + label: field.label, + response: customFields[field.identifier] || "", + })), }, - }), + }, + }), }, select: { id: true }, }); @@ -590,6 +602,14 @@ export async function POST(request: NextRequest) { }); } + if (documentVersion.type === "link") { + if (link.document?.versions && link.document.versions.length > 0) { + documentVersion.file = link.document.versions[0].file; + } else { + throw new Error("Link document version not found."); + } + } + if (documentVersion.type === "sheet") { if (useAdvancedExcelViewer) { if (!documentVersion.file.includes("https://")) { @@ -637,15 +657,16 @@ export async function POST(request: NextRequest) { (documentVersion.type === "pdf" || documentVersion.type === "image" || documentVersion.type === "zip" || - documentVersion.type === "video")) || - (documentVersion && useAdvancedExcelViewer) + documentVersion.type === "video" || + documentVersion.type === "link")) || + (documentVersion && useAdvancedExcelViewer) ? documentVersion.file : undefined, pages: documentPages ? documentPages : undefined, sheetData: documentVersion && - documentVersion.type === "sheet" && - !useAdvancedExcelViewer + documentVersion.type === "sheet" && + !useAdvancedExcelViewer ? sheetData : undefined, fileType: documentVersion @@ -658,10 +679,10 @@ export async function POST(request: NextRequest) { : undefined, ipAddress: link.enableWatermark && - link.watermarkConfig && - WatermarkConfigSchema.parse(link.watermarkConfig).text.includes( - "{{ipAddress}}", - ) + link.watermarkConfig && + WatermarkConfigSchema.parse(link.watermarkConfig).text.includes( + "{{ipAddress}}", + ) ? process.env.VERCEL === "1" ? ipAddress(request) : LOCALHOST_IP diff --git a/components/analytics/links-table.tsx b/components/analytics/links-table.tsx index c6448de47..e895ad034 100644 --- a/components/analytics/links-table.tsx +++ b/components/analytics/links-table.tsx @@ -22,11 +22,16 @@ import { ChevronsUpDownIcon, Copy, Download, - Link2Icon, + LinkIcon, } from "lucide-react"; import { toast } from "sonner"; import useSWR from "swr"; +import { usePlan } from "@/lib/swr/use-billing"; +import { cn, timeAgo } from "@/lib/utils"; +import { fetcher } from "@/lib/utils"; +import { downloadCSV } from "@/lib/utils/csv"; + import { Button } from "@/components/ui/button"; import { Table, @@ -38,11 +43,6 @@ import { } from "@/components/ui/table"; import { DataTablePagination } from "@/components/visitors/data-table-pagination"; -import { usePlan } from "@/lib/swr/use-billing"; -import { cn, timeAgo } from "@/lib/utils"; -import { fetcher } from "@/lib/utils"; -import { downloadCSV } from "@/lib/utils/csv"; - import { UpgradePlanModal } from "../billing/upgrade-plan-modal"; interface Link { @@ -315,7 +315,7 @@ export default function LinksTable({
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -365,7 +365,7 @@ export default function LinksTable({
- +

diff --git a/components/documents/add-document-modal.tsx b/components/documents/add-document-modal.tsx index 873138818..7d3ab2d06 100644 --- a/components/documents/add-document-modal.tsx +++ b/components/documents/add-document-modal.tsx @@ -72,6 +72,7 @@ export function AddDocumentModal({ const [isOpen, setIsOpen] = useState(undefined); const [currentFile, setCurrentFile] = useState(null); const [notionLink, setNotionLink] = useState(null); + const [linkUrl, setLinkUrl] = useState(null); const [showGroupPermissions, setShowGroupPermissions] = useState(false); const [uploadedFiles, setUploadedFiles] = useState< { @@ -503,9 +504,139 @@ export function AddDocumentModal({ } }; + const createLinkFileName = (url: string) => { + try { + const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); + const domain = urlObj.hostname.replace("www.", ""); + return `${domain} - Link`; + } catch { + return "External Link"; + } + }; + + const handleLinkUpload = async ( + event: FormEvent, + ): Promise => { + event.preventDefault(); + + if (!canAddDocuments) { + toast.error("You have reached the maximum number of documents."); + return; + } + const isValidURL = + /^(https?:\/\/)?([a-zA-Z0-9-]+\.){1,}[a-zA-Z]{2,}([a-zA-Z0-9-._~:/?#[\]@!$&'()*+,;=]+)?$/; + if (!linkUrl) { + toast.error("Please enter a link to proceed."); + return; + } + if (!isValidURL.test(linkUrl)) { + toast.error("Please enter a valid URL to proceed."); + return; + } + + try { + setUploading(true); + const finalUrl = linkUrl.startsWith("http") + ? linkUrl + : `https://${linkUrl}`; + + const response = await fetch( + `/api/teams/${teamInfo?.currentTeam?.id}/documents`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: createLinkFileName(finalUrl), + url: finalUrl, + numPages: 1, + type: "link", + createLink: false, + folderPathName: currentFolderPath?.join("/"), + }), + }, + ); + + if (!response.ok) { + const { error } = await response.json(); + toast.error(error); + return; + } + + const document = await response.json(); + + if (isDataroom && dataroomId) { + const dataroomResponse = await addDocumentToDataroom({ + documentId: document.id, + folderPathName: currentFolderPath?.join("/"), + }); + + if (dataroomResponse?.ok) { + const dataroomDocument = + (await dataroomResponse.json()) as DataroomDocument & { + dataroom: { + _count: { viewerGroups: number; permissionGroups: number }; + }; + }; + + await applyUnifiedPermissionsToDocument( + document, + dataroomDocument, + currentFolderPath, + ); + } + + analytics.capture("Document Added", { + documentId: document.id, + name: document.name, + numPages: document.numPages, + path: router.asPath, + type: "link", + teamId: teamId, + dataroomId: dataroomId, + $set: { + teamId: teamId, + teamPlan: plan, + }, + }); + + return; + } + + if (!newVersion) { + toast.success("Link saved. Redirecting to document page..."); + + analytics.capture("Document Added", { + documentId: document.id, + name: document.name, + fileSize: null, + path: router.asPath, + type: "link", + teamId: teamId, + $set: { + teamId: teamId, + teamPlan: plan, + }, + }); + + // redirect to the document page + router.push("/documents/" + document.id); + } + } catch (error) { + setUploading(false); + toast.error("An error occurred while saving the link."); + console.error("An error occurred while processing the link: ", error); + } finally { + setUploading(false); + setIsOpen(false); + } + }; + const clearModelStates = () => { currentFile !== null && setCurrentFile(null); notionLink !== null && setNotionLink(null); + linkUrl !== null && setLinkUrl(null); setIsOpen(!isOpen); setAddDocumentModalOpen && setAddDocumentModalOpen(!isOpen); }; @@ -545,9 +676,10 @@ export function AddDocumentModal({ {!newVersion ? ( - + Document Notion Page + Link ) : ( @@ -679,6 +811,54 @@ export function AddDocumentModal({ )} + {!newVersion && ( + + + + Share a Link + + Add an external link as a document. + + + +
+

+ +
+ setLinkUrl(e.target.value)} + /> +
+ + The link name will be automatically generated from the + URL. + +
+
+ +
+ + + + + )} diff --git a/components/documents/document-header.tsx b/components/documents/document-header.tsx index 5dcee7227..630fa56d4 100644 --- a/components/documents/document-header.tsx +++ b/components/documents/document-header.tsx @@ -537,10 +537,9 @@ export default function DocumentHeader({
- {primaryVersion.type !== "notion" && - primaryVersion.type !== "sheet" && - primaryVersion.type !== "zip" && - primaryVersion.type !== "video" && + {!["link", "notion", "sheet", "zip", "video"].includes( + primaryVersion?.type ?? "", + ) && (!orientationLoading ? (
))} - - {primaryVersion.type !== "notion" && ( + {!["link", "notion"].includes(primaryVersion?.type ?? "") && ( )} - {primaryVersion.type !== "notion" && - primaryVersion.type !== "zip" && - primaryVersion.type !== "map" && - primaryVersion.type !== "email" && ( - - isFree - ? handleUpgradeClick( - PlanEnum.Business, - "download-only-document", - ) - : toggleDownloadOnly() - } - > - {prismaDocument.downloadOnly ? ( - <> - - Set viewable - - ) : ( - <> - - Set download only{" "} - {isFree && } - - )} - - )} + {!["notion", "zip", "map", "email", "link"].includes( + primaryVersion?.type ?? "", + ) && ( + + isFree + ? handleUpgradeClick( + PlanEnum.Business, + "download-only-document", + ) + : toggleDownloadOnly() + } + > + {prismaDocument.downloadOnly ? ( + <> + + Set viewable + + ) : ( + <> + + Set download only{" "} + {isFree && } + + )} + + )} {prismaDocument.type === "notion" && ( <> @@ -792,15 +789,16 @@ export default function DocumentHeader({ {/* Download latest version */} - {primaryVersion.type !== "notion" && - primaryVersion.type !== "video" && ( - downloadDocument(primaryVersion)} - > - - Download latest version - - )} + {!["notion", "video", "link"].includes( + primaryVersion?.type ?? "", + ) && ( + downloadDocument(primaryVersion)} + > + + Download latest version + + )} diff --git a/components/documents/link-analytics.tsx b/components/documents/link-analytics.tsx new file mode 100644 index 000000000..36a841504 --- /dev/null +++ b/components/documents/link-analytics.tsx @@ -0,0 +1,168 @@ +import { useSearchParams } from "next/navigation"; +import { useRouter } from "next/router"; + +import { useState } from "react"; + +import { DocumentVersion, View } from "@prisma/client"; +import { Label } from "@radix-ui/react-label"; +import { formatDistanceToNow } from "date-fns"; +import useSWR from "swr"; + +import { fetcher } from "@/lib/utils"; + +import StatsElement from "@/components/documents/stats-element"; +import { Card, CardContent } from "@/components/ui/card"; +import LoadingSpinner from "@/components/ui/loading-spinner"; + +import { Switch } from "../ui/switch"; + +interface LinkAnalyticsProps { + teamId: string; + documentId: string; + primaryVersion: DocumentVersion; +} + +export default function LinkAnalytics({ + teamId, + documentId, + primaryVersion, +}: LinkAnalyticsProps) { + const searchParams = useSearchParams(); + const router = useRouter(); + + const initialExclude = searchParams?.get("excludeInternal") === "true"; + const [excludeTeamMembers, setExcludeTeamMembers] = + useState(initialExclude); + + const { data, error, isLoading } = useSWR<{ + views: View[]; + totalViews: number; + }>( + `/api/teams/${teamId}/documents/${documentId}/link-analytics${excludeTeamMembers ? "?excludeTeamMembers=true" : ""}`, + fetcher, + ); + + if (error) { + console.error("Error loading link analytics:", error); + return null; + } + + const onToggle = (checked: boolean) => { + setExcludeTeamMembers(checked); + const params = new URLSearchParams(searchParams?.toString()); + params.set("excludeInternal", checked.toString()); + router.push(`${documentId}/?${params.toString()}`); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + if (!data?.views?.length) { + const emptyStats = [ + { + name: "Total Views", + value: "0", + active: false, + }, + { + name: "Total Clicks", + value: "0", + active: false, + }, + { + name: "Last Visited", + value: "Never", + active: false, + }, + ]; + + return ( +
+
+ + +
+
+ {emptyStats.map((stat, index) => ( + + ))} +
+
+ ); + } + + const totalViews = data?.views?.length ?? 0; + const totalClicks = data?.views?.filter((v) => !!v.redirectAt).length ?? 0; + const lastRedirected = data?.views?.length + ? data.views + .map((v) => v.redirectAt) + .filter((d) => typeof d === "string" && !!d) + .sort( + (a, b) => + new Date(b as string).getTime() - new Date(a as string).getTime(), + )[0] + : null; + + const stats = [ + { + name: "Total Views", + value: totalViews.toString(), + active: true, + }, + { + name: "Total Clicks", + value: totalClicks.toString(), + active: true, + }, + { + name: "Last Redirection", + value: lastRedirected + ? formatDistanceToNow(new Date(lastRedirected), { addSuffix: true }) + : "Never Visited", + active: true, + }, + ]; + + return ( +
+
+ + +
+
+
+ {stats.map((stat, index) => ( + + ))} +
+
+
+ ); +} diff --git a/components/links/links-table.tsx b/components/links/links-table.tsx index 23db65bae..395b1edd9 100644 --- a/components/links/links-table.tsx +++ b/components/links/links-table.tsx @@ -896,6 +896,7 @@ export default function LinksTable({ diff --git a/components/links/links-visitors.tsx b/components/links/links-visitors.tsx index 1cdc56803..a0b314120 100644 --- a/components/links/links-visitors.tsx +++ b/components/links/links-visitors.tsx @@ -1,17 +1,20 @@ +import { useLinkVisits } from "@/lib/swr/use-link"; +import { durationFormat, timeAgo } from "@/lib/utils"; + import { Gauge } from "@/components/ui/gauge"; import { Skeleton } from "@/components/ui/skeleton"; import { TableCell, TableRow } from "@/components/ui/table"; +import { BadgeTooltip } from "@/components/ui/tooltip"; import { VisitorAvatar } from "@/components/visitors/visitor-avatar"; -import { useLinkVisits } from "@/lib/swr/use-link"; -import { durationFormat, timeAgo } from "@/lib/utils"; - export default function LinksVisitors({ linkId, linkName, + isLink = false, }: { linkId: string; linkName: string; + isLink?: boolean; }) { const { views } = useLinkVisits(linkId); @@ -36,9 +39,23 @@ export default function LinksVisitors({
- {durationFormat(view.totalDuration)} + {isLink ? ( + + + {view.redirectAt ? timeAgo(view.redirectAt) : "-"} + + + ) : ( + durationFormat(view.totalDuration) + )}
-
+ ) : viewData.fileType === "link" ? ( + ) : document.downloadOnly ? ( (undefined); + const hasRedirected = useRef(false); + + const trackLinkClick = useCallback(() => { + if (!isPreview && viewId) { + try { + navigator.sendBeacon?.( + "/api/record_link_click", + new Blob([JSON.stringify({ viewId, linkId })], { + type: "application/json", + }), + ); + } catch (error) { + console.error("Failed to track link click:", error); + } + } + }, [isPreview, viewId, linkId]); + + const redirect = useCallback(() => { + if (hasRedirected.current) return; + hasRedirected.current = true; + trackLinkClick(); + window.location.replace(linkUrl); + }, [trackLinkClick, linkUrl]); + + useEffect(() => { + let isMounted = true; + setCountdown(5); + autoRedirectTimer.current = window.setTimeout(() => { + if (isMounted) redirect(); + }, 5000); + + const interval = window.setInterval(() => { + setCountdown((prev) => (prev > 1 ? prev - 1 : 1)); + }, 1000); + + return () => { + isMounted = false; + if (autoRedirectTimer.current) { + clearTimeout(autoRedirectTimer.current); + } + clearInterval(interval); + }; + }, [redirect]); + + const handleOpenLink = async () => { + if (autoRedirectTimer.current) { + clearTimeout(autoRedirectTimer.current); + autoRedirectTimer.current = undefined; + } + setIsOpening(true); + await trackLinkClick(); + window.open(linkUrl, "_blank", "noopener,noreferrer"); + window.location.replace("https://www.papermark.com/home"); + }; + + return ( + <> +