diff --git a/components/visitors/visitor-avatar.tsx b/components/visitors/visitor-avatar.tsx index 7c7ce76e7..ffa40abf0 100644 --- a/components/visitors/visitor-avatar.tsx +++ b/components/visitors/visitor-avatar.tsx @@ -1,13 +1,19 @@ +import { ArchiveIcon } from "lucide-react"; + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { generateGravatarHash } from "@/lib/utils"; import { cn } from "@/lib/utils"; +import { BadgeTooltip } from "../ui/tooltip"; + export const VisitorAvatar = ({ viewerEmail, + isArchived, className, }: { viewerEmail: string | null; + isArchived?: boolean; className?: string; }) => { // Convert email string to a simple hash @@ -38,6 +44,22 @@ export const VisitorAvatar = ({ return colors[index]; }; + if (isArchived) { + return ( + + + + + + + + ); + } if (!viewerEmail) { return ( diff --git a/components/visitors/visitors-table.tsx b/components/visitors/visitors-table.tsx index 062a9c53e..b2e2d2b18 100644 --- a/components/visitors/visitors-table.tsx +++ b/components/visitors/visitors-table.tsx @@ -1,16 +1,24 @@ import { useState } from "react"; +import { useTeam } from "@/context/team-context"; import { AlertTriangleIcon, + ArchiveIcon, + ArchiveRestoreIcon, BadgeCheckIcon, BadgeInfoIcon, + ChevronRightIcon, DownloadCloudIcon, FileBadgeIcon, FileDigitIcon, + MoreHorizontalIcon, + RefreshCw, ServerIcon, ThumbsDownIcon, ThumbsUpIcon, } from "lucide-react"; +import { toast } from "sonner"; +import { mutate } from "swr"; import ChevronDown from "@/components/shared/icons/chevron-down"; import { @@ -32,10 +40,18 @@ import { BadgeTooltip } from "@/components/ui/tooltip"; import { usePlan } from "@/lib/swr/use-billing"; import { useDocumentVisits } from "@/lib/swr/use-document"; -import { durationFormat, timeAgo } from "@/lib/utils"; +import { cn, durationFormat, timeAgo } from "@/lib/utils"; import { UpgradePlanModal } from "../billing/upgrade-plan-modal"; import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; import { Pagination, PaginationContent, @@ -49,14 +65,59 @@ import { VisitorAvatar } from "./visitor-avatar"; import VisitorChart from "./visitor-chart"; import VisitorUserAgent from "./visitor-useragent"; -export default function VisitorsTable({ numPages }: { numPages: number }) { +export default function VisitorsTable() { + const teamInfo = useTeam(); + const teamId = teamInfo?.currentTeam?.id; const [currentPage, setCurrentPage] = useState(1); const limit = 10; // Set the number of items per page - const { views, loading, error } = useDocumentVisits(currentPage, limit); + const { views, mutate: mutateViews } = useDocumentVisits(currentPage, limit); const { plan } = usePlan(); const isFreePlan = plan === "free"; + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleArchiveView = async ( + viewId: string, + targetId: string, + isArchived: boolean, + ) => { + setIsLoading(true); + + const response = await fetch( + `/api/teams/${teamId}/views/${viewId}/archive`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + isArchived: !isArchived, + }), + }, + ); + + if (!response.ok) { + toast.error("Failed to archive view"); + return; + } + + // mutate the views on the current page + mutateViews(); + // mutate the stats + mutate( + `/api/teams/${teamId}/documents/${encodeURIComponent(targetId)}/stats`, + ); + + toast.success( + !isArchived + ? "View successfully archived" + : "View successfully unarchived", + ); + setIsLoading(false); + }; + return (
@@ -85,74 +146,25 @@ export default function VisitorsTable({ numPages }: { numPages: number }) { )} {views?.viewsWithDuration ? ( - views.viewsWithDuration.map((view) => ( - - <> - + views.viewsWithDuration.map((view) => { + if (view.isArchived) { + return ( + {/* Name */} - +
- +

{view.viewerEmail ? ( - <> - {view.viewerEmail}{" "} - {view.verified && ( - - - - )} - {view.internal && ( - - - - )} - {view.agreementResponse && ( - - - - )} - {view.downloadedAt && ( - - - - )} - {view.dataroomId && ( - - - - )} - {view.feedbackResponse && ( - - {view.feedbackResponse.data.answer === - "yes" ? ( - - ) : ( - - )} - - )} - + <>{view.viewerEmail} ) : ( "Anonymous" )} @@ -186,42 +198,225 @@ export default function VisitorsTable({ numPages }: { numPages: number }) { {timeAgo(view.viewedAt)} + {/* Actions */} - - -

- -
- + + + + + + + Actions + + + { + e.stopPropagation(); + e.preventDefault(); + handleArchiveView( + view.id, + view.documentId ?? "", + view.isArchived, + ); + }} + disabled={isLoading} + > + + Restore + + + - - - <> - - - {!isFreePlan ? ( - - ) : null} -
-
- Document - Version {view.versionNumber} + ); + } + return ( + + <> + + + {/* Name */} + +
+ +
+
+

+ {view.viewerEmail ? ( + <> + {view.viewerEmail}{" "} + {view.verified && ( + + + + )} + {view.internal && ( + + + + )} + {view.agreementResponse && ( + + + + )} + {view.downloadedAt && ( + + + + )} + {view.dataroomId && ( + + + + )} + {view.feedbackResponse && ( + + {view.feedbackResponse.data + .answer === "yes" ? ( + + ) : ( + + )} + + )} + + ) : ( + "Anonymous" + )} +

+

+ {view.link.name + ? view.link.name + : view.linkId} +

+
- +
+ {/* Duration */} + +
+ {durationFormat(view.totalDuration)} +
+
+ {/* Completion */} + +
+ +
+
+ {/* Last Viewed */} + + + + + {/* Actions */} + + + + + + + Actions + + + { + e.stopPropagation(); + e.preventDefault(); + handleArchiveView( + view.id, + view.documentId ?? "", + view.isArchived, + ); + }} + disabled={isLoading} + > + + Archive + + +
- - - -
- )) + + + + <> + + + {!isFreePlan ? ( + + ) : null} +
+
+ Document + Version {view.versionNumber} +
+
+ +
+
+ +
+ + + ); + }) ) : ( diff --git a/lib/swr/use-document.ts b/lib/swr/use-document.ts index 49ff7b4f4..676100e22 100644 --- a/lib/swr/use-document.ts +++ b/lib/swr/use-document.ts @@ -112,15 +112,19 @@ export function useDocumentVisits(page: number, limit: number) { ? `/api/teams/${teamId}/documents/${id}/views?page=${page}&limit=${limit}` : null; - const { data: views, error } = useSWR(cacheKey, fetcher, { + const { + data: views, + error, + mutate, + } = useSWR(cacheKey, fetcher, { dedupingInterval: 20000, - revalidateOnFocus: false, }); return { views, loading: !error && !views, error, + mutate, }; } diff --git a/lib/swr/use-stats.ts b/lib/swr/use-stats.ts index abb6c673c..79402c410 100644 --- a/lib/swr/use-stats.ts +++ b/lib/swr/use-stats.ts @@ -23,6 +23,7 @@ export function useStats({ // this gets the data for a document's graph of all views const router = useRouter(); const teamInfo = useTeam(); + const teamId = teamInfo?.currentTeam?.id; const { id } = router.query as { id: string; @@ -30,10 +31,8 @@ export function useStats({ const { data: stats, error } = useSWR( id && - teamInfo?.currentTeam && - `/api/teams/${teamInfo.currentTeam.id}/documents/${encodeURIComponent( - id, - )}/stats${excludeTeamMembers ? "?excludeTeamMembers=true" : ""}`, + teamId && + `/api/teams/${teamId}/documents/${encodeURIComponent(id)}/stats${excludeTeamMembers ? "?excludeTeamMembers=true" : ""}`, fetcher, { dedupingInterval: 10000, diff --git a/lib/tinybird/endpoints/get_total_average_page_duration.pipe b/lib/tinybird/endpoints/get_total_average_page_duration.pipe index 04244728a..42122babc 100644 --- a/lib/tinybird/endpoints/get_total_average_page_duration.pipe +++ b/lib/tinybird/endpoints/get_total_average_page_duration.pipe @@ -1,4 +1,4 @@ -VERSION 4 +VERSION 5 NODE endpoint SQL > @@ -10,8 +10,8 @@ SQL > WHERE documentId = {{ String(documentId, required=true) }} AND time >= {{ Int64(since, required=true) }} - AND linkId NOT IN {{ Array(excludedLinkIds, String) }} - AND viewId NOT IN {{ Array(excludedViewIds, String) }} + AND linkId NOT IN splitByChar(',', {{ String(excludedLinkIds, required=True) }}) + AND viewId NOT IN splitByChar(',', {{ String(excludedViewIds, required=True) }}) GROUP BY versionNumber, pageNumber, viewId ) SELECT versionNumber, pageNumber, AVG(distinct_duration) AS avg_duration diff --git a/lib/tinybird/pipes.ts b/lib/tinybird/pipes.ts index f2c46053f..992bdc765 100644 --- a/lib/tinybird/pipes.ts +++ b/lib/tinybird/pipes.ts @@ -7,8 +7,8 @@ export const getTotalAvgPageDuration = tb.buildPipe({ pipe: "get_total_average_page_duration__v5", parameters: z.object({ documentId: z.string(), - excludedLinkIds: z.array(z.string()), - excludedViewIds: z.array(z.string()), + excludedLinkIds: z.string().describe("Comma separated linkIds"), + excludedViewIds: z.string().describe("Comma separated viewIds"), since: z.number(), }), data: z.object({ diff --git a/pages/api/teams/[teamId]/documents/[id]/stats.ts b/pages/api/teams/[teamId]/documents/[id]/stats.ts index 6368228ea..09bac4117 100644 --- a/pages/api/teams/[teamId]/documents/[id]/stats.ts +++ b/pages/api/teams/[teamId]/documents/[id]/stats.ts @@ -6,7 +6,6 @@ import { getServerSession } from "next-auth/next"; import { LIMITS } from "@/lib/constants"; import { errorhandler } from "@/lib/errorHandler"; import prisma from "@/lib/prisma"; -import { getTeamWithUsersAndDocument } from "@/lib/team/helper"; import { getTotalAvgPageDuration } from "@/lib/tinybird"; import { CustomUser } from "@/lib/types"; @@ -51,19 +50,6 @@ export default async function handle( }, }); - // const { document } = await getTeamWithUsersAndDocument({ - // teamId, - // userId, - // docId, - // checkOwner: true, - // options: { - // include: { - // views: true, - // team: true, - // }, - // }, - // }); - const users = await prisma.user.findMany({ where: { teams: { @@ -89,22 +75,26 @@ export default async function handle( }); } - const totalViews = views.length; + const activeViews = views.filter((view) => !view.isArchived); + const archivedViews = views.filter((view) => view.isArchived); // limit the number of views to 20 on free plan const limitedViews = document?.team?.plan === "free" ? views.slice(0, LIMITS.views) : views; // exclude views from the team's members - let excludedViews: View[] = []; + let internalViews: View[] = []; if (excludeTeamMembers) { - excludedViews = limitedViews.filter((view) => { + internalViews = limitedViews.filter((view) => { return users.some((user) => user.email === view.viewerEmail); }); } + // combined archived and internal views + const allExcludedViews = [...internalViews, ...archivedViews]; + const filteredViews = limitedViews.filter( - (view) => !excludedViews.map((view) => view.id).includes(view.id), + (view) => !allExcludedViews.map((view) => view.id).includes(view.id), ); const groupedReactions = await prisma.reaction.groupBy({ @@ -112,7 +102,7 @@ export default async function handle( where: { view: { documentId: docId, - id: { notIn: excludedViews.map((view) => view.id) }, + id: { notIn: allExcludedViews.map((view) => view.id) }, }, }, _count: { type: true }, @@ -120,8 +110,8 @@ export default async function handle( const duration = await getTotalAvgPageDuration({ documentId: docId, - excludedLinkIds: [], - excludedViewIds: excludedViews.map((view) => view.id), + excludedLinkIds: "", + excludedViewIds: allExcludedViews.map((view) => view.id).join(","), since: 0, }); @@ -135,7 +125,7 @@ export default async function handle( duration, total_duration, groupedReactions, - totalViews, + totalViews: activeViews.length, }; return res.status(200).json(stats); diff --git a/pages/api/teams/[teamId]/views/[id]/archive.ts b/pages/api/teams/[teamId]/views/[id]/archive.ts new file mode 100644 index 000000000..2871074b1 --- /dev/null +++ b/pages/api/teams/[teamId]/views/[id]/archive.ts @@ -0,0 +1,58 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +import { authOptions } from "@/pages/api/auth/[...nextauth]"; +import { getServerSession } from "next-auth/next"; + +import { errorhandler } from "@/lib/errorHandler"; +import prisma from "@/lib/prisma"; +import { CustomUser } from "@/lib/types"; + +export default async function handle( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method === "PUT") { + // PUT /api/teams/:teamId/views/:id/archive + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).end("Unauthorized"); + } + + const userId = (session.user as CustomUser).id; + const { teamId, id } = req.query as { teamId: string; id: string }; + const { isArchived } = req.body; + + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + users: { some: { userId } }, + }, + }); + + if (!team) { + return res.status(403).json({ error: "Unauthorized" }); + } + + // Update the link in the database + const updatedView = await prisma.view.update({ + where: { id, teamId }, + data: { + isArchived: isArchived, + }, + }); + + if (!updatedView) { + return res.status(404).json({ error: "View not found" }); + } + + return res.status(200).json(updatedView); + } catch (error) { + errorhandler(error, res); + } + } + + // We only allow PUT requests + res.setHeader("Allow", ["PUT"]); + return res.status(405).end(`Method ${req.method} Not Allowed`); +} diff --git a/pages/documents/[id]/index.tsx b/pages/documents/[id]/index.tsx index 8524235a2..7732b6bd9 100644 --- a/pages/documents/[id]/index.tsx +++ b/pages/documents/[id]/index.tsx @@ -98,7 +98,7 @@ export default function DocumentPage() { /> {/* Visitors */} - +