Merge pull request #87 from Sosokker/dev

Add Dataroom, Dashboard, Portfolio, About, Notification, Calendar and Profile Editor; UI Enhancements and Bug Fixes
This commit is contained in:
Nantawat Sukrisunt 2024-11-28 12:31:37 +07:00 committed by GitHub
commit e20bdf44aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
140 changed files with 30176 additions and 2411 deletions

View File

@ -1,11 +1,29 @@
# Supabase Configuration
PROJECT_ID=supabase-project-id
NEXT_PUBLIC_SUPABASE_URL=supabase-project-url
NEXT_PUBLIC_SUPABASE_URL_SOURCE = supabase-project-url-without-https:// (ex: https://example.com -> example.com)
NEXT_PUBLIC_SUPABASE_URL_SOURCE=supabase-project-url-without-https (e.g., https://example.com -> example.com)
NEXT_PUBLIC_SUPABASE_ANON_KEY=supabase-anon-key
NEXT_PUBLIC_AUTH_GOOGLE_ID=google-auth-token
NEXT_PUBLIC_AUTH_GOOGLE_SECRET=google-secret-key
NEXT_PUBLIC_DUMMY_EMAIL=supabse-dummy-user-email-for-testing
NEXT_PUBLIC_DUMMY_PASSWORD=supabse-dummy-user-password-for-testing
NEXT_PUBLIC_TEST_URL=url-to-run-testing-server
SUPABASE_SERVICE_ROLE_KEY=supabase-service-role-key
# Google Authentication Configuration
NEXT_PUBLIC_AUTH_GOOGLE_ID=google-auth-client-id
NEXT_PUBLIC_AUTH_GOOGLE_SECRET=google-auth-client-secret
# Stripe Configuration
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=stripe-public-key
STRIPE_SECRET_KEY=stripe-secret-key
STRIPE_SECRET_KEY=stripe-secret-key
# Testing Server URL
NEXT_PUBLIC_TEST_URL=testing-server-url
BASE_URL = http://127.0.0.1:3000
# Admin User Credentials (Must exist in the real database)
NEXT_PUBLIC_ADMIN_EMAIL=admin@example.com
NEXT_PUBLIC_ADMIN_PASSWORD=admin-secure-password
# Temporary Regular User Credentials for Testing (Delete after test completion)
NEXT_PUBLIC_TEST_USER_EMAIL=test-user@example.com
NEXT_PUBLIC_TEST_USER_PASSWORD=test-user-password
# CAPTCHA
NEXT_PUBLIC_SITEKEY=captcha-sitekey-key

View File

@ -1,11 +1,26 @@
# Supabase Configuration
PROJECT_ID=supabase-project-id
NEXT_PUBLIC_SUPABASE_URL=supabase-project-url
NEXT_PUBLIC_SUPABASE_URL_SOURCE = supabase-project-url-without-https:// (ex: https://example.com -> example.com)
NEXT_PUBLIC_SUPABASE_URL_SOURCE=supabase-project-url-without-https (e.g., https://example.com -> example.com)
NEXT_PUBLIC_SUPABASE_ANON_KEY=supabase-anon-key
NEXT_PUBLIC_AUTH_GOOGLE_ID=google-auth-token
NEXT_PUBLIC_AUTH_GOOGLE_SECRET=google-secret-key
NEXT_PUBLIC_DUMMY_EMAIL=supabse-dummy-user-email-for-testing
NEXT_PUBLIC_DUMMY_PASSWORD=supabse-dummy-user-password-for-testing
NEXT_PUBLIC_TEST_URL=url-to-run-testing-server
SUPABASE_SERVICE_ROLE_KEY=supabase-service-role-key
# Google Authentication Configuration
NEXT_PUBLIC_AUTH_GOOGLE_ID=google-auth-client-id
NEXT_PUBLIC_AUTH_GOOGLE_SECRET=google-auth-client-secret
# Stripe Configuration
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=stripe-public-key
STRIPE_SECRET_KEY=stripe-secret-key
STRIPE_SECRET_KEY=stripe-secret-key
# Testing Server URL
NEXT_PUBLIC_TEST_URL=testing-server-url
BASE_URL = http://127.0.0.1:3000
# Admin User Credentials (Must exist in the real database)
NEXT_PUBLIC_ADMIN_EMAIL=admin@example.com
NEXT_PUBLIC_ADMIN_PASSWORD=admin-secure-password
# Temporary Regular User Credentials for Testing (Delete after test completion)
NEXT_PUBLIC_TEST_USER_EMAIL=test-user@example.com
NEXT_PUBLIC_TEST_USER_PASSWORD=test-user-password

View File

@ -19,7 +19,7 @@ jobs:
path: |
~/.npm
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
key: ${{ runner.os }}-nextjs-${{ github.run_id }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
@ -27,8 +27,6 @@ jobs:
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
@ -36,9 +34,14 @@ jobs:
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
echo BASE_URL=${{ secrets.BASE_URL }} >> $GITHUB_ENV
echo NEXT_PUBLIC_ADMIN_EMAIL=${{ secrets.NEXT_PUBLIC_ADMIN_EMAIL }} >> $GITHUB_ENV
echo NEXT_PUBLIC_ADMIN_PASSWORD=${{ secrets.NEXT_PUBLIC_ADMIN_PASSWORD }} >> $GITHUB_ENV
echo NEXT_PUBLIC_TEST_USER_EMAIL=${{ secrets.NEXT_PUBLIC_TEST_USER_EMAIL }} >> $GITHUB_ENV
echo NEXT_PUBLIC_TEST_USER_PASSWORD=${{ secrets.NEXT_PUBLIC_TEST_USER_PASSWORD }} >> $GITHUB_ENV
- name: Install dependencies
run: npm ci
- name: Run build
run: npm run build --if-present
run: npm run build --if-present --verbose

View File

@ -41,4 +41,4 @@ jobs:
run: npm ci
- name: Run eslint
run: npm run lint
run: npm run lint

View File

@ -1,54 +1,62 @@
name: Playwright Tests
# name: Playwright Tests
on: pull_request
# on:
# pull_request:
# branches:
# - main
jobs:
build:
timeout-minutes: 10
runs-on: ubuntu-latest
# jobs:
# build:
# timeout-minutes: 10
# runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
# 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: 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: 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
# echo SUPABASE_SERVICE_ROLE_KEY=${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} >> $GITHUB_ENV
# echo NEXT_PUBLIC_TEST_USER_EMAIL=${{ secrets.NEXT_PUBLIC_TEST_USER_EMAIL }} >> $GITHUB_ENV
# echo NEXT_PUBLIC_TEST_USER_PASSWORD=${{ secrets.NEXT_PUBLIC_TEST_USER_PASSWORD }} >> $GITHUB_ENV
# echo NEXT_PUBLIC_SITEKEY=${{ secrets.NEXT_PUBLIC_SITEKEY }} >> $GITHUB_ENV
# echo BASE_URL=${{ secrets.BASE_URL }} >> $GITHUB_ENV
- name: Install dependencies
run: npm ci
# - name: Install dependencies
# run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
# - name: Install Playwright Browsers
# run: npx playwright install --with-deps
- name: Run Playwright tests with 4 workers
run: npx playwright test --workers=4
# - name: Run Playwright tests with 1 workers
# run: npx playwright test --workers=1
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
# - uses: actions/upload-artifact@v4
# if: ${{ !cancelled() }}
# with:
# name: playwright-report
# path: playwright-report/
# retention-days: 30

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
/tailwind.config.ts

View File

@ -10,13 +10,33 @@ const nextConfig = {
port: "",
pathname: "/storage/v1/object/sign/**",
},
{
protocol: "https",
hostname: SUPABASE_URL,
port: "",
pathname: "/storage/v1/object/public/**",
},
{
protocol: "https",
hostname: "upload.wikimedia.org",
pathname: "/wikipedia/**",
},
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "assets.republic.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "media.licdn.com",
pathname: "/**",
},
],
},
};
export default nextConfig;

3892
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,13 @@
"pretty": "prettier --write \"./**/*.{js,jsx,mjs,cjs,ts,tsx,json}\""
},
"dependencies": {
"@hcaptcha/react-hcaptcha": "^1.11.0",
"@hookform/resolvers": "^3.9.0",
"@mdxeditor/editor": "^3.15.0",
"@nextui-org/calendar": "^2.0.12",
"@nextui-org/date-input": "^2.1.4",
"@nextui-org/system": "^2.2.6",
"@nextui-org/theme": "^2.2.11",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.2",
@ -31,23 +37,28 @@
"@stripe/react-stripe-js": "^2.8.1",
"@stripe/stripe-js": "^4.7.0",
"@supabase-cache-helpers/postgrest-react-query": "^1.10.1",
"@supabase/ssr": "^0.4.1",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.46.1",
"@tailwindcss/line-clamp": "^0.4.4",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-query-devtools": "^5.59.0",
"@tanstack/react-table": "^8.20.5",
"b2d-ventures": "file:",
"chart.js": "^4.4.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
"date-fns": "^4.1.0",
"date-fns": "^3.0.0",
"dotenv": "^16.4.5",
"embla-carousel-react": "^8.2.0",
"framer-motion": "^11.11.17",
"lucide-react": "^0.428.0",
"next": "^14.2.15",
"next-themes": "^0.3.0",
"react": "^18",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-countup": "^6.5.3",
"react-day-picker": "^9",
"react-day-picker": "^9.*",
"react-dom": "^18",
"react-file-icon": "^1.5.0",
"react-hook-form": "^7.53.0",
@ -55,6 +66,7 @@
"react-lottie": "^1.2.4",
"react-markdown": "^9.0.1",
"recharts": "^2.12.7",
"remark-gfm": "^4.0.0",
"stripe": "^17.1.0",
"sweetalert2": "^11.6.13",
"tailwind-merge": "^2.5.2",
@ -69,7 +81,7 @@
"@types/eslint__js": "^8.42.3",
"@types/next": "^8.0.7",
"@types/node": "^20",
"@types/react": "^18",
"@types/react": "^18.3.12",
"@types/react-dom": "^18",
"@types/react-fade-in": "^2.0.2",
"@types/react-file-icon": "^1.0.4",

View File

@ -13,6 +13,7 @@ dotenv.config({ path: path.resolve(__dirname, '.env') });
*/
export default defineConfig({
globalSetup: require.resolve('./test_util/global-setup'),
globalTeardown: require.resolve('./test_util/global-teardown'),
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
@ -27,7 +28,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://127.0.0.1:3000',
baseURL: process.env.BASE_URL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
@ -39,19 +40,21 @@ export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: { ...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'],
storageState:"./storageState.json"
storageState:"./storageState.json",
},
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
use: { ...devices['Desktop Safari'] ,
},
},
/* Test against mobile viewports. */

View File

@ -0,0 +1,53 @@
import { Button } from "@/components/ui/button";
import { FilterParams } from "@/lib/data/projectQuery";
export const ShowFilter = ({ filterParams, clearAll }: { filterParams: FilterParams; clearAll: () => void }) => {
const { searchTerm, tagFilter, projectStatusFilter, businessTypeFilter } = filterParams;
if (!searchTerm && !tagFilter && !projectStatusFilter && !businessTypeFilter) {
return <div></div>;
}
if (projectStatusFilter === "all" && businessTypeFilter === "all" && tagFilter === "all") {
return <div></div>;
}
return (
<div className="flex flex-wrap gap-2">
{searchTerm && (
<Button key={searchTerm} variant="secondary">
{searchTerm}
</Button>
)}
{tagFilter && tagFilter !== "all" && (
<Button key={tagFilter} variant="secondary">
{tagFilter}
</Button>
)}
{projectStatusFilter && projectStatusFilter !== "all" && (
<Button key={projectStatusFilter} variant="secondary">
{projectStatusFilter}
</Button>
)}
{businessTypeFilter && businessTypeFilter !== "all" && (
<Button key={businessTypeFilter} variant="secondary">
{businessTypeFilter}
</Button>
)}
{/* {sortByTimeFilter && sortByTimeFilter !== "all" && (
<Button key={sortByTimeFilter} variant="secondary">
{sortByTimeFilter}
</Button>
)} */}
{/* Clear All button */}
<Button variant="destructive" onClick={clearAll}>
Clear All
</Button>
</div>
);
};

View File

@ -0,0 +1,70 @@
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { getProjectLogByProjectId } from "@/lib/data/projectLogQuery";
import { LogEntry, parseProjectLog } from "./logParser";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
export const UpdateTab = async ({ projectId }: { projectId: number }) => {
const supabase = createSupabaseClient();
const { data, error } = await getProjectLogByProjectId(supabase, projectId);
if (error) {
return (
<div className="flex justify-center items-center h-full">
<Card>
<CardContent>
<div className="text-center text-gray-500">
<CardTitle className="mb-3 mt-4">No updates available</CardTitle>
<CardDescription>There are no updates to display at this time. Please check back later.</CardDescription>
</div>
</CardContent>
</Card>
</div>
);
}
const parsedLogs = parseProjectLog(data as unknown as LogEntry[]);
if (parsedLogs.length === 0) {
return (
<div className="flex justify-center items-center h-full">
<Card>
<CardContent>
<div className="text-center text-gray-500">
<CardTitle className="mb-3 mt-4">No updates available</CardTitle>
<CardDescription>There are no updates to display at this time. Please check back later.</CardDescription>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-4 w-full">
{parsedLogs.map((log, index) => (
<Card key={index} className="overflow-hidden">
<CardHeader>
<CardTitle>{log.table}</CardTitle>
<CardDescription>{log.changes.length} Changes</CardDescription>
</CardHeader>
<CardContent className="overflow-y-auto h-60 px-4">
{log.changes.map((change, changeIndex) => (
<div key={changeIndex} className="mb-4">
<div className="text-sm font-semibold">{change.field}</div>
<div className="text-gray-500">
<span className="text-red-500">From:</span> {JSON.stringify(change.from)}
</div>
<div className="text-gray-500">
<span className="text-green-500">To:</span> {JSON.stringify(change.to)}
</div>
</div>
))}
</CardContent>
<CardFooter>
<div className="text-xs text-gray-500">Updated at: {new Date(log.changed_at).toLocaleString()}</div>
</CardFooter>
</Card>
))}
</div>
);
};

View File

@ -3,25 +3,32 @@
/* eslint-disable */
import { useState, useEffect } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import CustomTooltip from "@/components/customToolTip";
import { ShareIcon, StarIcon } from "lucide-react";
import { redirect } from "next/navigation";
import useSession from "@/lib/supabase/useSession";
import { deleteFollow, getFollow, insertFollow } from "@/lib/data/followQuery";
import toast from "react-hot-toast";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
const FollowShareButtons = () => {
const [progress, setProgress] = useState(0);
const [tab, setTab] = useState("Pitch");
const { session, loading } = useSession();
const user = session?.user;
const [sessionLoaded, setSessionLoaded] = useState(false);
const [isFollow, setIsFollow] = useState(false);
interface FollowShareButtons {
userId: string;
projectId: number;
projectName: string;
}
const FollowShareButtons = ({ userId, projectId, projectName }: FollowShareButtons) => {
const supabase = createSupabaseClient();
const { data: follow, isLoading: followIsLoading } = useQuery(getFollow(supabase, userId, projectId), {
staleTime: 0,
});
const [isFollowState, setIsFollowState] = useState<boolean>(false);
useEffect(() => {
if (!loading) {
setSessionLoaded(true);
if (follow) {
setIsFollowState(!!follow);
}
}, [loading]);
}, [follow]);
const handleShare = () => {
const currentUrl = window.location.href;
@ -31,27 +38,43 @@ const FollowShareButtons = () => {
});
}
};
const handleFollow = () => {
if (user) {
setIsFollow((prevState) => !prevState);
const handleFollow = async () => {
if (!isFollowState) {
const error = await insertFollow(supabase, userId, projectId);
if (error) {
toast.error("Error occurred!");
} else {
toast.success("You have followed the project!", { icon: "❤️" });
setIsFollowState(true);
}
} else {
redirect("/login");
const error = await deleteFollow(supabase, userId, projectId);
if (error) {
toast.error("Error occurred!");
} else {
toast.success("You have unfollowed the project!", { icon: "💔" });
setIsFollowState(false);
}
}
};
if (followIsLoading) {
return (
<div className="grid grid-cols-2 gap-5 justify-self-end">
<div onClick={handleShare} className="cursor-pointer mt-2">
<ShareIcon />
</div>
</div>
);
}
return (
<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 NVIDIA</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CustomTooltip message={`Follow ${projectName}`}>
<StarIcon id="follow" fill={isFollowState ? "#fcb30e" : "#fff"} strokeWidth={2} />
</CustomTooltip>
</div>
<div onClick={handleShare} className="cursor-pointer mt-2">
<ShareIcon />

View File

@ -0,0 +1,57 @@
export interface LogEntry {
id: number;
operation_type: string;
record_id: number;
old_data: Record<string, any>;
new_data: Record<string, any>;
changed_at: string;
table_name: string;
}
type ChangeSummary = {
field: string;
from: any;
to: any;
};
function parseProjectLog(logs: LogEntry[]): { changes: ChangeSummary[]; table: string; changed_at: string }[] {
return logs.map((log) => {
const changes: ChangeSummary[] = [];
if (log.table_name === "project_investment_detail") {
if (log.operation_type === "UPDATE") {
for (const key in log.old_data) {
if (log.old_data[key] !== log.new_data[key]) {
changes.push({
field: key,
from: log.old_data[key],
to: log.new_data[key],
});
}
}
}
}
if (log.table_name === "project") {
if (log.operation_type === "UPDATE") {
for (const key in log.old_data) {
if (log.old_data[key] !== log.new_data[key]) {
changes.push({
field: key,
from: log.old_data[key],
to: log.new_data[key],
});
}
}
}
}
return {
table: log.table_name,
changed_at: log.changed_at,
changes,
};
});
}
export { parseProjectLog };

View File

@ -1,27 +1,37 @@
import Image from "next/image";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import * as Tabs from "@radix-ui/react-tabs";
import { Button } from "@/components/ui/button";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
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";
import { isOwnerOfProject } from "./query";
import { UpdateTab } from "./UpdateTab";
import remarkGfm from "remark-gfm";
import Gallery from "@/components/carousel";
const PHOTO_MATERIAL_ID = 2;
export default async function ProjectDealPage({ params }: { params: { id: number } }) {
const supabase = createSupabaseClient();
const { data: projectData, error: projectDataError } = await getProjectData(supabase, params.id);
const { data: user, error: userError } = await supabase.auth.getUser();
const { data: projectMaterial, error: projectMaterialError } = await supabase
.from("project_material")
.select("material_url")
.eq("project_id", params.id)
.eq("material_type_id", PHOTO_MATERIAL_ID);
if (projectMaterialError) {
console.error("Error while fetching project material" + projectMaterialError);
}
if (!projectData) {
redirect("/deals");
}
@ -30,13 +40,26 @@ export default async function ProjectDealPage({ params }: { params: { id: number
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>
<Link href={`/deals/${params.id}`} className="mt-4">
<Button className="mt-4">Refresh</Button>
</Link>
</div>
);
}
if (userError || !user) {
return (
<div className="container max-w-screen-xl my-5">
<p className="text-red-600">Error fetching data. Please try again.</p>
<Link href={`/deals/${params.id}`} className="mt-4">
<Button className="mt-4">Refresh</Button>
</Link>
</div>
);
}
const isOwner = await isOwnerOfProject(supabase, params.id, user.user?.id);
const projectBusinessOwnerId = projectData.user_id;
const dealList = await getDealList(projectBusinessOwnerId);
const totalDealAmount = sumByKey(dealList, "deal_amount");
@ -44,104 +67,120 @@ export default async function ProjectDealPage({ params }: { params: { id: number
const timeDiff = Math.max(new Date(projectData.investment_deadline).getTime() - new Date().getTime(), 0);
const hourLeft = Math.floor(timeDiff / (1000 * 60 * 60));
const carouselData = Array(5).fill({
src: projectData.card_image_url || "/boiler1.jpg",
alt: `${projectData.project_name} Image`,
});
const carouselData =
projectMaterial && projectMaterial.length > 0
? projectMaterial.flatMap((item) =>
(item.material_url || ["/boiler1.jpg"]).map((url: string) => ({
src: url,
}))
)
: [{ src: "/boiler1.jpg", alt: "Default Boiler Image" }];
return (
<div className="container max-w-screen-xl my-5">
<div className="flex flex-col gap-y-10">
<div id="content">
{/* Name, star and share button packed */}
<div id="header" className="flex flex-col">
<div className="flex justify-between">
<span className="flex">
<Image src="/logo.svg" alt="logo" width={50} height={50} className="sm:scale-75" />
<h1 className="mt-3 font-bold text-lg md:text-3xl">{projectData?.project_name}</h1>
</span>
<FollowShareButtons />
</div>
{/* end of pack */}
<p className="mt-2 sm:text-sm">{projectData?.project_short_description}</p>
<div className="flex flex-wrap mt-3">
{projectData?.tags.map((tag, index) => (
<span key={index} className="text-xs rounded-md bg-slate-200 dark:bg-slate-700 p-1 mx-1 mb-1">
{tag.tag_name}
</span>
))}
</div>
{/* Name, star and share button packed */}
<div id="header" className="flex flex-col">
<div className="flex justify-between">
<span className="flex">
<Image src="/logo.svg" alt="logo" width={50} height={50} className="sm:scale-75" />
<h1 className="mt-3 font-bold text-lg md:text-3xl">{projectData?.project_name}</h1>
</span>
<FollowShareButtons userId={user!.user.id} projectId={params.id} projectName={projectData?.project_name} />
</div>
{/* end of pack */}
<p className="mt-2 sm:text-sm">{projectData?.project_short_description}</p>
<div className="flex flex-wrap mt-3">
{projectData?.tags.map((tag, index) => (
<span key={index} className="text-xs rounded-md bg-slate-200 dark:bg-slate-700 p-1 mx-1 mb-1">
{tag.tag_name}
</span>
))}
</div>
</div>
<div id="sub-content" className="grid grid-cols-1 md:grid-cols-2 mt-5 space-y-5 md:space-y-0">
{/* image carousel */}
<div id="image-carousel" className="w-full">
<Gallery images={carouselData} />
</div>
<div id="sub-content" className="flex flex-row mt-5">
{/* image carousel */}
<div id="image-corousel" className="shrink-0 w-[700px] flex flex-col">
<Carousel className="w-full h-full ml-1">
<CarouselContent className="flex h-full">
{carouselData.map((item, index) => (
<CarouselItem key={index}>
<Image src={item.src} alt={item.alt} width={700} height={400} className="rounded-lg" />
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
<Carousel className="w-full ml-1 h-[100px]">
<CarouselContent className="flex space-x-1">
{carouselData.map((item, index) => (
<CarouselItem key={index} className="flex">
<Image src={item.src} alt={item.alt} width={200} height={100} className="rounded-lg basis-0" />
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
<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">${totalDealAmount}</h1>
<p className="text-sm md:text-lg">
{toPercentage(totalDealAmount, projectData?.target_investment)}% raised of $
{projectData?.target_investment} max goal
</p>
<Progress
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">{dealList.length}</p>
</h1>
<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>
{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">
<Card className="w-[80%] ml-10 shadow-sm">
<CardContent>
<div id="stats" className="flex flex-col w-full mt-4">
<div className="pl-5">
<span>
<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={toPercentage(totalDealAmount, projectData?.target_investment)}
className="w-4/5 h-3 mt-3 border-2"
/>
</span>
<span>
<h1 className="font-semibold text-4xl md:mt-8">
<p className="text-xl md:text-4xl">{dealList.length}</p>
</h1>
<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>
{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>
</div>
</div>
</CardContent>
<Separator />
<CardFooter className="flex flex-col space-y-4 mt-3">
<div className="flex justify-between w-full">
<Button className="w-full h-12 dark:text-white truncate">
<Link href={`/invest/${params.id}`}>Invest in {projectData?.project_name}</Link>
</Button>
{isOwner && (
<Button className="w-[48%] ml-4 h-12 dark:text-white" variant={"outline"}>
<Link href={`/project/${params.id}/edit`}>Edit</Link>
</Button>
)}
</div>
</div>
</div>
<div className="flex justify-between gap-4 w-full">
<Button
className="flex justify-center items-center w-[48%] h-12 text-xs md:text-sm text-center dark:text-white"
variant="outline"
>
<Link href={`/dataroom/${params.id}/files`} className="block text-center break-words">
<span className="block lg:inline">Access</span>
<span className="block lg:inline"> Dataroom</span>
</Link>
</Button>
<Button
className="flex justify-center items-center w-[48%] h-12 text-xs md:text-sm text-center dark:text-white"
variant="outline"
>
<Link href={`/dataroom/overview`} className="block text-center break-words">
<span className="block lg:inline">Request</span>
<span className="block lg:inline"> Dataroom Access</span>
</Link>
</Button>
</div>
</CardFooter>
</Card>
</div>
{/* menu */}
<div id="deck">
<div className="flex w-fit">
<Tabs.Root defaultValue="pitch">
<div className="flex w-full">
<Tabs.Root defaultValue="pitch" className="w-full">
<Tabs.List className="list-none flex gap-10 text-lg md:text-xl">
<Tabs.Trigger value="pitch">Pitch</Tabs.Trigger>
<Tabs.Trigger value="general">General Data</Tabs.Trigger>
<Tabs.Trigger value="update">Updates</Tabs.Trigger>
</Tabs.List>
<Separator className="mb-4 mt-2 w-full border-1" />
@ -149,34 +188,27 @@ export default async function ProjectDealPage({ params }: { params: { id: number
<Card>
<CardHeader>
<CardTitle>{projectData.project_name}</CardTitle>
<CardDescription />
<CardDescription>Project Pitch</CardDescription>
<Separator className="my-4" />
</CardHeader>
<CardContent>
<div className="prose prose-sm max-w-none ">
<ReactMarkdown>{projectData?.project_description || "No pitch available."}</ReactMarkdown>
<div className="prose dark:prose-invert prose-sm max-w-none ">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{projectData?.project_description || "No pitch available."}
</ReactMarkdown>
</div>
</CardContent>
</Card>
</Tabs.Content>
<Tabs.Content value="general">
<Card>
<CardHeader>
<CardTitle>general</CardTitle>
<CardDescription>general Description</CardDescription>
</CardHeader>
<CardContent>
<p>general Content</p>
</CardContent>
</Card>
</Tabs.Content>
<Tabs.Content value="update">
<Card>
<CardHeader>
<CardTitle>update</CardTitle>
<CardDescription>update Description</CardDescription>
<CardTitle>Update</CardTitle>
<CardDescription>Project log and updates</CardDescription>
<Separator className="my-4" />
</CardHeader>
<CardContent>
<p>update Content</p>
<UpdateTab projectId={params.id} />
</CardContent>
</Card>
</Tabs.Content>

View File

@ -0,0 +1,21 @@
import { SupabaseClient } from "@supabase/supabase-js";
const isOwnerOfProject = async (client: SupabaseClient, projectId: number, userId: string) => {
try {
const { data, error } = await client.from("project").select("...business(user_id)").eq("id", projectId).single();
if (error) {
throw new Error(`Error fetching project: ${error}`);
}
if (!data) {
return false;
}
return data.user_id === userId;
} catch (error) {
return false;
}
};
export { isOwnerOfProject };

View File

@ -1,189 +1,88 @@
"use client";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useEffect, useState } from "react";
import { Clock3Icon, UserIcon, UsersIcon } from "lucide-react";
import { useState } from "react";
import { Building2, ClipboardPen, Tag } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { ProjectCard } from "@/components/projectCard";
import { getALlFundedStatusQuery, getAllBusinessTypeQuery } from "@/lib/data/dropdownQuery";
import { getAllBusinessTypeQuery, getAllTagsQuery, getAllProjectStatusQuery } 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";
import { Input } from "@/components/ui/input";
import { ProjectSection } from "@/components/ProjectSection";
import { ShowFilter } from "./ShowFilter";
import { Button } from "@/components/ui/button";
import Link from "next/link";
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;
}
if (!filteredProjects) {
return <div>No projects found!</div>;
}
return (
<div>
<div className="mt-10">
<h2 className="text-2xl">Deals</h2>
<p className="mt-3">The deals attracting the most interest right now</p>
</div>
{/* Block for all the deals */}
<div className="mt-10 grid grid-cols-3 gap-4">
{filteredProjects.map((item, index) => (
<Link key={index} href={`/deals/${item.project_id}`}>
<ProjectCard
key={index}
name={item.project_name}
description={item.project_short_description}
joinDate={item.published_time}
imageUri={item.card_image_url}
location={item.business_location}
minInvestment={item.min_investment}
totalInvestor={item.total_investment}
totalRaised={item.target_investment}
tags={item.tags.map((tag: Tags) => tag.tag_name)}
/>
</Link>
))}
</div>
</div>
);
};
const ShowFilter = ({ filterParams, clearAll }: { filterParams: FilterParams; clearAll: () => void }) => {
const { searchTerm, tagsFilter, projectStatusFilter, businessTypeFilter, sortByTimeFilter } = filterParams;
if (!searchTerm && !tagsFilter && !projectStatusFilter && !businessTypeFilter && !sortByTimeFilter) {
return <div></div>;
}
if (
projectStatusFilter === "all" &&
businessTypeFilter === "all" &&
sortByTimeFilter === "all" &&
(!tagsFilter || tagsFilter.length === 0)
) {
return <div></div>;
}
return (
<div className="flex flex-wrap gap-2">
{searchTerm && (
<Button key={searchTerm} variant="secondary">
{searchTerm}
</Button>
)}
{tagsFilter &&
tagsFilter.map((tag: string) => (
<Button key={tag} variant="secondary">
{tag}
</Button>
))}
{projectStatusFilter && projectStatusFilter !== "all" && (
<Button key={projectStatusFilter} variant="secondary">
{projectStatusFilter}
</Button>
)}
{businessTypeFilter && businessTypeFilter !== "all" && (
<Button key={businessTypeFilter} variant="secondary">
{businessTypeFilter}
</Button>
)}
{sortByTimeFilter && sortByTimeFilter !== "all" && (
<Button key={sortByTimeFilter} variant="secondary">
{sortByTimeFilter}
</Button>
)}
{/* Clear All button */}
<Button variant="destructive" onClick={clearAll}>
Clear All
</Button>
</div>
);
};
import { sumByKey } from "@/lib/utils";
export default function Deals() {
const supabase = createSupabaseClient();
const [searchTerm, setSearchTerm] = useState("");
const [searchTermVisual, setSearchTermVisual] = useState("");
const [sortByTimeFilter, setSortByTimeFilter] = useState("all");
const [searchTermVisual, setSearchTermVisual] = useState(""); // For the input field
// const [sortByTimeFilter, setSortByTimeFilter] = useState("all");
const [businessTypeFilter, setBusinessTypeFilter] = useState("all");
const [tagFilter, setTagFilter] = useState([]);
const [tagFilter, setTagFilter] = useState("all");
const [projectStatusFilter, setProjectStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(4);
const filterParams: FilterParams = {
searchTerm,
tagsFilter: tagFilter,
tagFilter,
projectStatusFilter,
businessTypeFilter,
sortByTimeFilter,
// sortByTimeFilter,
};
const filterProjectQueryParams: FilterProjectQueryParams = {
searchTerm: "",
tagsFilter: [],
projectStatusFilter: "all",
businessTypeFilter: "all",
sortByTimeFilter: "all",
let filterProjectQueryParams: FilterProjectQueryParams = {
searchTerm,
tagFilter,
projectStatusFilter,
businessTypeFilter,
// sortByTimeFilter,
page,
pageSize,
};
const { data: tagData, isLoading: isLoadingTag, error: isTagError } = useQuery(getAllTagsQuery(supabase));
const {
data: projectStatus,
isLoading: isLoadingFunded,
error: fundedLoadingError,
} = useQuery(getALlFundedStatusQuery(supabase));
data: projectStatusData,
isLoading: isLoadingprojectStatus,
error: isprojectStatusError,
} = useQuery(getAllProjectStatusQuery(supabase));
const {
data: businessType,
isLoading: isLoadingBusinessType,
error: businessTypeLoadingError,
} = useQuery(getAllBusinessTypeQuery(supabase));
const {
data: projects,
isLoading: isLoadingProjects,
error: projectsLoadingError,
refetch,
} = useQuery(searchProjectsQuery(supabase, filterProjectQueryParams));
const formattedProjects =
projects?.map((project) => ({
id: project.project_id,
project_name: project.project_name,
short_description: project.project_short_description,
image_url: project.card_image_url,
join_date: new Date(project.published_time).toLocaleDateString(),
location: project.business_location,
tags: project.tags.map((tag) => tag.tag_name),
min_investment: project.min_investment || 0,
total_investor: new Set(project.investment_deal.map((deal) => deal.investor_id)).size || 0,
total_raise: sumByKey(project.investment_deal, "deal_amount") || 0,
})) || [];
const clearAll = () => {
setSearchTerm("");
setTagFilter([]);
setSearchTermVisual("");
setTagFilter("all");
setProjectStatusFilter("all");
setBusinessTypeFilter("all");
setSortByTimeFilter("all");
// setSortByTimeFilter("all");
};
const handlePageSizeChange = (value: number) => {
@ -191,9 +90,10 @@ export default function Deals() {
setPage(1);
};
useEffect(() => {
const handleSearchClick = () => {
setSearchTerm(searchTermVisual);
}, [searchTermVisual]);
refetch();
};
return (
<div className="container max-w-screen-xl mx-auto px-4">
@ -214,13 +114,17 @@ export default function Deals() {
onChange={(e) => setSearchTermVisual(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setSearchTerm(e.currentTarget.value);
handleSearchClick();
}
}}
/>
<Button variant="outline" onClick={handleSearchClick}>
Search
</Button>
{/* Posted At Filter */}
<Select onValueChange={(value) => setSortByTimeFilter(value)}>
{/* <Select value={sortByTimeFilter} onValueChange={(value) => setSortByTimeFilter(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<Clock3Icon className="ml-2" />
<SelectValue placeholder="Posted at" />
@ -231,12 +135,12 @@ export default function Deals() {
<SelectItem value="This Week">This Week</SelectItem>
<SelectItem value="This Month">This Month</SelectItem>
</SelectContent>
</Select>
</Select> */}
{/* Business Type Filter */}
<Select onValueChange={(value) => setBusinessTypeFilter(value)}>
<Select value={businessTypeFilter} onValueChange={(value) => setBusinessTypeFilter(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<UsersIcon className="ml-2" />
<Building2 className="ml-2" />
<SelectValue placeholder="Business Type" />
</SelectTrigger>
<SelectContent>
@ -263,25 +167,25 @@ export default function Deals() {
</Select>
{/* Project Status Filter */}
<Select onValueChange={(key) => setProjectStatusFilter(key)}>
<Select value={projectStatusFilter} onValueChange={(value) => setProjectStatusFilter(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<UserIcon className="ml-2" />
<ClipboardPen className="ml-2" />
<SelectValue placeholder="Project Status" />
</SelectTrigger>
<SelectContent>
{isLoadingFunded ? (
{isLoadingprojectStatus ? (
<SelectItem disabled value="_">
Loading...
</SelectItem>
) : fundedLoadingError ? (
) : isprojectStatusError ? (
<SelectItem disabled value="_">
No data available
</SelectItem>
) : (
<>
<SelectItem value="all">All Statuses</SelectItem>
{projectStatus &&
projectStatus.map((status) => (
{projectStatusData &&
projectStatusData.map((status) => (
<SelectItem key={status.id} value={status.value}>
{status.value}
</SelectItem>
@ -290,21 +194,51 @@ export default function Deals() {
)}
</SelectContent>
</Select>
{/* Tags Filter */}
<Select value={tagFilter} onValueChange={(value) => setTagFilter(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<Tag className="ml-2" />
<SelectValue placeholder="Tags" />
</SelectTrigger>
<SelectContent>
{isLoadingTag ? (
<SelectItem disabled value="_">
Loading...
</SelectItem>
) : isTagError ? (
<SelectItem disabled value="_">
No data available
</SelectItem>
) : (
<>
<SelectItem value="all">All Tags</SelectItem>
{tagData &&
tagData.map((tag) => (
<SelectItem key={tag.id} value={tag.value}>
{tag.value}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
{/* Active Filters */}
<Separator className="mt-4 bg-background" />
<ShowFilter filterParams={filterParams} clearAll={clearAll} />
<Separator className="mt-10" />
<Separator className="my-3" />
{/* Project Cards Section */}
{isLoadingProjects ? (
<div>Loading...</div>
) : projectsLoadingError ? (
<div>Error loading projects.</div>
) : projects ? (
<ProjectSection filteredProjects={projects} />
<div>Error loading projects</div>
) : (
<div>No projects found!</div>
<div>
<ProjectSection projectsData={formattedProjects} />
</div>
)}
{/* Pagination Controls */}
<div className="mt-6 flex items-center justify-between">

View File

@ -0,0 +1,13 @@
import { Separator } from "@/components/ui/separator";
export function InvestmentAmountInfo({ amount }: { amount: number }) {
return (
<div className="mt-4">
<span className="flex flex-row justify-between">
<p>Amount to be paid</p>
<p>${amount}</p>
</span>
<Separator />
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import React from "react";
import { Elements } from "@stripe/react-stripe-js";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import CheckoutPage from "./checkoutPage";
import convertToSubcurrency from "@/lib/convertToSubcurrency";
import { Button } from "@/components/ui/button";
interface PaymentDialogProps {
open: boolean;
// eslint-disable-next-line no-unused-vars
onOpenChange: (open: boolean) => void;
amount: number;
stripePromise: Promise<any>;
isAcceptTermAndService: () => boolean;
projectId: number;
investorId: string;
}
export function PaymentDialog({
open,
onOpenChange,
amount,
stripePromise,
isAcceptTermAndService,
projectId,
investorId,
}: PaymentDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Payment Information</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p>Proceed with the payment to complete your investment.</p>
<Elements
stripe={stripePromise}
options={{
mode: "payment",
amount: convertToSubcurrency(amount),
currency: "usd",
}}
>
<CheckoutPage
amount={amount}
isAcceptTermAndService={isAcceptTermAndService}
project_id={projectId}
investor_id={investorId}
/>
</Elements>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -7,13 +7,17 @@ import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import convertToSubcurrency from "@/lib/convertToSubcurrency";
import CheckoutPage from "./checkoutPage";
import { Elements } from "@stripe/react-stripe-js";
// import convertToSubcurrency from "@/lib/convertToSubcurrency";
// import CheckoutPage from "./checkoutPage";
// import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { getProjectDataQuery } from "@/lib/data/projectQuery";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { InvestmentAmountInfo } from "./InvestmentAmountInfo";
import { Button } from "@/components/ui/button";
import { PaymentDialog } from "./PaymentDialog";
import Link from "next/link";
if (process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY === undefined) {
throw new Error("NEXT_PUBLIC_STRIPE_PUBLIC_KEY is not defined");
@ -22,35 +26,35 @@ const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY);
const term_data = [
{
term: "Minimum Investment",
description: "The minimum investment amount is $500.",
},
{
term: "Investment Horizon",
description: "Investments are typically locked for a minimum of 12 months.",
term: "Risk Disclosure",
description:
"Investments carry risks, including the potential loss of principal. It is important to carefully consider your risk tolerance before proceeding with any investment.",
link: "/risks",
},
{
term: "Fees",
description: "A management fee of 2% will be applied annually.",
description:
"A management fee of 3% will be taken from the company after the fundraising is completed. This fee is non-refundable.",
link: null,
},
{
term: "Returns",
description: "Expected annual returns are between 8% and 12%.",
},
{
term: "Risk Disclosure",
description: "Investments carry risks, including the loss of principal.",
link: null,
},
{
term: "Withdrawal Policy",
description: "Withdrawals can be made after the lock-in period.",
description:
"Withdrawals cannot be made after the fundraising period ends. Once the investment is finalized, it is non-refundable.",
link: null,
},
];
export default function InvestPage() {
const [checkedTerms, setCheckedTerms] = useState(Array(term_data.length).fill(false));
const [investAmount, setInvestAmount] = useState(10);
const [checkboxStates, setCheckboxStates] = useState([false, false]);
const [investAmount, setInvestAmount] = useState<number>(10);
const [investor_id, setInvestorId] = useState<string>("");
const [showPaymentModal, setPaymentModal] = useState(false);
const params = useParams<{ id: string }>();
const supabase = createSupabaseClient();
@ -77,16 +81,17 @@ export default function InvestPage() {
} = useQuery(getProjectDataQuery(supabase, Number(params.id)));
const handleCheckboxChange = (index: number) => {
const updatedCheckedTerms = [...checkedTerms];
updatedCheckedTerms[index] = !updatedCheckedTerms[index];
setCheckedTerms(updatedCheckedTerms);
const updatedCheckboxStates = [...checkboxStates];
updatedCheckboxStates[index] = !updatedCheckboxStates[index];
setCheckboxStates(updatedCheckboxStates);
};
const isAcceptTermAndService = () => {
if (checkedTerms.some((checked) => !checked)) {
return false;
}
return true;
return checkboxStates.every((checked) => checked);
};
const getInputBorderColor = (min: number) => {
return investAmount >= min ? "border-green-500" : "border-red-500";
};
return (
@ -96,76 +101,118 @@ export default function InvestPage() {
) : projectError ? (
<p>Error loading project data. Please try again later.</p>
) : projectData ? (
<>
<div>
<h1 className="text-2xl md:text-4xl font-bold">Invest in {projectData.project_name}</h1>
<Separator className="my-4" />
<div className="flex gap-6">
<div id="investment" className="w-3/4">
{/* Investment Amount Section */}
<div className="w-3/4">
<h2 className="text:base md:text-2xl font-bold">Investment Amount</h2>
<h3 className="text-gray-500 text-md">Payments are processed immediately in USD$</h3>
<Input
className={`py-7 mt-4 text-lg border-2 ${getInputBorderColor(10)} rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus-visible:ring-0`}
type="number"
placeholder="min $10"
min={10}
onChangeCapture={(e) => setInvestAmount(Number(e.currentTarget.value))}
/>
<InvestmentAmountInfo amount={investAmount} />
</div>
<Separator className="my-4" />
{/* 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>
<h3 className="text-gray-500 text-md">Please read and accept Term and Services first</h3>
<div id="term-condition">
<Table>
<TableHeader>
<TableRow>
<TableHead>Term</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{term_data.map((item, index) => (
<TableRow key={index}>
<TableCell>
{item.link != null ? (
<Link
href={item.link}
rel="noopener noreferrer"
target="_blank"
className="text-blue-500 underline"
>
{item.term}
</Link>
) : (
item.term
)}
</TableCell>
<TableCell>{item.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 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>
</TableHeader>
<TableBody>
{term_data.map((item, index) => (
<TableRow key={index}>
<TableCell>
<input
<div className="mt-4 space-y-4">
{/* First Checkbox with Terms */}
<div className="flex items-center space-x-2">
<Input
type="checkbox"
checked={checkedTerms[index]}
onChange={() => handleCheckboxChange(index)}
checked={checkboxStates[0]}
onChange={() => handleCheckboxChange(0)}
className="h-4 w-4"
/>
</TableCell>
<TableCell>{item.term}</TableCell>
<TableCell>{item.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Separator className="my-4" />
<p className="text-xs text-gray-500">
I understand that this offering involves the use of a third-party custodian, who will act as the
legal holder of the assets involved. As an investor, I acknowledge that I will be required to
create an account with this custodian and enter into necessary agreements, including those
related to the custody of the assets. I am aware that I may need to provide certain information
to verify my identity and complete the account creation process. I also understand that the
platform facilitating this offering does not manage or hold any custodial accounts for its
investors. Additionally, I recognize that the platform, its affiliates, or its representatives
will not be held responsible for any damages, losses, costs, or expenses arising from (i) the
creation or management of custodial accounts, (ii) unauthorized access or loss of assets within
these accounts, or (iii) the custodian&apos;s failure to fulfill its obligations.
</p>
</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}
project_id={Number(params.id)}
investor_id={investor_id}
/>
</Elements>
{/* Second Checkbox for Acceptance */}
<div className="flex items-center space-x-2">
<Input
type="checkbox"
checked={checkboxStates[1]}
onChange={() => handleCheckboxChange(1)}
className="h-4 w-4"
/>
<p className="text-xs text-gray-500">I have read and accept the terms of investment.</p>
</div>
</div>
</div>
</div>
<Separator className="my-4" />
<Button disabled={!isAcceptTermAndService()} onClick={() => setPaymentModal(true)}>
Proceed to Payment
</Button>
</div>
</div>
</>
</div>
) : (
<p>No project data found.</p>
)}
<PaymentDialog
open={showPaymentModal}
onOpenChange={setPaymentModal}
amount={investAmount}
stripePromise={stripePromise}
isAcceptTermAndService={isAcceptTermAndService}
projectId={Number(params.id)}
investorId={investor_id}
/>
</div>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
export function NavSidebar() {
return (
<div className="w-64 p-6 bg-gray-100 dark:bg-gray-800 text-white shadow-lg rounded-lg border-2 border-gray-300 dark:border-gray-600">
<ul className="space-y-4 sticky top-24 text-black dark:text-white">
<Link href="/about">
<li className="flex items-center p-2 relative group hover:scale-105 transition-all duration-200 ease-in-out rounded-md">
<span>About</span>
<div className="absolute bottom-0 left-0 w-full h-[2px] bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-all duration-300"></div>
</li>
</Link>
<Separator className="bg-gray-200 dark:bg-gray-700" />
<Link href="/privacy">
<li className="flex items-center p-2 relative group hover:scale-105 transition-all duration-200 ease-in-out rounded-md">
<span>Privacy</span>
<div className="absolute bottom-0 left-0 w-full h-[2px] bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-all duration-300"></div>
</li>
</Link>
<Separator className="bg-gray-200 dark:bg-gray-700" />
<Link href="/risks">
<li className="flex items-center p-2 relative group hover:scale-105 transition-all duration-200 ease-in-out rounded-md">
<span>Risks</span>
<div className="absolute bottom-0 left-0 w-full h-[2px] bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-all duration-300"></div>
</li>
</Link>
<Separator className="bg-gray-200 dark:bg-gray-700" />
<Link href="/terms">
<li className="flex items-center p-2 relative group hover:scale-105 transition-all duration-200 ease-in-out rounded-md">
<span>Terms</span>
<div className="absolute bottom-0 left-0 w-full h-[2px] bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-all duration-300"></div>
</li>
</Link>
<Separator className="bg-gray-200 dark:bg-gray-700" />
</ul>
</div>
);
}

31
src/app/(legal)/Toc.tsx Normal file
View File

@ -0,0 +1,31 @@
"use client";
import Link from "next/link";
interface Section {
id: string;
title: string;
}
interface TableOfContentProps {
sections: Section[];
}
const TableOfContent = ({ sections }: TableOfContentProps) => {
return (
<div className="w-64 p-6 bg-gray-100 dark:bg-gray-800 text-white shadow-lg rounded-lg border-border border-2">
<h3 className="text-xl font-semibold text-blue-600 dark:text-blue-400 mb-6">Table of Contents</h3>
<ul className="space-y-4 sticky top-24">
{sections.map((section) => (
<li key={section.id}>
<Link href={`#${section.id}`} className="text-gray-800 dark:text-gray-300 hover:text-blue-400">
{`${sections.indexOf(section) + 1}. ${section.title}`}
</Link>
</li>
))}
</ul>
</div>
);
};
export default TableOfContent;

View File

@ -0,0 +1,18 @@
import Image from "next/image";
interface FounderCardProps {
image: string;
name: string;
position: string;
background: string;
}
export default function FounderCard(props: FounderCardProps) {
return (
<div className="flex flex-col justify-center items-center p-3 border-3">
<Image src={props.image} alt="profile" width={160} height={160} className="rounded-full w-32" />
<h1 className="font-bold text-2xl mt-5">{props.name}</h1>
<p className="text-sm text-neutral-500">{props.position}</p>
<span className="max-w-xs break-words text-base mt-5 justify-self-center">{props.background}</span>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { Button } from "@/components/ui/button";
import Image from "next/image";
import Link from "next/link";
type CardProps = {
imageSrc: string;
imageAlt: string;
heading: string;
content: string[];
link: string;
buttonText: string;
};
const InfoCard = ({ imageSrc, imageAlt, heading, content, link, buttonText }: CardProps) => {
return (
<div className="flex flex-col items-center justify-center">
<Image alt={imageAlt} width={460} height={460} className="w-36" src={imageSrc} />
<h1 className="text-2xl font-bold mt-3">{heading}</h1>
{content.map((text, index) => (
<p key={index} className={index === 0 ? "mt-3" : ""}>
{text}
</p>
))}
<Link href={link}>
<Button className="p-6 font-semibold text-base mt-5">{buttonText}</Button>
</Link>
</div>
);
};
export default InfoCard;

View File

@ -0,0 +1,106 @@
"use client";
import { Separator } from "@/components/ui/separator";
import InfoCard from "./infoCard";
import FounderCard from "./founderCard";
export default function About() {
// Static data for the cards
const imageData = {
img1: "https://assets.republic.com/assets/static_pages/about/growth_opportunities/individual_investors-0e85dfd02359a24ac4b232be008c7168fc57d3437a2f526f5d5889b874b20221.png",
img2: "https://assets.republic.com/assets/static_pages/about/growth_opportunities/accredited_investors-42d6aa046861adb7f0648f26ca3f798b07f3b13bf7024f7dc17c17acb78fdf2c.png",
img3: "https://assets.republic.com/assets/static_pages/about/growth_opportunities/entrepreneurs-a0ff450c2f3ba0cea82e2c55cd9265ad5612455c79ec831adaa2c94d09a0e617.png",
};
const founderData = [
{
name: "Sirisilp Kongsilp",
position: "Tech, Business",
profileLogo:
"https://media.licdn.com/dms/image/v2/C5603AQE6NwFfhOWqrw/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1642637269980?e=1737590400&v=beta&t=zh3GpG_d5lSxxZyFn6_RMgVtjz7lacqlpDwZ84BRpAA",
background:
"Founder and CEO of Perception, a holographic computer startup focused on innovative technologies like VR/AR and human-computer interaction. ",
},
];
return (
<div className="container">
<div className=" bg-neutral-100 dark:bg-neutral-800 w-full pb-5">
<div className="flex flex-col items-center justify-center">
<h1 className="mt-3 text-lg md:text-5xl font-black">Growth opportunities for all sides </h1>
<h1 className="mt-3 text-lg md:text-5xl font-black">of the investment market</h1>
<h1 className="text-gray-500 text-2xl mt-1">
B2DVentures is where both accredited and non-accredited investors meet
</h1>
<h1 className="text-gray-500 text-2xl">
entrepreneurs and access high-growth potential deals across a range
</h1>
<h1 className="text-gray-500 text-2xl">of private markets.</h1>
</div>
<div className="mt-10">
<div className="grid grid-cols-1 md:grid-cols-[0.5fr,auto,0.5fr] gap-3">
<InfoCard
imageSrc={imageData.img1}
imageAlt="Image1"
heading="Individual investors"
content={[
"B2DVentures's success has been built off our hundreds",
"of sourced private deals, all available for",
"investment from you with as little as $10 or as ",
"much as $124,000.",
]}
link="/deals"
buttonText="Explore opportunities"
/>
<Separator orientation="vertical" className="dark:bg-white" />
<InfoCard
imageSrc={imageData.img2}
imageAlt="Image2"
heading="Accredited investors"
content={[
"The benefits of the Republic platform, optimized for",
"accredited investors. Access a curated investor",
"portal for unique private investment opportunities. ",
]}
link="/dataroom/overview"
buttonText="Learn more"
/>
</div>
<Separator className="mt-5 mb-5 dark:bg-white" />
<InfoCard
imageSrc={imageData.img3}
imageAlt="Image3"
heading="Entrepreneurs"
content={[
"Seek funding from a wider base of diverse",
"investors while simultaneously growing a loyal",
"base and leveraging Republic's private investment",
"network.",
]}
link="/project/apply"
buttonText="Raise money"
/>
</div>
</div>
<div className="flex flex-col items-center justify-center mt-24">
<h1 className="font-black text-5xl ">Built by an experience team with deep </h1>
<h1 className="font-black text-5xl ">expertise in private investing</h1>
<h1 className="text-gray-500 text-2xl mt-2">B2D Ventures was established by innovators with experience in</h1>
<h1 className="text-gray-500 text-2xl">top investment platforms and entrepreneurial ecosystems. Since then,</h1>
<h1 className="text-gray-500 text-2xl">we have built a team and a network of the top people from</h1>
<h1 className="text-gray-500 text-2xl">the startup, venture capital, and investment worlds.</h1>
<div className="mt-10">
{founderData.map((profile, index) => {
return (
<FounderCard
key={index}
image={profile.profileLogo}
name={profile.name}
position={profile.position}
background={profile.background}
/>
);
})}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import TableOfContent from "../Toc";
import { NavSidebar } from "../NavigationSidebar";
const PrivacyPolicy = () => {
const sections = [
{ id: "introduction", title: "Introduction" },
{ id: "data-collection", title: "Data Collection" },
{ id: "data-usage", title: "Data Usage" },
{ id: "data-sharing", title: "Data Sharing" },
{ id: "data-security", title: "Data Security" },
{ id: "user-rights", title: "User Rights" },
{ id: "changes", title: "Changes to Privacy Policy" },
{ id: "contact", title: "Contact Information" },
];
return (
<div className="container max-w-screen-xl flex mt-5">
<div className="flex gap-2">
<NavSidebar />
<TableOfContent sections={sections} />
</div>
<div className="flex-1 ml-4">
<Card className="w-full max-w-3xl bg-white dark:bg-gray-800 shadow-lg rounded-lg">
<CardHeader>
<CardTitle className="text-3xl text-blue-600 dark:text-blue-400">Privacy Policy</CardTitle>
<CardDescription className="text-sm text-gray-500 dark:text-gray-400">
Last Updated: November 17, 2024
</CardDescription>
</CardHeader>
<CardContent>
<div className="prose dark:prose-invert text-gray-800 dark:text-gray-200">
<p>
This Privacy Policy (&quot;Policy&quot;) describes how we collect, use, and share your personal
information when you access and use our Website.
</p>
<h2 id="introduction">1. Introduction</h2>
<p>
We value your privacy. This Policy explains how we collect, use, and protect your personal data when you
use our services.
</p>
<h2 id="data-collection">2. Data Collection</h2>
<p>
We collect various types of information when you use our Website, including personal information,
technical data, and usage data. This includes your name, email address, IP address, and device
information.
</p>
<h2 id="data-usage">3. Data Usage</h2>
<p>
The data we collect is used to improve our services, personalize your experience, and ensure the
functionality of our Website. We may also use your data for marketing purposes and to communicate with
you.
</p>
<h2 id="data-sharing">4. Data Sharing</h2>
<p>
We do not sell your personal data to third parties. However, we may share your data with trusted
partners for the purpose of providing our services, complying with legal obligations, or protecting our
rights.
</p>
<h2 id="data-security">5. Data Security</h2>
<p>
We take reasonable measures to secure your personal data and protect it from unauthorized access,
alteration, disclosure, or destruction. However, no data transmission over the Internet can be
guaranteed as 100% secure.
</p>
<h2 id="user-rights">6. User Rights</h2>
<p>
You have the right to access, update, or delete your personal information. You may also object to the
processing of your data or request a restriction on how we process your data.
</p>
<h2 id="changes">7. Changes to Privacy Policy</h2>
<p>
We may update this Privacy Policy from time to time. Any changes will be posted on this page, and the
updated date will be reflected at the top of the page. We encourage you to review this Policy regularly.
</p>
<h2 id="contact">8. Contact Information</h2>
<p>
If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us
at privacy@b2dventure.com.
</p>
</div>
</CardContent>
<CardFooter className="flex justify-center"></CardFooter>
</Card>
</div>
</div>
);
};
export default PrivacyPolicy;

View File

@ -0,0 +1,105 @@
"use client";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import TableOfContent from "../Toc";
import { NavSidebar } from "../NavigationSidebar";
const InvestmentRisks = () => {
const sections = [
{ id: "investment-risk", title: "Investment Risk" },
{ id: "business-closure", title: "Business Closure" },
{ id: "no-return", title: "No Return on Investment" },
{ id: "market-conditions", title: "Market Conditions" },
{ id: "regulatory-risk", title: "Regulatory Risk" },
{ id: "liquidity-risk", title: "Liquidity Risk" },
{ id: "economic-factors", title: "Economic Factors" },
{ id: "mitigation-strategies", title: "Mitigation Strategies" },
];
return (
<div className="container max-w-screen-xl flex mt-5">
<div className="flex gap-2">
<NavSidebar />
<TableOfContent sections={sections} />
</div>
<div className="flex-1 ml-4">
<Card className="w-full max-w-3xl bg-white dark:bg-gray-800 shadow-lg rounded-lg">
<CardHeader>
<CardTitle className="text-3xl text-blue-600 dark:text-blue-400">Investment Risks</CardTitle>
<CardDescription className="text-sm text-gray-500 dark:text-gray-400">
Last Updated: November 17, 2024
</CardDescription>
</CardHeader>
<CardContent>
<div className="prose dark:prose-invert text-gray-800 dark:text-gray-200">
<p>
Investing in opportunities through our platform carries inherent risks. It is important that you fully
understand these risks before proceeding with any investment. This page outlines some of the potential
risks associated with investing in businesses listed on our platform.
</p>
<h2 id="investment-risk">1. Investment Risk</h2>
<p>
All investments carry risk. The value of your investment may fluctuate, and there is a possibility that
you could lose the entire amount invested. You should only invest money that you can afford to lose.
</p>
<h2 id="business-closure">2. Business Closure</h2>
<p>
The businesses listed on our platform may face financial difficulties or other challenges that could
lead to their closure. In the event of a business closing, investors may lose their entire investment,
and there may be no recourse for recovering the invested funds.
</p>
<h2 id="no-return">3. No Return on Investment</h2>
<p>
While some businesses may generate returns, there is no guarantee that you will receive a return on your
investment. If a business does not succeed or fails to generate profits, you may not receive any return
on your investment.
</p>
<h2 id="market-conditions">4. Market Conditions</h2>
<p>
Economic and market conditions can affect the success of a business. Factors such as changes in demand,
competition, or overall market downturns can negatively impact the businesss ability to generate
revenue and, consequently, affect your investment return.
</p>
<h2 id="regulatory-risk">5. Regulatory Risk</h2>
<p>
Changes in laws or regulations could affect the operations of a business and impact its ability to
operate successfully. Businesses may face additional compliance costs or regulatory restrictions, which
can negatively impact their profitability and the value of your investment.
</p>
<h2 id="liquidity-risk">6. Liquidity Risk</h2>
<p>
Investment opportunities on our platform may lack liquidity. This means you may not be able to easily
sell or exit your investment when you wish. The inability to quickly liquidate an investment may impact
your ability to access your funds.
</p>
<h2 id="economic-factors">7. Economic Factors</h2>
<p>
Broader economic factors, such as inflation, unemployment, and interest rates, can influence business
performance and impact your investment. Negative economic shifts can lead to a decline in investment
value.
</p>
<h2 id="mitigation-strategies">8. Mitigation Strategies</h2>
<p>
While we cannot eliminate all risks, we recommend conducting thorough due diligence before investing.
Additionally, diversifying your investment portfolio and staying informed about market trends and
business performance can help mitigate some of these risks.
</p>
</div>
</CardContent>
<CardFooter className="flex justify-center"></CardFooter>
</Card>
</div>
</div>
);
};
export default InvestmentRisks;

View File

@ -0,0 +1,108 @@
"use client";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import TableOfContent from "../Toc";
import { NavSidebar } from "../NavigationSidebar";
const TermsOfService = () => {
const sections = [
{ id: "eligibility", title: "Eligibility" },
{ id: "user-accounts", title: "User Accounts" },
{ id: "user-content", title: "User Content" },
{ id: "investment-activity", title: "Investment Activity" },
{ id: "disclaimers", title: "Disclaimers" },
{ id: "limitation-of-liability", title: "Limitation of Liability" },
{ id: "indemnification", title: "Indemnification" },
{ id: "termination", title: "Termination" },
];
return (
<div className="container max-w-screen-xl flex mt-5">
<div className="flex gap-2">
<NavSidebar />
<TableOfContent sections={sections} />
</div>
<div className="flex-1 ml-4">
<Card className="w-full max-w-3xl bg-white dark:bg-gray-800 shadow-lg rounded-lg">
<CardHeader>
<CardTitle className="text-3xl text-blue-600 dark:text-blue-400">Terms of Service</CardTitle>
<CardDescription className="text-sm text-gray-500 dark:text-gray-400">
Last Updated: November 17, 2024
</CardDescription>
</CardHeader>
<CardContent>
<div className="prose dark:prose-invert text-gray-800 dark:text-gray-200">
<p>
These Terms of Service (&quot;Terms&quot;) govern your access to and use of the Website, which is owned
and operated by B2DVenture Company LLC (&quot;B2DVenture&quot;, &quot;we&quot;, &quot;us&quot;, or
&quot;our&quot;).
</p>
<h2 id="eligibility">1. Eligibility</h2>
<p>
You must be at least 18 years old and have the legal capacity to enter into a binding agreement in order
to use the Website. You may not access or use the Website if you are not qualified.
</p>
<h2 id="user-accounts">2. User Accounts</h2>
<p>
In order to access certain features of the Website, such as uploading investment opportunities or making
investments, you may be required to create an account.
</p>
<p>
You are responsible for maintaining the confidentiality of your account information, including your
username and password. You are also responsible for all activity that occurs under your account.
</p>
<h2 id="user-content">3. User Content</h2>
<p>
The Website allows users to upload content, including images, videos, and text (collectively, &quot;User
Content&quot;). You retain all ownership rights in your User Content. However, by uploading User Content
to the Website, you grant B2DVenture a non-exclusive, royalty-free, worldwide license to use, reproduce,
modify, publish, and distribute your User Content in connection with the Website and our business.
</p>
<h2 id="investment-activity">4. Investment Activity</h2>
<p>
The Website is a platform that connects businesses seeking funding (the &quot;Issuers&quot;) with
potential investors (the &quot;Investors&quot;). B2DVenture does not act as a financial advisor, broker,
or dealer. We do not recommend or endorse any particular investment opportunity.
</p>
<h2 id="disclaimers">5. Disclaimers</h2>
<p>
The Website and the information contained herein are provided for informational purposes only and should
not be considered as investment advice.
</p>
<h2 id="limitation-of-liability">6. Limitation of Liability</h2>
<p>
B2DVenture shall not be liable for any damages arising out of or in connection with your use of the
Website, including but not limited to, direct, indirect, incidental, consequential, or punitive damages.
</p>
<h2 id="indemnification">7. Indemnification</h2>
<p>
You agree to indemnify and hold harmless B2DVenture, its officers, directors, employees, agents, and
licensors from and against any and all claims, demands, losses, liabilities, costs, or expenses
(including attorneys&quot; fees) arising out of or in connection with your use of the Website or your
violation of these Terms.
</p>
<h2 id="termination">8. Termination</h2>
<p>
We may terminate your access to the Website for any reason, at any time, without notice. This includes
if you violate any of these Terms, or if we believe that your use of the Website is harmful to us or to
any other user.
</p>
</div>
</CardContent>
<CardFooter className="flex justify-center"></CardFooter>
</Card>
</div>
</div>
);
};
export default TermsOfService;

View File

@ -1,46 +1,114 @@
"use client";
import { useState } from "react";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { Card, CardContent } from "@/components/ui/card";
import { BellIcon } from "lucide-react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { getNotificationByUserId } from "@/lib/data/notificationQuery";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import useSession from "@/lib/supabase/useSession";
import { LegacyLoader } from "@/components/loading/LegacyLoader";
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
}).format(date);
};
export default function Notification() {
const sampleNotifications = [
{ 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" },
];
const supabase = createSupabaseClient();
const router = useRouter();
const { session, loading } = useSession();
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const {
data: notifications,
error,
isLoading,
refetch,
} = useQuery(getNotificationByUserId(supabase, session?.user.id), { enabled: !!session });
if (loading) {
return <LegacyLoader />;
}
if (!session) {
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={() => router.refresh()}>
Refresh
</Button>
</div>
);
}
const filteredNotifications = showUnreadOnly
? notifications?.filter((notification) => !notification.is_read)
: notifications;
const markAsRead = async (id: number) => {
const { error } = await supabase.from("notification").update({ is_read: true }).eq("id", id);
if (!error) {
refetch();
}
};
if (isLoading) return <LegacyLoader />;
if (error) return <p>Error loading notifications: {error.message}</p>;
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">
{/* Cards */}
<Card className="border-slate-800 w-3/4 p-6">
<CardContent>
<Card>
<CardContent>
{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>
</div>
</div>
<button className="text-sm text-blue-500 hover:text-blue-600">Mark as read</button>
</div>
))}
</CardContent>
</Card>
</CardContent>
</Card>
</div>
<div className="container max-w-screen-xl my-4">
<h1 className="text-3xl font-bold mb-6 border-b pb-2">Notifications</h1>
<div className="mb-4 flex justify-end">
<label className="text-sm mr-2 font-medium">Show Unread Only</label>
<input
type="checkbox"
checked={showUnreadOnly}
onChange={() => setShowUnreadOnly((prev) => !prev)}
className="cursor-pointer"
/>
</div>
<Card className="shadow-lg rounded-lg border border-border">
<CardContent className="p-4 space-y-4">
{filteredNotifications?.map((notification) => (
<div
key={notification.id}
className={`flex bg-slate-100 dark:bg-slate-950 items-center justify-between p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-150 border ${
notification.is_read ? "bg-gray-100 text-gray-400" : "bg-white text-gray-800"
}`}
>
<div className="flex items-center">
<BellIcon className={`w-5 h-5 mr-3 ${notification.is_read ? "text-gray-400" : "text-blue-500"}`} />
<div>
<p className="text-sm font-medium text-black dark:text-white">{notification.message}</p>
<p className="text-xs text-gray-500">{formatDate(notification.created_at)}</p>
</div>
</div>
{!notification.is_read && (
<button
onClick={() => markAsRead(notification.id)}
className="text-xs text-blue-500 hover:text-blue-600 transition duration-150"
>
Mark as read
</button>
)}
</div>
))}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,76 @@
import { getBusinessByUserId } from "@/lib/data/businessQuery";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export const BusinessProfile = async ({ userId }: { userId: string }) => {
const supabase = createSupabaseClient();
const { data, error } = await getBusinessByUserId(supabase, userId);
if (error) {
return (
<Card className="text-center">
<CardHeader>
<CardTitle>Error Loading Data</CardTitle>
</CardHeader>
<CardContent>
<p>Can&apos;t load business data</p>
</CardContent>
</Card>
);
}
if (!data) {
return (
<Card className="text-center">
<CardHeader>
<CardTitle>No Business Found</CardTitle>
</CardHeader>
<CardContent>
<p>This business account doesn&apos;t have businesses</p>
</CardContent>
</Card>
);
}
return (
<div className="container max-w-screen-xl px-4">
<Card className="mb-6 shadow-md rounded-lg bg-white dark:bg-slate-900">
<CardHeader>
<div className="flex flex-col sm:flex-row justify-between items-center">
<div className="text-center sm:text-left">
<CardTitle className="text-2xl font-semibold">{data.business_name}</CardTitle>
<CardDescription className="text-md text-gray-600 dark:text-gray-400">
{data.business_type}
</CardDescription>
</div>
<div className="mt-4 sm:mt-0">
<p className="text-lg text-gray-700 dark:text-gray-400">
<strong>Location:</strong> {data.location}
</p>
<p className="text-lg text-gray-700 dark:text-gray-400">
<strong>Joined on:</strong> {new Date(data.joined_date).toLocaleDateString()}
</p>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="py-3">
<div className="grid gap-4">
<div>
<p className="text-md font-semibold text-gray-800 dark:text-gray-500">Business ID:</p>
<p className="text-md text-gray-600 dark:text-gray-400">{data.business_id}</p>
</div>
<div>
<p className="text-md font-semibold text-gray-800 dark:text-gray-500">Business Type:</p>
<p className="text-md text-gray-600 dark:text-gray-400">{data.business_type}</p>
</div>
<div>
<p className="text-md font-semibold text-gray-800 dark:text-gray-500">User ID:</p>
<p className="text-md text-gray-600 dark:text-gray-400">{data.user_id}</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@ -0,0 +1,66 @@
import { getProjectByUserId } from "@/lib/data/projectQuery";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export const ProjectProfileSection = async ({ userId }: { userId: string }) => {
const supabase = createSupabaseClient();
const { data, error } = await getProjectByUserId(supabase, userId);
if (error) {
return (
<Card className="text-center">
<CardHeader>
<CardTitle>Error Loading Data</CardTitle>
</CardHeader>
<CardContent>
<p>Can&apos;t load business data</p>
</CardContent>
</Card>
);
}
if (!data || data.length === 0) {
return (
<Card className="text-center">
<CardHeader>
<CardTitle>No Project Found</CardTitle>
</CardHeader>
<CardContent>
<p>This business doesn&apos;t have any projects</p>
</CardContent>
</Card>
);
}
return (
<div className="container max-w-screen-xl px-4">
<div className="overflow-y-auto max-h-screen flex flex-col gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{data.map((project) => (
<Card key={project.id} className="shadow-lg rounded-lg bg-white dark:bg-slate-900 overflow-hidden">
<CardHeader>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center">
<div className="text-center sm:text-left">
<CardTitle className="text-2xl font-semibold">{project.project_name}</CardTitle>
<CardDescription className="text-md text-gray-600 dark:text-gray-500">
{project.project_short_description}
</CardDescription>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="py-4">
<Button>
<Link href={`/deals/${project.id}`}>Go to deal</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,155 @@
"use client";
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 { uploadAvatar } from "@/lib/data/bucket/uploadAvatar";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { MdxEditor } from "@/components/MarkdownEditor";
import { updateProfile } from "@/lib/data/profileMutate";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useState } from "react";
interface ProfileData {
username: string;
full_name: string;
bio: string;
}
type EditProfileFormProps = {
profileData: ProfileData;
uid: string;
};
export default function EditProfileForm({ profileData, uid }: EditProfileFormProps) {
const router = useRouter();
const client = createSupabaseClient();
const profileForm = useForm<z.infer<typeof profileSchema>>({
resolver: zodResolver(profileSchema),
defaultValues: {
avatars: undefined,
username: profileData?.username || "",
full_name: profileData?.full_name || "",
bio: profileData?.bio || "",
},
});
const [bioContent, setBioContent] = useState<string>(profileData.bio || "");
const onProfileSubmit = async (updates: z.infer<typeof profileSchema>) => {
const { avatars, username, full_name } = updates;
let avatarUrl = null;
try {
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 updateData = {
username,
full_name,
bio: bioContent,
...(avatarUrl && { avatar_url: avatarUrl }),
};
const hasChanges = Object.values(updateData).some((value) => value !== undefined && value !== null);
if (!hasChanges) {
toast.error("No fields to update!");
return;
}
const result = await updateProfile(client, uid, updateData);
if (result) {
toast.success("Profile updated successfully!");
router.push(`/profile/${uid}`);
router.refresh();
} else {
toast.error("Failed to update profile!");
}
} catch (error) {
toast.error("Error updating profile!");
console.error("Error updating profile:", error);
}
};
return (
<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={() => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<MdxEditor content={bioContent} setContentInParent={setBioContent} />
</FormControl>
<FormDescription>This is your public bio description in Markdown format.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
</div>
);
}

View File

@ -1,149 +1,39 @@
"use client";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { getUserProfile } from "@/lib/data/userQuery";
import { notFound } from "next/navigation";
import EditProfileForm from "./EditProfileForm";
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";
type ProfilePageProps = {
params: { uid: string };
};
export default function EditProfilePage({ params }: { params: { uid: string } }) {
export default async function ProfilePage({ params }: ProfilePageProps) {
const uid = params.uid;
const client = createSupabaseClient();
const router = useRouter();
const { session, loading: isLoadingSession } = useSession();
const {
data: { user },
} = await client.auth.getUser();
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>
);
if (!user || user?.id !== uid) {
notFound();
}
const onProfileSubmit = async (updates: z.infer<typeof profileSchema>) => {
const { avatars, username, full_name, bio } = updates;
const { data: profileData, error } = await getUserProfile(client, uid);
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}`);
if (error || !profileData) {
return (
<div className="flex items-center justify-center h-screen">
<p>Error loading profile data. Please try again later.</p>
</div>
);
}
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>
<EditProfileForm profileData={profileData} uid={uid} />
</div>
);
}

View File

@ -6,6 +6,9 @@ import { format } from "date-fns";
import ReactMarkdown from "react-markdown";
import Link from "next/link";
import { getUserRole } from "@/lib/data/userQuery";
import { BusinessProfile } from "./BusinessProfile";
import { ProjectProfileSection } from "./ProjectProfile";
import remarkGfm from "remark-gfm";
export default async function ProfilePage({ params }: { params: { uid: string } }) {
const supabase = createSupabaseClient();
@ -85,12 +88,20 @@ export default async function ProfilePage({ params }: { params: { uid: string }
)}
</div>
</div>
{userRoleData.role === "business" && (
<div id="business-area" className="rounded-md mt-3">
<BusinessProfile userId={uid} />
<ProjectProfileSection userId={uid} />
</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 dark:prose-invert">
<ReactMarkdown>{profileData.bio || "No bio available."}</ReactMarkdown>
{/* <h2 className="text-4xl font-bold mb-2">Bio</h2> */}
<div className="border-[1px] mx-4 p-6 rounded-md">
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{profileData.bio || "No bio available."}</ReactMarkdown>
</div>
</div>
</div>
</div>

View File

@ -37,12 +37,16 @@ const BusinessTable = ({ businesses }: BusinessTableProps) => {
<TableRow key={business.id}>
<TableCell>
<Link href={`/admin/business/${business.id}/projects`}>
<p className="hover:text-blue-600">{business.business_name}</p>
<p className="text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-700 border border-transparent hover:border-blue-300 rounded-md px-2 py-1 cursor-pointer transition duration-200">
{business.business_name}
</p>
</Link>
</TableCell>
<TableCell>
<Link href={`/profile/${business.user_id}`}>
<p className="hover:text-blue-600">{business.username}</p>
<p className="text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-700 border border-transparent hover:border-blue-300 rounded-md px-2 py-1 cursor-pointer transition duration-200">
{business.username}
</p>
</Link>
</TableCell>
<TableCell>{business.business_type}</TableCell>

View File

@ -2,6 +2,7 @@ import { getAllBusinesses } from "@/lib/data/businessQuery";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import BusinessTable from "./BusinessTable";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
export default async function AdminPage() {
const client = createSupabaseClient();
@ -15,6 +16,14 @@ export default async function AdminPage() {
<div className="container max-w-screen-xl my-4">
<h1 className="text-2xl font-bold">Business List</h1>
<Separator className="my-3" />
{/* Navigation Links */}
<div className="mb-4">
<Link href="/admin/business" passHref>
<button className="px-4 py-2 bg-blue-500 text-white rounded-md">Go to Business Application</button>
</Link>
</div>
<BusinessTable businesses={data} />
</div>
);

View File

@ -35,7 +35,7 @@ export async function getDealList(userId: string | undefined) {
}
if (!dealData || !dealData.project.length) {
alert("No project available");
// alert("No project available");
return []; // Exit if there's no data
}
@ -70,7 +70,7 @@ export async function getRecentDealData(userId: string | undefined) {
console.error("User not found");
return; // Exit on error
}
const supabase = createSupabaseClient();
const dealList = await getDealList(userId);
@ -111,7 +111,6 @@ export async function getRecentDealData(userId: string | undefined) {
return { ...item, ...recentUserData[index] };
});
return recentDealData;
}
@ -133,6 +132,8 @@ export function convertToGraphData(deals: Deal[]): Record<string, number> {
// Create a sorted graph data object
const sortedGraphData: Record<string, number> = {};
sortedKeys.forEach((key) => {sortedGraphData[key] = graphData[key]});
sortedKeys.forEach((key) => {
sortedGraphData[key] = graphData[key];
});
return sortedGraphData;
}

View File

@ -1,3 +0,0 @@
export default function AuthError() {
return <div>Authentication Error</div>;
}

View File

@ -3,6 +3,8 @@ import { Card, CardContent, CardFooter, CardDescription, CardHeader, CardTitle }
import { LoginButton } from "@/components/auth/loginButton";
import { LoginForm } from "@/components/auth/loginForm";
import Link from "next/link";
export default function Login() {
return (
<div
@ -23,7 +25,17 @@ export default function Login() {
<LoginButton />
</CardContent>
<CardFooter className="text-xs justify-center">
By signing up, you agree to the Terms of Service and acknowledge youve read our Privacy Policy.
<span>
By signing in, you agree to the{" "}
<Link href="/terms" rel="noopener noreferrer" target="_blank" className="text-blue-600 underline">
Terms of Service
</Link>{" "}
and acknowledge youve read our{" "}
<Link href="/privacy" rel="noopener noreferrer" target="_blank" className="text-blue-600 underline">
Privacy Policy
</Link>
.
</span>
</CardFooter>
</Card>
</div>

View File

@ -3,11 +3,13 @@ import { Card, CardContent, CardFooter, CardDescription, CardHeader, CardTitle }
import { SignupButton } from "@/components/auth/signupButton";
import { SignupForm } from "@/components/auth/signupForm";
import Link from "next/link";
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(/login.png)" }}
>
<Card>
<CardHeader className="items-center">
@ -23,7 +25,17 @@ export default function Signup() {
<SignupButton />
</CardContent>
<CardFooter className="text-xs justify-center">
By signing up, you agree to the Terms of Service and acknowledge youve read our Privacy Policy.
<span>
By signing up, you agree to the{" "}
<Link href="/terms" rel="noopener noreferrer" target="_blank" className="text-blue-600 underline">
Terms of Service
</Link>{" "}
and acknowledge youve read our{" "}
<Link href="/privacy" rel="noopener noreferrer" target="_blank" className="text-blue-600 underline">
Privacy Policy
</Link>
.
</span>{" "}
</CardFooter>
</Card>
</div>

View File

@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
@ -9,22 +11,14 @@ import { businessFormSchema } from "@/types/schemas/application.schema";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@radix-ui/react-tooltip";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
type businessSchema = z.infer<typeof businessFormSchema>;
interface BusinessFormProps {
applyProject: boolean;
setApplyProject: Function;
onSubmit: SubmitHandler<businessSchema>;
}
const BusinessForm = ({
applyProject,
setApplyProject,
onSubmit,
}: BusinessFormProps & { onSubmit: SubmitHandler<businessSchema> }) => {
const BusinessForm = ({ onSubmit }: BusinessFormProps & { onSubmit: SubmitHandler<businessSchema> }) => {
const communitySize = [
{ id: 1, name: "N/A" },
{ id: 2, name: "0-5K" },
@ -80,6 +74,7 @@ const BusinessForm = ({
useEffect(() => {
fetchCountries();
fetchIndustry();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Form {...form}>
@ -368,7 +363,7 @@ const BusinessForm = ({
<FormItem>
<FormControl>
<MultipleOptionSelector
header={<>What's the rough size of your community?</>}
header={<>What&apos;s the rough size of your community?</>}
fieldName="communitySize"
choices={communitySize}
handleFunction={(selectedValues: any) => {
@ -385,24 +380,6 @@ const BusinessForm = ({
</FormItem>
)}
/>
<div className="flex space-x-5">
<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?
</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.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<center>
<Button className="mt-12 mb-20 h-10 text-base font-bold py-6 px-5" type="submit">
Submit application

View File

@ -0,0 +1,38 @@
import { SupabaseClient } from "@supabase/supabase-js";
export const hasUserApplied = async (supabase: SupabaseClient, userID: string) => {
let { data: business, error } = await supabase.from("business").select("*").eq("user_id", userID);
let { data: businessApplication, error: applicationError } = await supabase
.from("business_application")
.select("*")
.eq("user_id", userID);
// console.table(business);
if (error || applicationError) {
console.error(error);
console.error(applicationError);
}
if ((business && business.length > 0) || (businessApplication && businessApplication.length > 0)) {
return true;
}
return false;
};
export 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
}
} else {
acc[key] = value; // keep other types unchanged
}
return acc;
}, {});
return transformedData;
};

View File

@ -1,30 +1,32 @@
"use client";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { SubmitHandler } from "react-hook-form";
import { z } from "zod";
import BusinessForm from "@/components/BusinessForm";
import BusinessForm from "./BusinessForm";
import { businessFormSchema } from "@/types/schemas/application.schema";
import Swal from "sweetalert2";
import { getCurrentUserID } from "@/app/api/userApi";
import { uploadFile } from "@/app/api/generalApi";
import { Loader } from "@/components/loading/loader";
// import { Loader } from "@/components/loading/loader";
import { hasUserApplied, transformChoice } from "./actions";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
type businessSchema = z.infer<typeof businessFormSchema>;
const BUCKET_PITCH_NAME = "business-application";
let supabase = createSupabaseClient();
export default function ApplyBusiness() {
const [applyProject, setApplyProject] = useState(false);
const router = useRouter();
const alertShownRef = useRef(false);
const [success, setSucess] = useState(false);
// const [success, setSucess] = useState(false);
const onSubmit: SubmitHandler<businessSchema> = async (data) => {
const transformedData = await transformChoice(data);
await sendApplication(transformedData);
};
const sendApplication = async (recvData: any) => {
setSucess(false);
// setSucess(false);
const {
data: { user },
} = await supabase.auth.getUser();
@ -41,8 +43,8 @@ export default function ApplyBusiness() {
if (!uploadSuccess) {
return;
}
console.log("file upload successful");
toast.success("Send business appliction susccessfully!");
router.push("/");
} else {
console.error("user ID is undefined.");
return;
@ -66,60 +68,26 @@ export default function ApplyBusiness() {
},
])
.select();
setSucess(true);
// 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,
confirmButtonColor: error == null ? "green" : "red",
}).then((result) => {
if (result.isConfirmed && applyProject) {
window.location.href = "/project/apply";
} else {
window.location.href = "/";
}
}).then(() => {
router.push("/");
});
};
const hasUserApplied = async (userID: string) => {
let { data: business, error } = await supabase.from("business").select("*").eq("user_id", userID);
console.table(business);
if (error) {
console.error(error);
}
if (business) {
return true;
}
return false;
};
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
}
} else {
acc[key] = value; // keep other types unchanged
}
return acc;
}, {});
return transformedData;
};
useEffect(() => {
const fetchUserData = async () => {
try {
setSucess(false);
// setSucess(false);
const userID = await getCurrentUserID();
if (userID) {
const hasApplied = await hasUserApplied(userID);
setSucess(true);
const hasApplied = await hasUserApplied(supabase, userID);
// setSucess(true);
if (hasApplied && !alertShownRef.current) {
alertShownRef.current = true;
Swal.fire({
@ -131,25 +99,27 @@ export default function ApplyBusiness() {
allowEscapeKey: false,
}).then((result) => {
if (result.isConfirmed) {
window.location.href = "/";
router.push("/");
}
});
}
setSucess(false);
} else {
// setSucess(true);
console.error("User ID is undefined.");
}
} catch (error) {
// setSucess(true);
console.error("Error fetching user ID:", error);
}
};
// setSucess(true);
fetchUserData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div>
<Loader isSuccess={success} />
{/* <Loader isSuccess={success} /> */}
<div className="grid grid-flow-row auto-rows-max w-full h-52 md:h-92 bg-gray-100 dark:bg-gray-800 p-5">
<h1 className="text-2xl md:text-5xl font-medium md:font-bold justify-self-center md:mt-8">
Apply to raise on B2DVentures
@ -165,7 +135,7 @@ export default function ApplyBusiness() {
</div>
{/* form */}
{/* <form action="" onSubmit={handleSubmit(handleSubmitForms)}> */}
<BusinessForm onSubmit={onSubmit} applyProject={applyProject} setApplyProject={setApplyProject} />
<BusinessForm onSubmit={onSubmit} />
</div>
);
}

View File

@ -0,0 +1,240 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Clock } from "lucide-react";
import { Label } from "@/components/ui/label";
import { createCalendarEvent, createMeetingLog, getFreeDate } from "./actions";
import { Session } from "@supabase/supabase-js";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { TimeInput } from "@nextui-org/date-input";
import { Calendar, DateValue } from "@nextui-org/calendar";
import { TimeValue } from "@react-types/datepicker";
import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
// import { useLocale } from "@react-aria/i18n";
import { getMeetingLog } from "./actions";
import toast from "react-hot-toast";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { LegacyLoader } from "@/components/loading/LegacyLoader";
import { isEventOverlapping } from "./overlapEvent";
interface DialogProps {
children?: React.ReactNode;
open?: boolean;
defaultOpen?: boolean;
// eslint-disable-next-line no-unused-vars
onOpenChange?(open: boolean): void;
modal?: boolean;
session: Session;
projectName: string;
projectId: number;
}
export function MeetEventDialog(props: DialogProps) {
const supabase = createSupabaseClient();
const timezone = getLocalTimeZone();
const [eventDate, setEventDate] = useState<CalendarDate | undefined>(undefined);
const [startTime, setStartTime] = useState<TimeValue | undefined>(undefined);
const [endTime, setEndTime] = useState<TimeValue | undefined>(undefined);
const [eventName, setEventName] = useState(`Meet with ${props.projectName}`);
const [eventDescription, setEventDescription] = useState(
"Meet and gather more information on business in B2DVentures"
);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [noteToBusiness, setNoteToBusiness] = useState<string>("");
const session = props.session;
const {
data: freeDate,
error: freeDateError,
isLoading: isLoadingFreeDate,
} = useQuery(getFreeDate(supabase, props.projectId), { enabled: !!props.projectId });
useEffect(() => {
if (props.projectName) {
setEventName(`Meet with ${props.projectName}`);
}
}, [props.projectName]);
const {
data: meetingLog,
error: meetingLogError,
isLoading: isLoadingMeetingLog,
} = useQuery(getMeetingLog(supabase, props.projectId), { enabled: !!props.projectId });
const handleCreateEvent = async () => {
if (!session || !eventDate || !startTime || !endTime || !eventName) {
toast.error("Please fill in all event details.");
return;
}
setIsSubmitting(true);
try {
const startDate = eventDate.toDate(timezone);
startDate.setHours(startTime.hour, startTime.minute);
const endDate = eventDate.toDate(timezone);
endDate.setHours(endTime.hour, startTime.minute);
const existingEvents = (meetingLog || []).map((log) => ({
meet_date: log.meet_date,
start_time: log.start_time,
end_time: log.end_time,
}));
const hasOverlap = isEventOverlapping(eventDate, startTime, endTime, existingEvents);
if (hasOverlap) {
toast.error("This current selected date and time is overlaped with any existing events.");
return;
}
await createCalendarEvent(session, startDate, endDate, eventName, eventDescription);
const { status, error } = await createMeetingLog({
client: supabase,
meet_date: eventDate.toString().split("T")[0],
start_time: startTime.toString(),
end_time: endTime.toString(),
note: noteToBusiness,
userId: session.user.id,
projectId: props.projectId!,
});
if (!status) {
console.error("Meeting log error:", error);
toast.error("Failed to log the meeting. Please try again.");
return;
}
toast.success("Meeting event created successfully!");
props.onOpenChange?.(false);
} catch (error) {
console.error("Error creating event:", error);
toast.error("There was an error creating the event. Please try again.");
} finally {
setIsSubmitting(false);
}
};
const meetingLogRanges = (meetingLog || []).map((log) => {
const [year, month, day] = log.meet_date.split("-").map(Number);
const startDate = new CalendarDate(year, month, day);
const endDate = new CalendarDate(year, month, day);
return [startDate, endDate];
});
const freetimeLogRanges = (freeDate || []).map((log) => {
const [year, month, day] = log.meet_date.split("-").map(Number);
const startDate = new CalendarDate(year, month, day);
const endDate = new CalendarDate(year, month, day);
return [startDate, endDate];
});
// const disabledRanges = [...meetingLogRanges];
// const { locale } = useLocale();
const isDateUnavailable = (date: DateValue) => {
const isFreeDate = freetimeLogRanges.some(
(interval) => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0
);
const isMeetingDate = meetingLogRanges.some(
(interval) => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0
);
return !isFreeDate || isMeetingDate;
};
return (
<Dialog {...props}>
<DialogContent className="sm:max-w-md overflow-y-auto h-[80%]">
<DialogHeader>
<DialogTitle className="flex gap-2 items-center">
<Clock />
Arrange Meeting
</DialogTitle>
<DialogDescription>
Arrange a meeting with the business you&apos;re interested in for more information.
</DialogDescription>
</DialogHeader>
{meetingLogError || freeDateError ? (
<div className="error-message">
<p>Error loading meeting logs</p>
</div>
) : !isLoadingMeetingLog || !isLoadingFreeDate ? (
<div className="space-y-4 w-[90%]">
<div>
<Label htmlFor="eventName">Event Name</Label>
<Input
id="eventName"
placeholder="Enter event name"
value={eventName}
onChange={(e) => setEventName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="eventDescription">Event Description</Label>
<Input
id="eventDescription"
placeholder="Enter event description"
value={eventDescription}
onChange={(e) => setEventDescription(e.target.value)}
/>
</div>
<div>
<Label htmlFor="eventDescription">Event Description</Label>
<Input
id="note"
placeholder="Your note to business"
value={noteToBusiness}
onChange={(e) => setNoteToBusiness(e.target.value)}
/>
</div>
<div className="flex flex-col space-y-2">
<Label>Date</Label>
<Calendar
value={eventDate}
onChange={setEventDate}
minValue={today(getLocalTimeZone())}
isDateUnavailable={isDateUnavailable}
/>
</div>
<div>
<div>
<Label>Start Time</Label>
<TimeInput label="Start Time" value={startTime} onChange={setStartTime} />
</div>
<div>
<Label>End Time</Label>
<TimeInput label="End Time" value={endTime} onChange={setEndTime} />
</div>
</div>
</div>
) : (
<LegacyLoader />
)}
<DialogFooter className="sm:justify-start mt-4">
<Button type="button" onClick={handleCreateEvent} className="mr-2" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Event"}
</Button>
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

114
src/app/calendar/actions.ts Normal file
View File

@ -0,0 +1,114 @@
import { Database } from "@/types/database.types";
import { Session, SupabaseClient } from "@supabase/supabase-js";
export async function createCalendarEvent(
session: Session,
start: Date,
end: Date,
eventName: string,
eventDescription: string
) {
console.log("Creating calendar event");
const event = {
summary: eventName,
description: eventDescription,
start: {
dateTime: start.toISOString(),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
end: {
dateTime: end.toISOString(),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
};
try {
const response = await fetch("https://www.googleapis.com/calendar/v3/calendars/primary/events", {
method: "POST",
headers: {
Authorization: "Bearer " + session.provider_token,
},
body: JSON.stringify(event),
});
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Error creating calendar event:", error);
}
}
interface CreateMeetingLogProps {
client: SupabaseClient;
userId: string;
projectId: number;
meet_date: string;
start_time: string;
end_time: string;
note: string;
}
export async function createMeetingLog({
client,
userId,
projectId,
meet_date,
start_time,
end_time,
note,
}: CreateMeetingLogProps) {
const { error } = await client.from("meeting_log").insert([
{
meet_date: meet_date, // Format date as YYYY-MM-DD
start_time: start_time, // Format time as HH:MM:SS
end_time: end_time, // Format time as HH:MM:SS
note: note, // Text for meeting notes
user_id: userId, // Replace with a valid UUID
project_id: projectId,
},
]);
return error ? { status: false, error } : { status: true, error: null };
}
export function getMeetingLog(client: SupabaseClient<Database>, projectId: number) {
return client
.from("meeting_log")
.select(
`
id,
meet_date,
start_time,
end_time,
note,
user_id,
project_id,
created_at
`
)
.eq("project_id", projectId);
}
export function getFreeDate(client: SupabaseClient<Database>, projectId: number) {
return client.from("project_meeting_time").select("*").eq("project_id", projectId);
}
export async function specifyFreeDate({
client,
meet_date,
projectId,
}: {
client: SupabaseClient<Database>;
meet_date: string;
projectId: number;
}) {
const { error } = await client.from("project_meeting_time").insert([
{
project_id: projectId,
meet_date: meet_date, // Format date as YYYY-MM-DD
},
]);
return error ? { status: false, error } : { status: true, error: null };
}

View File

@ -0,0 +1,196 @@
import React, { useState } from "react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Calendar, DateValue } from "@nextui-org/calendar";
import { specifyFreeDate, getFreeDate } from "../actions";
import toast from "react-hot-toast";
import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import { Label } from "@/components/ui/label";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { LegacyLoader } from "@/components/loading/LegacyLoader";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
interface DialogProps {
children?: React.ReactNode;
open?: boolean;
defaultOpen?: boolean;
// eslint-disable-next-line no-unused-vars
onOpenChange?(open: boolean): void;
modal?: boolean;
projectId: number;
}
export default function FreeTimeDialog(props: DialogProps) {
const supabase = createSupabaseClient();
const timezone = getLocalTimeZone();
const [selectedDate, setSelectedDate] = useState<CalendarDate | undefined>(undefined);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [deleteDateId, setDeleteDateId] = useState<number | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState<boolean>(false);
const {
data: freeDate,
error: freeDateError,
isLoading: isLoadingFreeDate,
refetch: refetchFreeDate,
} = useQuery(getFreeDate(supabase, props.projectId), { enabled: !!props.projectId });
const handleSpecifyFreeDate = async () => {
if (!selectedDate) {
toast.error("Please select a date.");
return;
}
setIsSubmitting(true);
try {
const formattedDate = selectedDate.toString().split("T")[0];
const { status, error } = await specifyFreeDate({
client: supabase,
meet_date: formattedDate,
projectId: props.projectId,
});
if (!status || error) {
toast.error("Failed to save the free date. Please try again.");
return;
}
refetchFreeDate();
toast.success("Free time specified successfully!");
props.onOpenChange?.(false);
} catch (error) {
toast.error("There was an error specifying the free date. Please try again.");
} finally {
setIsSubmitting(false);
}
};
const handleDeleteFreeDate = async () => {
if (deleteDateId === null) return;
setIsSubmitting(true);
try {
const { error } = await supabase.from("project_meeting_time").delete().eq("id", deleteDateId);
if (error) {
toast.error("Failed to delete the free date. Please try again.");
return;
}
refetchFreeDate();
toast.success("Free date deleted successfully!");
} catch (error) {
toast.error("There was an error deleting the free date. Please try again.");
} finally {
setIsSubmitting(false);
setIsDeleteDialogOpen(false);
}
};
const meetingLogRanges = (freeDate || []).map((log) => {
const [year, month, day] = log.meet_date.split("-").map(Number);
const startDate = new CalendarDate(year, month, day);
const endDate = new CalendarDate(year, month, day);
return [startDate, endDate];
});
const disabledRanges = [...meetingLogRanges];
const isDateUnavailable = (date: DateValue) =>
disabledRanges.some((interval) => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0);
return (
<Dialog {...props}>
<DialogContent className="sm:max-w-md overflow-y-auto h-[80%]">
<DialogHeader>
<DialogTitle className="flex gap-2 items-center">Specify Your Free Time</DialogTitle>
<DialogDescription>Select a date when you are available for a meeting with the investor.</DialogDescription>
</DialogHeader>
{freeDateError ? (
<div>Error Loading data</div>
) : isLoadingFreeDate ? (
<LegacyLoader />
) : (
<div className="flex flex-col space-y-4 w-[90%] mt-4">
<div className="flex flex-col space-y-2">
<Label>Date</Label>
<Calendar
value={selectedDate}
onChange={setSelectedDate}
minValue={today(timezone)}
isDateUnavailable={isDateUnavailable}
/>
</div>
<div className="flex flex-col space-y-2 mt-4">
<Label>Current Free Dates</Label>
<div className="p-2 border rounded-md space-y-2">
{freeDate && freeDate.length > 0 ? (
freeDate.map((date) => (
<div
key={date.id}
className="bg-gray-100 p-2 rounded cursor-pointer hover:bg-red-400"
onClick={() => {
setDeleteDateId(date.id);
setIsDeleteDialogOpen(true);
}}
>
{date.meet_date || "No date specified"}
</div>
))
) : (
<div>No free dates specified</div>
)}
</div>
</div>
</div>
)}
<DialogFooter className="sm:justify-start mt-4">
<Button onClick={handleSpecifyFreeDate} disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Free Date"}
</Button>
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the free date.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setIsDeleteDialogOpen(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteFreeDate}>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Clock } from "lucide-react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { getMeetingLog } from "../actions";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { LegacyLoader } from "@/components/loading/LegacyLoader";
interface DialogProps {
children?: React.ReactNode;
open?: boolean;
defaultOpen?: boolean;
// eslint-disable-next-line no-unused-vars
onOpenChange?(open: boolean): void;
modal?: boolean;
projectId: number;
}
export function ManageMeetDialog(props: DialogProps) {
const supabase = createSupabaseClient();
const {
data: meetingLog,
error: meetingLogError,
isLoading: isLoadingMeetingLog,
} = useQuery(getMeetingLog(supabase, props.projectId), {
enabled: !!props.projectId,
});
return (
<Dialog {...props}>
<DialogContent className="sm:max-w-md overflow-y-auto h-[80%]">
<DialogHeader>
<DialogTitle className="flex gap-2 items-center">
<Clock />
Meeting Request
</DialogTitle>
<DialogDescription>List of meeting you need to attend.</DialogDescription>
</DialogHeader>
{meetingLogError ? (
<div>Error Loading data</div>
) : isLoadingMeetingLog ? (
<LegacyLoader />
) : meetingLog && meetingLog.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Date</TableHead>
<TableHead>Start Time</TableHead>
<TableHead>End Time</TableHead>
<TableHead>User</TableHead>
<TableHead>Note</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{meetingLog.map((log) => (
<TableRow key={log.id}>
<TableCell className="font-medium">{log.meet_date}</TableCell>
<TableCell>{log.start_time}</TableCell>
<TableCell>{log.end_time}</TableCell>
<TableCell>{log.user_id}</TableCell>
<TableCell>{log.note || "No note provided"}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={5} className="text-right">
Total Meetings: {meetingLog.length}
</TableCell>
</TableRow>
</TableFooter>
</Table>
) : (
<div>No meeting logs available</div>
)}
<DialogFooter className="sm:justify-start mt-4">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { Separator } from "@/components/ui/separator";
import { Card, CardContent, CardDescription, CardTitle, CardHeader } from "@/components/ui/card";
import { Tooltip, TooltipProvider, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ManageMeetDialog } from "./ManageMeetDialog";
import { Button } from "@/components/ui/button";
import FreeTimeDialog from "./FreeTimeDialog";
type ProjectCardSectionProps = {
id: number;
project_name: string;
project_short_description: string;
business_id: {
user_id: string;
};
dataroom_id: number | null;
};
type ProjectCardCalendarManageSectionProps = {
projectData: ProjectCardSectionProps[] | null;
};
export default function ProjectCardCalendarManageSection({ projectData }: ProjectCardCalendarManageSectionProps) {
const [showMeetModal, setShowMeetModal] = useState<boolean>(false);
const [showFreeTimeModal, setShowFreeTimeModal] = useState<boolean>(false);
const [currentProjectId, setCurrentProjectId] = useState<number | undefined>(undefined);
return (
<div id="content" className="grid grid-cols-1 md:grid-cols-2 space-x-2">
{projectData != null ? (
projectData.map((project) => (
<Card key={project.id} className="mb-3">
<CardHeader className="h-[55%]">
<CardTitle>{project.project_name}</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<CardDescription className="line-clamp-1">{project.project_short_description}</CardDescription>
</TooltipTrigger>
<TooltipContent>{project.project_short_description}</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
<Separator className="mb-3" />
<CardContent className="flex gap-3">
<Button
onClick={() => {
setCurrentProjectId(project.id);
setTimeout(() => setShowMeetModal(true), 0);
}}
>
Meeting List
</Button>
<Button
onClick={() => {
setCurrentProjectId(project.id);
setTimeout(() => setShowFreeTimeModal(true), 0);
}}
>
Specify Free Time
</Button>
</CardContent>
{/* <CardFooter></CardFooter> */}
</Card>
))
) : (
<div>No project data</div>
)}
<ManageMeetDialog open={showMeetModal} onOpenChange={setShowMeetModal} projectId={currentProjectId!} />
<FreeTimeDialog open={showFreeTimeModal} onOpenChange={setShowFreeTimeModal} projectId={currentProjectId!} />
</div>
);
}

View File

@ -0,0 +1,62 @@
import { Separator } from "@/components/ui/separator";
import { Clock } from "lucide-react";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { getProjectByUserId } from "@/lib/data/projectQuery";
import { Suspense } from "react";
import { LegacyLoader } from "@/components/loading/LegacyLoader";
import { getUserRole } from "@/lib/data/userQuery";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import ProjectCardCalendarManageSection from "./ProjectCardSection";
export default async function ManageMeetingPage() {
const supabase = createSupabaseClient();
const { data: user, error: userError } = await supabase.auth.getUser();
if (userError) {
throw "Can't get user data!";
}
const userId = user.user?.id;
const { data: roleData, error: roleDataError } = await getUserRole(supabase, userId);
if (roleDataError) {
throw "Error fetching user data";
}
if (!roleData || roleData.role != "business") {
return (
<div className="container max-w-screen-xl">
<span className="flex gap-2 items-center mt-4">
<Clock />
<p className="text-2xl font-bold">Manage Meeting Request</p>
</span>
<Separator className="my-3" />
<div className="mb-3 mt-2">Please apply for business first to access functionalities of busienss account</div>
<Link href="/business/apply">
<Button>Apply for business</Button>
</Link>
</div>
);
}
const { data: projectData, error: projectDataError } = await getProjectByUserId(supabase, userId);
if (projectDataError) {
throw "Can't get project data";
}
return (
<div className="container max-w-screen-xl">
<span className="flex gap-2 items-center mt-4">
<Clock />
<p className="text-2xl font-bold">Manage Meeting Request</p>
</span>
<Separator className="my-3" />
<Suspense fallback={<LegacyLoader />}>
<ProjectCardCalendarManageSection projectData={projectData} />
</Suspense>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { CalendarDate, getLocalTimeZone } from "@internationalized/date";
import { TimeValue } from "@react-types/datepicker";
export function isEventOverlapping(
eventDate: CalendarDate,
startTime: TimeValue,
endTime: TimeValue,
existingEvents: { meet_date: string; start_time: string; end_time: string }[]
): boolean {
const timezone = getLocalTimeZone();
const newStartDate = eventDate.toDate(timezone);
newStartDate.setHours(startTime.hour, startTime.minute);
const newEndDate = eventDate.toDate(timezone);
newEndDate.setHours(endTime.hour, endTime.minute);
for (const log of existingEvents) {
const [year, month, day] = log.meet_date.split("-").map(Number);
const existingStartDate = new Date(year, month - 1, day);
existingStartDate.setHours(parseInt(log.start_time.split(":")[0]), parseInt(log.start_time.split(":")[1]));
const existingEndDate = new Date(year, month - 1, day);
existingEndDate.setHours(parseInt(log.end_time.split(":")[0]), parseInt(log.end_time.split(":")[1]));
const isOverlapping =
(newStartDate < existingEndDate && newEndDate > existingStartDate) ||
(existingStartDate < newEndDate && existingEndDate > newStartDate);
if (isOverlapping) {
return true;
}
}
return false;
}

View File

@ -1,21 +1,154 @@
"use client";
import React, { useState } from "react";
import { DateTimePicker } from "@/components/ui/datetime-picker";
import { Label } from "@/components/ui/label";
import React, { useMemo, useState } from "react";
import useSession from "@/lib/supabase/useSession";
import { MeetEventDialog } from "./MeetEventDialog";
import { Clock } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHeader,
TableHead,
TableRow,
TableFooter,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { getInvestmentByUserId } from "@/lib/data/investmentQuery";
import { LegacyLoader } from "@/components/loading/LegacyLoader";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
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;
}
const DatetimePickerHourCycle = () => {
const [date12, setDate12] = useState<Date | undefined>(undefined);
const [date24, setDate24] = useState<Date | undefined>(undefined);
const supabase = createSupabaseClient();
const [showModal, setShowModal] = useState<boolean>(false);
const [currentProjectName, setCurrentProjectName] = useState<string>("");
const [currentProjectId, setCurrentProjectId] = useState<number | undefined>(undefined);
const { session, loading } = useSession();
const {
data: investments = [],
error: investmentsError,
isLoading: isLoadingInvestments,
} = useQuery(getInvestmentByUserId(supabase, session?.user.id ?? ""), {
enabled: !!session?.user.id,
});
if (investmentsError) {
throw "Error load investment data";
}
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]);
if (loading) {
return <LegacyLoader />;
}
if (!session || !session?.user.id) {
throw "Can't load session!";
}
if (isLoadingInvestments) {
return <LegacyLoader />;
}
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 className="container max-w-screen-xl">
<span className="flex gap-2 items-center mt-4">
<Clock />
<p className="text-2xl font-bold">Schedule Meeting</p>
</span>
<Separator className="my-3" />
<div className="space-y-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>
<Button
onClick={() => {
setCurrentProjectName(projectInvestments[0].project_name);
setCurrentProjectId(projectInvestments[0].project_id);
setTimeout(() => setShowModal(true), 0);
}}
>
Schedule Meeting
</Button>
</CardFooter>
</Card>
))}
</div>
<MeetEventDialog
open={showModal}
onOpenChange={(open) => {
setShowModal(open);
if (!open) {
setCurrentProjectId(undefined);
setCurrentProjectName("");
}
}}
session={session}
projectName={currentProjectName}
projectId={currentProjectId!}
/>
</div>
);
};

View File

@ -1,53 +0,0 @@
import { useEffect, useState } from "react";
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 fetchDealList = async () => {
// set the state to the deal list of current business user
setDealList(await getDealList(await getCurrentUserID()));
}
useEffect(() => {
fetchDealList();
}, []);
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

@ -1,207 +1,318 @@
"use client";
import Image from "next/image";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Overview } from "@/components/ui/overview";
import { RecentFunds } from "@/components/recent-funds";
import { useState } from "react";
import { useDealList, useGraphData, useRecentDealData } from "./hook";
import { sumByKey } from "@/lib/utils";
import { useEffect, useState } from "react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import useSession from "@/lib/supabase/useSession";
import { getProjectByUserId } from "@/lib/data/projectQuery";
// import { Loader } from "@/components/loading/loader";
import { getInvestmentByProjectsIds } from "@/lib/data/investmentQuery";
import { useQuery } from "@supabase-cache-helpers/postgrest-react-query";
import { overAllGraphData, fourYearGraphData, dayOftheWeekData } from "../portfolio/[uid]/query";
import CountUp from "react-countup";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { Modal } from "@/components/modal";
import { LegacyLoader } from "@/components/loading/LegacyLoader";
export default function Dashboard() {
const supabase = createSupabaseClient();
const router = useRouter();
const { session, loading: isLoadingSession } = useSession();
const userId = session?.user.id;
const [projects, setProjects] = useState<
{
id: number;
project_name: string;
project_short_description: string;
business_id: { user_id: string };
dataroom_id: number | null;
}[]
>([]);
const [latestInvestment, setLatestInvestment] = useState<
{
avatarUrl: string;
createdTime: Date;
dealAmount: number;
dealStatus: string;
investorId: string;
username: string;
}[]
>([]);
const tabOptions = ["daily", "monthly", "yearly"];
const [activeTab, setActiveTab] = useState("daily");
const [graphType, setGraphType] = useState("line");
const graphData = useGraphData();
const dealList = useDealList();
// #TODO dependency injection refactor + define default value inside function (and not here)
const recentDealData = useRecentDealData() || [];
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [currentProjectId, setCurrentProjectId] = useState<number>(projects[0]?.id);
const investmentDetail = useQuery(
getInvestmentByProjectsIds(
supabase,
projects.map((item) => {
return item.id.toString();
})
)
);
let graphData = [];
const filteredProject = (investmentDetail?.data || []).filter((deal) => deal.project_id === currentProjectId);
const handleTabChange = (tab: string) => {
setActiveTab(tab);
};
if (activeTab === "daily") {
graphData = dayOftheWeekData(filteredProject);
} else if (activeTab === "yearly") {
graphData = fourYearGraphData(filteredProject);
} else {
graphData = overAllGraphData(filteredProject);
}
useEffect(() => {
const fetchProjects = async () => {
if (!userId) return;
const { data, error } = await getProjectByUserId(supabase, userId);
if (error) console.error("Error fetching projects");
setProjects(data || []);
setIsLoadingProjects(false);
};
fetchProjects();
}, [supabase, userId]);
useEffect(() => {
if (projects.length > 0 && !currentProjectId) {
setCurrentProjectId(projects[0].id);
}
}, [projects, currentProjectId]);
useEffect(() => {
const setTopLatestInvestment = () => {
if (investmentDetail?.data) {
setLatestInvestment(
investmentDetail.data
.slice(0, 8)
.map((item) => {
if (item.project_id === currentProjectId) {
return {
avatarUrl: item.avatar_url,
createdTime: item.created_time,
dealAmount: item.deal_amount,
dealStatus: item.deal_status,
investorId: item.investor_id,
username: item.username,
};
}
return undefined;
})
.filter((item) => item !== undefined) as {
avatarUrl: string;
createdTime: Date;
dealAmount: number;
dealStatus: string;
investorId: string;
username: string;
}[]
);
}
};
setTopLatestInvestment();
}, [currentProjectId, investmentDetail?.data]);
if (isLoadingSession && isLoadingProjects) {
return <LegacyLoader />;
}
return (
<>
<div className="md:hidden">
<Image
src="/examples/dashboard-light.png"
width={1280}
height={866}
alt="Dashboard"
className="block dark:hidden"
/>
<Image
src="/examples/dashboard-dark.png"
width={1280}
height={866}
alt="Dashboard"
className="hidden dark:block"
/>
</div>
<div className="hidden flex-col md:flex">
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Business Dashboard</h2>
</div>
<Tabs defaultValue="overview" className="space-y-4">
<div className="container max-w-screen-xl">
{/* <Loader isSuccess={!isLoadingSession && !isLoadingProjects} />{" "} */}
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Business Dashboard</h2>
</div>
{projects && projects.length > 0 && (
<Tabs className="space-y-4" defaultValue={projects[0].project_name}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="analytics">Analytics</TabsTrigger>
{projects.map((project) => (
<TabsTrigger
key={project.id}
value={project.project_name}
onClick={() => setCurrentProjectId(project.id)}
>
{project.project_name}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Funds Raised
</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${sumByKey(dealList, "deal_amount")}</div>
{/* <p className="text-xs text-muted-foreground">
+20.1% from last month
</p> */}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Profile Views
</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2350</div>
{/* <p className="text-xs text-muted-foreground">
{projects.map((project) => (
<TabsContent value={project.project_name} className="space-y-4" key={project.id}>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Funds Raised</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
$
<CountUp
end={filteredProject
.filter((project) => project.deal_status === "Completed")
.reduce((sum, current) => sum + current.deal_amount, 0)}
duration={1}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Profile Views</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
+<CountUp end={2350} duration={1} />
</div>
{/* <p className="text-xs text-muted-foreground">
+180.1% from last month
</p> */}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Followers
</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+12,234</div>
{/* <p className="text-xs text-muted-foreground">
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Followers</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
+<CountUp end={12234} duration={1} />
</div>
{/* <p className="text-xs text-muted-foreground">
+19% from last month
</p> */}
</CardContent>
</Card>
{/* <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Active Now
</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</CardContent>
</Card>
<Button
onClick={() => {
router.push(`/project/${project.id}/edit`);
}}
className="h-full bg-emerald-500 hover:bg-emerald-800 font-bold text-xl"
>
Edit Project
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 17.25V21h3.75l11.05-11.05-3.75-3.75L3 17.25zM20.71 7.04a1.003 1.003 0 000-1.42l-2.34-2.34a1.003 1.003 0 00-1.42 0L15.13 4.5l3.75 3.75 1.83-1.21z"
fill="currentColor"
/>
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+573</div>
<p className="text-xs text-muted-foreground">
+201 since last hour
</p>
</CardContent>
</Card> */}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<Overview graphType={graphType} graphData={graphData} />
{/* tab to switch between line and bar graph */}
<Tabs
defaultValue="line"
className="space-y-4 ml-[50%] mt-2"
>
<TabsList>
<TabsTrigger
value="line"
onClick={() => setGraphType("line")}
>
Line
</TabsTrigger>
<TabsTrigger
value="bar"
onClick={() => setGraphType("bar")}
>
Bar
</TabsTrigger>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-56 grid-cols-3 ml-5">
{tabOptions.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</TabsTrigger>
))}
</TabsList>
<CardContent className="pl-2 mt-5">
<Overview graphType={graphType} data={graphData} />
<Tabs defaultValue="line" className="space-y-4 ml-[50%] mt-2">
<TabsList>
<TabsTrigger value="line" onClick={() => setGraphType("line")}>
Line
</TabsTrigger>
<TabsTrigger value="bar" onClick={() => setGraphType("bar")}>
Bar
</TabsTrigger>
</TabsList>
</Tabs>
</CardContent>
</Tabs>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader>
<CardTitle>Recent Funds</CardTitle>
<CardDescription>
You made {dealList?.length || 0} sales this month.
</CardDescription>
</CardHeader>
<CardContent>
<RecentFunds recentDealData={recentDealData}>
</RecentFunds>
</CardContent>
</Card>
</div>
</TabsContent>
</Card>
<Card className="col-span-4 md:col-span-3">
<CardHeader>
<CardTitle>Recent Funds</CardTitle>
</CardHeader>
<CardContent className="grid grid-flow-dense w-full">
<RecentFunds
data={latestInvestment.map((item) => {
return {
name: item.username,
amount: item.dealAmount,
avatar: item.avatarUrl,
date: new Date(item.createdTime),
status: item.dealStatus,
profile_url: `/profile/${item.investorId}`,
};
})}
/>
<div className="flex justify-center mt-5">
{filteredProject && filteredProject.length > 1 ? (
<Modal
data={filteredProject.map((item) => {
return {
date: item.created_time,
name: item.username,
amount: item.deal_amount,
status: item.deal_status,
logoURL: Array.isArray(item.avatar_url) ? item.avatar_url[0] : item.avatar_url,
profileURL: `/profile/${item.investor_id}`,
};
})}
/>
) : undefined}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
))}
</Tabs>
</div>
)}
</div>
</>
</div>
);
}

View File

@ -7,13 +7,35 @@ interface ErrorProps {
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>
<main className="flex justify-center items-center min-h-screen bg-gray-100">
<div className="container max-w-screen-xl flex justify-center items-center flex-col gap-8 p-8 bg-white rounded-lg shadow-lg border-2 border-red-500">
<div className="flex items-center justify-center gap-4 text-red-600">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="w-12 h-12"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01M21 12a9 9 0 10-18 0 9 9 0 0018 0z"
/>
</svg>
<h1 className="text-4xl font-bold">Something went wrong!</h1>
</div>
<button className="inline-block bg-accent-500 text-primary-800 px-6 py-3 text-lg" onClick={reset}>
Try again
</button>
<p className="text-lg text-center text-gray-800">{error.message}</p>
<button
className="inline-block bg-red-600 text-white px-8 py-3 text-lg font-semibold rounded-lg transition duration-300 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600"
onClick={reset}
>
Try again
</button>
</div>
</main>
);
}

View File

@ -0,0 +1,55 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { BusinessCard } from "@/components/businessCard";
import { BusinessCardProps } from "@/types/BusinessCard";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
interface BusinessSectionProps extends BusinessCardProps {
user_id: string;
}
export function BusinessSection({ businessData }: { businessData: BusinessSectionProps[] }) {
if (!businessData || businessData.length === 0) {
return (
<Card className="text-center">
<CardHeader>
<CardTitle>No Business Found</CardTitle>
</CardHeader>
<CardContent>
<p>Sorry, we could not find any businesses matching your search criteria.</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<div id="project-card">
<Card>
<CardHeader>
<CardTitle>Businesses</CardTitle>
<CardDescription>Found {businessData.length} projects!</CardDescription>
</CardHeader>
<Separator className="my-3" />
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{businessData.map((business) => (
<div key={business.business_id}>
<Link href={`/profile/${business.user_id}`}>
<BusinessCard
business_id={business.business_id}
business_name={business.business_name}
joined_date={business.joined_date}
location={business.location}
business_type={business.business_type}
/>
</Link>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,89 +1,57 @@
"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 { ProjectCard } from "@/components/projectCard";
import { getBusinessByName } from "@/lib/data/businessQuery";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { BusinessSection } from "./BusinessSection";
import { ProjectSection } from "@/components/ProjectSection";
import { getProjectCardData } from "@/lib/data/projectQuery";
import { Separator } from "@/components/ui/separator";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getBusinessAndProject } from "@/lib/data/businessQuery";
function FindContent() {
const searchParams = useSearchParams();
const query = searchParams.get("query");
export default async function FindContent({ searchParams }: { searchParams: { query: string } }) {
const query = searchParams.query;
let supabase = createSupabaseClient();
const supabase = createSupabaseClient();
const {
data: businesses,
isLoading: isLoadingBusinesses,
error: businessError,
} = useQuery(getBusinessAndProject(supabase, { businessName: query }));
const { data: projectIds, error: projectIdsError } = await supabase
.from("project")
.select(`id`)
.ilike("project_name", `%${query}%`);
const isLoading = isLoadingBusinesses;
const error = businessError;
const { data: businessData, error: businessDataError } = await getBusinessByName(supabase, { businessName: query });
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error fetching data: {error.message}</p>;
if (businessDataError || projectIdsError) {
throw new Error(businessDataError?.message || projectIdsError?.message || "Unknown error");
}
const projectIdList: string[] = projectIds.map((item) => item.id);
const { data: projectsData, error: projectsDataError } = await getProjectCardData(supabase, projectIdList);
if (projectsDataError) {
throw new Error(projectsDataError || "Unknown error");
}
return (
<div className="container max-w-screen-xl">
<div className="mt-4">
<h1 className="text-4xl font-bold">Result</h1>
<Separator className="my-4" />
<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 className="container max-w-screen-xl my-5 space-y-5">
<Suspense fallback={<div>Loading Business and Projects...</div>}>
<BusinessSection businessData={businessData} />
</Suspense>
<Separator className="my-3" />
<Suspense fallback={<div>Loading Projects...</div>}>
<div className="space-y-6">
<div id="project-card">
<Card>
<CardHeader>
<CardTitle>Projects</CardTitle>
<CardDescription>Found {projectsData?.length ?? 0} projects!</CardDescription>
</CardHeader>
<Separator className="my-3" />
<CardContent>
<ProjectSection projectsData={projectsData} />
</CardContent>
</Card>
</div>
</div>
</Suspense>
</div>
);
}
export default function Find() {
return (
<Suspense fallback={<p>Loading search parameters...</p>}>
<FindContent />
</Suspense>
);
}

46
src/app/follow/page.tsx Normal file
View File

@ -0,0 +1,46 @@
import { ProjectSection } from "@/components/ProjectSection";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { getProjectCardData } from "@/lib/data/projectQuery";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { getFollowByUserId } from "@/lib/data/followQuery";
export default async function FollowPage() {
const supabase = createSupabaseClient();
const { data: user, error: userError } = await supabase.auth.getUser();
if (userError || !user) {
throw new Error(userError?.message || "Unknown error");
}
const { data: followsProjects, error: followsProjectsError } = await getFollowByUserId(supabase, user!.user.id);
if (followsProjectsError) {
throw new Error(followsProjectsError.message || "Unknown error");
}
const projectIdList: string[] = followsProjects.map((follow) => follow.project_id);
const { data: projectsData, error: projectsDataError } = await getProjectCardData(supabase, projectIdList);
if (projectsDataError) {
throw new Error(projectsDataError || "Unknown error");
}
return (
<div className="container max-w-screen-xl my-5">
<div>
<Card>
<CardHeader>
<CardTitle>Your favorite projects</CardTitle>
<CardDescription>Found {projectsData?.length ?? 0} projects!</CardDescription>
</CardHeader>
<Separator className="my-3" />
<CardContent>
<ProjectSection projectsData={projectsData} />
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import React from "react";
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Montserrat } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { ReactQueryClientProvider } from "@/components/ReactQueryClientProvider";
@ -22,6 +22,11 @@ export const metadata: Metadata = {
description: "B2DVentures is a financial services company.",
};
export const viewport: Viewport = {
initialScale: 1,
width: "device-width",
};
interface RootLayoutProps {
children: Readonly<React.ReactNode>;
}
@ -35,7 +40,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<div className="relative flex min-h-screen flex-col">
<div>
<Toaster position="top-center" reverseOrder={false} toastOptions={{ duration: 1000 }} />
<Toaster position="top-center" reverseOrder={false} toastOptions={{ duration: 2000 }} />
</div>
<NavigationBar />
<div className="flex-1 bg-background">{children}</div>

View File

@ -1,22 +1,5 @@
import { LegacyLoader } from "@/components/loading/LegacyLoader";
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>
);
return <LegacyLoader />;
}

View File

@ -3,56 +3,10 @@ import { Button } from "@/components/ui/button";
import Link from "next/link";
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";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { Suspense } from "react";
import { FC } from "react";
interface Project {
id: number;
project_name: string;
project_short_description: string;
card_image_url: string;
published_time: string;
business: { location: string }[];
project_tag: { tag: { id: number; value: string }[] }[];
project_investment_detail: {
min_investment: number;
total_investment: number;
}[];
}
interface TopProjectsProps {
projects: Project[];
}
const TopProjects: FC<TopProjectsProps> = ({ projects }) => {
if (!projects || projects.length === 0) {
return <div>No top projects available.</div>;
}
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{projects.map((project) => (
<Link href={`/deals/${project.id}`} key={project.id}>
<ProjectCard
name={project.project_name}
description={project.project_short_description}
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) : []
)}
minInvestment={project.project_investment_detail[0]?.min_investment || 0}
totalInvestor={0}
totalRaised={project.project_investment_detail[0]?.total_investment || 0}
/>
</Link>
))}
</div>
);
};
import { ProjectSection } from "@/components/ProjectSection";
const ProjectsLoader = () => (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
@ -65,6 +19,12 @@ export default async function Home() {
const supabase = createSupabaseClient();
const { data: topProjectsData, error: topProjectsError } = await getTopProjects(supabase);
const formattedProjects =
topProjectsData?.map((project) => ({
...project,
id: project.project_id,
})) || [];
return (
<main>
<div className="relative mx-auto">
@ -121,14 +81,14 @@ export default async function Home() {
<CardTitle className="text-lg md:text-2xl">Follow Us</CardTitle>
</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" />
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" />
Github
</Button>
<Link href="https://github.com/Sosokker/B2D-Ventures" passHref>
<Button className="flex gap-1 border-2 border-border rounded-md p-1 bg-background text-foreground scale-75 md:scale-100">
<div className="dark:bg-white rounded-full">
<Image src={"/github.svg"} width={20} height={20} alt="github" className="scale-75 md:scale-100" />
</div>
Github
</Button>
</Link>
</CardContent>
</Card>
</div>
@ -141,10 +101,12 @@ export default async function Home() {
<p className="text-md md:text-lg">The deals attracting the most interest right now</p>
</span>
{topProjectsError ? (
<div className="text-red-500">Error fetching projects: {topProjectsError}</div>
<div className="text-center text-red-600">
<p>Failed to load top projects. Please try again later.</p>
</div>
) : (
<Suspense fallback={<ProjectsLoader />}>
<TopProjects projects={topProjectsData || []} />
<ProjectSection projectsData={formattedProjects} />
</Suspense>
)}
<div className="self-center py-5 scale-75 md:scale-100">

View File

@ -0,0 +1,277 @@
import { Overview } from "@/components/ui/overview";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { getInvestorDeal } from "@/lib/data/investmentQuery";
import PieChart from "@/components/pieChart";
import {
overAllGraphData,
fourYearGraphData,
dayOftheWeekData,
getInvestorProjectTag,
countTags,
getBusinessTypeName,
countValues,
checkForInvest,
getLatestInvestment,
getTotalInvestment,
} from "./query";
import CountUpComponent from "@/components/countUp";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { RecentFunds } from "@/components/recent-funds";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import QuestionMarkIcon from "@/components/icon/questionMark";
import { NoDataAlert } from "@/components/alert/noData/alert";
import { error } from "console";
import { UnAuthorizedAlert } from "@/components/alert/unauthorized/alert";
import Link from "next/link";
import { Modal } from "@/components/modal";
export default async function Portfolio({ params }: { params: { uid: string } }) {
const supabase = createSupabaseClient();
// if user hasn't invest in anything
const hasInvestments = await checkForInvest(supabase, params.uid);
if (!hasInvestments) {
return (
<div className="container max-w-screen-xl p-6">
<div className="self-center border-2 border-border flex flex-col items-center justify-center p-4 bg-gray-100 dark:bg-slate-800 rounded-lg shadow-md">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">No Data Available</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 text-center">
It looks like you haven&apos;t added any data yet. Please head over to the investment section to get
started.
</p>
<NoDataAlert />
<Link
href="/deals"
className="px-4 py-2 mt-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition"
>
Go to Investment
</Link>
</div>
</div>
);
}
const { data: deals, error: investorDealError } = await getInvestorDeal(supabase, params.uid);
if (investorDealError) {
console.error(investorDealError);
}
const { data: localUser, error: localUserError } = await supabase.auth.getUser();
if (localUserError) {
console.error("Error while fetching user" + error);
}
// block user from try to see other user portfolio
if (params.uid != localUser.user?.id) {
return (
<>
<UnAuthorizedAlert />
</>
);
}
const username = localUser ? localUser.user.user_metadata.name : "Anonymous";
const overAllData = deals ? overAllGraphData(deals) : [];
const fourYearData = deals ? fourYearGraphData(deals) : [];
const dayOfWeekData = deals ? dayOftheWeekData(deals) : [];
const tags = deals ? await getInvestorProjectTag(supabase, deals) : [];
const latestDeals = deals
? await Promise.all(
(
await getLatestInvestment(
supabase,
deals.map((deal) => ({
...deal,
status: deal.deal_status,
project_id: deal.project_id,
}))
)
).map(async (deal) => ({
...deal,
logo_url: await deal.logo_url,
}))
)
: [];
const totalInvestment = deals ? getTotalInvestment(deals) : 0;
const tagCount = countTags(tags);
const businessType = deals
? await Promise.all(deals.map(async (item) => await getBusinessTypeName(supabase, item.project_id)))
: [];
const countedBusinessType = countValues(businessType.filter((item) => item !== null));
return (
<div className="container max-w-screen-xl">
<div className="text-center py-4">
<h1 className="text-2xl font-semibold">Welcome to your Portfolio, {username}!</h1>
<p className="text-lg text-muted-foreground">
Here&lsquo;s an overview of your investment journey and progress.
</p>
<p className="text-xl font-medium text-green-400">
Total Investment: $
<CountUpComponent end={totalInvestment} duration={1} />
</p>
</div>
<div className="flex flew-rows-3 gap-10 mt-5 w-full">
<Tabs defaultValue="daily" className="space-y-4 w-full">
<TabsList className="grid w-96 grid-cols-3">
<TabsTrigger value="daily">Daily</TabsTrigger>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="yearly">Yearly</TabsTrigger>
</TabsList>
<TabsContent value="monthly">
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-md font-bold">Monthly Investment Trend</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<QuestionMarkIcon />
</TooltipTrigger>
<TooltipContent>
<p>
Displays total investments each month over the past 12 <br />
months, up to today.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
<CardContent className="mt-5">
<Overview graphType="line" data={overAllData} graphHeight={500}></Overview>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="yearly">
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-md font-bold">Yearly Investment Summary</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<QuestionMarkIcon />
</TooltipTrigger>
<TooltipContent>
<p>
Shows total investments for each of the last four years, <br />
including the current year to date.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
<CardContent className="mt-5">
<Overview graphType="bar" data={fourYearData} graphHeight={500}></Overview>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="daily">
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-md font-bold">Daily Investment Breakdown</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<QuestionMarkIcon />
</TooltipTrigger>
<TooltipContent>
<p>
Illustrates total investments for each day over the past <br />
year, up to today.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
<CardContent className="mt-5">
<Overview graphType="bar" data={dayOfWeekData} graphHeight={500}></Overview>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 w-full gap-5 mt-5">
<Card className="w-full h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-md font-bold">Categories of Invested Projects</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<QuestionMarkIcon />
</TooltipTrigger>
<TooltipContent>
<p>
Displays the distribution of project tags in your <br />
investments, highlighting areas of interest.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
<CardContent className="mt-5">
<PieChart
data={tagCount.map((item: { name: string; count: number }) => item.count)}
labels={tagCount.map((item: { name: string; count: number }) => item.name)}
header="Total"
/>
</CardContent>
</Card>
<Card className="w-full h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-md font-bold">Types of Businesses Invested In</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<QuestionMarkIcon />
</TooltipTrigger>
<TooltipContent>
<p>
Shows the breakdown of business types in your portfolio, <br />
illustrating sector diversity.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
<CardContent className="mt-5">
<PieChart
data={Object.values(countedBusinessType)}
labels={Object.keys(countedBusinessType)}
header="Total"
/>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-md font-bold">Recent investment</CardTitle>
</CardHeader>
<CardContent className="mt-5 grid grid-flow-row-dense">
<RecentFunds
data={latestDeals.map((item) => {
return {
name: item.name,
amount: item.amount,
avatar: item.logo_url,
date: new Date(item.date),
status: item.status,
profile_url: `/deals/${item.projectId}`,
};
})}
/>
<div className="mt-5 flex justify-center">
{deals && deals.length > 5 ? (
<Modal
data={deals.map((item) => {
return {
date: item.created_time,
name: item.project_name,
amount: item.deal_amount,
status: item.deal_status,
logoURL: Array.isArray(item.avatar_url) ? item.avatar_url[0] : item.avatar_url,
profileURL: `/deals/${item.project_id}`,
};
})}
/>
) : undefined}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,258 @@
import { SupabaseClient } from "@supabase/supabase-js";
import { getProjectTag, getTagName } from "@/lib/data/tagQuery";
async function fetchLogoURL(supabase: SupabaseClient, projectId: number) {
const logoIndex = 1;
let { data: project_material, error } = await supabase
.from("project_material")
.select("material_url")
.eq("project_id", projectId)
.eq("material_type_id", logoIndex);
if (error) {
console.error("Error while fetching golo url" + error);
}
if (project_material && project_material.length > 0) {
return project_material[0].material_url;
}
return "";
}
function getTotalInvestment(deals: { deal_amount: number }[]) {
let total = 0;
for (let index = 0; index < deals.length; index++) {
total += deals[index].deal_amount;
}
return total;
}
async function getLatestInvestment(
supabase: SupabaseClient,
deals: { project_id: number; deal_amount: number; created_time: Date; status: string }[]
) {
const llist = [];
const count = 5;
// select project name from the given id
for (let i = deals.length - 1; i >= 0 && llist.length < count; --i) {
let { data: project, error } = await supabase.from("project").select("project_name").eq("id", deals[i].project_id);
if (error) {
console.error(error);
}
let url = fetchLogoURL(supabase, deals[i].project_id);
llist.push({
projectId: deals[i].project_id,
name: project?.[0]?.project_name,
amount: deals[i].deal_amount,
date: new Date(deals[i].created_time),
logo_url: url,
status: deals[i].status,
});
}
return llist;
}
async function checkForInvest(supabase: SupabaseClient, userId: string) {
let { count, error } = await supabase
.from("investment_deal")
.select("*", { count: "exact" })
.eq("investor_id", userId);
if (error) {
console.error(error);
return false;
}
// if user already invest in something
if (count !== null && count > 0) {
return true;
}
return false;
}
function countValues(arr: { value: string }[][]): Record<string, number> {
const counts: Record<string, number> = {};
arr.forEach((subArray) => {
subArray.forEach((item) => {
const value = item.value;
counts[value] = (counts[value] || 0) + 1;
});
});
return counts;
}
async function getBusinessTypeName(supabase: SupabaseClient, projectId: number) {
// step 1: get business id from project id
let { data: project, error: projectError } = await supabase.from("project").select("business_id").eq("id", projectId);
if (projectError) {
console.error(projectError);
}
// step 2: get business type's id from business id
let { data: business, error: businessError } = await supabase
.from("business")
.select("business_type")
.eq("id", project?.[0]?.business_id);
if (businessError) {
console.error(businessError);
}
// step 3: get business type from its id
let { data: business_type, error: businessTypeError } = await supabase
.from("business_type")
.select("value")
.eq("id", business?.[0]?.business_type);
if (businessTypeError) {
console.error(businessError);
}
return business_type;
}
// only use deal that were made at most year ago
export interface Deal {
created_time: string | number | Date;
deal_amount: any;
}
interface GraphData {
name: string;
value: number;
}
function overAllGraphData(deals: Deal[]): GraphData[] {
// Initialize all months with value 0
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const acc: GraphData[] = months.map((month) => ({ name: month, value: 0 }));
deals
.filter((item: Deal) => new Date(item.created_time) >= yearAgo(1))
.forEach((item: Deal) => {
const monthName = getMonthName(item.created_time.toString()).slice(0, 3);
const monthEntry = acc.find((entry) => entry.name === monthName);
if (monthEntry) {
monthEntry.value += item.deal_amount;
}
});
return acc;
}
function fourYearGraphData(deals: Deal[]): GraphData[] {
const currentYear = new Date().getFullYear();
const acc: GraphData[] = Array.from({ length: 4 }, (_, i) => ({
name: (currentYear - i).toString(),
value: 0,
})).reverse();
deals
.filter((item: Deal) => new Date(item.created_time) >= yearAgo(3))
.forEach((item: Deal) => {
const year = new Date(item.created_time).getFullYear().toString();
const yearEntry = acc.find((entry) => entry.name === year);
if (yearEntry) {
yearEntry.value += item.deal_amount;
}
});
return acc;
}
interface DayOfWeekData {
name: string;
value: number;
}
function dayOftheWeekData(deals: Deal[]): DayOfWeekData[] {
const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const dayOfWeekData: DayOfWeekData[] = daysOfWeek.map((day) => ({
name: day,
value: 0,
}));
deals
.filter((item: Deal) => new Date(item.created_time) >= yearAgo(1))
.forEach((item: Deal) => {
const day = getDayAbbreviation(item.created_time);
const dayEntry = dayOfWeekData.find((entry) => entry.name === day);
if (dayEntry) {
dayEntry.value += item.deal_amount;
}
});
return dayOfWeekData;
}
async function getInvestorProjectTag(supabase: SupabaseClient, deals: number | { project_id: number }[]) {
// get unique project id from deals
const uniqueProjectIds: number[] = Array.isArray(deals)
? Array.from(new Set(deals.map((deal: { project_id: number }) => deal.project_id)))
: [];
const tagIds = (
await Promise.all(
uniqueProjectIds.map(async (projectId: number) => {
const { data: tagIdsArray, error: tagError } = await getProjectTag(supabase, projectId);
if (tagError) {
console.error(tagError);
return [];
}
return tagIdsArray?.map((tag: { tag_id: any }) => tag.tag_id) || [];
})
)
).flat();
// console.log(tagIds, uniqueProjectIds);
const tagNames = await Promise.all(
tagIds
.filter((tagId) => tagId !== null)
.map(async (id: number) => {
const { data: tagName, error: nameError } = await getTagName(supabase, id);
if (nameError) {
console.error(nameError);
return null;
}
return tagName;
})
);
// console.log(tagNames);
return tagNames.filter((tagName) => tagName !== null);
}
const countTags = (tags: any[]) => {
const tagCounts = tags.flat().reduce(
(acc, tag) => {
const tagName = tag.value;
acc[tagName] = (acc[tagName] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
return Object.entries(tagCounts).map(([name, count]) => ({
name,
count: count as number,
}));
};
const getDayAbbreviation = (dateString: string | number | Date) => {
const date = new Date(dateString);
return date.toLocaleString("default", { weekday: "short" });
};
const yearAgo = (num: number) => {
const newDate = new Date();
newDate.setFullYear(newDate.getFullYear() - num);
return newDate;
};
const getMonthName = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString("default", { month: "long", year: "numeric" });
};
export {
overAllGraphData,
fourYearGraphData,
dayOftheWeekData,
getInvestorProjectTag,
countTags,
getBusinessTypeName,
countValues,
checkForInvest,
getLatestInvestment,
getTotalInvestment,
fetchLogoURL,
};

View File

@ -0,0 +1,145 @@
"use client";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { projectEditSchema, ProjectEditSchema } from "@/types/schemas/project.schema";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { DateTimePicker } from "@/components/ui/datetime-picker";
import { MdxEditor } from "@/components/MarkdownEditor";
import { editProjectById } from "@/lib/data/projectMutate";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useState } from "react";
export default function EditProjectForm({
projectData,
projectId,
}: {
projectData: ProjectEditSchema;
projectId: number;
}) {
const router = useRouter();
const client = createSupabaseClient();
const projectForm = useForm<ProjectEditSchema>({
resolver: zodResolver(projectEditSchema),
defaultValues: {
project_name: projectData.project_name || "",
project_short_description: projectData.project_short_description || "",
project_description: projectData.project_description || "",
deadline: projectData.deadline ? projectData.deadline : undefined,
},
});
const [deadline, setDeadline] = useState(projectData.deadline);
const [descriptionContent, setDescriptionContent] = useState(projectData.project_description || "");
const onSubmit: SubmitHandler<ProjectEditSchema> = async (updates) => {
try {
const updatedData = {
...updates,
deadline: deadline ? new Date(deadline).toISOString() : undefined,
project_description: descriptionContent,
};
const result = await editProjectById(client, projectId, updatedData);
if (result) {
toast.success("Project updated successfully!");
router.push(`/deals/${projectId}`);
} else {
toast.error("No fields to update!");
}
} catch (error) {
toast.error("Error updating project!");
console.error("Error updating project:", error);
}
};
return (
<Form {...projectForm}>
<form onSubmit={projectForm.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={projectForm.control}
name="project_name"
render={({ field }) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input placeholder="Project Name" {...field} value={field.value || ""} />
</FormControl>
<FormDescription>Provide the name of the project.</FormDescription>
<FormMessage>
{projectForm.formState.errors.project_name && (
<span>{projectForm.formState.errors.project_name.message}</span>
)}
</FormMessage>
</FormItem>
)}
/>
<FormField
control={projectForm.control}
name="project_short_description"
render={({ field }) => (
<FormItem>
<FormLabel>Short Description</FormLabel>
<FormControl>
<Textarea placeholder="Brief project overview" {...field} value={field.value || ""} />
</FormControl>
<FormDescription>A short summary of the project.</FormDescription>
<FormMessage>
{projectForm.formState.errors.project_short_description && (
<span>{projectForm.formState.errors.project_short_description.message}</span>
)}
</FormMessage>
</FormItem>
)}
/>
<FormField
control={projectForm.control}
name="project_description"
render={() => (
<FormItem>
<FormLabel>Full Description</FormLabel>
<FormControl>
<MdxEditor content={descriptionContent} setContentInParent={setDescriptionContent} />
</FormControl>
<FormDescription>Provide a detailed description of the project in Markdown format.</FormDescription>
<FormMessage>
{projectForm.formState.errors.project_description && (
<span>{projectForm.formState.errors.project_description.message}</span>
)}
</FormMessage>
</FormItem>
)}
/>
<FormField
control={projectForm.control}
name="deadline"
render={() => (
<FormItem className="w-1/4">
<FormLabel>Deadline</FormLabel>
<FormControl>
<DateTimePicker
hourCycle={24}
value={deadline ? new Date(deadline) : undefined}
onChange={(date) => setDeadline(date?.toISOString())}
/>
</FormControl>
<FormDescription>Specify the project deadline in a 24-hour format.</FormDescription>
<FormMessage>
{projectForm.formState.errors.deadline && <span>{projectForm.formState.errors.deadline.message}</span>}
</FormMessage>
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}

View File

@ -0,0 +1,54 @@
import React, { Suspense } from "react";
import EditProjectForm from "./EditProjectForm";
import { Separator } from "@/components/ui/separator";
import { getProjectDataQuery } from "@/lib/data/projectQuery";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { ProjectEditSchema } from "@/types/schemas/project.schema";
import { redirect } from "next/navigation";
import { LegacyLoader } from "@/components/loading/LegacyLoader";
export default async function EditProjectPage({ params }: { params: { projectId: string } }) {
const client = createSupabaseClient();
const projectId = Number(params.projectId);
// Check permission
const { data: user, error: userError } = await client.auth.getUser();
const uuid = user.user?.id;
const { data, error } = await client.from("project").select("...business(user_id)").eq("id", projectId).single();
if (userError || error) {
redirect("/");
}
if (data.user_id != uuid || data == null) {
redirect("/");
}
const { data: projectData, error: projectDataError } = await getProjectDataQuery(client, projectId);
if (projectDataError) {
console.error("Error fetching project data:", projectDataError);
throw projectDataError;
}
const mappedProjectData: ProjectEditSchema = {
project_name: projectData.project_name,
project_status_id: projectData.project_status_id,
project_type_id: projectData.project_type_id,
project_short_description: projectData.project_short_description,
project_description: projectData.project_description,
deadline: projectData.deadline ? new Date(projectData.deadline).toISOString() : undefined,
};
return (
<div className="container max-w-screen-xl">
<div className="my-5">
<span className="text-2xl font-bold">Edit Project</span>
<Separator className="my-5" />
</div>
<Suspense fallback={<LegacyLoader />}>
{projectData ? <EditProjectForm projectData={mappedProjectData} projectId={projectId} /> : <LegacyLoader />}
</Suspense>
</div>
);
}

View File

@ -1,35 +1,19 @@
import { useEffect, useState } from "react";
"use client";
import React, { useEffect, useState } from "react";
import { SubmitHandler, useForm, ControllerRenderProps } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { MultipleOptionSelector } from "@/components/multipleSelector";
import {
Form,
FormControl,
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 { projectFormSchema } from "@/types/schemas/application.schema";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Label } from "@/components/ui/label";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { Textarea } from "./ui/textarea";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { ChevronsUpDown, Check, X } from "lucide-react";
@ -39,30 +23,32 @@ type FieldType = ControllerRenderProps<any, "projectPhotos">;
interface ProjectFormProps {
onSubmit: SubmitHandler<projectSchema>;
}
const ProjectForm = ({
onSubmit,
}: ProjectFormProps & { onSubmit: SubmitHandler<projectSchema> }) => {
const ProjectForm = ({ onSubmit }: ProjectFormProps & { onSubmit: SubmitHandler<projectSchema> }) => {
const form = useForm<projectSchema>({
resolver: zodResolver(projectFormSchema),
defaultValues: {},
defaultValues: {
projectName: "",
projectType: undefined,
shortDescription: "",
projectPitchDeck: "",
projectLogo: undefined,
projectPhotos: [],
minInvest: undefined,
targetInvest: undefined,
deadline: new Date(),
tag: [],
},
});
let supabase = createSupabaseClient();
const [projectType, setProjectType] = useState<
{ id: number; name: string }[]
>([]);
const [projectType, setProjectType] = useState<{ id: number; name: string }[]>([]);
const [projectPitch, setProjectPitch] = useState("text");
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const [projectPitchFile, setProjectPitchFile] = useState("");
const [tag, setTag] = useState<{ id: number; value: string }[]>([]);
const [open, setOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<
{ id: number; value: string }[]
>([]);
const [selectedTag, setSelectedTag] = useState<{ id: number; value: string }[]>([]);
const handleFileChange = (
event: React.ChangeEvent<HTMLInputElement>,
field: FieldType
) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>, field: FieldType) => {
if (event.target.files) {
const filesArray = Array.from(event.target.files);
console.log("first file", filesArray);
@ -86,9 +72,7 @@ const ProjectForm = ({
};
const fetchProjectType = async () => {
let { data: ProjectType, error } = await supabase
.from("project_type")
.select("id, value");
let { data: ProjectType, error } = await supabase.from("project_type").select("id, value");
if (error) {
console.error(error);
@ -122,13 +106,12 @@ const ProjectForm = ({
useEffect(() => {
fetchProjectType();
fetchTag();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit as SubmitHandler<projectSchema>)}
className="space-y-8"
>
<form onSubmit={form.handleSubmit(onSubmit as SubmitHandler<projectSchema>)} className="space-y-8">
<div className="ml-96 space-y-10">
{/* project name */}
<FormField
@ -137,17 +120,10 @@ const ProjectForm = ({
render={({ field }: { field: any }) => (
<FormItem>
<div className="space-y-5">
<FormLabel className="font-bold text-lg">
Project name
</FormLabel>
<FormLabel className="font-bold text-lg">Project name</FormLabel>
<FormControl>
<div className="flex space-x-5">
<Input
type="text"
id="projectName"
className="w-96"
{...field}
/>
<Input type="text" id="projectName" className="w-96" {...field} />
</div>
</FormControl>
</div>
@ -169,9 +145,7 @@ const ProjectForm = ({
handleFunction={(selectedValues: any) => {
field.onChange(selectedValues.id);
}}
description={
<>Please specify the primary purpose of the funds</>
}
description={<>Please specify the primary purpose of the funds</>}
placeholder="Select a Project type"
selectLabel="Project type"
/>
@ -189,18 +163,11 @@ const ProjectForm = ({
<FormItem>
<FormControl>
<div className="mt-10 space-y-5">
<FormLabel className="font-bold text-lg">
Short description
</FormLabel>
<FormLabel className="font-bold text-lg">Short description</FormLabel>
<div className="flex space-x-5">
<Textarea
id="shortDescription"
className="w-96"
{...field}
/>
<Textarea id="shortDescription" className="w-96" {...field} />
<span className="text-[12px] text-neutral-500 self-center">
Could you provide a brief description of your project{" "}
<br /> in one or two sentences?
Could you provide a brief description of your project <br /> in one or two sentences?
</span>
</div>
</div>
@ -225,9 +192,7 @@ const ProjectForm = ({
<div className="flex space-x-2 w-96">
<Button
type="button"
variant={
projectPitch === "text" ? "default" : "outline"
}
variant={projectPitch === "text" ? "default" : "outline"}
onClick={() => setProjectPitch("text")}
className="w-32 h-12 text-base"
>
@ -235,9 +200,7 @@ const ProjectForm = ({
</Button>
<Button
type="button"
variant={
projectPitch === "file" ? "default" : "outline"
}
variant={projectPitch === "file" ? "default" : "outline"}
onClick={() => setProjectPitch("file")}
className="w-32 h-12 text-base"
>
@ -247,11 +210,7 @@ const ProjectForm = ({
<div className="flex space-x-5">
<Input
type={projectPitch === "file" ? "file" : "text"}
placeholder={
projectPitch === "file"
? "Upload your Markdown file"
: "https:// "
}
placeholder={projectPitch === "file" ? "Upload your Markdown file" : "https:// "}
accept={projectPitch === "file" ? ".md" : undefined}
onChange={(e) => {
const value = e.target;
@ -266,11 +225,9 @@ const ProjectForm = ({
/>
<span className="text-[12px] text-neutral-500 self-center">
Please upload a file or paste a link to your pitch,
which should <br />
cover key aspects of your project: what it will do,
what investors <br /> can expect to gain, and any
highlights that make it stand out.
Please upload a file or paste a link to your pitch, which should <br />
cover key aspects of your project: what it will do, what investors <br /> can expect to gain,
and any highlights that make it stand out.
</span>
</div>
{projectPitchFile && (
@ -302,23 +259,22 @@ const ProjectForm = ({
<FormItem>
<FormControl>
<div className="mt-10 space-y-5">
<FormLabel className="font-bold text-lg mt-10">
Project logo
</FormLabel>
<Input
type="file"
id="projectLogo"
className="w-96"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
field.onChange(file || "");
}}
/>
<span className="text-[12px] text-neutral-500 self-center">
Please upload the logo picture that best represents your
project.
</span>
<FormLabel className="font-bold text-lg mt-10">Project logo</FormLabel>
<div className="flex space-x-5">
<Input
type="file"
id="projectLogo"
className="w-96"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
field.onChange(file || "");
}}
/>
<span className="text-[12px] text-neutral-500 self-center">
Please upload the logo picture that best represents your project.
</span>
</div>
</div>
</FormControl>
<FormMessage />
@ -334,9 +290,7 @@ const ProjectForm = ({
<FormItem>
<FormControl>
<div className="mt-10 space-y-5">
<FormLabel className="font-bold text-lg mt-10">
Project photos
</FormLabel>
<FormLabel className="font-bold text-lg mt-10">Project photos</FormLabel>
<div className="flex space-x-5">
<Input
type="file"
@ -349,16 +303,15 @@ const ProjectForm = ({
}}
/>
<span className="text-[12px] text-neutral-500 self-center">
Please upload the logo picture that best represents your
project.
Please upload the photo that best represents your project.
<p className="text-red-500">
*** It is recommended that the photo be horizontal for better presentation.
</p>
</span>
</div>
<div className="mt-5 space-y-2 w-96">
{selectedImages.map((image, index) => (
<div
key={index}
className="flex justify-between items-center border p-2 rounded"
>
<div key={index} className="flex justify-between items-center border p-2 rounded">
<span>{image.name}</span>
<Button
variant="outline"
@ -385,9 +338,7 @@ const ProjectForm = ({
render={({ field }: { field: any }) => (
<FormItem>
<div className="mt-10 space-y-5">
<FormLabel className="font-bold text-lg">
Minimum investment
</FormLabel>
<FormLabel className="font-bold text-lg">Minimum investment</FormLabel>
<FormControl>
<div className="flex space-x-5">
<Input
@ -419,9 +370,7 @@ const ProjectForm = ({
render={({ field }: { field: any }) => (
<FormItem>
<div className="mt-10 space-y-5">
<FormLabel className="font-bold text-lg">
Target investment
</FormLabel>
<FormLabel className="font-bold text-lg">Target investment</FormLabel>
<FormControl>
<div className="flex space-x-5">
<Input
@ -437,8 +386,8 @@ const ProjectForm = ({
value={field.value}
/>
<span className="text-[12px] text-neutral-500 self-center">
We encourage you to set a specific target investment{" "}
<br /> amount that reflects your funding goals.
We encourage you to set a specific target investment <br /> amount that reflects your funding
goals.
</span>
</div>
</FormControl>
@ -457,16 +406,10 @@ const ProjectForm = ({
<FormLabel className="font-bold text-lg">Deadline</FormLabel>
<FormControl>
<div className="flex space-x-5">
<Input
type="datetime-local"
id="deadline"
className="w-96"
{...field}
/>
<Input type="datetime-local" id="deadline" className="w-96" {...field} />
<span className="text-[12px] text-neutral-500 self-center">
What is the deadline for your fundraising project?
Setting <br /> a clear timeline can help motivate
potential investors.
What is the deadline for your fundraising project? Setting <br /> a clear timeline can help
motivate potential investors.
</span>
</div>
</FormControl>
@ -493,9 +436,7 @@ const ProjectForm = ({
aria-expanded={open}
className="w-96 justify-between overflow-hidden text-ellipsis whitespace-nowrap"
>
{selectedTag.length > 0
? selectedTag.map((t) => t.value).join(", ")
: "Select tags..."}
{selectedTag.length > 0 ? selectedTag.map((t) => t.value).join(", ") : "Select tags..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -511,15 +452,11 @@ const ProjectForm = ({
value={tag.value}
onSelect={() => {
setSelectedTag((prev) => {
const exists = prev.find(
(t) => t.id === tag.id
);
const exists = prev.find((t) => t.id === tag.id);
const updatedTags = exists
? prev.filter((t) => t.id !== tag.id)
: [...prev, tag];
field.onChange(
updatedTags.map((t) => t.id)
);
field.onChange(updatedTags.map((t) => t.id));
return updatedTags;
});
setOpen(false);
@ -528,9 +465,7 @@ const ProjectForm = ({
<Check
className={cn(
"h-4",
selectedTag.some((t) => t.id === tag.id)
? "opacity-100"
: "opacity-0"
selectedTag.some((t) => t.id === tag.id) ? "opacity-100" : "opacity-0"
)}
/>
{tag.value}
@ -542,8 +477,7 @@ const ProjectForm = ({
</PopoverContent>
</Popover>
<span className="text-[12px] text-neutral-500 self-center">
Add 1 to 5 tags that describe your project. Tags help{" "}
<br />
Add 1 to 5 tags that describe your project. Tags help <br />
investors understand your focus.
</span>
</div>
@ -561,9 +495,7 @@ const ProjectForm = ({
<button
onClick={() => {
setSelectedTag((prev) => {
const updatedTags = prev.filter(
(t) => t.id !== tag.id
);
const updatedTags = prev.filter((t) => t.id !== tag.id);
field.onChange(updatedTags.map((t) => t.id));
return updatedTags;
});
@ -579,10 +511,7 @@ const ProjectForm = ({
/>
</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

@ -0,0 +1,20 @@
import Swal from "sweetalert2";
import toast from "react-hot-toast";
export const displayAlert = (error: any) => {
Swal.fire({
icon: error == null ? "success" : "error",
title: error == null ? "Success" : `Error: ${error.code}`,
text: error == null ? "Your application has been submitted" : error.message,
confirmButtonColor: error == null ? "green" : "red",
allowOutsideClick: false,
}).then((result) => {
if (result.isConfirmed) {
if (error) {
toast.error("Error sending Project Application");
} else {
toast.success("Your application has been submitted!");
}
}
});
};

View File

@ -0,0 +1,6 @@
import { uploadFile } from "@/app/api/generalApi";
export const uploadFiles = async (files: File[], path: string) => {
const results = await Promise.all(files.map((file) => uploadFile(file, "project-application", path + file.name)));
return results;
};

View File

@ -1,225 +1,55 @@
"use client";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import ProjectForm from "@/components/ProjectForm";
import { projectFormSchema } from "@/types/schemas/application.schema";
import { z } from "zod";
import { SubmitHandler } from "react-hook-form";
import Swal from "sweetalert2";
import { uploadFile } from "@/app/api/generalApi";
import { Loader } from "@/components/loading/loader";
import { useState } from "react";
import { errors } from "@playwright/test";
type projectSchema = z.infer<typeof projectFormSchema>;
let supabase = createSupabaseClient();
const BUCKET_PITCH_APPLICATION_NAME = "project-application";
import { saveApplicationData, saveTags } from "./projectService";
import { uploadFiles } from "./fileUploadService";
import { displayAlert } from "./displayAlert";
import ProjectForm from "./ProjectForm";
import { LegacyLoader } from "@/components/loading/LegacyLoader";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { useUserRole } from "@/hooks/useUserRole";
export default function ApplyProject() {
const [isSuccess, setIsSuccess] = useState(true);
const onSubmit: SubmitHandler<projectSchema> = async (data) => {
// alert("มาแน้ววว");
await sendApplication(data);
// console.table(data);
// console.log(typeof data["projectPhotos"], data["projectPhotos"]);
};
const saveApplicationData = async (recvData: any, userId: string) => {
const pitchType = typeof recvData["projectPitchDeck"];
const { data: projectData, error: projectError } = await supabase
.from("project_application")
.insert([
{
user_id: userId,
pitch_deck_url: pitchType === "string" ? recvData["projectPitchDeck"] : "",
target_investment: recvData["targetInvest"],
deadline: recvData["deadline"],
project_name: recvData["projectName"],
project_type_id: recvData["projectType"],
short_description: recvData["shortDescription"],
min_investment: recvData["minInvest"],
},
])
.select();
const router = useRouter();
const { data, session, isLoading, error } = useUserRole();
const role: string = data?.role;
return { projectId: projectData?.[0]?.id, error: projectError };
};
const saveTags = async (tags: string[], projectId: string) => {
const tagPromises = tags.map(async (tag) => {
const response = await supabase
.from("project_application_tag")
.insert([{ tag_id: tag, item_id: projectId }])
.select();
if (isLoading || !session) {
return <LegacyLoader />;
}
// console.log("Insert response for tag:", tag, response);
const userId = session!.user.id;
return response;
});
if (error) {
throw error;
}
const results = await Promise.all(tagPromises);
if (role != "business") {
router.push("/business/apply");
toast.error("Please apply to raise on B2DVentures first!");
return;
}
// Collect errors
const errors = results.filter((result) => result.error).map((result) => result.error);
return { errors };
};
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}`);
};
const uploadLogoAndPhotos = async (logoFile: File, photos: File[], userId: string, projectId: string) => {
const uploadResults: { logo?: any; photos: any[] } = { photos: [] };
// upload logo
if (logoFile) {
const logoResult = await uploadFile(
logoFile,
BUCKET_PITCH_APPLICATION_NAME,
`${userId}/${projectId}/logo/${logoFile.name}`
);
if (!logoResult.success) {
console.error("Error uploading logo:", logoResult.errors);
return { success: false, logo: logoResult, photos: [] };
const sendApplication = async (data: any) => {
try {
const { projectId, error } = await saveApplicationData(data, userId);
if (error) {
displayAlert(error);
return;
}
uploadResults.logo = logoResult;
}
// upload each photo
const uploadPhotoPromises = photos.map((image) =>
uploadFile(image, BUCKET_PITCH_APPLICATION_NAME, `${userId}/${projectId}/photos/${image.name}`)
);
const photoResults = await Promise.all(uploadPhotoPromises);
uploadResults.photos = photoResults;
// check if all uploads were successful
const allUploadsSuccessful = photoResults.every((result) => result.success);
return {
success: allUploadsSuccessful,
logo: uploadResults.logo,
photos: uploadResults.photos,
};
};
const displayAlert = (error: any) => {
Swal.fire({
icon: error == null ? "success" : "error",
title: error == null ? "Success" : `Error: ${error.code}`,
text: error == null ? "Your application has been submitted" : error.message,
confirmButtonColor: error == null ? "green" : "red",
}).then((result) => {
if (result.isConfirmed) {
// window.location.href = "/";
}
});
};
const sendApplication = async (recvData: any) => {
setIsSuccess(false);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user?.id) {
console.error("User ID is undefined.");
return;
}
// save application data
const { projectId, error } = await saveApplicationData(recvData, user.id);
if (error) {
await saveTags(data["tag"], projectId);
await uploadFiles(data["projectPhotos"], `${userId}/${projectId}/photos/`);
displayAlert(null);
router.push("/");
} catch (error) {
displayAlert(error);
return;
}
const tagError = await saveTags(recvData["tag"], projectId);
// if (tagError) {
// displayAlert(tagError);
// return;
// }
// upload pitch file if its a file
if (typeof recvData["projectPitchDeck"] === "object") {
const uploadPitchSuccess = await uploadPitchFile(recvData["projectPitchDeck"], user.id, projectId);
if (!uploadPitchSuccess) {
console.error("Error uploading pitch file.");
} else {
console.log("Pitch file uploaded successfully.");
}
}
// upload logo and photos
const { success, logo, photos } = await uploadLogoAndPhotos(
recvData["projectLogo"],
recvData["projectPhotos"],
user.id,
projectId
);
if (!success) {
console.error("Error uploading media files.");
}
// console.log("Bucket Name:", BUCKET_PITCH_APPLICATION_NAME);
// console.log("Logo Path:", logo.data.path);
// console.table(photos);
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);
if (photoURL?.signedUrl) {
photoURLsArray.push(photoURL.signedUrl);
} else {
console.error("Signed URL for photo is undefined.");
}
}
);
await Promise.all(photoURLPromises);
// console.log(logoURL.publicUrl, projectId, logo.data.path);
// console.log(logoURL?.signedUrl, projectId);
// console.log(photoURLsArray[0], photoURLsArray[1]);
if (logoURL?.signedUrl) {
await updateImageURL(logoURL.signedUrl, "project_logo", projectId);
} else {
console.error("Signed URL for logo is undefined.");
}
await updateImageURL(photoURLsArray, "project_photos", projectId);
// console.log(logoURL, photosUrl);
setIsSuccess(true);
displayAlert(error);
};
const updateImageURL = async (url: string | string[], columnName: string, projectId: number) => {
const { error } = await supabase
.from("project_application")
.update({ [columnName]: url })
.eq("id", projectId);
// console.log(
// `Updating ${columnName} with URL: ${url} for project ID: ${projectId}`
// );
if (error) {
console.error(error);
}
};
const getPrivateURL = async (path: string, bucketName: string) => {
const { data } = await supabase.storage.from(bucketName).createSignedUrl(path, 9999999999999999999999999999);
// console.table(data);
return data;
};
return (
<div>
<Loader isSuccess={isSuccess} />
<div className="grid grid-flow-row auto-rows-max w-full h-52 md:h-92 bg-gray-2s00 dark:bg-gray-800 p-5">
<div className="grid grid-flow-row auto-rows-max w-full h-52 md:h-92 bg-gray-200 dark:bg-gray-800 p-5">
<h1 className="text-2xl md:text-5xl font-medium md:font-bold justify-self-center md:mt-8">
Apply to raise on B2DVentures
</h1>
@ -233,7 +63,7 @@ export default function ApplyProject() {
</div>
</div>
<div className="grid auto-rows-max bg-zinc-100 dark:bg-zinc-900 pt-12 -mb-6">
<ProjectForm onSubmit={onSubmit} />
<ProjectForm onSubmit={sendApplication} />
</div>
</div>
);

View File

@ -0,0 +1,38 @@
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
const supabase = createSupabaseClient();
export const saveApplicationData = async (data: any, userId: string) => {
const pitchType = typeof data["projectPitchDeck"];
const { data: projectData, error } = await supabase
.from("project_application")
.insert([
{
user_id: userId,
pitch_deck_url: pitchType === "string" ? data["projectPitchDeck"] : "",
target_investment: data["targetInvest"],
deadline: data["deadline"],
project_name: data["projectName"],
project_type_id: data["projectType"],
short_description: data["shortDescription"],
min_investment: data["minInvest"],
},
])
.select();
return { projectId: projectData?.[0]?.id, error };
};
export const saveTags = async (tags: string[], projectId: string) => {
const tagPromises = tags.map(async (tag) => {
const response = await supabase
.from("project_application_tag")
.insert([{ tag_id: tag, item_id: projectId }])
.select();
return response;
});
const results = await Promise.all(tagPromises);
const errors = results.filter((result) => result.error).map((result) => result.error);
return { errors };
};

43
src/app/verify/page.tsx Normal file
View File

@ -0,0 +1,43 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { useSearchParams } from "next/navigation";
import { Mail } from "lucide-react";
import Link from "next/link";
const VerifyPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email");
useEffect(() => {
if (!email) {
router.push("/");
}
}, [email, router]);
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="flex justify-center mb-4">
<Mail className="text-blue-600 dark:text-blue-400 w-10 h-10" />
</div>
<h2 className="text-2xl font-semibold text-center text-blue-600 dark:text-blue-400 mb-4">Check Your Email</h2>
<p className="text-gray-600 dark:text-gray-300 text-center mb-4">
We have sent a verification link to <strong>{email}</strong>. Please check your inbox (and spam folder) to
confirm your email address.
</p>
<p className="text-sm text-center text-gray-500 dark:text-gray-400">
If you did not receive the email, click below to contact support.
</p>
<Link href="mailto:b2d.ventures.contact@gmail.com">
<Button className="w-full mt-4">Contact Support</Button>
</Link>
</div>
</div>
);
};
export default VerifyPage;

View File

@ -0,0 +1,101 @@
"use client";
import "@mdxeditor/editor/style.css";
import React, { useEffect, useRef, useState } from "react";
import {
MDXEditor,
MDXEditorMethods,
codeBlockPlugin,
codeMirrorPlugin,
frontmatterPlugin,
headingsPlugin,
linkPlugin,
listsPlugin,
markdownShortcutPlugin,
quotePlugin,
tablePlugin,
thematicBreakPlugin,
toolbarPlugin,
UndoRedo,
BoldItalicUnderlineToggles,
InsertTable,
InsertCodeBlock,
} from "@mdxeditor/editor";
export interface MdxEditorProps {
content: string;
// eslint-disable-next-line no-unused-vars
setContentInParent: (content: string) => void;
// eslint-disable-next-line no-unused-vars
setEditorErrorInParent?: (error: { error: string; source: string }) => void;
}
export const MdxEditor: React.FC<MdxEditorProps> = ({ content, setContentInParent, setEditorErrorInParent }) => {
const ref = useRef<MDXEditorMethods>(null);
const [markdownContent, setMarkdownContent] = useState<string>(content);
useEffect(() => {
setMarkdownContent(content);
}, [content]);
useEffect(() => {
setContentInParent(markdownContent);
}, [markdownContent, setContentInParent]);
const handleDivClick = () => {
if (ref.current) {
ref.current.focus();
}
};
return (
<div className="h-full overflow-y-auto w-full cursor-text bg-slate-800" onClick={handleDivClick}>
<MDXEditor
ref={ref}
className="dark-theme dark-editor w-full h-48 border rounded-md p-2 overflow-auto resize-none"
onChange={(markdownContent) => {
setMarkdownContent(markdownContent);
}}
markdown={markdownContent}
contentEditableClassName="prose prose-invert"
suppressHtmlProcessing={true}
onError={(error) => {
console.error("MDXEditor error:", error);
if (setEditorErrorInParent) {
setEditorErrorInParent({ error: error.error, source: "MDXEditor" });
}
}}
plugins={[
toolbarPlugin({
toolbarClassName: "my-toolbar",
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<InsertTable />
<InsertCodeBlock />
</>
),
}),
listsPlugin(),
quotePlugin(),
headingsPlugin({ allowedHeadingLevels: [1, 2, 3] }),
linkPlugin(),
tablePlugin(),
thematicBreakPlugin(),
frontmatterPlugin(),
codeBlockPlugin({ defaultCodeBlockLanguage: "txt" }),
codeMirrorPlugin({
codeBlockLanguages: {
js: "JavaScript",
css: "CSS",
txt: "text",
tsx: "TypeScript",
},
}),
markdownShortcutPlugin(),
]}
/>
</div>
);
};

View File

@ -0,0 +1,44 @@
import React from "react";
import { ProjectCard } from "@/components/projectCard";
import { Separator } from "@/components/ui/separator";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ProjectCardProps } from "@/types/ProjectCard";
import Link from "next/link";
export function ProjectSection({ projectsData }: { projectsData: ProjectCardProps[] | null }) {
if (!projectsData || projectsData.length === 0) {
return (
<Card className="text-center">
<CardHeader>
<CardTitle>No Project Found</CardTitle>
</CardHeader>
<CardContent>
<p>Sorry, we could not find any projects.</p>
</CardContent>
</Card>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-6">
{projectsData.map((project) => (
<div key={project.id}>
<Link href={`/deals/${project.id}`}>
<ProjectCard
name={project.project_name}
description={project.short_description}
imageUri={project.image_url}
joinDate={new Date(project.join_date).toLocaleDateString()}
location={project.location}
tags={project.tags}
minInvestment={project.min_investment}
totalInvestor={project.total_investor}
totalRaised={project.total_raise}
/>
</Link>
<Separator />
</div>
))}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
"use client";
import Lottie from "react-lottie";
import * as alertData from "./alert.json";
const alertOption = {
loop: true,
autoplay: true,
animationData: alertData,
rendererSettings: {
preserveAspectRatio: "xMidYMid slice",
},
};
export function NoDataAlert() {
return (
<div>
<Lottie options={alertOption} height={"200"} width={"200"} />
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
"use client";
import Lottie from "react-lottie";
import * as alertData from "./alert.json";
const alertOption = {
loop: true,
autoplay: true,
animationData: alertData,
rendererSettings: {
preserveAspectRatio: "xMidYMid slice",
},
};
export function UnAuthorizedAlert() {
return (
<div className="fixed inset-0 flex items-center justify-center bg-black mt-24 z-50">
<Lottie options={alertOption} style={{ width: "50%", height: "auto" }} />
</div>
);
}

View File

@ -0,0 +1,57 @@
"use server";
import { revalidatePath } from "next/cache";
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { redirect } from "next/navigation";
export async function login(formData: FormData) {
const supabase = await createSupabaseClient();
const data = {
email: formData.get("email") as string,
password: formData.get("password") as string,
options: {
captchaToken: formData.get("captchaToken") as string,
},
};
const { error } = await supabase.auth.signInWithPassword(data);
if (error) {
throw error;
}
revalidatePath("/", "layout");
redirect("/");
}
export async function signup(formData: FormData) {
const supabase = await createSupabaseClient();
const data = {
email: formData.get("email") as string,
password: formData.get("password") as string,
options: {
captchaToken: formData.get("captchaToken") as string,
emailRedirectTo: "http://localhost:3000/auth",
},
};
const { error } = await supabase.auth.signUp(data);
if (error) {
throw error;
}
revalidatePath("/", "layout");
redirect("/");
}
export async function logout() {
const supabase = await createSupabaseClient();
const { error } = await supabase.auth.signOut();
if (error) {
throw new Error("Logout failed: " + error.message);
}
}

View File

@ -12,6 +12,7 @@ export function LoginButton(props: { nextUrl?: string }) {
provider: "google",
options: {
redirectTo: `${location.origin}/auth/callback?next=${props.nextUrl || ""}`,
scopes: "https://www.googleapis.com/auth/calendar",
},
});
};

View File

@ -1,40 +1,85 @@
"use client";
import React from "react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useState } from "react";
import React, { useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { login } from "./action";
import { LoginFormSchema } from "@/types/schemas/authentication.schema";
import toast from "react-hot-toast";
import HCaptcha from "@hcaptcha/react-hcaptcha";
export function LoginForm() {
const router = useRouter();
const supabase = createSupabaseClient();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState<{ email?: string; password?: string; server?: string }>({});
const [captchaToken, setCaptchaToken] = useState<string | undefined>(undefined);
const captcha = useRef<HCaptcha | null>(null);
const handleLogin = async (event: React.MouseEvent<HTMLButtonElement>) => {
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await supabase.auth.signInWithPassword({
email,
password,
});
router.push("/");
const formData = { email, password, options: { captchaToken } };
const result = LoginFormSchema.safeParse(formData);
if (!result.success) {
const formErrors: { email?: string; password?: string } = {};
result.error.errors.forEach((error) => {
formErrors[error.path[0] as keyof typeof formErrors] = error.message;
});
setErrors(formErrors);
return;
}
setErrors({});
const form = new FormData();
form.set("email", email);
form.set("password", password);
if (captchaToken) {
form.set("captchaToken", captchaToken);
}
try {
await login(form);
captcha.current?.resetCaptcha();
toast.success("Login succesfully!");
} catch (authError: any) {
captcha.current?.resetCaptcha();
setErrors((prevErrors) => ({
...prevErrors,
server: authError.message || "An error occurred during login.",
}));
}
};
return (
<div className="flex flex-col space-y-2">
<Input id="email" type="text" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
<form onSubmit={handleSubmit} className="flex flex-col space-y-2">
<div>
<Input id="email" type="text" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
{errors.email && <p className="text-red-600">{errors.email}</p>}
</div>
<div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{errors.password && <p className="text-red-600">{errors.password}</p>}
</div>
<HCaptcha
ref={captcha}
sitekey={process.env.NEXT_PUBLIC_SITEKEY!}
onVerify={(token) => {
setCaptchaToken(token);
}}
/>
<Button id="login" onClick={handleLogin}>
{errors.server && <p className="text-red-600">{errors.server}</p>}
<Button id="login" type="submit">
Login
</Button>
</div>
</form>
);
}

View File

@ -1,24 +1,30 @@
"use client";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { logout } from "./action"; // Adjust the import path accordingly
import { usePathname, useRouter } from "next/navigation";
import toast from "react-hot-toast";
export function LogoutButton() {
const supabase = createSupabaseClient();
const pathname = usePathname();
const router = useRouter();
const handleLogout = async () => {
await supabase.auth.signOut();
if (pathname === "/") {
window.location.reload();
} else {
await router.push("/");
window.location.reload();
try {
await logout();
if (pathname === "/") {
window.location.reload();
} else {
await router.push("/");
window.location.reload();
}
} catch (error: any) {
toast.error(error.message || "An error occurred during logout.");
}
};
return <button onClick={handleLogout}>Logout</button>;
return (
<div>
<button onClick={handleLogout}>Logout</button>
</div>
);
}

View File

@ -12,6 +12,7 @@ export function SignupButton(props: { nextUrl?: string }) {
provider: "google",
options: {
redirectTo: `${location.origin}/auth/callback?next=${props.nextUrl || ""}`,
scopes: "https://www.googleapis.com/auth/calendar",
},
});
};

View File

@ -1,50 +1,78 @@
"use client";
import React from "react";
import { createSupabaseClient } from "@/lib/supabase/clientComponentClient";
import { useState } from "react";
import React, { useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { signup } from "./action";
import { signupSchema } from "@/types/schemas/authentication.schema";
import HCaptcha from "@hcaptcha/react-hcaptcha";
export function SignupForm() {
const router = useRouter();
const supabase = createSupabaseClient();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [captchaToken, setCaptchaToken] = useState<string | undefined>(undefined);
const [isSendingForm, setIsSendingForm] = useState(false);
const captcha = useRef<HCaptcha | null>(null);
const handleSignup = async (event: React.MouseEvent<HTMLButtonElement>) => {
const handleSignup = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (password !== confirmPassword) {
alert("Passwords do not match!");
const parsedData = signupSchema.safeParse({
email,
password,
confirmPassword,
});
if (!parsedData.success) {
setError(parsedData.error.errors[0].message);
return;
}
const { error } = await supabase.auth.signUp({
email,
password,
});
const formData = new FormData();
formData.set("email", email);
formData.set("password", password);
formData.set("confirmPassword", confirmPassword);
if (error) {
toast.error(error.message);
} else {
if (captchaToken) {
formData.set("captchaToken", captchaToken);
}
try {
setIsSendingForm(true);
await signup(formData);
captcha.current?.resetCaptcha();
toast.success("Account created successfully!");
router.push("/");
router.push(`/verify?email=${formData.get("email") as string}`);
} catch (error: any) {
captcha.current?.resetCaptcha();
setError(error.message);
} finally {
setIsSendingForm(false);
}
};
return (
<div className="flex flex-col space-y-2">
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
<form onSubmit={handleSignup} className="flex flex-col space-y-2">
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<Input
id="confirmPassword"
@ -52,10 +80,19 @@ export function SignupForm() {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm Password"
required
/>
<Button id="signup" onClick={handleSignup}>
Sign Up
<HCaptcha
ref={captcha}
sitekey={process.env.NEXT_PUBLIC_SITEKEY!}
onVerify={(token) => {
setCaptchaToken(token);
}}
/>
{error && <p className="text-red-600">{error}</p>}
<Button id="signup" type="submit" disabled={isSendingForm}>
{isSendingForm ? "Sending" : "Sign Up"}
</Button>
</div>
</form>
);
}

View File

@ -1,53 +1,34 @@
import Image from "next/image";
import {
Card,
CardFooter,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardFooter, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { CalendarDaysIcon } from "lucide-react";
interface XMap {
// tagName: colorCode
[tag: string]: string;
}
interface BusinessCardProps {
name: string;
description: string;
joinDate: string;
location: string;
tags: XMap | null;
}
import { BusinessCardProps } from "@/types/BusinessCard";
import Image from "next/image";
export function BusinessCard(props: BusinessCardProps) {
return (
<Card>
<CardHeader>
<div className="h-[200px] hover:h-[100px] duration-75 pb-2">
<Card className="rounded-xl border border-gray-200 dark:border-gray-700 hover:shadow-xl transition-shadow h-full">
<CardHeader className="flex flex-row items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-800">
<div className="relative w-14 h-14 flex-shrink-0">
<Image
src={"/money.png"}
width={0}
height={0}
sizes="100vw"
style={{ width: "100%", height: "100%" }}
alt="nvidia"
src="/money.png"
alt="Business logo"
fill
className="rounded-full object-cover bg-white dark:bg-sky-900"
/>
</div>
<CardTitle>{props.name}</CardTitle>
<CardDescription>
{props.description}
<span className="flex items-center pt-2 gap-1">
<CalendarDaysIcon width={20} />
Joined {props.joinDate}
</span>
</CardDescription>
<div>
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-white">{props.business_name}</CardTitle>
<CardDescription className="text-sm text-gray-600 dark:text-gray-400 mt-1">
<span className="flex items-center gap-1">
<CalendarDaysIcon width={16} />
Joined {props.joined_date}
</span>
</CardDescription>
</div>
</CardHeader>
<CardFooter className="flex-col items-start">
{props.location}
<span className="text-xs rounded-md bg-slate-200 dark:bg-slate-700 p-1">
Technology
<CardFooter className="flex flex-col gap-2 pt-4">
<span className="text-sm text-gray-700 dark:text-gray-300">{props.location}</span>
<span className="text-xs font-medium rounded-md bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1">
{props.business_type}
</span>
</CardFooter>
</Card>

113
src/components/carousel.tsx Normal file
View File

@ -0,0 +1,113 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from "./ui/carousel";
import Image from "next/image";
interface GalleryProps {
images: { src: string }[];
}
const Gallery = ({ images }: GalleryProps) => {
const [mainApi, setMainApi] = useState<CarouselApi | null>(null);
const [thumbnailApi, setThumbnailApi] = useState<CarouselApi | null>(null);
const [current, setCurrent] = useState(0);
const [isReady, setIsReady] = useState(false);
const syncCarousels = useCallback(
(index: number) => {
if (mainApi && thumbnailApi) {
setCurrent(index);
mainApi.scrollTo(index);
thumbnailApi.scrollTo(index);
}
},
[mainApi, thumbnailApi]
);
const handleClick = useCallback(
(index: number) => {
syncCarousels(index);
},
[syncCarousels]
);
const mainImage = useMemo(
() =>
images.map((image, index) => (
<CarouselItem key={index} className="relative aspect-video w-full border-8 border-b">
<Image
src={image.src}
alt={`Carousel Main Image ${index + 1}`}
fill
style={{ objectFit: "contain" }}
priority={index === 0}
/>
</CarouselItem>
)),
[images]
);
const thumbnailImages = useMemo(
() =>
images.map((image, index) => (
<CarouselItem
key={index}
className="relative aspect-square basis-1/4 cursor-pointer"
onClick={() => handleClick(index)}
>
<Image
className={`transition-all duration-200 ${index === current ? "border-2 border-primary" : ""}`}
src={image.src}
fill
alt={`Carousel Thumbnail Image ${index + 1}`}
style={{ objectFit: "contain" }}
priority={index === 0}
/>
</CarouselItem>
)),
[images, current, handleClick]
);
useEffect(() => {
if (!mainApi || !thumbnailApi) return;
if (isReady) return;
const handleMainSelect = () => {
const selected = mainApi.selectedScrollSnap();
if (selected !== current) {
syncCarousels(selected);
}
};
const handleThumbnailSelect = () => {
const selected = thumbnailApi.selectedScrollSnap();
if (selected !== current) {
syncCarousels(selected);
}
};
mainApi.on("select", handleMainSelect);
thumbnailApi.on("select", handleThumbnailSelect);
syncCarousels(0);
setIsReady(true);
return () => {
mainApi.off("select", handleMainSelect);
thumbnailApi.off("select", handleThumbnailSelect);
};
}, [mainApi, thumbnailApi, current, syncCarousels, isReady]);
return (
<div className="w-full max-w-xl sm:w-auto">
<Carousel setApi={setMainApi} className="mb-2">
<CarouselContent className="m-1">{mainImage}</CarouselContent>
</Carousel>
<Carousel setApi={setThumbnailApi} className="cursor-pointer">
<CarouselContent className="m-1 h-16">{thumbnailImages}</CarouselContent>
</Carousel>
</div>
);
};
export default Gallery;

View File

@ -0,0 +1,19 @@
"use client";
import CountUp from "react-countup";
interface CountUpComponentProps {
end: number;
duration: number;
}
export default function CountUpComponent(props: CountUpComponentProps) {
return (
<>
<CountUp
end={props.end}
duration={props.duration}
start={props.end / 2}
/>
</>
);
}

View File

@ -0,0 +1,22 @@
import React from "react";
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./ui/tooltip";
interface CustomTooltipProps {
message: string;
children: React.ReactNode;
}
const CustomTooltip: React.FC<CustomTooltipProps> = ({ message, children }) => {
return (
<TooltipProvider delayDuration={0.5}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>
<p>{message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default CustomTooltip;

View File

@ -0,0 +1,293 @@
"use client";
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ArrowUpDown, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
export type ModalProps = {
date: Date;
amount: number;
name: string;
investorId?: string;
profileURL?: string;
logoURL?: string;
status?: string;
};
export const columns: ColumnDef<ModalProps>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Name
<ArrowUpDown />
</Button>
);
},
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
className="bg-transparent hover:bg-transparent text-current"
onClick={() => {
window.location.href = row.getValue("profileURL");
}}
>
<Avatar className="h-9 w-9">
<AvatarImage src={row.getValue("logoURL")} />
<AvatarFallback>{(row.getValue("name") as string).slice(0, 2)}</AvatarFallback>
</Avatar>
<span className="ml-2">{row.getValue("name")}</span>
</Button>
</div>
),
},
{
accessorKey: "date",
header: () => <div className="text-left">Date</div>,
cell: ({ row }) => {
const formatted = new Date(row.getValue("date")).toUTCString();
return <div className=" font-medium">{formatted}</div>;
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<div className="flex items-center space-x-1">
<span className="relative flex h-3 w-3">
<span
className={`animate-ping absolute inline-flex h-3 w-3 rounded-full opacity-75 ${
row.getValue("status") === "In Progress"
? "bg-sky-400"
: row.getValue("status") === "Completed"
? "bg-green-400"
: "bg-yellow-400"
}`}
></span>
<span
className={`relative inline-flex rounded-full h-2 w-2 mt-[2px] ml-0.5 ${
row.getValue("status") === "In Progress"
? "bg-sky-500"
: row.getValue("status") === "Completed"
? "bg-green-500"
: "bg-yellow-500"
}`}
></span>
</span>
<p
className={`text-xs m-0 ${
row.getValue("status") === "In Progress"
? "text-sky-500"
: row.getValue("status") === "Completed"
? "text-green-500"
: "text-yellow-500"
}`}
></p>
<div
className={`capitalize ${
row.getValue("status") === "In Progress"
? "text-sky-500"
: row.getValue("status") === "Completed"
? "text-green-500"
: "text-yellow-500"
}`}
>
{row.getValue("status")}
</div>
</div>
),
},
{
accessorKey: "amount",
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"));
// Format the amount as a dollar amount
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
return <div className="text-right font-medium">{formatted}</div>;
},
},
{
accessorKey: "logoURL",
id: "logoURL",
header: () => null,
cell: () => null,
},
{
accessorKey: "profileURL",
id: "profileURL",
header: () => null,
cell: () => null,
},
];
export function DataTable({ data }: { data: ModalProps[] }) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 5,
});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
},
});
return (
<div className="w-3/4 md:w-full">
<div className="flex items-center py-4">
<Input
placeholder="Filter names..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
className="md:max-w-sm max-w-xs"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Columns <ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide() && column.id != "logoURL" && column.id != "profileURL")
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
export default function QuestionMarkIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="18px"
width="18px"
version="1.1"
id="_x32_"
viewBox="0 0 512 512"
fill="currentColor"
>
<g>
<path d="M256,0C114.616,0,0,114.612,0,256s114.616,256,256,256s256-114.612,256-256S397.385,0,256,0z M207.678,378.794 c0-17.612,14.281-31.893,31.893-31.893c17.599,0,31.88,14.281,31.88,31.893c0,17.595-14.281,31.884-31.88,31.884 C221.959,410.678,207.678,396.389,207.678,378.794z M343.625,218.852c-3.596,9.793-8.802,18.289-14.695,25.356 c-11.847,14.148-25.888,22.718-37.442,29.041c-7.719,4.174-14.533,7.389-18.769,9.769c-2.905,1.604-4.479,2.95-5.256,3.826 c-0.768,0.926-1.029,1.306-1.496,2.826c-0.273,1.009-0.558,2.612-0.558,5.091c0,6.868,0,12.512,0,12.512 c0,6.472-5.248,11.728-11.723,11.728h-28.252c-6.475,0-11.732-5.256-11.732-11.728c0,0,0-5.645,0-12.512 c0-6.438,0.752-12.744,2.405-18.777c1.636-6.008,4.215-11.718,7.508-16.694c6.599-10.083,15.542-16.802,23.984-21.48 c7.401-4.074,14.723-7.455,21.516-11.281c6.789-3.793,12.843-7.91,17.302-12.372c2.988-2.975,5.31-6.05,7.087-9.52 c2.335-4.628,3.955-10.067,3.992-18.389c0.012-2.463-0.698-5.702-2.632-9.405c-1.926-3.686-5.066-7.694-9.264-11.29 c-8.45-7.248-20.843-12.545-35.054-12.521c-16.285,0.058-27.186,3.876-35.587,8.62c-8.36,4.776-11.029,9.595-11.029,9.595 c-4.268,3.718-10.603,3.85-15.025,0.314l-21.71-17.397c-2.719-2.173-4.322-5.438-4.396-8.926c-0.063-3.479,1.425-6.81,4.061-9.099 c0,0,6.765-10.43,22.451-19.38c15.62-8.992,36.322-15.488,61.236-15.429c20.215,0,38.839,5.562,54.268,14.661 c15.434,9.148,27.897,21.744,35.851,36.876c5.281,10.074,8.525,21.43,8.533,33.38C349.211,198.042,347.248,209.058,343.625,218.852 z" />
</g>
</svg>
);
}

View File

@ -0,0 +1,28 @@
import { NavigationMenuLink } from "@/components/ui/navigation-menu";
import { cn } from "@/lib/utils";
import React from "react";
const ListItem = React.forwardRef<React.ElementRef<"a">, React.ComponentPropsWithoutRef<"a">>(
({ className, title, children, ...props }, ref) => {
return (
<li>
<NavigationMenuLink asChild>
<a
ref={ref}
className={cn(
"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}
>
<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>
</a>
</NavigationMenuLink>
</li>
);
}
);
ListItem.displayName = "ListItem";
export default ListItem;

View File

@ -0,0 +1,22 @@
export function LegacyLoader() {
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>
);
}

View File

@ -1,4 +1,9 @@
import Lottie from "react-lottie";
"use client";
import dynamic from "next/dynamic";
// Dynamically import Lottie to prevent SSR issues
const Lottie = dynamic(() => import("react-lottie"), { ssr: false });
import * as loadingData from "./loading.json";
const loadingOption = {
@ -11,17 +16,17 @@ const loadingOption = {
};
interface LoaderProps {
isSuccess: boolean;
isSuccess?: boolean;
}
export function Loader(props: LoaderProps) {
return (
<>
<div>
{!props.isSuccess && (
<div className="fixed inset-0 flex items-center justify-center bg-white bg-opacity-10 backdrop-blur-sm z-50">
<Lottie options={loadingOption} height={200} width={200} />
</div>
)}
</>
</div>
);
}

29
src/components/modal.tsx Normal file
View File

@ -0,0 +1,29 @@
"use client";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "./ui/button";
import { DataTable } from "./dataTable";
export type ModalProps = {
date: Date;
amount: number;
name: string;
investorId?: string;
profileURL?: string;
logoURL?: string;
status?: string;
};
export function Modal({ data }: { data: ModalProps[] }) {
return (
<div>
<Dialog>
<DialogTrigger asChild>
<Button>View More</Button>
</DialogTrigger>
<DialogContent className="max-w-screen-md md:max-w-screen-lg ">
<DataTable data={data} />
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,126 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Bell, Heart, Wallet, ChartPie, CalendarClock, Calendar } from "lucide-react";
import { LogoutButton } from "@/components/auth/logoutButton";
import { useUserRole } from "@/hooks/useUserRole";
import CustomTooltip from "../customToolTip";
interface AuthenticatedComponentsProps {
uid: string;
avatarUrl?: string | null;
notificationCount: number;
}
export const AuthenticatedComponents = ({ uid, avatarUrl, notificationCount }: AuthenticatedComponentsProps) => {
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 ${businessClass}`}>
<CustomTooltip message="Notification">
<Link href={"/notification"}>
<div className="relative inline-block">
<Bell className="h-6 w-6 " />
{notificationCount >= 1 && (
<div>
<span className="absolute -top-1 -right-1 inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-red-600 rounded-full animate-ping"></span>
<span className="absolute -top-1 -right-1 inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-red-600 rounded-full ">
{notificationCount}
</span>
</div>
)}
</div>
</Link>
</CustomTooltip>
<CustomTooltip message="Followed">
<Link href="/follow">
<Heart />
</Link>
</CustomTooltip>
<CustomTooltip message="Portfolio">
<Link href={"/portfolio/" + uid}>
<Wallet className="cursor-pointer" />
</Link>
</CustomTooltip>
{data?.role === "investor" && (
<div className="flex gap-2">
<CustomTooltip message="Calendar">
<Link href="/calendar/">
<CalendarClock />
</Link>
</CustomTooltip>
</div>
)}
<CustomTooltip message="Calendar">
<Link href={"/calendar"}>
<Calendar className="cursor-pointer" />
</Link>
</CustomTooltip>
{/*chart pie icon for bussiness's dashboard */}
{data?.role === "business" && (
<div className="flex gap-2">
<CustomTooltip message="Dashboard">
<Link href="/dashboard">
<ChartPie />
</Link>
</CustomTooltip>
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="overflow-hidden rounded-full">
<Avatar>
{avatarUrl ? (
<AvatarImage src={avatarUrl} alt="profile" />
) : (
<AvatarImage src="https://api.dicebear.com/9.x/pixel-art/svg" alt="profile" />
)}
<AvatarFallback>1</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Link href={`/profile/${uid}`}>Profile</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{data?.role === "admin" && (
<DropdownMenuItem>
<Link href="/admin">Admin</Link>
</DropdownMenuItem>
)}
{data?.role === "business" && (
<>
<DropdownMenuItem>
<Link href="/calendar/manage">Calendar</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{data != null && data != undefined && data.role === "business" && (
<DropdownMenuItem>
<Link href="/dataroom/manage">Dataroom</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogoutButton />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@ -0,0 +1,19 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export const UnAuthenticatedComponents = () => {
return (
<div className="flex gap-2 pl-2">
<Link href="/auth">
<Button variant="secondary" className="border-2 border-border">
Login
</Button>
</Link>
<Link href="/auth/signup">
<Button>Sign up</Button>
</Link>
</div>
);
};

View File

@ -0,0 +1,23 @@
export const businessComponents = [
{
title: "Business",
href: "/business/apply",
description: "Apply to raise on on B2DVentures",
},
];
export const projectComponents = [
{
title: "Projects",
href: "/project/apply",
description: "Start your new project on B2DVentures",
},
];
export const dataroomComponents = [
{
title: "Overview",
href: "/dataroom/overview",
description: "View all dataroom available to you",
},
];

View File

@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { Menu, X } from "lucide-react";
import { Button } from "../ui/button";
import { AnimatePresence, motion } from "framer-motion";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu";
import React from "react";
import { SearchBar } from "./serchBar";
import ListItem from "../listItem";
import { businessComponents, projectComponents, dataroomComponents } from "./menu";
import { ThemeToggle } from "../theme-toggle";
export function MobileMenu() {
const [isVisible, setIsVisible] = useState(false);
return (
<div>
<Button onClick={() => setIsVisible((prev) => !prev)}>
<Menu />
</Button>
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ y: -100 }}
animate={{ y: 0 }}
exit={{ y: -100 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="fixed top-0 left-0 w-full bg-white dark:bg-slate-900 border-b dark:border-slate-800 shadow-sm z-50"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<button
onClick={() => setIsVisible(false)}
className="p-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="w-5 h-5" />
</button>
<NavigationMenu>
<NavigationMenuList className="flex space-x-2">
<NavigationMenuItem>
<NavigationMenuTrigger className="text-sm font-medium">Businesses</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="w-[240px] p-4 gap-3">
{businessComponents.map((component) => (
<ListItem key={component.title} title={component.title} href={component.href}>
{component.description}
</ListItem>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger className="text-sm font-medium">Projects</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="w-[240px] p-4 gap-3">
{projectComponents.map((component) => (
<ListItem key={component.title} title={component.title} href={component.href}>
{component.description}
</ListItem>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger className="text-sm font-medium">Dataroom</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="w-[240px] p-4 gap-3">
{dataroomComponents.map((component) => (
<ListItem key={component.title} title={component.title} href={component.href}>
{component.description}
</ListItem>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div className="flex items-center ml-2">
<SearchBar />
<div className="-ml-2">
<ThemeToggle />
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -2,60 +2,38 @@ import * as React from "react";
import Link from "next/link";
import Image from "next/image";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/theme-toggle";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu";
import { SearchBar } from "./serchBar";
import { ProfileBar } from "./profileBar";
import { AuthenticatedComponents } from "./AuthenticatedComponents";
import { UnAuthenticatedComponents } from "./UnAuthenticatedComponents";
const ListItem = React.forwardRef<React.ElementRef<"a">, React.ComponentPropsWithoutRef<"a">>(
({ className, title, children, ...props }, ref) => {
return (
<li>
<NavigationMenuLink asChild>
<a
ref={ref}
className={cn(
"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}
>
<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>
</a>
</NavigationMenuLink>
</li>
);
import { createSupabaseClient } from "@/lib/supabase/serverComponentClient";
import { getUserId } from "@/lib/supabase/actions/getUserId";
import { getUnreadNotificationCountByUserId } from "@/lib/data/notificationQuery";
import { MobileMenu } from "./mobileMenu";
import ListItem from "../listItem";
import { businessComponents, projectComponents, dataroomComponents } from "./menu";
export async function NavigationBar() {
const client = createSupabaseClient();
const userId = await getUserId();
const { data: avatarUrl } = await client.from("profiles").select("avatar_url").eq("id", userId).single();
let notification_count = 0;
if (userId != null) {
const { count: notiCount, error: notiError } = (await getUnreadNotificationCountByUserId(client, userId)) ?? {};
notification_count = notiError ? 0 : (notiCount ?? 0);
} else {
notification_count = 0;
}
);
ListItem.displayName = "ListItem";
export function NavigationBar() {
const businessComponents = [
{
title: "Business",
href: "/business/apply",
description: "Apply to raise on on B2DVentures",
},
];
const projectComponents = [
{
title: "Projects",
href: "/project/apply",
description: "Start your new project 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">
@ -67,14 +45,32 @@ export function NavigationBar() {
href="/"
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} />
B2DVentures
<span className="lg:inline-flex flex items-center gap-x-2 text-xl font-semibold dark:text-white">
<Image src="/logo.svg" alt="logo" width={50} height={50} className="w-10 h-10 sm:w-16 sm:h-16" />
<span className="block lg:inline">
B2D<span className=" lg:inline">Ventures</span>
</span>
</span>
</Link>
</div>
<div className="md:hidden grid grid-cols-2 justify-items-center items-center">
<div className="flex justify-end w-10">
{userId ? (
<AuthenticatedComponents
uid={userId}
avatarUrl={avatarUrl?.avatar_url}
notificationCount={notification_count}
/>
) : (
<UnAuthenticatedComponents />
)}
</div>
<div className="justify-end flex">
<MobileMenu />
</div>
</div>
<div className="flex items-center">
<div className="hidden md:flex items-center ">
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
@ -103,16 +99,39 @@ export function NavigationBar() {
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger className="text-base font-medium ">Dataroom</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] ">
{dataroomComponents.map((component) => (
<ListItem key={component.title} title={component.title} href={component.href}>
{component.description}
</ListItem>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem className="pl-5 flex">
<SearchBar />
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<div className="flex gap-2 pl-2">
<ThemeToggle />
<div className="hidden md:flex gap-2 pl-2">
<div className="mt-1">
<ThemeToggle />
</div>
<Separator orientation="vertical" className="mx-3" />
<ProfileBar />
{userId ? (
<AuthenticatedComponents
uid={userId}
avatarUrl={avatarUrl?.avatar_url}
notificationCount={notification_count}
/>
) : (
<UnAuthenticatedComponents />
)}
</div>
</div>
</div>

View File

@ -1,112 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
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 (
<div className="flex gap-2 pl-2">
<Link href="/auth">
<Button variant="secondary" className="border-2 border-border">
Login
</Button>
</Link>
<Link href="/auth/signup">
<Button>Sign up</Button>
</Link>
</div>
);
};
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 ${businessClass}`}>
<Link href={"/notification"}>
<div className="relative inline-block">
<Bell className="h-6 w-6" />
<span className="absolute -top-1 -right-1 inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-red-600 rounded-full">
{displayValue}
</span>
</div>
</Link>
<Heart />
<Wallet />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="overflow-hidden rounded-full">
<Avatar>
<AvatarImage src="https://api.dicebear.com/9.x/pixel-art/svg" />
<AvatarFallback>1</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Link href={`/profile/${uid}`}>Profile</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Support</DropdownMenuItem>
{data != null && data != undefined && data.role === "admin" && (
<DropdownMenuItem>
<Link href="/admin">Admin</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogoutButton />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
export function ProfileBar() {
const { session } = useSession();
const user = session?.user;
const [sessionLoaded, setSessionLoaded] = useState(false);
useEffect(() => {
if (!session) {
setSessionLoaded(true);
}
}, [session]);
return (
<>
{sessionLoaded ? (
user ? (
<AuthenticatedComponents uid={user.id} />
) : (
<UnAuthenticatedComponents />
)
) : (
<div>
<Skeleton className="rounded-lg h-full w-[160px]" />
</div>
)}
</>
);
}

View File

@ -2,34 +2,100 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useEffect, useRef } from "react";
export function SearchBar() {
const [searchActive, setSearchActive] = React.useState(false);
const [query, setQuery] = React.useState("");
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
const query = (e.target as HTMLInputElement).value.trim();
if (query) {
router.push(`/find?query=${encodeURIComponent(query)}`);
const trimmedQuery = query.trim();
if (trimmedQuery) {
router.push(`/find?query=${encodeURIComponent(trimmedQuery)}`);
setSearchActive(false);
setQuery("");
}
} else if (e.key === "Escape") {
setSearchActive(false);
setQuery("");
}
};
// Handle clicks outside of search bar
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setSearchActive(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Focus input when search becomes active
useEffect(() => {
if (searchActive && inputRef.current) {
inputRef.current.focus();
}
}, [searchActive]);
return (
<div className="flex items-center">
<Search onClick={() => setSearchActive(!searchActive)} className="cursor-pointer" />
<input
type="text"
placeholder="Enter business name..."
className={cn(
"ml-2 border rounded-md px-2 py-1 transition-all duration-300 ease-in-out",
searchActive ? "w-48 opacity-100" : "w-0 opacity-0"
)}
onKeyDown={handleKeyDown}
/>
<div
ref={containerRef}
className={cn(
"relative flex items-center transition-all duration-300",
searchActive &&
"fixed inset-0 bg-white/95 dark:bg-slate-900/95 z-50 px-4 md:relative md:bg-transparent md:dark:bg-transparent md:px-0"
)}
>
{/* Mobile overlay header when search is active */}
{searchActive && (
<div className="absolute top-0 left-0 right-0 flex items-center p-4 md:hidden">
<X
className="w-6 h-6 cursor-pointer text-slate-600 dark:text-slate-400"
onClick={() => {
setSearchActive(false);
setQuery("");
}}
/>
</div>
)}
<div className={cn("flex items-center w-full md:w-auto", searchActive ? "mt-16 md:mt-0" : "")}>
<Search
onClick={() => setSearchActive(!searchActive)}
className={cn(
"w-5 h-5 cursor-pointer transition-colors",
searchActive ? "text-blue-500" : "text-slate-600 dark:text-slate-400",
"md:hover:text-blue-500"
)}
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter business name..."
className={cn(
"ml-2 rounded-md transition-all duration-300 ease-in-out outline-none",
"placeholder:text-slate-400 dark:placeholder:text-slate-500",
"bg-transparent md:bg-slate-100 md:dark:bg-slate-800",
"md:px-3 md:py-1.5",
searchActive
? "w-full opacity-100 border-b md:border md:w-48 lg:w-64 md:border-slate-200 md:dark:border-slate-700"
: "w-0 opacity-0 border-transparent"
)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
"use client";
import { Pie } from "react-chartjs-2";
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
ChartJS.register(ArcElement, Tooltip, Legend);
interface PieChartProps {
labels: string[];
data: number[];
header: string;
}
const PieChart = (props: PieChartProps) => {
const chartData = {
labels: props.labels,
datasets: [
{
label: props.header,
data: props.data,
backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
hoverBackgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
borderWidth: 1,
},
],
};
const options = {
plugins: {
legend: {
position: "bottom" as const,
},
},
};
return (
<>
<Pie data={chartData} options={options} />
</>
);
};
export default PieChart;

Some files were not shown because too many files have changed in this diff Show More