- Languages: Node.js, Typescript
- Tools Used: Stripe
- Time saved: 3 weeks -> 40 mins
- Source code on Github
This is Part 1 in the series of guides on building a Stripe App.
Introduction
Stripe Apps extend a Stripe Business Owner's capabilities by sharing screen real estate with Stripe Dashboard. Here's a look at a Stripe App we will be building.
In this guide, we will build a Stripe app called CRM Buddy that helps business owners leave notes on Customer profiles.
In Part 1, we will build a Stripe app and create views for the following screens:
- On the Dashboard: View all recently added notes for all customer
- On the Customer screen: View all the previously added notes
- On the Customer screen: Add a new note for that customer
The Backend data will be mocked up. In Part 2, we will replace the mock data with a real Node.js API service.
Prerequisites
Step 0: Prepare the App mockup in Figma
Planning ahead makes development so much more fun. I always try to put myself in the end user's shoes and imagine how they will use the app before starting to build. Creating a mockup is a great way of doing just that.
I've gone ahead and mocked up how our CRM app will look like and behave in Figma. Feel free to duplicate the file here.
Step 1: Create a new Stripe App
We will start off by building just the front-end Stripe app with mocked-up data. We can pretend that the data is coming from a backend API and use it to build the various views. In Part 2, we will build a Node.js backend that will actually serve/save the data.
Create a new folder, call it stripe-crm-app
. We can create a boilerplate Stripe App project by opening up a terminal and running:
cd stripe-crm-app
stripe login
stripe apps create stripe-app
You can name the app something like com.. crm-buddy. I named mine - com.saasbase.crmbuddy
Let's run it with:
stripe apps start
Great! You can see a sample app running. Try going to the Customers screen and see the App UI change.
Step 2: Mock sample data
Before we build out the UI, let's create some mock APIs that will return sample data that we can then use in the app.
addNoteAPI = To add a note to a customer
getAllNotesAPI = Get all notes for all customers
getNotesForCustomerAPI = Get all notes for a specific customer
Let's start by laying out the types. Create a new file called src/types/index.ts
export interface Note {
id: number;
agentId: string;
customerId: string;
message: string;
createdAt: Date;
}
export interface APIResponse {
data: any;
error: boolean;
}
Now we can start on our fake data. Create a new file called src/api/index.ts
import { APIResponse, Note } from "../types";
const notes: Note[] = [{
id: 1,
agentId: "acc_",
customerId: "cus_Lkx8AOzZ3js2N1",
message: "Needs SSO auth integration",
createdAt: new Date()
}, {
id: 2,
agentId: "acc_",
customerId: "cus_LksZWRqAAxal22", // replace this with a customer Id in your dashboard
message: "Call scheduled for Aug 5th",
createdAt: new Date()
}]
const generateRandomId = (): number => {
return Math.floor(Math.random() * 100);
}
export async function addNoteAPI({ customerId, message, agentId }: { customerId: string, message: string, agentId: string }): Promise<APIResponse> {
const newNote: Note = { id: generateRandomId(), agentId, customerId, message, createdAt: new Date() }
notes.push(newNote)
const response: APIResponse = { error: false, data: {} }
return Promise.resolve(response)
}
export async function getAllNotesAPI(): Promise<APIResponse> {
const response: APIResponse = { error: false, data: { notes } }
return Promise.resolve(response);
}
export async function getNotesForCustomerAPI({ customerId }: { customerId: string }): Promise<APIResponse> {
const notesForCustomer = notes.filter((record: Note) => record.customerId === customerId)
const response: APIResponse = { error: false, data: { notes: notesForCustomer } }
return Promise.resolve(response);
}
Since we want to show notes for an actual customer on our account. Create a new customer here and replace the customerId
with the generated ID.
With the mock APIs intact, we are ready to consume this data on our app. We will build the Home Overview view next.
Step 3: Add a Home Screen view
Now that we can get some sample data, let's create the Home Screen. If you remember from the Figma mockup, the home screen shows all notes left for all customers.
Clean out all views by deleting App.tsx
and App.test.tsx
. Go to stripe.json
and remove all the views. This is how your stripe.json
will look like:
{
"id": "com.saasbase.stripe-app-fe",
"version": "0.0.1",
"name": "CRM Buddy",
"icon": "",
"permissions": [],
"app_backend": {
"webhooks": null
},
"ui_extension": {
"views": [
],
"actions": [],
"content_security_policy": {
"connect-src": null,
"image-src": null,
"purpose": ""
}
},
"post_install_action": null
}
Don't worry, we will create new ones in the following step.
Stripe CLI has an easy way to add a view. In your terminal, run: stripe apps add view
. It will show a list of available viewport options. Choose stripe.dashboard.home.overview
. This component will render when the user goes to the dashboard.stripe.com/dashboard
URL.
Open up the newly created HomeOverview.tsx
A ContextView
the container on the right you see that houses our App inside it. Let's customize it by setting a title and removing the link like so in HomeOverview.tsx
:
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { ContextView } from "@stripe/ui-extension-sdk/ui";
const HomeOverviewView = ({
userContext,
environment,
}: ExtensionContextValue) => {
return <ContextView title="Overview"></ContextView>;
};
export default HomeOverviewView;
As per Stripe's guidelines, the design language of the App should be very similar to Stripe's itself. This is why we should pre-built components provided by the Stripe UI Kit to build these views. Here's a list of more components.
From our mockup, our home page should display all notes from all customers as a list. First, let's get sample data from the mockup API with useEffect
.
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { ContextView } from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";
const HomeOverviewView = ({
userContext,
environment,
}: ExtensionContextValue) => {
const [notes, setNotes] = useState<Note[] | null>(null);
const getAllNotes = () => {
getAllNotesAPI().then((res: APIResponse) => {
if (!res.data.error) {
setNotes(res.data.notes);
}
});
};
useEffect(() => {
getAllNotes();
}, []);
return <ContextView title="Overview">{JSON.stringify(notes)}</ContextView>;
};
export default HomeOverviewView;
Sweet, our mocked notes are coming through. We can now use the Box and the List component to render them.
Let's create a separate component in components/Notes/index.tsx
import { Box, Inline, Link, List, ListItem } from "@stripe/ui-extension-sdk/ui";
import moment from "moment";
import { Note } from "../../types";
interface NotesProps {
notes: Note[] | null;
}
const Notes = ({ notes }: NotesProps) => {
if (!notes || notes.length === 0) {
return (
<Box css={{ marginTop: "medium" }}>
<Inline>No notes found</Inline>
</Box>
);
}
return (
<Box css={{ marginTop: "medium" }}>
{notes.map((note: Note, i: number) => {
return (
<List key={`messages_${i}`} aria-label="List of recent messages">
<ListItem
title={<Box>Note #{note.id}</Box>}
secondaryTitle={
<Box css={{ stack: "y" }}>
<Inline>{moment().calendar()}</Inline>
<Inline>{note.message}</Inline>
</Box>
}
value={
<Box css={{ marginRight: "xsmall" }}>
<Link
href={`https://dashboard.stripe.com/test/customers/${note.customerId}`}
>
View →
</Link>
</Box>
}
/>
</List>
);
})}
</Box>
);
};
export default Notes;
Now we can import it in the HomeOverviewView.tsx
:
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { Box, ContextView, Inline } from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";
import { getAllNotesAPI } from "../api";
import Notes from "../components/Notes";
import { APIResponse, Note } from "../types";
const HomeOverviewView = ({
userContext,
environment,
}: ExtensionContextValue) => {
const agentName = userContext?.account.name as string;
const [notes, setNotes] = useState<Note[] | null>(null);
const getAllNotes = () => {
getAllNotesAPI().then((res: APIResponse) => {
if (!res.data.error) {
setNotes(res.data.notes);
}
});
};
useEffect(() => {
getAllNotes();
}, []);
return (
<>
<ContextView title="Overview">
<Box css={{ stack: "y" }}>
<Inline
css={{
color: "primary",
fontWeight: "semibold",
}}
>
View All Notes
</Inline>
<Notes notes={notes} />
</Box>
</ContextView>
</>
);
};
export default HomeOverviewView;
Excellent! You got it done. Here's what the Home Screen looks like. Try passing an empty notes array to make sure it looks good.
//...
<Notes notes={[]} />
//...
Step 4: View notes for the selected customer
Now that we have a Home screen where all the notes we show all the notes left for our customers, we should create an experience for when an individual customer profile is selected on the Dashboard. We will show only the notes left on that customer.
- Create a new one by running:
stripe apps add view
. Choose thestripe.dashboard.customer.detail
.
Create a new file: CustomerDetailView.tsx
Stripe makes it easy to grab the current customer profile that has been pulled up, and get their ID with environment?.objectContext?.id
.
Let's start by getting notes by customer ID. If you remember, our getNotesForCustomerAPI
returns notes for a specific customer if we pass in the ID. We can reuse a lot of the logic we have already defined previously for the Home View.
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { Box, ContextView, Inline } from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";
import { getNotesForCustomerAPI } from "../api";
import Notes from "../components/Notes";
import { APIResponse, Note } from "../types";
import BrandIcon from "./brand_icon.svg";
const CustomerDetailView = ({
userContext,
environment,
}: ExtensionContextValue) => {
const customerId = environment?.objectContext?.id;
const agentId = userContext?.account.id as string;
const agentName = userContext?.account.name as string;
const [notes, setNotes] = useState<Note[] | null>(null);
const getNotes = () => {
if (!customerId) {
return;
}
getNotesForCustomerAPI({ customerId }).then((res: APIResponse) => {
if (!res.data.error) {
setNotes(res.data.notes);
}
});
};
useEffect(() => {
getNotes();
}, [customerId]);
console.log(notes);
return (
<ContextView
title="All Notes"
description={customerId}
brandColor="#F6F8FA"
brandIcon={BrandIcon}
>
<Box css={{ stack: "y" }}>
<Box css={{}}>
<Inline
css={{
font: "heading",
color: "primary",
fontWeight: "semibold",
paddingY: "medium",
}}
>
View All Notes
</Inline>
<Notes notes={notes} />
</Box>
</Box>
</ContextView>
);
};
export default CustomerDetailView;
Step 5: Let the user create a new note
Now that we can see notes for the selected Customer, we should be able to create a new note for them as well. Stripe has a great component called FocusView
specifically for this use case. A FocusView opens up a wizard-like view for a one-off action from the user. In our case, that would be adding a new note.
Create a new component in src/components/AddNoteView/index.tsx
:
import { Button, FocusView, TextArea } from "@stripe/ui-extension-sdk/ui";
import { FunctionComponent, useState } from "react";
import { addNoteAPI } from "../../api";
interface AddNoteViewProps {
isOpen: boolean;
customerId: string;
agentId: string;
onSuccessAction: () => void;
onCancelAction: () => void;
}
const AddNoteView: FunctionComponent<AddNoteViewProps> = ({
isOpen,
customerId,
agentId,
onSuccessAction,
onCancelAction,
}: AddNoteViewProps) => {
const [message, setMessage] = useState<string>("");
return (
<>
<FocusView
title="Add a new note"
shown={isOpen}
onClose={() => {
onCancelAction();
}}
primaryAction={
<Button
type="primary"
onPress={async () => {
await addNoteAPI({ customerId, agentId, message });
setMessage("");
onSuccessAction();
}}
>
Save note
</Button>
}
secondaryAction={
<Button
onPress={() => {
onCancelAction();
}}
>
Cancel
</Button>
}
>
<TextArea
label="Message"
placeholder="Looking for more enterprise features like SEO..."
value={message}
autoFocus
onChange={(e) => {
setMessage(e.target.value);
}}
/>
</FocusView>
</>
);
};
export default AddNoteView;
The ContextView
allows us to add an action button in it. Perfect place for an "Add Note" button. Let's add that to our CustomerDetailView.tsx
:
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import {
Banner,
Box,
Button,
ContextView,
Icon,
Inline,
} from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";
import { getNotesForCustomerAPI } from "../api";
import AddNoteView from "../components/AddNoteView";
import Notes from "../components/Notes";
import { APIResponse, Note } from "../types";
import BrandIcon from "./brand_icon.svg";
const CustomerDetailView = ({
userContext,
environment,
}: ExtensionContextValue) => {
const customerId = environment?.objectContext?.id;
const agentId = userContext?.account.id || ""; //todo
const agentName = userContext?.account.name || ""; //todo
const [notes, setNotes] = useState<Note[] | null>(null);
const [showAddNoteView, setShowAddNoteView] = useState<boolean>(false);
const [showAddNoteSuccessMessage, setShowAddNoteSuccessMessage] =
useState<boolean>(false);
const getNotes = () => {
if (!customerId) {
return;
}
getNotesForCustomerAPI({ customerId }).then((res: APIResponse) => {
if (!res.data.error) {
setNotes(res.data.notes);
}
});
};
useEffect(() => {
getNotes();
}, [customerId]);
console.log(notes);
return (
<ContextView
title="All Notes"
description={customerId}
brandColor="#F6F8FA"
brandIcon={BrandIcon}
actions={
<Button
type="primary"
css={{ width: "fill", alignX: "center" }}
onPress={() => {
setShowAddNoteView(true);
}}
>
<Box css={{ stack: "x", gap: "small", alignY: "center" }}>
<Icon name="addCircle" size="xsmall" />
<Inline>Add note</Inline>
</Box>
</Button>
}
>
<AddNoteView
isOpen={showAddNoteView}
customerId={customerId as string}
agentId={agentId}
onSuccessAction={() => {
setShowAddNoteView(false);
setShowAddNoteSuccessMessage(true);
getNotes();
}}
onCancelAction={() => {
setShowAddNoteView(false);
}}
/>
<Box css={{ stack: "y" }}>
<Box css={{}}>
<Inline
css={{
font: "heading",
color: "primary",
fontWeight: "semibold",
paddingY: "medium",
}}
>
View All Notes
</Inline>
<Notes notes={notes} />
</Box>
</Box>
</ContextView>
);
};
export default CustomerDetailView;
We should also show the user a notification when a note is successfully created. The Banner
component is perfect for that.
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import {
Banner,
Box,
Button,
ContextView,
Icon,
Inline,
} from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";
import { getNotesForCustomerAPI } from "../api";
import AddNoteView from "../components/AddNoteView";
import Notes from "../components/Notes";
import { APIResponse, Note } from "../types";
import BrandIcon from "./brand_icon.svg";
const CustomerDetailView = ({
userContext,
environment,
}: ExtensionContextValue) => {
const customerId = environment?.objectContext?.id;
const agentId = userContext?.account.id || ""; //todo
const agentName = userContext?.account.name || ""; //todo
const [notes, setNotes] = useState<Note[] | null>(null);
const [showAddNoteView, setShowAddNoteView] = useState<boolean>(false);
const [showAddNoteSuccessMessage, setShowAddNoteSuccessMessage] =
useState<boolean>(false);
const getNotes = () => {
if (!customerId) {
return;
}
getNotesForCustomerAPI({ customerId }).then((res: APIResponse) => {
if (!res.data.error) {
setNotes(res.data.notes);
}
});
};
useEffect(() => {
getNotes();
}, [customerId]);
console.log(notes);
return (
<ContextView
title="All Notes"
description={customerId}
brandColor="#F6F8FA"
brandIcon={BrandIcon}
actions={
<Button
type="primary"
css={{ width: "fill", alignX: "center" }}
onPress={() => {
setShowAddNoteView(true);
}}
>
<Box css={{ stack: "x", gap: "small", alignY: "center" }}>
<Icon name="addCircle" size="xsmall" />
<Inline>Add note</Inline>
</Box>
</Button>
}
>
{showAddNoteSuccessMessage && (
<Box css={{ marginBottom: "small" }}>
<Banner
type="default"
onDismiss={() => setShowAddNoteSuccessMessage(false)}
title="Added new note"
/>
</Box>
)}
<AddNoteView
isOpen={showAddNoteView}
customerId={customerId as string}
agentId={agentId}
onSuccessAction={() => {
setShowAddNoteView(false);
setShowAddNoteSuccessMessage(true);
getNotes();
}}
onCancelAction={() => {
setShowAddNoteView(false);
}}
/>
<Box css={{ stack: "y" }}>
<Box css={{}}>
<Inline
css={{
font: "heading",
color: "primary",
fontWeight: "semibold",
paddingY: "medium",
}}
>
View All Notes
</Inline>
<Notes notes={notes} />
</Box>
</Box>
</ContextView>
);
};
export default CustomerDetailView;
Here's what adding a new note looks like:
There you have it - You have just built your very first Stripe App! Currently, it uses mocked-up data which is not ideal. In Part 2, we will use Node.js to create an API server that saves and persists data in PostgresDB.