diff --git a/frontend/app/components/cart/CartCard.tsx b/frontend/app/components/cart/CartCard.tsx
new file mode 100644
index 00000000..6c9a7503
--- /dev/null
+++ b/frontend/app/components/cart/CartCard.tsx
@@ -0,0 +1,29 @@
+import { Card } from '@mantine/core';
+import { CartProps } from '~/stores/cartAtom';
+import BookCardThumbnail from '../books/BookCardThumbnail';
+import CartCardHeader from './CartCardHeader';
+
+interface CartCardProps {
+ book: CartProps;
+}
+
+const CartCard = ({ book }: CartCardProps) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CartCard;
diff --git a/frontend/app/components/cart/CartCardHeader.tsx b/frontend/app/components/cart/CartCardHeader.tsx
new file mode 100644
index 00000000..9615eb46
--- /dev/null
+++ b/frontend/app/components/cart/CartCardHeader.tsx
@@ -0,0 +1,81 @@
+import { Checkbox, Group } from '@mantine/core';
+import { useAtom } from 'jotai';
+import type { SelectedBookProps } from '~/stores/bookAtom';
+import { cartAtom, selectedCartBooksAtom } from '~/stores/cartAtom';
+import CartCardNumberInput from './CartCardNumberInput';
+
+interface CartCardHeaderProps {
+ id: number;
+ stock: number;
+ title: string;
+ volume: number;
+ thumbnail?: string;
+}
+
+const CartCardHeader = ({
+ id,
+ stock,
+ title,
+ volume,
+ thumbnail,
+}: CartCardHeaderProps) => {
+ const [cart, setCart] = useAtom(cartAtom);
+ const [selectedCartBook, setSelectedCartBook] = useAtom(
+ selectedCartBooksAtom,
+ );
+
+ // 該当する本のvolumeを変更する
+ const handleChangeVolume = (id: number, value: number) => {
+ setCart(
+ cart.map((element) => {
+ if (element.id === id) {
+ return {
+ id: element.id,
+ stock: element.stock,
+ title: element.title,
+ thumbnail: element.thumbnail,
+ volume: value,
+ };
+ }
+ return element;
+ }),
+ );
+ };
+
+ // 選択されている本のIDと表示する本のIDを比較する関数
+ const selectedCheck = (element: SelectedBookProps) => element.id === id;
+
+ const selectedBookAdd = () => {
+ // チェックボックスの状態が変化した時に
+ if (selectedCartBook.some(selectedCheck)) {
+ // すでに選択されていた場合は選択を外す
+ setSelectedCartBook(
+ selectedCartBook.filter((element) => element.id !== id),
+ );
+ } else {
+ // 選択されていなかった場合は選択する
+ setSelectedCartBook([
+ ...selectedCartBook,
+ { id, stock, title, thumbnail, volume: 1 },
+ ]);
+ }
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+export default CartCardHeader;
diff --git a/frontend/app/components/cart/CartCardNumberInput.tsx b/frontend/app/components/cart/CartCardNumberInput.tsx
new file mode 100644
index 00000000..4434ca9e
--- /dev/null
+++ b/frontend/app/components/cart/CartCardNumberInput.tsx
@@ -0,0 +1,40 @@
+import { Group, Select, Text } from '@mantine/core';
+import { range } from '@mantine/hooks';
+
+interface CartCardHeaderBadgeProps {
+ id: number;
+ stock: number;
+ volume: number;
+ handleChangeVolume: (id: number, value: number) => void;
+}
+
+const CartCardNumberInput = ({
+ id,
+ stock,
+ volume,
+ handleChangeVolume,
+}: CartCardHeaderBadgeProps) => {
+ const stockList = range(0, stock);
+ const dataList = stock >= volume ? stockList : [...stockList, volume];
+ const strList = dataList.map((data) => data.toString());
+
+ const handleOnChange = (volume: string | null) => {
+ if (!volume) return;
+ const numVolume = Number(volume);
+ handleChangeVolume(id, numVolume);
+ };
+ return (
+
+ 冊数
+
+ );
+};
+
+export default CartCardNumberInput;
diff --git a/frontend/app/components/cart/CartCards.tsx b/frontend/app/components/cart/CartCards.tsx
new file mode 100644
index 00000000..0072d183
--- /dev/null
+++ b/frontend/app/components/cart/CartCards.tsx
@@ -0,0 +1,31 @@
+import { ScrollArea, SimpleGrid } from '@mantine/core';
+import { useAtom } from 'jotai';
+import { cartAtom } from '~/stores/cartAtom';
+import CartCard from './CartCard';
+
+const CartCards = () => {
+ const [cart] = useAtom(cartAtom);
+
+ return (
+
+
+ {cart.map((book, index) => (
+
+ ))}
+
+
+ );
+};
+
+export default CartCards;
diff --git a/frontend/app/components/cart/CartListComponent.tsx b/frontend/app/components/cart/CartListComponent.tsx
new file mode 100644
index 00000000..0fc0bf5c
--- /dev/null
+++ b/frontend/app/components/cart/CartListComponent.tsx
@@ -0,0 +1,30 @@
+import { Stack } from '@mantine/core';
+import CartTitle from './CartTitle';
+import CartCards from './CartCards';
+import CartSelectedDialog from './CartSelectedDialog';
+import { useAtom } from 'jotai';
+import { cartAtom } from '~/stores/cartAtom';
+import NoCartComponent from './NoCartComponent';
+
+interface CartListComponentProps {
+ handleLoanPatch: () => void;
+}
+
+const CartListComponent = ({ handleLoanPatch }: CartListComponentProps) => {
+ const [cart] = useAtom(cartAtom);
+ return (
+
+
+ {cart.length == 0 ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ );
+};
+
+export default CartListComponent;
diff --git a/frontend/app/components/cart/CartSelectedDialog.tsx b/frontend/app/components/cart/CartSelectedDialog.tsx
new file mode 100644
index 00000000..e56ab386
--- /dev/null
+++ b/frontend/app/components/cart/CartSelectedDialog.tsx
@@ -0,0 +1,54 @@
+import { Button, Dialog, Stack } from '@mantine/core';
+import { useAtom } from 'jotai';
+import { cartAtom, selectedCartBooksAtom } from '~/stores/cartAtom';
+import { removeBooksFromCart } from '~/utils/cart';
+
+interface CartSelectedDialogProps {
+ handleLoanPatch: () => void;
+}
+
+const CartSelectedDialog = ({ handleLoanPatch }: CartSelectedDialogProps) => {
+ const [selectedCartBook, setSelectedCartBook] = useAtom(
+ selectedCartBooksAtom,
+ );
+ const [cart, setCart] = useAtom(cartAtom);
+
+ return (
+
+ );
+ return
CartSelectedDialog
;
+};
+
+export default CartSelectedDialog;
diff --git a/frontend/app/components/cart/CartTitle.tsx b/frontend/app/components/cart/CartTitle.tsx
new file mode 100644
index 00000000..b2eeaf31
--- /dev/null
+++ b/frontend/app/components/cart/CartTitle.tsx
@@ -0,0 +1,15 @@
+import { Center, Group, Title } from '@mantine/core';
+import { FaShoppingCart } from 'react-icons/fa';
+
+const CartTitle = () => {
+ return (
+
+
+
+ 貸出カート
+
+
+ );
+};
+
+export default CartTitle;
diff --git a/frontend/app/components/cart/NoCartComponent.tsx b/frontend/app/components/cart/NoCartComponent.tsx
new file mode 100644
index 00000000..acb76461
--- /dev/null
+++ b/frontend/app/components/cart/NoCartComponent.tsx
@@ -0,0 +1,19 @@
+import { Anchor, Blockquote, Center } from '@mantine/core';
+import { FaInfoCircle } from 'react-icons/fa';
+
+interface NoCartComponentProps {
+ color?: string;
+}
+
+const NoCartComponent = ({ color }: NoCartComponentProps) => {
+ return (
+
+ } mt="xl">
+ カートに本が入っていません。蔵書一覧は
+ こちらから。
+
+
+ );
+};
+
+export default NoCartComponent;
diff --git a/frontend/app/routes/home.cart/route.tsx b/frontend/app/routes/home.cart/route.tsx
new file mode 100644
index 00000000..bd0b52c7
--- /dev/null
+++ b/frontend/app/routes/home.cart/route.tsx
@@ -0,0 +1,106 @@
+import { ActionFunctionArgs, redirect } from '@remix-run/cloudflare';
+import { useSubmit } from '@remix-run/react';
+import { upsertLoans } from 'client/client';
+import { UpsertLoansBodyItem } from 'client/client.schemas';
+import { useAtom } from 'jotai';
+import { useEffect } from 'react';
+import CartListComponent from '~/components/cart/CartListComponent';
+import { commitSession, getSession } from '~/services/session.server';
+import { cartAtom, selectedCartBooksAtom } from '~/stores/cartAtom';
+import type { CartProps } from '~/stores/cartAtom';
+import { removeBooksFromCart } from '~/utils/cart';
+import { errorNotification } from '~/utils/notification';
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ const session = await getSession(request.headers.get('Cookie'));
+
+ // 未ログインの場合
+ const userId = session.get('userId');
+ if (!userId) {
+ return redirect('/login', {
+ headers: {
+ 'Set-Cookie': await commitSession(session),
+ },
+ });
+ }
+
+ const cookieHeader = [
+ `__Secure-user_id=${session.get('userId')};`,
+ `__Secure-session_token=${session.get('sessionToken')}`,
+ ].join('; ');
+
+ // prettier-ignore
+ const requestBody = await request.json<{ selectedCartBook: CartProps[] }>();
+ const selectedCartBook = requestBody.selectedCartBook;
+ const upsertBody: UpsertLoansBodyItem[] = selectedCartBook.map((book) => {
+ return {
+ bookId: book.id,
+ userId: Number(userId),
+ volume: book.volume,
+ };
+ });
+
+ const response = await upsertLoans(upsertBody, {
+ headers: { Cookie: cookieHeader },
+ });
+
+ switch (response.status) {
+ case 200:
+ session.flash('success', '本を借りました');
+ return redirect('/home', {
+ headers: {
+ 'Set-Cookie': await commitSession(session),
+ },
+ });
+
+ case 401:
+ session.flash('error', 'ログインしてください');
+ return redirect('/login', {
+ headers: {
+ 'Set-Cookie': await commitSession(session),
+ },
+ });
+
+ default:
+ session.flash('error', '本を借りられませんでした');
+ return redirect('/home/cart', {
+ headers: {
+ 'Set-Cookie': await commitSession(session),
+ },
+ });
+ }
+};
+
+const CartListPage = () => {
+ const [selectedCartBook, setSelectedCartBook] = useAtom(
+ selectedCartBooksAtom,
+ );
+ const [cart, setCart] = useAtom(cartAtom);
+ const submit = useSubmit();
+
+ useEffect(() => {
+ // 選択中の書籍をリセットする
+ setSelectedCartBook([]);
+ }, []);
+
+ // volumeがstockを超えていないかチェックする
+ const checkStock = (element: CartProps) => element.stock < element.volume;
+
+ const handleLoanPatch = () => {
+ if (selectedCartBook.length > 0 && !selectedCartBook.some(checkStock)) {
+ submit(JSON.stringify({ selectedCartBook: selectedCartBook }), {
+ action: '/home/cart',
+ method: 'PATCH',
+ encType: 'application/json',
+ });
+ setCart(removeBooksFromCart(cart, selectedCartBook));
+ setSelectedCartBook([]);
+ } else {
+ errorNotification('在庫が足りません');
+ }
+ };
+
+ return ;
+};
+
+export default CartListPage;
diff --git a/frontend/app/stores/cartAtom.ts b/frontend/app/stores/cartAtom.ts
index c4769c90..8a245bcb 100644
--- a/frontend/app/stores/cartAtom.ts
+++ b/frontend/app/stores/cartAtom.ts
@@ -1,11 +1,16 @@
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
+import { SelectedBookProps } from './bookAtom';
+import { atom } from 'jotai';
const storage = createJSONStorage(() => sessionStorage);
-export interface CartProps {
- id: number;
- stock: number;
+export interface CartProps extends SelectedBookProps {
+ volume: number;
}
// カートの中身を管理するAtom
// 生存時間: セッションストレージ(タブが閉じられるまで)
export const cartAtom = atomWithStorage('cart', [], storage);
+
+// 選択されたカートの本を管理するAtom
+// 生存時間: DOM(ページをリロードするまで)
+export const selectedCartBooksAtom = atom([]);
diff --git a/frontend/app/utils/cart.ts b/frontend/app/utils/cart.ts
new file mode 100644
index 00000000..d8caf7ed
--- /dev/null
+++ b/frontend/app/utils/cart.ts
@@ -0,0 +1,28 @@
+import { SelectedBookProps } from '~/stores/bookAtom';
+import { CartProps } from '~/stores/cartAtom';
+
+export const addBooksToCart = (
+ cart: CartProps[],
+ books: SelectedBookProps[],
+) => {
+ for (const book of books) {
+ const index = cart.findIndex((cartBook) => cartBook.id === book.id);
+ if (index !== -1) {
+ cart[index].volume += 1;
+ } else {
+ cart.push({
+ id: book.id,
+ stock: book.stock,
+ title: book.title,
+ thumbnail: book.thumbnail,
+ volume: 1,
+ });
+ }
+ }
+ return cart;
+};
+
+export const removeBooksFromCart = (cart: CartProps[], books: CartProps[]) => {
+ const idList = books.map((book) => book.id);
+ return cart.filter((cartBook) => !idList.includes(cartBook.id));
+};