🍽️

Next.js × ホットペッパーAPI でグルメ検索アプリを作成してみた

2023/06/25に公開

こんにちは。
フロントエンドエンジニアを目指して転職活動中のAtsuyaです。

React/Next.jsの学習のアウトプットとして何かアプリを作りたいなーって思っていた時、ChatGPTに「どんなアプリ作ったらいいかな?」と質問したら、グルメ検索アプリをオススメされたので作ってみました。

https://gourmet-search-tau.vercel.app/

主な技術スタック

  • Next.js
  • TypeScript
  • TailwindCSS
  • Vercel
  • Firebase
  • PWA

Next.jsのプロジェクトで、ホットペッパーAPIを活用して飲食店のデータを取得します。
いいね機能もつけたいので、Firebaseを用いて認証機能といいねした店舗IDを保存するためにDBも用意します。
最後にPWAの設定までしてみました。(PWAの説明は省きます)

ホットペッパーグルメAPIの準備

https://webservice.recruit.co.jp/about.html

こちらのサイトを通じてAPIキーを取得してNext.jsのenvファイルに格納しておきましょう。

サイトには「APIリファレンス」という項目があるので、そこから取得できるデータのAPIが一覧として確認できます。

https://webservice.recruit.co.jp/doc/hotpepper/guideline.html
またホットペッパーAPIを使用したことが分かるhtmlコードを貼らないといけなかったり、利用規約もきちんと確認しておきましょう。

データの取得

Next.jsのapiフォルダでデータ取得用のapiを作成し、ここをフロントから叩いています。

pages/api/getShopLists.ts
import { STATION_AREA } from "@/data/data";
import axios from "axios";
import type { NextApiRequest, NextApiResponse } from "next";

//表示件数
export const SHOW_NUM = 10;

/**
 * 飲食店のリストを取得するAPI
 */
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const apiUrl = process.env.HOTPEPPER_API;

  //apiキー
  const apiKey = `&key=${process.env.HOTPEPPER_API_KEY}`;

  //表示件数
  let apiCount = `&count=${SHOW_NUM}`;
  if (req.query.count) {
    apiCount = `&count=${req.query.count}`;
  }

  //何件目から取得するか
  let apiNum = "&start=1";
  if (req.query.startNum) {
    const resNum: any = req.query.startNum;
    const num = resNum * 10 - 9;
    apiNum = `&start=${num}`;
  }

  //地域 (初期値:東京)
  let apiPlace = "";
  if (req.query.areaCode) {
    apiPlace = `&large_area=${req.query.areaCode}`;
  }

  //駅
  let apiStation = "";
  if (
    typeof req.query.stationPos === "string" &&
    typeof req.query.stationPre === "string"
  ) {
    const stationObj: any = STATION_AREA[req.query.stationPre].find(
      (station: any) => {
        return station.NAME === req.query.stationPos;
      }
    );
    apiStation = stationObj.PLACE;
  }

  //ジャンル
  let apiGenre = "";
  if (req.query.genreCode) {
    apiGenre = `&genre=${req.query.genreCode}`;
  }

  //キーワード
  let apiKeyword = "";
  if (req.query.keyword) {
    apiKeyword = `&keyword=${req.query.keyword}`;
  }

  try {
    const resData = await axios.get(
      `${apiUrl}${apiKey}${apiPlace}${apiStation}${apiGenre}${apiKeyword}${apiCount}${apiNum}&range=3`
    );
    const shopLists = resData.data.results;

    res.status(200).json(shopLists);
  } catch (err) {
    console.log(err);
  }
}

apiキーやURLは必ず指定しますが、地域やジャンルなどはリクエストクエリで渡された値に応じて叩くように設定されています。
例えば検索時にジャンルを指定しなければ全てのジャンルのデータを取得し、居酒屋が指定されれば居酒屋のジャンルだけを取得するようにAPIを叩くといった感じです。

axiosを使って以下のように取得しています。

search.tsx
const res = await axios.get("/api/getShopLists", {
 params: {
   startNum: currentPage,  //ページネーション用
   areaCode, //エリア用
   stationPre, //駅名
   stationPos, //駅座標
   genreCode, //ジャンル用
   keyword, //キーワード
 },
});

検索機能

検索は都道府県、駅名、ジャンル、キーワードの4種類を組み合わせでできるようになっています。

全てURLパラメーターに応じて検索されるようにしており、例えば「url?area=[areaCode]&genre=[genreCode]&keyword=キーワード」といった感じになります。

ザッと流れとしては、
セレクトボックスやinputテキストで値を入力してもらい、それを加工したデータをuseStateで保持させて、検索ボタンを押したらパラメーターを付与してレンダリングさせます。
そのパラメーターの値を、先程のaxiosで渡す値として設定してapiを叩きます。

エリアはエリアコード、ジャンルはジャンルコード、キーワードはそのままキーワードなど、apiのパラメーターにつける値がそれぞれ決まっており、
エリアコードを取得するためapiがまた別であったりして、そういったものを活用しながらうまくデータを加工する作業は結構ありました。

かなり複雑になってしまいましたが、いちようコードを貼っておきます。

components/SearchArea.tsx
import { STATION_AREA } from "@/data/data";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";

type Props = {
  prefecture?: [];
  genres?: [];
};

export const SearchArea = ({ prefecture = [], genres = [] }: Props) => {
  const router = useRouter();

  //エリアコード
  const [selectedAreaCode, setSelectedAreaCode] = useState(
    router.query.area || "Z011"
  );
  //エリア名(初期値:東京)
  const [selectedAreaName, setSelectedAreaName] = useState<any>("東京");

  //ジャンルコード(初期値:全てのジャンル)
  const [selectedGenreCode, setSelectedGenreCode] = useState(
    router.query.genre || null
  );
  //ジャンル名(初期値:全てのジャンル)
  const [selectedGenreName, setSelectedGenreName] = useState("全てのジャンル");

  //キーワード入力テキスト
  const [searchText, setSearchText] = useState(router.query.keyword || "");

  //県名
  const [prefectureName, setPrefectureName] = useState<any>(
    router.query.pre || "東京"
  );

  //駅名
  const [stationName, setStationName] = useState("");

  useEffect(() => {
    firstPrefectureDisplay();
    firstGenreDisplay();
  }, []);

  /**
   * エリアのselectボックスの初期値を指定
   */
  const firstPrefectureDisplay = async () => {
    if (router.query.area) {
      const currentAreaObj: any = prefecture.filter((area: any) => {
        return area.code === selectedAreaCode;
      });
      setSelectedAreaName(currentAreaObj[0].name);
    } else {
      setSelectedAreaName(router.query.pre);
    }
  };

  /**
   * ジャンルのselectボックスの初期値を指定
   */
  const firstGenreDisplay = async () => {
    if (selectedGenreCode) {
      const currentGenreObj: any = genres.filter((genre: any) => {
        return genre.code === selectedGenreCode;
      });
      setSelectedGenreName(currentGenreObj[0].name);
    }
  };

  //駅の配列を取得
  const stationArray = STATION_AREA[prefectureName].map((area: any) => {
    return area.NAME;
  });

  /**
   * 選択中のエリアのコードを取得
   */
  const handleAreaChange = (e: any) => {
    setSelectedAreaCode(e.target.value);

    const selectedPrefecture =
      e.target.options[e.target.selectedIndex].dataset.prefecture;
    setPrefectureName(selectedPrefecture);
    setStationName(""); // 都道府県が変わった時に駅の選択をリセット
  };

  /**
   * 駅のselectボックスの変更時発火
   */
  const handleStationChange = (event: any) => {
    setStationName(event.target.value);
  };

  /**
   * 選択中のジャンルのコードを取得
   */
  const handleGenreChange = (e: any) => {
    setSelectedGenreCode(e.target.value);
  };

  /**
   * キーワード入力を取得
   */
  const handleSearchInput = (e: any) => {
    setSearchText(e.target.value);
  };

  /**
   * 検索ボタン
   */
  const handleSearch = () => {
    //県
    let resultPrefectureCode = "";

    //駅
    let resultStationName = "";
    let resultPreName = "";
    if (stationName !== "") {
      resultStationName = `&station=${stationName}`;
      resultPreName = `?pre=${prefectureName}`;
    } else {
      resultPrefectureCode = `?area=${selectedAreaCode}`;
    }

    //ジャンル
    let resultGenreCode = "";
    if (selectedGenreCode) {
      resultGenreCode = `&genre=${selectedGenreCode}`;
    }

    //キーワード
    let resultKeyword = "";
    if (searchText !== "") {
      resultKeyword = `&keyword=${searchText}`;
    }

    router.push(
      `/search${resultPrefectureCode}${resultPreName}${resultStationName}${resultGenreCode}${resultKeyword}`
    );
  };

  return (
    <div className="flex flex-wrap lg:flex-nowrap justify-between lg:justify-start">
      <div className="w-[100%] lg:w-auto flex justify-between lg:justify-start">
        {/* エリア */}
        <div className="w-[49%] lg:w-[100px] border-[1px] border-[#999s] rounded-md overflow-hidden shadow">
          <select
            name=""
            id=""
            onChange={handleAreaChange}
            className="w-[100%] py-[10px] px-3 text-[14px] outline-none h-[100%] "
          >
            {prefecture.map((area: any) => {
              //selectの初期値を設定
              let isSelect = false;
              if (area.name === selectedAreaName) {
                isSelect = true;
              }
              return (
                <option
                  key={area.name}
                  value={area.code}
                  selected={isSelect}
                  data-prefecture={area.name}
                >
                  {area.name}
                </option>
              );
            })}
          </select>
        </div>

        <div className="w-[49%] lg:w-[100px] border-[1px] border-[#999s] rounded-md overflow-hidden shadow lg:ml-2">
          {/* 駅名 */}
          <select
            onChange={handleStationChange}
            className="w-[100%] py-[10px] px-3 text-[14px] outline-none h-[100%] "
          >
            <option value="">駅を選択</option>
            {prefectureName &&
              stationArray.map((station) => {
                //selectの初期値を設定
                let isSelectStation = false;
                if (station === router.query.station) {
                  isSelectStation = true;
                }
                return (
                  <option
                    key={station}
                    value={station}
                    selected={isSelectStation}
                    className={`${isSelectStation}`}
                  >
                    {station}
                  </option>
                );
              })}
          </select>
        </div>
      </div>

      {/* ジャンル */}
      <div className="w-[100%] lg:w-[300px] border-[1px] border-[#999s] rounded-md overflow-hidden shadow mt-1 lg:mt-0 lg:ml-2">
        <select
          name=""
          id=""
          onChange={handleGenreChange}
          className="w-[100%] py-[10px] px-3 text-[14px] outline-none h-[100%]"
        >
          <option value="">全てのジャンル</option>
          {genres.map((genre: any) => {
            //selectの初期値を設定
            let isSelect = false;
            if (genre.name === selectedGenreName) {
              isSelect = true;
            }
            return (
              <option key={genre.name} value={genre.code} selected={isSelect}>
                {genre.name}
              </option>
            );
          })}
        </select>
      </div>

      {/* キーワード */}
      <div className="w-[100%] lg:w-[35%] lg:ml-2 mt-1 lg:mt-0">
        <input
          type="text"
          value={searchText}
          onChange={handleSearchInput}
          placeholder="キーワード"
          className="border-[1px] h-[100%] lg:border-t-2 lg:border-l-0 border-[#999s] w-[100%] py-[10px] px-3 text-[14px] outline-none rounded-md shadow"
        />
      </div>

      {/* 検索 */}
      <button
        onClick={handleSearch}
        className="block rounded-md mt-2 lg:mt-0 lg:ml-2 w-[100%] lg:w-[100px] bg-[#017D01] text-white py-[10px] border-0 text-[14px] max-w-[400px] shadow shrink-0"
      >
        検索する
      </button>
    </div>
  );
};

認証機能

会員登録やログインの機能は「Firebase Authentication」を使用しました。
Googleログインもしくはメールアドレスとパスワードでの登録と、転職活動用にゲストログインも用意しています。

login.ts
/**
   * Googleアカウントでログイン
   */
  const onGoogleLogin = async (e: any) => {
    e.preventDefault();
    await signInWithRedirect(auth, provider);
  };

  /**
   * メールアドレスのログイン
   */
  const onEmailLogin = async (e: any) => {
    e.preventDefault();
    try {
      signInWithEmailAndPassword(auth, email, password)
        .then(async (userCredential) => {
          const user = userCredential.user;
          const docRef = doc(db, "user", user.uid);
          const docSnap = await getDoc(docRef);
          if (!docSnap.exists()) {
            await setDoc(doc(db, "user", user.uid), {});
          }
          router.push("/");
        })
        .catch((error) => {
          console.log(error);
          alert("ログインに失敗しました");
        });
    } catch {
      alert("ログインエラーが起きました");
    }
  };

実装については下記の記事あたりが参考になるかもしれません。
https://firebase.google.com/docs/auth/web/start?hl=ja
https://reffect.co.jp/react/react-firebase-auth/

いいね機能

いいね機能は「Cloud Firestore」を活用しています。

各店舗にハートのいいねボタンを設置しており、それを押すと店舗IDを取得し、Cloud Firestoreで設定したフィールドの配列へ格納します。
ただしログインしていない時は、ログイン画面に遷移するようにしています。

  /**
   * いいね登録
   */
  const onLike = async (shopId: string) => {
    if (currentUser) {
      //ログイン中
      setIsClick(true);
      const res = await axios.post("/api/addLikeShop", {
        currentUserId: currentUser?.uid,
        shopId: shopId,
      });
      setPopUpText(res.data.message.popup);
      setIsLikePopUp(true);
      setTimeout(() => {
        setIsLikePopUp(false);
      }, 3000);
    } else {
      //未ログイン状態
      router.push("/login");
    }
  };

マイページのお気に入り一覧にいくと、配列に保存してある店舗IDで検索して指定の店舗を取得しているといった仕組みです。

DBのドキュメントには各ユーザーのUIDが入るように設定しています。

  /**
   * いいね済み店舗を取得
   */
  const getLikeShop = async () => {
    try {
      setIsLoad(true);
      const resData = await axios.post("/api/getLikeShopList", {
        currentUserId: currentUser?.uid,
      });
      setShopData(resData.data.shop);
      setIsLoad(false);
    } catch (error) {
      console.log(error);
    }
  };

まとめ

今回はデータをうまく加工してapiを叩くという訓練になったと感じています。

検索機能やいいね機能は、基本的なアプリ開発の機能だと思うので良い経験でした。
(実際に現場でこのようにやっているのかは分かりませんが。。)

もっと機能を追加しながらより本格的なアプリになるようにしていきたいと思います!

Discussion