概要
個人で開発しているサービスに決済機能を追加したく、どのサービスを使おうか迷っていたのですが、Stripe(https://stripe.com/jp )を使ってみたところ手軽にセキュアな決済機能を実装する事ができたのでメモ書き。
完成イメージ
https://simple-e-commerce-20210513.netlify.app
使用技術
- React
- TypeScript
- Stripe
- NetlifyFunctions
事前知識
具体的な実装に入る前に、Stripeの仕様についていくつか押さえておかなければならない点があるので軽くまとめておきます。
3種類の組み込み方法
現在、Stripeには以下3種類の組み込み方法があり、それぞれ特徴が異なります。
- Stripe Checkout
- Charges API
- Payment Intents API
参照: https://stripe.com/docs/payments
Stripe Checkout
Stripeが用意した専用のサーバーに誘導して決済を行ってもらう形式。決済後は自サービスへリダイレクトさせる。
イメージ
こんな感じでいかにもといった感じの良さげなページを少量の記述で準備する事ができます。
- メリット
- 実装がシンプル(自前でサーバーを用意する必要が無く、処理の大部分をStripeに丸投げする事ができる)。
- Stripeが提供している決済機能だと一目でわかるので利用者目線だと安心?(まだサービスが無名なうちとかは特に)
- デメリット
- 後述の2種類に比べるとカスタマイズ性はやや劣る。
参照: https://stripe.com/docs/payments/checkout
Charges API & Payment Intents API
自前でサーバーを用意し、自サービス内で決済を完了させる形式。
イメージ
Stripe Checkoutとは打って変わりUIや決済処理をほぼ全て自前で実装していく事になるため、カスタマイズ性という面では融通が効きそうです。
今回はサンプルアプリなのでだいぶ適当な感じですが、Stripeが提供している「Stripe Elements」を上手く組み合わせる事で多様な決済画面が作れると思います。
- メリット
- そのほとんどを自前で実装する事になるため、カスタマイズ性に優れる。
- デメリット
- 実装コストが高い(前述のようにクライアントだけでなくサーバーの実装も必要になるため)。
なお、「Charges API」と「Payment Intentes」の主な違いについては以下を確認してください。
参照: https://stripe.com/docs/payments/payment-intents/migration/charges
参照: https://stripe.com/docs/payments/charges-api
ざっくり言うと「Charges API」が旧式、「Payment Intentes API」が新式に該当するようです。
セキュリティ面などで後者の方が優れるため、できるだけ後者を使った方が良いように思えますが、より詳細な部分を見ていくと使用可能な決済方法が微妙に違うなどそれぞれ一長一短もあるようです。(つまり適切な使い分けがベター?)
もっとも、公式の見解によると今後新機能が追加される場合は「Payment Intents API」のみが対象となるようなので、今回の記事ではこちらを使用した実装で進めていきます。
参照: https://stripe.com/docs/payments/payment-intents/migration/charges
実装
前置きはほどほどに実装していきましょう。
各種APIキーを準備
まだStripeのアカウントを持っていない場合はササっと作成してください。
ダッシュボードから「開発者」→「APIキー」と進み、「公開可能キー」と「シークレットキー」の2つをメモに控えておきましょう。
なお、今回はテスト環境用のAPIキーを使用してきます。
クライアント
次にクライアント側の実装に入ります。
create-react-app
おなじみのコマンドで雛形を作成。
$ npx create-react-app simple-e-commerce --template typescript
$ cd simple-e-commerce
不要なファイルを削除
この先使う事の無いファイルを削除しておきます。
$ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts
それに伴い、以下の2ファイルを編集。
import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App"
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
)
import React from "react"
const App: React.FC = () => {
return (
<h1>Hello World!</h1>
)
}
export default App
動作確認
$ yarn start
http://localhost:3000 にアクセスして「Hello World!」が表示されていればOKです。
各種ライブラリをインストール
この先使用する事になるライブラリをまとめてインストールしてしまいます。
$ yarn add @material-ui/core @material-ui/icons @stripe/react-stripe-js @stripe/stripe-js stripe dotenv-webpack http-proxy-middleware netlify-lambda
$ yarn add -D @types/stripe npm-run-all
- material-ui関連
- UIを整えるために使用。
- stripe関連
- Stripeを利用するために使用。
- dotenv-webpack
- 環境変数を取り扱うために使用。
- http-proxy-middleware
- CORS対策のために使用。
- netlify-lambda
- Netlify Functionsを利用するために使用。
- npm-run-all
- npm-scriptsを一気に実行するために使用。
各種ディレクトリ・ファイルを作成
この先使用する事になるディレクトリ・ファイルをまとめて作成してしまいます。
$ cat > provisioning.sh << EOS
#!/bin/bash
mkdir src/components
mkdir src/components/cart
mkdir src/components/checkout
mkdir src/components/layouts
mkdir src/components/product
mkdir src/data
mkdir src/interfaces
mkdir src/lambda
touch src/components/cart/Cart.tsx
touch src/components/cart/CartItem.tsx
touch src/components/checkout/CheckoutForm.tsx
touch src/components/layouts/Header.tsx
touch src/components/product/Product.tsx
touch src/data/products.ts
touch src/interfaces/index.ts
touch src/lambda/paymentIntents.js
touch src/setupProxy.js
touch src/setupProxy.ts
touch .env
touch netlify.toml
touch webpack.functions.js
EOS
たくさんあって面倒なのでシェルスクリプトを作成して実行してください。
$ sh provisioning.sh
最終的に次のような構成になっていればOKです。
SIMPLE-E-COMMERCE
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── components
│ │ ├── cart
│ │ │ ├── Cart.tsx
│ │ │ └── CartItem.tsx
│ │ ├── checkout
│ │ │ └── CheckoutForm.tsx
│ │ ├── layouts
│ │ │ └── Header.tsx
│ │ └── product
│ │ └── Product.tsx
│ ├── data
│ │ └── products.ts
│ ├── lambda
│ │ └── paymentIntents.js
│ ├── interfaces
│ │ └── index.ts
│ ├── App.tsx
│ ├── index.css
│ ├── index.tsx
│ ├── react-app-env.d.ts
│ ├── setupProxy.js
│ └── setupProxy.ts
├── .env
├── .gitignore
├── netlify.toml
├── package.json
├── provisioning.sh
├── README.md
├── tsconfig.json
├── webpack.functions.js
└── yarn.lock
型を定義
まず最初にアプリ全体で使い回す型を定義しておきます。
export interface Product {
id: number
title: string
description: string
price: number
image: string
}
export interface CartItem {
id?: number | undefined
title?: string | undefined
price?: number | undefined
quantity: number
cost?: number | undefined
}
- 商品(Product)
- title: 商品名
- description: 商品説明
- price: 商品価格
- image: 画像
- カート内(CartItem)
- title: 商品名
- price: 商品価格
- quantity: 数量
- cost: 合計金額(price × quantity)
商品データを作成
本来であればAPI(microCMSとか使うと良いかも?)などで別途管理すべき項目かもしれませんが、今回は単純化のために適当にJSONデータで保持しておきます。
import { Product } from "../interfaces/index"
export const products: Product[] = [
{
id: 1,
title: "Banana",
description: "Lorem ipsum dolor sit amet.",
price: 100,
image: "https://food-foto.jp/free/img/images_big/fd400883.jpg"
},
{
id: 2,
title: "Apple",
description: "Lorem ipsum dolor sit amet.",
price: 200,
image: "https://food-foto.jp/free/img/images_big/fd400993.jpg"
},
{
id: 3,
title: "Orange",
description: "Lorem ipsum dolor sit amet.",
price: 300,
image: "https://food-foto.jp/free/img/images_big/fd401266.jpg"
}
]
各種ビューを作成
見た目の部分を作り込んでいきます。
index
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
.StripeElement {
display: block;
margin: 0.5rem auto 1.5rem;
max-width: 500px;
padding: 12px 16px;
font-size: 1rem;
border: 1px solid #eee;
border-radius: 3px;
outline: 0;
background: white;
}
App
import React, { useState } from "react"
import { Elements } from "@stripe/react-stripe-js"
import { loadStripe } from "@stripe/stripe-js"
import { Container, Grid } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"
import { products } from "./data/products"
import Product from "./components/product/Product"
import Cart from "./components/cart/Cart"
import CheckoutForm from "./components/checkout/CheckoutForm"
import Header from "./components/layouts/Header"
import { CartItem as CartItemType } from "./interfaces/index"
const useStyles = makeStyles(() => ({
container: {
marginTop: "3rem"
}
}))
const App: React.FC = () => {
const classes = useStyles()
const [cartItems, setCartItems] = useState<CartItemType[]>([])
const handleAddToCart = (id: number) => {
setCartItems((cartItems: CartItemType[]) => {
const cartItem = cartItems.find((item) => item.id === id)
// すでに同じ商品が入っている場合は数量を増加
if (cartItem) {
return cartItems.map((item) => {
if (item.id !== id) return item
return { ...cartItem, quantity: item.quantity + 1 }
})
}
// そうでない場合は新たに追加
const newCartItem = products.find(item => item.id === id)
return [...cartItems, { ...newCartItem, quantity: 1 }]
})
}
// 合計金額を算出
const totalCost = cartItems.reduce(
(acc: number, item: CartItemType) => acc + (item.price || 0) * item.quantity,
0
)
const stripePublicKey = process.env.REACT_APP_STRIPE_PUBLIC_KEY || "" // Stripe APIの公開鍵
const stripePromise = loadStripe(stripePublicKey)
return (
<>
<header>
<Header />
</header>
<main>
<Container maxWidth="lg">
<Grid container spacing={4} justify="center" className={classes.container}>
{products.map(product => (
<Grid item key={product.id}>
<Product
title={product.title}
description={product.description}
price={product.price}
image={product.image}
handleAddToCart={() => handleAddToCart(product.id)}
/>
</Grid>
))}
</Grid>
<Cart
cartItems={cartItems}
totalCost={totalCost}
setCartItems={setCartItems}
/>
{ cartItems.length > 0 && (
<Elements stripe={stripePromise}>
<CheckoutForm totalCost={totalCost} />
</Elements>
)}
</Container>
</main>
</>
)
}
export default App
header
import React from "react"
import AppBar from "@material-ui/core/AppBar"
import Toolbar from "@material-ui/core/Toolbar"
import IconButton from "@material-ui/core/IconButton"
import Typography from "@material-ui/core/Typography"
import InputBase from "@material-ui/core/InputBase"
import { createStyles, fade, Theme, makeStyles } from "@material-ui/core/styles"
import MenuIcon from "@material-ui/icons/Menu"
import SearchIcon from "@material-ui/icons/Search"
const useStyles = makeStyles((theme: Theme) =>
createStyles({
header: {
flexGrow: 1
},
menuButton: {
marginRight: theme.spacing(2)
},
title: {
flexGrow: 1,
display: "none",
[theme.breakpoints.up("sm")]: {
display: "block"
}
},
search: {
position: "relative",
borderRadius: theme.shape.borderRadius,
backgroundColor: fade(theme.palette.common.white, 0.15),
"&:hover": {
backgroundColor: fade(theme.palette.common.white, 0.25),
},
marginLeft: 0,
width: "100%",
[theme.breakpoints.up("sm")]: {
marginLeft: theme.spacing(1),
width: "auto"
}
},
searchIcon: {
padding: theme.spacing(0, 2),
height: "100%",
position: "absolute",
pointerEvents: "none",
display: "flex",
alignItems: "center",
justifyContent: "center"
},
inputRoot: {
color: "inherit"
},
inputInput: {
padding: theme.spacing(1, 1, 1, 0),
paddingLeft: `calc(1em + ${theme.spacing(4)}px)`,
transition: theme.transitions.create("width"),
width: "100%",
[theme.breakpoints.up("sm")]: {
width: "12ch",
"&:focus": {
width: "20ch"
}
}
}
})
)
const Header: React.FC = () => {
const classes = useStyles()
return (
<div className={classes.header}>
<AppBar position="static">
<Toolbar>
<IconButton
edge="start"
className={classes.menuButton}
color="inherit"
aria-label="open drawer"
>
<MenuIcon />
</IconButton>
<Typography className={classes.title} variant="h6" noWrap>
Sample Shop
</Typography>
<div className={classes.search}>
<div className={classes.searchIcon}>
<SearchIcon />
</div>
<InputBase
placeholder="Search…"
classes={{
root: classes.inputRoot,
input: classes.inputInput,
}}
inputProps={{ "aria-label": "search" }}
/>
</div>
</Toolbar>
</AppBar>
</div>
)
}
export default Header
product
import React from "react"
import { makeStyles } from "@material-ui/core/styles"
import Card from "@material-ui/core/Card"
import CardHeader from "@material-ui/core/CardHeader"
import CardMedia from "@material-ui/core/CardMedia"
import CardContent from "@material-ui/core/CardContent"
import Typography from "@material-ui/core/Typography"
import ShoppingCartOutlinedIcon from "@material-ui/icons/ShoppingCartOutlined"
import Button from "@material-ui/core/Button"
import Box from "@material-ui/core/Box"
const useStyles = makeStyles(() => ({
card: {
minWidth: 300,
margin: "1rem",
transition: "all 0.3s",
"&:hover": {
boxShadow:
"1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)",
transform: "translateY(-3px)"
}
},
cardHeader: {
textAlign: "center"
},
cardMedia: {
height: 0,
paddingTop: "56.25%"
},
box: {
padding: "0 1rem 1rem"
},
cartBtn: {
textTransform: "none"
}
}))
interface ProductProps {
handleAddToCart: React.MouseEventHandler<HTMLButtonElement> | undefined
price: number
title: string
description: string
image: string
}
const Product: React.FC<ProductProps> = ({ handleAddToCart, price, title, description, image }) => {
const classes = useStyles()
return (
<Card className={classes.card}>
<CardHeader
title={title}
className={classes.cardHeader}
/>
<CardMedia className={classes.cardMedia} image={image} title={title} />
<CardContent>
<Typography variant="body1" color="inherit" component="p" align="center" gutterBottom>
{description}
</Typography>
<Typography variant="body2" color="textSecondary" component="p" align="center">
¥{price}
</Typography>
</CardContent>
<Box className={classes.box}>
<Button
variant="outlined"
color="primary"
startIcon={<ShoppingCartOutlinedIcon />}
className={classes.cartBtn}
fullWidth
onClick={handleAddToCart}
>
Add to cart
</Button>
</Box>
</Card>
)
}
export default Product
cart
import React from "react"
import CartItem from "./CartItem"
import { makeStyles } from "@material-ui/core/styles"
import { Container } from "@material-ui/core"
import Button from "@material-ui/core/Button"
import ClearOutlinedIcon from "@material-ui/icons/ClearOutlined"
import { CartItem as CartItemType } from "../../interfaces/index"
const useStyles = makeStyles(() => ({
container: {
marginTop: "3rem",
maxWidth: 800,
textAlign: "center"
},
resetBtn: {
textTransform: "none"
}
}))
interface CartProps {
cartItems: CartItemType[]
totalCost: number
setCartItems: Function
}
const Cart: React.FC<CartProps> = ({ cartItems, totalCost, setCartItems }) => {
const classes = useStyles()
// カート内の商品をクリア
const handleResetCart = () => {
setCartItems([])
}
return (
<>
<Container className={classes.container}>
<h2>Your shopping cart</h2>
{ cartItems.length > 0 ? (
<>
{cartItems.map((cartItem: CartItemType) => (
<CartItem
key={cartItem.id}
id={cartItem.id}
title={cartItem.title}
cost={(cartItem.price || 0) * cartItem.quantity}
quantity={cartItem.quantity}
/>
))}
<h4>Total cost: ¥{totalCost.toFixed(2)}</h4>
<Button
type="submit"
variant="outlined"
color="secondary"
startIcon={<ClearOutlinedIcon />}
className={classes.resetBtn}
onClick={handleResetCart}
>
Clear cart
</Button>
</>
) : (
<p>Empty</p>
)}
</Container>
</>
)
}
export default Cart
import React from "react"
import { makeStyles } from "@material-ui/core/styles"
import Grid from "@material-ui/core/Grid"
import Divider from "@material-ui/core/Divider"
const useStyles = makeStyles(() => ({
container: {
flexGrow: 1,
marginTop: 0
},
item: {
textAlign: "center",
display: "table-cell",
verticalAlign: "middle"
},
divider: {
marginTop: "0.5rem"
}
}))
interface CartItemProps {
id: number | undefined
title: string | undefined
quantity: number
cost: number | undefined
}
const CartItem: React.FC<CartItemProps> = ({ title, quantity, cost }) => {
const classes = useStyles()
return (
<>
<Grid container spacing={3} justify="center" className={classes.container}>
<Grid item xs={4} className={classes.item}>
{title}
</Grid>
<Grid item xs={4} className={classes.item}>
{quantity}
</Grid>
<Grid item xs={4} className={classes.item}>
¥{cost}
</Grid>
</Grid>
<Divider className={classes.divider} />
</>
)
}
export default CartItem
checkout
import React, { useState } from "react"
import { CardElement, useStripe, useElements, } from "@stripe/react-stripe-js"
import { makeStyles } from "@material-ui/core/styles"
import { Container } from "@material-ui/core"
import Button from "@material-ui/core/Button"
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp"
import Dialog from "@material-ui/core/Dialog"
import DialogActions from "@material-ui/core/DialogActions"
import DialogContent from "@material-ui/core/DialogContent"
import DialogContentText from "@material-ui/core/DialogContentText"
import DialogTitle from "@material-ui/core/DialogTitle"
import Slide from "@material-ui/core/Slide"
import { TransitionProps } from "@material-ui/core/transitions"
const Transition = React.forwardRef(function Transition(
props: TransitionProps & { children?: React.ReactElement<any, any> },
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />
})
const useStyles = makeStyles(() => ({
container: {
margin: "5rem 0 3rem",
textAlign: "center"
},
agreeBtn: {
textTransform: "none"
},
submitBtn: {
textTransform: "none"
}
}))
interface CompletionDialogProps {
open: boolean
title: string
text: string
handleClose: VoidFunction
}
// 決済処理後に表示するダイアログ(成功時も失敗時も)
const CompletionDialog = ({ open, title, text, handleClose }: CompletionDialogProps) => {
const classes = useStyles()
return (
<div>
<Dialog
open={open}
TransitionComponent={Transition}
keepMounted
onClose={handleClose}
>
<DialogTitle>
{title}
</DialogTitle>
<DialogContent>
<DialogContentText>
{text}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
color="primary"
onClick={handleClose}
className={classes.agreeBtn}
>
Agree
</Button>
</DialogActions>
</Dialog>
</div>
)
}
interface CheckoutFormProps {
totalCost: number
}
const CheckoutForm: React.FC<CheckoutFormProps> = ({ totalCost}) => {
const classes = useStyles()
const [status, setStatus] = useState<"default" | "submitting" | "succeeded" | "failed">("default")
const [open, setOpen] = useState<boolean>(false)
const handleOpen = () => {
setOpen(true)
}
const handleClose = () => {
setOpen(false)
}
const stripe = useStripe()
const elements = useElements()
// Stripeの決済方法はCharges APIとPayment Intents APIの2種類があるのでどちらかを選択
// Charges: シンプルな一方、SCA対応していないためセキュリティ度はやや低い
// Payment Intents: Chargesに比べて支払いまでのプロセスが増える一方、SCA対応しているためセキュリティ度は高い
// 参照: https://stripe.com/docs/payments/payment-intents/migration/charges#understanding-the-stripe-payment-apis
// 必ずしもどちらが良いかというわけではなく上手く使い分けるのが望ましいらしいが、今後の新機能はPayment Intents APIのみに追加されるとの事
// なので今回はPayment Intents API方式で実装
const handleSubmit = async (e: any) => {
e.preventDefault()
if (!stripe || !elements) return
setStatus("submitting")
try {
const res = await fetch("/.netlify/functions/paymentIntents", {
method: "POST",
body: JSON.stringify({
amount: totalCost
}),
headers: { "Content-Type": "application/json" }
})
const data = await res.json()
const client_secret = data.client_secret // レスポンス内からclient_secretを取得
const card = elements?.getElement(CardElement) || { "token": ""} // クレジットカード情報を取得
// 決済処理
const result = await stripe?.confirmCardPayment(client_secret, {
payment_method: {
card: card,
billing_details: {
name: "Test User"
// 他にもaddress(住所)、email(メールアドレス)、phone(電話番号)などが付与可能
}
}
})
if (result?.paymentIntent?.status === "succeeded") {
setStatus("succeeded")
} else {
throw new Error("Network response was not ok.")
}
} catch (err) {
setStatus("failed")
}
handleOpen()
}
return (
<Container className={classes.container}>
<form onSubmit={handleSubmit}>
<h4>Would you like to complete the purchase?</h4>
<CardElement />
<Button
type="submit"
variant="outlined"
disabled={status === "submitting"} // submitting中は再度ボタンを押せないように
startIcon={<KeyboardArrowUpIcon />}
className={classes.submitBtn}
>
{status === "submitting" ? "Submitting" : "Submit"}
</Button>
</form>
<CompletionDialog
open={open}
title={status === "succeeded" ? "Succeeded!" : "Failed"}
text={status === "succeeded" ? "Thank you, your payment was successful!" : "Sorry, something went wrong. Please check your credit card information again."}
handleClose={handleClose}
/>
</Container>
)
}
export default CheckoutForm
サーバー
最後にサーバー側の実装に入ります。
lambda関数を作成
import Stripe from "stripe"
const secretKey = process.env.STRIPE_SECRET_KEY
const stripe = new Stripe(secretKey, { apiVersion: "2020-08-27" })
exports.handler = async (event, context, callback) => {
// POSTメソッド以外は拒否
if (event.httpMethod !== "POST") {
return callback(null, { statusCode: 405, body: "Method Not Allowed" })
}
const data = JSON.parse(event.body)
// 1円に満たない金額だった場合はエラー
if (parseInt(data.amount) < 1) {
return callback(null, {
statusCode: 400,
body: JSON.stringify({
message: "Some required fields were not supplied."
})
})
}
await stripe.paymentIntents.create({
amount: parseInt(data.amount),
currency: "jpy",
description: "Sample Shop",
metadata: { integration_check: "accept_a_payment" }
})
.then(({ client_secret }) => {
return callback(null, {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type"
},
body: JSON.stringify({
client_secret: client_secret // 取引を確認するためのclient_secretを返す
})
})
})
.catch((err) => {
return callback(null, {
statusCode: 400,
body: JSON.stringify({
message: `Error: ${err.message}`
})
})
})
}
環境変数をセット
const Dotenv = require("dotenv-webpack")
module.exports = {
plugins: [new Dotenv()]
}
REACT_APP_STRIPE_PUBLIC_KEY=<Stripeの公開可能キー>
STRIPE_SECRET_KEY=<Stripeのシークレットキー>
プロキシの設定
「/.netlify/functions/」へのリクエストを「http://localhost:9000」へ代替し、CORSを有効にします。
const { createProxyMiddleware } = require("http-proxy-middleware")
module.exports = function(app) {
app.use(
"/.netlify/functions/",
createProxyMiddleware({
target: "http://localhost:9000",
changeOrigin: true
})
)
}
import { createProxyMiddleware } from "http-proxy-middleware"
module.exports = function(app: any) {
app.use(
"/.netlify/functions/",
createProxyMiddleware({
target: "http://localhost:9000",
changeOrigin: true
})
)
}
Netfily Functionsの設定
Netlify Functionsに関しては下記の記事が参考になるので読んでみてください。
参照: https://qiita.com/Sr_Bangs/items/7867853f5e71bd4ada56
[build]
Command = "npm run build"
Functions = "lambda"
Publish = "build"
動作確認
クライアント、サーバーともに準備ができたのでいよいよ動作確認。
npm-scriptsを編集
「./package.json」内の「scripts」を次のように書き換えます。
"scripts": {
"start": "run-p start:**",
"start:app": "react-scripts start",
"start:lambda": "netlify-lambda serve src/lambda --config ./webpack.functions.js",
"build": "run-p build:**",
"build:app": "react-scripts build",
"build:lambda": "netlify-lambda build src/lambda",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
これをやっておくと、今後は
$ npm run start
を一発叩くだけでクライアント(localhost:3000)とサーバー(localhost:9000)が同時に立ち上がるようになるので便利です。
テスト環境の場合、以下のダミークレジットカード情報が使用可能なので色々試してみてください。
Visa
- カード番号: 4242424242424242
- セキュリティコード: 任意の数字3桁
- 日付: 将来の日付であれば何でもOK
- 郵便番号: 任意の数字5桁
Mastercard
- カード番号: 5555555555554444
- セキュリティコード: 任意の数字3桁
- 日付: 将来の日付であれば何でもOK
- 郵便番号: 任意の数字5桁
JCB
- カード番号: 3566002020360505
- セキュリティコード: 任意の数字3桁
- 日付: 将来の日付であれば何でもOK
- 郵便番号: 任意の数字5桁
無事、「Succeeded!」というダイアログが表示されれば成功です。
今回は簡潔化のためにダイアログを表示するだけに留めていますが、実際の運用においては結果に合わせてページを遷移するなどの工夫を加えてみると良いかもしれません。
念のため、Stripeダッシュボードの「支払い」から確認しておきましょう。
デプロイ
Netlifyのアカウントをお持ちでない場合は作成しておいてください。(GitHubアカウントでの作成を推奨。)
ログインしてダッシュボードに入ると、「New site from Git」というボタンがあるのでクリック。
するとデプロイのためのリポジトリを選択する画面になるので、該当のリポジトリを選択しましょう。(事前に先ほど作成したコードをGitHubにアップロードしておいてください。)
あとはデプロイ時の設定を上記画像のように行い、「Deploy site」をクリックすればOKです。数分程度でビルド〜デプロイまで完了するはず。
あとがき
以上、簡易的なものではありますが、ECサイトもどきを作ってみました。
サーバー有りでStripeを利用しようと思った場合、Netlify Functionsを使えば費用を大幅に抑える事ができそうです。ちょっとしたペラページで運用したい場合などは試してみてはいかがでしょうか。