diff --git a/next.config.mjs b/next.config.mjs index 9bf4e22..881ad15 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -15,6 +15,11 @@ const nextConfig = { hostname: "upload.wikimedia.org", pathname: "/wikipedia/**", }, + { + protocol: "https", + hostname: "avatars.githubusercontent.com", + pathname: "/**", + } ], }, }; diff --git a/package-lock.json b/package-lock.json index 0ce8bbd..c7bd8bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@tanstack/react-query": "^5.59.0", "@tanstack/react-query-devtools": "^5.59.0", "b2d-ventures": "file:", + "chart.js": "^4.4.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "1.0.0", @@ -44,7 +45,8 @@ "lucide-react": "^0.428.0", "next": "^14.2.15", "next-themes": "^0.3.0", - "react": "^18", + "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", "react-countup": "^6.5.3", "react-day-picker": "^9", "react-dom": "^18", @@ -68,7 +70,7 @@ "@types/eslint__js": "^8.42.3", "@types/next": "^8.0.7", "@types/node": "^20", - "@types/react": "^18", + "@types/react": "^18.3.12", "@types/react-dom": "^18", "@types/react-fade-in": "^2.0.2", "@types/react-file-icon": "^1.0.4", @@ -1132,6 +1134,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@lexical/clipboard": { "version": "0.17.1", "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.17.1.tgz", @@ -4724,6 +4731,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chart.js": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.6.tgz", + "integrity": "sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -10143,6 +10161,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-countup": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz", @@ -10260,6 +10287,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/react-lottie/-/react-lottie-1.2.4.tgz", "integrity": "sha512-kBGxI+MIZGBf4wZhNCWwHkMcVP+kbpmrLWH/SkO0qCKc7D7eSPcxQbfpsmsCo8v2KCBYjuGSou+xTqK44D/jMg==", + "license": "MIT", "dependencies": { "babel-runtime": "^6.26.0", "lottie-web": "^5.1.3" diff --git a/package.json b/package.json index 2b3da91..15c0586 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@tanstack/react-query": "^5.59.0", "@tanstack/react-query-devtools": "^5.59.0", "b2d-ventures": "file:", + "chart.js": "^4.4.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "1.0.0", @@ -46,7 +47,8 @@ "lucide-react": "^0.428.0", "next": "^14.2.15", "next-themes": "^0.3.0", - "react": "^18", + "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", "react-countup": "^6.5.3", "react-day-picker": "^9", "react-dom": "^18", @@ -70,7 +72,7 @@ "@types/eslint__js": "^8.42.3", "@types/next": "^8.0.7", "@types/node": "^20", - "@types/react": "^18", + "@types/react": "^18.3.12", "@types/react-dom": "^18", "@types/react-fade-in": "^2.0.2", "@types/react-file-icon": "^1.0.4", diff --git a/src/app/(investment)/deals/[id]/displayImage.tsx b/src/app/(investment)/deals/[id]/displayImage.tsx new file mode 100644 index 0000000..6bea6bf --- /dev/null +++ b/src/app/(investment)/deals/[id]/displayImage.tsx @@ -0,0 +1,42 @@ +"use client"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import Image from "next/image"; +import { StaticImport } from "next/dist/shared/lib/get-img-props"; + +interface ItemProps { + src: string | StaticImport; + alt: string; + width: number; + height: number; + className?: string; +} + +const ImageModal = ({ src, alt, width, height, className }: ItemProps) => { + return ( + + + {alt} + + + + Image Preview + Click outside to close the image preview. + + {alt} + + + + ); +}; + +export function DisplayFullImage({ src, alt, width, height, className }: ItemProps) { + return ; +} diff --git a/src/app/(investment)/deals/[id]/followShareButton.tsx b/src/app/(investment)/deals/[id]/followShareButton.tsx index ed34336..9242b98 100644 --- a/src/app/(investment)/deals/[id]/followShareButton.tsx +++ b/src/app/(investment)/deals/[id]/followShareButton.tsx @@ -10,8 +10,6 @@ import useSession from "@/lib/supabase/useSession"; import toast from "react-hot-toast"; const FollowShareButtons = () => { - const [progress, setProgress] = useState(0); - const [tab, setTab] = useState("Pitch"); const { session, loading } = useSession(); const user = session?.user; const [sessionLoaded, setSessionLoaded] = useState(false); diff --git a/src/app/(investment)/deals/[id]/page.tsx b/src/app/(investment)/deals/[id]/page.tsx index d0a3bd8..36f09a8 100644 --- a/src/app/(investment)/deals/[id]/page.tsx +++ b/src/app/(investment)/deals/[id]/page.tsx @@ -11,17 +11,26 @@ import { Progress } from "@/components/ui/progress"; import { Separator } from "@/components/ui/separator"; import { createSupabaseClient } from "@/lib/supabase/serverComponentClient"; import FollowShareButtons from "./followShareButton"; - +import { DisplayFullImage } from "./displayImage"; import { getProjectData } from "@/lib/data/projectQuery"; import { getDealList } from "@/app/api/dealApi"; import { sumByKey, toPercentage } from "@/lib/utils"; import { redirect } from "next/navigation"; +const PHOTO_MATERIAL_ID = 2; export default async function ProjectDealPage({ params }: { params: { id: number } }) { const supabase = createSupabaseClient(); - const { data: projectData, error: projectDataError } = await getProjectData(supabase, params.id); + const { data: projectMaterial, error: projectMaterialError } = await supabase + .from("project_material") + .select("material_url") + .eq("project_id", params.id) + .eq("material_type_id", PHOTO_MATERIAL_ID); + // console.log(projectMaterial); + if (projectMaterialError) { + console.error("Error while fetching project material" + projectMaterialError); + } if (!projectData) { redirect("/deals"); } @@ -44,94 +53,107 @@ export default async function ProjectDealPage({ params }: { params: { id: number const timeDiff = Math.max(new Date(projectData.investment_deadline).getTime() - new Date().getTime(), 0); const hourLeft = Math.floor(timeDiff / (1000 * 60 * 60)); - const carouselData = Array(5).fill({ - src: projectData.card_image_url || "/boiler1.jpg", - alt: `${projectData.project_name} Image`, - }); + const carouselData = + projectMaterial && projectMaterial.length > 0 + ? projectMaterial.flatMap((item) => + (item.material_url || ["/boiler1.jpg"]).map((url: string) => ({ + src: url, + alt: "Image", + })) + ) + : [{ src: "/boiler1.jpg", alt: "Default Boiler Image" }]; + + // console.log(carouselData); return (
-
- {/* Name, star and share button packed */} - diff --git a/src/app/portfolio/[uid]/page.tsx b/src/app/portfolio/[uid]/page.tsx new file mode 100644 index 0000000..d9a7ff9 --- /dev/null +++ b/src/app/portfolio/[uid]/page.tsx @@ -0,0 +1,245 @@ +import { Overview } from "@/components/ui/overview"; +import { createSupabaseClient } from "@/lib/supabase/serverComponentClient"; +import { getInvestorDeal } from "@/lib/data/investmentQuery"; +import PieChart from "@/components/pieChart"; +import { + overAllGraphData, + fourYearGraphData, + dayOftheWeekData, + getInvestorProjectTag, + countTags, + getBusinessTypeName, + countValues, + checkForInvest, + getLatestInvestment, + getTotalInvestment, +} from "./query"; +import CountUpComponent from "@/components/countUp"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { RecentFunds } from "@/components/recent-funds"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import QuestionMarkIcon from "@/components/icon/questionMark"; +import { NoDataAlert } from "@/components/alert/noData/alert"; +import { error } from "console"; +import { UnAuthorizedAlert } from "@/components/alert/unauthorized/alert"; + +export default async function Portfolio({ params }: { params: { uid: string } }) { + const supabase = createSupabaseClient(); + // if user hasn't invest in anything + const hasInvestments = await checkForInvest(supabase, params.uid); + if (!hasInvestments) { + return ( +
+ +
+ ); + } + const { data: deals, error: investorDealError } = await getInvestorDeal(supabase, params.uid); + if (investorDealError) { + console.error(investorDealError); + } + const { data: localUser, error: localUserError } = await supabase.auth.getUser(); + if (localUserError) { + console.error("Error while fetching user" + error); + } + // block user from try to see other user portfolio + if (params.uid != localUser.user?.id) { + return ( + <> + + + ); + } + const username = localUser ? localUser.user.user_metadata.name : "Anonymous"; + // console.log(username) + const overAllData = deals ? overAllGraphData(deals) : []; + const fourYearData = deals ? fourYearGraphData(deals) : []; + const dayOfWeekData = deals ? dayOftheWeekData(deals) : []; + const tags = deals ? await getInvestorProjectTag(supabase, deals) : []; + const latestDeals = deals + ? await Promise.all( + (await getLatestInvestment(supabase, deals)).map(async (deal) => ({ + ...deal, + logo_url: await deal.logo_url, + })) + ) + : []; + const totalInvestment = deals ? getTotalInvestment(deals) : 0; + // console.log(latestDeals); + + const tagCount = countTags(tags); + // console.log(investedBusinessIds); + const businessType = deals + ? await Promise.all(deals.map(async (item) => await getBusinessTypeName(supabase, item.project_id))) + : []; + const countedBusinessType = countValues(businessType.filter((item) => item !== null)); + // console.log(countedBusinessType); + + // console.log(tagCount); + return ( +
+ {/* {JSON.stringify(params.uid)} */} + {/* {JSON.stringify(tagCount)} */} + {/* {JSON.stringify(deals)} */} + {/* {JSON.stringify(dayOfWeekData)} */} + {/* {JSON.stringify(overAllGraphData)} */} + {/* {JSON.stringify(threeYearGraphData)} */} + {/* {JSON.stringify(uniqueProjectIds)} */} + {/*
+

Total Invest :

+
{totalInvest}
+
*/} + {/* */} +
+

Welcome to your Portfolio, {username}!

+

+ Here‘s an overview of your investment journey and progress. +

+

+ Total Investment: $ + +

+
+ +
+ + + Daily + Monthly + Yearly + + + + + Monthly Investment Trend + + + + + + +

+ Displays total investments each month over the past 12
+ months, up to today. +

+
+
+
+
+ + + +
+
+ + + + Yearly Investment Summary + + + + + + +

+ Shows total investments for each of the last four years,
+ including the current year to date. +

+
+
+
+
+ + + +
+
+ + + + Daily Investment Breakdown + + + + + + +

+ Illustrates total investments for each day over the past
+ year, up to today. +

+
+
+
+
+ + + +
+
+
+
+
+ + + Categories of Invested Projects + + + + + + +

+ Displays the distribution of project tags in your
+ investments, highlighting areas of interest. +

+
+
+
+
+ + item.count)} + labels={tagCount.map((item: { name: string; count: number }) => item.name)} + header="Total" + /> + +
+ + + Types of Businesses Invested In + + + + + + +

+ Shows the breakdown of business types in your portfolio,
+ illustrating sector diversity. +

+
+
+
+
+ + + +
+ + + Recent investment + + + + + +
+
+ ); +} diff --git a/src/app/portfolio/[uid]/query.ts b/src/app/portfolio/[uid]/query.ts new file mode 100644 index 0000000..151d0eb --- /dev/null +++ b/src/app/portfolio/[uid]/query.ts @@ -0,0 +1,256 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { getProjectTag, getTagName } from "@/lib/data/tagQuery"; + +async function fetchLogoURL(supabase: SupabaseClient, projectId: number) { + const logoIndex = 1; + let { data: project_material, error } = await supabase + .from("project_material") + .select("material_url") + .eq("project_id", projectId) + .eq("material_type_id", logoIndex); + if (error) { + console.error("Error while fetching golo url" + error); + } + if (project_material && project_material.length > 0) { + return project_material[0].material_url; + } + return ""; +} + +function getTotalInvestment(deals: { deal_amount: number }[]) { + let total = 0; + for (let index = 0; index < deals.length; index++) { + total += deals[index].deal_amount; + } + return total; +} +async function getLatestInvestment( + supabase: SupabaseClient, + deals: { project_id: number; deal_amount: number; created_time: Date }[] +) { + const llist = []; + const count = 8; + + for (let i = deals.length - 1; i >= 0 && llist.length < count; --i) { + let { data: project, error } = await supabase.from("project").select("project_name").eq("id", deals[i].project_id); + if (error) { + console.error(error); + } + let url = fetchLogoURL(supabase, deals[i].project_id); + llist.push({ + name: project?.[0]?.project_name, + amount: deals[i].deal_amount, + date: new Date(deals[i].created_time), + logo_url: url, + }); + } + + return llist; +} + +async function checkForInvest(supabase: SupabaseClient, userId: string) { + let { count, error } = await supabase + .from("investment_deal") + .select("*", { count: "exact" }) + .eq("investor_id", userId); + if (error) { + console.error(error); + return false; + } + // if user already invest in something + if (count !== null && count > 0) { + return true; + } + return false; +} + +function countValues(arr: { value: string }[][]): Record { + const counts: Record = {}; + + arr.forEach((subArray) => { + subArray.forEach((item) => { + const value = item.value; + counts[value] = (counts[value] || 0) + 1; + }); + }); + + return counts; +} + +async function getBusinessTypeName(supabase: SupabaseClient, projectId: number) { + // step 1: get business id from project id + let { data: project, error: projectError } = await supabase.from("project").select("business_id").eq("id", projectId); + if (projectError) { + console.error(projectError); + } + + // step 2: get business type's id from business id + let { data: business, error: businessError } = await supabase + .from("business") + .select("business_type") + .eq("id", project?.[0]?.business_id); + if (businessError) { + console.error(businessError); + } + // step 3: get business type from its id + let { data: business_type, error: businessTypeError } = await supabase + .from("business_type") + .select("value") + .eq("id", business?.[0]?.business_type); + if (businessTypeError) { + console.error(businessError); + } + return business_type; +} + +// only use deal that were made at most year ago +interface Deal { + created_time: string | number | Date; + deal_amount: any; +} + +interface GraphData { + name: string; + value: number; +} + +function overAllGraphData(deals: Deal[]): GraphData[] { + // Initialize all months with value 0 + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const acc: GraphData[] = months.map((month) => ({ name: month, value: 0 })); + + deals + .filter((item: Deal) => new Date(item.created_time) >= yearAgo(1)) + .forEach((item: Deal) => { + const monthName = getMonthName(item.created_time.toString()).slice(0, 3); + const monthEntry = acc.find((entry) => entry.name === monthName); + + if (monthEntry) { + monthEntry.value += item.deal_amount; + } + }); + + return acc; +} + +function fourYearGraphData(deals: Deal[]): GraphData[] { + const currentYear = new Date().getFullYear(); + const acc: GraphData[] = Array.from({ length: 4 }, (_, i) => ({ + name: (currentYear - i).toString(), + value: 0, + })).reverse(); + deals + .filter((item: Deal) => new Date(item.created_time) >= yearAgo(3)) + .forEach((item: Deal) => { + const year = new Date(item.created_time).getFullYear().toString(); + const yearEntry = acc.find((entry) => entry.name === year); + + if (yearEntry) { + yearEntry.value += item.deal_amount; + } + }); + + return acc; +} + +interface DayOfWeekData { + name: string; + value: number; +} + +function dayOftheWeekData(deals: Deal[]): DayOfWeekData[] { + const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const dayOfWeekData: DayOfWeekData[] = daysOfWeek.map((day) => ({ + name: day, + value: 0, + })); + deals + .filter((item: Deal) => new Date(item.created_time) >= yearAgo(1)) + .forEach((item: Deal) => { + const day = getDayAbbreviation(item.created_time); + const dayEntry = dayOfWeekData.find((entry) => entry.name === day); + if (dayEntry) { + dayEntry.value += item.deal_amount; + } + }); + return dayOfWeekData; +} +async function getInvestorProjectTag(supabase: SupabaseClient, deals: number | { project_id: number }[]) { + // get unique project id from deals + const uniqueProjectIds: number[] = Array.isArray(deals) + ? Array.from(new Set(deals.map((deal: { project_id: number }) => deal.project_id))) + : []; + + const tagIds = ( + await Promise.all( + uniqueProjectIds.map(async (projectId: number) => { + const { data: tagIdsArray, error: tagError } = await getProjectTag(supabase, projectId); + if (tagError) { + console.error(tagError); + return []; + } + return tagIdsArray?.map((tag: { tag_id: any }) => tag.tag_id) || []; + }) + ) + ).flat(); + + // console.log(tagIds, uniqueProjectIds); + const tagNames = await Promise.all( + tagIds + .filter((tagId) => tagId !== null) + .map(async (id: number) => { + const { data: tagName, error: nameError } = await getTagName(supabase, id); + if (nameError) { + console.error(nameError); + return null; + } + return tagName; + }) + ); + // console.log(tagNames); + return tagNames.filter((tagName) => tagName !== null); +} +const countTags = (tags: any[]) => { + const tagCounts = tags.flat().reduce( + (acc, tag) => { + const tagName = tag.value; + acc[tagName] = (acc[tagName] || 0) + 1; + return acc; + }, + {} as Record + ); + + return Object.entries(tagCounts).map(([name, count]) => ({ + name, + count: count as number, + })); +}; +const getDayAbbreviation = (dateString: string | number | Date) => { + const date = new Date(dateString); + return date.toLocaleString("default", { weekday: "short" }); +}; + +const yearAgo = (num: number) => { + const newDate = new Date(); + newDate.setFullYear(newDate.getFullYear() - num); + return newDate; +}; + +const getMonthName = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString("default", { month: "long", year: "numeric" }); +}; + +export { + overAllGraphData, + fourYearGraphData, + dayOftheWeekData, + getInvestorProjectTag, + countTags, + getBusinessTypeName, + countValues, + checkForInvest, + getLatestInvestment, + getTotalInvestment, + fetchLogoURL, +}; diff --git a/src/components/BusinessForm.tsx b/src/components/BusinessForm.tsx index 5e9dac8..b26d583 100644 --- a/src/components/BusinessForm.tsx +++ b/src/components/BusinessForm.tsx @@ -9,22 +9,14 @@ import { businessFormSchema } from "@/types/schemas/application.schema"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@radix-ui/react-tooltip"; import { createSupabaseClient } from "@/lib/supabase/clientComponentClient"; type businessSchema = z.infer; interface BusinessFormProps { - applyProject: boolean; - setApplyProject: Function; onSubmit: SubmitHandler; } -const BusinessForm = ({ - applyProject, - setApplyProject, - onSubmit, -}: BusinessFormProps & { onSubmit: SubmitHandler }) => { +const BusinessForm = ({ onSubmit }: BusinessFormProps & { onSubmit: SubmitHandler }) => { const communitySize = [ { id: 1, name: "N/A" }, { id: 2, name: "0-5K" }, @@ -385,24 +377,6 @@ const BusinessForm = ({ )} /> -
- setApplyProject(!applyProject)}> - - - - - Would you like to apply for your first fundraising project as well? - - - -

- Toggling this option allows you to begin your first project,
which is crucial for unlocking - the tools necessary to raise funds. -

-
-
-
-