├── .env.example
├── .gitignore
├── .vscode
└── extensions.json
├── README.md
├── docs
├── cfrp.png
└── show.png
├── package.json
├── packages
├── client
│ ├── .env.example
│ ├── .gitignore
│ ├── README.md
│ ├── package.json
│ ├── public
│ │ ├── img
│ │ │ ├── apartment.jpg
│ │ │ └── clerk-logo.svg
│ │ └── index.html
│ ├── src
│ │ ├── API
│ │ │ ├── apartments.ts
│ │ │ ├── fetcher.ts
│ │ │ ├── index.ts
│ │ │ └── user.ts
│ │ ├── App.test.tsx
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ ├── clerk-logo.svg
│ │ │ └── fonts
│ │ │ │ └── Quicksand-Regular.ttf
│ │ ├── components
│ │ │ ├── ApartmentCard.module.css
│ │ │ ├── ApartmentCard.tsx
│ │ │ ├── Apartments.tsx
│ │ │ ├── Button.module.css
│ │ │ ├── Button.tsx
│ │ │ ├── Footer.module.css
│ │ │ ├── Footer.tsx
│ │ │ ├── Header.module.css
│ │ │ ├── Header.tsx
│ │ │ ├── MyApartments.module.css
│ │ │ └── MyApartments.tsx
│ │ ├── hooks
│ │ │ ├── index.ts
│ │ │ ├── useApartments.ts
│ │ │ └── useUserApartments.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── layouts
│ │ │ ├── GridLayout.module.css
│ │ │ └── GridLayout.tsx
│ │ ├── react-app-env.d.ts
│ │ ├── types
│ │ │ └── index.ts
│ │ └── utils
│ │ │ └── cookies.ts
│ └── tsconfig.json
├── db
│ ├── .env.example
│ ├── README.md
│ ├── package.json
│ ├── schema.prisma
│ └── src
│ │ ├── index.ts
│ │ ├── models
│ │ ├── Apartment.ts
│ │ └── index.ts
│ │ └── types.ts
└── server
│ ├── package.json
│ ├── src
│ ├── auth
│ │ └── clerkHandler.ts
│ ├── index.ts
│ └── routes
│ │ ├── apartments.ts
│ │ └── user.ts
│ └── tsconfig.json
├── tsconfig.json
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 | # Port on which the server will receive connections
2 | SERVER_PORT=
3 | # Origin of the client app
4 | CLIENT_ORIGIN=
5 | # Clerk Secret for your application backend. You can retrieve it from https://dashboard.clerk.dev under Settings → API Keys
6 | CLERK_SECRET_KEY=
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | .env.local
3 | node_modules
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["prisma.prisma"]
3 | }
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Clerk Fastify React Prisma Starter
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | This repo shows an example use case for how you setup a fullstack monorepo starter with [Clerk](https://clerk.dev?utm_source=github&utm_medium=starters&utm_campaign=cfrp), Fastify, React and Prisma to achieve authenticated cross-domain user access.
14 |
15 | # Clerk Apartments Application
16 |
17 | ## The application
18 |
19 | The **Clerk Apartments** application allows a user to claim apartments from the gallery and view them in his own collection. Any apartment that is "claimed" by a user, cannot be reclaimed unless "foregone" by the previous holder.
20 |
21 | ## Under the hood
22 |
23 | The example is a fullstack application in a monorepo structure using:
24 |
25 | - [Clerk](https://clerk.dev?utm_source=github&utm_medium=starters&utm_campaign=cfrp) as an authentication provider.
26 | - [Fastify](https://www.fastify.io/) as the API server.
27 | - [React](https://reactjs.org/) as the frontend library.
28 | - [Prisma](https://www.prisma.io/) for data storage and model type sharing between client and server.
29 | - [Yarn workspaces](https://yarnpkg.com/features/workspaces) for the monorepo management.
30 |
31 | ## Where the magic happens
32 |
33 | Authenticating Prisma data access using Clerk works by introducing a thin and customizable access management layer on top of the Prisma generated API for our collection.
34 |
35 | This ultimately gets handled in the Fastify API routes with simple logic and the use of the Clerk authentication [preHandler hook](./packages/server/src/auth/clerkHandler.ts), like in the [/apartments routes](./packages/server/src/routes/apartments.ts).
36 |
37 | ## Running the example
38 |
39 | To run the example locally you need to:
40 |
41 | 1. Sign up for a Clerk account at [https://clerk.dev/](http://clerk.dev/?utm_source=github&utm_medium=starters&utm_campaign=cfrp).
42 | 2. Clone this repository `git clone git@github.com:clerkinc/clerk-fastify-react-prisma-starter.git`.
43 | 3. Setup the required API variables from your Clerk project as shown at the example env files. [Server](./.env.example) [Client](./packages/client/.env.example)
44 | 4. `yarn install` to install the required dependencies.
45 | 5. Setup your Prisma database, following the [instructions](./packages/db/README.md) at the `db` folder.
46 | 6. To start both the client and the server you need to run in separate terminals from the top level of the repository the commands: `yarn client:dev` and `yarn server:dev`
47 |
48 | ## Contact
49 |
50 | If you have any specific use case or anything you would like to ask, please reach out!
51 |
--------------------------------------------------------------------------------
/docs/cfrp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clerk/clerk-fastify-react-prisma-starter/678a6101221edb743c3b29218b41d59f14f885ce/docs/cfrp.png
--------------------------------------------------------------------------------
/docs/show.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clerk/clerk-fastify-react-prisma-starter/678a6101221edb743c3b29218b41d59f14f885ce/docs/show.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "clerk-fastify-react-prisma-starter",
4 | "workspaces": [
5 | "packages/**/*"
6 | ],
7 | "scripts": {
8 | "server:dev": "nodemon --watch './packages/server/**/*.ts' --exec 'ts-node' ./packages/server/src/index.ts",
9 | "client:dev": "yarn workspace @cfrp/client start",
10 | "prisma:schema": "yarn workspace @cfrp/db generate",
11 | "prisma:studio": "yarn workspace @cfrp/db studio"
12 | },
13 | "devDependencies": {
14 | "nodemon": "^2.0.14",
15 | "ts-node": "^10.4.0",
16 | "typescript": "^4.6.2"
17 | },
18 | "engines": {
19 | "node": ">=14"
20 | },
21 | "dependencies": {}
22 | }
23 |
--------------------------------------------------------------------------------
/packages/client/.env.example:
--------------------------------------------------------------------------------
1 | # Clerk API endpoint for your application. You can retrieve it from https://dashboard.clerk.dev
2 | REACT_APP_CLERK_PUBLISHABLE_KEY=
3 | # The backend API host
4 | REACT_APP_API_HOST=
--------------------------------------------------------------------------------
/packages/client/.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 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/packages/client/README.md:
--------------------------------------------------------------------------------
1 | # @cfrp/client
2 |
--------------------------------------------------------------------------------
/packages/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cfrp/client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@clerk/clerk-react": "latest",
7 | "@testing-library/jest-dom": "^5.11.4",
8 | "@testing-library/react": "^11.1.0",
9 | "@testing-library/user-event": "^12.1.10",
10 | "@types/jest": "^26.0.15",
11 | "@types/node": "^14.0.0",
12 | "@types/react": "^18.0.0",
13 | "@types/react-dom": "^18.0.0",
14 | "clsx": "^1.2.1",
15 | "react": "^18.2.0",
16 | "react-dom": "^18.2.0",
17 | "react-router-dom": "^6.8.0",
18 | "react-scripts": "5.0.1",
19 | "typescript": "^4.6.2"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "react-scripts test",
25 | "eject": "react-scripts eject"
26 | },
27 | "eslintConfig": {
28 | "extends": [
29 | "react-app",
30 | "react-app/jest"
31 | ]
32 | },
33 | "browserslist": {
34 | "production": [
35 | ">0.2%",
36 | "not dead",
37 | "not op_mini all"
38 | ],
39 | "development": [
40 | "last 1 chrome version",
41 | "last 1 firefox version",
42 | "last 1 safari version"
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/client/public/img/apartment.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clerk/clerk-fastify-react-prisma-starter/678a6101221edb743c3b29218b41d59f14f885ce/packages/client/public/img/apartment.jpg
--------------------------------------------------------------------------------
/packages/client/public/img/clerk-logo.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/packages/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
21 | Clerk Apartments
22 |
23 |
24 |
25 |
26 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/packages/client/src/API/apartments.ts:
--------------------------------------------------------------------------------
1 | import { Apartment } from "../types";
2 | import { fetcher } from "./fetcher";
3 |
4 | export async function getApartments(): Promise {
5 | return await (await fetcher("/apartments")).json();
6 | }
7 |
8 | export async function claimApartment(apartmentId: string): Promise {
9 | return await (
10 | await fetcher(
11 | "/apartments/claim",
12 | { method: "POST", body: JSON.stringify({ apartmentId }) },
13 | true
14 | )
15 | ).json();
16 | }
17 |
--------------------------------------------------------------------------------
/packages/client/src/API/fetcher.ts:
--------------------------------------------------------------------------------
1 | import { getCookie } from "../utils/cookies";
2 |
3 | export function fetcher(
4 | path: string,
5 | options: RequestInit = {},
6 | auth: boolean = false
7 | ) {
8 | return fetch(`${process.env.REACT_APP_API_HOST}${path}`, {
9 | headers: {
10 | ...(auth && { Authorization: `Bearer ${getCookie("__session")}` }),
11 | },
12 | ...options,
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/packages/client/src/API/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./apartments";
2 | export * from "./user";
3 |
--------------------------------------------------------------------------------
/packages/client/src/API/user.ts:
--------------------------------------------------------------------------------
1 | import { Apartment } from "../types";
2 | import { fetcher } from "./fetcher";
3 |
4 | export async function getUserApartments(): Promise {
5 | return await (await fetcher("/user/apartments", {}, true)).json();
6 | }
7 |
8 | export async function foregoApartment(apartmentId: string) {
9 | return await fetcher(
10 | "/user/forego",
11 | { method: "POST", body: JSON.stringify({ apartmentId }) },
12 | true
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/packages/client/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | render();
7 | const linkElement = screen.getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/packages/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { ClerkProvider } from "@clerk/clerk-react";
2 | import { useNavigate } from "react-router-dom";
3 | import { Apartments } from "./components/Apartments";
4 | import { Header } from "./components/Header";
5 | import { Routes, Route } from "react-router-dom";
6 | import { MyApartments } from "./components/MyApartments";
7 | import { Footer } from "./components/Footer";
8 |
9 | // Get the Frontend API from the environment
10 | const publishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || "";
11 |
12 | function App() {
13 | const navigate = useNavigate();
14 | return (
15 | navigate(to)}
18 | >
19 |
20 |
21 | }>
22 | }>
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | export default App;
30 |
--------------------------------------------------------------------------------
/packages/client/src/assets/clerk-logo.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/packages/client/src/assets/fonts/Quicksand-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clerk/clerk-fastify-react-prisma-starter/678a6101221edb743c3b29218b41d59f14f885ce/packages/client/src/assets/fonts/Quicksand-Regular.ttf
--------------------------------------------------------------------------------
/packages/client/src/components/ApartmentCard.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | width: 100%;
3 | border-radius: 4px;
4 | box-shadow: 0 2px 4px rgb(39 54 86 / 14%), 0 1px 10px rgb(39 54 86 / 20%);
5 | margin-bottom: 32px;
6 | margin-left: 12px;
7 | margin-right: 12px;
8 | }
9 |
10 | .info {
11 | display: flex;
12 | flex-direction: column;
13 | height: 100%;
14 | padding: 16px;
15 | width: 100%;
16 | }
17 |
18 | .title {
19 | font-weight: 600;
20 | }
21 |
22 | .imageContainer {
23 | height: 350px;
24 | border-top-left-radius: 4px;
25 | border-top-right-radius: 4px;
26 | overflow: hidden;
27 | position: relative;
28 | width: 100%;
29 | }
30 |
31 | .image {
32 | width: 100%;
33 | object-fit: cover;
34 | height: 100%;
35 | }
36 |
37 | .amenities {
38 | display: flex;
39 | justify-content: flex-start;
40 | line-height: 1.4rem;
41 | margin-bottom: 16px;
42 | margin-top: 4px;
43 | overflow: hidden;
44 | text-overflow: ellipsis;
45 | }
46 |
47 | .priceContainer {
48 | font-size: 1rem;
49 | font-weight: 400;
50 | display: flex;
51 | justify-content: space-between;
52 | }
53 |
54 | .amount {
55 | font-size: 1.5rem;
56 | font-weight: 600;
57 | }
58 |
59 | .actionButton {
60 | border: 1px solid;
61 | border-radius: 36px;
62 | font-size: 0.857rem;
63 | line-height: 1.429rem;
64 | padding: 4px 12px;
65 | background-color: #f3f7fc;
66 | border-color: #d6e6f2;
67 | color: #273656;
68 | }
69 |
70 | .claimed {
71 | color: white;
72 | background-color: #eb594b;
73 | font-weight: 600;
74 | }
75 |
76 | .forego {
77 | background-color: #f4cb7f;
78 | }
79 |
80 | @media (min-width: 830px) {
81 | .card {
82 | width: calc(50% - 8px);
83 | margin-left: 0px;
84 | margin-right: 0px;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/packages/client/src/components/ApartmentCard.tsx:
--------------------------------------------------------------------------------
1 | import { useClerk, withClerk } from "@clerk/clerk-react";
2 | import { WithClerkProp } from "@clerk/clerk-react/dist/types";
3 | import clsx from "clsx";
4 | import { Apartment } from "../types";
5 | import styles from "./ApartmentCard.module.css";
6 |
7 | type ApartmentCardProps = {
8 | apartment: Apartment;
9 | foregoApartment?: () => Promise;
10 | claimApartment?: () => Promise;
11 | };
12 |
13 | export function ApartmentCard({
14 | apartment,
15 | foregoApartment,
16 | claimApartment,
17 | }: ApartmentCardProps): JSX.Element {
18 | return (
19 |
20 |
21 |

26 |
27 |
28 |
{apartment.title}
29 |
30 | {apartment.amenities.join(" | ")}
31 |
32 |
33 |
34 | from {apartment.price}$/month
35 |
36 | {claimApartment && (
37 |
)}
41 | {foregoApartment && (
42 |
48 | )}
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | type ClaimApartmentProps = WithClerkProp<{
56 | claimedBy: string | null;
57 | claimApartment: () => Promise;
58 | }>;
59 |
60 | const ClaimApartment = withClerk(
61 | ({ claimedBy, claimApartment }: ClaimApartmentProps) => {
62 | const clerk = useClerk();
63 |
64 | return (
65 |
72 | );
73 | }
74 | );
75 |
--------------------------------------------------------------------------------
/packages/client/src/components/Apartments.tsx:
--------------------------------------------------------------------------------
1 | import { ApartmentCard } from "./ApartmentCard";
2 | import { useApartments } from "../hooks";
3 | import { GridLayout } from "../layouts/GridLayout";
4 |
5 | export function Apartments() {
6 | const { apartments, claimApartment } = useApartments();
7 |
8 | return (
9 |
10 | {apartments.map((apartment) => (
11 | claimApartment(apartment.id)}
15 | />
16 | ))}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/packages/client/src/components/Button.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | font-size: 1rem;
3 | height: 36px;
4 | padding: 0 24px;
5 | border-color: #4979ab;
6 | color: #4979ab;
7 | background: transparent;
8 |
9 | align-items: center;
10 | border-radius: 4px;
11 | border-style: solid;
12 | border-width: 2px;
13 | cursor: pointer;
14 | display: inline-flex;
15 | font-weight: 600;
16 | justify-content: center;
17 | line-height: 1;
18 | text-decoration: none;
19 | transition: background-color 0.3s ease, border 0.3s ease, color 0.3s ease,
20 | opacity 0.3s ease;
21 | }
22 |
23 | .button:hover {
24 | background-color: #f3f7fc;
25 | }
26 |
27 | .naked {
28 | border: none;
29 | padding: 0 6px;
30 | }
31 |
--------------------------------------------------------------------------------
/packages/client/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import styles from "./Button.module.css";
3 |
4 | type ButtonProps = {
5 | children: React.ReactNode;
6 | handleClick?: () => void;
7 | naked?: boolean;
8 | };
9 |
10 | export function Button({
11 | children,
12 | handleClick,
13 | naked = false,
14 | }: ButtonProps): JSX.Element {
15 | return (
16 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/packages/client/src/components/Footer.module.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | width: 100%;
3 | padding: 24px;
4 | border-top: 1px solid #e3dede;
5 | display: flex;
6 | position: fixed;
7 | bottom: 0;
8 | left: 0;
9 | }
10 |
11 | .contents {
12 | max-width: 1080px;
13 | }
14 |
15 | .footerTitle {
16 | opacity: 0.6;
17 | font-size: 1rem;
18 | letter-spacing: 1px;
19 | }
20 |
--------------------------------------------------------------------------------
/packages/client/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Footer.module.css";
2 |
3 | export function Footer() {
4 | return (
5 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/packages/client/src/components/Header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | padding: 0 24px;
3 | align-items: center;
4 | box-sizing: border-box;
5 | display: flex;
6 | height: 64px;
7 | justify-content: space-between;
8 | border-bottom: 1px solid #e3dede;
9 | margin-bottom: 48px;
10 | }
11 |
12 | .logoLink {
13 | display: flex;
14 | align-items: center;
15 | }
16 |
17 | .logoRow {
18 | display: flex;
19 | align-items: center;
20 | }
21 |
22 | .logoRowTitle {
23 | margin-left: 0.5rem;
24 | font-size: 1.5rem;
25 | letter-spacing: 2px;
26 | display: none;
27 | }
28 |
29 | .authButtons button:nth-of-type(1) {
30 | margin-right: 6px;
31 | }
32 |
33 | .separator {
34 | width: 1px;
35 | background-color: #e3dede;
36 | height: 24px;
37 | margin: 0 14px 0 16px;
38 | }
39 |
40 | .logoRowLink {
41 | font-size: 1rem;
42 | }
43 |
44 | @media (min-width: 830px) {
45 | .logoRowTitle {
46 | display: block;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/client/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Header.module.css";
2 | import { ReactComponent as ClerkLogo } from "../assets/clerk-logo.svg";
3 | import { SignedOut, SignedIn, useClerk, withClerk } from "@clerk/clerk-react";
4 | import { Button } from "./Button";
5 | import { Link } from "react-router-dom";
6 |
7 | export const Header = withClerk(() => {
8 | const clerk = useClerk();
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
ClerkApartments
17 |
18 |
19 |
20 |
21 | My Apartments
22 |
23 |
24 |
25 |
26 |
27 |
30 |
31 |
32 |
33 |
36 |
39 |
40 |
41 |
42 |
43 | );
44 | });
45 |
--------------------------------------------------------------------------------
/packages/client/src/components/MyApartments.module.css:
--------------------------------------------------------------------------------
1 | .homeLink {
2 | font-weight: 500;
3 | color: #4979ab;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/client/src/components/MyApartments.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./MyApartments.module.css";
2 | import { Link } from "react-router-dom";
3 | import { useUserApartments } from "../hooks/useUserApartments";
4 | import { GridLayout } from "../layouts/GridLayout";
5 | import { ApartmentCard } from "./ApartmentCard";
6 |
7 | export function MyApartments() {
8 | const { userApartments, foregoApartment } = useUserApartments();
9 |
10 | return (
11 |
12 | {userApartments.length ? (
13 | userApartments.map((userApartment) => (
14 | foregoApartment(userApartment.id)}
18 | />
19 | ))
20 | ) : (
21 |
22 | )}
23 |
24 | );
25 | }
26 |
27 | function GoClaimApartmentsLink() {
28 | return (
29 |
30 | No apartments claimed yet!{" "}
31 |
32 | Go claim some
33 | {" "}
34 | if available!
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/packages/client/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./useApartments";
2 |
--------------------------------------------------------------------------------
/packages/client/src/hooks/useApartments.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import {
3 | getApartments,
4 | claimApartment as claimApartmentApi,
5 | getUserApartments as getUserApartmentsApi,
6 | } from "../API";
7 | import { Apartment } from "../types";
8 |
9 | export function useApartments(): {
10 | apartments: Apartment[];
11 | userApartments: Apartment[];
12 | claimApartment: (apartmentId: string) => Promise;
13 | getUserApartments: () => Promise;
14 | } {
15 | const [apartments, setApartments] = useState([]);
16 | const [userApartments, setUserApartments] = useState([]);
17 |
18 | useEffect(() => {
19 | async function getApartmentsState() {
20 | const apartments = await getApartments();
21 | setApartments(apartments);
22 | }
23 | getApartmentsState();
24 | }, []);
25 |
26 | const claimApartment = async (apartmentId: string) => {
27 | try {
28 | const updatedApartment = await claimApartmentApi(apartmentId);
29 | setApartments(
30 | apartments.map((apartment) =>
31 | apartment.id === apartmentId
32 | ? { ...apartment, claimedBy: updatedApartment.claimedBy }
33 | : apartment
34 | )
35 | );
36 | } catch (err) {
37 | throw err;
38 | }
39 | };
40 |
41 | const getUserApartments = async () => {
42 | try {
43 | const userClaimedApartments = await getUserApartmentsApi();
44 | setUserApartments(userClaimedApartments);
45 | } catch (err) {
46 | throw err;
47 | }
48 | };
49 |
50 | return { apartments, userApartments, claimApartment, getUserApartments };
51 | }
52 |
--------------------------------------------------------------------------------
/packages/client/src/hooks/useUserApartments.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import {
3 | getUserApartments as getUserApartmentsApi,
4 | foregoApartment as foregoApartmentApi,
5 | } from "../API";
6 | import { Apartment } from "../types";
7 |
8 | export function useUserApartments(): {
9 | userApartments: Apartment[];
10 | getUserApartments: () => Promise;
11 | foregoApartment: (apartmentId: string) => Promise;
12 | } {
13 | const [userApartments, setUserApartments] = useState([]);
14 |
15 | useEffect(() => {
16 | getUserApartments();
17 | }, []);
18 |
19 | const getUserApartments = async () => {
20 | try {
21 | const userClaimedApartments = await getUserApartmentsApi();
22 | setUserApartments(userClaimedApartments);
23 | } catch (err) {
24 | throw err;
25 | }
26 | };
27 |
28 | const foregoApartment = async (apartmentId: string) => {
29 | try {
30 | /** Find the foregone apartment and remove it from my apartments. */
31 | await foregoApartmentApi(apartmentId);
32 | const deletedPostIndex = userApartments.findIndex(
33 | (apartment) => apartment.id === apartmentId
34 | );
35 | userApartments.splice(deletedPostIndex, 1);
36 | setUserApartments([...userApartments]);
37 | } catch (err) {
38 | throw err;
39 | }
40 | };
41 |
42 | return { userApartments, getUserApartments, foregoApartment };
43 | }
44 |
--------------------------------------------------------------------------------
/packages/client/src/index.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Quicksand";
3 | src: local("Quicksand"),
4 | url("./assets/fonts/Quicksand-Regular.ttf") format("truetype");
5 | }
6 |
7 | body {
8 | margin: 0;
9 | font-family: "Quicksand", sans-serif;
10 | -webkit-font-smoothing: antialiased;
11 | -moz-osx-font-smoothing: grayscale;
12 |
13 | /* Common */
14 | font-size: 1rem;
15 | font-style: normal;
16 | font-weight: 400;
17 | letter-spacing: -0.01rem;
18 | line-height: 1.571rem;
19 | color: #273656;
20 | }
21 |
22 | code {
23 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
24 | monospace;
25 | }
26 |
27 | * {
28 | box-sizing: border-box;
29 | }
30 |
31 | button {
32 | font-family: "Quicksand";
33 | border: none;
34 | outline: none;
35 | background: none;
36 | cursor: pointer;
37 | }
38 |
39 | a {
40 | text-decoration: none;
41 | color: unset;
42 | }
43 |
--------------------------------------------------------------------------------
/packages/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import "./index.css";
4 | import App from "./App";
5 | import { BrowserRouter } from "react-router-dom";
6 |
7 | ReactDOM.render(
8 |
9 |
10 |
11 |
12 | ,
13 | document.getElementById("root")
14 | );
15 |
--------------------------------------------------------------------------------
/packages/client/src/layouts/GridLayout.module.css:
--------------------------------------------------------------------------------
1 | .apartments {
2 | max-width: 1100px;
3 | margin: 0 auto;
4 | }
5 |
6 | .grid {
7 | display: flex;
8 | flex-wrap: wrap;
9 | justify-content: space-around;
10 | }
11 |
12 | @media (min-width: 830px) {
13 | .grid {
14 | justify-content: space-between;
15 | margin: 0 8px;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/client/src/layouts/GridLayout.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./GridLayout.module.css";
2 |
3 | export function GridLayout({ children }: { children: React.ReactNode }) {
4 | return (
5 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/packages/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/client/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import { Apartment as ApartmentModelType } from "@cfrp/db";
2 |
3 | export type Apartment = Pick<
4 | ApartmentModelType,
5 | "id" | "amenities" | "title" | "price" | "imageURL" | "claimedBy"
6 | >;
7 |
--------------------------------------------------------------------------------
/packages/client/src/utils/cookies.ts:
--------------------------------------------------------------------------------
1 | export function getCookie(name: string) {
2 | const value = `; ${document.cookie}`;
3 | const parts = value.split(`; ${name}=`);
4 | if (parts.length === 2) return parts.pop()!.split(";").shift();
5 | }
6 |
--------------------------------------------------------------------------------
/packages/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": [
4 | "src"
5 | ],
6 | "compilerOptions": {
7 | "allowSyntheticDefaultImports": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "isolatedModules": true,
10 | "noEmit": true,
11 | "jsx": "react-jsx",
12 | "allowJs": true,
13 | "skipLibCheck": true,
14 | "noFallthroughCasesInSwitch": true,
15 | "module": "esnext"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/db/.env.example:
--------------------------------------------------------------------------------
1 | # You can use the connection string from MongodDB atlas
2 | DATABASE_URL=
--------------------------------------------------------------------------------
/packages/db/README.md:
--------------------------------------------------------------------------------
1 | # @cfrp/db
2 |
3 | To setup the Prisma MongoDB database connector you need to follow some specific steps:
4 |
5 | 0. (_Optional but recommended_): Because of the [transactional nature](https://www.prisma.io/docs/concepts/database-connectors/mongodb#example) of the Prisma MongoDB connector, your MongoDB should be in replica-set mode. To get started it is recommended to use a **free** [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) database.
6 |
7 | 1. Create a `.env` file inside the **prisma** folder and add the `DATABASE_URL` value of the MongoDB database instance you would like to use.
8 |
9 | 2. Execute `yarn generate`, or `yarn prisma:schema` if you are in the top level of the monorepo, to generate the prisma-client files required.
10 |
11 | 3. (_Optional_): Execute `yarn studio` or `yarn prisma:studio` to open up the Prisma Studio instance, where you can add some test data for the app.
12 |
--------------------------------------------------------------------------------
/packages/db/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cfrp/db",
3 | "version": "1.0.0",
4 | "devDependencies": {
5 | "prisma": "^3.4.1"
6 | },
7 | "dependencies": {
8 | "@prisma/client": "^3.4.1"
9 | },
10 | "main": "src/index.ts",
11 | "scripts": {
12 | "generate": "prisma generate --schema ./schema.prisma",
13 | "studio": "prisma studio --schema ./schema.prisma"
14 | }
15 | }
--------------------------------------------------------------------------------
/packages/db/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "mongodb"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | previewFeatures = ["mongodb"]
9 | }
10 |
11 | model Apartment {
12 | id String @id @default(auto()) @map("_id") @db.ObjectId
13 | createdAt DateTime @default(now())
14 | title String
15 | imageURL String
16 | amenities String[]
17 | price Int
18 | claimedBy String?
19 | }
20 |
--------------------------------------------------------------------------------
/packages/db/src/index.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | // Prevent multiple instances of Prisma Client in development
4 | declare const global: typeof globalThis & { prisma?: PrismaClient };
5 |
6 | const prisma = global.prisma || new PrismaClient();
7 | if (process.env.NODE_ENV === "development") global.prisma = prisma;
8 |
9 | export * from "./types";
10 | export * from "./models";
11 | export default prisma;
12 |
--------------------------------------------------------------------------------
/packages/db/src/models/Apartment.ts:
--------------------------------------------------------------------------------
1 | import type { Prisma } from ".prisma/client";
2 | import prisma from "../index";
3 |
4 | export async function getApartments() {
5 | return await prisma.apartment.findMany();
6 | }
7 |
8 | export async function claimApartment(id: string, userId: string) {
9 | return await prisma.apartment.update({
10 | where: { id },
11 | data: { claimedBy: userId },
12 | });
13 | }
14 |
15 | export async function getUserApartments(userId: string) {
16 | return await prisma.apartment.findMany({ where: { claimedBy: userId } });
17 | }
18 |
19 | export async function getApartmentById(id: string) {
20 | return await prisma.apartment.findUnique({ where: { id } });
21 | }
22 |
23 | export async function updateApartment(
24 | id: string,
25 | data: Prisma.ApartmentUpdateInput
26 | ) {
27 | return await prisma.apartment.update({
28 | where: { id },
29 | data,
30 | });
31 | }
32 |
--------------------------------------------------------------------------------
/packages/db/src/models/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Apartment";
2 |
--------------------------------------------------------------------------------
/packages/db/src/types.ts:
--------------------------------------------------------------------------------
1 | /** Prisma provides typings directly from the models you created using the `prisma generate` command */
2 | export type { Apartment, Prisma } from "@prisma/client";
3 |
4 | /**
5 | * Additionally it provides typings for all the operations that can be done on your Apartment model e.g.:
6 | * ApartmentCreateArgs, ApartmentDeleteArgs etc.
7 | *
8 | * These are exported from the Prisma namespace.
9 | */
10 |
--------------------------------------------------------------------------------
/packages/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cfrp/server",
3 | "version": "1.0.0",
4 | "dependencies": {
5 | "@cfrp/db": "1.0.0",
6 | "@clerk/fastify": "^0.1.1",
7 | "dotenv": "^16.0.3",
8 | "fastify": "^4.12.0",
9 | "@fastify/cors": "^8.0.0",
10 | "fastify-env": "^2.2.0",
11 | "fastify-plugin": "^4.5.0"
12 | },
13 | "devDependencies": {
14 | "@types/node": "^16.11.6"
15 | },
16 | "main": "./src/index.ts"
17 | }
18 |
--------------------------------------------------------------------------------
/packages/server/src/auth/clerkHandler.ts:
--------------------------------------------------------------------------------
1 | import { getAuth } from "@clerk/fastify";
2 | import { FastifyRequest, FastifyReply } from "fastify";
3 |
4 | export async function clerkPreHandler(
5 | req: FastifyRequest,
6 | reply: FastifyReply
7 | ) {
8 | const { sessionId } = getAuth(req);
9 | if (!sessionId) {
10 | reply.status(401);
11 | reply.send({ error: "User could not be verified" });
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import dotenv from "dotenv";
2 | dotenv.config();
3 |
4 | import fastify from "fastify";
5 | import fastifyCors from "@fastify/cors";
6 | import {clerkPlugin} from "@clerk/fastify";
7 | import ApartmentRoutes from "./routes/apartments";
8 | import UserRoutes from "./routes/user";
9 |
10 | const server = fastify();
11 |
12 | const start = async () => {
13 | try {
14 | await server.register(clerkPlugin);
15 | await server.register(fastifyCors, {
16 | origin: process.env.CLIENT_ORIGIN,
17 | allowedHeaders: ["Authorization"],
18 | });
19 |
20 | await server.register(ApartmentRoutes);
21 | await server.register(UserRoutes);
22 | console.log('Listening to port: ', process.env.SERVER_PORT)
23 | await server.listen({ port: Number(process.env.SERVER_PORT) });
24 | } catch (err) {
25 | server.log.error(err);
26 | process.exit(1);
27 | }
28 | };
29 |
30 | start();
31 |
--------------------------------------------------------------------------------
/packages/server/src/routes/apartments.ts:
--------------------------------------------------------------------------------
1 | import { FastifyInstance, FastifyPluginAsync } from "fastify";
2 | import fp from "fastify-plugin";
3 | import { getApartments, claimApartment } from "@cfrp/db";
4 | import { clerkPreHandler } from "../auth/clerkHandler";
5 | import { getAuth } from "@clerk/fastify";
6 |
7 | const ApartmentRoutes: FastifyPluginAsync = async (server: FastifyInstance) => {
8 | server.get("/apartments", {}, async (_, reply) => {
9 | const apartments = await getApartments();
10 | return reply.send(apartments);
11 | });
12 |
13 | server.post(
14 | "/apartments/claim",
15 | {
16 | preHandler: clerkPreHandler,
17 | },
18 | async (request, reply) => {
19 | const auth = getAuth(request);
20 | const userId = auth.userId as string;
21 | const apartmentId = JSON.parse(request.body as string).apartmentId;
22 | const updatedApartment = await claimApartment(apartmentId, userId);
23 |
24 | return reply.send(updatedApartment);
25 | }
26 | );
27 | };
28 |
29 | export default fp(ApartmentRoutes);
30 |
--------------------------------------------------------------------------------
/packages/server/src/routes/user.ts:
--------------------------------------------------------------------------------
1 | import { FastifyInstance, FastifyPluginAsync } from "fastify";
2 | import fp from "fastify-plugin";
3 | import { getAuth } from "@clerk/fastify";
4 | import { getUserApartments, getApartmentById, updateApartment } from "@cfrp/db";
5 | import { clerkPreHandler } from "../auth/clerkHandler";
6 |
7 | const UserRoutes: FastifyPluginAsync = async (server: FastifyInstance) => {
8 | server.get(
9 | "/user/apartments",
10 | { preHandler: clerkPreHandler },
11 | async (request, reply) => {
12 | const { userId } = getAuth(request);
13 | const userApartments = await getUserApartments(userId as string);
14 | return reply.send(userApartments);
15 | }
16 | );
17 |
18 | server.post(
19 | "/user/forego",
20 | { preHandler: clerkPreHandler },
21 | async (request, reply) => {
22 | const { userId } = getAuth(request);
23 | const apartmentId = JSON.parse(request.body as string).apartmentId;
24 | const apartmentToForego = await getApartmentById(apartmentId);
25 |
26 | if (!apartmentToForego || userId !== apartmentToForego.claimedBy) {
27 | reply.status(401);
28 | return reply.send({ error: "Apartment not found" });
29 | }
30 |
31 | await updateApartment(apartmentId, { claimedBy: null });
32 | return reply.send({ success: true });
33 | }
34 | );
35 | };
36 |
37 | export default fp(UserRoutes);
38 |
--------------------------------------------------------------------------------
/packages/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig",
3 | "include": ["src"],
4 | "compilerOptions": {
5 | "module": "commonjs"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "esModuleInterop": true,
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "module": "ESNEXT",
7 | "moduleResolution": "node",
8 | "resolveJsonModule": true,
9 | "paths": {},
10 | "strict": true,
11 | "target": "ESNEXT"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------