mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
feat: create a sharable investor update page (#211)
* feat: add loading effect to profile update * feat: update loading icon to logo icon * feat: rendering investor updates public page * feat: update placeholder image * feat: minor cleanups * chore: stopping point before render experiments, current render is broken because of existing tailwind, trying webcomponent * feat: complete rendering investor update on a public page
This commit is contained in:
Generated
+41
@@ -77,6 +77,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/eslint": "^8.56.5",
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@@ -5865,6 +5866,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.10.tgz",
|
||||
"integrity": "sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lodash.castarray": "^4.4.0",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "4.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.36.1.tgz",
|
||||
@@ -13290,6 +13319,18 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
|
||||
},
|
||||
"node_modules/lodash.castarray": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
|
||||
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/eslint": "^8.56.5",
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Company" ADD COLUMN "logo" TEXT;
|
||||
@@ -70,6 +70,7 @@ model VerificationToken {
|
||||
model Company {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
logo String?
|
||||
publicId String
|
||||
incorporationType String
|
||||
incorporationDate DateTime
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#FBEECC"/>
|
||||
<path d="M142.545 133.465H152V142.898H48V133.465H57.4545V62.7166C57.4545 60.1117 59.571 58 62.1818 58H109.455C112.065 58 114.182 60.1117 114.182 62.7166V133.465H123.636V86.2993H137.818C140.429 86.2993 142.545 88.411 142.545 91.0159V133.465ZM76.3636 95.7324V105.166H95.2727V95.7324H76.3636ZM76.3636 76.8662V86.2993H95.2727V76.8662H76.3636Z" fill="#DF7513"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
|
Before Width: | Height: | Size: 603 B After Width: | Height: | Size: 603 B |
@@ -0,0 +1,122 @@
|
||||
"use server";
|
||||
|
||||
import Link from "next/link";
|
||||
import { db } from "@/server/db";
|
||||
import { render } from "jsx-email";
|
||||
import { dayjsExt } from "@/common/dayjs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import UpdateRenderer from "@/components/update/renderer";
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
const PublicUpdatePage = async ({
|
||||
params: { publicId },
|
||||
}: {
|
||||
params: { publicId: string };
|
||||
}) => {
|
||||
const update = await db.update.findFirst({
|
||||
where: {
|
||||
publicId,
|
||||
},
|
||||
|
||||
include: {
|
||||
company: {
|
||||
select: {
|
||||
name: true,
|
||||
logo: true,
|
||||
},
|
||||
},
|
||||
|
||||
author: {
|
||||
select: {
|
||||
title: true,
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!update) {
|
||||
return {
|
||||
status: 404,
|
||||
redirect: "/404",
|
||||
};
|
||||
}
|
||||
|
||||
const company = update?.company;
|
||||
const author = update?.author;
|
||||
const html = await render(<UpdateRenderer html={update.html} />);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100 px-5 pb-5 pt-12">
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-16 flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12 rounded">
|
||||
<AvatarImage
|
||||
src={
|
||||
"https://pbs.twimg.com/profile_images/1686033387482464257/Dk2qBvtc_400x400.jpg" ||
|
||||
"/placeholders/company.svg"
|
||||
}
|
||||
/>
|
||||
|
||||
{/* <AvatarImage src={company.logo || "/placeholders/company.svg"} /> */}
|
||||
</Avatar>
|
||||
|
||||
<span className="text-lg font-semibold">{company.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{update.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last updated {dayjsExt().to(update.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-4xl p-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 rounded-full">
|
||||
<AvatarImage
|
||||
src={author.user.image || "/placeholders/user.svg"}
|
||||
/>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-semibold">{author.user.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{author.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<article
|
||||
className="prose"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="my-10 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
Powered by{" "}
|
||||
<Link
|
||||
href={`https://opencap.co?utm_source=${company.name}&utm_medium=updates&utm_campaign=powered_by`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bold text-teal-500 hover:underline"
|
||||
>
|
||||
OpenCap
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicUpdatePage;
|
||||
@@ -30,8 +30,8 @@ export function UserDropdown({ companyPublicId }: UserDropdownProps) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={image || "/avatar.svg"} />
|
||||
<Avatar className="h-9 w-9 rounded-full">
|
||||
<AvatarImage src={image || "/placeholders/user.svg"} />
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -9,11 +9,12 @@ import { type z } from "zod";
|
||||
import { Form, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { api } from "@/trpc/react";
|
||||
import React, { useRef } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { uploadFile } from "@/common/uploads";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Loading from "@/components/shared/loading";
|
||||
import {
|
||||
compareFormDataWithInitial,
|
||||
isFileExists,
|
||||
@@ -31,6 +32,7 @@ type ProfileType = {
|
||||
};
|
||||
|
||||
export const ProfileSettings = ({ memberProfile }: ProfileType) => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const { data: session, update } = useSession();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
@@ -102,7 +104,7 @@ export const ProfileSettings = ({ memberProfile }: ProfileType) => {
|
||||
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Profile changed successfully.",
|
||||
title: "🎉 Successfully updated your profile",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
@@ -140,6 +142,7 @@ export const ProfileSettings = ({ memberProfile }: ProfileType) => {
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { imageUrl } = await handleImageUpload(file);
|
||||
|
||||
if (!imageUrl) {
|
||||
@@ -163,6 +166,8 @@ export const ProfileSettings = ({ memberProfile }: ProfileType) => {
|
||||
title: "Failed uploading image.",
|
||||
description: "Please try again later.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
@@ -190,6 +195,7 @@ export const ProfileSettings = ({ memberProfile }: ProfileType) => {
|
||||
const { fullName, jobTitle, loginEmail, workEmail } = values;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
saveProfileMutation.mutate({
|
||||
type: PayloadType.PROFILE_DATA,
|
||||
payload: {
|
||||
@@ -206,6 +212,8 @@ export const ProfileSettings = ({ memberProfile }: ProfileType) => {
|
||||
title: "Failed updating profile.",
|
||||
description: "Please try again later.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +222,10 @@ export const ProfileSettings = ({ memberProfile }: ProfileType) => {
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-6">
|
||||
<div className="col-span-full flex items-center gap-x-8">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={session?.user?.image || "/avatar.svg"} />
|
||||
<Avatar className="h-20 w-20 rounded-full">
|
||||
<AvatarImage
|
||||
src={session?.user?.image || "/placeholders/user.svg"}
|
||||
/>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
@@ -293,6 +303,8 @@ export const ProfileSettings = ({ memberProfile }: ProfileType) => {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{loading && <Loading />}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -110,8 +110,10 @@ export const columns: ColumnDef<Member[number]>[] = [
|
||||
accessorFn: (row) => row.user?.name,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex">
|
||||
<Avatar className="">
|
||||
<AvatarImage src={row.original?.user?.image ?? "/avatar.svg"} />
|
||||
<Avatar className="rounded-full">
|
||||
<AvatarImage
|
||||
src={row.original?.user?.image ?? "/placeholders/user.svg"}
|
||||
/>
|
||||
</Avatar>
|
||||
|
||||
<div className=" ml-2">
|
||||
|
||||
@@ -34,6 +34,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { type RouterOutputs } from "@/trpc/shared";
|
||||
import { dayjsExt } from "@/common/dayjs";
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
const formSchema = ZodOnboardingMutationSchema;
|
||||
|
||||
@@ -122,19 +123,10 @@ export const CompanyForm = ({ type, data }: CompanyFormProps) => {
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="col-span-full flex items-center gap-x-8">
|
||||
{user?.user.image ? (
|
||||
<img
|
||||
src={user.user.image}
|
||||
alt="Company logo"
|
||||
width={50}
|
||||
height={50}
|
||||
className="flex-none rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-30 w-30 flex items-center rounded-lg bg-[#CCFBF1] p-2">
|
||||
<RiImageCircleFill className="m-2 h-10 w-10 flex-none shrink-0 rounded-full object-cover text-[#14B8A6]" />
|
||||
</div>
|
||||
)}
|
||||
<Avatar className="h-20 w-20 rounded">
|
||||
<AvatarImage src={"/placeholders/company.svg"} />
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<Button size="sm" variant={"outline"} type="button">
|
||||
{type === "edit" ? "Change company logo" : "Upload company logo"}
|
||||
|
||||
@@ -99,8 +99,8 @@ export const columns: ColumnDef<Option[number]>[] = [
|
||||
accessorFn: (row) => row?.stakeholder?.name,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex">
|
||||
<Avatar className="">
|
||||
<AvatarImage src={"/avatar.svg"} />
|
||||
<Avatar className="rounded-full">
|
||||
<AvatarImage src={"/placeholders/user.svg"} />
|
||||
</Avatar>
|
||||
<div className=" ml-2 pt-2">
|
||||
<p>{row?.original?.stakeholder?.name}</p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
type IconProps = React.HTMLAttributes<SVGElement>;
|
||||
|
||||
export const SpinnerIcon = (props: IconProps) => {
|
||||
@@ -18,3 +19,43 @@ export const SpinnerIcon = (props: IconProps) => {
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogoIcon = (props: IconProps) => {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={cn(className)}
|
||||
width="1061"
|
||||
height="1061"
|
||||
viewBox="0 0 1061 1061"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_47_2)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M712.256 270.067C704.618 283.9 687.213 288.922 673.38 281.285C565.3 221.612 426.629 237.659 335.002 329.285C223.967 440.32 223.968 620.342 335.002 731.375C426.629 823.003 565.3 839.051 673.381 779.378C687.215 771.739 704.62 776.761 712.257 790.597C719.894 804.428 714.872 821.834 701.038 829.473C571.242 901.134 404.674 881.971 294.54 771.837C161.16 638.457 161.159 422.204 294.54 288.823C404.672 178.691 571.239 159.527 701.038 231.191C714.871 238.829 719.893 256.234 712.256 270.067Z"
|
||||
fill="#090000"
|
||||
stroke="#090000"
|
||||
stroke-width="40"
|
||||
/>
|
||||
<path
|
||||
d="M820.303 351.98L819.748 351.329C817.336 348.495 813.975 344.546 810.463 341.507C805.819 337.487 798.764 333.067 789.033 332.68C779.007 332.285 771.505 336.441 766.714 340.016C762.966 342.813 759.236 346.55 756.445 349.348L615.807 489.986C610.966 494.823 605.731 500.054 601.908 505.065C597.429 510.935 592.608 519.335 592.607 530.461C592.608 541.588 597.427 549.988 601.906 555.859C605.73 560.87 610.965 566.1 615.807 570.938L756.445 711.576C759.237 714.373 762.967 718.112 766.715 720.909C771.506 724.48 779.009 728.639 789.035 728.243C798.764 727.86 805.819 723.436 810.463 719.418C813.976 716.378 817.335 712.43 819.747 709.595L820.305 708.945C907.887 606.306 907.886 454.618 820.303 351.98Z"
|
||||
fill="#090000"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_47_2">
|
||||
<rect
|
||||
width="750"
|
||||
height="750"
|
||||
fill="white"
|
||||
transform="translate(530.33) rotate(45)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { RiPieChartLine } from "@remixicon/react";
|
||||
import { LogoIcon } from "@/components/shared/icons";
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-50 h-full w-full bg-white/80 opacity-75">
|
||||
<div className="mt-[50vh] flex items-center justify-center">
|
||||
<RiPieChartLine className="h-6 w-6 animate-ping text-gray-500" />
|
||||
<LogoIcon className="h-7 w-7 animate-ping text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ const Avatar = React.forwardRef<
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import "@blocknote/react/style.css";
|
||||
import "@/styles/editor.css";
|
||||
|
||||
type UpdatesEditorProps = {
|
||||
html: string;
|
||||
};
|
||||
|
||||
const UpdateRenderer = ({ html }: UpdatesEditorProps) => {
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
};
|
||||
|
||||
export default UpdateRenderer;
|
||||
+1
-1
@@ -31,7 +31,7 @@ export function validateFile(file: File): {
|
||||
} {
|
||||
const maxSizeInBytes = 1024 * 1024; // 1 MB
|
||||
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
|
||||
if (file.size > maxSizeInBytes) {
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const updateRouter = createTRPCRouter({
|
||||
.input(UpdateMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const author = ctx.session.user;
|
||||
const authorId = ctx.session.user.memberId;
|
||||
const companyId = ctx.session.user.companyId;
|
||||
const publicId = input.publicId ?? generatePublicId();
|
||||
const { title, content, html } = input;
|
||||
@@ -21,7 +21,6 @@ export const updateRouter = createTRPCRouter({
|
||||
} else {
|
||||
await ctx.db.$transaction(async (tx) => {
|
||||
if (input.publicId) {
|
||||
console.log("=============> Updating an existing update");
|
||||
await tx.update.update({
|
||||
where: { publicId },
|
||||
data: {
|
||||
@@ -38,7 +37,7 @@ export const updateRouter = createTRPCRouter({
|
||||
content,
|
||||
publicId,
|
||||
companyId,
|
||||
authorId: author.id,
|
||||
authorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ const config = {
|
||||
},
|
||||
],
|
||||
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
} satisfies Config;
|
||||
|
||||
export default config;
|
||||
|
||||
Reference in New Issue
Block a user