TinyHouse: A Fullstack React Masterclass with TypeScript and GraphQL - Part Two - PT 11

DISCONNECTING FROM STRIPE ON THE CLIENT

We’ve managed to create the functionality to have a logged-in user in our app connect with Stripe. In this lesson, we’ll look to visually indicate that the user is in the connected state and have an action responsible for allowing the user to disconnect from Stripe.

USER CONNECTED WITH STRIPE

At this moment, when we query for a user object in the /user/:id page, we’re already querying for the hasWallet field within the User graphQL object. The hasWallet field indicates the presence of a value in the walletId of the user in our database. If the hasWallet field is truthy, it means we have the stripe_user_id of the user.

If we take a look at the <UserProfile /> component we’ve prepared, we’ve specified that the additional details section is only shown when a user is logged in. However, the information that encompasses the button to connect with the Stripe should only be shown when the user hasn’t yet connected with Stripe.

We’ll take the markup that encompasses the button and description to allow a user to connect with Stripe and place it within a constant element we’ll call additionalDetails . We’ll say if the hasWallet field in the user object available as props is not true, the additionalDetails constant will contain the "Connect with Stripe" related markup we’ve seen before.

We’ll also place the additionalDetails constant element within the additionalDetailsSection constant element.

// ...

export const UserProfile = ({ user, viewerIsUser }: Props) => {
  const additionalDetails = user.hasWallet ? (
    <Fragment></Fragment>
  ) : (
    <Fragment>
      <Paragraph>
        Interested in becoming a TinyHouse host? Register with your Stripe account!
      </Paragraph>
      <Button
        type="primary"
        className="user-profile__details-cta"
        onClick={redirectToStripe}
      >
        Connect with Stripe
      </Button>
      <Paragraph type="secondary">
        TinyHouse uses{" "}
        <a
          href="https://stripe.com/en-US/connect"
          target="_blank"
          rel="noopener noreferrer"
        >
          Stripe
        </a>{" "}
        to help transfer your earnings in a secure and truster manner.
      </Paragraph>
    </Fragment>
  );

  const additionalDetailsSection = viewerIsUser ? (
    <Fragment>
      <Divider />
      <div className="user-profile__details">
        <Title level={4}>Additional Details</Title>
        {additionalDetails}
      </div>
    </Fragment>
  ) : null;

  // ...
};

We’ll now look to prepare the markup in the additionalDetails constant for when the user is connected with Stripe (i.e. user.hasWallet is true ). We’ll first import one other component from Ant Design that we’ll use - the <Tag /> component.

client/src/sections/User/components/UserProfile/index.tsx

import { Avatar, Button, Card, Divider, Tag, Typography } from "antd";

We’ll also import the utility function formatListingPrice() that is used to format price number values in our client app. We’ll import the formatListingPrice() function from the src/lib/utils/ folder.

import { formatListingPrice } from "../../../../lib/utils";

In the additionalDetails constant element within the <UserProfile /> component, when the user hasWallet field is true , the markup we’ll prepare will have:

  • A green <Tag /> that says "Stripe Registered" .
  • A <Paragraph /> to display the income of the user with which we’ll be able to access from the user.income field. If the income value within user exists, we’ll use the formatListingPrice() function to format the income. Otherwise we’ll simply show $0 .
  • A <Button /> to allow the user to disconnect from Stripe.
  • A secondary <Paragraph /> that will tell the user that if they were to disconnect from Stripe, this will prevent other users from booking listings that they might have already created.
// ...

export const UserProfile = ({ user, viewerIsUser }: Props) => {
  const additionalDetails = user.hasWallet ? (
    <Fragment>
      <Paragraph>
        <Tag color="green">Stripe Registered</Tag>
      </Paragraph>
      <Paragraph>
        Income Earned:{" "}
        <Text strong>{user.income ? formatListingPrice(user.income) : `$0`}</Text>
      </Paragraph>
      <Button type="primary" className="user-profile__details-cta">
        Disconnect Stripe
      </Button>
      <Paragraph type="secondary">
        By disconnecting, you won't be able to receive{" "}
        <Text strong>any further payments</Text>. This will prevent users from booking
        listings that you might have already created.
      </Paragraph>
    </Fragment>
  ) : (
    <Fragment>
      <Paragraph>
        Interested in becoming a TinyHouse host? Register with your Stripe account!
      </Paragraph>
      <Button
        type="primary"
        className="user-profile__details-cta"
        onClick={redirectToStripe}
      >
        Connect with Stripe
      </Button>
      <Paragraph type="secondary">
        TinyHouse uses{" "}
        <a
          href="https://stripe.com/en-US/connect"
          target="_blank"
          rel="noopener noreferrer"
        >
          Stripe
        </a>{" "}
        to help transfer your earnings in a secure and truster manner.
      </Paragraph>
    </Fragment>
  );

  const additionalDetailsSection = viewerIsUser ? (
    <Fragment>
      <Divider />
      <div className="user-profile__details">
        <Title level={4}>Additional Details</Title>
        {additionalDetails}
      </div>
    </Fragment>
  ) : null;

  // ...
};

If we take a look at our user page now and if we’re connected with Stripe, we’ll see the new section be shown in the user profile section.

DISCONNECTSTRIPE

We’ll now focus on providing the capability for a user to disconnect from Stripe when connected. We have the disconnectStripe mutation set up on the server so we’ll first create the mutation document in our client.

We’ll head to our src/lib/graphql/mutations/ folder and create a DisconnectStripe/ folder that is to have an index.ts file.

client/
  src/
    lib/
      graphql/
        mutations/
          // ...
          DisconnectStripe/
            index.ts
          // ...
        // ...
    // ...

The disconnectStripe mutation takes no arguments and we’ll want the hasWallet field from the viewer object to be returned.

import { gql } from "apollo-boost";

export const DISCONNECT_STRIPE = gql`
  mutation DisconnectStripe {
    disconnectStripe {
      hasWallet
    }
  }
`;

In the src/lib/graphql/mutations/index.ts file, we’ll re-export the DISCONNECT_STRIPE mutation document.

client/src/lib/graphql/mutations/index.ts

export * from "./DisconnectStripe";

We’ll run the codegen:generate command to autogenerate the TypeScript definitions.

npm run codegen:generate

We’ll have the disconnectStripe mutation be used in the <UserProfile /> component. In the <UserProfile/> component file, we’ll import the useMutation Hook, the DISCONNECT_STRIPE mutation document, and the relevant TypeScript definitions for the disconnectStripe mutation. There are no variables for this mutation so we’ll only import the data interface.

import { useMutation } from "@apollo/react-hooks";
// ...
import { DISCONNECT_STRIPE } from "../../../../lib/graphql/mutations/";
import { DisconnectStripe as DisconnectStripeData } from
"../../../../lib/graphql/mutations/DisconnectStripe/__generated__/DisconnectStripe";
import { User as UserData } from "../../../../lib/graphql/queries/User/__generated__/User";
// ...

At the beginning of our <UserProfile /> component function, we’ll use the useMutation Hook, pass along the appropriate type definitions, and destruct the disconnectStripe mutation function. The only result field we’ll use will be the loading state of our mutation.

// ...

export const UserProfile = ({ user, viewerIsUser }: Props) => {
  const [disconnectStripe, { loading }] = useMutation<DisconnectStripeData>(
    DISCONNECT_STRIPE
  );

  // ...
};

To handle the success and error states of the mutation, we’ll look to either display a success notification or an error message. Since we’re not going to render anything for these conditions, we’ll simply handle these conditions with the onCompleted() and onError() callback functions as part of the mutation results.

We’ll import the displaySuccessNotification and the displayErrorMessage functions from the src/lib/utils folder.

client/src/sections/User/components/UserProfile/index.tsx

import {
  formatListingPrice,
  displaySuccessNotification,
  displayErrorMessage
} from "../../../../lib/utils";

In the onCompleted() callback function of our disconnectStripe mutation, we’ll check for the data object and if the disconnectStripe object within data exists. If data and data.disconnectStripe is present, we’ll simply run the displaySuccessNotification() function with a message that says "You've successfully disconnected from Stripe!" and a description that says "You'll have to reconnect with Stripe to continue to create listings" .

// ...

export const UserProfile = ({ user, viewerIsUser }: Props) => {
  const [disconnectStripe, { loading }] = useMutation<DisconnectStripeData>(
    DISCONNECT_STRIPE,
    {
      onCompleted: data => {
        if (data && data.disconnectStripe) {
          displaySuccessNotification(
            "You've successfully disconnected from Stripe!",
            "You'll have to reconnect with Stripe to continue to create listings."
          );
        }
      }
    }
  );

  // ...
};

In the onError() callback of the mutation, we’ll use the displayErrorMessage() utility function to display an error message that says "Sorry! We weren't able to disconnect you from Stripe. Please try again later!" .

// ...

export const UserProfile = ({ user, viewerIsUser }: Props) => {
  const [disconnectStripe, { loading }] = useMutation<DisconnectStripeData>(
    DISCONNECT_STRIPE,
    {
      onCompleted: data => {
        if (data && data.disconnectStripe) {
          displaySuccessNotification(
            "You've successfully disconnected from Stripe!",
            "You'll have to reconnect with Stripe to continue to create listings."
          );
        }
      },
      onError: () => {
        displayErrorMessage(
          "Sorry! We weren't able to disconnect you from Stripe. Please try again later!"
        );
      }
    }
  );

  // ...
};

When the disconnectStripe mutation is successful, we’ll want to ensure the viewer state object available in our client is updated. With that said, we’ll state that the <UserProfile /> component is to expect the viewer object and setViewer() function as props. The type of the viewer prop object will be the Viewer interface we’ll import from the src/lib/types.ts file.

import { Viewer } from "../../../../lib/types";

interface Props {
  user: UserData["user"];
  viewer: Viewer;
  viewerIsUser: boolean;
  setViewer: (viewer: Viewer) => void;
}

In our mutation onCompleted callback, we’ll use the setViewer() function and the viewer object to update the hasWallet property of the viewer object in our client.

// ...

export const UserProfile = ({ user, viewer, viewerIsUser, setViewer }: Props) => {
  const [disconnectStripe, { loading }] = useMutation<DisconnectStripeData>(
    DISCONNECT_STRIPE,
    {
      onCompleted: data => {
        if (data && data.disconnectStripe) {
          setViewer({ ...viewer, hasWallet: data.disconnectStripe.hasWallet });
          displaySuccessNotification(
            "You've successfully disconnected from Stripe!",
            "You'll have to reconnect with Stripe to continue to create listings."
          );
        }
      },
      onError: () => {
        displayErrorMessage(
          "Sorry! We weren't able to disconnect you from Stripe. Please try again later!"
        );
      }
    }
  );

  // ...
};

Let’s make sure the viewer and setViewer props are to be passed down to this <UserProfile /> component. In the uppermost parent <App /> component, we’ll pass down viewer and setViewer as props to the <User /> component.

client/src/index.tsx

          <Route
            exact
            path="/user/:id"
            render={props => <User {...props} viewer={viewer} setViewer={setViewer} />}
          />

In the <User /> component page, we’ll declare the setViewer prop and we’ll pass the viewer and setViewer props further down to the <UserProfile/> component.

interface Props {
  viewer: Viewer;
  setViewer: (viewer: Viewer) => void;
}

export const User = ({
  viewer,
  setViewer,
  match
}: Props & RouteComponentProps<MatchParams>) => {
  // ...

  const userProfileElement = user ? (
    <UserProfile
      user={user}
      viewer={viewer}
      viewerIsUser={viewerIsUser}
      setViewer={setViewer}
    />
  ) : null;

  // ...
};

In the <UserProfile /> component, we’ll look to use the disconnectStripe() mutation function and the loading state from our mutation result. We’ll place the loading status of the mutation as the value of the loading prop in our "Disconnect Stripe" button. The onClick handler of the "Disconnect Stripe" button will trigger a callback that calls the disconnectStripe() mutation function.

// ...

export const UserProfile = ({ user, viewerIsUser }: Props) => {
  const additionalDetails = user.hasWallet ? (
    <Fragment>
      {/* ... */}
      <Button
        type="primary"
        className="user-profile__details-cta"
        loading={loading}
        onClick={() => disconnectStripe()}
      >
        Disconnect Stripe
      </Button>
      {/* ... */}
    </Fragment>
  ) : (
    <Fragment>{/* ... */}</Fragment>
  );

  // ...
};

REFETCH USER DATA AFTER DISCONNECTING FROM STRIPE

There’s one last thing we’ll look to handle. When the disconnectStripe mutation is successful, we’ll want the UI of the user profile section to update and show the markup that’s associated with a user that’s not connected with Stripe any longer. Since the markup in the user profile section is dependant on the hasWallet field of the user object - we’ll need to refetch the user query in the parent <User /> component to ensure the information in the user profile section is up to date.

In the parent <User /> component, we’ll destruct the refetch property from the useQuery Hook.

client/src/sections/User/index.tsx

  const { data, loading, error, refetch } = useQuery<UserData, UserVariables>(USER, {
    variables: {
      id: match.params.id,
      bookingsPage,
      listingsPage,
      limit: PAGE_LIMIT
    }
  });

We’ll then set-up an async function in the <User /> component called handleUserRefetch() that will simply trigger the refetch function.

client/src/sections/User/index.tsx

  const handleUserRefetch = async () => {
    await refetch();
  };

We’ll then ensure the handleUserRefetch() function is passed down as a prop to the <UserProfile /> component.

client/src/sections/User/index.tsx

  const userProfileElement = user ? (
    <UserProfile
      user={user}
      viewer={viewer}
      viewerIsUser={viewerIsUser}
      setViewer={setViewer}
      handleUserRefetch={handleUserRefetch}
    />
  ) : null;

In our <UserProfile /> component, we’ll state that the handleUserRefetch function is expected as a prop and in the onCompleted callback of the disconnectStripe mutation, we’ll trigger the handleUserRefetch() function. With this change and all the changes we’ve made to the <UserProfile /> component, the src/sections/User/UserProfile/index.tsx file will look like the following:

client/src/sections/User/components/UserProfile/index.tsx

import React, { Fragment } from "react";
import { useMutation } from "@apollo/react-hooks";
import { Avatar, Button, Card, Divider, Tag, Typography } from "antd";
import {
  formatListingPrice,
  displaySuccessNotification,
  displayErrorMessage
} from "../../../../lib/utils";
import { DISCONNECT_STRIPE } from "../../../../lib/graphql/mutations";
import { DisconnectStripe as DisconnectStripeData } from
"../../../../lib/graphql/mutations/DisconnectStripe/__generated__/DisconnectStripe";
import { User as UserData } from "../../../../lib/graphql/queries/User/__generated__/User";
import { Viewer } from "../../../../lib/types";

interface Props {
  user: UserData["user"];
  viewer: Viewer;
  viewerIsUser: boolean;
  setViewer: (viewer: Viewer) => void;
  handleUserRefetch: () => void;
}

const stripeAuthUrl = `
  https://connect.stripe.com/oauth/authorize?
    response_type=code
    &client_id=${process.env.REACT_APP_S_CLIENT_ID}
    &scope=read_write
`;
const { Paragraph, Text, Title } = Typography;

export const UserProfile = ({
  user,
  viewer,
  viewerIsUser,
  setViewer,
  handleUserRefetch
}: Props) => {
  const [disconnectStripe, { loading }] = useMutation<DisconnectStripeData>(
    DISCONNECT_STRIPE,
    {
      onCompleted: data => {
        if (data && data.disconnectStripe) {
          setViewer({ ...viewer, hasWallet: data.disconnectStripe.hasWallet });
          displaySuccessNotification(
            "You've successfully disconnected from Stripe!",
            "You'll have to reconnect with Stripe to continue to create listings."
          );
          handleUserRefetch();
        }
      },
      onError: () => {
        displayErrorMessage(
          "Sorry! We weren't able to disconnect you from Stripe. Please try again later!"
        );
      }
    }
  );

  const redirectToStripe = () => {
    window.location.href = stripeAuthUrl;
  };

  const additionalDetails = user.hasWallet ? (
    <Fragment>
      <Paragraph>
        <Tag color="green">Stripe Registered</Tag>
      </Paragraph>
      <Paragraph>
        Income Earned:{" "}
        <Text strong>{user.income ? formatListingPrice(user.income) : `$0`}</Text>
      </Paragraph>
      <Button
        type="primary"
        className="user-profile__details-cta"
        loading={loading}
        onClick={() => disconnectStripe()}
      >
        Disconnect Stripe
      </Button>
      <Paragraph type="secondary">
        By disconnecting, you won't be able to receive{" "}
        <Text strong>any further payments</Text>. This will prevent users from booking
        listings that you might have already created.
      </Paragraph>
    </Fragment>
  ) : (
    <Fragment>
      <Paragraph>
        Interested in becoming a TinyHouse host? Register with your Stripe account!
      </Paragraph>
      <Button
        type="primary"
        className="user-profile__details-cta"
        onClick={redirectToStripe}
      >
        Connect with Stripe
      </Button>
      <Paragraph type="secondary">
        TinyHouse uses{" "}
        <a
          href="https://stripe.com/en-US/connect"
          target="_blank"
          rel="noopener noreferrer"
        >
          Stripe
        </a>{" "}
        to help transfer your earnings in a secure and truster manner.
      </Paragraph>
    </Fragment>
  );

  const additionalDetailsSection = viewerIsUser ? (
    <Fragment>
      <Divider />
      <div className="user-profile__details">
        <Title level={4}>Additional Details</Title>
        {additionalDetails}
      </div>
    </Fragment>
  ) : null;

  return (
    <div className="user-profile">
      <Card className="user-profile__card">
        <div className="user-profile__avatar">
          <Avatar size={100} src={user.avatar} />
        </div>
        <Divider />
        <div className="user-profile__details">
          <Title level={4}>Details</Title>
          <Paragraph>
            Name: <Text strong>{user.name}</Text>
          </Paragraph>
          <Paragraph>
            Contact: <Text strong>{user.contact}</Text>
          </Paragraph>
        </div>
        {additionalDetailsSection}
      </Card>
    </div>
  );
};

Let’s now see how the disconnectStripe mutation works. When connected with Stripe for our user account, we’ll click the "Disconnect Stripe" button available in the user profile section of our /user/:id page. When successfully disconnected from Stripe, we’ll see the success notification message and the user query in the <User /> component is refetched to get the most up to date information showing the correct user profile information!

Great! For a user that’s logged in to our application, they now can connect and disconnect from Stripe.

MODULE 10 SUMMARY

In this module, we’ve set-up a Stripe Connect account for our TinyHouse application and we’ve provided the capability for users in our application to connect with their Stripe account and be a connected Stripe account in our platform.

SERVER PROJECT

SRC/GRAPHQL/TYPEDEFS.TS

In our GraphQL API type definitions, we’ve established two new root-level mutation fields - connectStripe and disconnectStripe .

  • The connectStripe mutation is to be executed, from the client, when the user proceeds through the Stripe OAuth page to connect their Stripe account and is redirected back to our app.
  • The disconnectStripe mutation is to be executed when a user decides to disconnect from Stripe from our TinyHouse application.

server/src/graphql/typeDefs.ts

  type Mutation {
    logIn(input: LogInInput): Viewer!
    logOut: Viewer!
    connectStripe(input: ConnectStripeInput!): Viewer!
    disconnectStripe: Viewer!
  }

SRC/GRAPHQL/RESOLVERS/VIEWER/INDEX.TS

We’ve constructed the resolver functions for the connectStripe and disconnectStripe mutations in the viewerResolvers map within the src/graphql/resolvers/Viewer/index.ts file.

In the connectStripe() resolver function, we obtain the value of the authorization code returned from Stripe’s servers and passed into our mutation as part of the input. We then verify that a valid viewer is making the request and then look to obtain the stripe_user_id of the viewer by interacting with Stripe’s API. When the stripe_user_id is available, we update the user document of the viewer by adding the stripe_user_id as the value to the walletId field of the document.

server/src/graphql/resolvers/Viewer/index.ts

export const viewerResolvers: IResolvers = {    connectStripe: async (
      _root: undefined,
      { input }: ConnectStripeArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Viewer> => {
      try {
        const { code } = input;

        let viewer = await authorize(db, req);
        if (!viewer) {
          throw new Error("viewer cannot be found");
        }

        const wallet = await Stripe.connect(code);
        if (!wallet) {
          throw new Error("stripe grant error");
        }

        const updateRes = await db.users.findOneAndUpdate(
          { _id: viewer._id },
          { $set: { walletId: wallet.stripe_user_id } },
          { returnOriginal: false }
        );

        if (!updateRes.value) {
          throw new Error("viewer could not be updated");
        }

        viewer = updateRes.value;

        return {
          _id: viewer._id,
          token: viewer.token,
          avatar: viewer.avatar,
          walletId: viewer.walletId,
          didRequest: true
        };
      } catch (error) {
        throw new Error(`Failed to connect with Stripe: ${error}`);
      }
    },};

The disconnectStripe() resolver function primarily involves removing the value of the walletId field of the viewer making the request.

server/src/graphql/resolvers/Viewer/index.ts

export const viewerResolvers: IResolvers = {    disconnectStripe: async (
      _root: undefined,
      _args: {},
      { db, req }: { db: Database; req: Request }
    ): Promise<Viewer> => {
      try {
        let viewer = await authorize(db, req);
        if (!viewer) {
          throw new Error("viewer cannot be found");
        }

        const updateRes = await db.users.findOneAndUpdate(
          { _id: viewer._id },
          { $set: { walletId: null } },
          { returnOriginal: false }
        );

        if (!updateRes.value) {
          throw new Error("viewer could not be updated");
        }

        viewer = updateRes.value;

        return {
          _id: viewer._id,
          token: viewer.token,
          avatar: viewer.avatar,
          walletId: viewer.walletId,
          didRequest: true
        };
      } catch (error) {
        throw new Error(`Failed to disconnect with Stripe: ${error}`);
      }
    }};

SRC/LIB/API/STRIPE.TS

In the src/lib/api/Stripe.ts file, we created a Stripe object instance that consolidates the functionality to interact with Stripe’s servers. In the src/lib/api/Stripe.ts file, we constructed an OAuth client and in the exported Stripe object, there exists a connect() function property that uses the Stripe client to fetch the user’s credentials from Stripe.

server/src/lib/api/Stripe.ts

import stripe from "stripe";

const client = new stripe(`${process.env.S_SECRET_KEY}`);

export const Stripe = {
  connect: async (code: string) => {
    const response = await client.oauth.token({
      /* eslint-disable @typescript-eslint/camelcase */
      grant_type: "authorization_code",
      code
      /* eslint-enable @typescript-eslint/camelcase */
    });

    return response;
  }
};

CLIENT PROJECT

SRC/LIB/GRAPHQL/

We created the GraphQL documents for the new root-level mutation fields - connectStripe and disconnectStripe . For both of these mutations, we simply return the value of the hasWallet field in the returned viewer object.

client/src/lib/graphql/mutations/ConnectStripe/index.ts

import { gql } from "apollo-boost";

export const CONNECT_STRIPE = gql`
  mutation ConnectStripe($input: ConnectStripeInput!) {
    connectStripe(input: $input) {
      hasWallet
    }
  }
`;

client/src/lib/graphql/mutations/DisconnectStripe/index.ts

import { gql } from "apollo-boost";

export const DISCONNECT_STRIPE = gql`
  mutation DisconnectStripe {
    disconnectStripe {
      hasWallet
    }
  }
`;

SRC/SECTIONS/USER/COMPONENTS/USERPROFILE/INDEX.TSX

In the <UserProfile /> component that is rendered as part of the <User /> component, we provide the functionality to allow the user to:

  1. Be redirected to Stripe’s OAuth page when connecting with Stripe.
  2. Trigger the disconnectStripe mutation when already connected with Stripe and interested in disconnecting.

client/src/sections/User/components/UserProfile/index.tsx

const stripeAuthUrl = `
  https://connect.stripe.com/oauth/authorize?
    response_type=code
    &client_id=${process.env.REACT_APP_S_CLIENT_ID}
    &scope=read_write
`;

client/src/sections/User/components/UserProfile/index.tsx

export const UserProfile = ({
  user,
  viewer,
  viewerIsUser,
  setViewer,
  handleUserRefetch
}: Props) => {
  const [disconnectStripe, { loading }] = useMutation<DisconnectStripeData>(
    DISCONNECT_STRIPE,
    {
      onCompleted: data => {
        if (data && data.disconnectStripe) {
          setViewer({ ...viewer, hasWallet: data.disconnectStripe.hasWallet });
          displaySuccessNotification(
            "You've successfully disconnected from Stripe!",
            "You'll have to reconnect with Stripe to continue to create listings."
          );
          handleUserRefetch();
        }
      },
      onError: () => {
        displayErrorMessage(
          "Sorry! We weren't able to disconnect you from Stripe. Please try again later!"
        );
      }
    }
  );

  const redirectToStripe = () => {
    window.location.href = stripeAuthUrl;
  };

  const additionalDetails = user.hasWallet ? (
    <Fragment>
      <Paragraph>
        <Tag color="green">Stripe Registered</Tag>
      </Paragraph>
      <Paragraph>
        Income Earned:{" "}
        <Text strong>{user.income ? formatListingPrice(user.income) : `$0`}</Text>
      </Paragraph>
      <Button
        type="primary"
        className="user-profile__details-cta"
        loading={loading}
        onClick={() => disconnectStripe()}
      >
        Disconnect Stripe
      </Button>
      <Paragraph type="secondary">
        By disconnecting, you won't be able to receive{" "}
        <Text strong>any further payments</Text>. This will prevent users from booking
        listings that you might have already created.
      </Paragraph>
    </Fragment>
  ) : (
    <Fragment>
      <Paragraph>
        Interested in becoming a TinyHouse host? Register with your Stripe account!
      </Paragraph>
      <Button
        type="primary"
        className="user-profile__details-cta"
        onClick={redirectToStripe}
      >
        Connect with Stripe
      </Button>
      <Paragraph type="secondary">
        TinyHouse uses{" "}
        <a
          href="https://stripe.com/en-US/connect"
          target="_blank"
          rel="noopener noreferrer"
        >
          Stripe
        </a>{" "}
        to help transfer your earnings in a secure and truster manner.
      </Paragraph>
    </Fragment>
  );

  const additionalDetailsSection = viewerIsUser ? (
    <Fragment>
      <Divider />
      <div className="user-profile__details">
        <Title level={4}>Additional Details</Title>
        {additionalDetails}
      </div>
    </Fragment>
  ) : null;

  return (
    <div className="user-profile">
      <Card className="user-profile__card">
        <div className="user-profile__avatar">
          <Avatar size={100} src={user.avatar} />
        </div>
        <Divider />
        <div className="user-profile__details">
          <Title level={4}>Details</Title>
          <Paragraph>
            Name: <Text strong>{user.name}</Text>
          </Paragraph>
          <Paragraph>
            Contact: <Text strong>{user.contact}</Text>
          </Paragraph>
        </div>
        {additionalDetailsSection}
      </Card>
    </div>
  );
};

SRC/SECTIONS/STRIPE/INDEX.TSX

We’ve constructed a <Stripe /> component that is to be rendered when a user is redirected to the /stripe route of our app after connecting their Stripe account. When the user is redirected to /stripe , a value for the authorization code is available in the URL as a query parameter. An effect is run in the <Stripe /> component to retrieve the value of the code parameter and execute the connectStripe mutation. When the connectStripe mutation is successful, the user is redirected to their /user/:id page.

client/src/sections/Stripe/index.tsx

export const Stripe = ({ viewer, setViewer, history }: Props & RouteComponentProps) => {
  const [connectStripe, { data, loading, error }] = useMutation<
    ConnectStripeData,
    ConnectStripeVariables
  >(CONNECT_STRIPE, {
    onCompleted: data => {
      if (data && data.connectStripe) {
        setViewer({ ...viewer, hasWallet: data.connectStripe.hasWallet });
        displaySuccessNotification(
          "You've successfully connected your Stripe Account!",
          "You can now begin to create listings in the Host page."
        );
      }
    }
  });
  const connectStripeRef = useRef(connectStripe);

  useEffect(() => {
    const code = new URL(window.location.href).searchParams.get("code");

    if (code) {
      connectStripeRef.current({
        variables: {
          input: { code }
        }
      });
    } else {
      history.replace("/login");
    }
  }, [history]);

  if (data && data.connectStripe) {
    return <Redirect to={`/user/${viewer.id}`} />;
  }

  if (loading) {
    return (
      <Content className="stripe">
        <Spin size="large" tip="Connecting your Stripe account..." />
      </Content>
    );
  }

  if (error) {
    return <Redirect to={`/user/${viewer.id}?stripe_error=true`} />;
  }

  return null;
};

MOVING FORWARD

In the next module, we move towards creating the capability for a user to host a listing in our application.

MODULE 11 INTRODUCTION

In this module, we’ll focus on allowing users to host their own listings on the TinyHouse platform.

Users will able to submit and create a new listing in the form presented in the /host route of our app which in the complete state will look similar to the following:

In this module, we’ll:

  • Introduce the hostListing GraphQL mutation.
  • Build the hostListing() resolver function that is to receive the appropriate input and subsequently update certain collections in our database.
  • On the client, build the form in the /host page and trigger the hostListing mutation when the host form is completed and submitted.

HOSTLISTING GRAPHQL FIELDS

For users in our application to add listings (i.e. host listings) in the TinyHouse application, we’ll need to create the functionality where users can upload and insert new listing data into our database. We’ll only allow for this functionality for users who have:

  1. Logged into our application.
  2. And have connected with Stripe.

We’ll need users who intend to host listings connected with Stripe since we’ll use the user’s stripe_user_id value for them to receive payments on our connected Stripe platform. We’ll discuss more about this in the next coming modules but for now we’ll talk about the GraphQL fields we’ll need to have for users to create listings.

All we’ll need to have users be able to create listings is one new root level mutation we’ll call hostListing . This hostListing mutation will receive an input that contains various different information about a new listing such as the listing address, title, image, etc. When the hostListing mutation is successful, a new listing document will be added to the "listings" collection in our database.

The hostListing mutation sounds like it will be fairly straightforward to implement. The more complicated things we’ll look to consider with this mutation is:

  • How we intend to receive address information for the new listing location and use Google’s Geocoding API to geocode the address information.
  • How we would want to handle image uploads from the client onto the server.

We’ll take this step by step. For this lesson, we’ll establish a GraphQL field in our GraphQL type definitions file and an accompanying resolver function before we begin to build the implementation. In the src/graphql/typeDefs.ts file, we’ll introduce a new mutation labeled hostListing that at this moment will just return a string.

server/src/graphql/typeDefs.ts

  type Mutation {
    logIn(input: LogInInput): Viewer!
    logOut: Viewer!
    connectStripe(input: ConnectStripeInput!): Viewer!
    disconnectStripe: Viewer!
    hostListing: String!
  }

We already have a listingResolvers map dedicated to containing the resolver functions that pertain to the domain of a listing. We’ll introduce a Mutation object in this listingResolvers map, within the src/graphql/resolvers/Listing/index.ts file, that is to contain a hostListing() mutation resolver. We’ll have this hostListing() resolver function return a string that says Mutation.hostListing .

server/src/graphql/resolvers/Listing/index.ts

export const listingResolvers: IResolvers = {  Mutation: {
    hostListing: () => {
      return "Mutation.hostListing";
    }
  },};

With our server running, when we head to GraphQL Playground and attempt to run the hostListing mutation, we’ll get the string message we’ve just set up.

In the next lesson, we’ll look to implement the functionality the hostListing() resolver should have.

BUILDING THE HOSTLISTING RESOLVER

GAMEPLAN

In this lesson, we’ll look to have the hostListing() resolver receive an input with new listing information and add a new listing document to the "listings" collection in our database.

It’s probably important to first discuss what kind of information is going to be provided from the client to the server to create a listing. When we build the form where the user can create a new listing, the form will collect the following information:

  • The listing type (i.e. apartment or house).
  • The maximum number of guests.
  • The title of the listing.
  • The description of the listing.
  • The address, city (or town), state (or province), and zip (or postal code) of the listing.
  • The image of the listing.
  • The price of the listing per day.

There are other fields in our listing documents in the database that govern the context of what a listing is to have in our app such as the number of bookings in a listing, the id of the host of the listing, etc. For most of these other fields, we won’t need the client to provide this information since we’ll govern the values in the server when the listing is being created. For example, when a new listing is being made, the bookings of that listing will be empty and the host id will be the id of the viewer making the request to create the listing.

The fields we’re going to capture from the client are for the most part going to be mapped one to one. For example, whatever the user says the title of the listing is going to be, we’ll simply make it the title of the listing document. This is the same for capturing the description, image, type, price, and the maximum number of guests for a listing.

Things will be a little different for the address information we’re going to capture. We’re not simply going to take the address information provided from the client and place those values directly in our database. This is for a few reasons:

  1. Our database isn’t going to accept information such as the postal code of a listing. The only location information we have for a listing document is address , country , admin , and city .
  2. We’ll want the country , admin , and city information within every listing document to be the geocoded information that Google’s Geocoding API gives us . We won’t want to store the city or country information the user types directly in the form into our database for a multitude of reasons. One being the fact that users might provide information differently, they may misspell something, etc. If we had different variations of the same representation of a location, this would affect how listings can be searched for in the /listings/:location? page !

In our server, the Google Geocoder functionality we’ve set-up requires a single input and outputs valid geographic information for the input regardless of how terse or verbose the input is. It can be just the address of a location or it can be a more formatted address that contains the specific address with the postal code, city, and country.

From the client, we’ll have the user provide information for the address, city, state, and postal code of the new listing.

When the mutation to create the listing (i.e. hostListing ) is made, we’ll concatenate all this information to a single address within the input of the mutation. For example, assume a user was to state in the client form, the address of a new listing is "251 North Bristol Avenue" , the city is "Los Angeles" , the state is "California" , and the zip code is "90210" . In the client, we’ll parse this information and simply pass a concatenated address that has all this information into the server (i.e. "251 North Bristol Avenue, Los Angeles, California, 90210" ).

The hostListing mutation will accept the concatenated address in the input argument, run it through Google’s Geocoding API, and retrieve the country , admin , and city information of the address and provide that in our new listing document.

With our gameplan sort of established, let’s begin to build out our hostListing mutation.

HOSTLISTING

In our GraphQL API type definitions file ( src/graphql/typeDefs.ts ), we’ll state that our hostListing mutation is to receive an input of object type HostListingInput and when the mutation resolves successfully, we’ll want it to return the newly created listing document to the client so it’ll return a Listing object type.

server/src/graphql/typeDefs.ts

  type Mutation {
    logIn(input: LogInInput): Viewer!
    logOut: Viewer!
    connectStripe(input: ConnectStripeInput!): Viewer!
    disconnectStripe: Viewer!
    hostListing(input: HostListingInput!): Listing!
  }

We’ll describe the shape of the HostListingInput object type and we’ll note the fields we’ve mentioned the client will pass on to the server. This input will contain:

  • title of type String .
  • description of type String .
  • image of type String . We’ll discuss how we’re going to handle image uploads shortly but it will be received as a string.
  • type with which will be of the ListingType Enum.
  • address of type String .
  • price which is to be an Int .
  • numOfGuests which is to be an Int as well.

server/src/graphql/typeDefs.ts

  input HostListingInput {
    title: String!
    description: String!
    image: String!
    type: ListingType!
    address: String!
    price: Int!
    numOfGuests: Int!
  }

We’ll then create the TypeScript interface type for this HostListingInput argument in the types file kept within the listing resolvers folder ( src/graphql/resolvers/Listing/types.ts ). We’ll export an interface called HostListingArgs which is to have an input of type HostListingInput . The shape of the HostListingInput interface will be as we described in our GraphQL API type definitions. We’ll import the ListingType interface from the src/lib/types.ts file which we’ll use to describe the shape of the type field within HostListingInput .

server/src/graphql/resolvers/Listing/types.ts

export interface HostListingInput {
  title: string;
  description: string;
  image: string;
  type: ListingType;
  address: string;
  price: number;
  numOfGuests: number;
}

export interface HostListingArgs {
  input: HostListingInput;
}

In the listingResolvers map (within src/graphql/resolvers/Listing/index.ts ), we’ll import the HostListingArgs interface from the adjacent types file and we’ll declare our hostListing() resolver will receive an input argument. We’ll also look to destruct the database and request objects available as context and when the mutation function is resolved successfully, it will return Promise<Listing> .

import {
  // ...,
  HostListingArgs
} from "./types";

export const listingResolvers: IResolvers = {
  Query: {
    // ...
  },
  Mutation: {
    hostListing: async (
      _root: undefined,
      { input }: HostListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {
      // ...
    }
  },
  Listing: {
    // ...
  }
};

There are a few things we’ll want to do in the hostListing() resolver function. The first thing we’ll want to do is verify that the input provided by the user is valid. A valid input is one where we want to verify that the title and description fields don’t exceed a certain number of characters, the type selected is an apartment or a house and the price isn’t less than 0. We’re going to provide some of these restrictions on the client-side as well but we’ll add these server-side validations as an extra precaution.

We’ll have these validations handled in a separate function that we’ll call verifyHostListingInput() and will receive the input argument. We’ll construct the verifyHostListingInput() function above the listingResolvers map object that is to receive an input argument. We’ll import the HostListingInput interface from the adjacent types file to describe the shape of the input.

import {
  // ...,
  HostListingInput
  // ...
} from "./types";

const verifyHostListingInput = (input: HostListingInput) => {};

export const listingResolvers: IResolvers = {
  Query: {
    // ...
  },
  Mutation: {
    hostListing: async (
      _root: undefined,
      { input }: HostListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {
      verifyHostListingInput(input);
    }
  },
  Listing: {
    // ...
  }
};

From the input argument of the verifyHostListingInput() function, we’ll destruct the fields that we’ll want to validate - title , description , type , and price .

const verifyHostListingInput = ({
  title,
  description,
  type,
  price
}: HostListingInput) => {};

We’ll provide the following validation checks in the verifyHostListingInput() function:

  • If the length of the title is greater than 100 characters, we’ll throw an error that says "listing title must be under 100 characters" .
  • If the length of the description is greater than 5000 characters, we’ll throw an error that says "listing description must be under 5000 characters" .
  • If the type of listing is neither an apartment or house , we’ll throw an error that says "listing type must either an apartment or house" . We’ll import the ListingType TypeScript Enum from the src/lib/types.ts for it to be used here.
  • If the price of the listing is less than 0, we’ll throw an error that says "price must be greater than 0"

server/src/graphql/resolvers/Listing/index.ts

import { Database, Listing, ListingType, User } from "../../../lib/types";

server/src/graphql/resolvers/Listing/index.ts

const verifyHostListingInput = ({
  title,
  description,
  type,
  price
}: HostListingInput) => {
  if (title.length > 100) {
    throw new Error("listing title must be under 100 characters");
  }
  if (description.length > 5000) {
    throw new Error("listing description must be under 5000 characters");
  }
  if (type !== ListingType.Apartment && type !== ListingType.House) {
    throw new Error("listing type must be either an apartment or house");
  }
  if (price < 0) {
    throw new Error("price must be greater than 0");
  }
};

In our hostListing() resolver function, if the input is valid, we’ll look to validate that a viewer who’s logged in to our application is making the listing since we only want listings to be created for users who’ve already logged in to the application. We have an authorize() function in the src/lib/utils/ folder that’s already imported and can be used here. We’ll use the authorize() function, pass in the db and req objects, and attempt to retrieve a user based on the CSRF token available in the request. If the viewer doesn’t exist, we’ll throw an error saying "viewer could not be found" .

export const listingResolvers: IResolvers = {
  Query: {
    // ...
  },
  Mutation: {
    hostListing: async (
      _root: undefined,
      { input }: HostListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {
      verifyHostListingInput(input);

      let viewer = await authorize(db, req);
      if (!viewer) {
        throw new Error("viewer cannot be found");
      }
    }
  },
  Listing: {
    // ...
  }
};

If the viewer object exists, we can now look to get the country , admin , and city information from our geocoder based on the address in the input object. We’ll run the geocode() function from the Google object imported in this file, pass the input.address field, and destruct the country , admin , and city information. If any of these location fields can’t be found from the geocoder, we’ll throw an error that says “invalid address input”.

export const listingResolvers: IResolvers = {
  Query: {
    // ...
  },
  Mutation: {
    hostListing: async (
      _root: undefined,
      { input }: HostListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {
      verifyHostListingInput(input);

      let viewer = await authorize(db, req);
      if (!viewer) {
        throw new Error("viewer cannot be found");
      }

      const { country, admin, city } = await Google.geocode(input.address);
      if (!country || !admin || !city) {
        throw new Error("invalid address input");
      }
    }
  },
  Listing: {
    // ...
  }
};

At this moment, if there are no errors, we can prepare to insert a new listing document to the "listings" collection in our database. We’ll use the insertOne() method of the Node Mongo Driver to insert a new document to the "listings" collection.

The _id field of the new document we’ll insert will be a new ObjectId we’ll create with the help of the ObjectId constructor from MongoDB. We’ll then use the spread operator to add the fields from the input object directly into the new document.

We’ll specify some other fields in the new document as well. For example:

  • The bookings field will be an empty array.
  • bookingsIndex will be an empty object
  • We’ll pass the country , admin , and city fields from our geocoder.
  • The host field will have a value of the id of the viewer creating the listing.
export const listingResolvers: IResolvers = {
  Query: {
    // ...
  },
  Mutation: {
    hostListing: async (
      _root: undefined,
      { input }: HostListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {
      verifyHostListingInput(input);

      let viewer = await authorize(db, req);
      if (!viewer) {
        throw new Error("viewer cannot be found");
      }

      const { country, admin, city } = await Google.geocode(input.address);
      if (!country || !admin || !city) {
        throw new Error("invalid address input");
      }

      const insertResult = await db.listings.insertOne({
        _id: new ObjectId(),
        ...input,
        bookings: [],
        bookingsIndex: {},
        country,
        admin,
        city,
        host: viewer._id
      });
    }
  },
  Listing: {
    // ...
  }
};

There’s one other thing we’re going to do in the hostListing() resolver function. Every user document in the "users" collection is to have a listings field that contains an array of listing ids to represent the listings that the user has . We’ll look to update the user document of the viewer making the request to state that the id of this new listing will be part of the listings array of that user document.

We’ll first look to access the newly inserted listing document. There are a few ways one can perhaps do this but we can achieve this by accessing the first item in the .ops array in the insert result. We’ll assign the insert result to a new constant called insertedListing and we’ll be sure to describe its shape as the Listing interface. We’ll then run the updateOne() method from the Node MongoDB driver to help update a document in the "users" collection. We’ll find the document where the _id field is that of the viewer_id and we’ll simply push the _id of the insertedListing to the listings field in this document.

Finally, we’ll have the hostListing() resolver function return the newly inserted listing document.

server/src/graphql/resolvers/Listing/index.ts

export const listingResolvers: IResolvers = {  Mutation: {
    hostListing: async (
      _root: undefined,
      { input }: HostListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {
      verifyHostListingInput(input);

      let viewer = await authorize(db, req);
      if (!viewer) {
        throw new Error("viewer cannot be found");
      }

      const { country, admin, city } = await Google.geocode(input.address);
      if (!country || !admin || !city) {
        throw new Error("invalid address input");
      }

      const insertResult = await db.listings.insertOne({
        _id: new ObjectId(),
        ...input,
        bookings: [],
        bookingsIndex: {},
        country,
        admin,
        city,
        host: viewer._id
      });

      const insertedListing: Listing = insertResult.ops[0];

      await db.users.updateOne(
        { _id: viewer._id },
        { $push: { listings: insertedListing._id } }
      );

      return insertedListing;
    }
  },};

Our hostListing mutation can now receive an input object that contains information about a new listing. In the hostListing() resolver, we verify if input is valid and we verify the viewer making the request. We then try to resolve the country , admin , and city information from the address that was provided. If all this is successful, we insert a new document into the "listings" collection and we update the user document of the viewer making the request.

In the next lesson, we’ll begin to build the client functionality to have the form available in the /host page which would receive information for the user creating a new listing and trigger the hostListing mutation.