chore: remove deprecated chat

This commit is contained in:
Marc Seitz
2025-12-08 17:00:11 +01:00
parent b36db3a457
commit 4c1694788e
13 changed files with 0 additions and 1387 deletions

View File

@@ -1 +0,0 @@
oss-gg

View File

@@ -1,87 +0,0 @@
import { useEffect, useRef } from "react";
import { AssistantStatus, type Message } from "ai/react";
import Textarea from "react-textarea-autosize";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useEnterSubmit } from "@/lib/utils/use-enter-submit";
import ArrowUp from "../shared/icons/arrow-up";
export function ChatInput({
status,
error,
input,
setInput,
handleInputChange,
submitMessage,
messages,
}: {
status: AssistantStatus;
error: unknown;
input: string;
setInput: (input: string) => void;
handleInputChange: (e: any) => void;
submitMessage: (e: any) => Promise<void>;
messages: Message[];
}) {
const { formRef, onKeyDown } = useEnterSubmit();
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (status === "awaiting_message") {
inputRef.current?.focus();
}
}, [status]);
return (
<div className="relative inset-x-0 bottom-0">
<div className="mx-auto sm:max-w-3xl sm:px-4">
<div className="space-y-4 bg-background px-4 py-4 md:py-4">
<form onSubmit={submitMessage} ref={formRef}>
<div className="relative flex max-h-60 w-full flex-col overflow-hidden rounded-xl bg-background pr-8 ring-1 ring-muted-foreground/50 focus-within:ring-1 focus-within:ring-foreground sm:pr-12">
<Textarea
ref={inputRef}
tabIndex={0}
rows={1}
onKeyDown={onKeyDown}
disabled={status !== "awaiting_message"}
className="min-h-[60px] w-full resize-none border-none bg-transparent px-4 py-[1.3rem] focus:ring-0 sm:text-sm"
value={input}
placeholder="Message Papermark Assistant..."
onChange={handleInputChange}
spellCheck={false}
/>
<div className="absolute bottom-3 right-3">
<Button
type="submit"
disabled={status === "in_progress" || input === ""}
title="Send message"
className="h-10 w-10 rounded-md p-1 md:p-2"
>
<ArrowUp className="h-full w-full" />
<span className="sr-only">Send message</span>
</Button>
</div>
</div>
</form>
</div>
</div>
</div>
);
}
function IconArrowElbow({ className, ...props }: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
fill="currentColor"
className={cn("h-4 w-4", className)}
{...props}
>
<path d="M200 32v144a8 8 0 0 1-8 8H67.31l34.35 34.34a8 8 0 0 1-11.32 11.32l-48-48a8 8 0 0 1 0-11.32l48-48a8 8 0 0 1 11.32 11.32L67.31 168H184V32a8 8 0 0 1 16 0Z" />
</svg>
);
}

View File

@@ -1,52 +0,0 @@
import { type Message } from "ai";
import { Separator } from "@/components/ui/separator";
import Skeleton from "../Skeleton";
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
import { ChatMessage } from "./chat-message";
export interface ChatList {
messages: Message[];
status: "in_progress" | "awaiting_message";
}
export function ChatList({ messages, status }: ChatList) {
if (!messages.length) {
return null;
}
return (
<div className="relative mx-auto max-w-2xl px-4">
{messages.map((message, index) => (
<div key={index}>
<ChatMessage message={message} />
{index < messages.length - 1 && (
<Separator className="my-4 bg-background" />
)}
</div>
))}
{status === "in_progress" && (
<>
<Separator className="my-4 bg-background" />
<div
key={"loading-message"}
className="group relative mb-4 ml-5 flex items-start whitespace-pre-wrap"
>
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow">
<PapermarkSparkle />
</div>
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
<div className="select-none font-semibold">
Papermark Assistant
</div>
<Skeleton className="h-4 w-64" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -1,43 +0,0 @@
"use client";
import { type Message } from "ai";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useCopyToClipboard } from "@/lib/utils/use-copy-to-clipboard";
import Check from "../shared/icons/check";
import Copy from "../shared/icons/copy";
interface ChatMessageActionsProps extends React.ComponentProps<"div"> {
message: Message;
}
export function ChatMessageActions({
message,
className,
...props
}: ChatMessageActionsProps) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
const onCopy = () => {
if (isCopied) return;
copyToClipboard(message.content);
};
return (
<div
className={cn(
"flex items-center justify-end md:absolute md:-top-2 md:right-0 md:hidden",
className,
)}
{...props}
>
<Button variant="ghost" size="icon" onClick={onCopy}>
{isCopied ? <Check /> : <Copy />}
<span className="sr-only">Copy message</span>
</Button>
</div>
);
}

View File

@@ -1,58 +0,0 @@
import { type Message } from "ai";
import { cn } from "@/lib/utils";
import AlertCircle from "../shared/icons/alert-circle";
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
import UserRound from "../shared/icons/user-round";
import { ChatMessageActions } from "./chat-message-actions";
// map role to icon and name
const mapMessageRole = {
user: { icon: <UserRound />, name: "You" },
system: { icon: <AlertCircle />, name: "System" },
assistant: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
function: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
data: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
tool: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
};
export interface ChatMessageProps {
message: Message;
}
export function ChatMessage({ message, ...props }: ChatMessageProps) {
return (
<div
key={message.id}
className={cn(
"group relative mb-4 flex items-start whitespace-pre-wrap md:ml-5",
)}
{...props}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow",
message.role === "user"
? "bg-background"
: message.role === "system"
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground",
)}
>
{mapMessageRole[message.role].icon}
</div>
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
<div className="group relative flex w-[calc(100%-50px)] flex-col">
<div className="select-none font-semibold">
{mapMessageRole[message.role].name}
</div>
<div className="prose break-words font-light dark:prose-invert prose-p:leading-relaxed prose-pre:p-0">
<p className="mb-2 last:mb-0">{message.content}</p>
</div>
</div>
<ChatMessageActions className="group-hover:flex" message={message} />
</div>
</div>
);
}

View File

@@ -1,30 +0,0 @@
"use client";
import * as React from "react";
import { useInView } from "react-intersection-observer";
import { useAtBottom } from "@/lib/utils/use-at-bottom";
interface ChatScrollAnchorProps {
trackVisibility?: boolean;
}
export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
const isAtBottom = useAtBottom();
const { ref, entry, inView } = useInView({
trackVisibility,
delay: 100,
rootMargin: "0px 0px -100px 0px",
});
React.useEffect(() => {
if (isAtBottom && trackVisibility && !inView) {
entry?.target.scrollIntoView({
block: "start",
});
}
}, [inView, entry, isAtBottom, trackVisibility]);
return <div ref={ref} className="h-px w-full" />;
}

View File

@@ -1,128 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import {
type Message,
experimental_useAssistant as useAssistant,
} from "ai/react";
import { nanoid } from "nanoid";
import { BasePlan } from "@/lib/swr/use-billing";
import { cn } from "@/lib/utils";
import { ChatInput } from "./chat-input";
import { ChatList } from "./chat-list";
import { ChatScrollAnchor } from "./chat-scroll-anchor";
import { EmptyScreen } from "./empty-screen";
export interface ChatProps extends React.ComponentProps<"div"> {
initialMessages: Message[];
threadId?: string;
firstPage?: string;
isPublic?: boolean;
userId?: string;
plan?: BasePlan | null;
}
export function Chat({
initialMessages,
threadId,
firstPage,
className,
isPublic,
userId,
plan,
}: ChatProps) {
const {
status,
messages: hookMessages,
input: hookInput,
submitMessage,
handleInputChange,
error,
} = useAssistant({
api: "/api/assistants/chat",
threadId: threadId,
body: {
isPublic: isPublic,
userId: userId,
plan: plan,
},
});
const [combinedMessages, setCombinedMessages] = useState<Message[]>([]);
useEffect(() => {
if (error instanceof Error) {
let content: string = "";
if (isPublic) {
content =
"You have reached your request limit for the day. Sign up for a free account to continue using Papermark Assistant.";
}
if (userId && plan !== "pro") {
content =
"You have reached your request limit for the day. Upgrade to a paid account to continue using Papermark Assistant.";
}
const message: Message = {
role: "system",
content: content,
id: nanoid(),
};
setCombinedMessages((prev) => [...prev, message]);
}
}, [error]);
useEffect(() => {
// Concatenate existing messages with messages from the hook
// and reverse the order so that the newest messages are at the bottom:
const reversedMessages = [...initialMessages].reverse();
setCombinedMessages([...reversedMessages, ...hookMessages]);
}, [initialMessages, hookMessages]);
let isLoading;
if (status === "in_progress") {
isLoading = true;
} else if (status === "awaiting_message") {
isLoading = true;
}
const [_, setInput] = useState<string>(hookInput);
return (
<>
<div
className={cn(
"relative h-[calc(100vh-96px)] overflow-y-auto pb-[20px] pt-24",
className,
)}
>
{combinedMessages.length ? (
<>
<ChatList messages={combinedMessages} status={status} />
{!isPublic ? (
<ChatScrollAnchor trackVisibility={isLoading} />
) : null}
</>
) : (
<EmptyScreen
firstPage={firstPage}
handleInputChange={handleInputChange}
setInput={setInput}
/>
)}
</div>
<ChatInput
status={status}
error={error}
messages={combinedMessages}
input={hookInput}
setInput={setInput}
submitMessage={submitMessage}
handleInputChange={handleInputChange}
/>
</>
);
}

View File

@@ -1,100 +0,0 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import Sparkle from "../shared/icons/sparkle";
const exampleMessages = [
{
heading: "Summarize the document",
message: "Summarize the document in a few sentences and bullet points.",
},
{
heading: "Tell me about the team",
message:
"Tell me more about the team behind the company. If there's no team page in the document, then let me know.",
},
{
heading: "Ask a question",
message: "What does the company do?",
},
{
heading: "What is this document about?",
message: "Please tell me in one sentence what this document is about.",
},
];
export function EmptyScreen({
firstPage,
setInput,
handleInputChange,
}: {
firstPage?: string;
setInput: (input: string) => void;
handleInputChange: (e: any) => void;
}) {
const manageInput = (message: string) => {
setInput(message);
handleInputChange(message);
};
return (
<>
<div className="mx-auto w-1/2 md:w-1/3">
<div className="flex items-center justify-center">
{firstPage ? (
firstPage.includes("cloudfront.net") ? (
<img
className="rounded-md object-contain ring-1 ring-gray-700"
src={firstPage}
alt={`Page 1`}
fetchPriority="high"
/>
) : (
<Image
src={firstPage}
width={768}
height={100}
className="rounded-md object-contain ring-1 ring-gray-700"
alt="First page of the document"
/>
)
) : (
<div className="flex w-full items-center justify-center rounded-md">
<p className="text-center text-2xl">
Chat with{" "}
<span className="text-2xl font-bold tracking-tighter ">
Papermark
</span>
&apos;s pitchdeck
</p>
</div>
)}
</div>
<div className="mt-4 flex justify-center text-lg">
What would you like to know?
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mx-auto max-w-2xl px-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{exampleMessages.map((message, index) => (
<Button
key={index}
variant="outline"
className="h-auto p-4 text-sm"
onClick={() => {
return handleInputChange({
target: { value: message.message },
});
}}
>
{message.heading}
</Button>
))}
</div>
</div>
</>
);
}

View File

@@ -1,157 +0,0 @@
import { Ratelimit } from "@upstash/ratelimit";
import { experimental_AssistantResponse } from "ai";
import { type MessageContentText } from "openai/resources/beta/threads/messages/messages";
import { type Run } from "openai/resources/beta/threads/runs/runs";
import { openai } from "@/lib/openai";
import { redis } from "@/lib/redis";
const ratelimit = {
public: new Ratelimit({
redis,
analytics: true,
prefix: "ratelimit:public",
// rate limit public to 3 request per hour
limiter: Ratelimit.slidingWindow(3, "1h"),
}),
free: new Ratelimit({
redis,
analytics: true,
prefix: "ratelimit:free",
// rate limit to 3 request per day
limiter: Ratelimit.fixedWindow(3, "24h"),
}),
paid: new Ratelimit({
redis,
analytics: true,
prefix: "ratelimit:paid",
limiter: Ratelimit.slidingWindow(60, "10s"),
}),
pro: new Ratelimit({
redis,
analytics: true,
prefix: "ratelimit:pro",
// rate limit to 1000 request per 30 days
limiter: Ratelimit.fixedWindow(1000, "30d"),
}),
};
// IMPORTANT! Set the runtime to edge
export const config = {
runtime: "edge",
};
export default async function POST(req: Request) {
// Parse the request body
const input: {
threadId: string | null;
message: string;
isPublic: boolean | null;
userId: string | null;
plan: string | null;
} = await req.json();
if (input.isPublic) {
const ip = req.headers.get("x-forwarded-for");
const { success, limit, reset, remaining } = await ratelimit.public.limit(
`ratelimit_${ip}`,
);
if (!success) {
return new Response("You have reached your request limit for the day.", {
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
});
}
}
if (input.userId && input.plan !== "pro") {
const { success, limit, reset, remaining } = await ratelimit.free.limit(
`ratelimit_${input.userId}`,
);
if (!success) {
return new Response("You have reached your request limit for the day.", {
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
});
}
}
// create a threadId if one wasn't provided
const threadId = input.threadId ?? (await openai.beta.threads.create()).id;
// Add a message to the thread
const createdMessage = await openai.beta.threads.messages.create(threadId, {
role: "user",
content: input.message,
});
// select the assistantId based on the isPublic flag
const assistantId = input.isPublic
? (process.env.OAI_PUBLIC_ASSISTANT_ID as string)
: (process.env.OAI_ASSISTANT_ID as string);
return experimental_AssistantResponse(
{
threadId,
messageId: createdMessage.id,
},
async ({ threadId, sendMessage }) => {
// Run the assistant on the thread
const run = await openai.beta.threads.runs.create(threadId, {
assistant_id: assistantId!,
});
async function waitForRun(run: Run) {
// Poll for status change
while (run.status === "queued" || run.status === "in_progress") {
// delay for 500ms:
await new Promise((resolve) => setTimeout(resolve, 500));
run = await openai.beta.threads.runs.retrieve(threadId!, run.id);
}
// Check the run status
if (
run.status === "cancelled" ||
run.status === "cancelling" ||
run.status === "failed" ||
run.status === "expired"
) {
throw new Error(run.status);
}
}
await waitForRun(run);
// Get new thread messages (after our message)
const responseMessages = (
await openai.beta.threads.messages.list(threadId, {
after: createdMessage.id,
order: "asc",
})
).data;
// Send the messages
for (const message of responseMessages) {
sendMessage({
id: message.id,
role: "assistant",
content: message.content.filter(
(content) => content.type === "text",
) as Array<MessageContentText>,
});
}
},
);
}

View File

@@ -1,159 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { errorhandler } from "@/lib/errorHandler";
import { getFile } from "@/lib/files/get-file";
import { openai } from "@/lib/openai";
import prisma from "@/lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "POST") {
// POST /api/assistants
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}
const { documentId } = req.body as { documentId: string };
try {
const document = await prisma.document.findUnique({
where: {
id: documentId,
},
select: {
assistantEnabled: true,
versions: {
where: { isPrimary: true },
take: 1,
select: {
id: true,
fileId: true,
file: true,
storageType: true,
},
},
},
});
if (!document) {
res.status(400).json("No document found");
return;
}
if (document.assistantEnabled) {
res.status(200).json("Assistant Already Enabled");
return;
}
// Upload the file to OpenAI
const fileId = (
await openai.files.create({
file: await fetch(
await getFile({
type: document.versions[0].storageType,
data: document.versions[0].file,
}),
),
purpose: "assistants",
})
).id;
// Update the document and documentVersion in the database
await prisma.documentVersion.update({
where: {
id: document.versions[0].id,
},
data: {
fileId: fileId,
document: {
update: {
assistantEnabled: true,
},
},
},
});
res.status(200).json("Assistant Enabled");
return;
} catch (error) {
errorhandler(error, res);
}
} else if (req.method === "DELETE") {
// DELETE /api/assistants
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}
const { documentId } = req.body as { documentId: string };
try {
const document = await prisma.document.findUnique({
where: {
id: documentId,
},
select: {
assistantEnabled: true,
versions: {
where: { isPrimary: true },
take: 1,
select: {
id: true,
fileId: true,
file: true,
},
},
},
});
if (!document) {
res.status(400).json("No document found");
return;
}
if (!document.assistantEnabled) {
res.status(200).json("Assistant is already disabled");
return;
}
// get the open AI file Id from db
const fileId = document.versions[0].fileId;
if (fileId) {
//deleting the file from openai
await openai.files.del(fileId);
}
// Update the document and documentVersion in the database
await prisma.documentVersion.update({
where: {
id: document.versions[0].id,
},
data: {
fileId: null,
document: {
update: {
assistantEnabled: false,
},
},
},
});
res.status(200).json("Assistant Disabled");
return;
} catch (error) {
errorhandler(error, res);
}
} else {
// We only allow POST and DELETE requests
res.setHeader("Allow", ["POST", "DELETE"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@@ -1,87 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { errorhandler } from "@/lib/errorHandler";
import { openai } from "@/lib/openai";
import prisma from "@/lib/prisma";
import { convertThreadMessagesToMessages } from "@/lib/utils";
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "POST") {
// TODO: block unauthorized requests
const { documentId, userId } = req.body as {
documentId: string;
userId: string;
};
try {
let chat;
chat = await prisma.chat.findUnique({
where: {
userId_documentId: {
userId,
documentId,
},
},
select: {
threadId: true,
},
});
if (!chat) {
// get fileId from document version
const documentVersion = await prisma.documentVersion.findFirst({
where: {
documentId,
isPrimary: true,
},
select: {
fileId: true,
},
});
const threadId = (
await openai.beta.threads.create({
messages: [
{
role: "user",
content: "Initializing chat with Papermark Assistant",
file_ids: [documentVersion?.fileId || ""],
metadata: { intitialMessage: true },
},
],
})
).id;
chat = await prisma.chat.create({
data: {
userId,
threadId,
documentId,
},
select: {
threadId: true,
},
});
}
const threadId = chat.threadId;
// get existing messages from thread, latest first (DESC)
const { data } = await openai.beta.threads.messages.list(threadId);
return res.status(200).json({
...chat,
messages: convertThreadMessagesToMessages(data),
});
} catch (error) {
errorhandler(error, res);
}
} else {
// We only allow GET and DELETE requests
res.setHeader("Allow", ["POST"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@@ -1,169 +0,0 @@
import Link from "next/link";
import { useEffect } from "react";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { type Message } from "ai/react";
import { getServerSession } from "next-auth";
import { usePlausible } from "next-plausible";
import { Chat } from "@/components/chat/chat";
import Sparkle from "@/components/shared/icons/sparkle";
import { Button } from "@/components/ui/button";
import { getFile } from "@/lib/files/get-file";
import prisma from "@/lib/prisma";
import { usePlan } from "@/lib/swr/use-billing";
import { CustomUser } from "@/lib/types";
export const getServerSideProps = async (context: any) => {
const { id } = context.params;
const session = await getServerSession(context.req, context.res, authOptions);
if (!session) {
return {
redirect: {
permanent: false,
destination: `/login?next=/documents/${id}/chat`,
},
};
}
const userId = (session.user as CustomUser).id;
const document = await prisma.document.findUnique({
where: {
id,
assistantEnabled: true,
team: {
users: {
some: {
userId: userId,
},
},
},
},
select: {
id: true,
assistantEnabled: true,
versions: {
where: { isPrimary: true },
select: {
pages: {
where: { pageNumber: 1 },
select: {
file: true,
storageType: true,
},
},
},
},
},
});
if (!document) {
return {
notFound: true,
};
}
// create or fetch threadId
const res = await fetch(
`${process.env.NEXTAUTH_URL}/api/assistants/threads`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
documentId: document.id,
userId: userId,
}),
},
);
if (!res.ok) {
return {
notFound: true,
};
}
const { threadId, messages } = await res.json();
const firstPage = document.versions[0].pages[0]
? await getFile({
type: document.versions[0].pages[0].storageType,
data: document.versions[0].pages[0].file,
})
: "";
return {
props: {
threadId,
messages: messages || [],
firstPage,
userId,
documentId: document.id,
},
};
};
export default function ChatPage({
threadId,
messages,
firstPage,
userId,
documentId,
}: {
threadId: string;
messages: Message[];
firstPage: string;
userId: string;
documentId: string;
}) {
const { plan } = usePlan();
const plausible = usePlausible();
useEffect(() => {
plausible("assistantViewedFromDocument", {
props: { documentId: documentId },
});
}, []);
return (
<>
<Nav documentId={documentId} />
<Chat
initialMessages={messages}
threadId={threadId}
firstPage={firstPage}
userId={userId}
plan={plan}
/>
</>
);
}
function Nav({ documentId }: { documentId: string }) {
return (
<nav className="fixed inset-x-0 top-0 z-10 bg-black">
<div className="mx-auto px-2 sm:px-6 lg:px-8">
<div className="relative flex h-16 items-center justify-between">
<div className="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start">
<div className="flex flex-shrink-0 items-center gap-x-2">
<p className="text-2xl font-bold tracking-tighter text-white">
Papermark
</p>
<Sparkle className="h-5 w-5 text-white" />
</div>
</div>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
<Link href={`/documents/${documentId}`}>
<Button variant="secondary">Back to document</Button>
</Link>
</div>
</div>
</div>
</nav>
);
}

View File

@@ -1,316 +0,0 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import useSWR, { mutate } from "swr";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import DocumentHeader from "@/components/documents/document-header";
import AppLayout from "@/components/layouts/app";
import { NavMenu } from "@/components/navigation-menu";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import LoadingSpinner from "@/components/ui/loading-spinner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { usePlan } from "@/lib/swr/use-billing";
import { useDocument } from "@/lib/swr/use-document";
import { cn, fetcher } from "@/lib/utils";
type Feedback = {
id: string;
documentId: string;
enabled: boolean;
data: {
question: string;
type: string;
};
createdAt: Date;
updatedAt: Date;
};
export default function Settings() {
const { document, primaryVersion } = useDocument();
const { plan, isBusiness } = usePlan();
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const id = document?.id;
// const { data: feedback } = useSWR<Feedback>(
// teamId && id && `/api/teams/${teamId}/documents/${id}/feedback`,
// fetcher,
// {
// dedupingInterval: 1000 * 60 * 60,
// },
// );
const [loading, setLoading] = useState(false);
const [loadingStatus, setLoadingStatus] = useState(false);
const [value, setValue] = useState<string>("");
// useEffect(() => {
// setValue(feedback?.data.question || "");
// }, [feedback]);
return (
<AppLayout>
<main className="relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10">
{document && primaryVersion ? (
<>
{/* Action Header */}
<DocumentHeader
primaryVersion={primaryVersion}
prismaDocument={document}
teamId={teamInfo?.currentTeam?.id!}
/>
<NavMenu
navigation={[
{
label: "Overview",
href: `/documents/${document.id}`,
segment: `${document.id}`,
},
{
label: "Settings",
href: `/documents/${document.id}/settings`,
segment: "settings",
},
]}
/>
{/* Settings */}
<div className="mx-auto grid w-full gap-2">
<h1 className="text-2xl font-semibold">Settings</h1>
</div>
<div className="mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]">
<nav className="grid gap-4 text-sm text-muted-foreground">
<Link href="#" className="font-semibold text-primary">
Feedback
</Link>
</nav>
<div className="grid gap-6">
{/* <Card>
<CardHeader>
<CardTitle>Document Name</CardTitle>
<CardDescription>
Used to identify your document.
</CardDescription>
</CardHeader>
<CardContent>
<form>
<Input placeholder="Document Name" />
</form>
</CardContent>
<CardFooter className="border-t px-6 py-3 bg-muted rounded-b-lg justify-end">
<Button>Save</Button>
</CardFooter>
</Card> */}
{/* <Card>
<CardHeader className="relative">
<CardTitle>Feedback Question</CardTitle>
<CardDescription>
This question will be shown to visitors after the last
page of your document.
</CardDescription>
<div className="absolute right-8 top-6">
<span
className="relative ml-auto flex h-4 w-4"
title={`Feedback is ${feedback?.enabled ? "" : "not"} active`}
>
<span
className={cn(
"absolute inline-flex h-full w-full rounded-full opacity-75",
feedback?.enabled
? "animate-ping bg-green-400"
: "",
)}
/>
<span
className={cn(
"relative inline-flex rounded-full h-4 w-4",
feedback?.enabled ? "bg-green-500" : "bg-red-500",
)}
/>
</span>
<span className="sr-only">
{feedback?.enabled ? "Enabled" : "Disabled"}
</span>
</div>
</CardHeader>
<form
onSubmit={async (e) => {
e.preventDefault();
if (value == "" || isNotBusiness) return null;
setLoading(true);
try {
const response = await fetch(
`/api/teams/${teamId}/documents/${id}/feedback`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ questionText: value }),
},
);
if (response.status === 200) {
await mutate(
`/api/teams/${teamId}/documents/${id}/feedback`,
);
toast.success(
"Successfully added a feedback question!",
);
} else {
const { error } = await response.json();
toast.error(error.message);
}
} catch (error) {
// Handle any errors that might occur during fetch
toast.error(
"An error occurred while adding the question.",
);
console.error("Fetch error:", error);
} finally {
setLoading(false);
}
}}
>
<CardContent>
<div className="grid w-full items-start gap-6 overflow-x-visible pb-4 pt-0">
<div className="grid gap-3">
<Label>Question Type</Label>
<Select defaultValue="yes-no">
<SelectTrigger>
<SelectValue placeholder="Select a question type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes-no">Yes / No</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-3">
<Label htmlFor="question">Question</Label>
<Input
id="question"
type="text"
name="question"
required={!isNotBusiness}
placeholder="Are you interested?"
value={value || ""}
onChange={(e) => setValue(e.target.value)}
/>
</div>
</div>
</CardContent>
<CardFooter className="border-t py-3 bg-muted rounded-b-lg justify-end gap-x-2">
{feedback ? (
<Button
type="button"
variant="outline"
loading={loadingStatus}
onClick={async (e) => {
try {
e.preventDefault();
setLoadingStatus(true);
const response = await fetch(
`/api/teams/${teamId}/documents/${id}/feedback`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
enabled: !feedback?.enabled,
}),
},
);
if (response.status === 200) {
await mutate(
`/api/teams/${teamId}/documents/${id}/feedback`,
);
toast.success(
`${feedback?.enabled ? "Turned off" : "Turned on"} feedback question`,
);
} else {
const { error } = await response.json();
toast.error(error.message);
}
} catch (error) {
// Handle any errors that might occur during fetch
toast.error("An error occurred.");
console.error("Fetch error:", error);
} finally {
setLoadingStatus(false);
}
}}
>
{feedback?.enabled ? "Turn off" : "Turn on"}
</Button>
) : null}
{isNotBusiness ? (
<UpgradePlanModal
clickedPlan={"Business"}
trigger={"feedback_question"}
>
<Button type="submit" loading={loading}>
{feedback ? "Update question" : "Create question"}
</Button>
</UpgradePlanModal>
) : (
<Button type="submit" loading={loading}>
{feedback ? "Update question" : "Create question"}
</Button>
)}
</CardFooter>
</form>
</Card> */}
{/* <Card className="border-red-500">
<CardHeader>
<CardTitle>Delete Document</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
This actions deletes the document and any associates links
and analytics.
</p>
</CardContent>
<CardFooter className="border-t px-6 py-3 border-red-500 rounded-b-lg justify-end">
<Button variant="destructive">Delete document</Button>
</CardFooter>
</Card> */}
</div>
</div>
</>
) : (
<div className="flex h-screen items-center justify-center">
<LoadingSpinner className="mr-1 h-20 w-20" />
</div>
)}
</main>
</AppLayout>
);
}