├── 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 | App logo 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 | 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 | 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 |
53 |
54 |
55 | 56 | App logo 57 | 58 |
59 | 60 |
61 | setSearch(evt.target.value)} 66 | onSearch={onSearch} 67 | /> 68 |
69 |
70 |
71 | 72 |
73 |
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 | <span>/day</span> 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 | 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 |
79 | 80 | 81 | 82 |
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 | <Tag color={iconColor}>Message</Tag> {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 |
106 |
107 |
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 | 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 | Los Angeles 101 |
102 | 103 | 104 | 105 | 106 |
107 | Cancún 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 |
140 |
141 |
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 | <span role="img" aria-label="wave"> 112 | 👋 113 | </span> 114 | 115 | 116 | Log in to PaperHouses! 117 | 118 | 119 | Sign in with Google to start booking available rentals! 120 | 121 |
122 | 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 | 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 | 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 |
132 | 133 |
134 | 135 |
136 | Details 137 | 138 | Name: {user.name} 139 | 140 | 141 | Contact: {user.contact} 142 | 143 |
144 | 145 |
153 | 154 | 161 | 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 | <KeyOutlined /> 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 | 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 | <span>/day</span> 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 | 192 | 193 | {buttonMessage} 194 | 195 |
196 |
197 | ); 198 | }; 199 | --------------------------------------------------------------------------------