feat: document share (#235)

* feat: started document share

* feat: add document share schema

* feat: prepare schema for backend

* feat: document share create trpc route

* feat: hook form with trpc create route

* feat: pass documentId to share document modal

* feat: render document share for public

* feat: add request access form for email and publicId to document share

* feat: generate link using publicId

* feat: layouting page for document analytics

* feat: use publicId for document share url instead of document url

* feat: replace hook form with server action for request access form

* feat: reorganize files to support the navigation and sub-nav flow

* feat: add missing index

* chore: cleaning up document pages

---------

Co-authored-by: Puru D <puru@dahal.me>
This commit is contained in:
Chetan
2024-04-12 04:46:34 -05:00
committed by GitHub
co-authored by Puru D
parent 56ff8ac518
commit 80b8daeb8e
32 changed files with 1871 additions and 1015 deletions
+908 -914
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -23,7 +23,7 @@
"deploy:fly": "fly deploy"
},
"dependencies": {
"@ant-design/plots": "^2.1.15",
"@ark-ui/react": "^2.2.3",
"@aws-sdk/client-s3": "^3.525.0",
"@aws-sdk/s3-request-presigner": "^3.540.0",
"@blocknote/react": "^0.12.2",
@@ -43,6 +43,7 @@
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toolbar": "^1.0.4",
@@ -66,6 +67,7 @@
"cmdk": "^1.0.0",
"dayjs": "^1.11.10",
"html-to-image": "^1.11.11",
"input-otp": "^1.1.0",
"jsx-email": "^1.7.2",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.4",
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "DocumentShare" (
"id" TEXT NOT NULL,
"link" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"recipients" JSONB,
"emailProtected" BOOLEAN NOT NULL DEFAULT false,
"documentId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DocumentShare_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,9 @@
/*
Warnings:
- The `recipients` column on the `DocumentShare` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "DocumentShare" DROP COLUMN "recipients",
ADD COLUMN "recipients" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `expiresAt` on the `DocumentShare` table. All the data in the column will be lost.
- Added the required column `linkExpiresAt` to the `DocumentShare` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "DocumentShare" DROP COLUMN "expiresAt",
ADD COLUMN "linkExpiresAt" TIMESTAMP(3) NOT NULL;
@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `publicId` to the `DocumentShare` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "DocumentShare" ADD COLUMN "publicId" TEXT NOT NULL;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "DocumentShare_documentId_idx" ON "DocumentShare"("documentId");
+21
View File
@@ -322,6 +322,8 @@ model Document {
convertibleNoteId String?
convertibleNote ConvertibleNote? @relation(fields: [convertibleNoteId], references: [id], onDelete: SetNull)
documentShares DocumentShare[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -335,6 +337,25 @@ model Document {
@@index([convertibleNoteId])
}
model DocumentShare {
id String @id @default(cuid())
link String
publicId String
linkExpiresAt DateTime
recipients String[] @default([])
emailProtected Boolean @default(false)
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([documentId])
}
enum FieldTypes {
TEXT
RADIO
@@ -1,5 +0,0 @@
const DocumentDetailPage = async () => {
return <div>document detail page</div>;
};
export default DocumentDetailPage;
@@ -13,6 +13,7 @@ const DocSign = async () => {
return (
<PageLayout
title="eSign documents"
description="Upload, sign and send documents for electronic signatures."
action={<EsignDocumentUpload companyPublicId={user.companyPublicId} />}
/>
);
@@ -0,0 +1,5 @@
const DocumentAnalyticsPage = async () => {
return <div>Document Analytics Page</div>;
};
export default DocumentAnalyticsPage;
@@ -1,13 +1,13 @@
import DocumentsTable from "./table";
import DocumentUploadModal from "./modal";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PageLayout } from "@/components/dashboard/page-layout";
import EmptyState from "@/components/shared/empty-state";
import { RiUploadCloudLine, RiAddFill } from "@remixicon/react";
import { api } from "@/trpc/server";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { withServerSession } from "@/server/auth";
import { api } from "@/trpc/server";
import { RiAddFill, RiUploadCloudLine } from "@remixicon/react";
import { type Metadata } from "next";
import DocumentUploadModal from "./modal";
import DocumentsTable from "./table";
export const metadata: Metadata = {
title: "Documents",
@@ -39,30 +39,28 @@ const DocumentsPage = async () => {
return (
<div className="flex flex-col gap-y-3">
<div className="flex items-center justify-between gap-y-3 ">
<div className="gap-y-3">
<h3 className="font-medium">Documents</h3>
<p className="text-sm text-muted-foreground">
Templates, agreements, and other important documents
</p>
</div>
<div>
<PageLayout
title="Share documents"
description="Share pitch decks, financials, and any other important documents."
action={
<DocumentUploadModal
companyPublicId={session.user.companyPublicId}
trigger={
<Button>
<RiAddFill className="mr-2 h-5 w-5" />
Upload a document
Document
</Button>
}
/>
</div>
</div>
}
/>
<Card className="mt-3">
<div className="p-6">
<DocumentsTable documents={documents} />
<DocumentsTable
companyPublicId={session.user.companyPublicId}
documents={documents}
/>
</div>
</Card>
</div>
@@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { dayjsExt } from "@/common/dayjs";
import { Card } from "@/components/ui/card";
import FileIcon from "@/components/shared/file-icon";
import { RiFileDownloadLine, RiMoreLine } from "@remixicon/react";
import { getPresignedGetUrl } from "@/server/file-uploads";
import {
Table,
TableRow,
TableBody,
TableCell,
TableHead,
TableHeader,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import DocumentShareModal from "@/components/document/share/document-share-modal";
import { type RouterOutputs } from "@/trpc/shared";
type DocumentType = RouterOutputs["document"]["getAll"];
type DocumentTableProps = {
documents: DocumentType;
companyPublicId: string;
};
const DocumentsTable = ({ documents, companyPublicId }: DocumentTableProps) => {
const router = useRouter();
const openFileOnTab = async (key: string) => {
const fileUrl = await getPresignedGetUrl(key);
window.open(fileUrl.url, "_blank");
};
const [openShareModal, setOpenShareModal] = useState(false);
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(
null,
);
return (
<>
<Card>
<Table className="">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{/* <TableHead>Type</TableHead> */}
<TableHead>Owner</TableHead>
<TableHead>Uploaded</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((document) => (
<TableRow key={document.id}>
<TableCell className="flex items-center ">
<FileIcon type={document.bucket.mimeType} />
<span className="flex">{document.name}</span>
</TableCell>
<TableCell>{document.uploader.user.name}</TableCell>
<TableCell suppressHydrationWarning>
{dayjsExt().to(document.createdAt)}
</TableCell>
<TableCell>
<div className="flex items-center gap-4">
<button
onClick={async () => {
await openFileOnTab(document.bucket.key);
}}
className="cursor-pointer text-muted-foreground hover:text-primary/80"
>
<RiFileDownloadLine className="cursor-pointer text-muted-foreground hover:text-primary/80" />
</button>
<DropdownMenu>
<DropdownMenuTrigger>
<RiMoreLine className="cursor-pointer text-muted-foreground hover:text-primary/80" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Options</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (document) {
setSelectedDocumentId(document.id);
}
setOpenShareModal(true);
}}
>
Share document
</DropdownMenuItem>
<DropdownMenuItem>E-sign document</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
router.push(
`/${companyPublicId}/documents/${document.id}/analytics`,
);
}}
>
Analytics
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<DocumentShareModal
title="Share document"
subtitle="Create a link to share this document."
open={openShareModal}
setOpen={setOpenShareModal}
documentId={selectedDocumentId}
/>
</>
);
};
export default DocumentsTable;
@@ -1,72 +0,0 @@
"use client";
import { dayjsExt } from "@/common/dayjs";
import { Card } from "@/components/ui/card";
import FileIcon from "@/components/shared/file-icon";
import { RiFileDownloadLine } from "@remixicon/react";
import { getPresignedGetUrl } from "@/server/file-uploads";
import {
Table,
TableRow,
TableBody,
TableCell,
TableHead,
TableHeader,
} from "@/components/ui/table";
import { type RouterOutputs } from "@/trpc/shared";
type DocumentType = RouterOutputs["document"]["getAll"];
type DocumentTableProps = {
documents: DocumentType;
};
const DocumentsTable = ({ documents }: DocumentTableProps) => {
const openFileOnTab = async (key: string) => {
const fileUrl = await getPresignedGetUrl(key);
window.open(fileUrl.url, "_blank");
};
return (
<Card>
<Table className="">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{/* <TableHead>Type</TableHead> */}
<TableHead>Owner</TableHead>
<TableHead>Uploaded</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((document) => (
<TableRow key={document.id}>
<TableCell className="flex items-center ">
<FileIcon type={document.bucket.mimeType} />
<span className="flex">{document.name}</span>
</TableCell>
<TableCell>{document.uploader.user.name}</TableCell>
<TableCell suppressHydrationWarning>
{dayjsExt().to(document.createdAt)}
</TableCell>
<TableCell>
<button
onClick={async () => {
await openFileOnTab(document.bucket.key);
}}
className="cursor-pointer text-muted-foreground hover:text-primary/80"
>
<RiFileDownloadLine className="cursor-pointer text-muted-foreground hover:text-primary/80" />
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
);
};
export default DocumentsTable;
@@ -0,0 +1,51 @@
"use server";
import { z } from "zod";
import { db } from "@/server/db";
import { revalidatePath } from "next/cache";
import { notFound } from "next/navigation";
const schema = z.object({
email: z.string().min(1, "Email is required").email(),
publicId: z.string().min(1, "Public Id is required"),
});
export async function provideRequestAccess(_: unknown, formData: FormData) {
const validatedFields = schema.safeParse({
email: formData.get("email"),
publicId: formData.get("publicId"),
});
if (!validatedFields.success) {
return {
emailError: validatedFields.error.flatten().fieldErrors.email,
};
}
const { publicId } = validatedFields.data;
const documentShare = await db.documentShare.findFirst({
where: {
publicId,
},
});
if (!documentShare) {
return notFound();
}
await db.documentShare.update({
where: {
id: documentShare.id,
},
data: {
emailProtected: false,
},
});
revalidatePath(`/document/${publicId}`);
return {
emailError: "",
};
}
@@ -0,0 +1,15 @@
"use client";
import { PdfViewer } from "@/components/ui/pdf-viewer";
type DocumentSharePdfViewerProps = {
url: string;
};
export const DocumentSharePdfViewer = ({
url,
}: DocumentSharePdfViewerProps) => {
return (
<PdfViewer onDocumentLoadSuccess={() => console.log("heer")} file={url} />
);
};
@@ -0,0 +1,57 @@
import { AccessRequestForm } from "@/components/document/share/access-request-form";
import { DocumentOTPForm } from "@/components/document/share/document-otp-form";
import { db } from "@/server/db";
import { getPresignedGetUrl } from "@/server/file-uploads";
import { notFound } from "next/navigation";
import { DocumentSharePdfViewer } from "./document-share-pdf-viewer";
const DocumentSharePublicPage = async ({
params: { publicId },
}: {
params: { publicId: string };
}) => {
const documentShare = await db.documentShare.findFirst({
where: {
publicId,
},
});
if (!documentShare) {
return notFound();
}
if (documentShare.emailProtected) {
return (
<div className="grid h-screen w-full place-items-center bg-gray-100">
<AccessRequestForm publicId={publicId} />
<DocumentOTPForm />
</div>
);
}
const document = await db.document.findFirst({
where: {
id: documentShare.documentId,
},
select: {
bucket: {
select: {
key: true,
},
},
},
});
if (!document) {
return notFound();
}
const { url } = await getPresignedGetUrl(document.bucket.key);
return (
<div className="grid h-screen w-full bg-gray-100">
<DocumentSharePdfViewer url={url} />
</div>
);
};
export default DocumentSharePublicPage;
@@ -0,0 +1,53 @@
"use client";
import { provideRequestAccess } from "@/app/documents/share/[publicId]/actions";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@radix-ui/react-label";
import { RiFileLockLine } from "@remixicon/react";
import { useFormState, useFormStatus } from "react-dom";
export const AccessRequestForm = ({ publicId }: { publicId: string }) => {
const [state, formAction] = useFormState(provideRequestAccess, {
emailError: "",
});
const { pending } = useFormStatus();
return (
<Card className="flex w-full max-w-md flex-col items-center justify-center py-4">
<CardHeader className="flex items-center justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-green-400">
<RiFileLockLine className="h-12 w-12 text-neutral-800" />
</div>
<CardTitle>Request Document Access</CardTitle>
<CardDescription>This document is email protected.</CardDescription>
</CardHeader>
<CardContent className="w-full">
<form
action={formAction}
className="flex flex-col items-center justify-center space-y-6"
>
<input type="hidden" name="publicId" value={publicId} />
<div className="w-full">
<Label htmlFor="email">Your Email</Label>
<Input className="w-full" id="email" name="email" type="email" />
<div>{state?.emailError}</div>
</div>
<Button size="sm" className="w-full" type="submit" disabled={pending}>
Submit
</Button>
</form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,106 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { RiShieldCheckLine } from "@remixicon/react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { toast } from "@/components/ui/use-toast";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
const FormSchema = z.object({
otp: z.string().min(6, {
message: "Your one-time password must be 6 characters.",
}),
});
export function DocumentOTPForm() {
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
otp: "",
},
});
function onSubmit(data: z.infer<typeof FormSchema>) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
});
}
return (
<Form {...form}>
<Card className="flex w-full max-w-lg flex-col items-center justify-center py-4">
<CardHeader className="flex items-center justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-green-400">
<RiShieldCheckLine className="h-12 w-12 text-gray-600" />
</div>
<CardTitle>Document Protected</CardTitle>
<CardDescription>
This document is protected. Please enter your one-time password.
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col items-center justify-center space-y-6"
>
<FormField
control={form.control}
name="otp"
render={({ field }) => (
<FormItem>
<FormLabel>Enter OTP</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
render={({ slots }) => (
<InputOTPGroup>
{slots.map((slot, index) => (
<InputOTPSlot key={index} {...slot} />
))}{" "}
</InputOTPGroup>
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button size="sm" className="w-full" type="submit">
Submit
</Button>
</form>
</CardContent>
</Card>
</Form>
);
}
@@ -0,0 +1,257 @@
"use client";
import { useRouter } from "next/navigation";
import { TagsInput } from "@ark-ui/react";
import { RiClipboardLine, RiCloseLine } from "@remixicon/react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { cn } from "@/lib/utils";
import { api } from "@/trpc/react";
import { OpenCapLogo } from "@/components/shared/logo";
import {
DocumentShareMutationSchema,
type TypeDocumentShareMutation,
} from "@/trpc/routers/document-share-router/schema";
import { useToast } from "@/components/ui/use-toast";
import { useEffect } from "react";
import { generatePublicId } from "@/common/id";
type DocumentShareModalProps = {
title: string | React.ReactNode;
subtitle: string | React.ReactNode;
size?: "sm" | "md" | "lg" | "xl" | "2xl";
open: boolean;
documentId: string | null;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const formSchema = DocumentShareMutationSchema;
const DocumentShareModal = ({
open,
setOpen,
size,
title,
subtitle,
documentId,
}: DocumentShareModalProps) => {
const publicId = generatePublicId();
const form = useForm<TypeDocumentShareMutation>({
resolver: zodResolver(formSchema),
defaultValues: {
linkExpiresAt: new Date(),
link: `http://localhost:3000/document/${publicId}`, // TODO: generate a link
emailProtected: true,
publicId,
},
});
const { toast } = useToast();
useEffect(() => {
if (documentId) {
form.setValue("documentId", documentId);
}
}, [documentId, form]);
const { mutateAsync } = api.documentShare.create.useMutation({
onSuccess: async ({ success, message }) => {
toast({
variant: success ? "default" : "destructive",
title: success
? "🎉 Successfully created"
: "Uh oh! Something went wrong.",
description: message,
});
form.reset();
setOpen(false);
router.refresh();
},
});
const router = useRouter();
const onSubmit = async (values: TypeDocumentShareMutation) => {
await mutateAsync(values);
form.reset();
router.refresh();
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className={cn(
"mb-10 mt-10 gap-0 bg-white p-0",
size === "sm" && "sm:max-w-sm",
size === "md" && "sm:max-w-md",
size === "lg" && "sm:max-w-lg",
size === "xl" && "sm:max-w-xl",
size === "2xl" && "sm:max-w-2xl",
)}
>
<div className="no-scrollbar max-h-[80vh] overflow-scroll">
<header className="border-b border-gray-200 p-5">
<div className="">
<DialogHeader>
<div className="flex justify-center">
<OpenCapLogo className="mb-3 h-10 w-10" />
</div>
<DialogTitle className="mb-4 text-center">{title}</DialogTitle>
<DialogDescription className="text-center">
{subtitle}
</DialogDescription>
</DialogHeader>
</div>
</header>
<section className=" bg-gray-100 px-8 py-5">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-6 md:col-span-2">
<div className="relative flex items-center gap-2 sm:col-span-6">
<div className="w-full">
<FormField
control={form.control}
name="link"
render={({ field }) => (
<FormItem>
<FormLabel>Link</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage className="text-xs font-light" />
</FormItem>
)}
/>
</div>
<div className="flex items-center">
<button
type="button"
className="inine-block rounded border p-2"
>
<RiClipboardLine />
</button>
</div>
</div>
<div className="sm:col-span-full">
<FormField
control={form.control}
name="linkExpiresAt"
render={({ field }) => (
<FormItem>
<FormLabel>Link Expires At (optional)</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={
field.value
? new Date(field.value)
.toISOString()
.split("T")[0]
: ""
}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</FormItem>
)}
/>
</div>
</div>
<div className="my-4 sm:col-span-full">
<TagsInput.Root
className="flex flex-col space-y-2"
max={3}
allowOverflow
defaultValue={[]}
>
{(api) => (
<>
<TagsInput.Label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Recipients (optional)
</TagsInput.Label>
<TagsInput.Control className="flex min-w-full flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
{api.value.map((value, index) => (
<TagsInput.Item
key={index}
index={index}
value={value}
>
<TagsInput.ItemPreview className="inline-flex items-center rounded-md border px-1 py-0.5 text-xs">
<TagsInput.ItemText>{value}</TagsInput.ItemText>
<TagsInput.ItemDeleteTrigger className="ml-0.5">
<RiCloseLine className="h-4 w-4" />
</TagsInput.ItemDeleteTrigger>
</TagsInput.ItemPreview>
</TagsInput.Item>
))}
<TagsInput.Input
placeholder="Add Recipients"
className="border-none bg-transparent outline-none"
/>
</TagsInput.Control>
</>
)}
</TagsInput.Root>
</div>
<div className="mt-8 flex justify-between">
<FormField
control={form.control}
name="emailProtected"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="order-last">
Require Email Protection
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
</FormItem>
)}
/>
<Button loadingText="Submitting..." type="submit">
Share document
</Button>
</div>
</form>
</Form>
</section>
</div>
</DialogContent>
</Dialog>
);
};
export default DocumentShareModal;
@@ -4,6 +4,7 @@ import Modal from "@/components/shared/modal";
import { Button } from "@/components/ui/button";
import Uploader from "@/components/ui/uploader";
import { api } from "@/trpc/react";
import { RiAddFill } from "@remixicon/react";
import { useRouter } from "next/navigation";
interface EsignDocumentUploadProps {
@@ -18,7 +19,12 @@ const EsignDocumentUpload = ({ companyPublicId }: EsignDocumentUploadProps) => {
<Modal
title="Upload equity document"
subtitle=""
trigger={<Button>Upload Document</Button>}
trigger={
<Button>
<RiAddFill className="mr-2 h-5 w-5" />
Document
</Button>
}
>
<Uploader
identifier={companyPublicId}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import * as React from "react";
import { OTPInput, type SlotProps } from "input-otp";
import { RiCrossLine } from "@remixicon/react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
SlotProps & React.ComponentPropsWithoutRef<"div">
>(({ char, hasFakeCaret, isActive, className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<RiCrossLine />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+29
View File
@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
-1
View File
@@ -22,7 +22,6 @@ const countries = [
{ name: "Indonesia", code: "ID" },
{ name: "Switzerland", code: "CH" },
{ name: "Italy", code: "IT" },
{ name: "Afghanistan", code: "AF" },
{ name: "Åland Islands", code: "AX" },
{ name: "Albania", code: "AL" },
+3 -1
View File
@@ -36,6 +36,8 @@ export const AuditSchema = z.object({
"safe.sent",
"safe.signed",
"safe.deleted",
"documentShare.created",
]),
occurredAt: z.date().optional(),
actor: z.object({
@@ -45,7 +47,7 @@ export const AuditSchema = z.object({
target: z.array(
z.object({
type: z.enum(["user", "company", "document", "option"]),
type: z.enum(["user", "company", "document", "option", "documentShare"]),
id: z.string().optional().nullable(),
}),
),
+2
View File
@@ -4,6 +4,7 @@ import { companyRouter } from "../routers/company-router/router";
import { shareClassRouter } from "../routers/share-class/router";
import { equityPlanRouter } from "../routers/equity-plan/router";
import { documentRouter } from "../routers/document-router/router";
import { documentShareRouter } from "../routers/document-share-router/router";
import { onboardingRouter } from "../routers/onboarding-router/router";
import { memberRouter } from "../routers/member-router/router";
import { bucketRouter } from "../routers/bucket-router/router";
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
audit: auditRouter,
company: companyRouter,
document: documentRouter,
documentShare: documentShareRouter,
onboarding: onboardingRouter,
shareClass: shareClassRouter,
equityPlan: equityPlanRouter,
@@ -0,0 +1,62 @@
import { withAuth, type withAuthTrpcContextType } from "@/trpc/api/trpc";
import {
type TypeDocumentShareMutation,
DocumentShareMutationSchema,
} from "../schema";
import { Audit } from "@/server/audit";
interface CreateDocumentShareHandlerOptions {
input: TypeDocumentShareMutation;
ctx: withAuthTrpcContextType;
}
export const createDocumentShareHandler = async ({
ctx,
input,
}: CreateDocumentShareHandlerOptions) => {
const user = ctx.session.user;
const { userAgent, requestIp } = ctx;
const companyId = user.companyId;
const { recipients, ...rest } = input;
try {
await ctx.db.$transaction(async (tx) => {
const documentShare = await ctx.db.documentShare.create({
data: {
...rest,
recipients: recipients ? [recipients] : [],
},
});
await Audit.create(
{
companyId,
action: "documentShare.created",
actor: { type: "user", id: user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "documentShare", id: documentShare.id }],
summary: `${user.name} created a document share: ${documentShare.link}`,
},
tx,
);
});
return { success: true, message: "Document share created successfully." };
} catch (err) {
console.log("err here ====>", err);
return {
success: false,
message: "Oops, something went wrong. Please try again later.",
};
}
};
export const createDocumentShareProcedure = withAuth
.input(DocumentShareMutationSchema)
.mutation(async (opts) => {
return createDocumentShareHandler(opts);
});
@@ -0,0 +1,6 @@
import { createTRPCRouter } from "@/trpc/api/trpc";
import { createDocumentShareProcedure } from "./procedures/create-document-share";
export const documentShareRouter = createTRPCRouter({
create: createDocumentShareProcedure,
});
@@ -0,0 +1,22 @@
import { z } from "zod";
export const DocumentShareMutationSchema = z.object({
id: z.string().optional(),
link: z.string().min(1, {
message: "Link is required",
}),
linkExpiresAt: z.coerce.date({
required_error: "Link expiry date is required",
invalid_type_error: "This is not a valid date",
}),
recipients: z.string().optional(),
emailProtected: z.boolean().default(true),
documentId: z.string().min(1, {
message: "Document Id is required",
}),
publicId: z.string().min(1, "PublicId is required"),
});
export type TypeDocumentShareMutation = z.infer<
typeof DocumentShareMutationSchema
>;
+5
View File
@@ -78,10 +78,15 @@ const config = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
},