├── client
├── public
│ ├── _redirects
│ ├── robots.txt
│ ├── favicon.ico
│ ├── logo192.png
│ ├── logo512.png
│ ├── paperhouses.png
│ ├── paperhouses-logo.png
│ ├── manifest.json
│ └── index.html
├── src
│ ├── lib
│ │ ├── components
│ │ │ ├── Map
│ │ │ │ ├── index.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── assets
│ │ │ │ │ └── mapbox-icon.png
│ │ │ │ ├── map.css
│ │ │ │ ├── ShowLocation.tsx
│ │ │ │ └── SelectLocation.tsx
│ │ │ ├── AppHeader
│ │ │ │ ├── components
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── MenuItems
│ │ │ │ │ │ └── index.tsx
│ │ │ │ ├── assets
│ │ │ │ │ ├── paperhouses-logo.png
│ │ │ │ │ └── paperhouses-logo2.png
│ │ │ │ └── index.tsx
│ │ │ ├── index.ts
│ │ │ ├── AppHeaderSkeleton
│ │ │ │ ├── assets
│ │ │ │ │ └── paper-houses-logo.png
│ │ │ │ └── index.tsx
│ │ │ ├── PageSkeleton
│ │ │ │ └── index.tsx
│ │ │ ├── ErrorBanner
│ │ │ │ └── index.tsx
│ │ │ └── ListingCard
│ │ │ │ └── index.tsx
│ │ ├── graphql
│ │ │ ├── subscriptions
│ │ │ │ ├── index.ts
│ │ │ │ ├── ListingBooked
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── ListingBooked.ts
│ │ │ │ └── SendMessage
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ └── SendMessage.ts
│ │ │ ├── queries
│ │ │ │ ├── AuthUrl
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── AuthUrl.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── Chat
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── Chat.ts
│ │ │ │ ├── Listings
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── Listings.ts
│ │ │ │ ├── Listing
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── Listing.ts
│ │ │ │ └── User
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ └── User.ts
│ │ │ ├── mutations
│ │ │ │ ├── DisconnectStripe
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── DisconnectStripe.ts
│ │ │ │ ├── HostListing
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── HostListing.ts
│ │ │ │ ├── LogOut
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── LogOut.ts
│ │ │ │ ├── DeleteReview
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── DeleteReview.ts
│ │ │ │ ├── CreateBooking
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── CreateBooking.ts
│ │ │ │ ├── CreateMessage
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── CreateMessage.ts
│ │ │ │ ├── ConnectStripe
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── ConnectStripe.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── LogIn
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── LogIn.ts
│ │ │ │ ├── UpdateListing
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ │ └── UpdateListing.ts
│ │ │ │ └── CreateReview
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── __generated__
│ │ │ │ │ └── CreateReview.ts
│ │ │ └── globalTypes.ts
│ │ ├── types.ts
│ │ ├── hooks
│ │ │ └── useScrollToTop.ts
│ │ └── utils
│ │ │ └── index.ts
│ ├── react-app-env.d.ts
│ ├── sections
│ │ ├── Home
│ │ │ ├── assets
│ │ │ │ ├── dubai.jpg
│ │ │ │ ├── cancun.jpg
│ │ │ │ ├── london.jpg
│ │ │ │ ├── toronto.jpg
│ │ │ │ ├── los-angeles.jpg
│ │ │ │ ├── san-fransisco.jpg
│ │ │ │ ├── map-background.jpg
│ │ │ │ └── listing-loading-card-cover.jpg
│ │ │ ├── components
│ │ │ │ ├── index.ts
│ │ │ │ ├── HomeListings
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── HomeListingsSkeleton
│ │ │ │ │ └── index.tsx
│ │ │ │ └── HomeHero
│ │ │ │ │ └── index.tsx
│ │ │ ├── tests
│ │ │ │ └── Home.test.tsx
│ │ │ └── index.tsx
│ │ ├── User
│ │ │ ├── components
│ │ │ │ ├── index.ts
│ │ │ │ ├── UserListings
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── UserBookings
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── UserChats
│ │ │ │ │ └── index.tsx
│ │ │ │ └── UserProfile
│ │ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── Login
│ │ │ ├── assets
│ │ │ │ └── google_logo.jpg
│ │ │ └── index.tsx
│ │ ├── Listings
│ │ │ ├── components
│ │ │ │ ├── index.ts
│ │ │ │ ├── ListingsPagination
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ListingsSkeleton
│ │ │ │ │ └── index.tsx
│ │ │ │ └── ListingsFilters
│ │ │ │ │ └── index.tsx
│ │ │ ├── assets
│ │ │ │ └── listing-loading-card-cover.jpg
│ │ │ └── index.tsx
│ │ ├── index.ts
│ │ ├── Listing
│ │ │ ├── components
│ │ │ │ ├── index.ts
│ │ │ │ ├── ListingCreateBooking
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ListingBookings
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ListingDetails
│ │ │ │ │ └── index.tsx
│ │ │ │ └── CreateBookingModal
│ │ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── Chat
│ │ │ ├── components
│ │ │ │ ├── DateSeparator
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── NewMessageInput
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Messages
│ │ │ │ │ └── index.tsx
│ │ │ │ └── Message
│ │ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── NotFound
│ │ │ └── index.tsx
│ │ └── Stripe
│ │ │ └── index.tsx
│ ├── setupTests.ts
│ ├── App.test.tsx
│ ├── reportWebVitals.ts
│ ├── index.tsx
│ └── App.tsx
├── .env.example
├── Dockerfile
├── .gitignore
├── tsconfig.json
├── README.md
└── package.json
├── server
├── src
│ ├── graphql
│ │ ├── index.ts
│ │ ├── resolvers
│ │ │ ├── Chat
│ │ │ │ ├── types.ts
│ │ │ │ └── index.ts
│ │ │ ├── Viewer
│ │ │ │ └── types.ts
│ │ │ ├── Booking
│ │ │ │ └── types.ts
│ │ │ ├── Message
│ │ │ │ ├── types.ts
│ │ │ │ └── index.ts
│ │ │ ├── Review
│ │ │ │ ├── types.ts
│ │ │ │ └── index.ts
│ │ │ ├── User
│ │ │ │ ├── types.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ └── Listing
│ │ │ │ └── types.ts
│ │ └── typeDefs.ts
│ ├── lib
│ │ ├── pubSub.ts
│ │ ├── api
│ │ │ ├── index.ts
│ │ │ ├── Geocoder.ts
│ │ │ ├── Cloudinary.ts
│ │ │ ├── Google.ts
│ │ │ └── Stripe.ts
│ │ ├── utils
│ │ │ └── index.ts
│ │ └── types.ts
│ ├── database
│ │ └── index.ts
│ ├── mock
│ │ └── clear.ts
│ └── index.ts
├── Dockerfile
├── .gitignore
├── tsconfig.json
├── .env.example
├── .eslintrc.json
├── README.md
└── package.json
├── docker-compose.yml
└── README.md
/client/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
--------------------------------------------------------------------------------
/client/src/lib/components/Map/index.tsx:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/lib/components/AppHeader/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./MenuItems";
2 |
--------------------------------------------------------------------------------
/server/src/graphql/index.ts:
--------------------------------------------------------------------------------
1 | export * from './resolvers';
2 | export * from './typeDefs';
3 |
--------------------------------------------------------------------------------
/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/Chat/types.ts:
--------------------------------------------------------------------------------
1 | export interface ChatArgs {
2 | recipient: string;
3 | }
--------------------------------------------------------------------------------
/client/src/lib/components/Map/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./SelectLocation";
2 | export * from "./ShowLocation";
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/public/logo192.png
--------------------------------------------------------------------------------
/client/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/public/logo512.png
--------------------------------------------------------------------------------
/client/src/lib/graphql/subscriptions/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ListingBooked";
2 | export * from "./SendMessage";
--------------------------------------------------------------------------------
/client/.env.example:
--------------------------------------------------------------------------------
1 | REACT_APP_STRIPE_PUBLISHABLE_KEY=
2 | REACT_APP_STRIPE_CLIENT_ID=
3 | REACT_APP_MAPBOX_API_KEY=
--------------------------------------------------------------------------------
/client/public/paperhouses.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/public/paperhouses.png
--------------------------------------------------------------------------------
/server/src/lib/pubSub.ts:
--------------------------------------------------------------------------------
1 | import { PubSub } from "graphql-subscriptions";
2 |
3 | export const pubSub = new PubSub();
--------------------------------------------------------------------------------
/server/src/lib/api/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Cloudinary";
2 | export * from "./Google";
3 | export * from "./Stripe";
4 |
--------------------------------------------------------------------------------
/client/public/paperhouses-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/public/paperhouses-logo.png
--------------------------------------------------------------------------------
/client/src/sections/Home/assets/dubai.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Home/assets/dubai.jpg
--------------------------------------------------------------------------------
/client/src/sections/Home/assets/cancun.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Home/assets/cancun.jpg
--------------------------------------------------------------------------------
/client/src/sections/Home/assets/london.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Home/assets/london.jpg
--------------------------------------------------------------------------------
/client/src/sections/Home/assets/toronto.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Home/assets/toronto.jpg
--------------------------------------------------------------------------------
/client/src/sections/User/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./UserBookings";
2 | export * from "./UserListings";
3 | export * from "./UserProfile";
4 |
--------------------------------------------------------------------------------
/client/src/sections/Home/assets/los-angeles.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Home/assets/los-angeles.jpg
--------------------------------------------------------------------------------
/client/src/sections/Home/assets/san-fransisco.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Home/assets/san-fransisco.jpg
--------------------------------------------------------------------------------
/client/src/sections/Home/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./HomeHero";
2 | export * from "./HomeListings";
3 | export * from "./HomeListingsSkeleton";
4 |
--------------------------------------------------------------------------------
/client/src/sections/Login/assets/google_logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Login/assets/google_logo.jpg
--------------------------------------------------------------------------------
/client/src/sections/Home/assets/map-background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Home/assets/map-background.jpg
--------------------------------------------------------------------------------
/client/src/lib/components/Map/assets/mapbox-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/lib/components/Map/assets/mapbox-icon.png
--------------------------------------------------------------------------------
/client/src/sections/Listings/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ListingsFilters";
2 | export * from "./ListingsPagination";
3 | export * from "./ListingsSkeleton";
4 |
--------------------------------------------------------------------------------
/client/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:alpine
2 |
3 | WORKDIR "/app"
4 |
5 | COPY ./package.json ./
6 |
7 | RUN npm install
8 |
9 | COPY ./ ./
10 |
11 | CMD ["npm", "run", "start"]
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/AuthUrl/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const AUTH_URL = gql`
4 | query AuthUrl {
5 | authUrl
6 | }
7 | `;
8 |
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:alpine
2 |
3 | WORKDIR "/app"
4 |
5 | COPY ./package.json ./
6 |
7 | RUN npm install
8 |
9 | COPY ./ ./
10 |
11 | CMD ["npm", "run", "dev"]
--------------------------------------------------------------------------------
/client/src/lib/components/AppHeader/assets/paperhouses-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/lib/components/AppHeader/assets/paperhouses-logo.png
--------------------------------------------------------------------------------
/client/src/lib/components/AppHeader/assets/paperhouses-logo2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/lib/components/AppHeader/assets/paperhouses-logo2.png
--------------------------------------------------------------------------------
/client/src/sections/Home/assets/listing-loading-card-cover.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Home/assets/listing-loading-card-cover.jpg
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./AuthUrl";
2 | export * from "./Listing";
3 | export * from "./Listings";
4 | export * from "./User";
5 | export * from "./Chat";
6 |
--------------------------------------------------------------------------------
/client/src/sections/Listings/assets/listing-loading-card-cover.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/sections/Listings/assets/listing-loading-card-cover.jpg
--------------------------------------------------------------------------------
/client/src/lib/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./AppHeaderSkeleton";
2 | export * from "./ErrorBanner";
3 | export * from "./ListingCard";
4 | export * from "./PageSkeleton";
5 | export * from "./Map";
--------------------------------------------------------------------------------
/client/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | export interface Viewer {
2 | id: string | null;
3 | token: string | null;
4 | avatar: string | null;
5 | hasWallet: boolean | null;
6 | didRequest: boolean;
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/lib/api/Geocoder.ts:
--------------------------------------------------------------------------------
1 | import NodeGeoCoder from "node-geocoder"
2 |
3 | export const GeoCoder = NodeGeoCoder({
4 | provider: "mapbox",
5 | apiKey: process.env.MAPBOX_API_KEY,
6 | });
7 |
--------------------------------------------------------------------------------
/client/src/lib/components/AppHeaderSkeleton/assets/paper-houses-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saalikmubeen/paperhouses/HEAD/client/src/lib/components/AppHeaderSkeleton/assets/paper-houses-logo.png
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/Viewer/types.ts:
--------------------------------------------------------------------------------
1 | export interface LogInArgs {
2 | input: { code: string } | null;
3 | }
4 |
5 | export interface ConnectStripeArgs {
6 | input: { code: string };
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/lib/hooks/useScrollToTop.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect } from "react";
2 |
3 | export const useScrollToTop = () => {
4 | useLayoutEffect(() => {
5 | window.scrollTo(0, 0);
6 | }, []);
7 | };
8 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | node_modules/
3 |
4 | # production
5 | build/
6 |
7 | # misc
8 | .DS_Store
9 |
10 | # environment variables
11 | .env
12 |
13 | npm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
--------------------------------------------------------------------------------
/client/src/sections/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Home";
2 | export * from "./Host";
3 | export * from "./Listing";
4 | export * from "./Listings";
5 | export * from "./NotFound";
6 | export * from "./User";
7 | export * from "./Login";
8 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/DisconnectStripe/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const DISCONNECT_STRIPE = gql`
4 | mutation DisconnectStripe {
5 | disconnectStripe {
6 | hasWallet
7 | }
8 | }
9 | `;
10 |
--------------------------------------------------------------------------------
/client/src/sections/Listing/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ListingBookings";
2 | export * from "./ListingDetails";
3 | export * from "./UpdateListing";
4 | export * from "./CreateBookingModal"
5 | export * from "./ListingCreateBooking";
6 | export * from "./ListingReviews";
--------------------------------------------------------------------------------
/client/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/Booking/types.ts:
--------------------------------------------------------------------------------
1 | export interface CreateBookingInput {
2 | id: string;
3 | source: string;
4 | checkIn: string;
5 | checkOut: string;
6 | }
7 |
8 | export interface CreateBookingArgs {
9 | input: CreateBookingInput;
10 | }
11 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/HostListing/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const HOST_LISTING = gql`
4 | mutation HostListing($input: HostListingInput!) {
5 | hostListing(input: $input) {
6 | id
7 | }
8 | }
9 | `;
10 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/LogOut/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const LOG_OUT = gql`
4 | mutation LogOut {
5 | logOut {
6 | id
7 | token
8 | avatar
9 | hasWallet
10 | didRequest
11 | }
12 | }
13 | `;
14 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/DeleteReview/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const DELETE_REVIEW = gql`
4 | mutation DeleteReview($input: DeleteReviewInput!) {
5 | deleteReview(input: $input) {
6 | id
7 | }
8 | }
9 | `;
10 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/CreateBooking/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const CREATE_BOOKING = gql`
4 | mutation CreateBooking($input: CreateBookingInput!) {
5 | createBooking(input: $input) {
6 | id
7 | }
8 | }
9 | `;
10 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/CreateMessage/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const CREATE_MESSAGE = gql`
4 | mutation CreateMessage($input: CreateMessageInput!) {
5 | createMessage(input: $input) {
6 | id
7 | }
8 | }
9 | `;
10 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/ConnectStripe/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const CONNECT_STRIPE = gql`
4 | mutation ConnectStripe($input: ConnectStripeInput!) {
5 | connectStripe(input: $input) {
6 | hasWallet
7 | }
8 | }
9 | `;
10 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/Message/types.ts:
--------------------------------------------------------------------------------
1 | export interface CreateMessageInput {
2 | content: string;
3 | to: string;
4 | }
5 |
6 | export interface CreateMessageArgs {
7 | input: CreateMessageInput;
8 | }
9 |
10 |
11 | export interface SendMessageArgs {
12 | to: string;
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ConnectStripe";
2 | export * from "./DisconnectStripe";
3 | export * from "./HostListing";
4 | export * from "./LogIn";
5 | export * from "./LogOut";
6 | export * from "./CreateBooking"
7 | export * from "./CreateMessage"
8 | export * from "./UpdateListing"
9 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "commonjs",
5 | "rootDir": "./src",
6 | "outDir": "./build",
7 | "esModuleInterop": true,
8 | "strict": true,
9 | "skipLibCheck": true
10 | },
11 | "exclude": ["temp"]
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/LogIn/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const LOG_IN = gql`
4 | mutation LogIn($input: LogInInput) {
5 | logIn(input: $input) {
6 | id
7 | token
8 | avatar
9 | hasWallet
10 | didRequest
11 | }
12 | }
13 | `;
14 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/UpdateListing/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const UPDATE_LISTING = gql`
4 | mutation UpdateListing($id: ID!, $input: UpdateListingInput!) {
5 | updateListing(id: $id, input: $input) {
6 | id
7 | }
8 | }
9 | `;
10 |
--------------------------------------------------------------------------------
/client/src/sections/Listing/components/ListingCreateBooking/types.ts:
--------------------------------------------------------------------------------
1 | interface BookingsIndexMonth {
2 | [key: string]: boolean;
3 | }
4 |
5 | interface BookingsIndexYear {
6 | [key: string]: BookingsIndexMonth;
7 | }
8 |
9 | export interface BookingsIndex {
10 | [key: string]: BookingsIndexYear;
11 | }
12 |
--------------------------------------------------------------------------------
/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.skip('renders learn react link', () => {
6 | render( );
7 | const linkElement = screen.getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/client/src/sections/Chat/components/DateSeparator/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 |
4 | export const DateSeparator = ({ date }: { date: string }) => {
5 | return (
6 |
7 |
{new Date(date).toDateString()}
8 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/subscriptions/ListingBooked/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const LISTING_BOOKED = gql`
4 | subscription ListingBooked($hostId: ID!, $isHost: Boolean!) {
5 | listingBooked(hostId: $hostId, isHost: $isHost) {
6 | id
7 | title
8 | description
9 | }
10 | }
11 | `;
--------------------------------------------------------------------------------
/server/.env.example:
--------------------------------------------------------------------------------
1 | PORT=9000
2 | NODE_ENV=development
3 | GOOGLE_CLIENT_ID=
4 | GOOGLE_CLIENT_SECRET=
5 | STRIPE_CLIENT_ID=
6 | PUBLIC_URL=http://localhost:3000
7 | SECRET=myXViZWVuIiwsecretiYSI6ImNrbsuper
8 | STRIPE_SECRET_KEY=
9 | CLOUDINARY_NAME=
10 | CLOUDINARY_API_KEY=
11 | CLOUDINARY_API_SECRET=
12 | MAPBOX_API_KEY=
13 | MONGO_URI_DEV=mongodb://localhost/paper-houses
14 | MONGO_USER=
15 | MONGO_PASSWORD=
--------------------------------------------------------------------------------
/server/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "parserOptions": {
4 | "ecmaVersion": 2018,
5 | "sourceType": "module"
6 | },
7 | "extends": ["plugin:@typescript-eslint/recommended"],
8 | "env": { "node": true },
9 | "rules": {
10 | "indent": "off",
11 | "@typescript-eslint/indent": "off",
12 | "@typescript-eslint/explicit-function-return-type": "off"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/AuthUrl/__generated__/AuthUrl.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | // ====================================================
7 | // GraphQL query operation: AuthUrl
8 | // ====================================================
9 |
10 | export interface AuthUrl {
11 | authUrl: string;
12 | }
13 |
--------------------------------------------------------------------------------
/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
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/Review/types.ts:
--------------------------------------------------------------------------------
1 | export interface CreateReviewInput {
2 | listingId: string;
3 | rating: number;
4 | comment?: string;
5 | }
6 |
7 | export interface CreateReviewArgs {
8 | input: CreateReviewInput;
9 | }
10 | export interface DeleteReviewInput {
11 | listingId: string;
12 | reviewId: string;
13 | }
14 | export interface DeleteReviewArgs {
15 | input: DeleteReviewInput;
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/subscriptions/SendMessage/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const SEND_MESSAGE = gql`
4 | subscription SendMessage($to: ID!) {
5 | sendMessage(to: $to) {
6 | id
7 | content
8 | createdAt
9 | author {
10 | id
11 | name
12 | avatar
13 | contact
14 | }
15 | }
16 | }
17 | `;
--------------------------------------------------------------------------------
/server/src/lib/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { Request } from "express";
2 | import { Database, User } from "../types";
3 |
4 | export const authorize = async (
5 | db: Database,
6 | req: Request
7 | ): Promise => {
8 | // const token = req.get("X-CSRF-TOKEN");
9 | // console.log(token);
10 |
11 | const viewer = await db.users.findOne({
12 | _id: req.signedCookies.viewer,
13 | // token,
14 | });
15 |
16 | return viewer;
17 | };
18 |
--------------------------------------------------------------------------------
/client/src/lib/components/PageSkeleton/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Skeleton } from "antd";
3 |
4 | export const PageSkeleton = () => {
5 | const skeletonParagraph = (
6 |
11 | );
12 |
13 | return (
14 | <>
15 | {skeletonParagraph}
16 | {skeletonParagraph}
17 | {skeletonParagraph}
18 | >
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/CreateReview/index.ts:
--------------------------------------------------------------------------------
1 |
2 | import { gql } from "@apollo/client";
3 |
4 | export const CREATE_REVIEW = gql`
5 | mutation CreateReview($input: CreateReviewInput!) {
6 | createReview(input: $input) {
7 | id
8 | rating
9 | comment
10 | createdAt
11 | author {
12 | id
13 | name
14 | avatar
15 | contact
16 | }
17 | }
18 | }
19 | `;
--------------------------------------------------------------------------------
/client/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/server/README.md:
--------------------------------------------------------------------------------
1 | - `npm install` to install package dependencies
2 | - `npm run start` to run Node:Express server
3 | - Navigate to `http://localhost:9000/api` to launch GraphQL Playground
4 |
5 | **Note - you'll need to use your MongoDB Atlas configuration credentials to make the server connection to MongoDB. In this and following repos, the following environment configuration variables will need to be defined in a `.env` file at the root of the project directory - `PORT`, `DB_USER`, `DB_USER_PASSWORD`, and `DB_CLUSTER`.**
6 |
--------------------------------------------------------------------------------
/client/src/lib/components/AppHeaderSkeleton/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Layout } from "antd";
3 |
4 | import logo from "./assets/paper-houses-logo.png";
5 |
6 | const { Header } = Layout;
7 |
8 | export const AppHeaderSkeleton = () => {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/User/types.ts:
--------------------------------------------------------------------------------
1 | import { Booking, Listing } from "../../../lib/types";
2 |
3 | export interface UserArgs {
4 | id: string
5 | }
6 |
7 | export interface UserBookingsArgs {
8 | limit: number;
9 | page: number;
10 | }
11 |
12 | export interface UserBookingsData {
13 | total: number;
14 | result: Booking[];
15 | }
16 |
17 | export interface UserListingsArgs {
18 | limit: number;
19 | page: number;
20 | }
21 |
22 | export interface UserListingsData {
23 | total: number;
24 | result: Listing[]
25 | }
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/lib/components/ErrorBanner/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Alert } from "antd";
3 |
4 | interface Props {
5 | message?: string;
6 | description?: string;
7 | }
8 |
9 | export const ErrorBanner = ({
10 | message = "Uh oh! Something went wrong :(",
11 | description = "Look like something went wrong. Please check your connection and/or try again later."
12 | }: Props) => {
13 | return (
14 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/index.ts:
--------------------------------------------------------------------------------
1 | import merge from "lodash.merge";
2 | import { bookingResolvers } from "./Booking";
3 | import { chatResolvers } from "./Chat";
4 | import { listingResolvers } from "./Listing";
5 | import { messageResolvers } from "./Message";
6 | import { userResolvers } from "./User";
7 | import { viewerResolvers } from "./Viewer";
8 | import { reviewResolvers } from "./Review";
9 |
10 | export const resolvers = merge(
11 | bookingResolvers,
12 | listingResolvers,
13 | userResolvers,
14 | viewerResolvers,
15 | messageResolvers,
16 | chatResolvers,
17 | reviewResolvers
18 | );
19 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/DisconnectStripe/__generated__/DisconnectStripe.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | // ====================================================
7 | // GraphQL mutation operation: DisconnectStripe
8 | // ====================================================
9 |
10 | export interface DisconnectStripe_disconnectStripe {
11 | __typename: "Viewer";
12 | hasWallet: boolean | null;
13 | }
14 |
15 | export interface DisconnectStripe {
16 | disconnectStripe: DisconnectStripe_disconnectStripe;
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/lib/api/Cloudinary.ts:
--------------------------------------------------------------------------------
1 | import cloudinary from "cloudinary";
2 |
3 | export const Cloudinary = {
4 | upload: async (image: string) => {
5 | /* eslint-disable @typescript-eslint/camelcase */
6 | const res = await cloudinary.v2.uploader.upload(image, {
7 | api_key: process.env.CLOUDINARY_API_KEY,
8 | api_secret: process.env.CLOUDINARY_API_SECRET,
9 | cloud_name: process.env.CLOUDINARY_NAME,
10 | folder: "PAPERHOUSES_Assets/",
11 | });
12 |
13 | return res.secure_url;
14 | /* eslint-enable @typescript-eslint/camelcase */
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/client/src/sections/Listings/components/ListingsPagination/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Pagination } from "antd";
3 |
4 | interface Props {
5 | total: number;
6 | page: number;
7 | limit: number;
8 | setPage: (page: number) => void;
9 | }
10 |
11 | export const ListingsPagination = ({ total, page, limit, setPage }: Props) => {
12 | return (
13 | setPage(page)}
20 | className="listings-pagination"
21 | />
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/LogOut/__generated__/LogOut.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | // ====================================================
7 | // GraphQL mutation operation: LogOut
8 | // ====================================================
9 |
10 | export interface LogOut_logOut {
11 | __typename: "Viewer";
12 | id: string | null;
13 | token: string | null;
14 | avatar: string | null;
15 | hasWallet: boolean | null;
16 | didRequest: boolean;
17 | }
18 |
19 | export interface LogOut {
20 | logOut: LogOut_logOut;
21 | }
22 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/Chat/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const CHAT = gql`
4 | query Chat($recipient: String!) {
5 | chat(recipient: $recipient) {
6 | id
7 | messages {
8 | id
9 | content
10 | createdAt
11 | author {
12 | id
13 | name
14 | avatar
15 | contact
16 | }
17 | }
18 | participants {
19 | id
20 | name
21 | avatar
22 | }
23 | }
24 | }
25 | `;
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/HostListing/__generated__/HostListing.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { HostListingInput } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL mutation operation: HostListing
10 | // ====================================================
11 |
12 | export interface HostListing_hostListing {
13 | __typename: "Listing";
14 | id: string;
15 | }
16 |
17 | export interface HostListing {
18 | hostListing: HostListing_hostListing;
19 | }
20 |
21 | export interface HostListingVariables {
22 | input: HostListingInput;
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/DeleteReview/__generated__/DeleteReview.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { DeleteReviewInput } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL mutation operation: DeleteReview
10 | // ====================================================
11 |
12 | export interface DeleteReview_deleteReview {
13 | __typename: "Review";
14 | id: string;
15 | }
16 |
17 | export interface DeleteReview {
18 | deleteReview: DeleteReview_deleteReview;
19 | }
20 |
21 | export interface DeleteReviewVariables {
22 | input: DeleteReviewInput;
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/CreateBooking/__generated__/CreateBooking.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { CreateBookingInput } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL mutation operation: CreateBooking
10 | // ====================================================
11 |
12 | export interface CreateBooking_createBooking {
13 | __typename: "Booking";
14 | id: string;
15 | }
16 |
17 | export interface CreateBooking {
18 | createBooking: CreateBooking_createBooking;
19 | }
20 |
21 | export interface CreateBookingVariables {
22 | input: CreateBookingInput;
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/CreateMessage/__generated__/CreateMessage.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { CreateMessageInput } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL mutation operation: CreateMessage
10 | // ====================================================
11 |
12 | export interface CreateMessage_createMessage {
13 | __typename: "Message";
14 | id: string;
15 | }
16 |
17 | export interface CreateMessage {
18 | createMessage: CreateMessage_createMessage;
19 | }
20 |
21 | export interface CreateMessageVariables {
22 | input: CreateMessageInput;
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/subscriptions/ListingBooked/__generated__/ListingBooked.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | // ====================================================
7 | // GraphQL subscription operation: ListingBooked
8 | // ====================================================
9 |
10 | export interface ListingBooked_listingBooked {
11 | __typename: "Listing";
12 | id: string;
13 | title: string;
14 | description: string;
15 | }
16 |
17 | export interface ListingBooked {
18 | listingBooked: ListingBooked_listingBooked;
19 | }
20 |
21 | export interface ListingBookedVariables {
22 | hostId: string;
23 | isHost: boolean;
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/ConnectStripe/__generated__/ConnectStripe.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { ConnectStripeInput } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL mutation operation: ConnectStripe
10 | // ====================================================
11 |
12 | export interface ConnectStripe_connectStripe {
13 | __typename: "Viewer";
14 | hasWallet: boolean | null;
15 | }
16 |
17 | export interface ConnectStripe {
18 | connectStripe: ConnectStripe_connectStripe;
19 | }
20 |
21 | export interface ConnectStripeVariables {
22 | input: ConnectStripeInput;
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/UpdateListing/__generated__/UpdateListing.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { UpdateListingInput } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL mutation operation: UpdateListing
10 | // ====================================================
11 |
12 | export interface UpdateListing_updateListing {
13 | __typename: "UpdateListingResult";
14 | id: string;
15 | }
16 |
17 | export interface UpdateListing {
18 | updateListing: UpdateListing_updateListing;
19 | }
20 |
21 | export interface UpdateListingVariables {
22 | id: string;
23 | input: UpdateListingInput;
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/Listings/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const LISTINGS = gql`
4 | query Listings(
5 | $location: String
6 | $filter: ListingsFilter!
7 | $limit: Int!
8 | $page: Int!
9 | ) {
10 | listings(
11 | location: $location
12 | filter: $filter
13 | limit: $limit
14 | page: $page
15 | ) {
16 | region
17 | total
18 | result {
19 | id
20 | title
21 | image
22 | address
23 | price
24 | numOfGuests
25 | numReviews
26 | rating
27 | }
28 | }
29 | }
30 | `;
31 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/LogIn/__generated__/LogIn.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { LogInInput } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL mutation operation: LogIn
10 | // ====================================================
11 |
12 | export interface LogIn_logIn {
13 | __typename: "Viewer";
14 | id: string | null;
15 | token: string | null;
16 | avatar: string | null;
17 | hasWallet: boolean | null;
18 | didRequest: boolean;
19 | }
20 |
21 | export interface LogIn {
22 | logIn: LogIn_logIn;
23 | }
24 |
25 | export interface LogInVariables {
26 | input?: LogInInput | null;
27 | }
28 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/subscriptions/SendMessage/__generated__/SendMessage.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | // ====================================================
7 | // GraphQL subscription operation: SendMessage
8 | // ====================================================
9 |
10 | export interface SendMessage_sendMessage_author {
11 | __typename: "User";
12 | id: string;
13 | name: string;
14 | avatar: string;
15 | contact: string;
16 | }
17 |
18 | export interface SendMessage_sendMessage {
19 | __typename: "Message";
20 | id: string;
21 | content: string;
22 | createdAt: string;
23 | author: SendMessage_sendMessage_author;
24 | }
25 |
26 | export interface SendMessage {
27 | sendMessage: SendMessage_sendMessage;
28 | }
29 |
30 | export interface SendMessageVariables {
31 | to: string;
32 | }
33 |
--------------------------------------------------------------------------------
/client/src/sections/Home/components/HomeListings/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { List, Typography } from "antd";
3 | import { ListingCard } from "../../../../lib/components";
4 | import { Listings } from "../../../../lib/graphql/queries/Listings/__generated__/Listings";
5 |
6 | interface Props {
7 | title: string;
8 | listings: Listings["listings"]["result"];
9 | }
10 |
11 | const { Title } = Typography;
12 |
13 | export const HomeListings = ({ title, listings }: Props) => {
14 | return (
15 |
16 |
17 | {title}
18 |
19 | (
28 |
29 |
30 |
31 | )}
32 | />
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/server/src/lib/api/Google.ts:
--------------------------------------------------------------------------------
1 | import { google } from "googleapis";
2 |
3 | const auth = new google.auth.OAuth2(
4 | process.env.GOOGLE_CLIENT_ID,
5 | process.env.GOOGLE_CLIENT_SECRET,
6 | `${process.env.PUBLIC_URL}/login` // redirect url
7 | );
8 |
9 | export const Google = {
10 |
11 | authUrl: auth.generateAuthUrl({
12 | access_type: "online",
13 | scope: [
14 | "https://www.googleapis.com/auth/userinfo.email",
15 | "https://www.googleapis.com/auth/userinfo.profile",
16 | ],
17 | }),
18 |
19 | logIn: async (code: string) => {
20 | const { tokens } = await auth.getToken(code);
21 |
22 | auth.setCredentials(tokens);
23 |
24 | const { data } = await google
25 | .people({ version: "v1", auth })
26 | .people.get({
27 | resourceName: "people/me",
28 | personFields: "emailAddresses,names,photos",
29 | });
30 |
31 | return { user: data };
32 | },
33 |
34 | };
35 |
--------------------------------------------------------------------------------
/client/src/lib/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { message, notification } from "antd";
2 |
3 | export const iconColor = "#1890ff";
4 |
5 | export let token = sessionStorage.getItem("token");
6 |
7 | export const setToken = (t: string) => {
8 | token = t;
9 | }
10 |
11 | export const formatListingPrice = (price: number, round = true) => {
12 | const formattedListingPrice = round ? Math.round(price / 100) : price / 100;
13 | return `$${formattedListingPrice}`;
14 | };
15 |
16 | export const displaySuccessNotification = (
17 | message: string,
18 | description?: string
19 | ) => {
20 | return notification["success"]({
21 | message,
22 | description,
23 | placement: "topLeft",
24 | style: {
25 | marginTop: 50
26 | }
27 | });
28 | };
29 |
30 | export const displayErrorMessage = (error: string) => {
31 | return message.error(error);
32 | };
33 |
34 |
35 | export const stripeAuthUrl = `https://connect.stripe.com/oauth/authorize?response_type=code&client_id=${process.env.REACT_APP_STRIPE_CLIENT_ID}&scope=read_write`;
--------------------------------------------------------------------------------
/client/src/lib/graphql/mutations/CreateReview/__generated__/CreateReview.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { CreateReviewInput } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL mutation operation: CreateReview
10 | // ====================================================
11 |
12 | export interface CreateReview_createReview_author {
13 | __typename: "User";
14 | id: string;
15 | name: string;
16 | avatar: string;
17 | contact: string;
18 | }
19 |
20 | export interface CreateReview_createReview {
21 | __typename: "Review";
22 | id: string;
23 | rating: number;
24 | comment: string | null;
25 | createdAt: string;
26 | author: CreateReview_createReview_author;
27 | }
28 |
29 | export interface CreateReview {
30 | createReview: CreateReview_createReview;
31 | }
32 |
33 | export interface CreateReviewVariables {
34 | input: CreateReviewInput;
35 | }
36 |
--------------------------------------------------------------------------------
/client/src/sections/Home/components/HomeListingsSkeleton/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Card, List, Skeleton } from "antd";
3 |
4 | import listingLoadingCardCover from "../../assets/listing-loading-card-cover.jpg";
5 |
6 | export const HomeListingsSkeleton = () => {
7 | const emptyData = [{}, {}, {}, {}];
8 |
9 | return (
10 |
11 |
12 | (
21 |
22 |
28 | }
29 | loading
30 | />
31 |
32 | )}
33 | />
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/Listings/__generated__/Listings.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { ListingsFilter } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL query operation: Listings
10 | // ====================================================
11 |
12 | export interface Listings_listings_result {
13 | __typename: "Listing";
14 | id: string;
15 | title: string;
16 | image: string;
17 | address: string;
18 | price: number;
19 | numOfGuests: number;
20 | numReviews: number;
21 | rating: number;
22 | }
23 |
24 | export interface Listings_listings {
25 | __typename: "Listings";
26 | region: string | null;
27 | total: number;
28 | result: Listings_listings_result[];
29 | }
30 |
31 | export interface Listings {
32 | listings: Listings_listings;
33 | }
34 |
35 | export interface ListingsVariables {
36 | location?: string | null;
37 | filter: ListingsFilter;
38 | limit: number;
39 | page: number;
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/sections/Listings/components/ListingsSkeleton/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Card, List, Skeleton } from "antd";
3 |
4 | import listingLoadingCardCover from "../../assets/listing-loading-card-cover.jpg";
5 |
6 | export const ListingsSkeleton = () => {
7 | const emptyData = [{}, {}, {}, {}, {}, {}, {}, {}];
8 |
9 | return (
10 |
11 |
12 | (
21 |
22 |
28 | }
29 | loading
30 | className="listings-skeleton__card"
31 | />
32 |
33 | )}
34 | />
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/client/src/sections/Listings/components/ListingsFilters/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Select } from "antd";
3 | import { ListingsFilter } from "../../../../lib/graphql/globalTypes";
4 |
5 | interface Props {
6 | filter: ListingsFilter;
7 | setFilter: (filter: ListingsFilter) => void;
8 | }
9 |
10 | const { Option } = Select;
11 |
12 | export const ListingsFilters = ({ filter, setFilter }: Props) => {
13 | return (
14 |
15 | Filter By
16 | setFilter(filter)}
19 | >
20 |
21 | Price: Low to High
22 |
23 |
24 | Price: High to Low
25 |
26 |
27 | Highest Rated
28 |
29 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/server/src/database/index.ts:
--------------------------------------------------------------------------------
1 | import { MongoClient } from "mongodb";
2 | import { Booking, Chat, Database, Listing, Message, User } from "../lib/types";
3 |
4 | const username = encodeURIComponent(process.env.MONGO_USER!);
5 | const password = encodeURIComponent(process.env.MONGO_PASSWORD!);
6 |
7 | let MONG0_URI_PROD = `mongodb+srv://${username}:${password}@paper-houses.3df2fiq.mongodb.net/?retryWrites=true&w=majority`;
8 |
9 | const url =
10 | process.env.NODE_ENV === "production"
11 | ? MONG0_URI_PROD
12 | : encodeURI(process.env.MONGO_URI_DEV!);
13 |
14 | export const connectDatabase = async (): Promise => {
15 | const client = await MongoClient.connect(url!, {
16 | useNewUrlParser: true,
17 | useUnifiedTopology: true,
18 | });
19 | const db = client.db("main");
20 |
21 | return {
22 | bookings: db.collection("bookings"),
23 | users: db.collection("users"),
24 | messages: db.collection("messages"),
25 | chat: db.collection("chat"),
26 | listings: db.collection("listings"),
27 | };
28 | };
29 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/Chat/__generated__/Chat.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | // ====================================================
7 | // GraphQL query operation: Chat
8 | // ====================================================
9 |
10 | export interface Chat_chat_messages_author {
11 | __typename: "User";
12 | id: string;
13 | name: string;
14 | avatar: string;
15 | contact: string;
16 | }
17 |
18 | export interface Chat_chat_messages {
19 | __typename: "Message";
20 | id: string;
21 | content: string;
22 | createdAt: string;
23 | author: Chat_chat_messages_author;
24 | }
25 |
26 | export interface Chat_chat_participants {
27 | __typename: "User";
28 | id: string;
29 | name: string;
30 | avatar: string;
31 | }
32 |
33 | export interface Chat_chat {
34 | __typename: "Chat";
35 | id: string;
36 | messages: Chat_chat_messages[];
37 | participants: Chat_chat_participants[];
38 | }
39 |
40 | export interface Chat {
41 | chat: Chat_chat;
42 | }
43 |
44 | export interface ChatVariables {
45 | recipient: string;
46 | }
47 |
--------------------------------------------------------------------------------
/client/src/sections/NotFound/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from "react";
2 | import { Link } from "react-router-dom";
3 | import { Empty, Layout, Typography } from "antd";
4 | import { useScrollToTop } from "../../lib/hooks/useScrollToTop";
5 |
6 | const { Content } = Layout;
7 | const { Text } = Typography;
8 |
9 | export const NotFound = () => {
10 |
11 | useScrollToTop();
12 |
13 | return (
14 |
15 |
18 |
19 | Uh oh! Something went wrong :(
20 |
21 |
22 | The page you're looking for can't be found
23 |
24 |
25 | }
26 | />
27 |
31 | Go to Home
32 |
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/server/src/mock/clear.ts:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 |
3 | import { connectDatabase } from "../database";
4 |
5 | const clear = async () => {
6 | try {
7 | console.log("[clear] : running...");
8 |
9 | const db = await connectDatabase();
10 |
11 | const bookings = await db.bookings.find({}).toArray();
12 | const listings = await db.listings.find({}).toArray();
13 | const users = await db.users.find({}).toArray();
14 | const messages = await db.messages.find({}).toArray();
15 | const chat = await db.chat.find({}).toArray();
16 |
17 | if (bookings.length > 0) {
18 | await db.bookings.drop();
19 | }
20 |
21 | if (listings.length > 0) {
22 | await db.listings.drop();
23 | }
24 |
25 | if (users.length > 0) {
26 | await db.users.drop();
27 | }
28 |
29 | if (messages.length > 0) {
30 | await db.messages.drop();
31 | }
32 |
33 | if (chat.length > 0) {
34 | await db.chat.drop();
35 | }
36 |
37 | console.log("[clear] : success");
38 | } catch {
39 | throw new Error("failed to clear database");
40 | }
41 | };
42 |
43 | clear();
44 |
--------------------------------------------------------------------------------
/client/src/lib/components/Map/map.css:
--------------------------------------------------------------------------------
1 | .map-container {
2 | width: 100%;
3 | height: 500px;
4 | border-radius: 5px;
5 | }
6 |
7 | .mapboxgl-map {
8 | width: 100% !important;
9 | }
10 |
11 | .marker {
12 | background-image: url('assets/mapbox-icon.png');
13 | background-size: cover;
14 | width: 20px;
15 | height: 20px;
16 | border-radius: 50%;
17 | cursor: pointer;
18 | }
19 |
20 | .mapboxgl-popup {
21 | max-width: 200px;
22 | }
23 |
24 | .mapboxgl-popup-content {
25 | text-align: center;
26 | font-family: 'Open Sans', sans-serif;
27 | }
28 |
29 | /* Circle */
30 | .circle {
31 | height: 15px;
32 | width: 15px;
33 | border-radius: 50%;
34 | background-color: blue;
35 | cursor: pointer;
36 | }
37 |
38 | .circle:before,
39 | .circle:after {
40 | content: '';
41 | display: block;
42 | position: absolute;
43 | top: 0;
44 | right: 0;
45 | bottom: 0;
46 | left: 0;
47 | border: 1px solid blue;
48 | border-radius: 50%;
49 | }
50 |
51 | .circle:before {
52 | animation: ripple 2s linear infinite;
53 | }
54 |
55 | .circle:after {
56 | animation: ripple 2s linear 1s infinite;
57 | }
58 |
59 | @keyframes ripple {
60 | 0% {
61 | transform: scale(1);
62 | }
63 | 50% {
64 | transform: scale(1.3);
65 | opacity: 1;
66 | }
67 | 100% {
68 | transform: scale(1.6);
69 | opacity: 0;
70 | }
71 | }
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/Listing/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const LISTING = gql`
4 | query Listing($id: ID!, $bookingsPage: Int!, $limit: Int!) {
5 | listing(id: $id) {
6 | id
7 | title
8 | description
9 | image
10 | host {
11 | id
12 | name
13 | avatar
14 | hasWallet
15 | }
16 | type
17 | address
18 | city
19 | bookings(limit: $limit, page: $bookingsPage) {
20 | total
21 | result {
22 | id
23 | tenant {
24 | id
25 | name
26 | avatar
27 | }
28 | checkIn
29 | checkOut
30 | }
31 | }
32 | bookingsIndex
33 | price
34 | numOfGuests
35 | reviews {
36 | id
37 | rating
38 | comment
39 | createdAt
40 | author {
41 | id
42 | name
43 | avatar
44 | contact
45 | }
46 | }
47 | numReviews
48 | rating
49 | }
50 | }
51 | `;
52 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/Listing/types.ts:
--------------------------------------------------------------------------------
1 | import { Booking, Listing, ListingType } from "../../../lib/types";
2 |
3 | export enum ListingsFilter {
4 | PRICE_LOW_TO_HIGH = "PRICE_LOW_TO_HIGH",
5 | PRICE_HIGH_TO_LOW = "PRICE_HIGH_TO_LOW",
6 | HIGHEST_RATED = "HIGHEST_RATED",
7 | }
8 |
9 | export interface ListingArgs {
10 | id: string;
11 | }
12 |
13 | export interface ListingBookingsArgs {
14 | limit: number;
15 | page: number;
16 | }
17 |
18 | export interface ListingBookingsData {
19 | total: number;
20 | result: Booking[];
21 | }
22 |
23 | export interface ListingsArgs {
24 | location: string | null;
25 | filter: ListingsFilter;
26 | limit: number;
27 | page: number;
28 | }
29 |
30 | export interface ListingsData {
31 | region: string | null;
32 | total: number;
33 | result: Listing[];
34 | }
35 |
36 | export interface ListingsQuery {
37 | country?: string;
38 | admin?: string;
39 | city?: string;
40 | }
41 |
42 | export interface HostListingInput {
43 | title: string;
44 | description: string;
45 | image: string;
46 | type: ListingType;
47 | address: string;
48 | price: number;
49 | numOfGuests: number;
50 | }
51 |
52 |
53 | export interface UpdateListingInput {
54 | title: string;
55 | description: string;
56 | image?: string;
57 | type: ListingType;
58 | price: number;
59 | numOfGuests: number;
60 | }
61 |
62 | export interface HostListingArgs {
63 | input: HostListingInput;
64 | }
65 |
66 | export interface UpdateListingArgs {
67 | id: string
68 | input: UpdateListingInput;
69 | }
70 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/User/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const USER = gql`
4 | query User(
5 | $id: ID!
6 | $bookingsPage: Int!
7 | $listingsPage: Int!
8 | $limit: Int!
9 | ) {
10 | user(id: $id) {
11 | id
12 | name
13 | avatar
14 | contact
15 | hasWallet
16 | income
17 | bookings(limit: $limit, page: $bookingsPage) {
18 | total
19 | result {
20 | id
21 | listing {
22 | id
23 | title
24 | image
25 | address
26 | price
27 | numOfGuests
28 | numReviews
29 | rating
30 | }
31 | checkIn
32 | checkOut
33 | }
34 | }
35 | listings(limit: $limit, page: $listingsPage) {
36 | total
37 | result {
38 | id
39 | title
40 | image
41 | address
42 | price
43 | numOfGuests
44 | numReviews
45 | rating
46 | }
47 | }
48 | chats {
49 | id
50 | participants {
51 | id
52 | name
53 | avatar
54 | }
55 | }
56 | }
57 | }
58 | `;
59 |
--------------------------------------------------------------------------------
/client/src/sections/Home/tests/Home.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render } from "@testing-library/react";
3 | import { MockedProvider } from "@apollo/client/testing";
4 | import { BrowserRouter } from "react-router-dom";
5 | import { Home } from "../index";
6 |
7 | // Object.defineProperty(window, "matchMedia", {
8 | // writable: true,
9 | // value: jest.fn().mockImplementation((query) => ({
10 | // matches: false,
11 | // media: query,
12 | // onchange: null,
13 | // addListener: jest.fn(), // Deprecated
14 | // removeListener: jest.fn(), // Deprecated
15 | // addEventListener: jest.fn(),
16 | // removeEventListener: jest.fn(),
17 | // dispatchEvent: jest.fn(),
18 | // })),
19 | // });
20 |
21 | describe("Home", () => {
22 | window.scrollTo = () => {};
23 | window.matchMedia =
24 | window.matchMedia ||
25 | function () {
26 | return {
27 | matches: false,
28 | addListener: function () {},
29 | removeListener: function () {},
30 | };
31 | };
32 |
33 | describe("Search Input", () => {
34 | it("renders an empty search input on initial render", () => {
35 | const {getByPlaceholderText} = render(
36 |
37 |
38 |
39 |
40 |
41 | );
42 |
43 | const searchInput = getByPlaceholderText("Search 'Los Angeles'") as HTMLInputElement;
44 |
45 | expect(searchInput.value).toEqual("");
46 | });
47 |
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "paperhouses-server",
3 | "version": "0.1.0",
4 | "engines": {
5 | "node": "16.14.0"
6 | },
7 | "dependencies": {
8 | "@types/body-parser": "^1.17.0",
9 | "@types/bson": "^4.2.0",
10 | "@types/compression": "^1.7.2",
11 | "@types/cookie": "^0.5.1",
12 | "@types/cookie-parser": "^1.4.2",
13 | "@types/express": "^4.17.0",
14 | "@types/graphql": "^14.2.1",
15 | "@types/lodash.merge": "^4.6.6",
16 | "@types/mongodb": "^3.1.28",
17 | "@types/node": "^12.0.10",
18 | "@types/node-geocoder": "^3.24.5",
19 | "@types/stripe": "^7.13.9",
20 | "@typescript-eslint/eslint-plugin": "^1.11.0",
21 | "@typescript-eslint/parser": "^1.11.0",
22 | "apollo-server-express": "^2.6.4",
23 | "body-parser": "^1.20.1",
24 | "bson": "^4.7.0",
25 | "cloudinary": "^1.17.0",
26 | "compression": "^1.7.4",
27 | "cookie": "^0.5.0",
28 | "cookie-parser": "^1.4.4",
29 | "dotenv": "^8.0.0",
30 | "eslint": "^6.0.1",
31 | "express": "^4.17.1",
32 | "googleapis": "^42.0.0",
33 | "graphql": "^14.3.1",
34 | "graphql-subscriptions": "^2.0.0",
35 | "lodash.merge": "^4.6.2",
36 | "mongodb": "^3.2.7",
37 | "node-geocoder": "^4.2.0",
38 | "nodemon": "^1.19.1",
39 | "stripe": "^7.13.1",
40 | "ts-node": "^8.3.0",
41 | "typescript": "^3.5.2"
42 | },
43 | "scripts": {
44 | "dev": "nodemon src/index.ts",
45 | "start": "node ./build/index.js",
46 | "seed": "ts-node src/mock/seed.ts",
47 | "clear": "ts-node src/mock/clear.ts",
48 | "build": "tsc"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/server/src/lib/api/Stripe.ts:
--------------------------------------------------------------------------------
1 | import stripe from "stripe";
2 |
3 | const client = new stripe(`${process.env.STRIPE_SECRET_KEY}`);
4 |
5 | export const Stripe = {
6 | connect: async (code: string) => {
7 | const response = await client.oauth.token({
8 | grant_type: "authorization_code",
9 | code,
10 | });
11 |
12 | return response; // response contains access_token, stripe_user_id and more
13 | },
14 |
15 | disconnect: async (stripeUserId: string) => {
16 | // @ts-ignore
17 | const response = await client.oauth.deauthorize({
18 | client_id: `${process.env.STRIPE_CLIENT_ID}`,
19 | stripe_user_id: stripeUserId,
20 | });
21 |
22 | return response;
23 | },
24 |
25 | charge: async (amount: number, source: string, stripeAccount: string) => {
26 | try {
27 | const res = await client.charges.create(
28 | {
29 | amount,
30 | currency: "usd",
31 | source, // who's paying the fee
32 | application_fee_amount: Math.round(amount * 0.05), // PaperHouses that's getting paid 5% of the amount for using our platform
33 | },
34 | {
35 | stripe_account: stripeAccount, // owner/host of the listing getting paid the amount = amount - 5% of the amount
36 | }
37 | );
38 |
39 | if (res.status !== "succeeded") {
40 | throw new Error("failed to create charge with Stripe");
41 | }
42 |
43 | }catch(err) {
44 | console.log(err);
45 | throw new Error("failed to create charge with Stripe");
46 | }
47 | },
48 | };
49 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/globalTypes.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | //==============================================================
7 | // START Enums and Input Objects
8 | //==============================================================
9 |
10 | export enum ListingType {
11 | APARTMENT = "APARTMENT",
12 | HOUSE = "HOUSE",
13 | }
14 |
15 | export enum ListingsFilter {
16 | HIGHEST_RATED = "HIGHEST_RATED",
17 | PRICE_HIGH_TO_LOW = "PRICE_HIGH_TO_LOW",
18 | PRICE_LOW_TO_HIGH = "PRICE_LOW_TO_HIGH",
19 | }
20 |
21 | export interface ConnectStripeInput {
22 | code: string;
23 | }
24 |
25 | export interface CreateBookingInput {
26 | id: string;
27 | source: string;
28 | checkIn: string;
29 | checkOut: string;
30 | }
31 |
32 | export interface CreateMessageInput {
33 | content: string;
34 | to: string;
35 | }
36 |
37 | export interface CreateReviewInput {
38 | listingId: string;
39 | rating: number;
40 | comment?: string | null;
41 | }
42 |
43 | export interface DeleteReviewInput {
44 | listingId: string;
45 | reviewId: string;
46 | }
47 |
48 | export interface HostListingInput {
49 | title: string;
50 | description: string;
51 | image: string;
52 | type: ListingType;
53 | address: string;
54 | price: number;
55 | numOfGuests: number;
56 | }
57 |
58 | export interface LogInInput {
59 | code: string;
60 | }
61 |
62 | export interface UpdateListingInput {
63 | title: string;
64 | description: string;
65 | image?: string | null;
66 | type: ListingType;
67 | price: number;
68 | numOfGuests: number;
69 | }
70 |
71 | //==============================================================
72 | // END Enums and Input Objects
73 | //==============================================================
74 |
--------------------------------------------------------------------------------
/client/src/sections/User/components/UserListings/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { List, Typography } from "antd";
3 | import { ListingCard } from "../../../../lib/components";
4 | import { User } from "../../../../lib/graphql/queries/User/__generated__/User";
5 |
6 | interface Props {
7 | userListings: User["user"]["listings"];
8 | listingsPage: number;
9 | limit: number;
10 | setListingsPage: (page: number) => void;
11 | }
12 |
13 | const { Paragraph, Title } = Typography;
14 |
15 | export const UserListings = ({
16 | userListings,
17 | listingsPage,
18 | limit,
19 | setListingsPage
20 | }: Props) => {
21 | const { total, result } = userListings;
22 |
23 | const userListingsList = (
24 | setListingsPage(page)
41 | }}
42 | renderItem={userListing => (
43 |
44 |
45 |
46 | )}
47 | />
48 | );
49 |
50 | return (
51 |
52 |
53 | Listings
54 |
55 |
56 | This section highlights the listings this user currently hosts and has
57 | made available for bookings.
58 |
59 | {userListingsList}
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/client/src/sections/Chat/components/NewMessageInput/index.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@apollo/client';
2 | import React, { useState } from 'react'
3 | import { CREATE_MESSAGE } from '../../../../lib/graphql/mutations';
4 | import { CreateMessage as CreateMessageData, CreateMessageVariables } from '../../../../lib/graphql/mutations/CreateMessage/__generated__/CreateMessage';
5 | import { displaySuccessNotification } from '../../../../lib/utils';
6 |
7 | interface Props {
8 | recipientId: string
9 | }
10 |
11 | export const NewMessageInput = ({ recipientId }: Props) => {
12 | const [message, setMessage] = useState("");
13 |
14 | // MUTATION TO SEND MESSAGE
15 | const [createMessage] = useMutation<
16 | CreateMessageData,
17 | CreateMessageVariables
18 | >(CREATE_MESSAGE, {
19 | onCompleted: () => {
20 | // displaySuccessNotification("Message Sent");
21 | },
22 | onError: (err) => {
23 | console.log(err);
24 | },
25 | });
26 |
27 | const handleSendMessage = (e: React.KeyboardEvent) => {
28 | if (e.key === "Enter") {
29 | setMessage("");
30 |
31 | createMessage({
32 | variables: {
33 | input: {
34 | content: message,
35 | to: recipientId,
36 | },
37 | },
38 | });
39 | }
40 | };
41 |
42 | const handleChange = (e: React.ChangeEvent) => {
43 | setMessage(e.target.value);
44 | };
45 |
46 | return (
47 |
48 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 |
4 | # Backend AI
5 | api:
6 | container_name: api
7 | build:
8 | context: ./server
9 | dockerfile: Dockerfile
10 | restart: always # "no", always, on-failure, unless-stopped
11 | working_dir: /app
12 | volumes:
13 | - ./server:/app
14 | - /app/node_modules
15 | ports:
16 | - "9000:9000"
17 | environment:
18 | MONGO_URI_DEV: mongodb://root:exapmle@mongo-db:27017/paperhouses?authSource=admin
19 | NODE_ENV: development
20 | PUBLIC_URL: http://localhost:3000
21 | SECRET: my_super_secret_for_cookies
22 | GOOGLE_CLIENT_ID:
23 | GOOGLE_CLIENT_SECRET:
24 | STRIPE_CLIENT_ID:
25 | STRIPE_SECRET_KEY:
26 | CLOUDINARY_NAME:
27 | CLOUDINARY_API_KEY:
28 | CLOUDINARY_API_SECRET:
29 | MAPBOX_API_KEY:
30 | depends_on:
31 | - mongo-db
32 |
33 | # MongoDB database
34 | mongo-db:
35 | container_name: mongo-db
36 | image: "mongo"
37 | restart: always
38 | environment:
39 | MONGO_INITDB_ROOT_USERNAME: root
40 | MONGO_INITDB_ROOT_PASSWORD: exapmle
41 | ports:
42 | - 27017:27017
43 |
44 |
45 | # client
46 | client:
47 | container_name: client
48 | build:
49 | context: ./client
50 | dockerfile: Dockerfile
51 | volumes:
52 | - ./client:/app
53 | - /app/node_modules
54 | ports:
55 | - "3000:3000"
56 | environment:
57 | REACT_APP_STRIPE_PUBLISHABLE_KEY:
58 | REACT_APP_STRIPE_CLIENT_ID:
59 | REACT_APP_MAPBOX_API_KEY:
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
14 |
15 |
19 |
20 |
29 | PaperHouses | Book Top Rated Rentals for Your Next Trip
30 |
31 |
32 | You need to enable JavaScript to run this app.
33 |
34 |
44 |
45 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/Listing/__generated__/Listing.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { ListingType } from "./../../../globalTypes";
7 |
8 | // ====================================================
9 | // GraphQL query operation: Listing
10 | // ====================================================
11 |
12 | export interface Listing_listing_host {
13 | __typename: "User";
14 | id: string;
15 | name: string;
16 | avatar: string;
17 | hasWallet: boolean;
18 | }
19 |
20 | export interface Listing_listing_bookings_result_tenant {
21 | __typename: "User";
22 | id: string;
23 | name: string;
24 | avatar: string;
25 | }
26 |
27 | export interface Listing_listing_bookings_result {
28 | __typename: "Booking";
29 | id: string;
30 | tenant: Listing_listing_bookings_result_tenant;
31 | checkIn: string;
32 | checkOut: string;
33 | }
34 |
35 | export interface Listing_listing_bookings {
36 | __typename: "Bookings";
37 | total: number;
38 | result: Listing_listing_bookings_result[];
39 | }
40 |
41 | export interface Listing_listing_reviews_author {
42 | __typename: "User";
43 | id: string;
44 | name: string;
45 | avatar: string;
46 | contact: string;
47 | }
48 |
49 | export interface Listing_listing_reviews {
50 | __typename: "Review";
51 | id: string;
52 | rating: number;
53 | comment: string | null;
54 | createdAt: string;
55 | author: Listing_listing_reviews_author;
56 | }
57 |
58 | export interface Listing_listing {
59 | __typename: "Listing";
60 | id: string;
61 | title: string;
62 | description: string;
63 | image: string;
64 | host: Listing_listing_host;
65 | type: ListingType;
66 | address: string;
67 | city: string;
68 | bookings: Listing_listing_bookings | null;
69 | bookingsIndex: string;
70 | price: number;
71 | numOfGuests: number;
72 | reviews: Listing_listing_reviews[];
73 | numReviews: number;
74 | rating: number;
75 | }
76 |
77 | export interface Listing {
78 | listing: Listing_listing;
79 | }
80 |
81 | export interface ListingVariables {
82 | id: string;
83 | bookingsPage: number;
84 | limit: number;
85 | }
86 |
--------------------------------------------------------------------------------
/client/src/lib/graphql/queries/User/__generated__/User.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | // ====================================================
7 | // GraphQL query operation: User
8 | // ====================================================
9 |
10 | export interface User_user_bookings_result_listing {
11 | __typename: "Listing";
12 | id: string;
13 | title: string;
14 | image: string;
15 | address: string;
16 | price: number;
17 | numOfGuests: number;
18 | numReviews: number;
19 | rating: number;
20 | }
21 |
22 | export interface User_user_bookings_result {
23 | __typename: "Booking";
24 | id: string;
25 | listing: User_user_bookings_result_listing;
26 | checkIn: string;
27 | checkOut: string;
28 | }
29 |
30 | export interface User_user_bookings {
31 | __typename: "Bookings";
32 | total: number;
33 | result: User_user_bookings_result[];
34 | }
35 |
36 | export interface User_user_listings_result {
37 | __typename: "Listing";
38 | id: string;
39 | title: string;
40 | image: string;
41 | address: string;
42 | price: number;
43 | numOfGuests: number;
44 | numReviews: number;
45 | rating: number;
46 | }
47 |
48 | export interface User_user_listings {
49 | __typename: "Listings";
50 | total: number;
51 | result: User_user_listings_result[];
52 | }
53 |
54 | export interface User_user_chats_participants {
55 | __typename: "User";
56 | id: string;
57 | name: string;
58 | avatar: string;
59 | }
60 |
61 | export interface User_user_chats {
62 | __typename: "Chat";
63 | id: string;
64 | participants: User_user_chats_participants[];
65 | }
66 |
67 | export interface User_user {
68 | __typename: "User";
69 | id: string;
70 | name: string;
71 | avatar: string;
72 | contact: string;
73 | hasWallet: boolean;
74 | income: number | null;
75 | bookings: User_user_bookings | null;
76 | listings: User_user_listings;
77 | chats: User_user_chats[] | null;
78 | }
79 |
80 | export interface User {
81 | user: User_user;
82 | }
83 |
84 | export interface UserVariables {
85 | id: string;
86 | bookingsPage: number;
87 | listingsPage: number;
88 | limit: number;
89 | }
90 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 |
3 | import http from "http";
4 | import express, { Application } from "express";
5 | import { ApolloServer } from "apollo-server-express";
6 | import bodyParser from "body-parser";
7 | import cookieParser from "cookie-parser";
8 | import compression from "compression";
9 | import { connectDatabase } from "./database";
10 | import { typeDefs, resolvers } from "./graphql";
11 | import { pubSub } from "./lib/pubSub";
12 |
13 | const PORT = process.env.PORT || 9000;
14 | const FRONTEND_URL = process.env.PUBLIC_URL;
15 |
16 | const mount = async (app: Application) => {
17 | const db = await connectDatabase();
18 |
19 | // const indexExists = await db.listings.indexExists("country_1_city_1_admin_1")
20 |
21 | // if(!indexExists) {
22 | // const result = await db.listings.createIndex({ country: 1, city: 1, admin: 1 });
23 | // console.log(`Index created: ${result}`);
24 | // }
25 |
26 | app.use(bodyParser.json({ limit: "2mb" }));
27 | app.use(cookieParser(process.env.SECRET));
28 | app.use(compression());
29 |
30 | const apolloServer = new ApolloServer({
31 | typeDefs,
32 | resolvers,
33 |
34 | subscriptions: {
35 | path: "/api",
36 | onConnect: (connectionParams, webSocket, context) => {
37 | console.log("Connected!");
38 | // console.log((webSocket as any).upgradeReq.headers.cookie);
39 | return {
40 | req: context.request,
41 | csrfToken: (connectionParams as any).xCsrfToken || ""
42 | };
43 | },
44 | },
45 | context: ({ req, res, connection }) => {
46 |
47 | if(connection) {
48 | // the object returned from onConnect will be connection.context
49 | // console.log(connection.context.csrfToken);
50 | }
51 |
52 | return { db, req, res, pubSub: pubSub, connection };
53 | },
54 |
55 | });
56 |
57 | apolloServer.applyMiddleware({ app, path: "/api", cors : { origin: FRONTEND_URL, credentials: true } });
58 |
59 | const httpServer = http.createServer(app);
60 | apolloServer.installSubscriptionHandlers(httpServer);
61 | httpServer.listen(PORT, () => console.log(`[app] : http://localhost:${PORT}`));
62 | };
63 |
64 | mount(express());
65 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "paperhouses-client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@ant-design/icons": "^4.7.0",
7 | "@apollo/client": "^3.7.0",
8 | "@mapbox/mapbox-gl-geocoder": "^5.0.1",
9 | "@mapbox/mapbox-sdk": "^0.13.6",
10 | "@stripe/react-stripe-js": "^1.14.0",
11 | "@stripe/stripe-js": "^1.42.0",
12 | "@testing-library/jest-dom": "^5.16.5",
13 | "@testing-library/react": "^13.4.0",
14 | "@testing-library/user-event": "^13.5.0",
15 | "@types/jest": "^27.5.2",
16 | "@types/node": "^16.11.65",
17 | "@types/react": "^18.0.21",
18 | "@types/react-dom": "^18.0.6",
19 | "@types/react-router-dom": "^5.3.0",
20 | "antd": "^4.23.5",
21 | "eslint-config-react-app": "^6.0.0",
22 | "graphql": "^16.6.0",
23 | "mapbox-gl": "^2.10.0",
24 | "moment": "^2.29.4",
25 | "react": "^18.2.0",
26 | "react-dom": "^18.2.0",
27 | "react-router-dom": "^6.4.2",
28 | "react-scripts": "5.0.1",
29 | "subscriptions-transport-ws": "^0.11.0",
30 | "typescript": "^4.8.4",
31 | "web-vitals": "^2.1.4"
32 | },
33 | "scripts": {
34 | "start": "react-scripts start",
35 | "build": "react-scripts build",
36 | "test": "react-scripts test",
37 | "eject": "react-scripts eject",
38 | "codegen:schema": "npx apollo client:download-schema --endpoint=http://localhost:9000/api",
39 | "codegen:generate": "npx apollo client:codegen --localSchemaFile=schema.json --includes=src/**/*.ts --globalTypesFile=./src/lib/graphql/globalTypes.ts --target=typescript"
40 | },
41 | "eslintConfig": {
42 | "extends": [
43 | "react-app",
44 | "react-app/jest"
45 | ]
46 | },
47 | "browserslist": {
48 | "production": [
49 | ">0.2%",
50 | "not dead",
51 | "not op_mini all"
52 | ],
53 | "development": [
54 | "last 1 chrome version",
55 | "last 1 firefox version",
56 | "last 1 safari version"
57 | ]
58 | },
59 | "devDependencies": {
60 | "@types/mapbox__mapbox-gl-geocoder": "^4.7.3",
61 | "@types/mapbox__mapbox-sdk": "^0.13.4",
62 | "@types/mapbox-gl": "^2.7.6",
63 | "worker-loader": "^3.0.8"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/client/src/sections/Stripe/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import { useNavigate } from "react-router-dom";
3 | import { ApolloError, useMutation } from "@apollo/client";
4 | import { Layout, Spin } from "antd";
5 | import { CONNECT_STRIPE } from "../../lib/graphql/mutations";
6 | import {
7 | ConnectStripe as ConnectStripeData,
8 | ConnectStripeVariables,
9 | } from "../../lib/graphql/mutations/ConnectStripe/__generated__/ConnectStripe";
10 | import { displaySuccessNotification } from "../../lib/utils";
11 | import { Viewer } from "../../lib/types";
12 |
13 | interface Props {
14 | viewer: Viewer;
15 | setViewer: (viewer: Viewer) => void;
16 | }
17 |
18 | const { Content } = Layout;
19 |
20 | export const Stripe = ({
21 | viewer,
22 | setViewer,
23 | }: Props ) => {
24 | let navigate = useNavigate();
25 | const [connectStripe, { data, loading, error }] = useMutation<
26 | ConnectStripeData,
27 | ConnectStripeVariables
28 | >(CONNECT_STRIPE, {
29 | onCompleted: (data: ConnectStripeData) => {
30 | if (data && data.connectStripe) {
31 | setViewer({
32 | ...viewer,
33 | hasWallet: !!data.connectStripe.hasWallet,
34 | });
35 | displaySuccessNotification(
36 | "You've successfully connected your Stripe Account!",
37 | "You can now begin to create listings in the Host page."
38 | );
39 |
40 | navigate(`/user/${viewer.id}`)
41 | }
42 | },
43 | onError: (err: ApolloError) => {
44 | console.log(err);
45 | navigate(`/user/${viewer.id}?stripe_error=true`);
46 | }
47 | });
48 | const connectStripeRef = useRef(connectStripe);
49 |
50 | useEffect(() => {
51 | const code = new URL(window.location.href).searchParams.get("code");
52 |
53 | if (code) {
54 | connectStripeRef.current({
55 | variables: {
56 | input: { code },
57 | },
58 | });
59 | } else {
60 | navigate("/login")
61 | }
62 | }, [navigate]);
63 |
64 |
65 | if (loading) {
66 | return (
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | return null;
74 | };
75 |
--------------------------------------------------------------------------------
/client/src/lib/components/AppHeader/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { Link, useNavigate, useLocation } from "react-router-dom";
3 | import { Layout, Input } from "antd";
4 | import { MenuItems } from "./components";
5 |
6 | import logo from "./assets/paperhouses-logo.png";
7 | import { Viewer } from "../../types";
8 | import { displayErrorMessage } from "../../utils";
9 |
10 | interface Props {
11 | viewer: Viewer;
12 | setViewer: (viewer: Viewer) => void;
13 | }
14 |
15 | const { Header } = Layout;
16 | const { Search } = Input;
17 |
18 | export const AppHeader = ({ viewer, setViewer }: Props) => {
19 | const [search, setSearch] = useState("");
20 |
21 |
22 | const navigate = useNavigate();
23 | const location = useLocation();
24 |
25 | useEffect(() => {
26 | const { pathname } = location;
27 | const pathnameSubStrings = pathname.split("/");
28 |
29 | if (!pathname.includes("/listings")) {
30 | setSearch("");
31 | return;
32 | }
33 |
34 | if (pathname.includes("/listings") && pathnameSubStrings.length === 3) {
35 | var replaced = pathnameSubStrings[2].replace(/%20/g, " ");
36 | setSearch(replaced);
37 | return;
38 | }
39 | }, [location]);
40 |
41 | const onSearch = (value: string) => {
42 | const trimmedValue = value.trim();
43 |
44 | if (trimmedValue) {
45 | navigate(`/listings/${trimmedValue}`);
46 | } else {
47 | displayErrorMessage("Please enter a valid search!");
48 | }
49 | };
50 |
51 | return (
52 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/client/src/sections/Listing/components/ListingBookings/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 | import { Avatar, Divider, List, Typography } from "antd";
4 | import { Listing } from "../../../../lib/graphql/queries/Listing/__generated__/Listing";
5 |
6 | interface Props {
7 | listingBookings: Listing["listing"]["bookings"];
8 | bookingsPage: number;
9 | limit: number;
10 | setBookingsPage: (page: number) => void;
11 | }
12 |
13 | const { Text, Title } = Typography;
14 |
15 | export const ListingBookings = ({
16 | listingBookings,
17 | bookingsPage,
18 | limit,
19 | setBookingsPage
20 | }: Props) => {
21 | const total = listingBookings ? listingBookings.total : null;
22 | const result = listingBookings ? listingBookings.result : null;
23 |
24 | const listingBookingsList = listingBookings ? (
25 | setBookingsPage(page)
41 | }}
42 | renderItem={listingBooking => {
43 | const bookingHistory = (
44 |
45 |
46 | Check in: {listingBooking.checkIn}
47 |
48 |
49 | Check out: {listingBooking.checkOut}
50 |
51 |
52 | );
53 |
54 | return (
55 |
56 | {bookingHistory}
57 |
58 |
59 |
60 |
61 | );
62 | }}
63 | />
64 | ) : null;
65 |
66 | const listingBookingsElement = listingBookingsList ? (
67 |
68 |
69 |
70 |
Bookings
71 |
72 | {listingBookingsList}
73 |
74 | ) : null;
75 |
76 | return listingBookingsElement;
77 | };
78 |
--------------------------------------------------------------------------------
/client/src/lib/components/ListingCard/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 | import { Card, Rate, Typography } from "antd";
4 | import Icon, {
5 | UserOutlined,
6 | } from "@ant-design/icons";
7 | import { iconColor, formatListingPrice } from "../../utils";
8 |
9 | interface Props {
10 | listing: {
11 | id: string;
12 | title: string;
13 | image: string;
14 | address: string;
15 | price: number;
16 | numOfGuests: number;
17 | rating: number;
18 | };
19 | }
20 |
21 | const { Text, Title } = Typography;
22 |
23 | export const ListingCard = ({ listing }: Props) => {
24 | const { id, title, image, address, price, numOfGuests, rating } = listing;
25 |
26 | return (
27 |
28 |
35 | }
36 | >
37 |
38 |
39 |
40 | {formatListingPrice(price)}
41 | /day
42 |
43 |
44 | {title}
45 |
46 |
47 | {address}
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
58 | }
59 | style={{ color: iconColor }}
60 | />
61 | {numOfGuests} guests
62 |
63 |
64 |
65 |
66 |
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/client/src/sections/User/components/UserBookings/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { List, Typography } from "antd";
3 | import { ListingCard } from "../../../../lib/components";
4 | import { User } from "../../../../lib/graphql/queries/User/__generated__/User";
5 |
6 | interface Props {
7 | userBookings: User["user"]["bookings"];
8 | bookingsPage: number;
9 | limit: number;
10 | setBookingsPage: (page: number) => void;
11 | }
12 |
13 | const { Paragraph, Text, Title } = Typography;
14 |
15 | export const UserBookings = ({
16 | userBookings,
17 | bookingsPage,
18 | limit,
19 | setBookingsPage
20 | }: Props) => {
21 | const total = userBookings ? userBookings.total : null;
22 | const result = userBookings ? userBookings.result : null;
23 |
24 | const userBookingsList = userBookings ? (
25 | setBookingsPage(page)
42 | }}
43 | renderItem={userBooking => {
44 | const bookingHistory = (
45 |
46 |
47 | Check in: {userBooking.checkIn}
48 |
49 |
50 | Check out: {userBooking.checkOut}
51 |
52 |
53 | );
54 |
55 | return (
56 |
57 | {bookingHistory}
58 |
59 |
60 | );
61 | }}
62 | />
63 | ) : null;
64 |
65 | const userBookingsElement = userBookingsList ? (
66 |
67 |
68 | Bookings
69 |
70 |
71 | This section highlights the bookings you've made, and the
72 | check-in/check-out dates associated with said bookings.
73 |
74 | {userBookingsList}
75 |
76 | ) : null;
77 |
78 | return userBookingsElement;
79 | };
80 |
--------------------------------------------------------------------------------
/client/src/sections/Home/components/HomeHero/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 | import { Card, Col, Input, Row, Typography } from "antd";
4 |
5 | import torontoImage from "../../assets/toronto.jpg";
6 | import dubaiImage from "../../assets/dubai.jpg";
7 | import losAngelesImage from "../../assets/los-angeles.jpg";
8 | import londonImage from "../../assets/london.jpg";
9 |
10 | const { Title } = Typography;
11 | const { Search } = Input;
12 |
13 | interface Props {
14 | onSearch: (value: string) => void;
15 | }
16 |
17 | export const HomeHero = ({ onSearch }: Props) => {
18 | return (
19 |
20 |
21 |
22 | Find a place you'll love to stay at
23 |
24 |
31 |
32 |
33 |
34 |
35 | }>
36 | Toronto
37 |
38 |
39 |
40 |
41 |
42 | }>
43 | Dubai
44 |
45 |
46 |
47 |
48 |
49 |
52 | }
53 | >
54 | Los Angeles
55 |
56 |
57 |
58 |
59 |
60 | }>
61 | London
62 |
63 |
64 |
65 |
66 |
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import {
4 | ApolloClient,
5 | ApolloProvider,
6 | InMemoryCache,
7 | createHttpLink,
8 | split,
9 | Operation
10 | } from "@apollo/client";
11 | import { setContext } from "@apollo/client/link/context";
12 | import { SubscriptionClient } from "subscriptions-transport-ws";
13 | import { WebSocketLink } from "@apollo/client/link/ws";
14 | import { getMainDefinition } from "@apollo/client/utilities";
15 | import { loadStripe } from "@stripe/stripe-js";
16 | import { Elements } from "@stripe/react-stripe-js";
17 | import reportWebVitals from './reportWebVitals';
18 | import "./styles/index.css";
19 | import { App } from './App';
20 |
21 | // const uri = "https://paperhouses-server.onrender.com/api";
22 | // const wsUrl = "wss://paperhouses-server.onrender.com/api";
23 |
24 | const uri = "http://localhost:9000/api"
25 | const wsUrl = "ws://localhost:9000/api";
26 |
27 | const httpLink = createHttpLink({
28 | uri: uri,
29 | credentials: "include",
30 | });
31 |
32 |
33 | const wsLink = new WebSocketLink(
34 | new SubscriptionClient(wsUrl, {
35 | reconnect: true,
36 | lazy: true,
37 | connectionParams: {
38 | xCsrfToken: sessionStorage.getItem("token") || "",
39 | },
40 | })
41 | );
42 |
43 | const tokenLink = setContext((_, { headers }) => {
44 | // get the X-CSRF-TOKEN token from session storage if it exists
45 | const token = sessionStorage.getItem("token");
46 | console.log(token)
47 | // return the headers to the context so httpLink can read them
48 | return {
49 | headers: {
50 | ...headers,
51 | "X-CSRF-TOKEN": sessionStorage.getItem("token") || "",
52 | },
53 | };
54 | });
55 |
56 | function isSubscription(operation: Operation) {
57 | const definition = getMainDefinition(operation.query);
58 | return (
59 | definition.kind === "OperationDefinition" &&
60 | definition.operation === "subscription"
61 | );
62 | }
63 |
64 | const splitLink = split(
65 | isSubscription,
66 | wsLink,
67 | tokenLink.concat(httpLink)
68 | );
69 |
70 |
71 | const client = new ApolloClient({
72 | link: splitLink,
73 | credentials: 'include',
74 | cache: new InMemoryCache(),
75 | });
76 |
77 |
78 |
79 | const root = ReactDOM.createRoot(
80 | document.getElementById('root') as HTMLElement
81 | );
82 |
83 | const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY!);
84 |
85 | root.render(
86 | //
87 |
88 |
89 |
90 |
91 |
92 | //
93 | );
94 |
95 | // If you want to start measuring performance in your app, pass a function
96 | // to log results (for example: reportWebVitals(console.log))
97 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
98 |
99 | reportWebVitals();
100 |
--------------------------------------------------------------------------------
/server/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | import { Collection, ObjectId } from "mongodb";
2 |
3 | export interface Viewer {
4 | _id?: string;
5 | token?: string;
6 | avatar?: string;
7 | walletId?: string;
8 | didRequest: boolean;
9 | }
10 |
11 | export enum ListingType {
12 | Apartment = "APARTMENT",
13 | House = "HOUSE",
14 | }
15 |
16 | export interface BookingsIndexMonth {
17 | [key: string]: boolean;
18 | }
19 |
20 | export interface BookingsIndexYear {
21 | [key: string]: BookingsIndexMonth;
22 | }
23 |
24 | export interface BookingsIndex {
25 | [key: string]: BookingsIndexYear;
26 | }
27 |
28 |
29 | // NOTE: JavaScript function for getting month returns 0 for Jan and 11 for Dec
30 | const bookingsIndex: BookingsIndex = {
31 | "2019": {
32 |
33 | // 2019-01-01 is booked
34 | "00": {
35 | "01": true,
36 | "02": true
37 | },
38 |
39 | // 2019-04-31 is booked
40 | "04": {
41 | "31": true
42 | },
43 |
44 | // 2019-05-01 is booked
45 | "05": {
46 | "01": true
47 | },
48 |
49 | // 2019-06-20 is booked
50 | "06": {
51 | "20": true
52 | }
53 | }
54 | }
55 |
56 |
57 |
58 | export interface Booking {
59 | _id: ObjectId;
60 | listing: ObjectId;
61 | tenant: string; // user_id that booked the listing
62 | checkIn: string;
63 | checkOut: string;
64 | }
65 |
66 | export interface Listing {
67 | _id: ObjectId;
68 | title: string;
69 | description: string;
70 | image: string;
71 | host: string; // host user_id
72 | type: ListingType;
73 | address: string;
74 | country: string;
75 | admin: string;
76 | city: string;
77 | bookings: ObjectId[]; // bookings made for a certain listing from many different users
78 | bookingsIndex: BookingsIndex;
79 | price: number;
80 | numOfGuests: number;
81 | authorized?: boolean;
82 | reviews: Review[];
83 | numReviews: number;
84 | rating: number;
85 | }
86 |
87 | export interface User {
88 | _id: string;
89 | token: string;
90 | name: string;
91 | avatar: string;
92 | contact: string;
93 | walletId?: string;
94 | income: number;
95 | bookings: ObjectId[]; // refers to the document in the bookings collection
96 | listings: ObjectId[]; // refers to the document in the listings collection
97 | authorized?: boolean; // not a database field
98 | chats: ObjectId[];
99 | }
100 |
101 |
102 | export interface Message {
103 | _id: ObjectId;
104 | content: string;
105 | author: string;
106 | createdAt: string;
107 | }
108 |
109 | export interface Chat {
110 | _id: ObjectId;
111 | participants: string[];
112 | messages: ObjectId[]; // array of message ids
113 | }
114 |
115 | export interface Review {
116 | _id: ObjectId;
117 | rating: number;
118 | comment?: string;
119 | createdAt: string;
120 | author: string;
121 | }
122 |
123 | export interface Database {
124 | bookings: Collection;
125 | listings: Collection;
126 | users: Collection;
127 | messages: Collection;
128 | chat: Collection;
129 | }
130 |
--------------------------------------------------------------------------------
/client/src/sections/Chat/components/Messages/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import Message from "../Message";
3 | import { Typography } from "antd";
4 | import {
5 | Chat,
6 | Chat_chat_messages as ChatMessage,
7 | Chat_chat_participants,
8 | } from "../../../../lib/graphql/queries/Chat/__generated__/Chat";
9 | import { Viewer } from "../../../../lib/types";
10 | import { DateSeparator } from "../DateSeparator";
11 |
12 | const { Text } = Typography;
13 |
14 | interface Props {
15 | messages: Chat["chat"]["messages"];
16 | viewer: Viewer;
17 | recipient: Chat_chat_participants
18 | }
19 |
20 | export const Messages = ({ messages, viewer, recipient } : Props) => {
21 | const messagesEndRef = useRef(null);
22 |
23 | const sameAuthor = (message: ChatMessage, index: number) => {
24 | if (index === 0) {
25 | return false;
26 | }
27 | return message.author.id === messages[index - 1].author.id;
28 | };
29 |
30 | const scrollToBottom = () => {
31 | if (messagesEndRef.current) {
32 | messagesEndRef?.current.scrollIntoView({ behavior: "smooth" });
33 | }
34 | };
35 |
36 | useEffect(() => {
37 | scrollToBottom();
38 | }, [messages]);
39 |
40 | return (
41 |
42 |
50 | This is the beginning of your conversation with{" "}
51 | {recipient.name}
52 |
53 |
54 | {messages.map((message, index) => {
55 | const thisMessageDate = new Date(
56 | message.createdAt
57 | ).toDateString();
58 | const prevMessageDate =
59 | index > 0 &&
60 | new Date(messages[index - 1]?.createdAt).toDateString();
61 |
62 | const isSameDay =
63 | index > 0 ? thisMessageDate === prevMessageDate : true;
64 |
65 | const incomingMessage = message.author.id !== viewer.id;
66 |
67 | return (
68 |
69 | {(!isSameDay || index === 0) && (
70 |
71 | )}
72 |
73 |
82 |
83 | );
84 | })}
85 |
86 |
87 | );
88 | };
89 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/Chat/index.ts:
--------------------------------------------------------------------------------
1 | import { IResolvers } from "apollo-server-express";
2 | import { Request } from "express";
3 | import { ObjectId } from "mongodb";
4 |
5 | import {
6 | Chat,
7 | Database,
8 | Message,
9 | User,
10 | } from "../../../lib/types";
11 | import { authorize } from "../../../lib/utils";
12 | import { ChatArgs } from "./types";
13 |
14 | export const chatResolvers: IResolvers = {
15 | Query: {
16 | chat: async (
17 | _root: undefined,
18 | { recipient }: ChatArgs,
19 | { db, req }: { db: Database; req: Request }
20 | ): Promise => {
21 | try {
22 | const viewer = await authorize(db, req);
23 |
24 | if (!viewer) {
25 | throw new Error("viewer cannot be found | unauthorized");
26 | }
27 |
28 | const recipientUser = await db.users.findOne({
29 | _id: recipient
30 | });
31 |
32 | if (!recipientUser) {
33 | throw new Error("recipient cannot be found");
34 | }
35 |
36 | if(viewer._id === recipientUser._id) {
37 | throw new Error("Cannot create chat with your own self.");
38 | }
39 |
40 | let chat = await db.chat.findOne({
41 | participants: {
42 | $all: [viewer._id, recipient],
43 | },
44 | });
45 |
46 | if (!chat) {
47 | console.log("creating new chat....!");
48 | const inserted = await db.chat.insertOne({
49 | _id: new ObjectId(),
50 | messages: [],
51 | participants: [viewer._id, recipient],
52 | });
53 |
54 | chat = inserted.ops[0];
55 |
56 | await db.users.updateMany(
57 | {
58 | _id: { $in: [viewer._id, recipient] },
59 | },
60 | {
61 | $push: { chats: chat._id },
62 | }
63 | );
64 | } else {
65 | console.log("chat exists....!");
66 | }
67 | return chat;
68 | } catch (error) {
69 | throw new Error(`Failed to query the chat: ${error}`);
70 | }
71 | },
72 |
73 | },
74 |
75 | Chat: {
76 | id: (chat: Chat): string => {
77 | return chat._id.toString();
78 | },
79 |
80 | messages: async (
81 | chat: Chat,
82 | _args: {},
83 | { db }: { db: Database }
84 | ): Promise => {
85 | let cursor = await db.messages.find({
86 | _id: { $in: chat.messages },
87 | });
88 |
89 | return cursor.toArray();
90 | },
91 |
92 | participants: async (
93 | chat: Chat,
94 | _args: {},
95 | { db }: { db: Database }
96 | ): Promise => {
97 | let cursor = await db.users.find({
98 | _id: { $in: chat.participants },
99 | });
100 |
101 | return cursor.toArray();
102 | },
103 | },
104 | };
105 |
--------------------------------------------------------------------------------
/client/src/sections/User/components/UserChats/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useQuery } from "@apollo/client";
3 | import { Layout, Typography, Button, List, Avatar, Empty, Drawer, Divider } from "antd";
4 | import Icon, { CommentOutlined } from "@ant-design/icons";
5 | import { Viewer } from "../../../../lib/types";
6 | import { User } from "../../../../lib/graphql/queries/User/__generated__/User";
7 | import { Link } from "react-router-dom";
8 | import { iconColor } from "../../../../lib/utils";
9 |
10 |
11 | const { Content } = Layout;
12 | const { Text, Title } = Typography;
13 |
14 | interface Props {
15 | viewer: Viewer;
16 | chats: User["user"]["chats"]
17 | }
18 |
19 | export const UserChats = ({ viewer, chats }: Props) => {
20 | const [open, setOpen] = useState(false);
21 |
22 | const chatList = chats?.map((chat) => {
23 | return {
24 | ...chat,
25 | recipient: chat.participants.find((participant) => participant.id !== viewer.id)
26 | }
27 | })
28 |
29 | const handleClose = () => {
30 | setOpen(false);
31 | };
32 |
33 | const showChatHistory = () => {
34 | setOpen(true);
35 | };
36 |
37 | return (
38 | <>
39 |
44 | Chat History
45 |
46 |
50 |
53 | }
54 | style={{
55 | marginRight: "5px",
56 | fontSize: "20px",
57 | color: iconColor,
58 | }}
59 | />
60 |
64 | Chat History
65 |
66 | >
67 | }
68 | placement="right"
69 | onClose={handleClose}
70 | >
71 | {chatList && chatList.length > 0 ? (
72 | (
76 |
77 |
78 |
83 | }
84 | title={item.recipient?.name}
85 | />
86 |
87 |
88 | )}
89 | />
90 | ) : (
91 | Empty chat history!} />
92 | )}
93 |
94 | >
95 | );
96 | };
97 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PaperHouses
2 | Airbnb like web app. | Rent out your property, house and apartment and earn money.
3 | Book Top Rated Rentals for Your Next Trip. Helping you make the best decisions in renting and choosing your last minute locations.
4 |
5 | # [Demo](https://paperhouses.netlify.app/)
6 |
7 | # Libraries used
8 |
9 | *I have used as [Ben Awad](https://github.com/benawad) likes to calls it: **The Hypebeast Stack** (React, Typescript, Node.js, GraphQL)*
10 |
11 | - **`React`** the love of my life
12 | - **`GraphQL`** because REST APIS are boring
13 | - **`apollo-server-express`** as server
14 | - **`MongoDB`** for persistance of data
15 | - **`GraphQL Subscriptions`** for realtime communication and chat system
16 | - **`Ant Design`** for creating UI
17 | - **`Apollo Client`** for client state management
18 | - **`Typescript`** for type safety, cure for headache you get when props are flowing all over the app with no hint
19 | - **`Stripe`** for handling payments
20 | - **`Cloudinary`** for image uploads
21 | - **`Mapbox`** for showing maps
22 |
23 |
24 | # Features
25 |
26 | * Google Oauth
27 | * Book Top Rated Rentals and Listings for Your Next Trip
28 | * Chat system | Direct message the host/owner of rentals for more information.
29 | * Create a listing and rent out your property, house or apartment and earn money.
30 | * Update the Listing information
31 | * Handle payments with stripe.
32 | * Review and rating system.
33 | * Search for listings and rentals based on location.
34 | * Sort listings based on price and ratings.
35 | * View the location of any listing or rental on a detailed map.
36 |
37 | **and much more....**
38 |
39 |
40 | # Installation
41 |
42 | 1. Clone project
43 |
44 | ```
45 | git clone git@github.com:saalikmubeen/paperhouses.git
46 | ```
47 |
48 | ## Manual
49 |
50 | If you aren't a docker person, [We can't be friends. 😑]
51 |
52 | cd into root project
53 |
54 | ```
55 | 1. cd server
56 | ```
57 |
58 | `npm install` to to install server dependencies
59 |
60 | `npm install --force` if `npm install` doesn't work due to conflicting dependencies
61 |
62 | `Setup required environment variables:`
63 |
64 | *Make a **`.env`** file inside the server directory with below environment variables*
65 |
66 | - MONGO_URI_DEV
67 | - PUBLIC_URL:
68 | - SECRET: my_super_secret_for_cookies
69 | - GOOGLE_CLIENT_ID
70 | - GOOGLE_CLIENT_SECRET
71 | - STRIPE_CLIENT_ID
72 | - STRIPE_SECRET_KEY
73 | - CLOUDINARY_NAME
74 | - CLOUDINARY_API_KEY
75 | - CLOUDINARY_API_SECRET
76 | - MAPBOX_API_KEY
77 |
78 | `npm run seed` if you want to seed the database with some data
79 |
80 | `npm run dev` to start development server
81 |
82 | *Make sure you have mongoDB installed*
83 |
84 | ```
85 | 1. cd client
86 | ```
87 |
88 | `npm install` installs client dependencies.
89 |
90 | `Setup required environment variables:`
91 |
92 | *Make a **`.env`** file inside the client directory with below environment variables*
93 |
94 | - REACT_APP_STRIPE_PUBLISHABLE_KEY
95 | - REACT_APP_STRIPE_CLIENT_ID
96 | - REACT_APP_MAPBOX_API_KEY
97 |
98 | `npm run start` to start the react development server.
99 |
100 |
101 | ## Docker
102 |
103 | **`If you use docker, respect++`**
104 |
105 | Running project through docker is a breeze. You don't have to do any setup. Just one docker-compose command and magic
106 |
107 | `cd into root project`
108 |
109 | *First replace the environment variables in the docker-compose.yml file with your own.*
110 |
111 | `Then run:`
112 |
113 | ```
114 | docker-compose up --build
115 | ```
116 |
117 | *Only if you have docker installed in the first place*
--------------------------------------------------------------------------------
/client/src/sections/Chat/components/Message/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Typography, Avatar, message } from "antd";
3 | import { iconColor } from "../../../../lib/utils";
4 | import { Link } from "react-router-dom";
5 |
6 | const { Text } = Typography;
7 |
8 | interface MessageProps {
9 | content: string;
10 | sameAuthor: boolean;
11 | username: string;
12 | date: string;
13 | incomingMessage: boolean;
14 | avatar: string
15 | userId: string;
16 | }
17 |
18 |
19 | function formatDate(date: Date) {
20 | let hours = date.getHours();
21 | let minutes: string | number = date.getMinutes();
22 | let ampm = hours >= 12 ? "PM" : "AM";
23 | hours = hours % 12;
24 | hours = hours ? hours : 12; // the hour '0' should be '12'
25 | minutes = minutes < 10 ? "0" + minutes : minutes;
26 | let strTime = hours + ":" + minutes + " " + ampm;
27 |
28 | return strTime;
29 | }
30 |
31 |
32 | const Message = ({
33 | content,
34 | sameAuthor,
35 | username,
36 | date,
37 | incomingMessage,
38 | avatar,
39 | userId
40 | }: MessageProps) => {
41 | if (!incomingMessage) {
42 | return (
43 |
44 |
50 |
57 | {content}
58 |
59 |
60 |
68 | {formatDate(new Date(date))}
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | return (
76 |
77 | {!sameAuthor && (
78 |
83 | )}
84 |
85 |
92 |
99 | {content}
100 |
101 |
102 |
109 | {formatDate(new Date(date))}
110 |
111 |
112 |
113 | );
114 | };
115 |
116 | export default Message;
117 |
--------------------------------------------------------------------------------
/client/src/sections/Listing/components/ListingDetails/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 | import { Avatar, Divider, Rate, Tag, Typography } from "antd";
4 | import { EnvironmentOutlined } from "@ant-design/icons";
5 | import { Listing as ListingData } from "../../../../lib/graphql/queries/Listing/__generated__/Listing";
6 | import { iconColor } from "../../../../lib/utils";
7 | import { ShowLocation } from "../../../../lib/components/Map";
8 | import { Viewer } from "../../../../lib/types";
9 |
10 | interface Props {
11 | listing: ListingData["listing"];
12 | viewer: Viewer;
13 | }
14 |
15 | const { Paragraph, Title } = Typography;
16 |
17 | export const ListingDetails = ({ listing, viewer }: Props) => {
18 | const {
19 | title,
20 | description,
21 | image,
22 | type,
23 | address,
24 | city,
25 | numOfGuests,
26 | host,
27 | rating,
28 | numReviews,
29 | } = listing;
30 |
31 | return (
32 |
33 |
37 |
38 |
39 |
44 |
45 | {" "}
46 | {city}
47 |
48 |
49 | {address}
50 |
51 |
52 | {title}
53 |
54 |
{" "}
55 |
56 | ({numReviews} reviews)
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | {host.name}
67 |
68 |
69 |
70 |
71 |
72 |
79 |
80 | Message {host.name} to know
81 | more.
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
About this space
90 |
91 | {type}
92 | {numOfGuests} Guests
93 |
94 |
95 | {description}
96 |
97 |
98 |
99 |
100 |
101 | );
102 | };
103 |
--------------------------------------------------------------------------------
/client/src/sections/Chat/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { Layout, Spin } from "antd";
3 | import { useQuery, useSubscription } from "@apollo/client";
4 | import { useParams, useNavigate } from "react-router-dom";
5 | import { SEND_MESSAGE } from "../../lib/graphql/subscriptions/SendMessage";
6 | import { Viewer } from "../../lib/types";
7 | import { CHAT } from "../../lib/graphql/queries";
8 | import {
9 | Chat as ChatData,
10 | ChatVariables,
11 | } from "../../lib/graphql/queries/Chat/__generated__/Chat";
12 | import {
13 | ErrorBanner,
14 | PageSkeleton,
15 | } from "../../lib/components";
16 | import { Messages } from "./components/Messages";
17 | import { NewMessageInput } from "./components/NewMessageInput";
18 | import { SendMessage as SendMessageData, SendMessageVariables } from "../../lib/graphql/subscriptions/SendMessage/__generated__/SendMessage";
19 | import { useScrollToTop } from "../../lib/hooks/useScrollToTop";
20 |
21 | const { Content } = Layout;
22 |
23 | interface Props {
24 | viewer: Viewer;
25 | }
26 |
27 | type Params = Record<"recipientId", string>;
28 |
29 | export const Chat = (props: Props) => {
30 | let params = useParams() as Params;
31 | const navigate = useNavigate();
32 |
33 | const { loading, error, data, subscribeToMore } = useQuery<
34 | ChatData,
35 | ChatVariables
36 | >(CHAT, {
37 | variables: {
38 | recipient: params.recipientId
39 | }
40 | });
41 |
42 | useScrollToTop();
43 |
44 | useEffect(() => {
45 |
46 | if (!props.viewer.id) {
47 | navigate("/");
48 | return;
49 | }
50 |
51 | if (!loading && data?.chat) {
52 | subscribeToMore({
53 | document: SEND_MESSAGE,
54 | variables: { to: params.recipientId },
55 | updateQuery: (prev, { subscriptionData }) => {
56 | if (!subscriptionData.data) return prev;
57 | const newMessage = (
58 | subscriptionData.data as unknown as SendMessageData
59 | ).sendMessage;
60 |
61 | const updatedChat: ChatData = Object.assign({}, prev, {
62 | chat: {
63 | ...prev.chat,
64 | messages: [...prev.chat.messages, newMessage],
65 | },
66 | });
67 |
68 | return updatedChat;
69 | },
70 | });
71 | }
72 | }, [
73 | params.recipientId,
74 | subscribeToMore,
75 | loading,
76 | props.viewer.id,
77 | navigate,
78 | ]);
79 |
80 | if(!props.viewer.id) {
81 | return <>>;
82 | }
83 |
84 | if (loading) {
85 | return (
86 |
87 |
88 |
89 | );
90 | }
91 |
92 | if (error) {
93 | console.log(error)
94 | return (
95 |
96 |
97 |
98 |
99 | );
100 | }
101 |
102 | const chat = data ? data.chat : null;
103 |
104 | if(!chat) {
105 | return null;
106 | }
107 |
108 | console.log(chat)
109 |
110 | const recipient = chat.participants.find((participant) => {
111 | return participant.id !== props.viewer.id;
112 | });
113 |
114 | return (
115 |
116 |
117 |
118 |
119 | );
120 | };
121 |
--------------------------------------------------------------------------------
/client/src/sections/Listings/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from "react";
2 | import { Link, useParams } from "react-router-dom";
3 | import { useQuery } from "@apollo/client";
4 | import { Affix, Layout, List, Typography } from "antd";
5 | import { ErrorBanner, ListingCard } from "../../lib/components";
6 | import { LISTINGS } from "../../lib/graphql/queries";
7 | import {
8 | Listings as ListingsData,
9 | ListingsVariables
10 | } from "../../lib/graphql/queries/Listings/__generated__/Listings";
11 | import { ListingsFilter } from "../../lib/graphql/globalTypes";
12 | import { ListingsFilters, ListingsPagination, ListingsSkeleton } from "./components";
13 | import { useScrollToTop } from "../../lib/hooks/useScrollToTop";
14 |
15 | type Params = Record<"location", string>;
16 |
17 | const { Content } = Layout;
18 | const { Paragraph, Text, Title } = Typography;
19 |
20 | const PAGE_LIMIT = 8;
21 |
22 | export const Listings = () => {
23 | const { location } = useParams() as Params;
24 | const locationRef = useRef(location);
25 | const [filter, setFilter] = useState(ListingsFilter.PRICE_LOW_TO_HIGH);
26 | const [page, setPage] = useState(1);
27 |
28 | const { loading, data, error } = useQuery(
29 | LISTINGS,
30 | {
31 | skip: locationRef.current !== location && page !== 1, // If true, the query is not executed
32 | variables: {
33 | location,
34 | filter,
35 | limit: PAGE_LIMIT,
36 | page,
37 | },
38 | }
39 | );
40 |
41 | useScrollToTop();
42 |
43 |
44 | useEffect(() => {
45 | setPage(1);
46 | locationRef.current = location;
47 | }, [location]);
48 |
49 | if (loading) {
50 | return (
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | if (error) {
58 | return (
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | const listings = data ? data.listings : null;
67 | const listingsRegion = listings ? listings.region : null;
68 |
69 | const listingsSectionElement =
70 | listings && listings.result.length > 0 ? (
71 |
72 |
73 |
79 |
80 |
81 |
(
90 |
91 |
92 |
93 | )}
94 | />
95 |
96 | ) : (
97 |
98 |
99 | It appears that no listings have yet been created for{" "}
100 | "{listingsRegion}"
101 |
102 |
103 | Be the first person to create a listing in this area!
104 |
105 |
106 | );
107 |
108 | const listingsRegionElement = listingsRegion ? (
109 |
110 | Results for "{listingsRegion}"
111 |
112 | ) : null;
113 |
114 | return (
115 |
116 | {listingsRegionElement}
117 | {listingsSectionElement}
118 |
119 | );
120 | };
121 |
--------------------------------------------------------------------------------
/client/src/lib/components/Map/ShowLocation.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useCallback } from "react";
2 | // eslint-disable-next-line import/no-webpack-loader-syntax
3 | import mapboxgl, { Map } from "mapbox-gl";
4 | import mbxGeocoding from "@mapbox/mapbox-sdk/services/geocoding";
5 | import "mapbox-gl/dist/mapbox-gl.css";
6 | import "./map.css";
7 |
8 | // The following is required to stop "npm build" from transpiling mapbox code.
9 | // notice the exclamation point in the import.
10 | // @ts-ignore
11 | // eslint-disable-next-line import/no-webpack-loader-syntax, import/no-unresolved
12 | // mapboxgl.workerClass = require("worker-loader!mapbox-gl/dist/mapbox-gl-csp-worker").default;
13 |
14 | export const ShowLocation = ({ address } : {address: string}) => {
15 | const map = useRef(null);
16 | const mapContainerRef = useRef(null);
17 | mapboxgl.accessToken = process.env.REACT_APP_MAPBOX_API_KEY as string;
18 |
19 | const fetchData = useCallback(() => {
20 | const geocodingClient = mbxGeocoding({
21 | accessToken: mapboxgl.accessToken,
22 | });
23 |
24 | // geocoding with countries
25 | return geocodingClient
26 | .forwardGeocode({
27 | query: address,
28 |
29 | })
30 | .send()
31 | .then((response) => {
32 | const match = response.body;
33 | const coordinates = match.features[0].geometry.coordinates;
34 | const placeName = match.features[0].place_name;
35 | const center = match.features[0].center;
36 |
37 | return {
38 | type: "Feature",
39 | center: center,
40 | geometry: {
41 | type: "Point",
42 | coordinates: coordinates,
43 | },
44 | properties: {
45 | description: placeName,
46 | },
47 | };
48 | });
49 | }, [address]);
50 |
51 | useEffect(() => {
52 | // if (map.current) return; // Checks if there's an already existing map initialised.
53 |
54 | map.current = new mapboxgl.Map({
55 | container: mapContainerRef.current as HTMLDivElement,
56 | style: "mapbox://styles/mapbox/streets-v11",
57 | zoom: 9,
58 | center: [3.361881, 6.672557],
59 | });
60 |
61 | // clean up on unmount
62 | return () => {
63 | if(map.current) {
64 | map.current.remove();
65 | }
66 | };
67 | }, []);
68 |
69 | useEffect(() => {
70 |
71 | if (!map || !map.current) {
72 | return;
73 | }; // Waits for the map to initialise
74 |
75 | const results = fetchData();
76 |
77 | results.then((marker) => {
78 | // create a HTML element for each feature
79 | var el = document.createElement("div");
80 | el.className = "circle";
81 |
82 | if(!map.current) {
83 | return
84 | }
85 |
86 | // make a marker for each feature and add it to the map
87 | new mapboxgl.Marker(el)
88 | .setLngLat(marker.geometry.coordinates as any)
89 | .setPopup(
90 | new mapboxgl.Popup({ offset: 25 }) // add popups
91 | .setHTML("" + marker.properties.description + "
")
92 | )
93 | .addTo(map.current);
94 |
95 | map.current.on("load", async () => {
96 |
97 | map.current!.flyTo({
98 | center: marker.center as any,
99 | });
100 | });
101 | });
102 | }, [fetchData]);
103 |
104 | return (
105 |
108 | );
109 | };
110 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from "react";
2 | import { BrowserRouter, Route, Routes } from "react-router-dom";
3 | import { Affix, Layout, Spin } from "antd";
4 | import { useMutation } from "@apollo/client";
5 | import { Viewer } from "./lib/types";
6 | import {
7 | Home,
8 | Host,
9 | Listing,
10 | Listings,
11 | NotFound,
12 | User,
13 | Login,
14 | } from "./sections";
15 | import { LOG_IN } from "./lib/graphql/mutations";
16 | import { LogInVariables, LogIn as LogInData } from "./lib/graphql/mutations/LogIn/__generated__/LogIn";
17 | import { AppHeaderSkeleton, ErrorBanner } from "./lib/components";
18 | import { AppHeader } from "./lib/components/AppHeader";
19 | import { Stripe } from "./sections/Stripe";
20 | import { Chat } from "./sections/Chat";
21 |
22 | const initialViewer: Viewer = {
23 | id: null,
24 | token: null,
25 | avatar: null,
26 | hasWallet: null,
27 | didRequest: false,
28 | };
29 |
30 | export const App = () => {
31 | const [viewer, setViewer] = useState(initialViewer);
32 |
33 | const [logIn, { error }] = useMutation(LOG_IN, {
34 | onCompleted: (data) => {
35 | if (data && data.logIn) {
36 | setViewer(data.logIn);
37 |
38 | if (data.logIn.token) {
39 | sessionStorage.setItem("token", data.logIn.token);
40 | } else {
41 | sessionStorage.removeItem("token");
42 | }
43 | }
44 | },
45 | });
46 | const logInRef = useRef(logIn);
47 |
48 | useEffect(() => {
49 | logInRef.current();
50 | }, []);
51 |
52 | if (!viewer.didRequest && !error) {
53 | return (
54 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | const logInErrorBannerElement = error ? (
64 |
65 | ) : null;
66 |
67 | return (
68 |
69 |
70 | {logInErrorBannerElement}
71 |
72 |
73 |
74 |
75 |
76 | } />
77 | } />
78 |
82 | }
83 | />
84 |
88 | }
89 | />
90 | }
93 | />
94 |
95 |
96 | } />
97 | } />
98 |
99 |
100 | }
103 | />
104 |
105 | }
108 | />
109 |
110 | } />
111 |
112 |
113 |
114 | );
115 | };
116 |
--------------------------------------------------------------------------------
/client/src/lib/components/AppHeader/components/MenuItems/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Link, useNavigate } from "react-router-dom";
3 | import { useMutation } from "@apollo/client";
4 | import { Avatar, Button, Menu, } from "antd";
5 | import type { MenuProps } from "antd";
6 | import Icon, {
7 | HomeOutlined,
8 | UserOutlined,
9 | LogoutOutlined,
10 | } from "@ant-design/icons";
11 | import { Viewer } from "../../../../types";
12 | import { LOG_OUT } from "../../../../graphql/mutations";
13 | import { LogOut as LogOutData } from "../../../../graphql/mutations/LogOut/__generated__/LogOut";
14 | import {
15 | displayErrorMessage,
16 | displaySuccessNotification,
17 | } from "../../../../utils";
18 |
19 | interface Props {
20 | viewer: Viewer;
21 | setViewer: (viewer: Viewer) => void;
22 | }
23 |
24 | export const MenuItems = ({ viewer, setViewer }: Props) => {
25 | const navigate = useNavigate();
26 |
27 | const [logOut] = useMutation(LOG_OUT, {
28 | onCompleted: (data) => {
29 | if (data && data.logOut) {
30 | setViewer(data.logOut);
31 | sessionStorage.removeItem("token");
32 | displaySuccessNotification("You've successfully logged out!");
33 | navigate("/");
34 | }
35 | },
36 | onError: () => {
37 | displayErrorMessage(
38 | "Sorry! We weren't able to log you out. Please try again later!"
39 | );
40 | },
41 | });
42 |
43 | const handleLogOut = () => {
44 | logOut();
45 | };
46 |
47 | let items: MenuProps["items"] = [
48 | {
49 | label: (
50 |
51 |
54 | }
55 | style={{ marginRight: "6px" }}
56 | />
57 | Become a Host
58 |
59 | ),
60 | key: "/host",
61 | },
62 | ];
63 |
64 | if (viewer.id && viewer.avatar) {
65 | items = [
66 | ...items,
67 | {
68 | key: "viewer",
69 | icon: ,
70 | children: [
71 | {
72 | label: (
73 |
74 |
77 | }
78 | style={{ marginRight: "10px" }}
79 | />
80 | Profile
81 |
82 | ),
83 | key: "/user",
84 | },
85 | {
86 | label: (
87 |
88 |
91 | }
92 | style={{ marginRight: "10px" }}
93 | />
94 | Log out
95 |
96 | ),
97 | key: "/logout",
98 | },
99 | ],
100 | },
101 | ];
102 | } else {
103 | items = [
104 | ...items,
105 | {
106 | label: (
107 |
108 | Sign In
109 |
110 | ),
111 | key: "/login"
112 | },
113 | ];
114 | }
115 | return (
116 | <>
117 |
123 | >
124 | );
125 | };
126 |
--------------------------------------------------------------------------------
/client/src/sections/User/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useParams } from "react-router-dom";
3 | import { useQuery, useSubscription } from "@apollo/client";
4 | import { Col, Layout, Row } from "antd";
5 | import { USER } from "../../lib/graphql/queries";
6 | import {
7 | User as UserData,
8 | UserVariables
9 | } from "../../lib/graphql/queries/User/__generated__/User";
10 | import { ErrorBanner, PageSkeleton } from "../../lib/components";
11 | import { Viewer } from "../../lib/types";
12 | import { UserBookings, UserListings, UserProfile } from "./components";
13 | import { LISTING_BOOKED } from "../../lib/graphql/subscriptions";
14 | import { ListingBooked as ListingBookedData, ListingBookedVariables } from "../../lib/graphql/subscriptions/ListingBooked/__generated__/ListingBooked";
15 | import { displaySuccessNotification } from "../../lib/utils";
16 | import { useScrollToTop } from "../../lib/hooks/useScrollToTop";
17 |
18 | interface Props {
19 | viewer: Viewer;
20 | setViewer: (viewer: Viewer) => void;
21 | }
22 |
23 | type Params = Record<"id", string>;
24 |
25 | const { Content } = Layout;
26 | const PAGE_LIMIT = 4;
27 |
28 | export const User = ({
29 | viewer,
30 | setViewer
31 | }: Props) => {
32 | const [listingsPage, setListingsPage] = useState(1);
33 | const [bookingsPage, setBookingsPage] = useState(1);
34 |
35 | let params = useParams() as Params;
36 |
37 | const { data, loading, error, refetch } = useQuery(USER, {
38 | variables: {
39 | id: params.id,
40 | bookingsPage,
41 | listingsPage,
42 | limit: PAGE_LIMIT
43 | }
44 | });
45 |
46 | const { data: subscriptionData } = useSubscription(LISTING_BOOKED, {
47 | variables: { hostId: params.id, isHost: viewer.id === params.id },
48 | onData({ data }) {
49 | displaySuccessNotification(`Yayyyy 🎉✨!!! ${data.data?.listingBooked.title} has been booked by someone.`);
50 | },
51 | });
52 |
53 | const handleUserRefetch = async () => {
54 | await refetch();
55 | };
56 |
57 | useScrollToTop();
58 |
59 | const stripeError = new URL(window.location.href).searchParams.get(
60 | "stripe_error"
61 | );
62 | const stripeErrorBanner = stripeError ? (
63 |
64 | ) : null;
65 |
66 |
67 | if (loading) {
68 | return (
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | if (error) {
76 | return (
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | const user = data ? data.user : null;
85 | const viewerIsUser = viewer.id === params.id;
86 |
87 | const userListings = user ? user.listings : null;
88 | const userBookings = user ? user.bookings : null;
89 | const userChats = user ? user.chats: null
90 |
91 | const userProfileElement = user ? (
92 |
93 | ) : null;
94 |
95 | const userListingsElement = userListings ? (
96 |
102 | ) : null;
103 |
104 | // userBookings will be null only when the currently logged in user is not same as the user profile being viewed
105 | const userBookingsElement = userBookings ? (
106 |
112 | ) : null;
113 |
114 | return (
115 |
116 | {stripeErrorBanner}
117 |
118 |
119 | {userProfileElement}
120 |
121 |
122 | {userListingsElement}
123 | {userBookingsElement}
124 |
125 |
126 |
127 | );
128 | };
129 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/User/index.ts:
--------------------------------------------------------------------------------
1 | import { Request } from "express";
2 | import { IResolvers } from "apollo-server-express";
3 | import { Chat, Database, User } from "../../../lib/types";
4 | import { UserArgs, UserBookingsArgs, UserBookingsData, UserListingsArgs, UserListingsData } from "./types";
5 | import { authorize } from "../../../lib/utils";
6 |
7 | export const userResolvers: IResolvers = {
8 | Query: {
9 | user: async (
10 | _root: undefined,
11 | { id }: UserArgs,
12 | { db, req }: { db: Database; req: Request }
13 | ): Promise => {
14 | try {
15 | const user = await db.users.findOne({ _id: id });
16 | console.log(user);
17 |
18 | if (!user) {
19 | throw new Error("User not found!");
20 | }
21 |
22 | const viewer = await authorize(db, req);
23 |
24 | // if currently logged in user is same as the use being queried
25 | if (viewer && viewer._id === user._id) {
26 | user.authorized = true;
27 | } else {
28 | user.authorized = false;
29 | }
30 |
31 | return user;
32 | } catch (err) {
33 | throw new Error(`Failed to query user: ${err}`);
34 | }
35 | },
36 | },
37 |
38 | User: {
39 | id: (parent: User): string => {
40 | // parent = user
41 | return parent._id;
42 | },
43 |
44 | hasWallet: (user: User): boolean => {
45 | return Boolean(user.walletId);
46 | },
47 |
48 | income: (user: User): number | null => {
49 | return user.authorized ? user.income : null;
50 | },
51 |
52 | bookings: async (
53 | user: User,
54 | { limit, page }: UserBookingsArgs,
55 | { db }: { db: Database }
56 | ): Promise => {
57 | try {
58 | if (!user.authorized) {
59 | return null;
60 | }
61 |
62 | const data: UserBookingsData = {
63 | total: 0,
64 | result: [],
65 | };
66 |
67 | let cursor = await db.bookings.find({
68 | _id: { $in: user.bookings },
69 | });
70 |
71 | const skip = page > 0 ? (page - 1) * limit : 0;
72 | cursor = cursor.skip(skip);
73 | cursor = cursor.limit(limit);
74 |
75 | data.total = await cursor.count();
76 | data.result = await cursor.toArray();
77 |
78 | return data;
79 | } catch (error) {
80 | throw new Error(`Failed to query user bookings: ${error}`);
81 | }
82 | },
83 |
84 | listings: async (
85 | user: User,
86 | { limit, page }: UserListingsArgs,
87 | { db }: { db: Database }
88 | ): Promise => {
89 | try {
90 | const data: UserListingsData = {
91 | total: 0,
92 | result: [],
93 | };
94 |
95 | let cursor = await db.listings.find({
96 | _id: { $in: user.listings },
97 | });
98 |
99 | const skip = page > 0 ? (page - 1) * limit : 0;
100 | cursor = cursor.skip(skip);
101 | cursor = cursor.limit(limit);
102 |
103 | data.total = await cursor.count();
104 | data.result = await cursor.toArray();
105 |
106 | return data;
107 | } catch (error) {
108 | throw new Error(`Failed to query user listings: ${error}`);
109 | }
110 | },
111 |
112 | chats: async (
113 | user: User,
114 | args,
115 | { db }: { db: Database }
116 | ): Promise => {
117 | try {
118 | if (!user.authorized) {
119 | return null;
120 | }
121 |
122 | let cursor = await db.chat.find({
123 | _id: { $in: user.chats },
124 | });
125 |
126 | return cursor.toArray();
127 | } catch (error) {
128 | throw new Error(`Failed to query user chats: ${error}`);
129 | }
130 | },
131 | },
132 | };
133 |
--------------------------------------------------------------------------------
/server/src/graphql/typeDefs.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "apollo-server-express";
2 |
3 | export const typeDefs = gql`
4 | type Booking {
5 | id: ID!
6 | listing: Listing!
7 | tenant: User!
8 | checkIn: String!
9 | checkOut: String!
10 | }
11 |
12 | type Bookings {
13 | total: Int!
14 | result: [Booking!]!
15 | }
16 |
17 | enum ListingType {
18 | APARTMENT
19 | HOUSE
20 | }
21 |
22 | enum ListingsFilter {
23 | PRICE_LOW_TO_HIGH
24 | PRICE_HIGH_TO_LOW
25 | HIGHEST_RATED
26 | }
27 |
28 | type Listing {
29 | id: ID!
30 | title: String!
31 | description: String!
32 | image: String!
33 | host: User!
34 | type: ListingType!
35 | address: String!
36 | city: String!
37 | bookings(limit: Int!, page: Int!): Bookings
38 | bookingsIndex: String!
39 | price: Int!
40 | numOfGuests: Int!
41 | reviews: [Review!]!
42 | numReviews: Int!
43 | rating: Float!
44 | }
45 |
46 | type Listings {
47 | region: String
48 | total: Int! # total number of objects that can be queried
49 | result: [Listing!]!
50 | }
51 |
52 | type User {
53 | id: ID!
54 | name: String!
55 | avatar: String!
56 | contact: String!
57 | hasWallet: Boolean!
58 | income: Int
59 | bookings(limit: Int!, page: Int!): Bookings
60 | listings(limit: Int!, page: Int!): Listings!
61 | chats: [Chat!]
62 | }
63 |
64 | # currently logged in user
65 | type Viewer {
66 | id: ID
67 | token: String
68 | avatar: String
69 | hasWallet: Boolean
70 | didRequest: Boolean! # boolean to identify if we already attempted to obtain Viewers's info
71 | }
72 |
73 | type Message {
74 | id: ID!
75 | content: String!
76 | author: User!
77 | createdAt: String!
78 | }
79 |
80 | type Chat {
81 | id: ID!
82 | participants: [User!]!
83 | messages: [Message!]!
84 | }
85 |
86 | type Review {
87 | id: ID!
88 | rating: Float!
89 | comment: String
90 | createdAt: String!
91 | author: User!
92 | }
93 |
94 | input LogInInput {
95 | code: String!
96 | }
97 |
98 | input HostListingInput {
99 | title: String!
100 | description: String!
101 | image: String!
102 | type: ListingType!
103 | address: String!
104 | price: Int!
105 | numOfGuests: Int!
106 | }
107 |
108 | input UpdateListingInput {
109 | title: String!
110 | description: String!
111 | image: String
112 | type: ListingType!
113 | price: Int!
114 | numOfGuests: Int!
115 | }
116 |
117 | type UpdateListingResult {
118 | id: ID!
119 | }
120 |
121 | input ConnectStripeInput {
122 | code: String!
123 | }
124 |
125 | input CreateBookingInput {
126 | id: ID!
127 | source: String!
128 | checkIn: String!
129 | checkOut: String!
130 | }
131 |
132 | input CreateMessageInput {
133 | content: String!
134 | to: String!
135 | }
136 |
137 | input CreateReviewInput {
138 | listingId: String!
139 | rating: Float!
140 | comment: String
141 | }
142 |
143 | input DeleteReviewInput {
144 | listingId: String!
145 | reviewId: String!
146 | }
147 |
148 | type Query {
149 | authUrl: String!
150 | user(id: ID!): User!
151 | listing(id: ID!): Listing!
152 | listings(
153 | location: String
154 | filter: ListingsFilter!
155 | limit: Int!
156 | page: Int!
157 | ): Listings!
158 | chat(recipient: String!): Chat!
159 | }
160 |
161 | type Mutation {
162 | logIn(input: LogInInput): Viewer!
163 | logOut: Viewer!
164 | connectStripe(input: ConnectStripeInput!): Viewer!
165 | disconnectStripe: Viewer!
166 | hostListing(input: HostListingInput!): Listing!
167 | updateListing(id: ID!, input: UpdateListingInput!): UpdateListingResult!
168 | createBooking(input: CreateBookingInput!): Booking!
169 | createMessage(input: CreateMessageInput!): Message!
170 | createReview(input: CreateReviewInput!): Review!
171 | deleteReview(input: DeleteReviewInput!): Review!
172 | }
173 |
174 | type Subscription {
175 | listingBooked(hostId: ID!, isHost: Boolean!): Listing!
176 | sendMessage(to: ID!): Message!
177 | }
178 | `;
179 |
--------------------------------------------------------------------------------
/client/src/sections/Home/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link, useNavigate } from "react-router-dom";
3 | import { useQuery } from "@apollo/client";
4 | import { Col, Row, Layout, Typography } from "antd";
5 | import { LISTINGS } from "../../lib/graphql/queries";
6 | import {
7 | Listings as ListingsData,
8 | ListingsVariables
9 | } from "../../lib/graphql/queries/Listings/__generated__/Listings";
10 | import { ListingsFilter } from "../../lib/graphql/globalTypes";
11 | import { displayErrorMessage } from "../../lib/utils";
12 | import { HomeHero, HomeListings, HomeListingsSkeleton } from "./components";
13 |
14 | import mapBackground from "./assets/map-background.jpg";
15 | import sanFransiscoImage from "./assets/san-fransisco.jpg";
16 | import cancunImage from "./assets/cancun.jpg";
17 | import { useScrollToTop } from "../../lib/hooks/useScrollToTop";
18 |
19 | const { Content } = Layout;
20 | const { Paragraph, Title } = Typography;
21 |
22 | const PAGE_LIMIT = 4;
23 | const PAGE_NUMBER = 1;
24 |
25 | export const Home = () => {
26 | const { loading, data } = useQuery(LISTINGS, {
27 | variables: {
28 | filter: ListingsFilter.PRICE_HIGH_TO_LOW,
29 | limit: PAGE_LIMIT,
30 | page: PAGE_NUMBER
31 | },
32 | fetchPolicy: "cache-and-network"
33 | });
34 |
35 | const navigate = useNavigate();
36 |
37 | const onSearch = (value: string) => {
38 | const trimmedValue = value.trim();
39 |
40 | if (trimmedValue) {
41 | navigate(`/listings/${trimmedValue}`);
42 | } else {
43 | displayErrorMessage("Please enter a valid search!");
44 | }
45 | };
46 |
47 | useScrollToTop();
48 |
49 | const renderListingsSection = () => {
50 | if (loading) {
51 | return ;
52 | }
53 |
54 | if (data) {
55 | return ;
56 | }
57 |
58 | return null;
59 | };
60 |
61 | return (
62 |
66 |
67 |
68 |
69 |
70 | Your guide for all things rental
71 |
72 |
73 | Rent out your property, house and apartment and earn money.
74 | Book Top Rated Rentals for Your Next Trip. Helping you make
75 | the best decisions in renting and choosing your last minute
76 | locations.
77 |
78 |
82 | Popular listings in the United States
83 |
84 |
85 |
86 | {renderListingsSection()}
87 |
88 |
89 |
90 | Listings of any kind
91 |
92 |
93 |
94 |
95 |
96 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
122 | Explore all listings
123 |
124 |
125 |
126 |
127 | );
128 | };
129 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/Message/index.ts:
--------------------------------------------------------------------------------
1 | import { Request } from "express";
2 | import { IResolvers } from "apollo-server-express";
3 | import { Database, Message, User } from "../../../lib/types";
4 | import { authorize } from "../../../lib/utils";
5 | import { CreateMessageArgs, SendMessageArgs } from "./types";
6 | import { ObjectId } from "mongodb";
7 | import { PubSub } from "graphql-subscriptions";
8 | import cookieParser from "cookie-parser";
9 | import cookie from 'cookie'
10 |
11 | export const messageResolvers: IResolvers = {
12 | Mutation: {
13 | createMessage: async (
14 | _root: undefined,
15 | { input }: CreateMessageArgs,
16 | { db, req, pubSub }: { db: Database; req: Request; pubSub: PubSub }
17 | ): Promise => {
18 | let viewer = await authorize(db, req);
19 | if (!viewer) {
20 | throw new Error("viewer cannot be found");
21 | }
22 |
23 | // add message to database
24 | const insertResult = await db.messages.insertOne({
25 | _id: new ObjectId(),
26 | content: input.content,
27 | author: viewer._id,
28 | createdAt: new Date().toISOString(),
29 | });
30 |
31 | const insertedMessage: Message = insertResult.ops[0];
32 |
33 | // find the chat and add the message to the list of messages in that chat
34 | let chat = await db.chat.findOne({
35 | participants: {
36 | $all: [viewer._id, input.to],
37 | },
38 | });
39 |
40 | if (!chat) {
41 | throw new Error("Chat doesn't exist!");
42 | }
43 |
44 | await db.chat.updateOne(
45 | {
46 | _id: chat._id,
47 | },
48 | {
49 | $push: { messages: insertedMessage._id },
50 | }
51 | );
52 |
53 | pubSub.publish(`SEND_MESSAGE ${chat._id}`, {
54 | sendMessage: insertedMessage,
55 | });
56 |
57 | return insertedMessage;
58 | },
59 | },
60 |
61 | Subscription: {
62 | sendMessage: {
63 | async subscribe(
64 | _parent,
65 | { to }: SendMessageArgs,
66 | {
67 | db,
68 | req,
69 | pubSub,
70 | connection,
71 | }: {
72 | db: Database;
73 | req: Request;
74 | pubSub: PubSub;
75 | connection: any;
76 | },
77 | _info
78 | ) {
79 | const reqCookie = connection.context.req.headers.cookie;
80 | var cookies = cookie.parse(reqCookie);
81 |
82 | if (!cookies.viewer) {
83 | throw new Error("viewer cannot be found | unauthorized");
84 | }
85 |
86 | const viewerId = cookieParser.signedCookie(
87 | cookies.viewer,
88 | process.env.SECRET!
89 | );
90 |
91 | if (!viewerId) {
92 | throw new Error("viewer cannot be found | unauthorized");
93 | }
94 |
95 | // console.log("X-CSRF-TOKEN:", connection.context.csrfToken);
96 |
97 | const viewer = await db.users.findOne({
98 | _id: viewerId,
99 | // token,
100 | });
101 |
102 | if (!viewer) {
103 | throw new Error("viewer cannot be found | unauthorized");
104 | }
105 |
106 | let chat = await db.chat.findOne({
107 | participants: {
108 | $all: [viewer._id, to],
109 | },
110 | });
111 |
112 | if (chat) {
113 | return pubSub.asyncIterator(`SEND_MESSAGE ${chat._id}`);
114 | }
115 | },
116 | },
117 | },
118 |
119 | Message: {
120 | id: (message: Message): string => {
121 | return message._id.toString();
122 | },
123 |
124 | author: async (
125 | message: Message,
126 | _args: {},
127 | { db }: { db: Database }
128 | ): Promise => {
129 | const authorOfMessage = await db.users.findOne({
130 | _id: message.author,
131 | });
132 | if (!authorOfMessage) {
133 | throw new Error("user can't be found");
134 | }
135 | return authorOfMessage;
136 | },
137 | },
138 | };
139 |
--------------------------------------------------------------------------------
/client/src/lib/components/Map/SelectLocation.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useCallback, useState } from "react";
2 | import mapboxgl, { Map } from "mapbox-gl"; // eslint-disable-line import/no-webpack-loader-syntax
3 | import mbxGeocoding from "@mapbox/mapbox-sdk/services/geocoding";
4 | import MapboxGeocoder from "@mapbox/mapbox-gl-geocoder";
5 | // import "mapbox-gl/dist/mapbox-gl.css";
6 | import "@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css";
7 | import "./map.css";
8 |
9 | // The following is required to stop "npm build" from transpiling mapbox code.
10 | // notice the exclamation point in the import.
11 | // @ts-ignore
12 | // eslint-disable-next-line import/no-webpack-loader-syntax, import/no-unresolved
13 | // mapboxgl.workerClass = require("worker-loader!mapbox-gl/dist/mapbox-gl-csp-worker").default;
14 |
15 |
16 | interface Props {
17 | setFullAddress: (fullAddress: {
18 | address: string;
19 | city: string;
20 | state: string;
21 | postalCode: string;
22 | }) => void;
23 | }
24 |
25 | export const SelectLocation = ({setFullAddress}: Props) => {
26 | const [lng, setLng] = useState(-70.9);
27 | const [lat, setLat] = useState(42.35);
28 | const map = useRef(null);
29 | const mapContainerRef = useRef(null);
30 | mapboxgl.accessToken = process.env.REACT_APP_MAPBOX_API_KEY as string;
31 |
32 | const reverseGeoCode = useCallback(({ lat, lng }: {lat: number, lng: number}) => {
33 | const geocodingClient = mbxGeocoding({
34 | accessToken: mapboxgl.accessToken,
35 | });
36 |
37 | // geocoding with countries
38 | return geocodingClient
39 | .reverseGeocode({
40 | query: [lng, lat]
41 | })
42 | .send()
43 | .then((response) => {
44 | const data = response.body;
45 | console.log(data)
46 | let address = "";
47 | let city= "";
48 | let state = "";
49 | let postalCode = "";
50 |
51 | data.features.forEach((feature) => {
52 | if (feature.place_type[0] === "address") {
53 | address = feature.place_name
54 | }
55 |
56 | if (feature.place_type[0] === "region") {
57 | state = feature.text
58 | }
59 |
60 | if (feature.place_type[0] === "place") {
61 | city += ` ${feature.text}`;
62 | }
63 |
64 | if (feature.place_type[0] === "district") {
65 | city += ` ${feature.text}`;
66 | }
67 |
68 | if(feature.place_type[0] === "postcode") {
69 | postalCode = feature.text;
70 | }
71 | });
72 |
73 |
74 | setFullAddress({
75 | address,
76 | city,
77 | state,
78 | postalCode
79 | });
80 |
81 |
82 | });
83 | }, []);
84 |
85 | useEffect(() => {
86 | // if (map.current) return; // Checks if there's an already existing map initialised.
87 |
88 | map.current = new mapboxgl.Map({
89 | container: mapContainerRef.current as HTMLDivElement,
90 | style: "mapbox://styles/mapbox/streets-v11",
91 | center: [lng, lat],
92 | zoom: 8,
93 | });
94 |
95 | const marker = new mapboxgl.Marker({
96 | draggable: true,
97 | })
98 | .setLngLat([lng, lat])
99 | .addTo(map.current);
100 |
101 | const geocoder = new MapboxGeocoder({
102 | accessToken: mapboxgl.accessToken,
103 | mapboxgl: mapboxgl,
104 | });
105 |
106 | geocoder.on("result", function (e) {
107 | console.log(e.result.center);
108 | geocoder.clear();
109 | marker
110 | .setLngLat(e.result.center)
111 | });
112 |
113 | map.current.addControl(geocoder)
114 |
115 | function onDragEnd() {
116 | const lngLat = marker.getLngLat();
117 | setLat(lngLat.lat);
118 | setLng(lngLat.lng);
119 |
120 | reverseGeoCode({lng: lngLat.lng, lat: lngLat.lat});
121 | }
122 |
123 | marker.on("dragend", onDragEnd);
124 |
125 | if(!map.current) {
126 | return
127 | }
128 |
129 | // clean up on unmount
130 | return () => {
131 | if (map.current) {
132 | map.current.remove();
133 | }
134 | };
135 | }, [reverseGeoCode]);
136 |
137 |
138 | return (
139 |
142 | );
143 | };
144 |
--------------------------------------------------------------------------------
/client/src/sections/Login/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import { useNavigate } from "react-router-dom";
3 | import { useApolloClient, useMutation } from "@apollo/client";
4 | import { Card, Layout, Spin, Typography } from "antd";
5 | import { ErrorBanner } from "../../lib/components";
6 | import { LOG_IN } from "../../lib/graphql/mutations";
7 | import { AUTH_URL } from "../../lib/graphql/queries";
8 | import {
9 | LogIn as LogInData,
10 | LogInVariables,
11 | } from "../../lib/graphql/mutations/LogIn/__generated__/LogIn";
12 | import { AuthUrl as AuthUrlData } from "../../lib/graphql/queries/AuthUrl/__generated__/AuthUrl";
13 | import {
14 | displaySuccessNotification,
15 | displayErrorMessage,
16 | } from "../../lib/utils";
17 | import { Viewer } from "../../lib/types";
18 |
19 | // Image Assets
20 | import googleLogo from "./assets/google_logo.jpg";
21 | import { useScrollToTop } from "../../lib/hooks/useScrollToTop";
22 |
23 | interface Props {
24 | setViewer: (viewer: Viewer) => void;
25 | viewer: Viewer
26 | }
27 |
28 | const { Content } = Layout;
29 | const { Text, Title } = Typography;
30 |
31 | export const Login = ({ setViewer, viewer }: Props) => {
32 |
33 | const client = useApolloClient();
34 | let navigate = useNavigate();
35 |
36 |
37 | const [
38 | logIn,
39 | { data: logInData, loading: logInLoading, error: logInError },
40 | ] = useMutation(LOG_IN, {
41 | onCompleted: (data) => {
42 | if (data && data.logIn && data.logIn.token) {
43 | setViewer(data.logIn);
44 | sessionStorage.setItem("token", data.logIn.token);
45 | displaySuccessNotification("You've successfully logged in!");
46 |
47 | navigate(`/user/${data.logIn.id}`);
48 | }
49 | },
50 | });
51 | const logInRef = useRef(logIn);
52 |
53 | const handleAuthorize = async () => {
54 | try {
55 | const { data } = await client.query({
56 | query: AUTH_URL,
57 | });
58 | window.location.href = data.authUrl;
59 | } catch {
60 | displayErrorMessage(
61 | "Sorry! We weren't able to log you in. Please try again later!"
62 | );
63 | }
64 | };
65 |
66 | useScrollToTop();
67 |
68 | useEffect(() => {
69 |
70 | if(viewer.id) {
71 | navigate(`/user/${viewer.id}`);
72 | return
73 | }
74 |
75 | const code = new URL(window.location.href).searchParams.get("code");
76 | if (code) {
77 | logInRef.current({
78 | variables: {
79 | input: { code },
80 | },
81 | });
82 | }
83 | }, [navigate, viewer.id]);
84 |
85 | if(viewer.id) {
86 | return <>>
87 | }
88 |
89 | if (logInLoading) {
90 | return (
91 |
92 |
93 |
94 | );
95 | }
96 |
97 | if (logInData && logInData.logIn) {
98 | return <>>;
99 | }
100 |
101 | const logInErrorBannerElement = logInError ? (
102 |
103 | ) : null;
104 |
105 | return (
106 |
107 | {logInErrorBannerElement}
108 |
109 |
110 |
111 |
112 | 👋
113 |
114 |
115 |
116 | Log in to PaperHouses!
117 |
118 |
119 | Sign in with Google to start booking available rentals!
120 |
121 |
122 |
126 |
131 |
132 | Sign in with Google
133 |
134 |
135 |
136 | Note: By signing in, you'll be redirected to the Google
137 | consent form to sign in with your Google account.
138 |
139 |
140 |
141 | );
142 | };
143 |
--------------------------------------------------------------------------------
/client/src/sections/Listing/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useParams } from "react-router-dom";
3 | import { useQuery } from "@apollo/client";
4 | import { Col, Layout, Row } from "antd";
5 | import { Moment } from "moment";
6 | import { ErrorBanner, PageSkeleton } from "../../lib/components";
7 | import { LISTING } from "../../lib/graphql/queries";
8 | import {
9 | Listing as ListingData,
10 | ListingVariables,
11 | } from "../../lib/graphql/queries/Listing/__generated__/Listing";
12 | import { Viewer } from "../../lib/types";
13 | import {
14 | ListingBookings,
15 | ListingDetails,
16 | UpdateListing,
17 | CreateBookingModal,
18 | ListingCreateBooking,
19 | CreateReview
20 | } from "./components";
21 | import { useScrollToTop } from "../../lib/hooks/useScrollToTop";
22 |
23 | type Params = Record<"id", string>;
24 |
25 | interface Props {
26 | viewer: Viewer;
27 | }
28 |
29 | const { Content } = Layout;
30 | const PAGE_LIMIT = 3;
31 |
32 | export const Listing = ({ viewer }: Props) => {
33 | const [bookingsPage, setBookingsPage] = useState(1);
34 | const [checkInDate, setCheckInDate] = useState(null);
35 | const [checkOutDate, setCheckOutDate] = useState(null);
36 | const [modalVisible, setModalVisible] = useState(false);
37 |
38 | const { id } = useParams() as Params;
39 |
40 | const { loading, data, error, refetch } = useQuery<
41 | ListingData,
42 | ListingVariables
43 | >(LISTING, {
44 | variables: {
45 | id,
46 | bookingsPage,
47 | limit: PAGE_LIMIT,
48 | },
49 | });
50 |
51 | const handleListingRefetch = async () => {
52 | await refetch();
53 | };
54 |
55 | const clearBookingData = () => {
56 | setModalVisible(false);
57 | setCheckInDate(null);
58 | setCheckOutDate(null);
59 | };
60 |
61 | useScrollToTop();
62 |
63 | if (loading) {
64 | return (
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | if (error) {
72 | return (
73 |
74 |
75 |
76 |
77 | );
78 | }
79 |
80 | const listing = data ? data.listing : null;
81 | const listingBookings = listing ? listing.bookings : null;
82 |
83 | const listingDetailsElement = listing ? (
84 |
85 | ) : null;
86 |
87 | const listingBookingsElement = listingBookings ? (
88 |
94 | ) : null;
95 |
96 | const listingCreateBookingElement = listing ? (
97 |
108 | ) : null;
109 |
110 | const createBookingModalElement =
111 | listing && checkInDate && checkOutDate ? (
112 |
122 | ) : null;
123 |
124 | const listingReviewsElement = listing && (
125 |
131 | );
132 |
133 | return (
134 |
135 |
136 |
137 | {listingDetailsElement}
138 | {viewer.id === listing?.host.id && (
139 |
143 | )}
144 | {listingBookingsElement}
145 | {listingReviewsElement}
146 |
147 |
148 | {listingCreateBookingElement}
149 |
150 |
151 | {createBookingModalElement}
152 |
153 | );
154 | };
155 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers/Review/index.ts:
--------------------------------------------------------------------------------
1 | import { IResolvers } from "apollo-server-express";
2 | import { Request } from "express";
3 | import { ObjectId } from "mongodb";
4 |
5 | import { Database, Review, User } from "../../../lib/types";
6 | import { authorize } from "../../../lib/utils";
7 | import { CreateReviewArgs, DeleteReviewArgs } from "./types";
8 |
9 | export const reviewResolvers: IResolvers = {
10 | Mutation: {
11 | createReview: async (
12 | _root: undefined,
13 | { input }: CreateReviewArgs,
14 | { db, req }: { db: Database; req: Request }
15 | ): Promise => {
16 | try {
17 | const viewer = await authorize(db, req);
18 |
19 | if (!viewer) {
20 | throw new Error("viewer cannot be found | unauthorized");
21 | }
22 |
23 | let listing = await db.listings.findOne({
24 | _id: new ObjectId(input.listingId),
25 | });
26 |
27 | if (!listing) {
28 | throw new Error("Listing not found!");
29 | }
30 |
31 | const alreadyReviewed = listing.reviews.some(
32 | (review) =>
33 | review.author.toString() === viewer._id.toString()
34 | );
35 |
36 | if (alreadyReviewed) {
37 | throw new Error("You have already reviewed this listing!");
38 | }
39 |
40 | if (input.rating < 0 || input.rating > 5) {
41 | throw new Error("Provide a rating between 0 and 5!");
42 | }
43 |
44 | const review: Review = {
45 | _id: new ObjectId(),
46 | createdAt: new Date().toISOString(),
47 | rating: input.rating,
48 | comment: input.comment,
49 | author: viewer._id,
50 | };
51 |
52 | const updatedTotalReviews = [...listing.reviews, review];
53 |
54 | const numReviews = updatedTotalReviews.length;
55 |
56 | const avgRating =
57 | updatedTotalReviews.reduce(
58 | (acc, next) => acc + next.rating,
59 | 0
60 | ) / numReviews;
61 |
62 | await db.listings.updateOne(
63 | {
64 | _id: listing._id,
65 | },
66 | {
67 | $set: {
68 | numReviews: numReviews,
69 | rating: avgRating,
70 | },
71 | $push: { reviews: review },
72 | }
73 | );
74 |
75 | return review;
76 | } catch (error) {
77 | throw new Error(`${error}`);
78 | }
79 | },
80 |
81 | deleteReview: async (
82 | _root: undefined,
83 | { input }: DeleteReviewArgs,
84 | { db, req }: { db: Database; req: Request }
85 | ): Promise => {
86 | try {
87 | const viewer = await authorize(db, req);
88 |
89 | if (!viewer) {
90 | throw new Error("viewer cannot be found | unauthorized");
91 | }
92 |
93 | let listing = await db.listings.findOne({
94 | _id: new ObjectId(input.listingId),
95 | });
96 |
97 | if (!listing) {
98 | throw new Error("Listing not found!");
99 | }
100 |
101 | const reviewToDelete = listing.reviews.find((review) => {
102 | return review._id.toString() === input.reviewId
103 | })
104 |
105 | if(!reviewToDelete) {
106 | throw new Error("Review not found!");
107 | }
108 |
109 | const isAuthorizedToDelete = reviewToDelete.author === viewer._id;
110 |
111 | if (!isAuthorizedToDelete) {
112 | throw new Error("Unauthorized!");
113 | }
114 |
115 | // reviews after deleting the requested review
116 | const updatedTotalReviews = listing.reviews.filter((review) => {
117 | return review._id.toString() !== input.reviewId
118 | });
119 |
120 | const numReviews = updatedTotalReviews.length;
121 |
122 | let avgRating;
123 |
124 | if(numReviews === 0) {
125 | avgRating = 0;
126 | } else {
127 | avgRating = updatedTotalReviews.reduce(
128 | (acc, next) => acc + next.rating,
129 | 0
130 | ) / numReviews;
131 | }
132 |
133 | await db.listings.updateOne(
134 | {
135 | _id: listing._id,
136 | },
137 | {
138 | $set: {
139 | numReviews: numReviews,
140 | rating: avgRating,
141 | reviews: updatedTotalReviews
142 | },
143 | }
144 | );
145 |
146 | return reviewToDelete;
147 | } catch (error) {
148 | throw new Error(`${error}`);
149 | }
150 | },
151 |
152 | },
153 |
154 | Review: {
155 | id: (review: Review): string => {
156 | return review._id.toString();
157 | },
158 |
159 | author: async (
160 | review: Review,
161 | _args: {},
162 | { db }: { db: Database }
163 | ): Promise => {
164 | const author = await db.users.findOne({ _id: review.author });
165 | if (!author) {
166 | throw new Error("User can't be found");
167 | }
168 | return author;
169 | },
170 | },
171 | };
172 |
--------------------------------------------------------------------------------
/client/src/sections/User/components/UserProfile/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Avatar, Button, Card, Divider, Typography, Tag } from "antd";
3 | import { useMutation } from "@apollo/client";
4 | import { User as UserData } from "../../../../lib/graphql/queries/User/__generated__/User";
5 | import { DISCONNECT_STRIPE } from "../../../../lib/graphql/mutations";
6 | import { DisconnectStripe as DisconnectStripeData } from "../../../../lib/graphql/mutations/DisconnectStripe/__generated__/DisconnectStripe";
7 | import {
8 | displayErrorMessage,
9 | displaySuccessNotification,
10 | formatListingPrice,
11 | iconColor,
12 | stripeAuthUrl,
13 | } from "../../../../lib/utils";
14 | import { Viewer } from "../../../../lib/types";
15 | import { UserChats } from "../UserChats";
16 | import { Link } from "react-router-dom";
17 |
18 | interface Props {
19 | user: UserData["user"];
20 | viewerIsUser: boolean;
21 | viewer: Viewer;
22 | setViewer: (viewer: Viewer) => void;
23 | handleUserRefetch: () => void;
24 | }
25 |
26 | const { Paragraph, Text, Title } = Typography;
27 |
28 | export const UserProfile = ({
29 | user,
30 | viewerIsUser,
31 | viewer,
32 | setViewer,
33 | handleUserRefetch,
34 | }: Props) => {
35 | const [disconnectStripe, { loading }] = useMutation(
36 | DISCONNECT_STRIPE,
37 | {
38 | onCompleted: (data) => {
39 | if (data && data.disconnectStripe) {
40 | setViewer({
41 | ...viewer,
42 | hasWallet: !!data.disconnectStripe.hasWallet,
43 | });
44 | displaySuccessNotification(
45 | "You've successfully disconnected from Stripe!",
46 | "You'll have to reconnect with Stripe to continue to create listings."
47 | );
48 | handleUserRefetch();
49 | }
50 | },
51 | onError: (err) => {
52 | console.log(err);
53 | displayErrorMessage(
54 | "Sorry! We weren't able to disconnect you from Stripe. Please try again later!"
55 | );
56 | },
57 | }
58 | );
59 |
60 | const redirectToStripe = () => {
61 | window.location.href = stripeAuthUrl;
62 | };
63 |
64 | const additionalDetails = user.hasWallet ? (
65 | <>
66 |
67 | Stripe Registered
68 |
69 |
70 | Income Earned:{" "}
71 |
72 | {user.income ? formatListingPrice(user.income) : `$0`}
73 |
74 |
75 | disconnectStripe()}
80 | >
81 | Disconnect Stripe
82 |
83 |
84 | By disconnecting, you won't be able to receive{" "}
85 | any further payments . This will prevent
86 | users from booking listings that you might have already created.
87 |
88 | >
89 | ) : (
90 | <>
91 |
92 | Interested in becoming a PaperHouses host and rent out your
93 | property? Register with your Stripe account!
94 |
95 |
100 | Connect with Stripe
101 |
102 |
103 | PaperHouses uses{" "}
104 |
109 | Stripe
110 | {" "}
111 | to help transfer your earnings in a secure and truster manner.
112 |
113 | >
114 | );
115 |
116 | const additionalDetailsSection = viewerIsUser ? (
117 | <>
118 |
119 |
120 |
Additional Details
121 | {additionalDetails}
122 |
123 |
124 |
125 | >
126 | ) : null;
127 |
128 | return (
129 |
130 |
131 |
134 |
135 |
136 |
Details
137 |
138 | Name: {user.name}
139 |
140 |
141 | Contact: {user.contact}
142 |
143 |
144 |
145 |
153 |
154 |
161 |
Message
162 |
163 |
164 |
165 | {additionalDetailsSection}
166 |
167 |
168 | );
169 | };
170 |
--------------------------------------------------------------------------------
/client/src/sections/Listing/components/CreateBookingModal/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useMutation } from "@apollo/client";
3 | // import {
4 | // CardElement,
5 | // injectStripe,
6 | // ReactStripeElements,
7 | // } from "react-stripe-elements";
8 | import { loadStripe } from "@stripe/stripe-js";
9 | import { useStripe, useElements, CardElement, Elements } from "@stripe/react-stripe-js";
10 | import { Button, Divider, Modal, Typography } from "antd";
11 | import {
12 | KeyOutlined
13 | } from "@ant-design/icons";
14 | import moment, { Moment } from "moment";
15 | import { CREATE_BOOKING } from "../../../../lib/graphql/mutations";
16 | import {
17 | CreateBooking as CreateBookingData,
18 | CreateBookingVariables,
19 | } from "../../../../lib/graphql/mutations/CreateBooking/__generated__/CreateBooking";
20 | import {
21 | formatListingPrice,
22 | displaySuccessNotification,
23 | displayErrorMessage,
24 | } from "../../../../lib/utils";
25 |
26 | interface Props {
27 | listingId: string;
28 | price: number;
29 | modalVisible: boolean;
30 | checkInDate: Moment;
31 | checkOutDate: Moment;
32 | setModalVisible: (modalVisible: boolean) => void;
33 | clearBookingData: () => void;
34 | refetchListing: () => Promise;
35 | }
36 |
37 | const { Paragraph, Text, Title } = Typography;
38 |
39 | export const CreateBookingModal = ({
40 | listingId,
41 | price,
42 | modalVisible,
43 | checkInDate,
44 | checkOutDate,
45 | setModalVisible,
46 | clearBookingData,
47 | refetchListing,
48 | }: Props) => {
49 | const [createBooking, { loading }] = useMutation<
50 | CreateBookingData,
51 | CreateBookingVariables
52 | >(CREATE_BOOKING, {
53 | onCompleted: () => {
54 | clearBookingData();
55 | displaySuccessNotification(
56 | "You've successfully booked the listing!",
57 | "Booking history can always be found in your User page."
58 | );
59 | refetchListing();
60 | },
61 | onError: () => {
62 | displayErrorMessage(
63 | "Sorry! We weren't able to successfully book the listing. Please try again later!"
64 | );
65 | },
66 | });
67 |
68 | const daysBooked = checkOutDate.diff(checkInDate, "days") + 1;
69 | const listingPrice = price * daysBooked;
70 |
71 | const stripe = useStripe();
72 | const elements = useElements();
73 |
74 | const handleCreateBooking = async () => {
75 | if (!stripe || !elements) {
76 | return displayErrorMessage(
77 | "Sorry! We weren't able to connect with Stripe."
78 | );
79 | }
80 |
81 | const cardElement = elements.getElement(CardElement);
82 |
83 | if(!cardElement) {
84 | return displayErrorMessage(
85 | "Sorry! We weren't able to connect with Stripe."
86 | );
87 | }
88 |
89 | let { token: stripeToken, error } = await stripe.createToken(cardElement);
90 | if (stripeToken) {
91 | createBooking({
92 | variables: {
93 | input: {
94 | id: listingId,
95 | source: stripeToken.id,
96 | checkIn: moment(checkInDate).format("YYYY-MM-DD"),
97 | checkOut: moment(checkOutDate).format("YYYY-MM-DD"),
98 | },
99 | },
100 | });
101 | } else {
102 | displayErrorMessage(
103 | error && error.message
104 | ? error.message
105 | : "Sorry! We weren't able to book the listing. Please try again later."
106 | );
107 | }
108 | };
109 |
110 |
111 | return (
112 | setModalVisible(false)}
117 | >
118 |
119 |
120 |
121 |
122 |
123 |
127 | Book your trip
128 |
129 |
130 | Enter your payment information to book the listing from
131 | the dates between{" "}
132 |
133 | {moment(checkInDate).format("MMMM Do YYYY")}
134 | {" "}
135 | and{" "}
136 |
137 | {moment(checkOutDate).format("MMMM Do YYYY")}
138 |
139 | , inclusive.
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 | {formatListingPrice(price, false)} * {daysBooked} days ={" "}
148 |
149 | {formatListingPrice(listingPrice, false)}
150 |
151 |
152 |
153 | Total ={" "}
154 |
155 | {formatListingPrice(listingPrice, false)}
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
171 | Book
172 |
173 |
174 |
175 |
176 | );
177 | };
--------------------------------------------------------------------------------
/client/src/sections/Listing/components/ListingCreateBooking/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Button, Card, DatePicker, Divider, Tooltip, Typography } from "antd";
3 | import moment, { Moment } from "moment";
4 | import { Listing as ListingData } from "../../../../lib/graphql/queries/Listing/__generated__/Listing";
5 | import { displayErrorMessage, formatListingPrice } from "../../../../lib/utils";
6 | import { Viewer } from "../../../../lib/types";
7 | import { BookingsIndex } from "./types";
8 |
9 | const { Paragraph, Text, Title } = Typography;
10 |
11 | interface Props {
12 | viewer: Viewer;
13 | host: ListingData["listing"]["host"];
14 | price: number;
15 | bookingsIndex: ListingData["listing"]["bookingsIndex"];
16 | checkInDate: Moment | null;
17 | checkOutDate: Moment | null;
18 | setCheckInDate: (checkInDate: Moment | null) => void;
19 | setCheckOutDate: (checkOutDate: Moment | null) => void;
20 | setModalVisible: (modalVisible: boolean) => void;
21 | }
22 |
23 | export const ListingCreateBooking = ({
24 | viewer,
25 | host,
26 | price,
27 | bookingsIndex,
28 | checkInDate,
29 | checkOutDate,
30 | setCheckInDate,
31 | setCheckOutDate,
32 | setModalVisible
33 | }: Props) => {
34 | const bookingsIndexJSON: BookingsIndex = JSON.parse(bookingsIndex);
35 |
36 | const dateIsBooked = (currentDate: Moment) => {
37 | const year = moment(currentDate).year();
38 | const month = moment(currentDate).month();
39 | const day = moment(currentDate).date();
40 |
41 | if (bookingsIndexJSON[year] && bookingsIndexJSON[year][month]) {
42 | return Boolean(bookingsIndexJSON[year][month][day]);
43 | } else {
44 | return false;
45 | }
46 | };
47 |
48 | const disabledDate = (currentDate?: Moment) => {
49 | if (currentDate) {
50 | const dateIsBeforeEndOfDay = currentDate.isBefore(moment().endOf("day"));
51 | const dateIsMoreThanThreeMonthsAhead = moment(currentDate).isAfter(
52 | moment()
53 | .endOf("day")
54 | .add(90, "days")
55 | );
56 |
57 | return (
58 | dateIsBeforeEndOfDay ||
59 | dateIsMoreThanThreeMonthsAhead ||
60 | dateIsBooked(currentDate)
61 | );
62 | } else {
63 | return false;
64 | }
65 | };
66 |
67 | const verifyAndSetCheckOutDate = (selectedCheckOutDate: Moment | null) => {
68 | if (checkInDate && selectedCheckOutDate) {
69 | if (moment(selectedCheckOutDate).isBefore(checkInDate, "days")) {
70 | return displayErrorMessage(
71 | `You can't book date of check out to be prior to check in!`
72 | );
73 | }
74 |
75 | let dateCursor = checkInDate;
76 |
77 | while (moment(dateCursor).isBefore(selectedCheckOutDate, "days")) {
78 | dateCursor = moment(dateCursor).add(1, "days");
79 |
80 | const year = moment(dateCursor).year();
81 | const month = moment(dateCursor).month();
82 | const day = moment(dateCursor).date();
83 |
84 | if (
85 | bookingsIndexJSON[year] &&
86 | bookingsIndexJSON[year][month] &&
87 | bookingsIndexJSON[year][month][day]
88 | ) {
89 | return displayErrorMessage(
90 | "You can't book a period of time that overlaps existing bookings. Please try again!"
91 | );
92 | }
93 | }
94 | }
95 |
96 | setCheckOutDate(selectedCheckOutDate);
97 | };
98 |
99 | const viewerIsHost = viewer.id === host.id;
100 | const checkInInputDisabled = !viewer.id || viewerIsHost || !host.hasWallet;
101 | const checkOutInputDisabled = checkInInputDisabled || !checkInDate;
102 | const buttonDisabled = checkOutInputDisabled || !checkInDate || !checkOutDate;
103 |
104 | let buttonMessage = "You won't be charged yet";
105 | if (!viewer.id) {
106 | buttonMessage = "You have to be signed in to book a listing!";
107 | } else if (viewerIsHost) {
108 | buttonMessage = "You can't book your own listing!";
109 | } else if (!host.hasWallet) {
110 | buttonMessage =
111 | "The host has disconnected from Stripe and thus won't be able to receive payments!";
112 | }
113 |
114 | return (
115 |
116 |
117 |
118 |
119 |
120 | {formatListingPrice(price)}
121 | /day
122 |
123 |
124 |
125 |
126 |
Check In
127 |
setCheckInDate(dateValue)}
134 | onOpenChange={() => setCheckOutDate(null)}
135 | renderExtraFooter={() => {
136 | return (
137 |
138 |
139 | You can only book a listing within 90 days from today.
140 |
141 |
142 | );
143 | }}
144 | />
145 |
146 |
147 |
Check Out
148 |
verifyAndSetCheckOutDate(dateValue)}
155 | dateRender={current => {
156 | if (
157 | moment(current).isSame(checkInDate ? checkInDate : undefined, "day")
158 | ) {
159 | return (
160 |
161 |
162 | {current.date()}
163 |
164 |
165 | );
166 | } else {
167 | return {current.date()}
;
168 | }
169 | }}
170 | renderExtraFooter={() => {
171 | return (
172 |
173 |
174 | Check-out cannot be before check-in.
175 |
176 |
177 | );
178 | }}
179 | />
180 |
181 |
182 |
183 | setModalVisible(true)}
189 | >
190 | Request to book!
191 |
192 |
193 | {buttonMessage}
194 |
195 |
196 |
197 | );
198 | };
199 |
--------------------------------------------------------------------------------