mirror of
https://github.com/Sosokker/B2D-Ventures.git
synced 2025-12-18 13:34:06 +01:00
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:
commit
e20bdf44aa
30
.env.example
30
.env.example
@ -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
|
||||
|
||||
# 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
|
||||
@ -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
|
||||
|
||||
# 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
|
||||
|
||||
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@ -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
|
||||
|
||||
98
.github/workflows/playwright.yml
vendored
98
.github/workflows/playwright.yml
vendored
@ -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
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
||||
/tailwind.config.ts
|
||||
@ -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
3892
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@ -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",
|
||||
|
||||
@ -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. */
|
||||
|
||||
53
src/app/(investment)/deals/ShowFilter.tsx
Normal file
53
src/app/(investment)/deals/ShowFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
70
src/app/(investment)/deals/[id]/UpdateTab.tsx
Normal file
70
src/app/(investment)/deals/[id]/UpdateTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 {
|
||||
redirect("/login");
|
||||
toast.success("You have followed the project!", { icon: "❤️" });
|
||||
setIsFollowState(true);
|
||||
}
|
||||
} else {
|
||||
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 />
|
||||
|
||||
57
src/app/(investment)/deals/[id]/logParser.ts
Normal file
57
src/app/(investment)/deals/[id]/logParser.ts
Normal 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 };
|
||||
@ -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,15 +67,18 @@ 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">
|
||||
@ -60,7 +86,7 @@ export default async function ProjectDealPage({ params }: { params: { id: number
|
||||
<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 />
|
||||
<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>
|
||||
@ -72,32 +98,15 @@ export default async function ProjectDealPage({ params }: { params: { id: number
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div id="sub-content" className="flex flex-row mt-5">
|
||||
<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-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 id="image-carousel" className="w-full">
|
||||
<Gallery images={carouselData} />
|
||||
</div>
|
||||
<div id="stats" className="flex flex-col w-full mt-4 pl-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>
|
||||
@ -107,7 +116,7 @@ export default async function ProjectDealPage({ params }: { params: { id: number
|
||||
</p>
|
||||
<Progress
|
||||
value={toPercentage(totalDealAmount, projectData?.target_investment)}
|
||||
className="w-[60%] h-3 mt-3"
|
||||
className="w-4/5 h-3 mt-3 border-2"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
@ -128,20 +137,50 @@ export default async function ProjectDealPage({ params }: { params: { id: number
|
||||
<p className="text-xl md:text-4xl">No deadline</p>
|
||||
)}
|
||||
</span>
|
||||
<Button className="mt-5 w-3/4 h-12">
|
||||
</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 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>
|
||||
</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>
|
||||
|
||||
21
src/app/(investment)/deals/[id]/query.ts
Normal file
21
src/app/(investment)/deals/[id]/query.ts
Normal 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 };
|
||||
@ -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">
|
||||
|
||||
13
src/app/(investment)/invest/[id]/InvestmentAmountInfo.tsx
Normal file
13
src/app/(investment)/invest/[id]/InvestmentAmountInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/app/(investment)/invest/[id]/PaymentDialog.tsx
Normal file
60
src/app/(investment)/invest/[id]/PaymentDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,30 +101,34 @@ 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-1/2 space-y-2">
|
||||
<h2 className="text:base md:text-2xl">Investment Amount</h2>
|
||||
<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="w-52"
|
||||
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" />
|
||||
|
||||
{/* 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>Select</TableHead>
|
||||
<TableHead>Term</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
@ -128,44 +137,82 @@ export default function InvestPage() {
|
||||
{term_data.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedTerms[index]}
|
||||
onChange={() => handleCheckboxChange(index)}
|
||||
/>
|
||||
{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.term}</TableCell>
|
||||
<TableCell>{item.description}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* First Checkbox with Terms */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={checkboxStates[0]}
|
||||
onChange={() => handleCheckboxChange(0)}
|
||||
className="h-4 w-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's failure to fulfill its obligations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
44
src/app/(legal)/NavigationSidebar.tsx
Normal file
44
src/app/(legal)/NavigationSidebar.tsx
Normal 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
31
src/app/(legal)/Toc.tsx
Normal 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;
|
||||
18
src/app/(legal)/about/founderCard.tsx
Normal file
18
src/app/(legal)/about/founderCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/app/(legal)/about/infoCard.tsx
Normal file
31
src/app/(legal)/about/infoCard.tsx
Normal 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;
|
||||
106
src/app/(legal)/about/page.tsx
Normal file
106
src/app/(legal)/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/app/(legal)/privacy/page.tsx
Normal file
101
src/app/(legal)/privacy/page.tsx
Normal 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 ("Policy") 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;
|
||||
105
src/app/(legal)/risks/page.tsx
Normal file
105
src/app/(legal)/risks/page.tsx
Normal 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 business’s 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;
|
||||
108
src/app/(legal)/terms/page.tsx
Normal file
108
src/app/(legal)/terms/page.tsx
Normal 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 ("Terms") govern your access to and use of the Website, which is owned
|
||||
and operated by B2DVenture Company LLC ("B2DVenture", "we", "us", or
|
||||
"our").
|
||||
</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, "User
|
||||
Content"). 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 "Issuers") with
|
||||
potential investors (the "Investors"). 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" 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;
|
||||
@ -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 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 items-center justify-between p-4 border-b border-gray-200"
|
||||
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 text-blue-500 mr-3" />
|
||||
<BellIcon className={`w-5 h-5 mr-3 ${notification.is_read ? "text-gray-400" : "text-blue-500"}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{notification.message}</p>
|
||||
<p className="text-xs text-gray-500">{notification.time}</p>
|
||||
<p className="text-sm font-medium text-black dark:text-white">{notification.message}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(notification.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-sm text-blue-500 hover:text-blue-600">Mark as read</button>
|
||||
|
||||
{!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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
76
src/app/(user)/profile/[uid]/BusinessProfile.tsx
Normal file
76
src/app/(user)/profile/[uid]/BusinessProfile.tsx
Normal 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'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'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>
|
||||
);
|
||||
};
|
||||
66
src/app/(user)/profile/[uid]/ProjectProfile.tsx
Normal file
66
src/app/(user)/profile/[uid]/ProjectProfile.tsx
Normal 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'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'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>
|
||||
);
|
||||
};
|
||||
155
src/app/(user)/profile/[uid]/edit/EditProfileForm.tsx
Normal file
155
src/app/(user)/profile/[uid]/edit/EditProfileForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
export default function EditProfilePage({ params }: { params: { uid: string } }) {
|
||||
const uid = params.uid;
|
||||
const client = createSupabaseClient();
|
||||
const router = useRouter();
|
||||
const { session, loading: isLoadingSession } = useSession();
|
||||
|
||||
const profileForm = useForm<z.infer<typeof profileSchema>>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
});
|
||||
|
||||
if (isLoadingSession) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<p>Loading session...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onProfileSubmit = async (updates: z.infer<typeof profileSchema>) => {
|
||||
const { avatars, username, full_name, bio } = updates;
|
||||
|
||||
try {
|
||||
let avatarUrl = null;
|
||||
|
||||
if (avatars instanceof File) {
|
||||
const avatarData = await uploadAvatar(client, avatars, uid);
|
||||
avatarUrl = avatarData?.path
|
||||
? `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/avatars/${avatarData.path}`
|
||||
: null;
|
||||
}
|
||||
|
||||
const result = await updateProfile(client, uid, {
|
||||
username,
|
||||
full_name,
|
||||
bio,
|
||||
...(avatarUrl && { avatar_url: avatarUrl }),
|
||||
});
|
||||
|
||||
if (result) {
|
||||
toast.success("Profile updated successfully!");
|
||||
router.push(`/profile/${uid}`);
|
||||
} else {
|
||||
toast.error("No fields to update!");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Error updating profile!");
|
||||
console.error("Error updating profile:", error);
|
||||
}
|
||||
type ProfilePageProps = {
|
||||
params: { uid: string };
|
||||
};
|
||||
|
||||
if (uid != session?.user.id) {
|
||||
router.push(`/profile/${uid}`);
|
||||
export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
const uid = params.uid;
|
||||
const client = createSupabaseClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await client.auth.getUser();
|
||||
|
||||
if (!user || user?.id !== uid) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { data: profileData, error } = await getUserProfile(client, 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
{/* <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>{profileData.bio || "No bio available."}</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{profileData.bio || "No bio available."}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
export default function AuthError() {
|
||||
return <div>Authentication Error</div>;
|
||||
}
|
||||
@ -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 you’ve 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 you’ve read our{" "}
|
||||
<Link href="/privacy" rel="noopener noreferrer" target="_blank" className="text-blue-600 underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -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 you’ve 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 you’ve read our{" "}
|
||||
<Link href="/privacy" rel="noopener noreferrer" target="_blank" className="text-blue-600 underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</span>{" "}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -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'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
|
||||
38
src/app/business/apply/actions.ts
Normal file
38
src/app/business/apply/actions.ts
Normal 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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
240
src/app/calendar/MeetEventDialog.tsx
Normal file
240
src/app/calendar/MeetEventDialog.tsx
Normal 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'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
114
src/app/calendar/actions.ts
Normal 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 };
|
||||
}
|
||||
196
src/app/calendar/manage/FreeTimeDialog.tsx
Normal file
196
src/app/calendar/manage/FreeTimeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/app/calendar/manage/ManageMeetDialog.tsx
Normal file
101
src/app/calendar/manage/ManageMeetDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/app/calendar/manage/ProjectCardSection.tsx
Normal file
74
src/app/calendar/manage/ProjectCardSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/app/calendar/manage/page.tsx
Normal file
62
src/app/calendar/manage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/app/calendar/overlapEvent.ts
Normal file
36
src/app/calendar/overlapEvent.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1,62 +1,155 @@
|
||||
"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="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>
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
{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">
|
||||
{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>
|
||||
<CardTitle className="text-sm font-medium">Total Funds Raised</CardTitle>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@ -71,17 +164,20 @@ export default function Dashboard() {
|
||||
</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> */}
|
||||
<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>
|
||||
<CardTitle className="text-sm font-medium">Profile Views</CardTitle>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@ -97,7 +193,9 @@ export default function Dashboard() {
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+2350</div>
|
||||
<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> */}
|
||||
@ -105,9 +203,7 @@ export default function Dashboard() {
|
||||
</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>
|
||||
<CardTitle className="text-sm font-medium">Total Followers</CardTitle>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@ -124,84 +220,99 @@ export default function Dashboard() {
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+12,234</div>
|
||||
<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"
|
||||
<Button
|
||||
onClick={() => {
|
||||
router.push(`/project/${project.id}/edit`);
|
||||
}}
|
||||
className="h-full bg-emerald-500 hover:bg-emerald-800 font-bold text-xl"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
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> */}
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
<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")}
|
||||
>
|
||||
<TabsTrigger value="line" onClick={() => setGraphType("line")}>
|
||||
Line
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="bar"
|
||||
onClick={() => setGraphType("bar")}
|
||||
>
|
||||
<TabsTrigger value="bar" onClick={() => setGraphType("bar")}>
|
||||
Bar
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<Card className="col-span-4 md: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 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
55
src/app/find/BusinessSection.tsx
Normal file
55
src/app/find/BusinessSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
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>
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
||||
export default function Find() {
|
||||
return (
|
||||
<Suspense fallback={<p>Loading search parameters...</p>}>
|
||||
<FindContent />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
46
src/app/follow/page.tsx
Normal file
46
src/app/follow/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
|
||||
277
src/app/portfolio/[uid]/page.tsx
Normal file
277
src/app/portfolio/[uid]/page.tsx
Normal 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'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‘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>
|
||||
);
|
||||
}
|
||||
258
src/app/portfolio/[uid]/query.ts
Normal file
258
src/app/portfolio/[uid]/query.ts
Normal 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,
|
||||
};
|
||||
145
src/app/project/[projectId]/edit/EditProjectForm.tsx
Normal file
145
src/app/project/[projectId]/edit/EditProjectForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/project/[projectId]/edit/page.tsx
Normal file
54
src/app/project/[projectId]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,9 +259,8 @@ const ProjectForm = ({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="mt-10 space-y-5">
|
||||
<FormLabel className="font-bold text-lg mt-10">
|
||||
Project logo
|
||||
</FormLabel>
|
||||
<FormLabel className="font-bold text-lg mt-10">Project logo</FormLabel>
|
||||
<div className="flex space-x-5">
|
||||
<Input
|
||||
type="file"
|
||||
id="projectLogo"
|
||||
@ -316,10 +272,10 @@ const ProjectForm = ({
|
||||
}}
|
||||
/>
|
||||
<span className="text-[12px] text-neutral-500 self-center">
|
||||
Please upload the logo picture that best represents your
|
||||
project.
|
||||
Please upload the logo picture that best represents your project.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -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>
|
||||
20
src/app/project/apply/displayAlert.ts
Normal file
20
src/app/project/apply/displayAlert.ts
Normal 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!");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
6
src/app/project/apply/fileUploadService.ts
Normal file
6
src/app/project/apply/fileUploadService.ts
Normal 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;
|
||||
};
|
||||
@ -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();
|
||||
|
||||
// console.log("Insert response for tag:", tag, response);
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
const results = await Promise.all(tagPromises);
|
||||
|
||||
// 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;
|
||||
if (isLoading || !session) {
|
||||
return <LegacyLoader />;
|
||||
}
|
||||
|
||||
return await uploadFile(file, BUCKET_PITCH_APPLICATION_NAME, `${userId}/${projectId}/pitches/${file.name}`);
|
||||
};
|
||||
const userId = session!.user.id;
|
||||
|
||||
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: [] };
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
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.");
|
||||
if (role != "business") {
|
||||
router.push("/business/apply");
|
||||
toast.error("Please apply to raise on B2DVentures first!");
|
||||
return;
|
||||
}
|
||||
|
||||
// save application data
|
||||
const { projectId, error } = await saveApplicationData(recvData, user.id);
|
||||
|
||||
const sendApplication = async (data: any) => {
|
||||
try {
|
||||
const { projectId, error } = await saveApplicationData(data, userId);
|
||||
if (error) {
|
||||
displayAlert(error);
|
||||
return;
|
||||
}
|
||||
const tagError = await saveTags(recvData["tag"], projectId);
|
||||
// if (tagError) {
|
||||
// displayAlert(tagError);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// upload pitch file if it’s 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);
|
||||
await saveTags(data["tag"], projectId);
|
||||
await uploadFiles(data["projectPhotos"], `${userId}/${projectId}/photos/`);
|
||||
displayAlert(null);
|
||||
router.push("/");
|
||||
} catch (error) {
|
||||
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>
|
||||
);
|
||||
|
||||
38
src/app/project/apply/projectService.ts
Normal file
38
src/app/project/apply/projectService.ts
Normal 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
43
src/app/verify/page.tsx
Normal 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;
|
||||
101
src/components/MarkdownEditor.tsx
Normal file
101
src/components/MarkdownEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
src/components/ProjectSection.tsx
Normal file
44
src/components/ProjectSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17614
src/components/alert/noData/alert.json
Normal file
17614
src/components/alert/noData/alert.json
Normal file
File diff suppressed because it is too large
Load Diff
20
src/components/alert/noData/alert.tsx
Normal file
20
src/components/alert/noData/alert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2066
src/components/alert/unauthorized/alert.json
Normal file
2066
src/components/alert/unauthorized/alert.json
Normal file
File diff suppressed because it is too large
Load Diff
20
src/components/alert/unauthorized/alert.tsx
Normal file
20
src/components/alert/unauthorized/alert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/auth/action.ts
Normal file
57
src/components/auth/action.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,30 +1,65 @@
|
||||
"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,
|
||||
|
||||
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;
|
||||
});
|
||||
router.push("/");
|
||||
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">
|
||||
<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"
|
||||
@ -32,9 +67,19 @@ export function LoginForm() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<Button id="login" onClick={handleLogin}>
|
||||
{errors.password && <p className="text-red-600">{errors.password}</p>}
|
||||
</div>
|
||||
<HCaptcha
|
||||
ref={captcha}
|
||||
sitekey={process.env.NEXT_PUBLIC_SITEKEY!}
|
||||
onVerify={(token) => {
|
||||
setCaptchaToken(token);
|
||||
}}
|
||||
/>
|
||||
{errors.server && <p className="text-red-600">{errors.server}</p>}
|
||||
<Button id="login" type="submit">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
<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
113
src/components/carousel.tsx
Normal 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;
|
||||
19
src/components/countUp.tsx
Normal file
19
src/components/countUp.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
src/components/customToolTip.tsx
Normal file
22
src/components/customToolTip.tsx
Normal 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;
|
||||
293
src/components/dataTable.tsx
Normal file
293
src/components/dataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/icon/questionMark.tsx
Normal file
17
src/components/icon/questionMark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/listItem.tsx
Normal file
28
src/components/listItem.tsx
Normal 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;
|
||||
22
src/components/loading/LegacyLoader.tsx
Normal file
22
src/components/loading/LegacyLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
29
src/components/modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/components/navigationBar/AuthenticatedComponents.tsx
Normal file
126
src/components/navigationBar/AuthenticatedComponents.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
src/components/navigationBar/UnAuthenticatedComponents.tsx
Normal file
19
src/components/navigationBar/UnAuthenticatedComponents.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
src/components/navigationBar/menu.ts
Normal file
23
src/components/navigationBar/menu.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
102
src/components/navigationBar/mobileMenu.tsx
Normal file
102
src/components/navigationBar/mobileMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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" />
|
||||
<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 border rounded-md px-2 py-1 transition-all duration-300 ease-in-out",
|
||||
searchActive ? "w-48 opacity-100" : "w-0 opacity-0"
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
42
src/components/pieChart.tsx
Normal file
42
src/components/pieChart.tsx
Normal 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;
|
||||
@ -3,6 +3,7 @@
|
||||
import { CalendarDaysIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { Separator } from "./ui/separator";
|
||||
|
||||
interface XMap {
|
||||
[tag: string]: string;
|
||||
@ -13,7 +14,7 @@ interface ProjectCardProps {
|
||||
description: string;
|
||||
joinDate: string;
|
||||
location: string;
|
||||
tags: XMap | null | never[] | string[];
|
||||
tags?: XMap | null | never[] | string[];
|
||||
imageUri: string | null;
|
||||
minInvestment: number;
|
||||
totalInvestor: number;
|
||||
@ -70,15 +71,19 @@ export function ProjectCard(props: ProjectCardProps) {
|
||||
<span className="text-xs">{props.location}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap mt-1 items-center text-muted-foreground">
|
||||
{props.tags && Array.isArray(props.tags) ? (
|
||||
props.tags.map((tag) => (
|
||||
<span id="tag" key={tag} className="text-[10px] rounded-md bg-slate-200 dark:bg-slate-700 p-1 mr-1">
|
||||
{props.tags?.length !== 0 && Array.isArray(props.tags)
|
||||
? props.tags
|
||||
.filter((tag) => tag && tag.trim() !== "") // Filters out null or blank tags
|
||||
.map((tag) => (
|
||||
<span
|
||||
id="tag"
|
||||
key={tag}
|
||||
className="text-[10px] rounded-md bg-slate-200 dark:bg-slate-700 p-1 mr-1"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No tags available</span>
|
||||
)}
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -86,18 +91,32 @@ export function ProjectCard(props: ProjectCardProps) {
|
||||
{/* Info 2 */}
|
||||
<div className="hidden group-hover:flex group-hover:absolute group-hover:bottom-4 p-4 ">
|
||||
{/* Info 2 (Visible on hover) */}
|
||||
<div className="transition-transform duration-500 transform translate-y-6 opacity-0 group-hover:translate-y-0 group-hover:opacity-100">
|
||||
<hr className="-ml-4 mb-2" />
|
||||
<div className="transition-transform duration-500 transform translate-y-6 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 w-full">
|
||||
<Separator className="-ml-4 mb-2" />
|
||||
<p className="text-base">
|
||||
<strong>${props.totalRaised.toLocaleString()}</strong> committed and reserved
|
||||
<strong>
|
||||
${isNaN(props.totalRaised) || props.totalRaised == null ? "N/A" : props.totalRaised.toLocaleString()}
|
||||
</strong>{" "}
|
||||
committed and reserved
|
||||
</p>
|
||||
<hr className="-ml-4 mb-2 mt-2" />
|
||||
<Separator className="-ml-4 mb-2 mt-2" />
|
||||
<p className="mb-2 text-base">
|
||||
<strong>{props.totalInvestor.toLocaleString()}</strong> investors
|
||||
<strong>
|
||||
{isNaN(props.totalInvestor) || props.totalInvestor == null
|
||||
? "N/A "
|
||||
: props.totalInvestor.toLocaleString()}
|
||||
</strong>{" "}
|
||||
investors
|
||||
</p>
|
||||
<hr className="-ml-4 mb-2" />
|
||||
<Separator className="-ml-4 mb-2" />
|
||||
<p className="text-base">
|
||||
<strong>${props.minInvestment.toLocaleString()}</strong> min. investment
|
||||
<strong>
|
||||
$
|
||||
{isNaN(props.minInvestment) || props.minInvestment == null
|
||||
? "N/A"
|
||||
: props.minInvestment.toLocaleString()}
|
||||
</strong>{" "}
|
||||
min. investment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user