本記事 Cocone Advent Calendar 2023
12日目の記事となります。
11日目の【スターウォーズ】帝国軍よりバトンを受け取り、記事を書かせていただきます。
作るもの
自己紹介
こんにちは!
ココネでビリングシステム(決済システム)の開発/保守運用を担当していますnkです。
担当領域は、基本的にサーバサイドエンジニアに該当するのですが、ゲームに実装される課金部分の処理を主に担当しています。
各ゲームの事業部に課金関連の処理のapiを提供したり、集計システムで集計をして経理に月次報告をするなど、業務は多岐に渡ります。web決済や社内ツールのUIの設計実装など、フロント部分も少し触ることがあります。
記事の内容
世間でも今すごい人気だと思うのですが、ポケモンカードに今年になってすごいハマりました。
友達がやってたのをみて始めたのがきっかけなのですが、とても奥が深いんですよね。
と、いうわけで検索システムみたいなの作ってみたいと思ったわけです。
ポケモンカード一覧画面のデータは、Pokémon TCG APIを利用させていただきました。
ポケモンカードのデータをapiを叩くことでjson形式で取得できます。
無料登録とかすればより多くの機能を使えるそうなのですが、しなくても充分でした。
現在のカードの相場(ユーロ)とかも分かるようなので、折角なので、ショッピングサイト(風)にしてみました。
使用したもの
- React.js
- 人気のJavaScript ライブラリ
- Ant Design
- UIを作成する際に役立つ多数のコンポーネントを備えたUIフレームワーク
ディレクトリ構成
src
├── API
│ └── index.js
├── App.css
├── App.js
├── CartContext.js
├── Components
├── Header
│ └── index.js
├── PageContent
│ └── index.js
├── Products
│ └── index.js
└── Routes
└── index.js
Pokémon TCG APIを非同期で呼び出す
ソースコードを表示 (API/index.js)
let controller;
export const getProductsByCategory = (page, supertype, name) => {
if (controller) {
controller.abort();
}
controller = new AbortController();
const signal = controller.signal;
return fetch(
`https://api.pokemontcg.io/v2/cards?page=${page}&pageSize=40&q=supertype:${supertype} name:${name} (regulationMark:E OR regulationMark:F OR regulationMark:G)`,
{ signal }
).then(
(res) => res.json()
).catch(e => {
//AbortError
});
};
export const getCart = (cartQueryStr) => {
return fetch(`https://api.pokemontcg.io/v2/cards?q=${cartQueryStr}`).then((res) => res.json());
};
- getProductsByCategory
- ${page} : ページ数を指定
- ${supertype} : カードのtypeを指定(* or Pokémon or Trainer or Energy)
- ${name} : 名前を指定
- regulationMark:E OR regulationMark:F OR regulationMark:G : レギュレーションを現在使われているE,F,Gのみに絞る
AbortControllerを使って、前のリクエストを中断しています。検索で文字を3文字打つと3回getProductsByCategory関数が呼び出されます。非同期処理は順序を担保しないので、2文字目を打った時に1文字目で呼ばれたリクエストを中断し、3文字目を打った時に2文字目で呼ばれたリクエストを中断します。そうすることで、3文字入力した際の検索結果のみを取得できます。
- getCart
- ${cartQueryStr} : ショッピングカートに入れたカードのidの連結文字列(ex. id:a OR id:b)
ショッピングカートの情報は通常DBで管理するのが一般的だと思いますが、Contextでグローバルに値を保持する形にしています。ショッピングカートボタンが押されると、getCart関数を呼び出してapiを通じてショッピングカートの情報を表示しています。
ショッピングカートに入れたカードidと注文数をCartContextで管理
ソースコードを表示 (CartContext.js)
import { createContext, useState } from "react";
export const CartContext = createContext({
items: [],
getCartQueryStr: () => { },
getTotalQuantity: () => { },
getProductQuantity: () => { },
transformedCartItems: () => { },
addOneToCart: () => { },
changeItemQuantity: () => { },
deleteFromCart: () => { },
deleteAllFromCart: () => { },
});
export function CartProvider({ children }) {
const [cartProducts, setCartProducts] = useState([]);
// [ { id: 1 , quantity: 3 }, { id: 2, quantity: 1 } ]
function getCartQueryStr() {
// id:a OR id:b
return cartProducts.map(item => `id:${item.id}`).join(' OR ');
}
function getTotalQuantity() {
return cartProducts.reduce((sum, item) => sum + item.quantity, 0);
}
function getProductQuantity(id) {
const quantity = cartProducts.find(product => product.id === id)?.quantity;
if (quantity === undefined) {
return 0;
}
return quantity;
}
function transformedCartItems(cartItems) {
return cartItems.map(item => {
const price = item.cardmarket.prices.averageSellPrice;
const quantity = getProductQuantity(item.id);
return {
key: item.id,
image: item.images.large,
price: price,
quantity: quantity,
total: price * quantity
};
});
}
function addOneToCart(id) {
const quantity = getProductQuantity(id);
if (quantity === 0) {
setCartProducts(
[
...cartProducts,
{
id: id,
quantity: 1
}
]
)
} else {
setCartProducts(
cartProducts.map(
product =>
product.id === id
? { ...product, quantity: product.quantity + 1 }
: product
)
)
}
}
function changeItemQuantity(id, value) {
setCartProducts(
cartProducts.map(
product =>
product.id === id
? { ...product, quantity: value }
: product
)
)
}
function deleteFromCart(id) {
setCartProducts(
cartProducts =>
cartProducts.filter(currentProduct => {
return currentProduct.id !== id;
})
)
}
function deleteAllFromCart() {
setCartProducts([])
}
const contextValue = {
items: cartProducts,
getCartQueryStr,
getTotalQuantity,
getProductQuantity,
transformedCartItems,
addOneToCart,
changeItemQuantity,
deleteFromCart,
deleteAllFromCart,
}
return (
<CartContext.Provider value={contextValue}>
{children}
</CartContext.Provider>
)
}
export default CartProvider;
ショッピングカートに関する処理をグローバルに保持しています。Headerではカートに入れた商品数の表示や、カートの中身を編集する処理を、Productsのカード一覧表示部分では、ショッピングカートに選択した商品を入れる際に用いています。
カード一覧の取得
カード一覧を検索する処理は3つあり、以下のように分かれています。
-
Header
- 画面上部のtypeを押下し、リストからカードのタイプを指定(* or Pokémon or Trainer or Energy)
- urlに選択した上記パラメータを含めることで、apiを叩きカード一覧を取得します。
- React Routerのルーティングで実現しています。
- 画面上部のtypeを押下し、リストからカードのタイプを指定(* or Pokémon or Trainer or Energy)
-
Products
- 画面下部のページボタンを押下
- Ant Designのページネーションにより、ページを選択しapiを叩きカード一覧を取得します。
- 検索ボックスに文字入力
- Ant DesignのインプットのonChangeイベントによりapiを叩きカード一覧を取得します。
- 画面下部のページボタンを押下
ポケモンカードショッピングサイト全体設計
- ポケモンカードショッピングサイトの全体的な構築は以下のソースコードからなります。
ソースコードを表示 (Components/Routes/index.js)
import { Routes, Route } from "react-router-dom";
import Products from "../../Components/Products";
function AppRoutes() {
return (
<Routes>
<Route path="/" element={<Products />}></Route>
<Route path="/:supertype" element={<Products />}></Route>
</Routes>
);
}
export default AppRoutes;
ソースコードを表示 (Components/PageContent/index.js)
import AppRoutes from "../Routes";
function PageContent() {
return (
<div className="pageContent">
<AppRoutes />
</div>
);
}
export default PageContent;
ソースコードを表示 (App.js)
import { BrowserRouter } from "react-router-dom";
import "./App.css";
import AppHeader from "./Components/Header";
import PageContent from "./Components/PageContent";
import CartProvider from './CartContext';
function App() {
return (
<div className="App">
<CartProvider>
<BrowserRouter>
<AppHeader />
<PageContent />
</BrowserRouter>
</CartProvider>
</div>
);
}
export default App;
ソースコードを表示 (App.css)
.App{
display: flex;
flex-direction: column;
width: 1000px;
margin: auto;
}
.appHeader{
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 1px 2px 2px #00000033;
}
.soppingCartIcon{
font-size: 30px;
margin-right: 16px;
cursor: pointer;
}
.productsContainer{
padding: 8px;
text-align: center;
}
.paginationContainer{
padding: 12px;
margin: auto;
}
.appMenu{
border: none;
}
.pageContent{
display: flex;
flex: 1;
overflow-y: auto;
margin-top: 2px;
}
.appFooter{
height: 60px;
background-color: black;
display: flex;
justify-content: space-evenly;
align-items: center;
}
.itemCard{
margin: 8px;
}
ポケモンカードショッピングサイト詳細処理
- ポケモンカードショッピングサイトの詳細な処理はHeaderとProductsからなります。
ソースコードを表示 (Components/Header/index.js)
import { HomeFilled, ShoppingCartOutlined, DeleteOutlined } from "@ant-design/icons";
import {
Badge,
Button,
Drawer,
InputNumber,
Menu,
message,
Table,
Image,
Typography,
} from "antd";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getCart } from "../../API";
import { CartContext } from '../../CartContext';
import { useContext } from 'react';
const { Text } = Typography;
function AppHeader() {
const navigate = useNavigate();
const onMenuClick = (item) => {
navigate(`/${item.key}`);
};
return (
<div className="appHeader">
<Menu
className="appMenu"
onClick={onMenuClick}
mode="horizontal"
items={[
{
label: <HomeFilled />,
key: "",
},
{
label: "type",
key: "supertype",
children: [
{
label: "AllCards",
key: "*",
},
{
label: "Pokémon",
key: "Pokémon",
},
{
label: "Trainers",
key: "Trainer",
},
{
label: "Energy",
key: "Energy",
},
],
},
]}
/>
<Typography.Title>Pokemon Card Store</Typography.Title>
<AppCart />
</div>
);
}
function AppCart() {
const [cartDrawerOpen, setCartDrawerOpen] = useState(false);
const [cartItems, setCartItems] = useState([]);
const [totalQuantity, setTotalQuantity] = useState(0);
const cart = useContext(CartContext);
useEffect(() => {
setTotalQuantity(cart.getTotalQuantity);
}, [cart]);
const onConfirmOrder = (values) => {
console.log({ values });
setCartDrawerOpen(false);
cart.deleteAllFromCart();
setCartItems([]);
message.success("Your order has been placed successfully.");
};
return (
<div>
<Badge
onClick={() => {
if (!totalQuantity) return;
getCart(cart.getCartQueryStr()).then((res) => {
setCartItems(cart.transformedCartItems(res.data));
});
setCartDrawerOpen(true);
}}
count={totalQuantity}
className="soppingCartIcon"
>
<ShoppingCartOutlined />
</Badge>
<Drawer
open={cartDrawerOpen}
onClose={() => {
setCartDrawerOpen(false);
}}
title="Your Cart"
contentWrapperStyle={{ width: 500 }}
>
<Table
pagination={false}
columns={[
{
title: 'Card',
dataIndex: '',
render: (record) => (
<Image src={record.image} />
),
},
{
title: "Price",
dataIndex: "price",
render: (value) => {
return <span>€{value}</span>;
},
},
{
title: "Quantity",
dataIndex: "quantity",
render: (value, record) => {
return (
<InputNumber
min={0}
value={value}
onChange={(value) => {
setCartItems((pre) =>
pre.map((cartItem) => {
if (record.key === cartItem.key) {
cart.changeItemQuantity(record.key, value);
cartItem.quantity = value;
cartItem.total = Math.floor(cartItem.price * value * 100) / 100;
}
return cartItem;
})
);
}}
></InputNumber>
);
},
},
{
title: "Total",
dataIndex: "total",
render: (value) => {
return <span>€{Math.floor(value * 100) / 100}</span>;
},
},
{
title: "Actions",
render: (record) => {
return (
<>
<DeleteOutlined
onClick={() => {
setCartItems((pre) =>
pre.filter((cartItem) => {
return record.key !== cartItem.key
})
);
cart.deleteFromCart(record.key);
}}
style={{ color: "red", marginLeft: 12 }}
/>
</>
);
},
},
]}
dataSource={cartItems}
summary={(data) => {
let total = data.reduce((pre, current) => {
return pre + current.total;
}, 0);
total = Math.floor(total * 100) / 100;
return (
<>
<Table.Summary.Row>
<Table.Summary.Cell colSpan={4}>
<Text type="danger">Total: €{total}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
</>
);
}}
/>
<Button
onClick={() => {
onConfirmOrder(cartItems);
}}
type="primary"
>
Checkout Your Cart
</Button>
</Drawer>
</div>
);
}
export default AppHeader;
ソースコードを表示 (Components/Products/index.js)
import {
Button,
Card,
Image,
List,
message,
Input,
Pagination,
Typography,
} from "antd";
import { SearchOutlined } from '@ant-design/icons';
import { useEffect, useState } from "react";
import { getProductsByCategory } from "../../API";
import { useParams } from "react-router-dom";
import { CartContext } from '../../CartContext';
import { useContext } from 'react';
function Products() {
const [loading, setLoading] = useState(false);
const param = useParams();
const cart = useContext(CartContext);
const [items, setItems] = useState([]);
const [totalCount, setTotalCount] = useState(1);
const [page, setPage] = useState(1);
const [superType, setSuperType] = useState("*");
const [name, setName] = useState("");
useEffect(() => {
const superType = param?.supertype ? param.supertype : "*";
setSuperType(superType);
setPage(1);
setName("");
setLoading(true);
(
getProductsByCategory(1, superType, "*")
).then((res) => {
if (!res) return;
setItems(res.data);
setTotalCount(res.totalCount);
setLoading(false);
});
}, [param]);
// Pagination
const fetchProductsByName = (name) => {
setLoading(true);
setPage(1);
setName(name);
(
getProductsByCategory(1, superType, name ? `*${name}*` : "*")
).then((res) => {
if (!res) return;
setItems(res.data);
setTotalCount(res.totalCount);
setLoading(false);
});
}
const fetchProducts = (page) => {
setLoading(true);
setPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
(
getProductsByCategory(page, superType, name ? `*${name}*` : "*")
).then((res) => {
if (!res) return;
setItems(res.data);
setLoading(false);
});
};
return (
<div className="productsContainer">
<div className="paginationContainer">
<Input
addonBefore={<SearchOutlined />}
placeholder="search name"
style={{ width: 500 }}
value={name}
onChange={(event) => fetchProductsByName(event.target.value)}
/>
<Typography.Title level={5}>{totalCount} items</Typography.Title>
</div>
<List
loading={loading}
grid={{ column: 5 }}
renderItem={(product, index) => {
return (
<Card
className="itemCard"
key={index}
cover={
<Image src={product.images.large} />
}
>
Price: €{product.cardmarket.prices.averageSellPrice}
<Button
type="link"
onClick={() => {
message.success(`${product.name} has been added to cart!`);
cart.addOneToCart(product.id);
}}
>
Add to Cart
</Button>
</Card>
);
}}
dataSource={items}
></List>
<div className="paginationContainer">
<Pagination
total={totalCount}
showTotal={(total) => `Total ${total} items`}
pageSize={40}
showSizeChanger={false}
current={page}
onChange={(page) => fetchProducts(page)}
/>
</div>
</div>
);
}
export default Products;
参考資料
Youtubeの素晴らしい動画を参考にさせていただきました!感謝します。
Build e-Commerce Website with React and Ant Design | Simple e-Commerce Website in React JS
https://www.youtube.com/watch?v=maTYhCuHEGw&list=LL&index=4&t=2925s
Build a Shopping Cart With React JS & Stripe
https://www.youtube.com/watch?v=_8M-YVY76O8&list=LL&index=1
まとめ
ココまで読んでいただきありがとうございました!
期限に間に合わせるために急いで作ったので、もっと改良できる部分があると思います。
- Stripeを用いて決済部分を実装する。
- 検索できる項目を増やす。
- ユーザのログイン情報やショッピングカートの情報をDBで管理する。
- ヘッダーと商品表示部分で分かれている検索処理やクラス設計のやり方などを見直す。
クラス名や変数名も若干統一性がない部分があるので、リファクタリングしてもいいかもしれません。
プログラミングを知らないけどポケモンカードは知ってる人、プログラミング始めたばかりの人など色々な人に読んでいただけたら嬉しく思います
そして明日は @rougan さんによる、「ChatGPT Slack botを作った話」となります!
お楽しみに〜