Merge pull request #76 from Sosokker/back-end

Add admin page + data room page + enable profile edit + Fix all eslint error + povide ci/cd
This commit is contained in:
Pattadon L. 2024-11-04 11:23:05 +07:00 committed by GitHub
commit f4d24ab214
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 8930 additions and 3825 deletions

6
.eslintignore Normal file
View File

@ -0,0 +1,6 @@
src/app/(investment)/deals/[id]/followShareButton.tsx
src/components/ui/*
src/types/database.types.ts
src/components/BusinessForm.tsx
src/components/ProjectForm.tsx
src/app/project/apply/page.tsx

View File

@ -1,3 +1,3 @@
{
"extends": "next/core-web-vitals"
"extends": ["eslint:recommended", "next", "next/core-web-vitals"]
}

44
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: Build CI
on: pull_request
jobs:
build:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Caching
uses: actions/cache@v4
with:
path: |
~/.npm
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- name: Set environment variables
run: |
echo NEXT_PUBLIC_AUTH_GOOGLE_ID=${{ secrets.NEXT_PUBLIC_AUTH_GOOGLE_ID }} >> $GITHUB_ENV
echo NEXT_PUBLIC_AUTH_GOOGLE_SECRET=${{ secrets.NEXT_PUBLIC_AUTH_GOOGLE_SECRET }} >> $GITHUB_ENV
echo NEXT_PUBLIC_DUMMY_EMAIL=${{ secrets.NEXT_PUBLIC_DUMMY_EMAIL }} >> $GITHUB_ENV
echo NEXT_PUBLIC_DUMMY_PASSWORD=${{ secrets.NEXT_PUBLIC_DUMMY_PASSWORD }} >> $GITHUB_ENV
echo NEXT_PUBLIC_STRIPE_PUBLIC_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLIC_KEY }} >> $GITHUB_ENV
echo NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} >> $GITHUB_ENV
echo NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} >> $GITHUB_ENV
echo NEXT_PUBLIC_SUPABASE_URL_SOURCE=${{ secrets.NEXT_PUBLIC_SUPABASE_URL_SOURCE }} >> $GITHUB_ENV
echo NEXT_PUBLIC_TEST_URL=${{ secrets.NEXT_PUBLIC_TEST_URL }} >> $GITHUB_ENV
echo PROJECT_ID=${{ secrets.PROJECT_ID }} >> $GITHUB_ENV
echo STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }} >> $GITHUB_ENV
- name: Install dependencies
run: npm ci
- name: Run build
run: npm run build --if-present

44
.github/workflows/eslint.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: Eslint CI
on: pull_request
jobs:
build:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Caching
uses: actions/cache@v4
with:
path: |
~/.npm
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- name: Set environment variables
run: |
echo NEXT_PUBLIC_AUTH_GOOGLE_ID=${{ secrets.NEXT_PUBLIC_AUTH_GOOGLE_ID }} >> $GITHUB_ENV
echo NEXT_PUBLIC_AUTH_GOOGLE_SECRET=${{ secrets.NEXT_PUBLIC_AUTH_GOOGLE_SECRET }} >> $GITHUB_ENV
echo NEXT_PUBLIC_DUMMY_EMAIL=${{ secrets.NEXT_PUBLIC_DUMMY_EMAIL }} >> $GITHUB_ENV
echo NEXT_PUBLIC_DUMMY_PASSWORD=${{ secrets.NEXT_PUBLIC_DUMMY_PASSWORD }} >> $GITHUB_ENV
echo NEXT_PUBLIC_STRIPE_PUBLIC_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLIC_KEY }} >> $GITHUB_ENV
echo NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} >> $GITHUB_ENV
echo NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} >> $GITHUB_ENV
echo NEXT_PUBLIC_SUPABASE_URL_SOURCE=${{ secrets.NEXT_PUBLIC_SUPABASE_URL_SOURCE }} >> $GITHUB_ENV
echo NEXT_PUBLIC_TEST_URL=${{ secrets.NEXT_PUBLIC_TEST_URL }} >> $GITHUB_ENV
echo PROJECT_ID=${{ secrets.PROJECT_ID }} >> $GITHUB_ENV
echo STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }} >> $GITHUB_ENV
- name: Install dependencies
run: npm ci
- name: Run eslint
run: npm run lint

View File

@ -1,27 +1,54 @@
# name: Playwright Tests
# on:
# push:
# branches: [ main, master ]
# pull_request:
# branches: [ main, master ]
# jobs:
# test:
# timeout-minutes: 60
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: lts/*
# - name: Install dependencies
# run: npm ci
# - name: Install Playwright Browsers
# run: npx playwright install --with-deps
# - name: Run Playwright tests
# run: npx playwright test
# - uses: actions/upload-artifact@v4
# if: ${{ !cancelled() }}
# with:
# name: playwright-report
# path: playwright-report/
# retention-days: 30
name: Playwright Tests
on: pull_request
jobs:
build:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Caching
uses: actions/cache@v4
with:
path: |
~/.npm
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- name: Set environment variables
run: |
echo NEXT_PUBLIC_AUTH_GOOGLE_ID=${{ secrets.NEXT_PUBLIC_AUTH_GOOGLE_ID }} >> $GITHUB_ENV
echo NEXT_PUBLIC_AUTH_GOOGLE_SECRET=${{ secrets.NEXT_PUBLIC_AUTH_GOOGLE_SECRET }} >> $GITHUB_ENV
echo NEXT_PUBLIC_DUMMY_EMAIL=${{ secrets.NEXT_PUBLIC_DUMMY_EMAIL }} >> $GITHUB_ENV
echo NEXT_PUBLIC_DUMMY_PASSWORD=${{ secrets.NEXT_PUBLIC_DUMMY_PASSWORD }} >> $GITHUB_ENV
echo NEXT_PUBLIC_STRIPE_PUBLIC_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLIC_KEY }} >> $GITHUB_ENV
echo NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} >> $GITHUB_ENV
echo NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} >> $GITHUB_ENV
echo NEXT_PUBLIC_SUPABASE_URL_SOURCE=${{ secrets.NEXT_PUBLIC_SUPABASE_URL_SOURCE }} >> $GITHUB_ENV
echo NEXT_PUBLIC_TEST_URL=${{ secrets.NEXT_PUBLIC_TEST_URL }} >> $GITHUB_ENV
echo PROJECT_ID=${{ secrets.PROJECT_ID }} >> $GITHUB_ENV
echo STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }} >> $GITHUB_ENV
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests with 4 workers
run: npx playwright test --workers=4
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": true,
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@ -1,3 +1,6 @@
[![Build CI](https://github.com/Sosokker/B2D-Ventures/actions/workflows/build.yml/badge.svg)](https://github.com/Sosokker/B2D-Ventures/actions/workflows/build.yml)
[![Eslint CI](https://github.com/Sosokker/B2D-Ventures/actions/workflows/eslint.yml/badge.svg)](https://github.com/Sosokker/B2D-Ventures/actions/workflows/eslint.yml)
[![Playwright Tests](https://github.com/Sosokker/B2D-Ventures/actions/workflows/playwright.yml/badge.svg)](https://github.com/Sosokker/B2D-Ventures/actions/workflows/playwright.yml)
# B2D-Ventures
## About

2518
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,12 +6,14 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"pretty": "prettier --write \"./**/*.{js,jsx,mjs,cjs,ts,tsx,json}\""
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
@ -30,7 +32,7 @@
"@stripe/stripe-js": "^4.7.0",
"@supabase-cache-helpers/postgrest-react-query": "^1.10.1",
"@supabase/ssr": "^0.4.1",
"@supabase/supabase-js": "^2.45.2",
"@supabase/supabase-js": "^2.46.1",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-query-devtools": "^5.59.0",
"b2d-ventures": "file:",
@ -45,35 +47,43 @@
"next-themes": "^0.3.0",
"react": "^18",
"react-countup": "^6.5.3",
"react-day-picker": "^9",
"react-dom": "^18",
"react-file-icon": "^1.5.0",
"react-hook-form": "^7.53.0",
"react-hot-toast": "^2.4.1",
"react-lottie": "^1.2.4",
"react-markdown": "^9.0.1",
"recharts": "^2.12.7",
"stripe": "^17.1.0",
"sweetalert2": "^11.14.3",
"sweetalert2": "^11.6.13",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@playwright/test": "^1.47.2",
"@tailwindcss/typography": "^0.5.15",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/eslint__js": "^8.42.3",
"@types/next": "^8.0.7",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-fade-in": "^2.0.2",
"@types/react-file-icon": "^1.0.4",
"@types/react-lottie": "^1.2.10",
"@types/react-select-country-list": "^2.2.3",
"eslint": "^8",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.5",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"postcss": "^8",
"prettier": "^3.3.3",
"supabase": "^1.200.3",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"typescript": "^5.6.3",
"typescript-eslint": "^8.11.0"
}
}

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -1,5 +1,7 @@
"use client";
/* eslint-disable */
import { useState, useEffect } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ShareIcon, StarIcon } from "lucide-react";

View File

@ -13,24 +13,42 @@ import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import FollowShareButtons from "./followShareButton";
import { getProjectData } from "@/lib/data/projectQuery";
import { getDealList } from "@/app/api/dealApi";
import { sumByKey, toPercentage } from "@/lib/utils";
import { redirect } from "next/navigation";
export default async function ProjectDealPage({ params }: { params: { id: number } }) {
const supabase = createSupabaseClient();
const { data: projectData, error: projectDataError } = await getProjectData(supabase, params.id);
const carouselData = [
{ src: "/boiler1.jpg", alt: "Boiler 1" },
{ src: "/boiler1.jpg", alt: "Boiler 1" },
{ src: "/boiler1.jpg", alt: "Boiler 1" },
{ src: "/boiler1.jpg", alt: "Boiler 1" },
{ src: "/boiler1.jpg", alt: "Boiler 1" },
];
if (!projectData) {
redirect("/deals");
}
if (projectDataError) {
return <div>Error</div>;
return (
<div className="container max-w-screen-xl my-5">
<p className="text-red-600">Error fetching data. Please try again.</p>
<Button className="mt-4" onClick={() => window.location.reload()}>
Refresh
</Button>
</div>
);
}
const projectBusinessOwnerId = projectData.user_id;
const dealList = await getDealList(projectBusinessOwnerId);
const totalDealAmount = sumByKey(dealList, "deal_amount");
// timeDiff, if negative convert to zero
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`,
});
return (
<div className="container max-w-screen-xl my-5">
<div className="flex flex-col gap-y-10">
@ -82,24 +100,33 @@ export default async function ProjectDealPage({ params }: { params: { id: number
<div id="stats" className="flex flex-col w-full mt-4 pl-12">
<div className="pl-5">
<span>
<h1 className="font-semibold text-xl md:text-4xl mt-8">${projectData?.total_investment}</h1>
<p className="text-sm md:text-lg"> 5% raised of \$5M max goal</p>
<h1 className="font-semibold text-xl md:text-4xl mt-8">${totalDealAmount}</h1>
<p className="text-sm md:text-lg">
{toPercentage(totalDealAmount, projectData?.target_investment)}% raised of $
{projectData?.target_investment} max goal
</p>
<Progress
value={projectData?.total_investment / projectData?.target_investment}
value={toPercentage(totalDealAmount, projectData?.target_investment)}
className="w-[60%] h-3 mt-3"
/>
</span>
<span>
<h1 className="font-semibold text-4xl md:mt-8">
<p className="text-xl md:text-4xl">{projectData?.total_investment}</p>
<p className="text-xl md:text-4xl">{dealList.length}</p>
</h1>
<p className="text-sm md:text-lg"> Investors</p>
<p className="text-sm md:text-lg">Investors</p>
</span>
<Separator decorative className="mt-3 w-3/4 ml-5" />
<span>
<h1 className="font-semibold text-xl md:text-4xl mt-8 ml-5"></h1>
<p className="text-xl md:text-4xl">1 hours</p>
<p> Left to invest</p>
{projectData?.investment_deadline ? (
<>
<p className="text-xl md:text-4xl">{Math.floor(hourLeft)} hours</p>
<p>Left to invest</p>
</>
) : (
<p className="text-xl md:text-4xl">No deadline</p>
)}
</span>
<Button className="mt-5 w-3/4 h-12">
<Link href={`/invest/${params.id}`}>Invest in {projectData?.project_name}</Link>
@ -121,8 +148,8 @@ export default async function ProjectDealPage({ params }: { params: { id: number
<Tabs.Content value="pitch">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardTitle>{projectData.project_name}</CardTitle>
<CardDescription />
</CardHeader>
<CardContent>
<div className="prose prose-sm max-w-none ">

View File

@ -1,11 +1,11 @@
"use client";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Clock3Icon, UserIcon, UsersIcon } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { ProjectCard } from "@/components/projectCard";
import { getAllTagsQuery, getALlFundedStatusQuery, getAllBusinessTypeQuery } from "@/lib/data/dropdownQuery";
import { getALlFundedStatusQuery, getAllBusinessTypeQuery } from "@/lib/data/dropdownQuery";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { searchProjectsQuery, FilterParams, FilterProjectQueryParams } from "@/lib/data/projectQuery";
@ -13,7 +13,29 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import Link from "next/link";
const ProjectSection = ({ filteredProjects }) => {
interface Project {
project_id: string;
project_name: string;
published_time: string;
project_short_description: string;
card_image_url: string;
project_status: {
value: string;
};
min_investment: number;
total_investment: number;
target_investment: number;
investment_deadline: string;
tags: {
tag_name: string;
}[];
business_type: {
value: string;
};
business_location: string;
}
const ProjectSection = ({ filteredProjects }: { filteredProjects: Project[] }) => {
interface Tags {
tag_name: string;
}
@ -117,7 +139,7 @@ export default function Deals() {
const [sortByTimeFilter, setSortByTimeFilter] = useState("all");
const [businessTypeFilter, setBusinessTypeFilter] = useState("all");
const [tagFilter, setTagFilter] = useState([]);
const [projectStatusFilter, setprojectStatusFilter] = useState("all");
const [projectStatusFilter, setProjectStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(4);
@ -130,16 +152,15 @@ export default function Deals() {
};
const filterProjectQueryParams: FilterProjectQueryParams = {
searchTerm,
tagsFilter: tagFilter,
projectStatusFilter,
businessTypeFilter,
sortByTimeFilter,
searchTerm: "",
tagsFilter: [],
projectStatusFilter: "all",
businessTypeFilter: "all",
sortByTimeFilter: "all",
page,
pageSize,
};
const { data: tags, isLoading: isLoadingTags, error: tagsLoadingError } = useQuery(getAllTagsQuery(supabase));
const {
data: projectStatus,
isLoading: isLoadingFunded,
@ -160,11 +181,20 @@ export default function Deals() {
const clearAll = () => {
setSearchTerm("");
setTagFilter([]);
setprojectStatusFilter("all");
setProjectStatusFilter("all");
setBusinessTypeFilter("all");
setSortByTimeFilter("all");
};
const handlePageSizeChange = (value: number) => {
setPageSize(value);
setPage(1);
};
useEffect(() => {
setSearchTerm(searchTermVisual);
}, [searchTermVisual]);
return (
<div className="container max-w-screen-xl mx-auto px-4">
<div className="h-auto mt-10">
@ -175,8 +205,7 @@ export default function Deals() {
All companies are <u>vetted & pass due diligence.</u>
</p>
{/* {JSON.stringify(projects, null, 4)} */}
{/* Search Input and Filters */}
<div className="flex mt-10 gap-3">
<Input
type="text"
@ -205,7 +234,7 @@ export default function Deals() {
</Select>
{/* Business Type Filter */}
<Select onValueChange={(value) => setBusinessTypeFilter}>
<Select onValueChange={(value) => setBusinessTypeFilter(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<UsersIcon className="ml-2" />
<SelectValue placeholder="Business Type" />
@ -234,7 +263,7 @@ export default function Deals() {
</Select>
{/* Project Status Filter */}
<Select onValueChange={(key) => setprojectStatusFilter(key)}>
<Select onValueChange={(key) => setProjectStatusFilter(key)}>
<SelectTrigger className="w-full sm:w-[180px]">
<UserIcon className="ml-2" />
<SelectValue placeholder="Project Status" />
@ -262,11 +291,54 @@ export default function Deals() {
</SelectContent>
</Select>
</div>
{/* Active Filters */}
<ShowFilter filterParams={filterParams} clearAll={clearAll} />
<Separator className="mt-10" />
{/* Project Cards Section */}
<ProjectSection filteredProjects={projects} />
{isLoadingProjects ? (
<div>Loading...</div>
) : projectsLoadingError ? (
<div>Error loading projects.</div>
) : projects ? (
<ProjectSection filteredProjects={projects} />
) : (
<div>No projects found!</div>
)}
{/* Pagination Controls */}
<div className="mt-6 flex items-center justify-between">
<Button
variant="outline"
onClick={() => setPage((prevPage) => Math.max(prevPage - 1, 1))}
disabled={page === 1}
>
Previous
</Button>
<div className="flex items-center gap-2">
<span>Page Size:</span>
<Select onValueChange={(value) => handlePageSizeChange(Number(value))}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder={`${pageSize}`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="4">4</SelectItem>
<SelectItem value="8">8</SelectItem>
<SelectItem value="12">12</SelectItem>
<SelectItem value="16">16</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="outline"
onClick={() => setPage((prevPage) => prevPage + 1)}
disabled={projects! && projects.length < pageSize}
>
Next
</Button>
</div>
</div>
</div>
);

View File

@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import { useStripe, useElements, CardElement } from "@stripe/react-stripe-js";
import { useStripe, useElements, CardNumberElement, CardCvcElement, CardExpiryElement } from "@stripe/react-stripe-js";
import convertToSubcurrency from "@/lib/convertToSubcurrency";
import {
Dialog,
@ -14,9 +14,9 @@ import {
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import useSession from "@/lib/supabase/useSession";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useRouter } from "next/navigation";
import Link from "next/link";
import toast from "react-hot-toast";
const CheckoutPage = ({
amount,
@ -35,11 +35,8 @@ const CheckoutPage = ({
const [clientSecret, setClientSecret] = useState("");
const [loading, setLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false); // New success dialog state
const isAcceptTerm = isAcceptTermAndService();
const router = useRouter();
const { session } = useSession();
const user = session?.user;
useEffect(() => {
fetch("/api/create-payment-intent", {
@ -72,7 +69,7 @@ const CheckoutPage = ({
await stripe
.confirmCardPayment(clientSecret, {
payment_method: {
card: elements.getElement(CardElement)!,
card: elements.getElement(CardNumberElement)!,
},
})
.then(async (result) => {
@ -90,12 +87,15 @@ const CheckoutPage = ({
]);
if (error) {
toast.error("Unexpected error with server");
console.error("Supabase Insert Error:", error.message);
} else {
console.log("Insert successful:", data);
router.push(`http://www.localhost:3000/payment-success?amount=${amount}`);
toast.success("Invest successfully");
setIsSuccessDialogOpen(true);
}
} catch (err) {
toast.error("Unexpected error with server");
console.error("Unexpected error during Supabase insert:", err);
}
}
@ -108,7 +108,8 @@ const CheckoutPage = ({
<div className="flex items-center justify-center">
<div
className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-e-transparent align-[-0.125em] text-surface motion-reduce:animate-[spin_1.5s_linear_infinite] dark:text-white"
role="status">
role="status"
>
<span className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]">
Loading...
</span>
@ -119,7 +120,70 @@ const CheckoutPage = ({
return (
<div>
{clientSecret && <CardElement />}
{clientSecret && (
<div className="space-y-4 bg-gray-50 p-6 rounded-lg shadow-md">
<div>
<label className="text-gray-700 text-sm font-medium" htmlFor="cardNumber">
Card Number
</label>
<CardNumberElement
id="cardNumber"
className="block w-full p-3 mt-2 border border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
options={{
style: {
base: {
fontSize: "16px",
color: "#32325d",
"::placeholder": { color: "#a0aec0" },
},
invalid: { color: "#e53e3e" },
},
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-gray-700 text-sm font-medium" htmlFor="cardExpiry">
Expiration Date
</label>
<CardExpiryElement
id="cardExpiry"
className="block w-full p-3 mt-2 border border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
options={{
style: {
base: {
fontSize: "16px",
color: "#32325d",
"::placeholder": { color: "#a0aec0" },
},
invalid: { color: "#e53e3e" },
},
}}
/>
</div>
<div>
<label className="text-gray-700 text-sm font-medium" htmlFor="cardCvc">
CVC
</label>
<CardCvcElement
id="cardCvc"
className="block w-full p-3 mt-2 border border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
options={{
style: {
base: {
fontSize: "16px",
color: "#32325d",
"::placeholder": { color: "#a0aec0" },
},
invalid: { color: "#e53e3e" },
},
}}
/>
</div>
</div>
</div>
)}
{errorMessage && <div>{errorMessage}</div>}
@ -130,7 +194,8 @@ const CheckoutPage = ({
type="button"
onClick={() => setIsDialogOpen(true)}
disabled={!stripe || loading}
className="text-white w-full p-5 bg-black mt-2 rounded-md font-bold disabled:opacity-50 disabled:animate-pulse">
className="text-white w-full p-5 bg-black mt-2 rounded-md font-bold disabled:opacity-50 disabled:animate-pulse"
>
{!loading ? `Pay $${amount}` : "Processing..."}
</button>
</DialogTrigger>
@ -154,6 +219,23 @@ const CheckoutPage = ({
{errorMessage && <p className="text-red-500 mt-2 text-lg font-bold">{errorMessage}</p>}
</DialogContent>
</Dialog>
{/* Success Dialog */}
<Dialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Thank you!</DialogTitle>
<DialogDescription>You successfully sent ${amount}.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button">
<Link href="/">Return to Main Page</Link>
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -1,7 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { Input } from "@/components/ui/input";
@ -15,7 +14,6 @@ import { loadStripe } from "@stripe/stripe-js";
import { getProjectDataQuery } from "@/lib/data/projectQuery";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import useSession from "@/lib/supabase/useSession";
if (process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY === undefined) {
throw new Error("NEXT_PUBLIC_STRIPE_PUBLIC_KEY is not defined");
@ -72,7 +70,11 @@ export default function InvestPage() {
fetchInvestorData();
}, [supabase]);
const { data: projectData, isLoading: isLoadingProject } = useQuery(getProjectDataQuery(supabase, Number(params.id)));
const {
data: projectData,
isLoading: isLoadingProject,
error: projectError,
} = useQuery(getProjectDataQuery(supabase, Number(params.id)));
const handleCheckboxChange = (index: number) => {
const updatedCheckedTerms = [...checkedTerms];
@ -89,57 +91,69 @@ export default function InvestPage() {
return (
<div className="mx-10 md:mx-40 my-10">
<h1 className="text-2xl md:text-4xl font-bold">Invest on ${projectData?.project_name}</h1>
<Separator className="my-4" />
<div></div>
<div>
<div className="w-1/2 space-y-2">
<h2 className="text:base md:text-2xl">Investment Amount</h2>
<Input
className="w-52"
type="number"
placeholder="min $10"
min={10}
onChangeCapture={(e) => setInvestAmount(Number(e.currentTarget.value))}
/>
</div>
<Separator className="my-4" />
{isLoadingProject ? (
<p>Loading project details...</p>
) : projectError ? (
<p>Error loading project data. Please try again later.</p>
) : projectData ? (
<>
<h1 className="text-2xl md:text-4xl font-bold">Invest in {projectData.project_name}</h1>
<Separator className="my-4" />
<div className=" md:w-2/3 space-y-2">
<h2 className="text-2xl">Terms and Services</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Select</TableHead>
<TableHead>Term</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{term_data.map((item, index) => (
<TableRow key={index}>
<TableCell>
<input type="checkbox" checked={checkedTerms[index]} onChange={() => handleCheckboxChange(index)} />
</TableCell>
<TableCell>{item.term}</TableCell>
<TableCell>{item.description}</TableCell>
{/* Investment Amount Section */}
<div className="w-1/2 space-y-2">
<h2 className="text:base md:text-2xl">Investment Amount</h2>
<Input
className="w-52"
type="number"
placeholder="min $10"
min={10}
onChangeCapture={(e) => setInvestAmount(Number(e.currentTarget.value))}
/>
</div>
<Separator className="my-4" />
{/* Terms and Services Section */}
<div className="md:w-2/3 space-y-2">
<h2 className="text-2xl">Terms and Services</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Select</TableHead>
<TableHead>Term</TableHead>
<TableHead>Description</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Separator className="my-4" />
</TableHeader>
<TableBody>
{term_data.map((item, index) => (
<TableRow key={index}>
<TableCell>
<input
type="checkbox"
checked={checkedTerms[index]}
onChange={() => handleCheckboxChange(index)}
/>
</TableCell>
<TableCell>{item.term}</TableCell>
<TableCell>{item.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Separator className="my-4" />
<div className="w-full space-y-2">
<h2 className="text:base md:text-2xl">Payment Information</h2>
<div>
{/* Payment Information Section */}
<div className="w-full space-y-2">
<h2 className="text:base md:text-2xl">Payment Information</h2>
<Elements
stripe={stripePromise}
options={{
mode: "payment",
amount: convertToSubcurrency(investAmount),
currency: "usd",
}}>
}}
>
<CheckoutPage
amount={investAmount}
isAcceptTermAndService={isAcceptTermAndService}
@ -148,8 +162,10 @@ export default function InvestPage() {
/>
</Elements>
</div>
</div>
</div>
</>
) : (
<p>No project data found.</p>
)}
</div>
);
}

View File

@ -5,36 +5,34 @@ import { BellIcon } from "lucide-react";
export default function Notification() {
const sampleNotifications = [
{ message: "New message from John Doe", time: "5 minutes ago" },
{ message: "Your order has been shipped", time: "2 hours ago" },
{ message: "Meeting reminder: Team sync at 3 PM", time: "1 day ago" },
{ id: 1, message: "New message from John Doe", time: "5 minutes ago" },
{ id: 2, message: "Your order has been shipped", time: "2 hours ago" },
{ id: 3, message: "Meeting reminder: Team sync at 3 PM", time: "1 day ago" },
];
return (
<div>
<div className="ml-24 md:ml-56 mt-16 ">
<h1 className="font-bold text-2xl md:text-3xl h-0">Notifications</h1>
<div className=" w-full mt-20 ">
<div className="w-full mt-20">
{/* Cards */}
<Card className=" border-slate-800 w-3/4 p-6">
<Card className="border-slate-800 w-3/4 p-6">
<CardContent>
<Card>
<CardContent>
{sampleNotifications.map((notification, _) => (
<div className="flex items-center justify-between p-4 border-b border-gray-200">
{sampleNotifications.map((notification) => (
<div
key={notification.id}
className="flex items-center justify-between p-4 border-b border-gray-200"
>
<div className="flex items-center">
<BellIcon className="w-5 h-5 text-blue-500 mr-3" />
<div>
<p className="text-sm font-medium ">
{notification.message}
</p>
<p className="text-xs text-gray-500">
{notification.time}
</p>
<p className="text-sm font-medium">{notification.message}</p>
<p className="text-xs text-gray-500">{notification.time}</p>
</div>
</div>
<button className="text-sm text-blue-500 hover:text-blue-600">
Mark as read
</button>
<button className="text-sm text-blue-500 hover:text-blue-600">Mark as read</button>
</div>
))}
</CardContent>

View File

@ -0,0 +1,149 @@
"use client";
import { updateProfile } from "@/lib/data/profileMutate";
import { useForm } from "react-hook-form";
import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "@/components/ui/form";
import { z } from "zod";
import { profileSchema } from "@/types/schemas/profile.schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { uploadAvatar } from "@/lib/data/bucket/uploadAvatar";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import useSession from "@/lib/supabase/useSession";
export default function EditProfilePage({ params }: { params: { uid: string } }) {
const uid = params.uid;
const client = createSupabaseClient();
const router = useRouter();
const { session, loading: isLoadingSession } = useSession();
const profileForm = useForm<z.infer<typeof profileSchema>>({
resolver: zodResolver(profileSchema),
});
if (isLoadingSession) {
return (
<div className="flex items-center justify-center h-screen">
<p>Loading session...</p>
</div>
);
}
const onProfileSubmit = async (updates: z.infer<typeof profileSchema>) => {
const { avatars, username, full_name, bio } = updates;
try {
let avatarUrl = null;
if (avatars instanceof File) {
const avatarData = await uploadAvatar(client, avatars, uid);
avatarUrl = avatarData?.path
? `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/avatars/${avatarData.path}`
: null;
}
const result = await updateProfile(client, uid, {
username,
full_name,
bio,
...(avatarUrl && { avatar_url: avatarUrl }),
});
if (result) {
toast.success("Profile updated successfully!");
router.push(`/profile/${uid}`);
} else {
toast.error("No fields to update!");
}
} catch (error) {
toast.error("Error updating profile!");
console.error("Error updating profile:", error);
}
};
if (uid != session?.user.id) {
router.push(`/profile/${uid}`);
}
return (
<div className="container max-w-screen-xl">
<div className="my-5">
<span className="text-2xl font-bold">Update Profile</span>
<Separator className="my-5" />
</div>
<div>
<Form {...profileForm}>
<form onSubmit={profileForm.handleSubmit(onProfileSubmit)} className="space-y-8">
<FormField
control={profileForm.control}
name="avatars"
// eslint-disable-next-line no-unused-vars
render={({ field: { value, onChange, ...fieldProps } }) => (
<FormItem>
<FormLabel>Avatar</FormLabel>
<FormControl>
<Input
{...fieldProps}
type="file"
onChange={(event) => onChange(event.target.files && event.target.files[0])}
/>
</FormControl>
<FormDescription />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={profileForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={profileForm.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="" {...field} />
</FormControl>
<FormDescription>This is your public full name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={profileForm.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea placeholder="Your bio here" {...field} />
</FormControl>
<FormDescription>This is your public bio description.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
</div>
</div>
);
}

View File

@ -2,15 +2,18 @@ import React from "react";
import Image from "next/image";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { getUserProfile } from "@/lib/data/userQuery";
import { Tables } from "@/types/database.types";
import { format } from "date-fns";
import ReactMarkdown from "react-markdown";
interface Profile extends Tables<"Profiles"> {}
import Link from "next/link";
import { getUserRole } from "@/lib/data/userQuery";
export default async function ProfilePage({ params }: { params: { uid: string } }) {
const supabase = createSupabaseClient();
const uid = params.uid;
const {
data: { user },
} = await supabase.auth.getUser();
const { data: userRoleData, error: userRoleError } = await getUserRole(supabase, uid);
const { data: profileData, error } = await getUserProfile(supabase, uid);
@ -22,6 +25,14 @@ export default async function ProfilePage({ params }: { params: { uid: string }
);
}
if (userRoleError) {
return (
<div className="flex items-center justify-center h-screen">
<p className="text-red-500">Error fetching role data. Please try again later.</p>
</div>
);
}
if (!profileData) {
return (
<div className="flex items-center justify-center h-screen">
@ -33,11 +44,13 @@ export default async function ProfilePage({ params }: { params: { uid: string }
return (
<div className="container max-w-screen-xl px-4 py-8">
<div className="bg-card border-2 border-border shadow-xl rounded-lg overflow-hidden">
<div className="bg-cover bg-center h-64 p-4" style={{ backgroundImage: "url(./banner.jpg)" }}>
<div className="bg-cover bg-center h-64 p-4" style={{ backgroundImage: "url(/banner.jpg)" }}>
<div className="flex justify-end">
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Edit Profile
</button>
{user && user.id === uid && (
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
<Link href={`/profile/${uid}/edit`}>Edit Profile</Link>
</button>
)}
</div>
</div>
<div className="px-4 py-2">
@ -64,13 +77,19 @@ export default async function ProfilePage({ params }: { params: { uid: string }
</a>
</p>
)}
{/* Business Profile Indicator */}
{userRoleData.role === "business" && (
<span className="mt-2 inline-block bg-yellow-500 text-white text-sm font-medium px-2 py-1 rounded-full">
Business Profile
</span>
)}
</div>
</div>
{/* Lower */}
<div>
<div className="mt-6">
<h2 className="text-xl font-semibold mb-2">Bio</h2>
<div className="prose prose-sm max-w-none">
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown>{profileData.bio || "No bio available."}</ReactMarkdown>
</div>
</div>

View File

@ -0,0 +1,59 @@
import Link from "next/link";
import { Table, TableHeader, TableRow, TableCell, TableBody, TableHead } from "@/components/ui/table";
interface Business {
id: any;
location: any;
business_name: any;
business_type: any;
joined_date: any;
user_id: number;
username: string;
full_name: string;
email: string;
}
interface BusinessTableProps {
businesses: Business[] | null;
}
const BusinessTable = ({ businesses }: BusinessTableProps) => {
if (!businesses) return <div>No data available</div>;
return (
<div>
<Table className="min-w-full border-2 border-border">
<TableHeader>
<TableRow>
<TableHead>Business Name</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Business Type</TableHead>
<TableHead>Location</TableHead>
<TableHead>Joined Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{businesses.map((business) => (
<TableRow key={business.id}>
<TableCell>
<Link href={`/admin/business/${business.id}/projects`}>
<p className="hover:text-blue-600">{business.business_name}</p>
</Link>
</TableCell>
<TableCell>
<Link href={`/profile/${business.user_id}`}>
<p className="hover:text-blue-600">{business.username}</p>
</Link>
</TableCell>
<TableCell>{business.business_type}</TableCell>
<TableCell>{business.location}</TableCell>
<TableCell>{new Date(business.joined_date).toLocaleDateString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
export default BusinessTable;

View File

@ -0,0 +1,104 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Check, X } from "lucide-react";
import { useState } from "react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { rejectBusiness, approveBusiness } from "@/lib/data/applicationMutate";
import toast from "react-hot-toast";
interface BusinessActionButtonsProps {
businessApplicationId: number;
}
export function BusinessActionButtons({ businessApplicationId }: BusinessActionButtonsProps) {
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectOpen, setIsRejectOpen] = useState(false);
const [isApproveOpen, setIsApproveOpen] = useState(false);
const onRejectBusiness = async () => {
try {
setIsRejectLoading(true);
const client = createSupabaseClient();
const { error } = await rejectBusiness(client, businessApplicationId);
if (error) throw error;
toast.success("Rejected successfully");
window.location.reload();
} catch (error) {
toast.error("Failed to reject business");
console.error("Failed to reject business:", error);
} finally {
setIsRejectLoading(false);
setIsRejectOpen(false);
}
};
const onApproveBusiness = async () => {
try {
setIsApproveLoading(true);
const client = createSupabaseClient();
const { error } = await approveBusiness(client, businessApplicationId);
if (error) throw error;
toast.success("Approved successfully");
window.location.reload();
} catch (error) {
toast.error("Failed to approve business");
console.error("Failed to approve business:", error);
} finally {
setIsApproveLoading(false);
setIsApproveOpen(false);
}
};
return (
<div className="flex flex-col gap-1">
<Dialog open={isApproveOpen} onOpenChange={setIsApproveOpen}>
<DialogTrigger asChild>
<Check className="border-[2px] border-black dark:border-white rounded-md hover:bg-primary cursor-pointer" />
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Approve this Business</DialogTitle>
<DialogDescription>Are you sure that you will approve this business?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="submit" variant="default" onClick={onApproveBusiness} disabled={isApproveLoading}>
{isApproveLoading ? "Approving..." : "Approve"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isRejectOpen} onOpenChange={setIsRejectOpen}>
<DialogTrigger asChild>
<X className="border-[2px] border-black dark:border-white rounded-md hover:bg-destructive cursor-pointer" />
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Reject this Business</DialogTitle>
<DialogDescription>Are you sure that you will reject this business?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="submit" variant="destructive" onClick={onRejectBusiness} disabled={isRejectLoading}>
{isRejectLoading ? "Rejecting..." : "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,104 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Check, X } from "lucide-react";
import { useState } from "react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { approveProject, rejectBusiness } from "@/lib/data/applicationMutate";
import toast from "react-hot-toast";
interface ProjectActionsProps {
projectId: number;
}
export default function ProjectActions({ projectId }: ProjectActionsProps) {
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectOpen, setIsRejectOpen] = useState(false);
const [isApproveOpen, setIsApproveOpen] = useState(false);
const onRejectProject = async () => {
try {
setIsRejectLoading(true);
const client = createSupabaseClient();
const { error } = await rejectBusiness(client, projectId);
if (error) throw error;
toast.success("Project rejected successfully");
window.location.reload();
} catch (error) {
toast.error("Failed to reject project");
console.error("Failed to reject project:", error);
} finally {
setIsRejectLoading(false);
setIsRejectOpen(false);
}
};
const onApproveProject = async () => {
try {
setIsApproveLoading(true);
const client = createSupabaseClient();
const { error } = await approveProject(client, projectId);
if (error) throw error;
toast.success("Project approved successfully");
window.location.reload();
} catch (error) {
toast.error("Failed to approve project");
console.error("Failed to approve project:", error);
} finally {
setIsApproveLoading(false);
setIsApproveOpen(false);
}
};
return (
<div className="flex flex-col gap-1">
<Dialog open={isApproveOpen} onOpenChange={setIsApproveOpen}>
<DialogTrigger asChild>
<Check className="border-[2px] border-black dark:border-white rounded-md hover:bg-primary cursor-pointer" />
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Approve this Project</DialogTitle>
<DialogDescription>Are you sure you want to approve this project?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="submit" variant="default" onClick={onApproveProject} disabled={isApproveLoading}>
{isApproveLoading ? "Approving..." : "Approve"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isRejectOpen} onOpenChange={setIsRejectOpen}>
<DialogTrigger asChild>
<X className="border-[2px] border-black dark:border-white rounded-md hover:bg-destructive cursor-pointer" />
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Reject this Project</DialogTitle>
<DialogDescription>Are you sure you want to reject this project?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="submit" variant="destructive" onClick={onRejectProject} disabled={isRejectLoading}>
{isRejectLoading ? "Rejecting..." : "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,139 @@
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { getAllProjectApplicationByBusinessQuery } from "@/lib/data/applicationQuery";
import Link from "next/link";
import Image from "next/image";
import ProjectActions from "./ProjectAction";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
interface ProjectApplicationData {
id: number;
created_at: string;
deadline: string;
status: string;
project_name: string;
business_id: number;
business_name: string;
user_id: number;
project_type_id: number;
project_type_value: string;
short_description: string;
pitch_deck_url: string | null;
project_logo: string | null;
min_investment: number;
target_investment: number;
project_photos: string[] | null;
}
interface ProjectApplicationTableProps {
projects: ProjectApplicationData[];
}
function ProjectApplicationTable({ projects }: ProjectApplicationTableProps) {
if (!projects || projects.length === 0) {
return (
<TableRow>
<TableCell colSpan={11} className="text-center h-24 text-muted-foreground">
No project applications found
</TableCell>
</TableRow>
);
}
return (
<>
{projects.map((project) => (
<TableRow key={project.id}>
<TableCell>{project.project_name}</TableCell>
<TableCell>{project.project_type_value}</TableCell>
<TableCell>
{project.project_logo && (
<Image
src={project.project_logo}
alt={`${project.project_name} logo`}
width={40}
height={40}
className="rounded-md"
/>
)}
</TableCell>
<TableCell>{project.short_description}</TableCell>
<TableCell>
{project.pitch_deck_url && (
<Link href={project.pitch_deck_url} className="text-blue-500 hover:text-blue-600">
Pitch Deck
</Link>
)}
</TableCell>
<TableCell>{project.min_investment}</TableCell>
<TableCell>{project.target_investment}</TableCell>
<TableCell>{new Date(project.created_at).toLocaleDateString()}</TableCell>
<TableCell>{new Date(project.deadline).toLocaleDateString()}</TableCell>
<TableCell>
{project.project_photos ? (
project.project_photos.length > 0 && (
<div className="flex space-x-2">
{project.project_photos.map((photoUrl, index) => (
<Image
key={index}
src={photoUrl}
alt={`Project photo ${index + 1}`}
width={40}
height={40}
className="rounded-md"
/>
))}
</div>
)
) : (
<p>No images available</p>
)}
</TableCell>
<TableCell>
<ProjectActions projectId={project.id} />
</TableCell>
</TableRow>
))}
</>
);
}
export default async function ProjectAdminPage({ params }: { params: { businessId: string } }) {
const client = createSupabaseClient();
const { data: projectApplicationData, error: projectApplicationError } =
await getAllProjectApplicationByBusinessQuery(client, Number(params.businessId));
if (projectApplicationError) {
console.log(projectApplicationError);
return <div>Error loading project applications</div>;
}
return (
<div className="container max-w-screen-xl my-4">
<h1 className="text-2xl font-semibold mb-4">
{projectApplicationData?.[0]?.business_name || "Business Projects"}
</h1>
<Table className="border-2 border-border rounded-md">
<TableCaption>Project Applications for Business</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Project Name</TableHead>
<TableHead>Project Type</TableHead>
<TableHead>Logo</TableHead>
<TableHead>Description</TableHead>
<TableHead>Pitch Deck</TableHead>
<TableHead>Min Investment</TableHead>
<TableHead>Target Investment</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Deadline</TableHead>
<TableHead>Photos</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<ProjectApplicationTable projects={projectApplicationData || []} />
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,156 @@
import { getUserRole } from "@/lib/data/userQuery";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { redirect } from "next/navigation";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import Link from "next/link";
import { FolderOpenDot } from "lucide-react";
import { getAllBusinessApplicationQuery } from "@/lib/data/applicationQuery";
import { BusinessActionButtons } from "./BusinessActionButtons";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
interface ApplicationData {
id: any;
user_id: any;
username: any;
business_type_id: any;
business_type_value: any;
project_application_id: any;
business_name: any;
created_at: any;
is_in_us: any;
is_for_sale: any;
pitch_deck_url: any;
community_size: any;
is_generating_revenue: any;
money_raised_to_date: any;
location: any;
status: any;
}
function ApplicationTable({ applications }: { applications: ApplicationData[] }) {
if (!applications || applications.length === 0) {
return (
<TableRow>
<TableCell colSpan={11} className="text-center h-24 text-muted-foreground">
No business applications found
</TableCell>
</TableRow>
);
}
return applications.map((application: ApplicationData) => (
<TableRow key={application.id}>
<TableCell>{application.business_name}</TableCell>
<TableCell>
<Link href={`/profile/${application.user_id}`} className="text-blue-500 hover:text-blue-600">
{application.username}
</Link>
</TableCell>
<TableCell>
{application.pitch_deck_url && (
<Link href={application.pitch_deck_url} className="text-blue-500 hover:text-blue-600">
{application.pitch_deck_url}
</Link>
)}
</TableCell>
<TableCell>
<Checkbox checked={application.is_in_us} disabled />
</TableCell>
<TableCell>
<Checkbox checked={application.is_for_sale} disabled />
</TableCell>
<TableCell>
<Checkbox checked={application.is_generating_revenue} disabled />
</TableCell>
<TableCell>{application.community_size}</TableCell>
<TableCell>{application.money_raised_to_date}</TableCell>
<TableCell>{application.location}</TableCell>
<TableCell>
{application.project_application_id && (
<Link href={`/admin/project/${application.project_application_id}`}>
<FolderOpenDot className="border-[2px] border-black dark:border-white rounded-md hover:bg-gray-400 w-full cursor-pointer" />
</Link>
)}
</TableCell>
<TableCell>
{application.status === "pending" && <BusinessActionButtons businessApplicationId={application.id} />}
</TableCell>
</TableRow>
));
}
export default async function BusinesssApplicationAdminPage() {
const client = createSupabaseClient();
const { data: userData, error: userDataError } = await client.auth.getUser();
if (userDataError) {
redirect("/");
}
const uid = userData.user!.id;
const { data: roleData, error: roleDataError } = await getUserRole(client, uid);
if (roleDataError || roleData!.role != "admin") {
redirect("/");
}
const { data: businessApplicationData, error: businessApplicationError } =
await getAllBusinessApplicationQuery(client);
if (businessApplicationError) {
console.log(businessApplicationError);
}
const pendingApplications = businessApplicationData?.filter((app) => app.status === "pending") || [];
const approvedApplications = businessApplicationData?.filter((app) => app.status === "approve") || [];
const rejectedApplications = businessApplicationData?.filter((app) => app.status === "rejecte") || [];
return (
<div className="container max-w-screen-xl my-4">
<div className="font-bold text-2xl">Admin Page</div>
<Separator className="my-4" />
<Tabs defaultValue="pending" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="pending">Pending ({pendingApplications.length})</TabsTrigger>
<TabsTrigger value="approved">Approved ({approvedApplications.length})</TabsTrigger>
<TabsTrigger value="rejected">Rejected ({rejectedApplications.length})</TabsTrigger>
</TabsList>
{["pending", "approved", "rejected"].map((status) => (
<TabsContent key={status} value={status}>
<Table className="border-2 border-border rounded-md">
<TableCaption>{status.charAt(0).toUpperCase() + status.slice(1)} business applications</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Business Name</TableHead>
<TableHead>User Account</TableHead>
<TableHead>Pitch Deck URL</TableHead>
<TableHead>Is In US?</TableHead>
<TableHead>Is For Sale?</TableHead>
<TableHead>Generate Revenue</TableHead>
<TableHead>Community Size</TableHead>
<TableHead>Money raised to date</TableHead>
<TableHead>Location</TableHead>
<TableHead>Project</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<ApplicationTable
applications={
status === "pending"
? pendingApplications
: status === "approved"
? approvedApplications
: rejectedApplications
}
/>
</TableBody>
</Table>
</TabsContent>
))}
</Tabs>
</div>
);
}

21
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,21 @@
import { getAllBusinesses } from "@/lib/data/businessQuery";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import BusinessTable from "./BusinessTable";
import { Separator } from "@/components/ui/separator";
export default async function AdminPage() {
const client = createSupabaseClient();
const { data, error } = await getAllBusinesses(client);
if (error) {
return <div>Error fetching businesses: {error.message}</div>;
}
return (
<div className="container max-w-screen-xl my-4">
<h1 className="text-2xl font-bold">Business List</h1>
<Separator className="my-3" />
<BusinessTable businesses={data} />
</div>
);
}

View File

@ -1,5 +1,4 @@
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { getCurrentUserID } from "./userApi";
export type Deal = {
deal_amount: number;
@ -7,38 +6,133 @@ export type Deal = {
investor_id: string;
};
export async function getDealList() {
export async function getDealList(userId: string | undefined) {
if (!userId) {
// console.error("No deal list of this user was found");
return []; // Exit on error
}
const supabase = createSupabaseClient();
const { data: dealData, error } = await supabase
.from('business')
.select(`
id,
// get id of investors who invest in the business
const { data: dealData, error: dealError } = await supabase
.from("business")
.select(
`
project (
id,
investment_deal (
deal_amount,
created_time,
investor_id
)
)
`)
.eq('user_id', await getCurrentUserID())
`
)
.eq("user_id", userId)
.single();
if (error || !dealData) {
alert(JSON.stringify(error));
console.error('Error fetching deal list:', error);
} else {
const dealList = dealData.project[0].investment_deal;
if (dealError) {
// alert(JSON.stringify(dealError));
console.error("Error fetching deal list:", dealError);
return []; // Exit on error
}
if (!dealList.length) {
alert("No data available");
return; // Exit early if there's no data
}
if (!dealData || !dealData.project.length) {
alert("No project available");
return []; // Exit if there's no data
}
// Sort the dealList by created_time in descending order
const byCreatedTimeDesc = (a: Deal, b: Deal) =>
new Date(b.created_time).getTime() - new Date(a.created_time).getTime();
return dealList.sort(byCreatedTimeDesc);
}
};
const investorIdList = dealData.project[0].investment_deal.map((deal) => deal.investor_id);
// get investment_deal data then sort by created_time
const { data: sortedDealData, error: sortedDealDataError } = await supabase
.from("investment_deal")
.select(
`
deal_amount,
created_time,
investor_id
`
)
.in("investor_id", investorIdList)
.order("created_time", { ascending: false });
if (sortedDealDataError) {
alert(JSON.stringify(sortedDealDataError));
console.error("Error sorting deal list:", sortedDealDataError);
return []; // Exit on error
}
// console.log(sortedDealData)
return sortedDealData;
}
// #TODO fix query to be non unique
export async function getRecentDealData(userId: string | undefined) {
if (!userId) {
console.error("User not found");
return; // Exit on error
}
const supabase = createSupabaseClient();
const dealList = await getDealList(userId);
if (!dealList) {
// #TODO div error
console.error("No deal available");
return;
}
// get 5 most recent investor
const recentDealList = dealList.slice(0, 5);
const recentInvestorIdList = recentDealList.map((deal) => deal.investor_id);
const { data: recentUserData, error: recentUserError } = await supabase
.from("profiles")
.select(
`
username,
avatar_url
`
)
.in("id", recentInvestorIdList);
if (!recentUserData) {
alert("No recent users available");
return;
}
if (recentUserError) {
// Handle the error and return a meaningful message
console.error("Error fetching profiles:", recentUserError);
return;
// #TODO div error
}
// combine two arrays
const recentDealData = recentDealList.map((item, index) => {
return { ...item, ...recentUserData[index] };
});
return recentDealData;
}
// #TODO refactor using supabase query instead of manual
// #TODO move to util
export function convertToGraphData(deals: Deal[]): Record<string, number> {
// group by year & month
let graphData = deals.reduce(
(acc, deal) => {
const monthYear = new Date(deal.created_time).toISOString().slice(0, 7); // E.g., '2024-10'
acc[monthYear] = (acc[monthYear] || 0) + deal.deal_amount; // Sum the deal_amount
return acc;
},
{} as Record<string, number>
); // Change type to Record<string, number>
// Sort keys in ascending order
const sortedKeys = Object.keys(graphData).sort((a, b) => (a > b ? 1 : -1));
// Create a sorted graph data object
const sortedGraphData: Record<string, number> = {};
sortedKeys.forEach((key) => {sortedGraphData[key] = graphData[key]});
return sortedGraphData;
}

View File

@ -1,5 +1,3 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LoginButton } from "@/components/auth/loginButton";
@ -9,7 +7,8 @@ export default function Login() {
return (
<div
className="bg-cover bg-center min-h-screen flex items-center justify-center"
style={{ backgroundImage: "url(/login.png)" }}>
style={{ backgroundImage: "url(/login.png)" }}
>
<Card>
<CardHeader className="items-center">
<CardTitle className="text-2xl font-bold">Empower Your Vision</CardTitle>

View File

@ -1,5 +1,3 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SignupButton } from "@/components/auth/signupButton";
@ -9,7 +7,8 @@ export default function Signup() {
return (
<div
className="bg-cover bg-center min-h-screen flex items-center justify-center"
style={{ backgroundImage: "url(/signup.png)" }}>
style={{ backgroundImage: "url(/signup.png)" }}
>
<Card>
<CardHeader className="items-center">
<CardTitle className="text-2xl font-bold">Join Our Community</CardTitle>

View File

@ -49,7 +49,7 @@ export default function ApplyBusiness() {
}
}
const { data, error } = await supabase
const { error } = await supabase
.from("business_application")
.insert([
{
@ -60,20 +60,18 @@ export default function ApplyBusiness() {
is_for_sale: recvData["isForSale"],
is_generating_revenue: recvData["isGenerating"],
is_in_us: recvData["isInUS"],
pitch_deck_url:
pitchType === "string" ? recvData["businessPitchDeck"] : "",
pitch_deck_url: pitchType === "string" ? recvData["businessPitchDeck"] : "",
money_raised_to_date: recvData["totalRaised"],
community_size: recvData["communitySize"],
},
])
.select();
setSucess(true);
// console.table(data);
Swal.fire({
icon: error == null ? "success" : "error",
title: error == null ? "success" : "Error: " + error.code,
text:
error == null ? "Your application has been submitted" : error.message,
text: error == null ? "Your application has been submitted" : error.message,
confirmButtonColor: error == null ? "green" : "red",
}).then((result) => {
if (result.isConfirmed && applyProject) {
@ -85,10 +83,7 @@ export default function ApplyBusiness() {
};
const hasUserApplied = async (userID: string) => {
let { data: business, error } = await supabase
.from("business")
.select("*")
.eq("user_id", userID);
let { data: business, error } = await supabase.from("business").select("*").eq("user_id", userID);
console.table(business);
if (error) {
console.error(error);
@ -100,24 +95,21 @@ export default function ApplyBusiness() {
};
const transformChoice = (data: any) => {
// convert any yes and no to true or false
const transformedData = Object.entries(data).reduce(
(acc: Record<any, any>, [key, value]) => {
if (typeof value === "string") {
const lowerValue = value.toLowerCase();
if (lowerValue === "yes") {
acc[key] = true;
} else if (lowerValue === "no") {
acc[key] = false;
} else {
acc[key] = value; // keep other string values unchanged
}
const transformedData = Object.entries(data).reduce((acc: Record<any, any>, [key, value]) => {
if (typeof value === "string") {
const lowerValue = value.toLowerCase();
if (lowerValue === "yes") {
acc[key] = true;
} else if (lowerValue === "no") {
acc[key] = false;
} else {
acc[key] = value; // keep other types unchanged
acc[key] = value; // keep other string values unchanged
}
return acc;
},
{}
);
} else {
acc[key] = value; // keep other types unchanged
}
return acc;
}, {});
return transformedData;
};
useEffect(() => {
@ -164,22 +156,16 @@ export default function ApplyBusiness() {
</h1>
<div className="mt-5 justify-self-center">
<p className="text-sm md:text-base text-neutral-500">
All information submitted in this application is for internal use
only and is treated with the utmost{" "}
All information submitted in this application is for internal use only and is treated with the utmost{" "}
</p>
<p className="text-sm md:text-base text-neutral-500">
confidentiality. Companies may apply to raise with B2DVentures more
than once.
confidentiality. Companies may apply to raise with B2DVentures more than once.
</p>
</div>
</div>
{/* form */}
{/* <form action="" onSubmit={handleSubmit(handleSubmitForms)}> */}
<BusinessForm
onSubmit={onSubmit}
applyProject={applyProject}
setApplyProject={setApplyProject}
/>
<BusinessForm onSubmit={onSubmit} applyProject={applyProject} setApplyProject={setApplyProject} />
</div>
);
}

23
src/app/calendar/page.tsx Normal file
View File

@ -0,0 +1,23 @@
"use client";
import React, { useState } from "react";
import { DateTimePicker } from "@/components/ui/datetime-picker";
import { Label } from "@/components/ui/label";
const DatetimePickerHourCycle = () => {
const [date12, setDate12] = useState<Date | undefined>(undefined);
const [date24, setDate24] = useState<Date | undefined>(undefined);
return (
<div className="flex flex-col gap-3 lg:flex-row">
<div className="flex w-72 flex-col gap-2">
<Label>12 Hour</Label>
<DateTimePicker hourCycle={12} value={date12} onChange={setDate12} />
</div>
<div className="flex w-72 flex-col gap-2">
<Label>24 Hour</Label>
<DateTimePicker hourCycle={24} value={date24} onChange={setDate24} />
</div>
</div>
);
};
export default DatetimePickerHourCycle;

View File

@ -1,12 +1,15 @@
import { useEffect, useState } from "react";
import { Deal, getDealList } from "../api/dealApi";
import { Deal, getDealList, convertToGraphData, getRecentDealData } from "../api/dealApi";
import { RecentDealData } from "@/components/recent-funds";
import { getCurrentUserID } from "../api/userApi";
// custom hook for deal list
export function useDealList() {
const [dealList, setDealList] = useState<Deal[]>();
const [dealList, setDealList] = useState<Deal[]>([]);
const fetchDealList = async () => {
setDealList(await getDealList());
// set the state to the deal list of current business user
setDealList(await getDealList(await getCurrentUserID()));
}
useEffect(() => {
@ -14,4 +17,37 @@ export function useDealList() {
}, []);
return dealList;
}
export function useGraphData() {
const [graphData, setGraphData] = useState({});
const fetchGraphData = async () => {
// fetch the state to the deal list of current business user
const dealList = await getDealList(await getCurrentUserID());
if (dealList) {
setGraphData(convertToGraphData(dealList));
}
}
useEffect(() => {
fetchGraphData();
}, []);
return graphData;
}
export function useRecentDealData() {
const [recentDealData, setRecentDealData] = useState<RecentDealData[]>();
const fetchRecentDealData = async () => {
// set the state to the deal list of current business user
setRecentDealData(await getRecentDealData(await getCurrentUserID()));
}
useEffect(() => {
fetchRecentDealData();
}, []);
return recentDealData;
}

View File

@ -12,22 +12,18 @@ import { Overview } from "@/components/ui/overview";
import { RecentFunds } from "@/components/recent-funds";
import { useState } from "react";
import { useDealList } from "./hook";
import { useDealList, useGraphData, useRecentDealData } from "./hook";
import { sumByKey } from "@/lib/utils";
export default function Dashboard() {
const [graphType, setGraphType] = useState("line");
const graphData = useGraphData();
const dealList = useDealList();
const totalDealAmount = dealList?.reduce((sum, deal) => sum + deal.deal_amount, 0) || 0;
// #TODO dependency injection refactor + define default value inside function (and not here)
const recentDealData = useRecentDealData() || [];
return (
<>
{dealList?.map((deal, index) => (
<div key={index} className="deal-item">
<p>Deal Amount: {deal.deal_amount}</p>
<p>Created Time: {new Date(deal.created_time).toUTCString()}</p>
<p>Investor ID: {deal.investor_id}</p>
</div>
))}
<div className="md:hidden">
<Image
src="/examples/dashboard-light.png"
@ -75,7 +71,7 @@ export default function Dashboard() {
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${totalDealAmount}</div>
<div className="text-2xl font-bold">${sumByKey(dealList, "deal_amount")}</div>
{/* <p className="text-xs text-muted-foreground">
+20.1% from last month
</p> */}
@ -166,7 +162,7 @@ export default function Dashboard() {
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<Overview graphType={graphType} />
<Overview graphType={graphType} graphData={graphData} />
{/* tab to switch between line and bar graph */}
<Tabs
defaultValue="line"
@ -197,7 +193,7 @@ export default function Dashboard() {
</CardDescription>
</CardHeader>
<CardContent>
<RecentFunds>
<RecentFunds recentDealData={recentDealData}>
</RecentFunds>
</CardContent>
</Card>

View File

@ -0,0 +1,182 @@
"use client";
import { useState, useEffect } from "react";
import { FileIcon, defaultStyles } from "react-file-icon";
import { getFilesByDataroomId } from "@/lib/data/dataroomQuery";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { format } from "date-fns";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import Link from "next/link";
import { Skeleton } from "@/components/ui/skeleton";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { getAccessRequests } from "@/lib/data/dataroomQuery";
import useSession from "@/lib/supabase/useSession";
export default function ViewDataRoomFilesPage({ params }: { params: { dataroomId: number } }) {
const dataroomId = params.dataroomId;
const supabase = createSupabaseClient();
const router = useRouter();
const [sortOption, setSortOption] = useState("name");
const { session, loading: sessionLoading } = useSession();
const userId = session?.user?.id;
useEffect(() => {
if (!sessionLoading && !session) {
toast.error("Please login to access this page");
router.push("/auth/login");
}
}, [session, sessionLoading, router]);
const {
data: project,
error: projectError,
isLoading: isLoadingProject,
} = useQuery(supabase.from("project").select(`id, project_name, dataroom_id`).eq("dataroom_id", dataroomId), {
enabled: !!userId,
});
const {
data: accessRequest,
error: accessRequestError,
isLoading: accessRequestLoading,
} = useQuery(getAccessRequests(supabase, { dataroomId: dataroomId, userId: userId }));
useEffect(() => {
if (!accessRequestLoading && accessRequest) {
const hasAccess = accessRequest[0]?.status === "approve";
if (!hasAccess) {
toast.error("You don't have permission to access this dataroom");
router.push("/dataroom/overview");
}
}
}, [accessRequest, accessRequestLoading, router]);
const {
data: files,
error: filesError,
isLoading: isLoadingFiles,
} = useQuery(getFilesByDataroomId(supabase, dataroomId));
if (projectError || filesError || accessRequestError) {
const errorMessage = projectError
? "Unable to load project details"
: filesError
? "Unable to load files"
: "Unable to verify access permissions";
toast.error(errorMessage);
router.push("/dataroom/overview");
return null;
}
if (isLoadingProject || isLoadingFiles || accessRequestLoading) {
return (
<div className="container max-w-screen-xl p-4">
<Skeleton className="h-8 w-64 mb-4" />
<div className="space-y-3">
{[...Array(3)].map((_, index) => (
<Skeleton key={index} className="h-16 w-full" />
))}
</div>
</div>
);
}
function getFileNameFromUrl(fileUrl: string): string {
const fullFileName = fileUrl.split("/").pop() || "";
return decodeURIComponent(fullFileName.split("?")[0]);
}
const sortedFiles = [...(files || [])].sort((a, b) => {
if (sortOption === "name") {
const nameA = getFileNameFromUrl(a.file_url).toLowerCase();
const nameB = getFileNameFromUrl(b.file_url).toLowerCase();
return nameA.localeCompare(nameB);
} else if (sortOption === "date") {
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
}
return 0;
});
return (
<div className="container max-w-screen-xl p-4">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
Dataroom Files |&nbsp;
{isLoadingProject ? (
"Loading..."
) : project && project.length > 0 ? (
<Link href={`/deals/${project[0].id}`} className="text-blue-600">
{project[0].project_name}
</Link>
) : (
"Project Not Found"
)}
</h2>
<div
id="file-section"
className="border border-border dark:border-gray-700 rounded-md p-4 space-y-4 bg-white dark:bg-gray-900 shadow-sm"
>
<div className="flex justify-between items-center mb-2">
<p className="text-gray-700 dark:text-gray-300">{`Uploaded files (${sortedFiles.length})`}</p>
<div className="flex items-center space-x-2">
<label htmlFor="sort" className="text-gray-500 dark:text-gray-400">
Sort by:
</label>
<select
id="sort"
value={sortOption}
onChange={(e) => setSortOption(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded-md p-1 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800"
>
<option value="name">Name</option>
<option value="date">Date</option>
</select>
</div>
</div>
{/* Conditional rendering based on loading state */}
{isLoadingProject || isLoadingFiles ? (
<div className="space-y-3">
{[...Array(3)].map((_, index) => (
<Skeleton key={index} />
))}
</div>
) : (
<div className="space-y-3">
{sortedFiles.length > 0 ? (
sortedFiles.map((file) => (
<div
key={file.id}
className="flex justify-between items-center border border-border dark:border-gray-700 rounded-md p-3 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
>
<span className="flex items-center space-x-3">
<div className="w-8 h-8 flex items-center justify-center">
<FileIcon
extension={file.file_type.value}
{...defaultStyles[file.file_type.value as keyof typeof defaultStyles]}
/>
</div>
<Link href={file.file_url} rel="noopener noreferrer" target="_blank">
<p className="text-blue-600 dark:text-blue-400 hover:text-blue-800">
{getFileNameFromUrl(file.file_url)}
</p>
</Link>
</span>
<span className="flex items-center space-x-4 text-gray-600 dark:text-gray-400">
<p>{"Unknown size"}</p>
<p>{format(new Date(file.uploaded_at), "MMM d, yyyy")}</p>
<button className="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400">
Select
</button>
</span>
</div>
))
) : (
<p className="text-gray-500 dark:text-gray-400">No files uploaded yet.</p>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
"use client";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { useState, useEffect } from "react";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { updateAccessRequestStatus } from "@/lib/data/dataroomMutate";
import { getAccessRequests } from "@/lib/data/dataroomQuery";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
interface AccessRequest {
id: number;
dataroom_id: number;
user_id: string;
status: "pending" | "approve" | "reject";
requested_at: string;
}
export default function AccessRequestsManagement({ dataroomId }: { dataroomId: number }) {
const supabase = createSupabaseClient();
const { data, error, isLoading } = useQuery(getAccessRequests(supabase, { dataroomId }));
const [accessRequests, setAccessRequests] = useState<AccessRequest[]>([]);
useEffect(() => {
if (data) {
setAccessRequests(data);
}
}, [data]);
const handleStatusChange = async (requestId: number, newStatus: "approve" | "reject" | "pending") => {
await updateAccessRequestStatus(supabase, requestId, newStatus);
setAccessRequests((prevRequests) =>
prevRequests.map((request) => (request.id === requestId ? { ...request, status: newStatus } : request))
);
};
if (isLoading) return <p>Loading access requests...</p>;
if (error) return <p>Error loading access requests: {error.message}</p>;
return (
<>
<h3 className="text-lg font-medium mb-2">Manage Access Requests</h3>
<Separator className="my-2" />
<div className="overflow-y-auto max-h-60">
<Table>
<TableCaption>A list of access requests for the selected project/dataroom.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accessRequests.map((request) => (
<TableRow key={request.id}>
<TableCell>{request.user_id}</TableCell>
<TableCell>{request.status}</TableCell>
<TableCell className="text-right">
{request.status === "pending" ? (
<>
<Button
variant="outline"
className="bg-green-500 text-white mr-2"
onClick={() => handleStatusChange(request.id, "approve")}
>
Approve
</Button>
<Button
variant="outline"
className="bg-red-500 text-white"
onClick={() => handleStatusChange(request.id, "reject")}
>
Reject
</Button>
</>
) : request.status === "approve" ? (
<Button
variant="outline"
className="bg-red-500 text-white"
onClick={() => handleStatusChange(request.id, "pending")}
>
Revoke Access
</Button>
) : (
<Button
variant="outline"
className="bg-green-500 text-white"
onClick={() => handleStatusChange(request.id, "approve")}
>
Approve
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
);
}

View File

@ -0,0 +1,186 @@
"use client";
import React from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { uploadFileToDataRoom } from "@/lib/data/bucket/uploadFile";
import { getFilesByDataroomId } from "@/lib/data/dataroomQuery";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import Link from "next/link";
import { deleteFileFromDataRoom } from "@/lib/data/bucket/deleteFile";
import toast from "react-hot-toast";
interface FileManagementInterface {
dataroomId: number;
}
interface Files {
id: number;
dataroom_id: number;
file_url: string;
file_type: {
id: number;
value: string;
};
uploaded_at: string;
}
export default function FileManagement({ dataroomId }: FileManagementInterface) {
const supabase = createSupabaseClient();
const [fileToDelete, setFileToDelete] = useState<Files | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const { data: files, isLoading, error, refetch } = useQuery(getFilesByDataroomId(supabase, dataroomId));
function getFileNameFromUrl(fileUrl: string): string {
const fullFileName = fileUrl.split("/").pop() || "";
return decodeURIComponent(fullFileName.split("?")[0]);
}
const handleDeleteClick = (file: any) => {
setFileToDelete(file);
};
const handleDeleteConfirm = async () => {
if (!fileToDelete) return;
try {
await deleteFileFromDataRoom(
supabase,
fileToDelete.dataroom_id,
getFileNameFromUrl(fileToDelete.file_url),
fileToDelete.id
);
setFileToDelete(null);
refetch();
toast.success("Delete successfully!");
} catch (error) {
toast.error("Error occur while deleting file!");
setDeleteError("Error occur while deleting file!");
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
setSelectedFile(event.target.files[0]);
}
};
const handleUploadFile = async () => {
if (!selectedFile) return;
setUploading(true);
setUploadError(null);
try {
await uploadFileToDataRoom(supabase, selectedFile, dataroomId);
refetch();
toast.success("Upload successfully!");
} catch (error) {
toast.error("Error occur while uploading!");
setUploadError("Error occur while uploading!");
} finally {
setUploading(false);
setSelectedFile(null);
}
};
if (isLoading) return <p>Loading files...</p>;
if (error) return <p>Error loading files: {error.message}</p>;
return (
<>
<h3 className="text-lg font-medium mb-2">Manage Files</h3>
<Separator className="my-2" />
<Input type="file" className="mb-2" onChange={handleFileChange} />
<Button className="mb-4" onClick={handleUploadFile} disabled={uploading}>
{uploading ? "Uploading..." : "Upload File"}
</Button>
{uploadError && <div className="text-red-500">{uploadError}</div>}
{deleteError && <div className="text-red-500">{deleteError}</div>}
<div className="overflow-y-auto max-h-60 mb-4">
<Table>
<TableCaption>A list of files in the selected data room.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>File Name</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files?.map((file) => (
<TableRow key={file.id}>
<TableCell className="font-medium">
<Link href={file.file_url} rel="noopener noreferrer" target="_blank">
<p className="text-blue-600 dark:text-blue-400 hover:text-blue-800">
{getFileNameFromUrl(file.file_url)}
</p>
</Link>
</TableCell>
<TableCell>Uploaded</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-red-500" onClick={() => handleDeleteClick(file)}>
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the file &quot;
{getFileNameFromUrl(file.file_url)}
&quot;.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setFileToDelete(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2}>Total Files</TableCell>
<TableCell className="text-right">{files?.length}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</>
);
}

View File

@ -0,0 +1,110 @@
"use client";
import { useEffect, useState } from "react";
import FileManagement from "./FileManagement";
import AccessRequestsManagement from "./AccessRequestsManagement";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { getProjectByUserId } from "@/lib/data/projectQuery";
import { getUserRole } from "@/lib/data/userQuery";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function ManageDataroomPage() {
const supabase = createSupabaseClient();
const router = useRouter();
const [userRole, setUserRole] = useState<string | null>(null);
const [projects, setProjects] = useState<any[]>([]);
const [selectedProject, setSelectedProject] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUserData = async () => {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
router.push("/");
return;
}
const { data: roleData, error: roleError } = await getUserRole(supabase, user.id);
if (roleError) {
toast.error("Error loading user role.");
router.push("/");
return;
}
setUserRole(roleData.role);
const { data: projectData, error: projectError } = await getProjectByUserId(supabase, user.id);
if (projectError) {
toast.error("Error loading projects.");
router.push("/");
return;
}
setProjects(projectData);
setLoading(false);
};
fetchUserData();
}, [supabase, router]);
useEffect(() => {
if (userRole && userRole !== "business") {
router.push("/");
}
}, [userRole, router]);
if (loading) {
return <p className="flex items-center justify-center h-screen">Loading...</p>;
}
return (
<div className="container max-w-screen-xl p-4">
<h2 className="text-xl font-semibold mb-4">Manage Data Room / Projects</h2>
<div className="mb-4">
<Select
onValueChange={(value) => {
const selected = projects.find((project) => project.id.toString() === value);
setSelectedProject(selected);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Projects</SelectLabel>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id.toString()}>
{project.project_name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{selectedProject && (
<>
<div className="border border-border p-4 rounded-md my-3">
<FileManagement dataroomId={selectedProject.dataroom_id} />
</div>
<div className="border border-border p-4 rounded-md">
<AccessRequestsManagement dataroomId={selectedProject.dataroom_id} />
</div>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,216 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { requestAccessToDataRoom } from "@/lib/data/dataroomMutate";
import { getInvestmentByUserId } from "@/lib/data/investmentQuery";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHeader,
TableHead,
TableRow,
TableFooter,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { getAccessRequests } from "@/lib/data/dataroomQuery";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { Separator } from "@/components/ui/separator";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import useSession from "@/lib/supabase/useSession";
import toast from "react-hot-toast";
interface Investment {
id: any;
project_id: any;
project_name: any;
project_short_description: any;
dataroom_id: any;
deal_amount: any;
investor_id: any;
created_time: any;
}
interface AccessRequest {
id: number;
dataroom_id: number;
user_id: string;
status: "approve" | "reject" | "pending";
requested_at: string;
}
export default function ListRequestAccessPage() {
const client = createSupabaseClient();
const { session, loading: sessionLoading } = useSession();
const userId = session?.user?.id;
const {
data: investments = [],
error: investmentsError,
isLoading: isLoadingInvestments,
} = useQuery(getInvestmentByUserId(client, userId ?? ""), {
enabled: !!userId,
});
const {
data: accessRequests = [],
error: accessRequestsError,
isLoading: isLoadingAccessRequests,
refetch: refetchAccessRequests,
} = useQuery(getAccessRequests(client, { userId: userId ?? "" }), {
enabled: !!userId,
});
const projectInvestments = useMemo(() => {
if (!investments) return {};
return investments.reduce((acc: { [key: string]: Investment[] }, investment: Investment) => {
const projectId = investment.project_id;
if (!acc[projectId]) {
acc[projectId] = [];
}
acc[projectId].push(investment);
return acc;
}, {});
}, [investments]);
const handleRequestAccess = async (dataroomId: any) => {
if (!userId) {
toast.error("Please login first");
return;
}
try {
const { error } = await requestAccessToDataRoom(client, dataroomId, userId);
if (error) {
toast.error("Error sending request.");
return;
}
toast.success("Request sent successfully!");
await refetchAccessRequests();
} catch (error) {
toast.error("An unexpected error occurred");
}
};
if (sessionLoading) {
return <div className="flex items-center justify-center h-screen">Checking authentication...</div>;
}
if (!session) {
return <div className="flex items-center justify-center h-screen">Please login to view this page</div>;
}
if (isLoadingInvestments || isLoadingAccessRequests) {
return <div className="flex items-center justify-center h-screen">Loading data...</div>;
}
if (investmentsError || accessRequestsError) {
return <div className="flex items-center justify-center h-screen">Error loading data!</div>;
}
return (
<div className="container mx-auto p-5">
<h1 className="text-2xl font-bold mb-4">List of Access Requests</h1>
<div className="mb-6 space-y-4">
<h2 className="text-xl font-semibold">Project Investments</h2>
<Separator className="my-2" />
{Object.entries(projectInvestments).map(([projectId, projectInvestments]) => (
<Card key={projectId}>
<CardHeader>
<CardTitle className="text-xl">{projectInvestments[0].project_name}</CardTitle>
<CardDescription>{projectInvestments[0].project_short_description}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableCaption>Investments for this project</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Investment Id</TableHead>
<TableHead>Invested At</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectInvestments.map((investment) => (
<TableRow key={investment.id}>
<TableCell className="font-medium">{investment.id}</TableCell>
<TableCell>{investment.created_time}</TableCell>
<TableCell className="text-right">{investment.deal_amount}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2}>Total</TableCell>
<TableCell className="text-right">
{projectInvestments
.reduce((total, investment) => total + (parseFloat(investment.deal_amount) || 0), 0)
.toLocaleString("en-US", { style: "currency", currency: "USD" })}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</CardContent>
<CardFooter>
{(accessRequests || []).some(
(request) => request.dataroom_id === projectInvestments[0].dataroom_id && request.status === "pending"
) ? (
<Button disabled>Pending Request</Button>
) : (accessRequests || []).some(
(request) => request.dataroom_id === projectInvestments[0].dataroom_id && request.status === "approve"
) ? (
<Button onClick={() => window.open(`${projectInvestments[0].dataroom_id}/files`, "_blank")}>
Access Dataroom
</Button>
) : (
<Button onClick={() => handleRequestAccess(projectInvestments[0].dataroom_id)}>
Request Dataroom Access
</Button>
)}
</CardFooter>
</Card>
))}
</div>
{/* Access Requests Table - Unchanged */}
<div>
<h2 className="text-xl font-semibold mb-2">Access Requests & Status</h2>
<Table>
<TableCaption>A list of your access requests.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Request ID</TableHead>
<TableHead>Dataroom ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Requested At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(accessRequests || []).length > 0 ? (
accessRequests?.map((request: AccessRequest) => (
<TableRow key={request.id}>
<TableCell className="font-medium">{request.id}</TableCell>
<TableCell>{request.dataroom_id}</TableCell>
<TableCell>{request.status}</TableCell>
<TableCell>{new Date(request.requested_at).toLocaleDateString()}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="text-center">
No access requests found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

19
src/app/error.tsx Normal file
View File

@ -0,0 +1,19 @@
"use client";
interface ErrorProps {
error: Error;
reset: () => void;
}
export default function Error({ error, reset }: ErrorProps) {
return (
<main className="flex justify-center items-center flex-col gap-6">
<h1 className="text-3xl font-semibold">Something went wrong!</h1>
<p className="text-lg">{error.message}</p>
<button className="inline-block bg-accent-500 text-primary-800 px-6 py-3 text-lg" onClick={reset}>
Try again
</button>
</main>
);
}

View File

@ -1,29 +1,17 @@
"use client";
import React, { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ProjectCard } from "@/components/projectCard";
import { Separator } from "@/components/ui/separator";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getBusinesses, getInvestmentCounts, getProjects, getTags } from "@/lib/data/query";
import { Tables } from "@/types/database.types";
import { getBusinessAndProject } from "@/lib/data/businessQuery";
interface ProjectInvestmentDetail extends Tables<"ProjectInvestmentDetail"> {}
interface Project extends Tables<"Project"> {
ProjectInvestmentDetail: ProjectInvestmentDetail[];
}
interface Business extends Tables<"Business"> {
Projects: Project[];
}
export default function Find() {
function FindContent() {
const searchParams = useSearchParams();
const query = searchParams.get("query");
// const query = "neon";
let supabase = createSupabaseClient();
@ -31,95 +19,71 @@ export default function Find() {
data: businesses,
isLoading: isLoadingBusinesses,
error: businessError,
} = useQuery(getBusinesses(supabase, query));
} = useQuery(getBusinessAndProject(supabase, { businessName: query }));
const businessIds = businesses?.map((b) => b.id) || [];
const {
data: projects,
isLoading: isLoadingProjects,
error: projectError,
} = useQuery(getProjects(supabase, businessIds), {
enabled: businessIds.length > 0,
});
const projectIds = projects?.map((p) => p.id) || [];
const {
data: tags,
isLoading: isLoadingTags,
error: tagError,
} = useQuery(getTags(supabase, projectIds), {
enabled: projectIds.length > 0,
});
const {
data: investmentCounts,
isLoading: isLoadingInvestments,
error: investmentError,
} = useQuery(getInvestmentCounts(supabase, projectIds), {
enabled: projectIds.length > 0,
});
// -----
const isLoading = isLoadingBusinesses || isLoadingProjects || isLoadingTags || isLoadingInvestments;
const error = businessError || projectError || tagError || investmentError;
const results: Business[] =
businesses?.map((business) => ({
...business,
Projects:
projects
?.filter((project) => project.businessId === business.id)
.map((project) => ({
...project,
tags: tags?.filter((tag) => tag.itemId === project.id).map((tag) => tag.Tag.value) || [],
investmentCount: investmentCounts?.find((ic) => ic.projectId === project.id)?.count || 0,
})) || [],
})) || [];
const isLoading = isLoadingBusinesses;
const error = businessError;
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error fetching data: {error.message}</p>;
return (
<div>
<div className="mt-10 mx-[15%]">
<div className="container max-w-screen-xl">
<div className="mt-4">
<h1 className="text-4xl font-bold">Result</h1>
<Separator className="my-4" />
{results.length === 0 && <p>No results found.</p>}
{results.length > 0 && (
<ul>
{results.map((business) => (
<li key={business.id}>
<Card className="w-full">
<CardHeader>
<CardTitle>{business.businessName}</CardTitle>
<CardDescription>Joined Date: {new Date(business.joinedDate).toLocaleDateString()}</CardDescription>
</CardHeader>
<CardContent>
{business.Projects.map((project) => (
<ProjectCard
key={project.id}
name={project.projectName}
description={project.projectName}
joinDate={project.projectName}
location={"Bangkok"}
minInvestment={project.ProjectInvestmentDetail[0]?.minInvestment}
totalInvestor={project.ProjectInvestmentDetail[0]?.totalInvestment}
totalRaised={project.ProjectInvestmentDetail[0]?.targetInvestment}
tags={[]}
imageUri={null}
/>
))}
</CardContent>
</Card>
</li>
))}
</ul>
)}
<ReactQueryDevtools initialIsOpen={false} />
<Card className="w-full">
<CardContent className="my-2">
{businesses!.length === 0 && <p>No results found.</p>}
{businesses!.length > 0 && (
<ul>
{businesses!.map((business) => (
<li key={business.business_id}>
<Card className="w-full">
<CardHeader>
<CardTitle>{business.business_name}</CardTitle>
<CardDescription>
Joined Date: {new Date(business.joined_date).toLocaleDateString()}
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-4 gap-4">
{business?.projects && business.projects.length > 0 ? (
business.projects.map((project) => (
<ProjectCard
key={project.id}
name={project.project_name}
description={project.project_short_description}
joinDate={project.published_time}
location={business.location}
minInvestment={project.min_investment}
totalInvestor={project.total_investment}
totalRaised={project.target_investment}
tags={project.tags?.map((tag) => String(tag.tag_value)) || []}
imageUri={project.card_image_url}
/>
))
) : (
<p>No Projects</p>
)}
</CardContent>
</Card>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default function Find() {
return (
<Suspense fallback={<p>Loading search parameters...</p>}>
<FindContent />
</Suspense>
);
}

View File

@ -1,3 +1,4 @@
import React from "react";
import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";

22
src/app/loading.tsx Normal file
View File

@ -0,0 +1,22 @@
export default function Loading() {
return (
<div className="container flex items-center justify-center h-screen">
<div className="text-center">
<svg
className="animate-spin h-12 w-12 text-gray-600 mx-auto mb-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291l-2.832 2.832A10.003 10.003 0 0112 22v-4a8.001 8.001 0 01-6-5.709z"
></path>
</svg>
<p className="text-lg font-semibold text-gray-600">Loading data...</p>
</div>
</div>
);
}

23
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,23 @@
import Link from "next/link";
function NotFound() {
return (
<main className="flex flex-col items-center justify-center min-h-screen bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div className="max-w-md text-center p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h1 className="text-4xl font-bold mb-4">404</h1>
<h2 className="text-2xl font-semibold mb-4">This page could not be found :(</h2>
<p className="mb-6 text-gray-600 dark:text-gray-400">
Sorry, the page you are looking for does not exist or has been moved.
</p>
<Link
href="/"
className="inline-block bg-accent-500 text-primary-800 px-8 py-4 text-lg font-medium rounded transition duration-200 hover:bg-accent-600 dark:bg-accent-400 dark:text-primary-800 dark:hover:bg-accent-500"
>
Go back home
</Link>
</div>
</main>
);
}
export default NotFound;

View File

@ -1,191 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import Image from "next/image";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel";
import { Card, CardContent } from "@/components/ui/card";
import CountUp from "react-countup";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { ShareIcon, StarIcon } from "lucide-react";
import { Toaster, toast } from "react-hot-toast";
import useSession from "@/lib/supabase/useSession";
import { redirect } from "next/navigation";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import Link from "next/link";
export default function Invest() {
const [progress, setProgress] = useState(0);
const [tab, setTab] = useState("Pitch");
const { session, loading } = useSession();
const user = session?.user;
const [sessionLoaded, setSessionLoaded] = useState(false);
const [isFollow, setIsFollow] = useState(false);
useEffect(() => {
// set sessionLoaded to true once session is confirmed
if (!loading) {
setSessionLoaded(true);
}
}, [loading]);
const handleClick = (item: string) => {
setTab(item);
};
const handleShare = () => {
const currentUrl = window.location.href;
if (document.hasFocus()) {
navigator.clipboard.writeText(currentUrl).then(() => {
toast.success("URL copied to clipboard!");
});
}
};
const handleFollow = () => {
if (user) {
setIsFollow((prevState) => !prevState);
// save follow to database
} else {
redirect("/login");
}
};
useEffect(() => {
// percent success
const timer = setTimeout(() => setProgress(66), 500);
return () => clearTimeout(timer);
}, []);
return (
<div>
<div>
<Toaster position="top-right" reverseOrder={false} />
</div>
<div className="w-[90%] h-[450px]-500 md:m-auto mt-12 md:mt-12 pl-14 md:pl-24">
<div>
{/* Name, star and share button packed */}
<div className="grid grid-cols-4">
<div className="flex col-span-2">
<Image src="./logo.svg" alt="logo" width={50} height={50} className="sm:scale-75" />
<div className="mt-3 font-bold text-lg md:text-3xl">NVIDIA</div>
</div>
<div className="grid grid-cols-2 gap-5 justify-self-end ">
<div className="mt-2 cursor-pointer" onClick={handleFollow}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<StarIcon id="follow" fill={isFollow ? "#FFFF00" : "#fff"} strokeWidth={2} />
</TooltipTrigger>
<TooltipContent>
<p>Follow NIVIDIA</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div onClick={handleShare} className=" cursor-pointer mt-2">
<ShareIcon />
</div>
</div>
</div>
{/* end of pack */}
<p className="mt-2 sm:text-sm"> World's first non-metal sustainable battery</p>
<div className="flex flex-wrap mt-3">
{["Technology", "Gaming"].map((tag) => (
<span key={tag} className="text-xs rounded-md bg-slate-200 dark:bg-slate-700 p-1 mx-1 mb-1">
{tag}
</span>
))}
</div>
<div className="grid grid-cols-2 mt-5">
{/* image carousel */}
<div>
<Carousel className="w-full mt-20 md:mt-0">
<CarouselContent className="h-[400px] flex h-full">
{Array.from({ length: 5 }).map((_, index) => (
<CarouselItem key={index}>
<img src="./boiler1.jpg" alt="" className="rounded-lg self-center" />
</CarouselItem>
))}
</CarouselContent>{" "}
<CarouselPrevious />
<CarouselNext />
</Carousel>
<Carousel className="w-2/3 md:w-full ml-10 md:ml-0 mt-5 md:mt-10 ">
<CarouselContent>
{/* boiler plate for an actual pictures */}
<CarouselItem className="pl-1 md:basis-1/2 lg:basis-1/3">
<img src="./boiler1.jpg" alt="" className="rounded-lg" />
</CarouselItem>
<CarouselItem className="pl-1 md:basis-1/2 lg:basis-1/3">
<img src="./boiler1.jpg" alt="" className="rounded-lg" />
</CarouselItem>
<CarouselItem className="pl-1 md:basis-1/2 lg:basis-1/3">
<img src="./boiler1.jpg" alt="" className="rounded-lg" />
</CarouselItem>
<CarouselItem className="pl-1 md:basis-1/2 lg:basis-1/3">
<img src="./boiler1.jpg" alt="" className="rounded-lg" />
</CarouselItem>
<CarouselItem className="pl-1 md:basis-1/2 lg:basis-1/3">
<img src="./boiler1.jpg" alt="" className="rounded-lg" />
</CarouselItem>
</CarouselContent>
</Carousel>
</div>
<div className=" w-2/3 mt-4 m-auto grid-rows-5">
<div className="pl-5">
<h1 className="font-semibold text-xl md:text-4xl mt-8">
<CountUp start={0} end={100000} duration={2} prefix="$" className="" />
</h1>
<p className="text-sm md:text-lg"> 5% raised of $5M max goal</p>
<Progress value={progress} className="w-[60%] h-3 mt-3" />
<h1 className="font-semibold text-4xl md:mt-8">
{" "}
<CountUp start={0} end={1000} duration={2} className="text-xl md:text-4xl" />
</h1>
<p className="text-sm md:text-lg"> Investors</p>
</div>
<Separator decorative className="mt-3 w-3/4 ml-5" />
<h1 className="font-semibold text-xl md:text-4xl mt-8 ml-5">
<CountUp start={0} end={5800} duration={2} className="text-xl md:text-4xl" /> hours
</h1>
<p className="ml-5"> Left to invest</p>
<Button className="mt-5 md:mt-10 ml-0 md:ml-[25%] scale-75 md:scale-100">
<Link href="/invest">Invest in NVIDIA</Link>
</Button>
</div>
</div>
</div>
</div>
{/* menu */}
<div className="flex w-[90%] mt-24 m-auto ml-10 md:ml-32">
<ul className="list-none flex gap-10 text-lg md:text-xl ">
<li>
<a onClick={() => handleClick("Pitch")} className={tab === "Pitch" ? "text-blue-600" : ""}>
Pitch
</a>
</li>
<li>
<a onClick={() => handleClick("General Data")} className={tab === "General Data" ? "text-blue-600" : ""}>
General Data
</a>
</li>
<li>
<a onClick={() => handleClick("Updates")} className={tab === "Updates" ? "text-blue-600" : ""}>
Updates
</a>
</li>
</ul>
</div>
<hr className="mt-2" />
{/* Card section */}
<div className="flex w-full mt-10">
{/* Cards */}
<Card className="m-auto border-slate-800 w-3/4 p-6">
<CardContent>
<Card>
<CardContent>{tab}</CardContent>
</Card>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,13 +1,7 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ProjectCard } from "@/components/projectCard";
import { getTopProjects } from "@/lib/data/projectQuery";
@ -47,17 +41,12 @@ const TopProjects: FC<TopProjectsProps> = ({ projects }) => {
imageUri={project.card_image_url}
joinDate={new Date(project.published_time).toLocaleDateString()}
location={project.business[0]?.location || ""}
tags={project.project_tag.flatMap(
(item: { tag: { id: number; value: string }[] }) =>
Array.isArray(item.tag) ? item.tag.map((tag) => tag.value) : []
tags={project.project_tag.flatMap((item: { tag: { id: number; value: string }[] }) =>
Array.isArray(item.tag) ? item.tag.map((tag) => tag.value) : []
)}
minInvestment={
project.project_investment_detail[0]?.min_investment || 0
}
minInvestment={project.project_investment_detail[0]?.min_investment || 0}
totalInvestor={0}
totalRaised={
project.project_investment_detail[0]?.total_investment || 0
}
totalRaised={project.project_investment_detail[0]?.total_investment || 0}
/>
</Link>
))}
@ -68,17 +57,13 @@ const TopProjects: FC<TopProjectsProps> = ({ projects }) => {
const ProjectsLoader = () => (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, index) => (
<div
key={index}
className="h-64 bg-gray-200 animate-pulse rounded-lg"
></div>
<div key={index} className="h-64 bg-gray-200 animate-pulse rounded-lg"></div>
))}
</div>
);
export default async function Home() {
const supabase = createSupabaseClient();
const { data: topProjectsData, error: topProjectsError } =
await getTopProjects(supabase);
const { data: topProjectsData, error: topProjectsError } = await getTopProjects(supabase);
return (
<main>
@ -87,14 +72,9 @@ export default async function Home() {
<div className="flex flex-row bg-slate-100 dark:bg-gray-800">
<div className="container max-w-screen-xl flex flex-col">
<span className="mx-20 px-10 py-10">
<p className="text-4xl font-bold">
Explore the world of ventures
</p>
<p className="text-4xl font-bold">Explore the world of ventures</p>
<span className="text-lg">
<p>
Unlock opportunities and connect with a community of
passionate
</p>
<p>Unlock opportunities and connect with a community of passionate</p>
<p>investors and innovators.</p>
<p>Together, we turn ideas into impact.</p>
</span>
@ -142,23 +122,11 @@ export default async function Home() {
</CardHeader>
<CardContent className="flex gap-2">
<Button className="flex gap-1 border-2 border-border rounded-md p-1 bg-background text-foreground scale-75 md:scale-100">
<Image
src={"/github.svg"}
width={20}
height={20}
alt="github"
className="scale-75 md:scale-100"
/>
<Image src={"/github.svg"} width={20} height={20} alt="github" className="scale-75 md:scale-100" />
Github
</Button>
<Button className="flex gap-1 border-2 border-border rounded-md p-1 bg-background text-foreground scale-75 md:scale-100">
<Image
src={"/github.svg"}
width={20}
height={20}
alt="github"
className="scale-75 md:scale-100"
/>
<Image src={"/github.svg"} width={20} height={20} alt="github" className="scale-75 md:scale-100" />
Github
</Button>
</CardContent>
@ -170,13 +138,15 @@ export default async function Home() {
<div className="flex flex-col px-10">
<span className="pb-5">
<p className="text-xl md:text-2xl font-bold">Hottest Deals</p>
<p className="text-md md:text-lg">
The deals attracting the most interest right now
</p>
<p className="text-md md:text-lg">The deals attracting the most interest right now</p>
</span>
<Suspense fallback={<ProjectsLoader />}>
<TopProjects projects={topProjectsData || []} />
</Suspense>
{topProjectsError ? (
<div className="text-red-500">Error fetching projects: {topProjectsError}</div>
) : (
<Suspense fallback={<ProjectsLoader />}>
<TopProjects projects={topProjectsData || []} />
</Suspense>
)}
<div className="self-center py-5 scale-75 md:scale-100">
<Button>
<Link href={"/deals"}>View all</Link>

View File

@ -1,4 +1,5 @@
"use client";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import ProjectForm from "@/components/ProjectForm";
import { projectFormSchema } from "@/types/schemas/application.schema";
@ -17,7 +18,7 @@ const BUCKET_PITCH_APPLICATION_NAME = "project-application";
export default function ApplyProject() {
const [isSuccess, setIsSuccess] = useState(true);
const onSubmit: SubmitHandler<projectSchema> = async (data) => {
alert("มาแน้ววว");
// alert("มาแน้ววว");
await sendApplication(data);
// console.table(data);
// console.log(typeof data["projectPhotos"], data["projectPhotos"]);
@ -29,8 +30,7 @@ export default function ApplyProject() {
.insert([
{
user_id: userId,
pitch_deck_url:
pitchType === "string" ? recvData["projectPitchDeck"] : "",
pitch_deck_url: pitchType === "string" ? recvData["projectPitchDeck"] : "",
target_investment: recvData["targetInvest"],
deadline: recvData["deadline"],
project_name: recvData["projectName"],
@ -58,36 +58,21 @@ export default function ApplyProject() {
const results = await Promise.all(tagPromises);
// Collect errors
const errors = results
.filter((result) => result.error)
.map((result) => result.error);
const errors = results.filter((result) => result.error).map((result) => result.error);
return { errors };
};
const uploadPitchFile = async (
file: File,
userId: string,
projectId: string
) => {
const uploadPitchFile = async (file: File, userId: string, projectId: string) => {
if (!file || !userId) {
console.error("Pitch file or user ID is undefined.");
return false;
}
return await uploadFile(
file,
BUCKET_PITCH_APPLICATION_NAME,
`${userId}/${projectId}/pitches/${file.name}`
);
return await uploadFile(file, BUCKET_PITCH_APPLICATION_NAME, `${userId}/${projectId}/pitches/${file.name}`);
};
const uploadLogoAndPhotos = async (
logoFile: File,
photos: File[],
userId: string,
projectId: string
) => {
const uploadLogoAndPhotos = async (logoFile: File, photos: File[], userId: string, projectId: string) => {
const uploadResults: { logo?: any; photos: any[] } = { photos: [] };
// upload logo
@ -108,11 +93,7 @@ export default function ApplyProject() {
// upload each photo
const uploadPhotoPromises = photos.map((image) =>
uploadFile(
image,
BUCKET_PITCH_APPLICATION_NAME,
`${userId}/${projectId}/photos/${image.name}`
)
uploadFile(image, BUCKET_PITCH_APPLICATION_NAME, `${userId}/${projectId}/photos/${image.name}`)
);
const photoResults = await Promise.all(uploadPhotoPromises);
@ -132,8 +113,7 @@ export default function ApplyProject() {
Swal.fire({
icon: error == null ? "success" : "error",
title: error == null ? "Success" : `Error: ${error.code}`,
text:
error == null ? "Your application has been submitted" : error.message,
text: error == null ? "Your application has been submitted" : error.message,
confirmButtonColor: error == null ? "green" : "red",
}).then((result) => {
if (result.isConfirmed) {
@ -168,11 +148,7 @@ export default function ApplyProject() {
// upload pitch file if its a file
if (typeof recvData["projectPitchDeck"] === "object") {
const uploadPitchSuccess = await uploadPitchFile(
recvData["projectPitchDeck"],
user.id,
projectId
);
const uploadPitchSuccess = await uploadPitchFile(recvData["projectPitchDeck"], user.id, projectId);
if (!uploadPitchSuccess) {
console.error("Error uploading pitch file.");
@ -196,21 +172,11 @@ export default function ApplyProject() {
// console.log("Logo Path:", logo.data.path);
// console.table(photos);
const logoURL = await getPrivateURL(
logo.data.path,
BUCKET_PITCH_APPLICATION_NAME
);
const logoURL = await getPrivateURL(logo.data.path, BUCKET_PITCH_APPLICATION_NAME);
let photoURLsArray: string[] = [];
const photoURLPromises = photos.map(
async (item: {
success: boolean;
errors: typeof errors;
data: { path: string };
}) => {
const photoURL = await getPrivateURL(
item.data.path,
BUCKET_PITCH_APPLICATION_NAME
);
async (item: { success: boolean; errors: typeof errors; data: { path: string } }) => {
const photoURL = await getPrivateURL(item.data.path, BUCKET_PITCH_APPLICATION_NAME);
if (photoURL?.signedUrl) {
photoURLsArray.push(photoURL.signedUrl);
} else {
@ -233,11 +199,7 @@ export default function ApplyProject() {
setIsSuccess(true);
displayAlert(error);
};
const updateImageURL = async (
url: string | string[],
columnName: string,
projectId: number
) => {
const updateImageURL = async (url: string | string[], columnName: string, projectId: number) => {
const { error } = await supabase
.from("project_application")
.update({ [columnName]: url })
@ -250,9 +212,7 @@ export default function ApplyProject() {
}
};
const getPrivateURL = async (path: string, bucketName: string) => {
const { data } = await supabase.storage
.from(bucketName)
.createSignedUrl(path, 9999999999999999999999999999);
const { data } = await supabase.storage.from(bucketName).createSignedUrl(path, 9999999999999999999999999999);
// console.table(data);
return data;
};
@ -265,12 +225,10 @@ export default function ApplyProject() {
</h1>
<div className="mt-5 justify-self-center">
<p className="text-sm md:text-base text-neutral-500">
Begin Your First Fundraising Project. Starting a fundraising project
is mandatory for all businesses.
Begin Your First Fundraising Project. Starting a fundraising project is mandatory for all businesses.
</p>
<p className="text-sm md:text-base text-neutral-500">
This step is crucial to begin your journey and unlock the necessary
tools for raising funds.
This step is crucial to begin your journey and unlock the necessary tools for raising funds.
</p>
</div>
</div>

View File

@ -3,27 +3,14 @@ import { SubmitHandler, useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { DualOptionSelector } from "@/components/dualSelector";
import { MultipleOptionSelector } from "@/components/multipleSelector";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@radix-ui/react-tooltip";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
type businessSchema = z.infer<typeof businessFormSchema>;
@ -54,14 +41,10 @@ const BusinessForm = ({
let supabase = createSupabaseClient();
const [businessPitch, setBusinessPitch] = useState("text");
const [businessPitchFile, setBusinessPitchFile] = useState("");
const [countries, setCountries] = useState<{ id: number; name: string }[]>(
[]
);
const [countries, setCountries] = useState<{ id: number; name: string }[]>([]);
const [industry, setIndustry] = useState<{ id: number; name: string }[]>([]);
const fetchIndustry = async () => {
let { data: BusinessType, error } = await supabase
.from("business_type")
.select("id, value");
let { data: BusinessType, error } = await supabase.from("business_type").select("id, value");
if (error) {
console.error(error);
@ -84,18 +67,12 @@ const BusinessForm = ({
throw new Error("Network response was not ok");
}
const data = await response.json();
const countryList = data.map(
(country: { name: { common: string } }, index: number) => ({
id: index + 1,
name: country.name.common,
})
);
const countryList = data.map((country: { name: { common: string } }, index: number) => ({
id: index + 1,
name: country.name.common,
}));
setCountries(
countryList.sort((a: { name: string }, b: { name: any }) =>
a.name.localeCompare(b.name)
)
);
setCountries(countryList.sort((a: { name: string }, b: { name: any }) => a.name.localeCompare(b.name)));
} catch (error) {
console.error("Error fetching countries:", error);
}
@ -106,15 +83,11 @@ const BusinessForm = ({
}, []);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit as SubmitHandler<businessSchema>)}
className="space-y-8"
>
<form onSubmit={form.handleSubmit(onSubmit as SubmitHandler<businessSchema>)} className="space-y-8">
<div className="grid grid-flow-row auto-rows-max w-3/4 ml-1/2 md:ml-[0%] ">
<h1 className="text-3xl font-bold mt-10 ml-96">About your company</h1>
<p className="ml-96 mt-5 text-neutral-500">
<span className="text-red-500 font-bold">**</span>All requested
information in this section is required.
<span className="text-red-500 font-bold">**</span>All requested information in this section is required.
</p>
<div className="ml-96 mt-5 space-y-10">
{/* Company Name */}
@ -123,21 +96,13 @@ const BusinessForm = ({
name="companyName"
render={({ field }: { field: any }) => (
<FormItem>
<FormLabel className="font-bold text-lg">
Company name
</FormLabel>
<FormLabel className="font-bold text-lg">Company name</FormLabel>
<FormControl>
<div className="mt-10 space-y-5">
<div className="flex space-x-5">
<Input
type="text"
id="companyName"
className="w-96"
{...field}
/>
<Input type="text" id="companyName" className="w-96" {...field} />
<span className="text-[12px] text-neutral-500 self-center">
This should be the name your company uses on your{" "}
<br />
This should be the name your company uses on your <br />
website and in the market.
</span>
</div>
@ -162,9 +127,7 @@ const BusinessForm = ({
// console.log("Country selected: " + selectedValues.name);
field.onChange(selectedValues.name);
}}
description={
<>Select the country where your business is based.</>
}
description={<>Select the country where your business is based.</>}
placeholder="Select a country"
selectLabel="Country"
/>
@ -189,12 +152,7 @@ const BusinessForm = ({
// console.log("Type of selected value:", selectedValues.id);
field.onChange(selectedValues.id);
}}
description={
<>
Choose the industry that best aligns with your
business.
</>
}
description={<>Choose the industry that best aligns with your business.</>}
placeholder="Select an industry"
selectLabel="Industry"
/>
@ -229,8 +187,7 @@ const BusinessForm = ({
value={field.value}
/>
<span className="text-[12px] text-neutral-500 self-center">
The sum total of past financing, including angel or
venture <br />
The sum total of past financing, including angel or venture <br />
capital, loans, grants, or token sales.
</span>
</div>
@ -251,11 +208,7 @@ const BusinessForm = ({
<div className="flex space-x-5">
<DualOptionSelector
name="isInUS"
label={
<>
Is your company incorporated in the United States?
</>
}
label={<>Is your company incorporated in the United States?</>}
choice1="Yes"
choice2="No"
handleFunction={(selectedValues: string) => {
@ -266,8 +219,7 @@ const BusinessForm = ({
value={field.value}
/>
<span className="text-[12px] text-neutral-500 self-center">
Only companies that are incorporated or formed in the US
are eligible to raise via Reg CF.
Only companies that are incorporated or formed in the US are eligible to raise via Reg CF.
</span>
</div>
</FormControl>
@ -287,21 +239,14 @@ const BusinessForm = ({
<DualOptionSelector
name="isForSale"
value={field.value}
label={
<>Is your product available (for sale) in market?</>
}
label={<>Is your product available (for sale) in market?</>}
choice1="Yes"
choice2="No"
handleFunction={(selectedValues: string) => {
// setIsForSale;
field.onChange(selectedValues);
}}
description={
<>
Only check this box if customers can access, use, or
buy your product today.
</>
}
description={<>Only check this box if customers can access, use, or buy your product today.</>}
/>
</div>
</FormControl>
@ -328,10 +273,7 @@ const BusinessForm = ({
field.onChange(selectedValues);
}}
description={
<>
Only check this box if your company is making money.
Please elaborate on revenue below.
</>
<>Only check this box if your company is making money. Please elaborate on revenue below.</>
}
/>
</div>
@ -356,9 +298,7 @@ const BusinessForm = ({
<div className="flex space-x-2 w-96">
<Button
type="button"
variant={
businessPitch === "text" ? "default" : "outline"
}
variant={businessPitch === "text" ? "default" : "outline"}
onClick={() => setBusinessPitch("text")}
className="w-32 h-12 text-base"
>
@ -366,9 +306,7 @@ const BusinessForm = ({
</Button>
<Button
type="button"
variant={
businessPitch === "file" ? "default" : "outline"
}
variant={businessPitch === "file" ? "default" : "outline"}
onClick={() => setBusinessPitch("file")}
className="w-32 h-12 text-base"
>
@ -378,14 +316,8 @@ const BusinessForm = ({
<div className="flex space-x-5">
<Input
type={businessPitch === "file" ? "file" : "text"}
placeholder={
businessPitch === "file"
? "Upload your Markdown file"
: "https:// "
}
accept={
businessPitch === "file" ? ".md" : undefined
}
placeholder={businessPitch === "file" ? "Upload your Markdown file" : "https:// "}
accept={businessPitch === "file" ? ".md" : undefined}
onChange={(e) => {
const value = e.target;
if (businessPitch === "file") {
@ -399,17 +331,12 @@ const BusinessForm = ({
/>
<span className="text-[12px] text-neutral-500 self-center">
Your pitch deck and other application info will be
used for <br />
Your pitch deck and other application info will be used for <br />
internal purposes only. <br />
Please make sure this document is publicly
accessible. This can <br />
be a DocSend, Box, Dropbox, Google Drive or other
link.
Please make sure this document is publicly accessible. This can <br />
be a DocSend, Box, Dropbox, Google Drive or other link.
<br />
<p className="text-red-500">
** support only markdown(.md) format
</p>
<p className="text-red-500">** support only markdown(.md) format</p>
</span>
</div>
{businessPitchFile && (
@ -448,10 +375,7 @@ const BusinessForm = ({
field.onChange(selectedValues.name);
}}
description={
<>
Include your email list, social media following (e.g.,
Instagram, Discord, Twitter).
</>
<>Include your email list, social media following (e.g., Instagram, Discord, Twitter).</>
}
placeholder="Select"
selectLabel="Select"
@ -462,32 +386,25 @@ const BusinessForm = ({
)}
/>
<div className="flex space-x-5">
<Switch
onCheckedChange={() => setApplyProject(!applyProject)}
></Switch>
<Switch onCheckedChange={() => setApplyProject(!applyProject)}></Switch>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-[12px] text-neutral-500 self-center cursor-pointer">
Would you like to apply for your first fundraising project
as well?
Would you like to apply for your first fundraising project as well?
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-[11px]">
Toggling this option allows you to begin your first
project, <br /> which is crucial for unlocking the tools
necessary to raise funds.
Toggling this option allows you to begin your first project, <br /> which is crucial for unlocking
the tools necessary to raise funds.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<center>
<Button
className="mt-12 mb-20 h-10 text-base font-bold py-6 px-5"
type="submit"
>
<Button className="mt-12 mb-20 h-10 text-base font-bold py-6 px-5" type="submit">
Submit application
</Button>
</center>

View File

@ -1,5 +1,6 @@
"use client";
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

View File

@ -1,5 +1,6 @@
"use client";
import React from "react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useState } from "react";
import { Input } from "@/components/ui/input";

View File

@ -1,5 +1,6 @@
"use client";
import React from "react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useState } from "react";
import { Input } from "@/components/ui/input";

View File

@ -1,7 +1,5 @@
type IconProps = React.HTMLAttributes<SVGElement>;
export const Icons = {
userLogo: (props: IconProps) => (
userLogo: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"

View File

@ -27,7 +27,8 @@ const ListItem = React.forwardRef<React.ElementRef<"a">, React.ComponentPropsWit
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
className
)}
{...props}>
{...props}
>
<div className="text-sm font-medium leading-none">{title}</div>
<hr />
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">{children}</p>
@ -56,13 +57,6 @@ export function NavigationBar() {
},
];
const blogComponents = [
{
title: "Blogs",
href: "/landing",
description: "Raise on B2DVentures",
},
];
return (
<header className="sticky top-0 flex flex-wrap w-full bg-card text-sm py-3 border-b-2 border-border z-50">
<nav className="max-w-screen-xl w-full mx-auto px-4">
@ -71,9 +65,10 @@ export function NavigationBar() {
<Link
className="flex-none text-xl font-semibold dark:text-white focus:outline-none focus:opacity-80"
href="/"
aria-label="Brand">
aria-label="Brand"
>
<span className="inline-flex items-center gap-x-2 text-xl font-semibold dark:text-white">
<Image src="./logo.svg" alt="logo" width={50} height={50} />
<Image src="/logo.svg" alt="logo" width={50} height={50} />
B2DVentures
</span>
</Link>
@ -108,25 +103,6 @@ export function NavigationBar() {
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger className="text-base">Blogs</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] ">
{blogComponents.map((component) => (
<ListItem key={component.title} title={component.title} href={component.href}>
{component.description}
</ListItem>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink className="text-base font-medium" href="docs">
Docs
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem className="pl-5 flex">
<SearchBar />
</NavigationMenuItem>

View File

@ -7,7 +7,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@ -16,6 +15,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Bell, Heart, Wallet } from "lucide-react";
import { LogoutButton } from "@/components/auth/logoutButton";
import useSession from "@/lib/supabase/useSession";
import { useUserRole } from "@/hooks/useUserRole";
const UnAuthenticatedComponents = () => {
return (
@ -35,8 +35,13 @@ const UnAuthenticatedComponents = () => {
const AuthenticatedComponents = ({ uid }: { uid: string }) => {
let notifications = 100;
const displayValue = notifications >= 100 ? "..." : notifications;
const { data } = useUserRole();
const businessClass =
data?.role === "business" ? "border-2 border-[#FFD700] bg-[#FFF8DC] dark:bg-[#4B3E2B] rounded-md p-1" : "";
return (
<div className="flex gap-3 pl-2 items-center">
<div className={`flex gap-3 pl-2 items-center ${businessClass}`}>
<Link href={"/notification"}>
<div className="relative inline-block">
<Bell className="h-6 w-6" />
@ -63,6 +68,11 @@ const AuthenticatedComponents = ({ uid }: { uid: string }) => {
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Support</DropdownMenuItem>
{data != null && data != undefined && data.role === "admin" && (
<DropdownMenuItem>
<Link href="/admin">Admin</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogoutButton />

View File

@ -1,25 +1,11 @@
"use client";
import { Icons } from "./ui/icons";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export function CardsPaymentMethod() {
return (
@ -33,12 +19,7 @@ export function CardsPaymentMethod() {
<CardContent className="grid gap-6">
<RadioGroup defaultValue="card" className="flex w-full justify-center gap-1 md:gap-3">
<div className="w-[100px] lg:w-[130px]">
<RadioGroupItem
value="card"
id="card"
className="peer sr-only"
aria-label="Card"
/>
<RadioGroupItem value="card" id="card" className="peer sr-only" aria-label="Card" />
<Label
htmlFor="card"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-transparent p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
@ -61,12 +42,7 @@ export function CardsPaymentMethod() {
</div>
<div className="w-[100px] lg:w-[130px]">
<RadioGroupItem
value="paypal"
id="paypal"
className="peer sr-only"
aria-label="Paypal"
/>
<RadioGroupItem value="paypal" id="paypal" className="peer sr-only" aria-label="Paypal" />
<Label
htmlFor="paypal"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-transparent p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
@ -77,12 +53,7 @@ export function CardsPaymentMethod() {
</div>
<div className="w-[100px] lg:w-[130px]">
<RadioGroupItem
value="apple"
id="apple"
className="peer sr-only"
aria-label="Apple"
/>
<RadioGroupItem value="apple" id="apple" className="peer sr-only" aria-label="Apple" />
<Label
htmlFor="apple"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-transparent p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"

View File

@ -27,7 +27,8 @@ export function ProjectCard(props: ProjectCardProps) {
className={cn(
"flex flex-col group border-[1px] border-border relative hover:shadow-md rounded-xl h-[450px] ",
props.className
)}>
)}
>
<div className="flex flex-col h-full">
{/* Image */}
<div className="relative h-3/4 w-full">
@ -69,11 +70,15 @@ export function ProjectCard(props: ProjectCardProps) {
<span className="text-xs">{props.location}</span>
</div>
<div className="flex flex-wrap mt-1 items-center text-muted-foreground">
{props.tags.map((tag) => (
<span id="tag" key={tag} className="text-[10px] rounded-md bg-slate-200 dark:bg-slate-700 p-1 mr-1">
{tag}
</span>
))}
{props.tags && Array.isArray(props.tags) ? (
props.tags.map((tag) => (
<span id="tag" key={tag} className="text-[10px] rounded-md bg-slate-200 dark:bg-slate-700 p-1 mr-1">
{tag}
</span>
))
) : (
<span className="text-xs text-muted-foreground">No tags available</span>
)}
</div>
</div>
</div>

View File

@ -1,59 +1,39 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
const data = [
{
name: "Olivia Martin",
email: "olivia.martin@email.com",
amount: "1900.00",
avatar: "/avatars/01.png", // psuedo avatar image
initials: "OM",
},
{
name: "Jackson Lee",
email: "jackson.lee@email.com",
amount: "39.00",
avatar: "/avatars/02.png",
initials: "JL",
},
{
name: "Isabella Nguyen",
email: "isabella.nguyen@email.com",
amount: "299.00",
avatar: "/avatars/03.png",
initials: "IN",
},
{
name: "William Kim",
email: "will@email.com",
amount: "99.00",
avatar: "/avatars/04.png",
initials: "WK",
},
{
name: "Sofia Davis",
email: "sofia.davis@email.com",
amount: "39.00",
avatar: "/avatars/05.png",
initials: "SD",
},
];
export type RecentDealData = {
created_time: Date;
deal_amount: number;
investor_id: string;
username: string;
avatar_url?: string;
// email: string;
};
export function RecentFunds() {
interface RecentFundsProps {
recentDealData: RecentDealData[];
}
export function RecentFunds({ recentDealData }: RecentFundsProps) {
return (
<div className="space-y-8">
{data.map((person, index) => (
<div className="flex items-center" key={index}>
<Avatar className="h-9 w-9">
<AvatarImage src={person.avatar} alt={person.name} />
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">{person.name}</p>
<p className="text-sm text-muted-foreground">{person.email}</p>
{recentDealData?.length > 0 ? (
recentDealData.map((data) => (
<div className="flex items-center" key={data.investor_id}>
<Avatar className="h-9 w-9">
<AvatarImage src={data.avatar_url} alt={data.username} />
{/* #TODO make this not quick fix */}
<AvatarFallback>{data.username ? data.username[0]: ""}</AvatarFallback>
</Avatar>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">{data.username}</p>
{/* <p className="text-sm text-muted-foreground">{data.email}</p> */}
</div>
<div className="ml-auto font-medium">+${data.deal_amount}</div>
</div>
<div className="ml-auto font-medium">+${person.amount}</div>
</div>
))}
))
) : (
<p>No recent deals available.</p>
)}
</div>
);
}

View File

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={
{
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: () => <ChevronRight className="h-4 w-4" />,
} as any
}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,742 @@
import { Button, buttonVariants } from "@/components/ui/button";
import type { CalendarProps } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { add, format } from "date-fns";
import { type Locale, enUS } from "date-fns/locale";
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
import { Clock } from "lucide-react";
import * as React from "react";
import { useImperativeHandle, useRef } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DayPicker } from "react-day-picker";
// ---------- utils start ----------
/**
* regular expression to check for valid hour format (01-23)
*/
function isValidHour(value: string) {
return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
}
/**
* regular expression to check for valid 12 hour format (01-12)
*/
function isValid12Hour(value: string) {
return /^(0[1-9]|1[0-2])$/.test(value);
}
/**
* regular expression to check for valid minute format (00-59)
*/
function isValidMinuteOrSecond(value: string) {
return /^[0-5][0-9]$/.test(value);
}
type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
function getValidNumber(value: string, { max, min = 0, loop = false }: GetValidNumberConfig) {
let numericValue = parseInt(value, 10);
if (!Number.isNaN(numericValue)) {
if (!loop) {
if (numericValue > max) numericValue = max;
if (numericValue < min) numericValue = min;
} else {
if (numericValue > max) numericValue = min;
if (numericValue < min) numericValue = max;
}
return numericValue.toString().padStart(2, "0");
}
return "00";
}
function getValidHour(value: string) {
if (isValidHour(value)) return value;
return getValidNumber(value, { max: 23 });
}
function getValid12Hour(value: string) {
if (isValid12Hour(value)) return value;
return getValidNumber(value, { min: 1, max: 12 });
}
function getValidMinuteOrSecond(value: string) {
if (isValidMinuteOrSecond(value)) return value;
return getValidNumber(value, { max: 59 });
}
type GetValidArrowNumberConfig = {
min: number;
max: number;
step: number;
};
function getValidArrowNumber(value: string, { min, max, step }: GetValidArrowNumberConfig) {
let numericValue = parseInt(value, 10);
if (!Number.isNaN(numericValue)) {
numericValue += step;
return getValidNumber(String(numericValue), { min, max, loop: true });
}
return "00";
}
function getValidArrowHour(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 23, step });
}
function getValidArrow12Hour(value: string, step: number) {
return getValidArrowNumber(value, { min: 1, max: 12, step });
}
function getValidArrowMinuteOrSecond(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 59, step });
}
function setMinutes(date: Date, value: string) {
const minutes = getValidMinuteOrSecond(value);
date.setMinutes(parseInt(minutes, 10));
return date;
}
function setSeconds(date: Date, value: string) {
const seconds = getValidMinuteOrSecond(value);
date.setSeconds(parseInt(seconds, 10));
return date;
}
function setHours(date: Date, value: string) {
const hours = getValidHour(value);
date.setHours(parseInt(hours, 10));
return date;
}
function set12Hours(date: Date, value: string, period: Period) {
const hours = parseInt(getValid12Hour(value), 10);
const convertedHours = convert12HourTo24Hour(hours, period);
date.setHours(convertedHours);
return date;
}
type TimePickerType = "minutes" | "seconds" | "hours" | "12hours";
type Period = "AM" | "PM";
function setDateByType(date: Date, value: string, type: TimePickerType, period?: Period) {
switch (type) {
case "minutes":
return setMinutes(date, value);
case "seconds":
return setSeconds(date, value);
case "hours":
return setHours(date, value);
case "12hours": {
if (!period) return date;
return set12Hours(date, value, period);
}
default:
return date;
}
}
function getDateByType(date: Date | null, type: TimePickerType) {
if (!date) return "00";
switch (type) {
case "minutes":
return getValidMinuteOrSecond(String(date.getMinutes()));
case "seconds":
return getValidMinuteOrSecond(String(date.getSeconds()));
case "hours":
return getValidHour(String(date.getHours()));
case "12hours":
return getValid12Hour(String(display12HourValue(date.getHours())));
default:
return "00";
}
}
function getArrowByType(value: string, step: number, type: TimePickerType) {
switch (type) {
case "minutes":
return getValidArrowMinuteOrSecond(value, step);
case "seconds":
return getValidArrowMinuteOrSecond(value, step);
case "hours":
return getValidArrowHour(value, step);
case "12hours":
return getValidArrow12Hour(value, step);
default:
return "00";
}
}
/**
* handles value change of 12-hour input
* 12:00 PM is 12:00
* 12:00 AM is 00:00
*/
function convert12HourTo24Hour(hour: number, period: Period) {
if (period === "PM") {
if (hour <= 11) {
return hour + 12;
}
return hour;
}
if (period === "AM") {
if (hour === 12) return 0;
return hour;
}
return hour;
}
/**
* time is stored in the 24-hour form,
* but needs to be displayed to the user
* in its 12-hour representation
*/
function display12HourValue(hours: number) {
if (hours === 0 || hours === 12) return "12";
if (hours >= 22) return `${hours - 12}`;
if (hours % 12 > 9) return `${hours}`;
return `0${hours % 12}`;
}
function genMonths(locale: Pick<Locale, "options" | "localize" | "formatLong">) {
return Array.from({ length: 12 }, (_, i) => ({
value: i,
label: format(new Date(2021, i), "MMMM", { locale }),
}));
}
function genYears(yearRange = 50) {
const today = new Date();
return Array.from({ length: yearRange * 2 + 1 }, (_, i) => ({
value: today.getFullYear() - yearRange + i,
label: (today.getFullYear() - yearRange + i).toString(),
}));
}
// ---------- utils end ----------
function Calendar({
className,
classNames,
showOutsideDays = true,
yearRange = 50,
...props
}: CalendarProps & { yearRange?: number }) {
const MONTHS = React.useMemo(() => {
let locale: Pick<Locale, "options" | "localize" | "formatLong"> = enUS;
const { options, localize, formatLong } = props.locale || {};
if (options && localize && formatLong) {
locale = {
options,
localize,
formatLong,
};
}
return genMonths(locale);
}, []);
const YEARS = React.useMemo(() => genYears(yearRange), []);
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-y-0 justify-center",
month: "flex flex-col items-center space-y-4",
month_caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center ",
button_previous: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-5 top-5"
),
button_next: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-5 top-5"
),
month_grid: "w-full border-collapse space-y-1",
weekdays: cn("flex", props.showWeekNumber && "justify-end"),
weekday: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
week: "flex w-full mt-2",
day: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 rounded-1",
day_button: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100 rounded-l-md rounded-r-md"
),
range_end: "day-range-end",
selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground rounded-l-md rounded-r-md",
today: "bg-accent text-accent-foreground",
outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
disabled: "text-muted-foreground opacity-50",
range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ ...props }) =>
props.orientation === "left" ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />,
MonthCaption: ({ calendarMonth }) => {
return (
<div className="inline-flex gap-2">
<Select
defaultValue={calendarMonth.date.getMonth().toString()}
onValueChange={(value) => {
const newDate = new Date(calendarMonth.date);
newDate.setMonth(Number.parseInt(value, 10));
props.onMonthChange?.(newDate);
}}
>
<SelectTrigger className="w-fit gap-1 border-none p-0 focus:bg-accent focus:text-accent-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTHS.map((month) => (
<SelectItem key={month.value} value={month.value.toString()}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
defaultValue={calendarMonth.date.getFullYear().toString()}
onValueChange={(value) => {
const newDate = new Date(calendarMonth.date);
newDate.setFullYear(Number.parseInt(value, 10));
props.onMonthChange?.(newDate);
}}
>
<SelectTrigger className="w-fit gap-1 border-none p-0 focus:bg-accent focus:text-accent-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{YEARS.map((year) => (
<SelectItem key={year.value} value={year.value.toString()}>
{year.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
},
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
interface PeriodSelectorProps {
period: Period;
setPeriod?: (m: Period) => void;
date?: Date | null;
onDateChange?: (date: Date | undefined) => void;
onRightFocus?: () => void;
onLeftFocus?: () => void;
}
const TimePeriodSelect = React.forwardRef<HTMLButtonElement, PeriodSelectorProps>(
({ period, setPeriod, date, onDateChange, onLeftFocus, onRightFocus }, ref) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "ArrowRight") onRightFocus?.();
if (e.key === "ArrowLeft") onLeftFocus?.();
};
const handleValueChange = (value: Period) => {
setPeriod?.(value);
/**
* trigger an update whenever the user switches between AM and PM;
* otherwise user must manually change the hour each time
*/
if (date) {
const tempDate = new Date(date);
const hours = display12HourValue(date.getHours());
onDateChange?.(setDateByType(tempDate, hours.toString(), "12hours", period === "AM" ? "PM" : "AM"));
}
};
return (
<div className="flex h-10 items-center">
<Select defaultValue={period} onValueChange={(value: Period) => handleValueChange(value)}>
<SelectTrigger
ref={ref}
className="w-[65px] focus:bg-accent focus:text-accent-foreground"
onKeyDown={handleKeyDown}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AM">AM</SelectItem>
<SelectItem value="PM">PM</SelectItem>
</SelectContent>
</Select>
</div>
);
}
);
TimePeriodSelect.displayName = "TimePeriodSelect";
interface TimePickerInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
picker: TimePickerType;
date?: Date | null;
onDateChange?: (date: Date | undefined) => void;
period?: Period;
onRightFocus?: () => void;
onLeftFocus?: () => void;
}
const TimePickerInput = React.forwardRef<HTMLInputElement, TimePickerInputProps>(
(
{
className,
type = "tel",
value,
id,
name,
date = new Date(new Date().setHours(0, 0, 0, 0)),
onDateChange,
onChange,
onKeyDown,
picker,
period,
onLeftFocus,
onRightFocus,
...props
},
ref
) => {
const [flag, setFlag] = React.useState<boolean>(false);
const [prevIntKey, setPrevIntKey] = React.useState<string>("0");
/**
* allow the user to enter the second digit within 2 seconds
* otherwise start again with entering first digit
*/
React.useEffect(() => {
if (flag) {
const timer = setTimeout(() => {
setFlag(false);
}, 2000);
return () => clearTimeout(timer);
}
}, [flag]);
const calculatedValue = React.useMemo(() => {
return getDateByType(date, picker);
}, [date, picker]);
const calculateNewValue = (key: string) => {
/*
* If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
* The second entered digit will break the condition and the value will be set to 10-12.
*/
if (picker === "12hours") {
if (flag && calculatedValue.slice(1, 2) === "1" && prevIntKey === "0") return `0${key}`;
}
return !flag ? `0${key}` : calculatedValue.slice(1, 2) + key;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Tab") return;
e.preventDefault();
if (e.key === "ArrowRight") onRightFocus?.();
if (e.key === "ArrowLeft") onLeftFocus?.();
if (["ArrowUp", "ArrowDown"].includes(e.key)) {
const step = e.key === "ArrowUp" ? 1 : -1;
const newValue = getArrowByType(calculatedValue, step, picker);
if (flag) setFlag(false);
const tempDate = date ? new Date(date) : new Date();
onDateChange?.(setDateByType(tempDate, newValue, picker, period));
}
if (e.key >= "0" && e.key <= "9") {
if (picker === "12hours") setPrevIntKey(e.key);
const newValue = calculateNewValue(e.key);
if (flag) onRightFocus?.();
setFlag((prev) => !prev);
const tempDate = date ? new Date(date) : new Date();
onDateChange?.(setDateByType(tempDate, newValue, picker, period));
}
};
return (
<Input
ref={ref}
id={id || picker}
name={name || picker}
className={cn(
"w-[48px] text-center font-mono text-base tabular-nums caret-transparent focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none",
className
)}
value={value || calculatedValue}
onChange={(e) => {
e.preventDefault();
onChange?.(e);
}}
type={type}
inputMode="decimal"
onKeyDown={(e) => {
onKeyDown?.(e);
handleKeyDown(e);
}}
{...props}
/>
);
}
);
TimePickerInput.displayName = "TimePickerInput";
interface TimePickerProps {
date?: Date | null;
onChange?: (date: Date | undefined) => void;
hourCycle?: 12 | 24;
/**
* Determines the smallest unit that is displayed in the datetime picker.
* Default is 'second'.
* */
granularity?: Granularity;
}
interface TimePickerRef {
minuteRef: HTMLInputElement | null;
hourRef: HTMLInputElement | null;
secondRef: HTMLInputElement | null;
}
const TimePicker = React.forwardRef<TimePickerRef, TimePickerProps>(
({ date, onChange, hourCycle = 24, granularity = "second" }, ref) => {
const minuteRef = React.useRef<HTMLInputElement>(null);
const hourRef = React.useRef<HTMLInputElement>(null);
const secondRef = React.useRef<HTMLInputElement>(null);
const periodRef = React.useRef<HTMLButtonElement>(null);
const [period, setPeriod] = React.useState<Period>(date && date.getHours() >= 12 ? "PM" : "AM");
useImperativeHandle(
ref,
() => ({
minuteRef: minuteRef.current,
hourRef: hourRef.current,
secondRef: secondRef.current,
periodRef: periodRef.current,
}),
[minuteRef, hourRef, secondRef]
);
return (
<div className="flex items-center justify-center gap-2">
<label htmlFor="datetime-picker-hour-input" className="cursor-pointer">
<Clock className="mr-2 h-4 w-4" />
</label>
<TimePickerInput
picker={hourCycle === 24 ? "hours" : "12hours"}
date={date}
id="datetime-picker-hour-input"
onDateChange={onChange}
ref={hourRef}
period={period}
onRightFocus={() => minuteRef?.current?.focus()}
/>
{(granularity === "minute" || granularity === "second") && (
<>
:
<TimePickerInput
picker="minutes"
date={date}
onDateChange={onChange}
ref={minuteRef}
onLeftFocus={() => hourRef?.current?.focus()}
onRightFocus={() => secondRef?.current?.focus()}
/>
</>
)}
{granularity === "second" && (
<>
:
<TimePickerInput
picker="seconds"
date={date}
onDateChange={onChange}
ref={secondRef}
onLeftFocus={() => minuteRef?.current?.focus()}
onRightFocus={() => periodRef?.current?.focus()}
/>
</>
)}
{hourCycle === 12 && (
<div className="grid gap-1 text-center">
<TimePeriodSelect
period={period}
setPeriod={setPeriod}
date={date}
onDateChange={(date) => {
onChange?.(date);
if (date && date?.getHours() >= 12) {
setPeriod("PM");
} else {
setPeriod("AM");
}
}}
ref={periodRef}
onLeftFocus={() => secondRef?.current?.focus()}
/>
</div>
)}
</div>
);
}
);
TimePicker.displayName = "TimePicker";
type Granularity = "day" | "hour" | "minute" | "second";
type DateTimePickerProps = {
value?: Date;
onChange?: (date: Date | undefined) => void;
disabled?: boolean;
/** showing `AM/PM` or not. */
hourCycle?: 12 | 24;
placeholder?: string;
/**
* The year range will be: `This year + yearRange` and `this year - yearRange`.
* Default is 50.
* For example:
* This year is 2024, The year dropdown will be 1974 to 2024 which is generated by `2024 - 50 = 1974` and `2024 + 50 = 2074`.
* */
yearRange?: number;
/**
* The format is derived from the `date-fns` documentation.
* @reference https://date-fns.org/v3.6.0/docs/format
**/
displayFormat?: { hour24?: string; hour12?: string };
/**
* The granularity prop allows you to control the smallest unit that is displayed by DateTimePicker.
* By default, the value is `second` which shows all time inputs.
**/
granularity?: Granularity;
className?: string;
} & Pick<CalendarProps, "locale" | "weekStartsOn" | "showWeekNumber" | "showOutsideDays">;
type DateTimePickerRef = {
value?: Date;
} & Omit<HTMLButtonElement, "value">;
const DateTimePicker = React.forwardRef<Partial<DateTimePickerRef>, DateTimePickerProps>(
(
{
locale = enUS,
value,
onChange,
hourCycle = 24,
yearRange = 50,
disabled = false,
displayFormat,
granularity = "second",
placeholder = "Pick a date",
className,
...props
},
ref
) => {
const [month, setMonth] = React.useState<Date>(value ?? new Date());
const buttonRef = useRef<HTMLButtonElement>(null);
/**
* carry over the current time when a user clicks a new day
* instead of resetting to 00:00
*/
const handleSelect = (newDay: Date | undefined) => {
if (!newDay) return;
if (!value) {
onChange?.(newDay);
setMonth(newDay);
return;
}
const diff = newDay.getTime() - value.getTime();
const diffInDays = diff / (1000 * 60 * 60 * 24);
const newDateFull = add(value, { days: Math.ceil(diffInDays) });
onChange?.(newDateFull);
setMonth(newDateFull);
};
useImperativeHandle(
ref,
() => ({
...buttonRef.current,
value,
}),
[value]
);
const initHourFormat = {
hour24: displayFormat?.hour24 ?? `PPP HH:mm${!granularity || granularity === "second" ? ":ss" : ""}`,
hour12: displayFormat?.hour12 ?? `PP hh:mm${!granularity || granularity === "second" ? ":ss" : ""} b`,
};
let loc = enUS;
const { options, localize, formatLong } = locale;
if (options && localize && formatLong) {
loc = {
...enUS,
options,
localize,
formatLong,
};
}
return (
<Popover>
<PopoverTrigger asChild disabled={disabled}>
<Button
variant="outline"
className={cn("w-full justify-start text-left font-normal", !value && "text-muted-foreground", className)}
ref={buttonRef}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value ? (
format(value, hourCycle === 24 ? initHourFormat.hour24 : initHourFormat.hour12, {
locale: loc,
})
) : (
<span>{placeholder}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={value}
month={month}
onSelect={(d) => handleSelect(d)}
onMonthChange={handleSelect}
yearRange={yearRange}
locale={locale}
{...props}
/>
{granularity !== "day" && (
<div className="border-t border-border p-3">
<TimePicker onChange={onChange} date={value} hourCycle={hourCycle} granularity={granularity} />
</div>
)}
</PopoverContent>
</Popover>
);
}
);
DateTimePicker.displayName = "DateTimePicker";
export { DateTimePicker, TimePickerInput, TimePicker };
export type { TimePickerType, DateTimePickerProps, DateTimePickerRef };

View File

@ -2,66 +2,22 @@
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, LineChart, Line } from "recharts";
const data = [
{
name: "Jan",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Feb",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Mar",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Apr",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "May",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Jun",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Jul",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Aug",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Sep",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Oct",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Nov",
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: "Dec",
total: Math.floor(Math.random() * 5000) + 1000,
},
];
interface OverViewProps{
graphType:string;
interface OverViewProps {
graphType: string;
graphData: Record<string, number>; // Object with month-year as keys and sum as value
}
export function Overview(props: OverViewProps) {
// Transform the grouped data into the format for the chart
const chartData = Object.entries(props.graphData).map(([monthYear, totalArray]) => ({
name: monthYear,
total: totalArray, // Get the total amount for the month
}));
return (
<ResponsiveContainer width="100%" height={350}>
{props.graphType === 'line' ? (
<LineChart data={data}>
<LineChart data={chartData}>
<XAxis
dataKey="name"
stroke="#888888"
@ -83,7 +39,7 @@ export function Overview(props: OverViewProps) {
/>
</LineChart>
) : (
<BarChart data={data}>
<BarChart data={chartData}>
<XAxis
dataKey="name"
stroke="#888888"
@ -106,5 +62,5 @@ export function Overview(props: OverViewProps) {
</BarChart>
)}
</ResponsiveContainer>
);
);
}

0
src/hooks/useDealList.ts Normal file
View File

41
src/hooks/useUserRole.ts Normal file
View File

@ -0,0 +1,41 @@
"use client";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { getUserRole } from "@/lib/data/userQuery";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { useState, useEffect } from "react";
import { Session } from "@supabase/supabase-js";
export function useUserRole() {
const client = createSupabaseClient();
const [session, setSession] = useState<Session | null>(null);
const [sessionError, setSessionError] = useState<Error | null>(null);
useEffect(() => {
async function fetchSession() {
try {
const {
data: { session },
error,
} = await client.auth.getSession();
if (error) throw error;
setSession(session);
} catch (error) {
setSessionError(error as Error);
console.error("Error fetching session:", error);
}
}
fetchSession();
}, [client.auth]);
const { data, isLoading, error: userRoleError } = useQuery(getUserRole(client, session?.user?.id!));
return {
data,
isLoading: isLoading || !session,
error: userRoleError || sessionError,
session,
userId: session?.user?.id,
};
}

View File

@ -0,0 +1,37 @@
import { SupabaseClient } from "@supabase/supabase-js";
export async function rejectBusiness(client: SupabaseClient, businessApplicationId: Number) {
return client
.from("business_application")
.update({
status: "reject",
})
.eq("id", businessApplicationId);
}
export async function approveBusiness(client: SupabaseClient, businessApplicationId: Number) {
return client
.from("business_application")
.update({
status: "approve",
})
.eq("id", businessApplicationId);
}
export async function rejectProject(client: SupabaseClient, projectApplicationId: Number) {
return client
.from("project_application")
.update({
status: "reject",
})
.eq("id", projectApplicationId);
}
export async function approveProject(client: SupabaseClient, projectApplicationId: Number) {
return client
.from("project_application")
.update({
status: "approve",
})
.eq("id", projectApplicationId);
}

View File

@ -0,0 +1,59 @@
import { SupabaseClient } from "@supabase/supabase-js";
export const getAllBusinessApplicationQuery = (client: SupabaseClient) => {
return client.from("business_application").select(
`
id,
...user_id!inner (
user_id:id,
username
),
...business_type_id!inner (
business_type_id:id,
business_type_value:value
),
project_application_id,
business_name,
created_at,
is_in_us,
is_for_sale,
pitch_deck_url,
community_size,
is_generating_revenue,
money_raised_to_date,
location,
status
`
);
};
export const getAllProjectApplicationByBusinessQuery = (client: SupabaseClient, businessId: number) => {
return client
.from("project_application")
.select(
`
id,
created_at,
deadline,
status,
project_name,
...business_id (
business_id:id,
business_name,
user_id
),
...project_type_id!inner (
project_type_id:id,
project_type_value:value
),
short_description,
pitch_deck_url,
project_logo,
min_investment,
target_investment,
user_id,
project_photos
`
)
.eq("business_id", businessId);
};

View File

@ -0,0 +1,29 @@
import { SupabaseClient } from "@supabase/supabase-js";
import { Database } from "@/types/database.types";
export async function deleteFileFromDataRoom(
supabase: SupabaseClient<Database>,
dataroomId: number,
fileName: string,
dataroomMaterialId: number
) {
const filePath = `${dataroomId}/${fileName}`;
if (!filePath) {
throw new Error("Invalid filepath: Unable to extract file path for deletion.");
}
const { error: storageError } = await supabase.storage.from("dataroom-material").remove([filePath]);
if (storageError) {
throw new Error(`Error deleting file from storage: ${storageError.message}`);
}
const { error: dbError } = await supabase.from("dataroom_material").delete().eq("id", dataroomMaterialId);
if (dbError) {
throw new Error(`Error deleting file from database: ${dbError.message}`);
}
return { success: true };
}

View File

@ -0,0 +1,31 @@
import { SupabaseClient } from "@supabase/supabase-js";
export async function uploadAvatar(
supabase: SupabaseClient,
file: File,
uid: string,
) {
const allowedExtensions = ["jpeg", "jpg", "png"];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedExtensions.includes(fileExtension)) {
throw new Error(
"Invalid file format. Only jpeg, jpg, and png are allowed.",
);
}
const fileName = `profile-${uid}.${fileExtension}`;
const { data, error } = await supabase.storage
.from("avatars")
.upload(fileName, file, {
upsert: true,
contentType: `image/${fileExtension}`,
});
if (error) {
throw error;
}
return data;
}

View File

@ -0,0 +1,54 @@
import { SupabaseClient } from "@supabase/supabase-js";
export async function uploadFileToDataRoom(supabase: SupabaseClient, file: File, dataRoomId: number) {
const allowedExtensions = ["pdf", "docx", "xlsx", "pptx", "txt"];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedExtensions.includes(fileExtension)) {
throw new Error("Invalid file format. Only pdf, docx, xlsx, pptx, and txt are allowed.");
}
const { data: fileTypeData, error: fileTypeError } = await supabase
.from("material_file_type")
.select("id")
.eq("value", fileExtension)
.single();
if (fileTypeError || !fileTypeData) {
throw new Error("File type not supported or not found in material_file_type table.");
}
const fileTypeId = fileTypeData.id;
const fileName = `${dataRoomId}/${file.name}`;
const { data: uploadData, error: uploadError } = await supabase.storage
.from("dataroom-material")
.upload(fileName, file, {
upsert: true,
contentType: file.type,
});
if (uploadError) {
throw uploadError;
}
const { data: signedUrlData, error: signedUrlError } = await supabase.storage
.from("dataroom-material")
.createSignedUrl(fileName, 2628000);
if (signedUrlError) {
throw signedUrlError;
}
const { error: insertError } = await supabase.from("dataroom_material").insert([
{
dataroom_id: dataRoomId,
file_url: signedUrlData.signedUrl,
file_type_id: fileTypeId,
},
]);
if (insertError) {
throw insertError;
}
return uploadData;
}

View File

@ -0,0 +1,68 @@
import { SupabaseClient } from "@supabase/supabase-js";
export const getAllBusinesses = (client: SupabaseClient) => {
return client.from("business").select(`
id,
location,
business_name,
...business_type (
business_type:value
),
joined_date,
...user_id (
user_id:id,
username,
full_name,
email
)
`);
};
export const getBusinessAndProject = (
client: SupabaseClient,
params: { businessName?: String | null; businessId?: number | null; single?: boolean } = { single: false }
) => {
const query = client.from("business").select(`
business_id:id,
location,
business_name,
business_type:business_type (
business_type_id:id,
value
),
joined_date,
user_id,
projects:project (
id,
project_name,
business_id,
published_time,
card_image_url,
project_short_description,
...project_investment_detail (
min_investment,
total_investment,
target_investment
),
tags:project_tag (
...tag (
tag_value:value
)
)
)
`);
if (params.businessName && params.businessName.trim() !== "") {
return query.ilike("business_name", `%${params.businessName}%`);
}
if (params.businessId) {
query.eq("id", params.businessId);
}
if (params.single) {
query.single();
}
return query;
};

View File

@ -0,0 +1,19 @@
import { SupabaseClient } from "@supabase/supabase-js";
export const requestAccessToDataRoom = (client: SupabaseClient, dataRoomId: number, userId: string) => {
return client.from("access_request").insert([
{
dataroom_id: dataRoomId,
user_id: userId,
status: "pending",
},
]);
};
export const updateAccessRequestStatus = (
client: SupabaseClient,
requestId: number,
status: "approve" | "reject" | "pending"
) => {
return client.from("access_request").update({ status: status }).eq("id", requestId);
};

View File

@ -0,0 +1,70 @@
import { Database } from "@/types/database.types";
import { SupabaseClient } from "@supabase/supabase-js";
export const getFilesByDataroomId = (client: SupabaseClient<Database>, dataroomId: number) => {
const query = client
.from("dataroom_material")
.select(
`
id,
dataroom_id,
file_url,
file_type:material_file_type!inner (
id,
value
),
uploaded_at
`
)
.eq("dataroom_id", dataroomId);
return query;
};
export const getDataRoomsByProjectId = (client: SupabaseClient, projectId: number) => {
return client
.from("dataroom")
.select(
`
id,
project_id,
is_public,
created_at,
updated_at,
dataroom_material (
id,
file_url,
...file_type_id (
file_type_id:id,
file_type_value:value
),
uploaded_at
)
`
)
.eq("project_id", projectId);
};
export const getAccessRequests = (
client: SupabaseClient<Database>,
filters: { dataroomId?: number; userId?: string }
) => {
let query = client.from("access_request").select(
`
id,
dataroom_id,
user_id,
status,
requested_at
`
);
if (filters.dataroomId !== undefined) {
query = query.eq("dataroom_id", filters.dataroomId);
}
if (filters.userId !== undefined) {
query = query.eq("user_id", filters.userId);
}
return query;
};

View File

@ -0,0 +1,31 @@
import { SupabaseClient } from "@supabase/supabase-js";
export const getInvestmentCountsByProjectsIds = (client: SupabaseClient, projectIds: string[]) => {
return client
.from("investment_deal")
.select("*", {
count: "exact",
head: true,
})
.in("project_id", projectIds);
};
export const getInvestmentByUserId = (client: SupabaseClient, userId: string) => {
return client
.from("investment_deal")
.select(
`
id,
...project_id (
project_id:id,
project_name,
project_short_description,
dataroom_id
),
deal_amount,
investor_id,
created_time
`
)
.eq("investor_id", userId);
};

View File

@ -0,0 +1,48 @@
import { SupabaseClient } from "@supabase/supabase-js";
interface UpdateData {
username?: string;
full_name?: string;
bio?: string;
updated_at?: Date;
}
export async function updateProfile(
supabase: SupabaseClient,
userId: string,
updates: UpdateData,
) {
const updateData: { [key: string]: any | undefined } = {};
if (updates.username || updates.username != "") {
updateData.username = updates.username;
}
if (updates.full_name || updates.full_name != "") {
updateData.full_name = updates.full_name;
}
if (updates.bio || updates.bio != "") {
updateData.bio = updates.bio;
}
updateData.updated_at = new Date();
if (
updateData.username != undefined || updateData.full_name != undefined ||
updateData.bio != undefined
) {
const { error } = await supabase
.from("profiles")
.update(updateData)
.eq("id", userId);
if (error) {
console.error("Error updating profile:", error);
throw error;
}
return true;
} else {
console.log("No fields to update.");
return null;
}
}

View File

@ -1,36 +1,33 @@
import { SupabaseClient } from "@supabase/supabase-js";
async function getTopProjects(
client: SupabaseClient,
numberOfRecords: number = 4
) {
async function getTopProjects(client: SupabaseClient, numberOfRecords: number = 4) {
try {
const { data, error } = await client
.from("project")
.select(
`
id,
project_name,
business_id,
published_time,
project_short_description,
card_image_url,
project_investment_detail (
min_investment,
total_investment,
target_investment,
investment_deadline
),
project_tag (
tag (
id,
value
)
),
business (
location
id,
project_name,
business_id,
published_time,
project_short_description,
card_image_url,
project_investment_detail (
min_investment,
total_investment,
target_investment,
investment_deadline
),
project_tag (
tag (
id,
value
)
`
),
business (
location
)
`
)
.order("published_time", { ascending: false })
.limit(numberOfRecords);
@ -62,7 +59,7 @@ function getProjectDataQuery(client: SupabaseClient, projectId: number) {
target_investment,
investment_deadline
),
tags:item_tag!inner (
tags:project_tag!inner (
...tag!inner (
tag_name:value
)
@ -82,16 +79,20 @@ async function getProjectData(client: SupabaseClient, projectId: number) {
project_short_description,
project_description,
published_time,
card_image_url,
...project_investment_detail!inner (
min_investment,
total_investment,
target_investment,
investment_deadline
),
tags:item_tag!inner (
tags:project_tag!inner (
...tag!inner (
tag_name:value
)
),
...business (
user_id
)
`
)
@ -129,7 +130,7 @@ function searchProjectsQuery(
}: FilterProjectQueryParams
) {
const start = (page - 1) * pageSize;
const end = start + pageSize - 1;
const end = start + pageSize;
let query = client
.from("project")
@ -149,7 +150,7 @@ function searchProjectsQuery(
target_investment,
investment_deadline
),
tags:item_tag!inner (
tags:project_tag!inner (
...tag!inner (
tag_name:value
)
@ -186,7 +187,7 @@ function searchProjectsQuery(
}
if (tagsFilter) {
query = query.in("item_tag.tag.value", tagsFilter);
query = query.in("project_tag.tag.value", tagsFilter);
}
if (projectStatus) {
@ -200,9 +201,48 @@ function searchProjectsQuery(
return query;
}
const getProjectByBusinessId = (client: SupabaseClient, businessIds: string[]) => {
return client
.from("project")
.select(
`
id,
project_name,
business_id,
published_time,
card_image_url,
project_short_description,
...project_investment_detail (
min_investment,
total_investment,
target_investment
)
`
)
.in("business_id", businessIds);
};
const getProjectByUserId = (client: SupabaseClient, userId: string) => {
return client
.from("project")
.select(
`
id,
project_name,
business_id:business!inner (
user_id
),
dataroom_id
`
)
.eq("business.user_id", userId);
};
export {
getProjectData,
getProjectDataQuery,
getTopProjects,
searchProjectsQuery,
getProjectByBusinessId,
getProjectByUserId,
};

View File

@ -1,44 +0,0 @@
import { SupabaseClient } from "@supabase/supabase-js";
function getBusinesses(client: SupabaseClient, query: string | null) {
return client.from("business").select("id, business_name, joined_date").ilike(
"business_name",
`%${query}%`,
);
}
function getProjects(client: SupabaseClient, businessIds: string[]) {
return client
.from("project")
.select(
`
id,
project_name,
business_id,
published_time,
project_short_description,
project_investment_detail (
min_investment,
total_investment,
target_investment
)
`,
)
.in("business_id", businessIds);
}
function getTags(client: SupabaseClient, projectIds: string[]) {
return client.from("item_tag").select("item_id, tag (value)").in(
"item_id",
projectIds,
);
}
function getInvestmentCounts(client: SupabaseClient, projectIds: string[]) {
return client.from("investment_deal").select("*", {
count: "exact",
head: true,
}).in("project_id", projectIds);
}
export { getBusinesses, getInvestmentCounts, getProjects, getTags };

5
src/lib/data/tagQuery.ts Normal file
View File

@ -0,0 +1,5 @@
import { SupabaseClient } from "@supabase/supabase-js";
export const getTagsByProjectIds = (client: SupabaseClient, projectIds: string[]) => {
return client.from("project_tag").select(`item_id, ...tag (tag_value:value)`).in("item_id", projectIds);
};

View File

@ -20,4 +20,8 @@ async function getUserProfile(client: SupabaseClient, userId: string) {
}
}
export { getUserProfile };
function getUserRole(client: SupabaseClient, userId: string) {
return client.from("user_role").select("role").eq("user_id", userId).single();
}
export { getUserProfile, getUserRole };

View File

@ -15,7 +15,7 @@ export async function updateSession(request: NextRequest) {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value));
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
supabaseResponse = NextResponse.next({
request,
});

View File

@ -1,4 +1,4 @@
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export function createSupabaseClient() {
@ -12,7 +12,9 @@ export function createSupabaseClient() {
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
} catch {}
} catch (error) {
console.error("Error setting cookies:", error);
}
},
},
});

View File

@ -4,3 +4,30 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function sum(list: any[]) {
if (!list || list.length === 0) {
return 0;
}
return list.reduce((total, num) => total + num, 0);
}
export function sumByKey(list: any[], key: string) {
// example usage
// const items = [
// { amount: 10 },
// { amount: 20 },
// { amount: 30 },
// { amount: 40 }
// ];
// const totalAmount = sumByKey(items, 'amount');
// console.log(totalAmount); // Output: 100
return list.reduce((total, obj) => total + (obj[key] || 0), 0);
}
export function toPercentage(part: number, total: number): number {
if (total === 0) return 0; // Prevent division by zero
return Number(((part * 100) / total).toFixed(2));
}

Binary file not shown.

View File

@ -0,0 +1,27 @@
import { z } from "zod";
const MAX_FILE_SIZE = 500000;
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png"];
const profileAvatarSchema = z
.custom<File>(
(val) =>
val && typeof val === "object" && "size" in val && "type" in val,
{
message: "Input must be a file.",
},
)
.refine((file) => file.size < MAX_FILE_SIZE, {
message: "File can't be bigger than 5MB.",
})
.refine((file) => ACCEPTED_IMAGE_TYPES.includes(file.type), {
message: "File format must be either jpg, jpeg, or png.",
}).optional();
export const profileSchema = z.object({
username: z.string().min(3).max(50).optional(),
full_name: z.string().min(4).max(100).optional(),
bio: z.string().min(10).max(1000).optional(),
updated_at: z.string().datetime().optional(),
avatars: profileAvatarSchema,
});

View File

@ -0,0 +1,51 @@
import { test, Locator, Page } from '@playwright/test';
// Helper function to handle dropdown selection with retry mechanism, automatically selecting the first option
const selectFirstOption = async (page: Page, triggerLocator: Locator) => {
try {
await triggerLocator.hover();
await triggerLocator.click({ force: true });
// Select the first available option
const firstOption = page.getByRole("option").first();
await firstOption.waitFor({ state: 'visible', timeout: 1000 });
await firstOption.click();
console.log("First option selected.");
} catch (error) {
console.log("Retrying as the combobox disappeared...");
await page.waitForTimeout(100);
}
};
test('test', async ({ page }) => {
await page.goto("http://127.0.0.1:3000/");
await page.getByRole('button', { name: 'Projects' }).hover();
await page.getByRole('link', { name: 'Projects Start your new' }).click();
await page.locator('#projectName').fill('DummyTester');
await page.locator('#projectName').fill('DummyTester');
// Select first option in 'Select a Project type'
const projectTypeButton = page.locator('button').filter({ hasText: 'Select a Project type' });
await selectFirstOption(page, projectTypeButton);
await page.locator('#shortDescription').fill('0123456789');
await page.getByPlaceholder('https:// ').fill('https://www.google.com/');
await page.getByPlaceholder('$ 500').fill('499');
await page.getByPlaceholder('$ 1,000,000').fill('99999999');
await page.locator('#deadline').fill('2024-11-29T21:19');
// Log the text content of the second combobox
const tag = page.getByRole('combobox').nth(1);
const tagText = await tag.textContent();
console.log('Tag text:', tagText);
// Select first option in the tag combobox
await selectFirstOption(page, tag);
// Submit the form
await page.getByRole('button', { name: 'Submit application' }).click();
});

View File

@ -1,23 +0,0 @@
import { test, expect } from '@playwright/test';
test.use({
storageState: './storageState.json'
});
test('Test search businesses', async ({ page }) => {
await page.goto('http://127.0.0.1:3000/');
await page.getByLabel('Main').getByRole('img').click();
const businessInput = page.getByPlaceholder('Enter business name...');
await expect(businessInput).toBeVisible();
await businessInput.fill('neon');
await businessInput.press('Enter');
const heading = page.getByRole('heading', { name: 'Neon Solution, A dummy company' });
await expect(heading).toBeVisible();
await heading.click();
const fundSection = page.locator('div').filter({ hasText: /^Neon raising fund #1$/ });
await expect(fundSection).toBeVisible();
await fundSection.click();
});

View File

@ -1,42 +0,0 @@
import { test, expect } from '@playwright/test';
test.use({
storageState: './storageState.json'
});
test('Test filter with tags', async ({ page }) => {
await page.goto('http://127.0.0.1:3000/');
// Start Investing
await page.getByRole('button', { name: 'Start Investing' }).click();
// Filter by AI tag
await page.locator('button').filter({ hasText: 'Tags' }).click();
await page.getByLabel('AI', { exact: true }).click();
const aiTag = page.locator('span#tag', { hasText: 'AI' });
await expect(aiTag).toBeVisible();
// Filter by Technology tag
await page.locator('button').filter({ hasText: 'AI' }).click();
await page.getByLabel('Technology').click();
const techTag = page.locator('span#tag', { hasText: 'Technology' });
await expect(techTag).toBeVisible();
// Filter by Consumer Electronics tag
await page.locator('button').filter({ hasText: 'Technology' }).click();
await page.getByLabel('Consumer Electronics').click();
const consumerElectronicsTag = page.locator('span#tag', { hasText: 'Consumer Electronics' });
await expect(consumerElectronicsTag).toBeVisible();
// Filter by Software tag
await page.locator('button').filter({ hasText: 'Consumer Electronics' }).click();
await page.getByLabel('Software').click();
const softwareTag = page.locator('span#tag', { hasText: 'Software' });
await expect(softwareTag).toBeVisible();
// Filter by Internet tag
await page.locator('button').filter({ hasText: 'Software' }).click();
await page.getByLabel('Internet').click();
const internetTag = page.locator('span#tag', { hasText: 'Internet' });
await expect(internetTag).toBeVisible();
});

View File

@ -1,26 +0,0 @@
import { test, expect } from '@playwright/test';
test.use({
storageState: './storageState.json'
});
test('Test dashboard visibility', async ({ page }) => {
await page.goto('http://127.0.0.1:3000/dashboard');
const dashboardHeading = page.locator('h2', { hasText: 'Dashboard' });
await expect(dashboardHeading).toBeVisible();
const profileViewHeading = page.locator('h3', { hasText: 'Profile Views' });
await expect(profileViewHeading).toBeVisible();
const totalFollowerHeading = page.locator('h3', { hasText: 'Total Followers' });
await expect(totalFollowerHeading).toBeVisible();
const fundsRaisedHeading = page.locator('h3', { hasText: 'Total Funds Raised' });
await expect(fundsRaisedHeading).toBeVisible();
const overviewHeading = page.locator('h3', { hasText: 'Overview' });
await expect(overviewHeading).toBeVisible();
const recentFundHeading = page.locator('h3', { hasText: 'Recent Funds' });
await expect(recentFundHeading).toBeVisible();
});

View File

@ -1,107 +0,0 @@
import { test, expect, Page } from '@playwright/test';
test.use({
storageState: './storageState.json',
});
test('Investment process test', async ({ page }) => {
await page.goto('http://127.0.0.1:3000/');
// Navigate to the investment page
// await page.getByRole('link', { name: 'Card image NVDA Founded in' }).click();
await page.click('a[href="/invest"]');
await page.getByRole('button', { name: 'Invest in NVIDIA' }).click();
// Fill investment amount
await fillInvestmentAmount(page, '10000');
// Fill card information
await fillCardInformation(page, {
name: 'Dummy',
city: 'Bangkok',
cardNumber: '4111 1111 1111 1111',
expirationMonth: 'August',
expirationYear: '2032',
cvc: '111',
});
// Accept terms
await acceptTerms(page, [
'Minimum Investment',
'Investment Horizon',
'Fees',
'Returns',
]);
// Click Invest button and confirm
await page.getByRole('button', { name: 'Invest' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
// Ensure error message is displayed when not all terms are accepted
await ensureErrorMessageDisplayed(page, 'Please accept all terms');
// Close the error dialog
await closeErrorDialog(page);
// Accept remaining terms
await acceptTerms(page, [
'Risk Disclosure',
'Withdrawal Policy',
]);
// Click Invest button and confirm again
await page.getByRole('button', { name: 'Invest' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
// Ensure that success toast is displayed when investment is successful
await expect(
page.locator('div[role="status"][aria-live="polite"]').filter({ hasText: /^You successfully invested!$/ })
).toBeVisible();
// Helper functions
async function fillInvestmentAmount(page: Page, amount: string): Promise<void> {
await page.getByPlaceholder('min $').click();
await page.getByPlaceholder('min $').fill(amount);
}
interface CardInfo {
name: string;
city: string;
cardNumber: string;
expirationMonth: string;
expirationYear: string;
cvc: string;
}
async function fillCardInformation(
page: Page,
{ name, city, cardNumber, expirationMonth, expirationYear, cvc }: CardInfo
): Promise<void> {
await page.getByPlaceholder('First Last').click();
await page.getByPlaceholder('First Last').fill(name);
await page.getByLabel('City').click();
await page.getByLabel('City').fill(city);
await page.getByLabel('Card number').click();
await page.getByLabel('Card number').fill(cardNumber);
await page.getByLabel('Month').click();
await page.getByText(expirationMonth).click();
await page.getByLabel('Year').click();
await page.getByLabel(expirationYear).click();
await page.getByPlaceholder('CVC').click();
await page.getByPlaceholder('CVC').fill(cvc);
}
async function acceptTerms(page: Page, terms: string[]): Promise<void> {
for (const term of terms) {
await page.getByRole('row', { name: new RegExp(term) }).getByRole('checkbox').check();
}
}
async function ensureErrorMessageDisplayed(page: Page, message: string): Promise<void> {
await expect(page.getByText(message)).toBeVisible();
}
async function closeErrorDialog(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Close' }).first().click();
}
});

View File

@ -0,0 +1,107 @@
// import { test, expect, Page } from '@playwright/test';
//
// test.use({
// storageState: './storageState.json',
// });
//
// test('Investment process test', async ({ page }) => {
// await page.goto('http://127.0.0.1:3000/');
//
// // Navigate to the investment page
// // await page.getByRole('link', { name: 'Card image NVDA Founded in' }).click();
// await page.click('a[href="/invest"]');
// await page.getByRole('button', { name: 'Invest in NVIDIA' }).click();
//
// // Fill investment amount
// await fillInvestmentAmount(page, '10000');
//
// // Fill card information
// await fillCardInformation(page, {
// name: 'Dummy',
// city: 'Bangkok',
// cardNumber: '4111 1111 1111 1111',
// expirationMonth: 'August',
// expirationYear: '2032',
// cvc: '111',
// });
//
// // Accept terms
// await acceptTerms(page, [
// 'Minimum Investment',
// 'Investment Horizon',
// 'Fees',
// 'Returns',
// ]);
//
// // Click Invest button and confirm
// await page.getByRole('button', { name: 'Invest' }).click();
// await page.getByRole('button', { name: 'Confirm' }).click();
//
// // Ensure error message is displayed when not all terms are accepted
// await ensureErrorMessageDisplayed(page, 'Please accept all terms');
//
// // Close the error dialog
// await closeErrorDialog(page);
//
// // Accept remaining terms
// await acceptTerms(page, [
// 'Risk Disclosure',
// 'Withdrawal Policy',
// ]);
//
// // Click Invest button and confirm again
// await page.getByRole('button', { name: 'Invest' }).click();
// await page.getByRole('button', { name: 'Confirm' }).click();
//
// // Ensure that success toast is displayed when investment is successful
// await expect(
// page.locator('div[role="status"][aria-live="polite"]').filter({ hasText: /^You successfully invested!$/ })
// ).toBeVisible();
//
// // Helper functions
// async function fillInvestmentAmount(page: Page, amount: string): Promise<void> {
// await page.getByPlaceholder('min $').click();
// await page.getByPlaceholder('min $').fill(amount);
// }
//
// interface CardInfo {
// name: string;
// city: string;
// cardNumber: string;
// expirationMonth: string;
// expirationYear: string;
// cvc: string;
// }
//
// async function fillCardInformation(
// page: Page,
// { name, city, cardNumber, expirationMonth, expirationYear, cvc }: CardInfo
// ): Promise<void> {
// await page.getByPlaceholder('First Last').click();
// await page.getByPlaceholder('First Last').fill(name);
// await page.getByLabel('City').click();
// await page.getByLabel('City').fill(city);
// await page.getByLabel('Card number').click();
// await page.getByLabel('Card number').fill(cardNumber);
// await page.getByLabel('Month').click();
// await page.getByText(expirationMonth).click();
// await page.getByLabel('Year').click();
// await page.getByLabel(expirationYear).click();
// await page.getByPlaceholder('CVC').click();
// await page.getByPlaceholder('CVC').fill(cvc);
// }
//
// async function acceptTerms(page: Page, terms: string[]): Promise<void> {
// for (const term of terms) {
// await page.getByRole('row', { name: new RegExp(term) }).getByRole('checkbox').check();
// }
// }
//
// async function ensureErrorMessageDisplayed(page: Page, message: string): Promise<void> {
// await expect(page.getByText(message)).toBeVisible();
// }
//
// async function closeErrorDialog(page: Page): Promise<void> {
// await page.getByRole('button', { name: 'Close' }).first().click();
// }
// });

View File

@ -0,0 +1,23 @@
// import { test, expect } from "@playwright/test";
//
// test.use({
// storageState: "./storageState.json",
// });
//
// test("Test search businesses", async ({ page }) => {
// await page.goto("http://127.0.0.1:3000/");
// await page.getByLabel("Main").getByRole("img").click();
//
// const businessInput = page.getByPlaceholder("Enter business name...");
// await expect(businessInput).toBeVisible();
// await businessInput.fill("Project Blackwell");
// await businessInput.press("Enter");
//
// const heading = page.getByRole("heading", { name: "Project Blackwell" });
// await expect(heading).toBeVisible();
// await heading.click();
//
// const fundSection = page.locator("div").filter({ hasText: /^Project Blackwell$/ });
// await expect(fundSection).toBeVisible();
// await fundSection.click();
// });

View File

@ -0,0 +1,26 @@
// import { test, expect } from '@playwright/test';
// test.use({
// storageState: './storageState.json'
// });
//
// test('Test dashboard visibility', async ({ page }) => {
// await page.goto('http://127.0.0.1:3000/dashboard');
//
// const dashboardHeading = page.locator('h2', { hasText: 'Dashboard' });
// await expect(dashboardHeading).toBeVisible();
//
// const profileViewHeading = page.locator('h3', { hasText: 'Profile Views' });
// await expect(profileViewHeading).toBeVisible();
//
// const totalFollowerHeading = page.locator('h3', { hasText: 'Total Followers' });
// await expect(totalFollowerHeading).toBeVisible();
//
// const fundsRaisedHeading = page.locator('h3', { hasText: 'Total Funds Raised' });
// await expect(fundsRaisedHeading).toBeVisible();
//
// const overviewHeading = page.locator('h3', { hasText: 'Overview' });
// await expect(overviewHeading).toBeVisible();
//
// const recentFundHeading = page.locator('h3', { hasText: 'Recent Funds' });
// await expect(recentFundHeading).toBeVisible();
// });

View File

@ -0,0 +1,42 @@
// import { test, expect } from '@playwright/test';
//
// test.use({
// storageState: './storageState.json'
// });
//
// test('Test filter with tags', async ({ page }) => {
// await page.goto('http://127.0.0.1:3000/');
//
// // Start Investing
// await page.getByRole('button', { name: 'Start Investing' }).click();
//
// // Filter by AI tag
// await page.locator('button').filter({ hasText: 'Tags' }).click();
// await page.getByLabel('AI', { exact: true }).click();
// const aiTag = page.locator('span#tag', { hasText: 'AI' });
// await expect(aiTag).toBeVisible();
//
// // Filter by Technology tag
// await page.locator('button').filter({ hasText: 'AI' }).click();
// await page.getByLabel('Technology').click();
// const techTag = page.locator('span#tag', { hasText: 'Technology' });
// await expect(techTag).toBeVisible();
//
// // Filter by Consumer Electronics tag
// await page.locator('button').filter({ hasText: 'Technology' }).click();
// await page.getByLabel('Consumer Electronics').click();
// const consumerElectronicsTag = page.locator('span#tag', { hasText: 'Consumer Electronics' });
// await expect(consumerElectronicsTag).toBeVisible();
//
// // Filter by Software tag
// await page.locator('button').filter({ hasText: 'Consumer Electronics' }).click();
// await page.getByLabel('Software').click();
// const softwareTag = page.locator('span#tag', { hasText: 'Software' });
// await expect(softwareTag).toBeVisible();
//
// // Filter by Internet tag
// await page.locator('button').filter({ hasText: 'Software' }).click();
// await page.getByLabel('Internet').click();
// const internetTag = page.locator('span#tag', { hasText: 'Internet' });
// await expect(internetTag).toBeVisible();
// });

View File

@ -4,6 +4,7 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",