【初学者歓迎🔰】Next.jsでつくるショッピングサイト🛒

# 今回の制作物
https://shopping-cart-pi-wheat.vercel.app/

## Githubのリポジトリ

https://github.com/hato-taka/shopping-cart

## 実装する機能
- 商品一覧を表示(Top画面)
 <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3554678/295500a5-4413-947a-0431-f35c6b63a778.gif" alt="Image from Gyazo" width="300"/>

- ログイン・ログアウト機能(Contact画面)
<img src="https://i.gyazo.com/05753b2335fdeecf3ca1415465ed9063.gif" alt="Image from Gyazo" width="300"/>

- カートに追加(Cart画面)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3554678/60d4aae1-7741-25b9-d401-ba816d6bb4f3.gif" alt="Image from Gyazo" width="300"/>

- 小計＆合計金額の表示(Cart画面)
<img src="https://i.gyazo.com/21d1ee55711dad346e69678d092c73fe.gif" alt="Image from Gyazo" width="300"/>

## 参考にした動画
https://www.youtube.com/watch?v=tEMrD9t85v4&list=PLyv7aFX9dA7K-ic7_tDu2R0zLuENnf5Sv&index=8


## 注意事項
:::note info
開発環境
- OS: windows10
- エディター: VScode
- ターミナル: Bash
- node.jsのバージョン: v18.17.0
:::

:::note info
今回は、初学者向けということで、TypeScriptではなく、JavaScriptで記述しています。
（TypeScriptに慣れている方は是非、TypeScriptで書いてみることをおススメします！）
:::


:::note info
今回はNext.js のロジック部分に焦点を当てるため、デザイン部分の解説は省略しております。
CSSのコードは下記のように折りたたんでいます。
<details><summary>CSSのコード</summary>

```sample.css
h1 {
    color:red;
}
```
</details>
:::



# Step1: vercelでデプロイの環境を整える

① githubでリポジトリの作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3554678/7c903870-8a40-ecc7-c39d-5a7d69db40ac.png)

### ② ターミナルで `npx create-next-app` を実行

設定は以下の通りにしました。
今回は、`src`フォルダを作成します。
```bash
√ What is your project named? ... shopping-cart
√ Would you like to use TypeScript? ... No
√ Would you like to use ESLint? ... Yes
√ Would you like to use Tailwind CSS? ... No
√ Would you like to use `src/` directory? ... Yes
√ Would you like to use App Router? (recommended) » Yes
√ Would you like to customize the default import alias (@/*)? » No
```

### ③Vercelにログイン
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3554678/63b2f2c5-8910-90b9-eff0-e14dae5f9c3e.png)

### ④プロジェクトを新規作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3554678/7b58dc3f-b0e5-5e4c-2c4e-6ad1c2ceceb0.png)

### ⑤Githubと連携
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3554678/01b459aa-f4e4-5a27-68e9-4841766a4e51.png)

### ⑥buildが自動で開始する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3554678/e9d35ea3-bcb0-325a-35c3-48c71c44e7d4.png)

# Step2: App routingの設定

`app`フォルダの直下に、ページごとのフォルダを作成する。
今回は、`cart`ページと`contact`ページを作成する。
各ページの直下に、`page.jsx`を作成する。

```bash: フォルダ構成
└─app
    │  favicon.ico
    │  globals.css
    │  layout.js
    │  page.js
    │
    ├─cart
    │      page.jsx
    │
    └─contact
            page.jsx
```

```cart/page.jsx
import React from "react";

const Cart = () => {
  return <div>Cart</div>;
};

export default Cart;
```

```contact/page.jsx
import React from "react";

const Contact = () => {
  return <div>Contact</div>;
};

export default Contact;
```

`npm run dev` でサーバーを起動し、
http://localhost:3000/cart にアクセスすると、「Cart」と表示されればOK！
（念のため、http://localhost:3000/contact にアクセスして「Contact」と表示されるか確認しましょう）

### navigation の設定

`components`フォルダを作成し、`navbar.jsx`と`navbar.css`を作成する。

```bash: フォルダ構成
└─app
    ├─cart
    ├─components
    │      navbar.css
    │      navbar.jsx  
    └─contact
```

```components/navbar.jsx
import Link from "next/link";
import React from "react";
import "./navbar.css";

function Navbar() {
  return (
    <div className="navbar">
      <div className="links">
        <Link href="/">Shop</Link>
        <Link href="/contact">Contact</Link>
        <Link href="/cart">Cart</Link>
      </div>
    </div>
  );
}

export default Navbar;
```

※CSSのコードは、折りたたんでいます
<details><summary>navbar.css</summary>

```components/navbar.css
.navbar {
  width: 100%;
  height: 80px;
  background-color: rgb(19, 19, 19);
  display: flex;
  justify-content: flex-end;
  align-items: center;
}

.links {
  margin-right: 50px;
  display: flex;
  align-items: center;
}

.links a {
  text-decoration: none;
  color: white;
  margin-left: 20px;
  font-size: 25px;
}
```
</details>

### `src/app/layout.js` に`navbar`コンポーネントを追加する
```diff_jsx: src/app/layout.js
import { Inter } from 'next/font/google'
import './globals.css'
+ import Navbar from './components/navbar'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>
+        <Navbar />
        {children}
      </body>
    </html>
  )
}
```

# Step3 HOME画面の作成

今回は、下記の無料のAPIを使用して、商品データを取得します。

https://fakestoreapi.com/



**※先頭に`use client` をつけることを忘れずに！**
`useEffect`や`useState`などのHooksを利用する場合は、CSR（クライアントサイドレンダリング）をする必要があるため

```src/app/components/shop.jsx
"use client";
import React, { useEffect, useState } from "react";

function Shop() {
  const [items, setItems] = useState([]);

  useEffect(() => {
    fetch("https://fakestoreapi.com/products")
      .then((res) => res.json())
      .then((json) => {
        console.log(json);
        setItems(json);
      });
  }, []);

  return (
    <div className="shop">
      <div className="shopTitle">
        <h1>E-commerce Shop</h1>
      </div>

      <div className="products">
        {items.map((item) => (
          <div key={item.id}>
            <p>{item.id}</p>
            <p>{item.title}</p>
            <p>{item.price}</p>
            <p>{item.category}</p>
            <p>{item.description}</p>
            <img src={item.image} />
          </div>
        ))}
      </div>
    </div>
  );
}

export default Shop;

```

```diff_jsx:src/app/page.js
+ import Shop from './components/shop'

export default function Home() {
  return (
    <main>
+      <Shop />
    </main>
  )
}
```

# Step4 Home画面をスタイリング

Home画面にCSSを当てて、レイアウトを整えていきます。
`shop.jsx`に`className`を追加して、CSSで定義していきます。

```diff_jsx: src/app/components/shop.jsx(差分表示)
"use client";
import React, { useEffect, useState } from "react";
+ import "./shop.css";

function Shop() {
  const [items, setItems] = useState([]);

  useEffect(() => {
    fetch("https://fakestoreapi.com/products")
      .then((res) => res.json())
      .then((json) => {
        console.log(json);
        setItems(json);
      });
  }, []);

  return (
    <div className="shop">
      <div className="shopTitle">
        <h1>E-commerce Shop</h1>
      </div>

      <div className="products">
        {items.map((item) => (
+          <div key={item.id} className="product">
+            <div className="content">
+              <p className="title">
+                {item.id}. {item.title}
+              </p>
+              <img src={item.image} alt={item.title} className="image" />
+              <p className="price">${item.price}</p>
+              <p className="description">{item.description}</p>
+              <p className="category">{item.category}</p>
+            </div>
+            <button className="addToCartBtn">ADD</button>
+          </div>
        ))}
      </div>
    </div>
  );
}

export default Shop;

```

<details><summary>shop.css のコード</summary>

```src/app/components/shop.css
.shopTitle {
  margin-top: 100px;
  text-align: center;
  font-size: 40px;
}

.products {
  width: 100%;
  height: auto;
  margin: auto;
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  place-items: start;
}

.product {
  border-radius: 15px;
  width: 300px;
  height: fit-content;
  margin: 50px auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.content {
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  margin-bottom: 10px;
}

.image {
  max-width: 100%;
  max-height: 300px;
  object-fit: contain;
  transition: 0.3s;
  margin: 50px auto;
}

.title {
  font-size: 1.2em;
  font-weight: bold;
}

.content {
  text-align: center;
}

.description {
  font-style: italic;
  font-size: 0.8em;
  margin: 15px auto;
  text-align: left;
}

.price {
  font-weight: bold;
  font-size: 2em;
}

.category {
  background-color: darkblue;
  color: white;
  border-radius: 100px;
  width: fit-content;
  padding: 2px 10px;
}

.image:hover {
  transform: scale(1.2);
  position: relative;
}

.product:hover {
  transition: 0.3s ease-in;
}

.title:hover,
.image:hover {
  cursor: pointer;
}

.addToCartBtn {
  background-color: transparent;
  border: 2px solid rgb(19, 19, 19);
  min-width: 100px;
  padding-left: 10px;
  padding-right: 10px;
  padding-top: 5px;
  padding-bottom: 5px;
  border-radius: 15px;
  transition: 0.3s;
}

.addToCartBtn:hover {
  background-color: rgb(19, 19, 19);
  color: white;
  cursor: pointer;
}
```
</details>

# Step5 Contactページの作成
Contactページを作成していきます。
ログイン状態によって、画面表示が切り替わるように`useState`を使っていきます。

```src/app/contact/page.jsx
"use client";

import React, { useState } from "react";
import "./contact.css";

const Contact = () => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [isLogin, setIsLogin] = useState(false);

  const handleUsername = (e) => {
    setUsername(e.target.value);
  };

  const handlePassword = (e) => {
    setPassword(e.target.value);
  };

  const handleLogin = () => {
    if (username === "" || password === "") return;
    setIsLogin(true);
  };

  const handleLogout = () => {
    setUsername("");
    setPassword("");
    setIsLogin(false);
  };

  return (
    <div className="wrapper">
      <div className="content">
        {isLogin ? (
          <button className="button" onClick={handleLogout}>
            logout
          </button>
        ) : (
          <>
            <div className="input">
              ユーザー名:
              <input
                type="text"
                name="username"
                id="username"
                className="username"
                onChange={handleUsername}
              />
            </div>
            <div className="input">
              パスワード:
              <input
                type="password"
                name="password"
                id="password"
                className="password"
                onChange={handlePassword}
              />
            </div>
            <button className="button" onClick={handleLogin}>
              login
            </button>
          </>
        )}
      </div>
    </div>
  );
};

export default Contact;

```

<details><summary>contact.css</summary>

```src/app/contact/contact.css
.wrapper {
  display: flex;
  align-items: center;
  height: 90vh;
}

.content {
  width: fit-content;
  margin: auto;
  text-align: center;
}

.input {
  margin: 10px auto;
}

.username,
.password {
  display: inline-block;
  margin-left: 5px;
  padding: 2.5px 5px;
}

.button {
  font-weight: bold;
  padding: 5px;
}
```
</details>

# Step6 Cartページの作成

Cartページを作成していきます。
まずは、レイアウトを整えたいので、画像などのデータは仮で入れています

```src/app/cart/page.jsx
import React from "react";
import "./cart.css";

const Cart = () => {
  return (
    <div className="cart">
      <div>
        <h1>カートの商品</h1>
      </div>
      <div className="cart">
        <div className="cartItem">
          <img src="https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg" />
          <div className="description">
            <p>
              <b>name</b>
            </p>
            <p>price</p>
            <div className="countHandler">
              <button> - </button>
              <input value={"1"} />
              <button> + </button>
            </div>
          </div>
        </div>
      </div>

      <div className="checkout">
        <p>小計: xxxx</p>
        <button>買い物を続ける</button>
        <button>チェックアウト</button>
      </div>
    </div>
  );
};

export default Cart;

```

<details><summary>cart.css</summary>

```src/app/cart/cart.css
.cart {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  margin-top: 30px;
}

.cartItem {
  width: 700px;
  height: 250px;
  display: flex;
  align-items: center;
  box-shadow: 0px 3px 15px rgba(0, 0, 0, 0.2);
  border-radius: 25px;
  margin: 30px;
  padding: 20px;
}

.cartItem img {
  width: 200px;
  max-height: 90%;
  object-fit: contain;
}

.cartItem .description {
  width: 100%;
  font-size: 30px;
  padding-left: 20px;
}

.cartItemTitle {
  font-weight: bold;
  font-size: 18px;
  margin-bottom: 10px;
}

.countHandler input {
  width: 40px;
  text-align: center;
  font-weight: bolder;
  margin: 0 5px;
}

.countHandler button {
  padding: 0 0.5em;
}

.subtotal {
  margin-top: 20px;
  font-size: 24px;
}

.total {
  font-size: 36px;
}

.checkout button {
  width: 150px;
  height: 50px;
  background-color: rgb(19, 19, 19);
  color: white;
  border: none;
  border-radius: 8px;
  margin: 10px;
  cursor: pointer;
}
```
</details>


# Step7 <span style="color:red;">【重要！】</span>useContextで状態管理

`src`フォルダの直下に`context`フォルダを作成し、
`shop-context.js`を作成します

:::note warn
`useContext`の詳しい使い方は、下記の記事を参照ください！
- [React コンテキストの使い方（useContext）](https://zenn.dev/hitoshiasano/articles/3aea56a6a8c0f7)
- [【React Hooks】useContextとは？概念や使い方を徹底解説！](https://terupro.net/react-usecontext-sample/)
:::



```diff:bash フォルダ構成
 └─src
     ├─app
     │  ├─cart
     │  ├─components
     │  └─contact
+    └─context
```

```jsx:src/context/shop-context.js
"use client"
import { createContext, useState } from "react";

// グローバルなpropsを定義し、初期値を渡す
export const ShopContext = createContext(null);

// カート内の商品の初期状態を生成
const getDefaultCart = () => {
  const productsLength = 20;
  let cart = {};
  for (let i = 1; i < productsLength + 1; i++) {
    cart[i] = 0;
  }
  return cart;
}

export const ShopContextProvider = (props) => {
  const [cartItems, setCartItems] = useState(getDefaultCart());

  const getTotalCartAmount = () => {
    let totalAmount = 0;
    for (const item in cartItems) {
      if (cartItems[item] > 0) {
        let itemInfo = items.find((product) => product.id === Number(item));
        totalAmount += cartItems[item] * itemInfo.price;
      }
    }
    return totalAmount;
  };

  const addToCart = (itemId) => {
    setCartItems((prev) => ({ ...prev, [itemId]: prev[itemId] + 1 }));
  };

  const removeFromCart = (itemId) => {
    setCartItems((prev) => ({ ...prev, [itemId]: prev[itemId] - 1 }));
  };

  const updateCartItemCount = (newAmount, itemId) => {
    setCartItems((prev) => ({ ...prev, [itemId]: newAmount }));
  };

  const checkout = () => {
    setCartItems(getDefaultCart());
  };

  const contextValue = {
    cartItems,
    getTotalCartAmount,
    addToCart,
    removeFromCart,
    updateCartItemCount,
    checkout
  };
  return (
    <ShopContext.Provider value={contextValue}>
      {props.children}
    </ShopContext.Provider>
  )
}
```

`layout.js`にProviderを渡す
全体を`ShopContextProvider`で囲む

```diff_jsx: src/app/layout.js
import { Inter } from 'next/font/google'
import './globals.css'
import Navbar from './components/navbar'
+ import { ShopContextProvider } from '@/context/shop-context'


const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>
+        <ShopContextProvider>
           <Navbar />
           {children}
+        </ShopContextProvider>
      </body>
    </html>
  )
}

```


### `Shop`コンポーネントを切り分けて、 `Item`コンポーネントを作る

```diff_jsx: src/app/components/shop.jsx(差分表示)
"use client";
import React, { useEffect, useState } from "react";
import "./shop.css";
+ import Item from "./item";

function Shop() {
  const [items, setItems] = useState([]);

  useEffect(() => {
    fetch("https://fakestoreapi.com/products")
      .then((res) => res.json())
      .then((json) => {
        console.log(json);
        setItems(json);
      });
  }, []);

  return (
    <div className="shop">
      <div className="shopTitle">
        <h1>E-commerce Shop</h1>
      </div>

      <div className="products">
        {items.map((item) => (
+          <Item
+            id={item.id}
+            title={item.title}
+            image={item.image}
+            price={item.price}
+            description={item.description}
+            category={item.category}
+            key={item.id}
+          />
        ))}
      </div>
    </div>
  );
}

export default Shop;

```

`shop.jsx`より`props`を受け取り、`Item`コンポーネントを作成します。

```src/app/components/item.jsx
"use client";
import React from "react";

function Item({ id, title, image, price, description, category }) {
  return (
    <div key={id} className="product">
      <div className="content">
        <p className="title">
          {id}. {title}
        </p>
        <img src={image} alt={title} className="image" />
        <p className="price">${price}</p>
        <p className="description">{description}</p>
        <p className="category">{category}</p>
      </div>
      <button className="addToCartBtn">ADD</button>
    </div>
  );
}

export default Item;
```

# Step8 カートボタンの実装
`Item`コンポーネントにカートボタンを実装していきます

`useContext`を使い、グローバル状態変数を取得していきます 
なお、propsはオブジェクトとして、一つにまとめて定数宣言する方法に変更しています。
`const { id, title, image, price, description, category } = props;`

```diff_jsx: src/app/components/item.jsx
"use client";
+ import { ShopContext } from "@/context/shop-context";
+ import React, { useContext } from "react";

function Item(props) {
  const { id, title, image, price, description, category } = props;
+  const { addToCart, cartItems } = useContext(ShopContext);
+  const cartItemCount = cartItems[id];

  return (
    <div key={id} className="product">
      <div className="content">
        <p className="title">
          {id}. {title}
        </p>
        <img src={image} alt={title} className="image" />
        <p className="price">${price}</p>
        <p className="description">{description}</p>
        <p className="category">{category}</p>
      </div>
+      <button className="addToCartBtn" onClick={() => addToCart(id)}>
+        カートに追加する {cartItemCount > 0 && <span>({cartItemCount}個)</span>}
+      </button>
    </div>
  );
}

export default Item;

```

# Step9 カートのロジックの実装
仮で入れていたデータを`useContext`を使って実装していきます

カート内の商品を、`cartItem`コンポーネントに切り分けて実装します。
`data`属性を使って、`cartItem`コンポーネントにpropsを渡します

```diff_jsx: src/app/cart/page.jsx
+ "use client";

+ import React, { useContext } from "react";
  import "./cart.css";
+ import CartItem from "./cartItem";
+ import { ShopContext } from "@/context/shop-context";

const Cart = () => {
+  const { items, cartItems } = useContext(ShopContext);

  return (
    <div className="cart">
      <div>
        <h1>カートの商品</h1>
      </div>
      <div className="cart">
+        {items.map((item) => {
+          if (cartItems[item.id] !== 0) {
+            return <CartItem data={item} />;
+          }
+        })}
      </div>

      <div className="checkout">
        <p>小計: xxxx</p>
        <button>買い物を続ける</button>
        <button>チェックアウト</button>
      </div>
    </div>
  );
};

export default Cart;

```

```src/app/cart/cartItem.jsx
"use client";
import { ShopContext } from "@/context/shop-context";
import React, { useContext } from "react";

function CartItem(props) {
  const { id, title, image, price } = props.data;
  const { cartItems, addToCart, removeFromCart, updateCartItemCount } =
    useContext(ShopContext);

  return (
    <div className="cartItem">
      <img src={image} />
      <div className="description">
        <p className="cartItemTitle">{title}</p>
        <p>$ {price}</p>
        <div className="countHandler">
          <button onClick={() => removeFromCart(id)}> - </button>
          <input
            value={cartItems[id]}
            onChange={(e) => updateCartItemCount(Number(e.target.value), id)}
          />
          <button onClick={() => addToCart(id)}> + </button>
        </div>
      </div>
    </div>
  );
}

export default CartItem;
```

# Step10 合計金額の表示&カートを空にする機能の実装

```diff_jsx:src/app/cart/page.jsx
"use client";

import React, { useContext } from "react";
import "./cart.css";
import CartItem from "./cartItem";
import { ShopContext } from "@/context/shop-context";

const Cart = () => {
+ const { items, cartItems, getTotalCartAmount, checkout } =
    useContext(ShopContext);
+ // 小数点第2位で四捨五入
+ const totalAmount = Math.round(getTotalCartAmount() * 100) / 100;

  return (
    <div className="cart">
      <div>
        <h1>カートの商品</h1>
      </div>
      <div className="cart">
        {items.map((item) => {
          if (cartItems[item.id] !== 0) {
            return <CartItem data={item} key={item.id} />;
          }
        })}
      </div>

+      {totalAmount > 0 ? (
+        <div className="checkout">
+          <p className="total">合計: ${totalAmount}</p>
+          <button
+            onClick={() => {
+              checkout();
+            }}
+          >
+            カートを空にする
+          </button>
+        </div>
+     ) : (
+       <h1> cart is empty</h1>
+     )}
    </div>
  );
};

export default Cart;
```

```diff_jsx: src/app/cart/cartItem.jsx
"use client";
import { ShopContext } from "@/context/shop-context";
import React, { useContext } from "react";

function CartItem(props) {
  const { id, title, image, price } = props.data;
  const { cartItems, addToCart, removeFromCart, updateCartItemCount } =
    useContext(ShopContext);

  return (
    <div className="cartItem">
      <img src={image} />
      <div className="description">
        <p className="cartItemTitle">{title}</p>
        <p>$ {price}</p>
        <div className="countHandler">
          <button onClick={() => removeFromCart(id)}> - </button>
          <input
            value={cartItems[id]}
            onChange={(e) => updateCartItemCount(Number(e.target.value), id)}
          />
          <button onClick={() => addToCart(id)}> + </button>
        </div>
+        <p className="subtotal">小計: ${price * cartItems[id]}</p>
      </div>
    </div>
  );
}

export default CartItem;

```

完成です！！

# まとめ

いかがだったでしょうか？
Next.jsを使えば、ショッピングサイトが手軽に作れ、デプロイまで簡単にできます。
ログイン機能の追加など、是非、アレンジしてみてください！

分からない点やご不明な点がございましたら、お気軽に質問投稿ください！
なるべく答えるようにいたします！


# さいごに

弊社では、エンジニア積極採用中です！！

**株式会社ワクトで、あなたの「ワクワク」を仕事にしませんか？**

https://wakuto.net/recruit/