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 theuser.income
field. If theincome
value withinuser
exists, we’ll use theformatListingPrice()
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:
- Be redirected to Stripe’s OAuth page when connecting with Stripe.
- 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 thehostListing
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:
- Logged into our application.
- 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:
- 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
, andcity
. - We’ll want the
country
,admin
, andcity
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 typeString
. -
description
of typeString
. -
image
of typeString
. 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 theListingType
Enum. -
address
of typeString
. -
price
which is to be anInt
. -
numOfGuests
which is to be anInt
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 anapartment
orhouse
, we’ll throw an error that says"listing type must either an apartment or house"
. We’ll import theListingType
TypeScript Enum from thesrc/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
, andcity
fields from our geocoder. - The
host
field will have a value of theid
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.