├── .env.example
├── public
├── favicon.ico
├── og-image.png
├── newIcon-white.svg
├── newIcon.svg
└── vercel.svg
├── postcss.config.js
├── next.config.js
├── tailwind.config.ts
├── utils
├── prisma.ts
└── utils.ts
├── styles
└── globals.css
├── app
├── page.tsx
├── layout.tsx
└── ClientPage.tsx
├── .gitignore
├── components
├── Icons
│ ├── WebsiteIcon.tsx
│ ├── TwitterIcon.tsx
│ └── CheckIcon.tsx
├── SearchBar.tsx
├── Header.tsx
├── Stats.tsx
├── Footer.tsx
└── InvestorTable.tsx
├── README.md
├── tsconfig.json
├── package.json
└── prisma
└── schema.prisma
/.env.example:
--------------------------------------------------------------------------------
1 | POSTGRES_PRISMA_URL=
2 | POSTGRES_URL_NON_POOLING=
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/exception/aiangels/main/public/favicon.ico
--------------------------------------------------------------------------------
/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/exception/aiangels/main/public/og-image.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | images: {
6 | domains: ["pbs.twimg.com", "media-exp1.licdn.com"],
7 | },
8 | };
9 |
10 | module.exports = nextConfig;
11 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | const config: Config = {
4 | content: [
5 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | plugins: [],
10 | };
11 | export default config;
12 |
--------------------------------------------------------------------------------
/utils/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 |
3 | declare global {
4 | var prisma: PrismaClient | undefined;
5 | }
6 |
7 | const client = globalThis.prisma || new PrismaClient();
8 | if (process.env.NODE_ENV !== 'production') globalThis.prisma = client;
9 |
10 | export default client;
11 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* Hide scrollbar for Chrome, Safari and Opera */
6 | body::-webkit-scrollbar {
7 | display: none;
8 | }
9 |
10 | /* Hide scrollbar for IE, Edge and Firefox */
11 | body {
12 | -ms-overflow-style: none; /* IE and Edge */
13 | scrollbar-width: none; /* Firefox */
14 | }
15 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Dashboard from './ClientPage';
2 | import prisma from '../utils/prisma';
3 | import { cache } from 'react';
4 |
5 | export const revalidate = 86400; // revalidate the data at most every 24 hours
6 |
7 | const getAllAngels = cache(async () => {
8 | const data = await prisma.investor.findMany({});
9 | return data;
10 | });
11 |
12 | export default async function HomePage() {
13 | const data = await getAllAngels();
14 |
15 | return ;
16 | }
17 |
--------------------------------------------------------------------------------
/public/newIcon-white.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/components/Icons/WebsiteIcon.tsx:
--------------------------------------------------------------------------------
1 | export default function WebsiteIcon(props: any) {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [aiangels.fund](https://www.aiangels.fund)
2 |
3 | A list of active angel investors that invest in AI startups.
4 |
5 | [](https://aiangels.fund/)
6 |
7 | ## Powered by
8 |
9 | This example is powered by the following services:
10 |
11 | - Next.js App Router (Framework)
12 | - Vercel Postgres & Prisma (Database)
13 | - Vercel (Analytics and hosting)
14 | - Tailwind (CSS Framework)
15 |
16 | ## Future Tasks
17 |
18 | - [ ] Dark mode
19 | - [ ] Profile pages with a full angel investor profile. Should include all the info in the table + maybe sections like previous investments
20 |
--------------------------------------------------------------------------------
/components/Icons/TwitterIcon.tsx:
--------------------------------------------------------------------------------
1 | export default function TwitterIcon(props: any) {
2 | return (
3 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "incremental": true,
15 | "esModuleInterop": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve",
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ],
26 | "strictNullChecks": true
27 | },
28 | "include": [
29 | "next-env.d.ts",
30 | "**/*.ts",
31 | "**/*.tsx",
32 | ".next/types/**/*.ts"
33 | ],
34 | "exclude": [
35 | "node_modules"
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ai-angels",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "vercel-build": "prisma generate && next build",
11 | "prisma:generate": "prisma generate"
12 | },
13 | "dependencies": {
14 | "@prisma/client": "^5.3.1",
15 | "@vercel/analytics": "^1.0.2",
16 | "fuse.js": "^6.6.2",
17 | "next": "^13.4.19",
18 | "react": "^18.2.0",
19 | "react-dom": "^18.2.0",
20 | "react-highlight-words": "^0.18.0"
21 | },
22 | "devDependencies": {
23 | "@types/node": "18.7.18",
24 | "@types/react": "^18.2.22",
25 | "autoprefixer": "^10.4.11",
26 | "postcss": "^8.4.16",
27 | "prisma": "^5.3.1",
28 | "tailwindcss": "^3.3.3",
29 | "typescript": "^5.2.2"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/public/newIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
39 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("POSTGRES_PRISMA_URL") // uses connection pooling
8 | directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
9 | }
10 |
11 | model Investor {
12 | id Int @id @default(autoincrement())
13 | name String
14 | email String?
15 | company String?
16 | title String?
17 | checkSize String?
18 | details String?
19 | twitterPicture String?
20 | site String?
21 | twitterVerified Boolean? @default(false)
22 | hidden Boolean? @default(false)
23 | rank Int? @default(0)
24 | createdAt DateTime @default(now())
25 | updatedAt DateTime @updatedAt
26 | checksize CheckSize? @relation(fields: [checksize_id], references: [id])
27 | checksize_id Int?
28 | }
29 |
30 | model CheckSize {
31 | id Int @unique
32 | name String @unique
33 | angels Investor[]
34 | }
35 |
--------------------------------------------------------------------------------
/components/SearchBar.tsx:
--------------------------------------------------------------------------------
1 | export default function SearchBar({ search, setSearch }) {
2 | return (
3 |
4 |
8 | setSearch(e.target.value)}
14 | className="w-full rounded-xl shadow-sm inline-flex relative items-center border border-gray-300 px-4 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:z-10 focus:outline-none focus:ring-gray-500 md:w-72 pl-10 xs:pl-12"
15 | placeholder="Search by name"
16 | />
17 |
18 | );
19 | }
20 |
21 | function SearchIcon(props: any) {
22 | return (
23 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Link from 'next/link';
3 |
4 | export default function Header() {
5 | return (
6 | <>
7 |
25 |
26 |
27 |
28 | Find the next angel investor for your AI startup
29 |
30 |
31 |
32 | >
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import { Analytics } from '@vercel/analytics/react';
3 | import '../styles/globals.css';
4 | import Header from '../components/Header';
5 |
6 | let title = 'AI Angel Investors';
7 | let description = 'Find your next AI angel';
8 | let url = 'https://www.aiangels.fund';
9 | let ogimage = 'https://www.aiangels.fund/og-image.png';
10 | let sitename = 'aiangels.fund';
11 |
12 | export const metadata: Metadata = {
13 | metadataBase: new URL(url),
14 | title,
15 | description,
16 | icons: {
17 | icon: '/favicon.ico',
18 | },
19 | openGraph: {
20 | images: [ogimage],
21 | title,
22 | description,
23 | url: url,
24 | siteName: sitename,
25 | locale: 'en_US',
26 | type: 'website',
27 | },
28 | twitter: {
29 | card: 'summary_large_image',
30 | images: [ogimage],
31 | title,
32 | description,
33 | },
34 | };
35 |
36 | export default function RootLayout({
37 | children,
38 | }: {
39 | children: React.ReactNode;
40 | }) {
41 | return (
42 |
43 |
44 |
45 |
46 |
47 | {children}
48 |
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/components/Icons/CheckIcon.tsx:
--------------------------------------------------------------------------------
1 | export default function CheckIcon(props: any) {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/utils/utils.ts:
--------------------------------------------------------------------------------
1 | export function compare(a: any, b: any) {
2 | // if (a.twitterVerified === true && b.twitterVerified !== true) {
3 | // return -1;
4 | // }
5 | // if (a.twitterVerified !== true && b.twitterVerified === true) {
6 | // return 1;
7 | // }
8 | if (a.checksize_id > b.checksize_id) {
9 | return -1;
10 | }
11 | if (a.checksize_id < b.checksize_id) {
12 | return 1;
13 | }
14 | return 0;
15 | }
16 |
17 | export function kFormatter(num: any) {
18 | return Math.abs(num) > 4000
19 | ? Math.sign(num) * Number((Math.abs(num) / 1000).toFixed(0)) + 'k'
20 | : Math.sign(num) * Math.abs(num);
21 | }
22 |
23 | export const checkSizeMap = {
24 | 0: 'Unknown',
25 | 1: '$2-5k',
26 | 2: '$5-15k',
27 | 3: '$15-25k',
28 | 4: '$25-50k',
29 | 5: '$50-100k',
30 | 6: '$100k+',
31 | 7: 'All',
32 | };
33 |
34 | let checksizes = {
35 | 0: 0,
36 | 1: 3500,
37 | 2: 10000,
38 | 3: 20000,
39 | 4: 37500,
40 | 5: 75000,
41 | 6: 100000,
42 | };
43 | export function getCheckSizeForId(id: keyof typeof checksizes) {
44 | return checksizes[id];
45 | }
46 |
47 | export const checkSizes = [
48 | { id: '7', label: 'All' },
49 | { id: '2', label: '$5-15k' },
50 | { id: '3', label: '$15-25k' },
51 | { id: '4', label: '$25-50k' },
52 | { id: '6', label: '$100k' },
53 | ];
54 |
55 | export function classNames(...classes: string[]) {
56 | return classes.filter(Boolean).join(' ');
57 | }
58 |
59 | export const searchOptions = {
60 | threshold: 0.3,
61 | location: 0,
62 | distance: 100,
63 | minMatchCharLength: 2,
64 | keys: ['name', 'email', 'company', 'title', 'details'],
65 | };
66 |
--------------------------------------------------------------------------------
/components/Stats.tsx:
--------------------------------------------------------------------------------
1 | import { kFormatter } from '../utils/utils';
2 |
3 | export default function Stats({ angelsLength, averageCheck, companiesLength }) {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
-
11 | Angel Investors
12 |
13 | -
14 | {angelsLength}
15 |
16 |
17 |
18 |
-
19 | Average Check Size
20 |
21 | -
22 | {kFormatter(averageCheck) ? '$' + kFormatter(averageCheck) : '$0'}
23 |
24 |
25 |
26 |
-
27 | Confirmed Investments
28 |
29 | -
30 | {(angelsLength * 2.5).toFixed(0)}+
31 |
32 |
33 |
34 |
-
35 | Companies
36 |
37 | -
38 | {companiesLength}
39 |
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import CheckIcon from './Icons/CheckIcon';
3 |
4 | export default function Footer() {
5 | return (
6 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/app/ClientPage.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Fuse from 'fuse.js';
4 | import Link from 'next/link';
5 | import { useSearchParams } from 'next/navigation';
6 | import { useMemo, useState } from 'react';
7 | import CheckIcon from '../components/Icons/CheckIcon';
8 | import InvestorTable from '../components/InvestorTable';
9 | import SearchBar from '../components/SearchBar';
10 | import Stats from '../components/Stats';
11 | import {
12 | checkSizes,
13 | classNames,
14 | compare,
15 | getCheckSizeForId,
16 | searchOptions,
17 | } from '../utils/utils';
18 | import Footer from '../components/Footer';
19 |
20 | export default function Dashboard({ data }: any) {
21 | const allAngels = data;
22 | const [search, setSearch] = useState('');
23 |
24 | const searchParams = useSearchParams();
25 | const category = searchParams!.get('category');
26 |
27 | // Define filtered & sorted angels array
28 | const ALL_ANGELS = allAngels
29 | .filter((angel: any) => !angel.hidden)
30 | .sort(compare)
31 | .filter((person: any) => {
32 | return !category ? true : person.checksize_id.toString() === category;
33 | });
34 |
35 | // Fuzzy search with highlighting
36 | const fuse = new Fuse(ALL_ANGELS, searchOptions);
37 | const angels = useMemo(() => {
38 | if (search.length > 0) {
39 | return fuse.search(search).map((match) => match.item);
40 | }
41 | return ALL_ANGELS;
42 | }, [search, ALL_ANGELS]);
43 |
44 | // Get stats
45 | const companies = [...new Set(angels.map((angel: any) => angel.company))];
46 | const allChecksizes = angels
47 | .filter((angel: any) => angel.checksize_id)
48 | .map((angel: any) => getCheckSizeForId(angel.checksize_id));
49 | const averageCheck =
50 | allChecksizes.reduce((a: number, b: number) => a + b, 0) /
51 | allChecksizes.length;
52 |
53 | return (
54 | <>
55 |
60 |
61 |
62 | {checkSizes.map((checkSize) => (
63 |
73 | {checkSize.label}
74 |
75 | ))}
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | {/*
*/}
86 |
87 | {/*
*/}
88 |
89 |
90 |
91 | >
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/components/InvestorTable.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Highlighter from 'react-highlight-words';
3 | import { checkSizeMap, classNames } from '../utils/utils';
4 | import CheckIcon from './Icons/CheckIcon';
5 | import TwitterIcon from './Icons/TwitterIcon';
6 | import WebsiteIcon from './Icons/WebsiteIcon';
7 |
8 | export default function InvestorTable({ angels, search }) {
9 | return (
10 |
11 |
12 |
13 |
14 | |
18 | Name
19 | |
20 |
24 | Company
25 | |
26 |
30 | Title
31 | |
32 |
36 | Check Size
37 | |
38 |
42 | Details
43 | |
44 |
45 |
46 |
47 | {angels.map((person: any) => (
48 |
52 |
53 |
54 |
55 |
62 |
63 |
64 |
65 |
70 | {person.twitterVerified && (
71 |
72 | )}
73 |
74 |
96 |
97 |
98 | |
99 |
100 |
105 | |
106 |
107 |
112 | |
113 |
114 |
130 | {checkSizeMap[person.checksize_id]}
131 |
132 | |
133 |
134 |
139 | |
140 |
141 | ))}
142 |
143 |
144 | {angels.length === 0 && (
145 |
No results found
146 | )}
147 |
148 | );
149 | }
150 |
--------------------------------------------------------------------------------