diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 59fe20a23..963179eee 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; @@ -6,6 +7,7 @@ import { TeamContextType, initialState, useTeam } from "@/context/team-context"; import Cookies from "js-cookie"; import { CogIcon, + ContactIcon, FolderIcon as FolderLucideIcon, FolderOpenIcon, PaletteIcon, @@ -29,7 +31,6 @@ import { AddTeamModal } from "./teams/add-team-modal"; import SelectTeam from "./teams/select-team"; import { Progress } from "./ui/progress"; import { ScrollArea } from "./ui/scroll-area"; -import Link from "next/link"; export default function Sidebar() { return ( @@ -123,6 +124,14 @@ export const SidebarComponent = ({ className }: { className?: string }) => { ? false : true, }, + { + name: "Visitors", + href: "/visitors", + icon: ContactIcon, + current: router.pathname.includes("visitors"), + active: false, + disabled: userPlan === "free" ? true : false, + }, { name: "Branding", href: "/settings/branding", @@ -157,7 +166,7 @@ export const SidebarComponent = ({ className }: { className?: string }) => {

- Papermark{" "} + Papermark {userPlan && userPlan != "free" ? ( {userPlan.charAt(0).toUpperCase() + userPlan.slice(1)} diff --git a/components/visitors/contacts-document-table.tsx b/components/visitors/contacts-document-table.tsx new file mode 100644 index 000000000..4773d32f7 --- /dev/null +++ b/components/visitors/contacts-document-table.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { useRouter } from "next/router"; + +import { useMemo, useState } from "react"; +import React from "react"; + +import { + ColumnDef, + ExpandedState, + SortingState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + ChevronDownIcon, + ChevronUpIcon, + ChevronsUpDownIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { durationFormat, timeAgo } from "@/lib/utils"; +import { fileIcon } from "@/lib/utils/get-file-icon"; + +import { DataTablePagination } from "./data-table-pagination"; + +type DocumentView = { + documentId: string; + document: { + name: string; + type: string; + }; + lastViewed: Date; + totalDuration: number; + viewCount: number; +}; + +const columns: ColumnDef[] = [ + { + accessorKey: "document", + header: "Document Name", + cell: ({ row }) => { + const view = row.original; + return ( +

+ {fileIcon({ + fileType: view.document.type ?? "", + className: "h-7 w-7", + isLight: true, + })} +
+
+

+ {view.document.name} +

+
+
+
+ ); + }, + }, + { + accessorKey: "lastViewed", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const view = row.original; + return ( + + ); + }, + sortingFn: (rowA, rowB) => { + return ( + new Date(rowB.original.lastViewed).getTime() - + new Date(rowA.original.lastViewed).getTime() + ); + }, + }, + { + accessorKey: "totalDuration", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const view = row.original; + return ( +
+ {durationFormat(view.totalDuration)} +
+ ); + }, + }, + { + accessorKey: "viewCount", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const view = row.original; + return ( +
{view.viewCount}
+ ); + }, + }, + // INFO: disable for now until we have more details + // { + // id: "expander", + // header: () => null, + // cell: ({ row }) => { + // return ( + // + // ); + // }, + // }, +]; + +export function ContactsDocumentsTable({ + views, +}: { + views: DocumentView[] | null | undefined; +}) { + const router = useRouter(); + const [sorting, setSorting] = useState([ + { id: "lastViewed", desc: false }, + ]); + const [expanded, setExpanded] = useState({}); + + const data = useMemo(() => views || [], [views]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getExpandedRowModel: getExpandedRowModel(), + onSortingChange: (updater) => { + setSorting((old) => { + const newSorting = + typeof updater === "function" ? updater(old) : updater; + if (newSorting.length > 0) { + const [{ id, desc }] = newSorting; + const prevSorting = old.find((s) => s.id === id); + if (prevSorting) { + if (prevSorting.desc && !desc) { + // If it was descending and now ascending, reset + return []; + } + } + } + return newSorting; + }); + }, + onExpandedChange: setExpanded, + state: { + sorting, + expanded, + }, + }); + + if (!views) { + return ( +
+ + + + Document Name + Last Viewed + Time Spent + Visits + + + + + {[...Array(5)].map((_, index) => ( + + +
+ + +
+
+ + + + + + + + + + + + +
+ ))} +
+
+
+ ); + } + + const handleRowClick = (id: string) => { + router.push(`/documents/${id}`); + }; + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + handleRowClick(row.original.documentId)} + className="cursor-pointer" + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + {row.getIsExpanded() && ( + + + {/* Placeholder for expanded content */} +
+

+ Additional Details +

+

Chart placeholder

+

Rows of all visits placeholder

+
+
+
+ )} +
+ )) + ) : ( + + + No results. + + + )} +
+
+
+ +
+ ); +} diff --git a/components/visitors/contacts-table.tsx b/components/visitors/contacts-table.tsx new file mode 100644 index 000000000..cdd6da669 --- /dev/null +++ b/components/visitors/contacts-table.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useRouter } from "next/router"; + +import { useMemo, useState } from "react"; +import React from "react"; + +import { + ColumnDef, + SortingState, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + ArrowUpDown, + ChevronDownIcon, + ChevronUpIcon, + ChevronsUpDownIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { VisitorAvatar } from "@/components/visitors/visitor-avatar"; + +import { timeAgo } from "@/lib/utils"; + +import { Skeleton } from "../ui/skeleton"; +import { DataTablePagination } from "./data-table-pagination"; + +type Viewer = { + id: string; + email: string; + views: { viewedAt: Date }[]; +}; + +const columns: ColumnDef[] = [ + { + accessorKey: "email", + header: "Contact", + cell: ({ row }) => ( +
+ +
+
+

+ {row.original.email} +

+
+
+
+ ), + }, + { + accessorKey: "lastViewed", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const lastView = row.original.views[0]; + return lastView ? ( + + ) : ( +
-
+ ); + }, + sortingFn: (rowA, rowB) => { + const dateA = rowA.original.views[0]?.viewedAt; + const dateB = rowB.original.views[0]?.viewedAt; + + if (!dateA && !dateB) return 0; + if (!dateA) return 1; + if (!dateB) return -1; + + const timeA = + dateA instanceof Date ? dateA.getTime() : new Date(dateA).getTime(); + const timeB = + dateB instanceof Date ? dateB.getTime() : new Date(dateB).getTime(); + + return timeB - timeA; // Sort in descending order (most recent first) + }, + }, + { + accessorKey: "totalVisits", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( +
+ {row.original.views.length} +
+ ), + sortingFn: (rowA, rowB) => + rowA.original.views.length - rowB.original.views.length, + }, +]; + +export function ContactsTable({ + viewers, +}: { + viewers: Viewer[] | null | undefined; +}) { + const router = useRouter(); + const [sorting, setSorting] = useState([ + { id: "lastViewed", desc: false }, + ]); + + const data = useMemo(() => viewers || [], [viewers]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: (updater) => { + setSorting((old) => { + const newSorting = + typeof updater === "function" ? updater(old) : updater; + if (newSorting.length > 0) { + const [{ id, desc }] = newSorting; + const prevSorting = old.find((s) => s.id === id); + if (prevSorting) { + if (prevSorting.desc && !desc) { + // If it was descending and now ascending, reset + return []; + } + } + } + return newSorting; + }); + }, + state: { + sorting, + }, + }); + + if (!viewers) { + return ( +
+ + + + Name + Last Viewed + Total Visits + + + + {[...Array(5)].map((_, index) => ( + + +
+ + +
+
+ + + + + + +
+ ))} +
+
+
+ ); + } + + const handleRowClick = (id: string) => { + router.push(`/visitors/${id}`); + }; + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + handleRowClick(row.original.id)} + className="cursor-pointer" + > + {row.getVisibleCells().map((cell) => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ); +} diff --git a/components/visitors/data-table-pagination.tsx b/components/visitors/data-table-pagination.tsx new file mode 100644 index 000000000..0c8ab99a4 --- /dev/null +++ b/components/visitors/data-table-pagination.tsx @@ -0,0 +1,103 @@ +import { Table } from "@tanstack/react-table"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface DataTablePaginationProps { + table: Table; + name: string; +} + +export function DataTablePagination({ + table, + name, +}: DataTablePaginationProps) { + const { pageSize, pageIndex } = table.getState().pagination; + const totalRows = table.getFilteredRowModel().rows.length; + const visibleRows = table.getRowModel().rows.length; + + return ( +
+
+ {visibleRows} of {totalRows} {name}s shown +
+
+
+

+ {name.charAt(0).toUpperCase() + name.slice(1)}s per page +

+ +
+
+ Page {pageIndex + 1} of {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} diff --git a/lib/swr/use-viewer.ts b/lib/swr/use-viewer.ts new file mode 100644 index 000000000..008e2744a --- /dev/null +++ b/lib/swr/use-viewer.ts @@ -0,0 +1,35 @@ +import { useRouter } from "next/router"; + +import { useTeam } from "@/context/team-context"; +import { View, Viewer } from "@prisma/client"; +import useSWR from "swr"; + +import { fetcher } from "@/lib/utils"; + +type ViewerWithViews = Viewer & { + views: { + documentId: string; + viewCount: number; + viewIds: string[]; + lastViewed: Date; + }[]; +}; + +export default function useViewer() { + const router = useRouter(); + const teamInfo = useTeam(); + const teamId = teamInfo?.currentTeam?.id; + + const { id } = router.query; + + const { data: viewer, error } = useSWR( + teamId && id && `/api/teams/${teamId}/viewers/${id}`, + fetcher, + ); + + return { + viewer, + loading: !viewer && !error, + error, + }; +} diff --git a/lib/swr/use-viewers.ts b/lib/swr/use-viewers.ts new file mode 100644 index 000000000..4537c6f14 --- /dev/null +++ b/lib/swr/use-viewers.ts @@ -0,0 +1,21 @@ +import { useTeam } from "@/context/team-context"; +import { Viewer } from "@prisma/client"; +import useSWR from "swr"; + +import { fetcher } from "@/lib/utils"; + +export default function useViewers() { + const teamInfo = useTeam(); + const teamId = teamInfo?.currentTeam?.id; + + const { data: viewers, error } = useSWR( + teamId && `/api/teams/${teamId}/viewers`, + fetcher, + ); + + return { + viewers, + loading: !viewers && !error, + error, + }; +} diff --git a/lib/tinybird/endpoints/get_document_duration_per_viewer.pipe b/lib/tinybird/endpoints/get_document_duration_per_viewer.pipe new file mode 100644 index 000000000..4a2695435 --- /dev/null +++ b/lib/tinybird/endpoints/get_document_duration_per_viewer.pipe @@ -0,0 +1,12 @@ +VERSION 1 + +NODE endpoint +SQL > + % + SELECT + SUM(duration) AS sum_duration + FROM + page_views__v3 + WHERE + documentId = {{ String(documentId, required=True)}} + AND viewId IN splitByChar(',', {{ String(viewIds, required=True) }}) diff --git a/lib/tinybird/pipes.ts b/lib/tinybird/pipes.ts index 41fa5eae6..f2c46053f 100644 --- a/lib/tinybird/pipes.ts +++ b/lib/tinybird/pipes.ts @@ -60,3 +60,14 @@ export const getTotalDataroomDuration = tb.buildPipe({ sum_duration: z.number(), }), }); + +export const getDocumentDurationPerViewer = tb.buildPipe({ + pipe: "get_document_duration_per_viewer__v1", + parameters: z.object({ + documentId: z.string(), + viewIds: z.string().describe("Comma separated viewIds"), + }), + data: z.object({ + sum_duration: z.number(), + }), +}); diff --git a/pages/api/teams/[teamId]/viewers/[id]/index.ts b/pages/api/teams/[teamId]/viewers/[id]/index.ts new file mode 100644 index 000000000..7c42a4d53 --- /dev/null +++ b/pages/api/teams/[teamId]/viewers/[id]/index.ts @@ -0,0 +1,161 @@ +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 { getDocumentDurationPerViewer } from "@/lib/tinybird"; +import { CustomUser } from "@/lib/types"; + +export default async function handle( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method === "GET") { + // GET /api/teams/:teamId/viewers/:id + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).end("Unauthorized"); + } + + const { teamId, id } = req.query as { teamId: string; id: string }; + + const userId = (session.user as CustomUser).id; + + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + users: { + some: { + userId, + }, + }, + }, + }); + + if (!team) { + return res.status(404).json({ error: "Team not found" }); + } + + const viewer = await prisma.viewer.findUnique({ + where: { id }, + include: { + views: { + where: { + documentId: { + not: null, + }, + }, + }, + }, + }); + + // same but for documentId + const groupedViewsByDocumentId = viewer?.views.reduce( + ( + acc: Record< + string, + { + documentId: string; + viewCount: number; + viewIds: string[]; + lastViewed: Date; + } + >, + view, + ) => { + const key = view.documentId!; + if (!acc[key]) { + acc[key] = { + documentId: view.documentId ?? "", + viewCount: 0, + viewIds: [], + lastViewed: new Date(0), + }; + } + acc[key].viewCount++; + acc[key].viewIds.push(view.id); + if (view.viewedAt > acc[key].lastViewed) { + acc[key].lastViewed = view.viewedAt; + } + return acc; + }, + {}, + ); + + // get the document details for each documentId + let documentDetails: { + id: string; + name: string | null; + type: string | null; + contentType: string | null; + }[] = []; + if (groupedViewsByDocumentId) { + documentDetails = await prisma.document.findMany({ + where: { + id: { + in: Object.keys(groupedViewsByDocumentId), + }, + }, + select: { + id: true, + name: true, + type: true, + contentType: true, + }, + }); + } + + // Calculate sum_duration for each document + const documentDurations = await Promise.all( + Object.entries(groupedViewsByDocumentId ?? {}).map( + async ([documentId, view]) => { + const durationResult = await getDocumentDurationPerViewer({ + documentId, + viewIds: view.viewIds.join(","), + }); + return { + documentId, + sum_duration: durationResult.data[0].sum_duration, + }; + }, + ), + ); + + // Create a map for quick lookup + const durationMap = new Map( + documentDurations.map((d) => [d.documentId, d.sum_duration]), + ); + + // create a new array with the grouped views and merge with document details + const groupedViews = groupedViewsByDocumentId + ? Object.values(groupedViewsByDocumentId).map((view) => { + const document = documentDetails?.find( + (doc) => doc.id === view.documentId, + ); + + return { + ...view, + document, + totalDuration: durationMap.get(view.documentId) || 0, + }; + }) + : []; + + const newViewer = { + ...viewer, + views: groupedViews, + }; + + return res.status(200).json(newViewer); + } catch (error) { + errorhandler(error, res); + } + } else { + // We only allow GET requests + res.setHeader("Allow", ["GET"]); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } +} diff --git a/pages/api/teams/[teamId]/viewers/index.ts b/pages/api/teams/[teamId]/viewers/index.ts new file mode 100644 index 000000000..fe1ff26af --- /dev/null +++ b/pages/api/teams/[teamId]/viewers/index.ts @@ -0,0 +1,66 @@ +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 === "GET") { + // GET /api/teams/:teamId/viewers + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).end("Unauthorized"); + } + + const { teamId } = req.query as { teamId: string }; + + const userId = (session.user as CustomUser).id; + + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + users: { + some: { + userId, + }, + }, + }, + }); + + if (!team) { + return res.status(404).json({ error: "Team not found" }); + } + + const viewers = await prisma.viewer.findMany({ + where: { teamId }, + include: { + views: { + orderBy: { + viewedAt: "desc", + }, + where: { + documentId: { + not: null, + }, + }, + }, + }, + }); + + return res.status(200).json(viewers); + } catch (error) { + errorhandler(error, res); + } + } else { + // We only allow GET requests + res.setHeader("Allow", ["GET"]); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } +} diff --git a/pages/api/views-dataroom.ts b/pages/api/views-dataroom.ts index 372f5abf9..96e2698f4 100644 --- a/pages/api/views-dataroom.ts +++ b/pages/api/views-dataroom.ts @@ -8,7 +8,6 @@ import { parsePageId } from "notion-utils"; import { hashToken } from "@/lib/api/auth/token"; import sendNotification from "@/lib/api/notification-helper"; import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification"; -import { sendVerificationEmail } from "@/lib/emails/send-email-verification"; import { getFile } from "@/lib/files/get-file"; import { newId } from "@/lib/id-helper"; import notion from "@/lib/notion"; diff --git a/pages/datarooms/index.tsx b/pages/datarooms/index.tsx index 7f918f26e..40fb82dad 100644 --- a/pages/datarooms/index.tsx +++ b/pages/datarooms/index.tsx @@ -1,4 +1,7 @@ import Link from "next/link"; +import { useRouter } from "next/router"; + +import { useEffect } from "react"; import { PlusIcon } from "lucide-react"; @@ -21,27 +24,25 @@ import { usePlan } from "@/lib/swr/use-billing"; import useDatarooms from "@/lib/swr/use-datarooms"; import useLimits from "@/lib/swr/use-limits"; import { daysLeft } from "@/lib/utils"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; export default function DataroomsPage() { const { datarooms } = useDatarooms(); const { plan, trial } = usePlan(); const { limits } = useLimits(); - const router = useRouter() + const router = useRouter(); const numDatarooms = datarooms?.length ?? 0; const limitDatarooms = limits?.datarooms ?? 1; + const isTrial = !!trial; const isBusiness = plan === "business"; const isDatarooms = plan === "datarooms"; - const isTrialDatarooms = trial === "drtrial"; const canCreateUnlimitedDatarooms = isDatarooms || (isBusiness && numDatarooms < limitDatarooms); - useEffect(()=>{ - if(trial == null && plan == 'free') router.push('/documents') - },[trial,plan]) + useEffect(() => { + if (plan == "free" && !isTrial) router.push("/documents"); + }, [isTrial, plan]); return ( @@ -65,7 +66,7 @@ export default function DataroomsPage() { Upgrade to Add Data Room - ) : isTrialDatarooms && datarooms && !isBusiness && !isDatarooms ? ( + ) : isTrial && datarooms && !isBusiness && !isDatarooms ? (
Dataroom Trial: diff --git a/pages/visitors/[id]/index.tsx b/pages/visitors/[id]/index.tsx new file mode 100644 index 000000000..e855bb5ef --- /dev/null +++ b/pages/visitors/[id]/index.tsx @@ -0,0 +1,56 @@ +import Link from "next/link"; + +import { useState } from "react"; + +import AppLayout from "@/components/layouts/app"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Separator } from "@/components/ui/separator"; +import { ContactsDocumentsTable } from "@/components/visitors/contacts-document-table"; + +import useViewer from "@/lib/swr/use-viewer"; + +export default function VisitorDetailPage() { + const { viewer } = useViewer(); + const views = viewer?.views; + + return ( + +
+
+ + + + + All Visitors + + + + + {viewer?.email} + + + +
+

+ {viewer?.email} +

+
+
+ + +
+ +
+ {/* @ts-ignore */} + +
+
+ ); +} diff --git a/pages/visitors/index.tsx b/pages/visitors/index.tsx new file mode 100644 index 000000000..6e77a2a8a --- /dev/null +++ b/pages/visitors/index.tsx @@ -0,0 +1,46 @@ +import { useRouter } from "next/router"; + +import { useEffect, useState } from "react"; + +import AppLayout from "@/components/layouts/app"; +import { Separator } from "@/components/ui/separator"; +import { ContactsTable } from "@/components/visitors/contacts-table"; + +import { usePlan } from "@/lib/swr/use-billing"; +import useViewers from "@/lib/swr/use-viewers"; + +export default function Visitors() { + const router = useRouter(); + const { plan, trial } = usePlan(); + const { viewers } = useViewers(); + + const isTrial = !!trial; + const isFree = plan == "free"; + + useEffect(() => { + if (isFree && !isTrial) router.push("/documents"); + }, [isTrial, isFree]); + + return ( + +
+
+
+

+ All visitors +

+

+ Manage all your visitors in one place. +

+
+
+ +
+ +
+ {/* @ts-ignore */} + +
+
+ ); +}