グルメ検索Webアプリを作って弱かったあの頃の自分にさよならバイバイした話

※前段・後段は個人的な思いを話しているだけなので
興味のない方は飛ばして **【本編】** をお読み下さい🚀

## 前段

### 人生は挫折の連続

人生は挫折の連続ですが、
僕の26年の人生の中で大きな挫折の一つが
学生の時、ある人生の分岐点になるような課題の取り組みで失敗をしてしまったことです。

当時心境の変化などもあり
デザイナー志望からエンジニア志望にシフトチェンジしたばかりの僕に立ちはだかったのは、

ある大事な場面（詳しくはぼかします）で出された
**『グルメサーチAPIを利用して現在地周辺の店舗を検索して表示する』**
という課題でした。

### 高かった壁

- HTML
- CSS
- JavaScript少々

程度の知識しか持っていなかったたまねぎ剣士の僕は、
当然「API？何それ」状態で

ネットの記事などをゴリゴリに参考(というかほぼパクリでした)にして
バニラJavaScriptとPHPで
涙目になりながら実装しましたが……

結果はダメでした。
必然だったと思います。
だってたまねぎ剣士だし。

### 心残り

今となっては後悔も無いですが、
唯一の心残りが課題で自分を全く表現できなかったことです。

結果どうこうより自分で考えて作れなかったことが
ずっと喉の奥にひっかかってました。
 　
このまま思い出のひとつとして終わるのもいいですが……

 　
どうせなら今の自分で成功体験に塗り替えて
あの頃の自分にさよならバイバイしてしまえ


  　
ということで
周辺店舗の検索Webアプリを
今の自分で作成することに思い至りました。
（ただの自己満足です。）

# 周辺店舗検索アプリ 【本編】

### 成果
成果はこのような感じです↓
![sugutabe_capture.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612989/230dda60-fc89-99f5-a3f8-129c9dca53a3.gif)

Cloudflare Pagesを使用して公開しました。
https://sugutabe-app-sveltekit.pages.dev/

:::note warn
API側の仕様？でプロキシサーバをたてないと
リクエストエラーが返ってきてしまうので
見た目だけでご容赦ください…🙇🏻‍♂️

GIFは開発環境で、
Dev用に無料のプロキシサーバをたてて動かしています。
:::

### 環境
Mac OS
`Node: v18.17.0`
`Svelte: v4.2.7`
`SvelteKit: v2.0.0`
`TypeScript: v5.0.0`

### 使用したAPI
ホットペッパーのオープンAPIを使用しています。
リクルートWEBサービスに登録すれば誰でも使うことができます。

https://webservice.recruit.co.jp/doc/hotpepper/reference.html

**※クレジットの表記が必要なので利用の際はご注意ください**

### SvelteKitとは
SvelteKit は **[Next.js](https://nextjs.org/docs)** や **[Nuxt.js](https://nuxt.com/docs/getting-started/installation)** のような
Webアプリケーションを構築するためのフレームワークです。

SvelteKitを選んだ理由は
ほぼ9割方「やってみたかった😏」ですが
他にも言うならシンプルさ、軽快さに惹かれたからですかね…

:::note info
Svelteの基本的な記法などについては省略しているので
詳しくは公式サイトをご覧ください〜
:::
 
https://svelte.jp/docs/introduction

https://kit.svelte.jp/docs/introduction


## SvelteKitプロジェクトのセットアップ

### 
SvelteKitプロジェクトを作成・実行するには
ディレクトリで下記を実行します。
```
npm create svelte@latest プロジェクト名

cd プロジェクト名
npm install
```

プロジェクト作成時にオプションを質問されます。
今回は下記のように設定してます。
```
Which Svelte app template?
- Skeleton project

Add type checking with TypeScript?
- Yes, using TypeScript syntax

Select additional options (use arrow keys/space bar)
- Add ESLint for code linting
- Add Prettier for code formatting
```
TypeScriptを使用し、ESLint・Prettierを追加しました。
他にもPlaywright、Vitestなどのテストツールを
設定するかなどを選ぶことができます。

これだけでSvelte環境に合わせて
eslint、prettierの設定までしてくれるなんて
便利な世の中ですね…

　
```
npm run dev -- --open
```
あとはnpm run devをすれば
Welcomeページを表示することができました🥳
![sveltekit-welcome.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612989/d190e6ba-98a7-c796-4050-878ceabcce7a.png)

### ディレクトリ構成

<details><summary>プロジェクトの構成</summary>


```
sugutabe/
├ src/
│ ├ assets/
│ │ ├ css/
│ │ │ └ [CSS]
│ │ └ img/
│ │   └ [画像]
│ ├ lib/
│ │ └ components/
│ │   ├ Container.svelte
│ │   ├ ResultList.svelte
│ │   └ [コンポーネント]
│ ├ routes/
│ │ ├ +layout.svelte
│ │ ├ +page.server.ts
│ │ └ +page.svelte
│ ├ app.d.ts
│ ├ app.html
│ └ env.d.ts
├ static/
│ └ favicon.png
├ package.json
├ svelte.config.js
├ tsconfig.json
├ vite.config.js
└ [その他設定ファイル]
```
</details>


## svelteファイルの実装

中心になるコードを抜粋して掲載しています。

### +layout.svelte
svelteでは`src/routes/+layout.svelte`ファイルで
ページ共通のレイアウトを定義することができます。
<details><summary>コード</summary>

```html:src/routes/+layout.svelte
<script lang="ts">
  import "$style/_partials/_reset.scss";
  import Container from "$lib/components/Container.svelte";
</script>

<Container>
  <slot />
</Container>

<style lang="scss">
  :global(body) {
    --color-black: #222;
    --color-white: #fff;
    --color-gray: #ccc;
    --color-red: #bd3d2d;
    --z-index-1: 1;

    height: auto;
    background-color: whitesmoke;
    color: var(--color-black);
  }
  
  /*　その他グローバルスタイル　*/
</style>
```
</details>

#### グローバルスタイルの適用
svelteコンポーネントでは
デフォルトで`<style>`ブロック内のCSSはスコープされます。

変数などスタイルをグローバルに適用したいときは
`:global(...)`修飾子を使います。

### +page.svelte

`src/routes/+page.svelte`ファイルに
メインのグルメ検索機能を実装します。（一部省略してます）

<details><summary>コード</summary>

```html:src/routes/+page.svelte
<script lang="ts">
  import type { PageData } from './$types';
  import { page } from '$app/stores';
  import logo from '$img/logo.png?enhanced'
  import ResultList from '$lib/components/ResultList.svelte';

  interface Query {
    count: number,
    lat?: number,
    lng?: number,
    name: string,
    range?: number,
    keyword: string,
    format: 'json' | 'xml',
  };

  let queryInit = {// URLパラメータの初期値
    count: 100,
    name: '',
    keyword: '',
    format: 'json',
  };

  $: resultData = null;
  $: query = queryInit as Query;
  $: status = '条件を入力してボタンを押して下さい';
  $: isSearching = false;

  const requestURL = (query: Query): string => {
    let requestUrl;
    const key = $page.data.api_key;
    const proxyUrl = $page.data.proxy_url;
    const searchParams = new URLSearchParams(Object.entries(query));// api_key以外のパラメータを作成

    const url = `http://webservice.recruit.co.jp/hotpepper/gourmet/v1/?key=${key}&${searchParams.toString()}`;

    // 開発時はプロキシサーバを追加
    $page.data.mode === 'development'
      ? requestUrl = proxyUrl + url
      : requestUrl = url;
    return requestUrl;
  };

  // リクエストを送信してレスポンスデータをresultDataに入れる
  const searchShops = () => {
    isSearching = true;
    resultData = null;

    // 位置情報が取得できた場合のコールバック
    const getPositionCallback = async (position: GeolocationPosition): Promise<void> => {
      status = '';
      query.lat = position.coords.latitude;
      query.lng = position.coords.longitude;

      const requestUrl = requestURL(query);

      const response = await fetch(requestUrl, {
        method: 'GET',
      });
      resultData = await response.json().catch(console.error);
      isSearching = false;
    };

    // 位置情報の取得に失敗した場合エラーコードに合わせてステータスを更新する
    const errorCallback = (positionError: any): void => {
      switch (positionError.code) {
        case 0:
          status = "原因不明のエラーが発生しました。";
          break;
        case 1:
          status = "位置情報の取得を許可して下さい。";
          break;
        case 2:
          status = "位置情報が取得できませんでした。電波状況が悪い可能性があります。";
          break;
        case 3:
          status = "位置情報の取得がタイムアウトしました。";
          break;
      }
    };

    if (!navigator.geolocation) {
      status = '現在地情報を取得することができませんでした';
    } else {
      status = '検索中です…';
      navigator.geolocation.getCurrentPosition(getPositionCallback, errorCallback);
    }
  };
</script>

<div class="search">
  <h1 class="logo">
    <enhanced:img
      src={logo}
      alt="ロゴ"
    />
    <span class="u-sr-only">スグ食べサーチ</span>
  </h1>

  <form action="#">
    <div class="search-item">
      <label for="keywords" class="label">キーワード</label>
      <input type="text" bind:value={query.keyword} id="keywords" class="input" placeholder="&nbsp;">
    </div>
    <div class="search-item">
      <label for="shop-name" class="label">店舗名</label>
      <input type="text" bind:value={query.name} id="shop-name" class="input" placeholder="&nbsp;">
    </div>
    <div class="search-item">
      <label for="range" class="label">検索する範囲</label>
      <select bind:value={query.range} id="range" class="select">
        <option value="1">300m</option>
        <option value="2">500m</option>
        <option value="3" selected>1000m</option>
        <option value="4">2000m</option>
        <option value="5">3000m</option>
      </select>
    </div>
  </form>

  <button on:click={searchShops} type="button" class="button">検索する</button>
  <p class="status-text">{status}</p>
</div>


<div class="result">
  {#if resultData}
    <ResultList {resultData} />
  {/if}
</div>
```
</details>

　
要点としては下記のような感じです。

- 「検索する」ボタンを押したら [Geolocation API](https://developer.mozilla.org/ja/docs/Web/API/Geolocation_API) の `getCurrentPosition（）`メソッドを使用して現在地の緯度・経度を取得します
- 取得した緯度・経度と入力された値からリクエストURLのパラメータを作成します
- fetchリクエストのレスポンスデータを`ResultList`コンポーネントに渡して表示しています

### ResultList.svelte

`src/lib/components/ResultList.svelte` では
店舗情報のデータを受け取って10件ずつ表示します。

Svelteコンポーネントは `export let ~` で Propsを受け取ることができます。


<details><summary>コード</summary>


```html:src/lib/components/ResultList.svelte
<script lang="ts">
  export let resultData;

  let resultShopArray = resultData.results.shop;

  let currentPage = 1;
  let itemsPerPage = 10;// １回で表示する最大件数

  $: paginatedData = resultShopArray ? resultShopArray.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage) : null;
  $: totalPagesArray = Math.ceil(resultShopArray.length / itemsPerPage);

  const paginate = (pageNumber: number) => {
    currentPage = pageNumber;
    updateCurrent(pageNumber);
  };

  const isCurrent = (pageNumber: number): string => {
    return pageNumber === currentPage ? 'is-current' : '';
  };

  const updateCurrent = (pageNumber: number) => {
    const paginationButtons = document.querySelectorAll('.pagination');
    paginationButtons.forEach((button, index) => {
      if (index + 1 === pageNumber) {
        button.classList.add('is-current');
      } else {
        button.classList.remove('is-current');
      }
    });
  };
</script>

{#if resultShopArray.length}
<h2 class="result-status-text">{resultShopArray.length}件の店舗が見つかりました</h2>
{:else}
<h2 class="result-status-text">該当する店舗は見つかりませんでした</h2>
{/if}

<!-- 検索結果が11件以上の時はページング -->
{#if resultShopArray.length > 10}
<ul class="pagination-list">
  {#each Array(totalPagesArray).keys() as page}
  <li><button type="button" on:click={() => paginate(page + 1)} class="pagination {isCurrent(page + 1)}">{page + 1}</button></li>
  {/each}
</ul>
{/if}

<ul class="result-list">
  {#each paginatedData as shop}
  <li class="result-card">
    <a href="{shop.urls.pc}" aria-label="{shop.name} ホットペッパーグルメ" class="result-card-inner" target="_blank">
      <div class="result-card-img"><img src="{shop?.photo.pc.l}" alt="{shop.name} ロゴ画像"></div>
      <div class="result-card-info">
        <p class="result-card-infoName">{shop.name}</p>
        <p>{shop?.catch}</p>
      </div>
      <dl class="result-card-list">
        <div class="result-card-listItem">
          <dt>営業時間</dt>
          <dd>{shop?.open}</dd>
        </div>
        <div class="result-card-listItem">
          <dt>定休日</dt>
          <dd>{shop?.close}</dd>
        </div>
        <div class="result-card-listItem">
          <dt>住所</dt>
          <dd>{shop?.address}</dd>
        </div>
        <div class="result-card-listItem">
          <dt>食べ放題</dt>
          <dd>{shop?.free_food}</dd>
        </div>
        <div class="result-card-listItem">
          <dt>飲み放題</dt>
          <dd>{shop?.free_drink}</dd>
        </div>
        <div class="result-card-listItem">
          <dt>個室</dt>
          <dd>{shop?.private_room}</dd>
        </div>
        <div class="result-card-listItem">
          <dt>禁煙席</dt>
          <dd>{shop?.non_smoking}</dd>
        </div>
        <div class="result-card-listItem">
          <dt>駐車場</dt>
          <dd>{shop?.parking}</dd>
        </div>
        <div class="result-card-listItem">
          <dt>その他設備</dt>
          <dd>{shop?.other_memo}</dd>
        </div>
        <div class="result-card-listItem">
          <dt>23時以降も営業</dt>
          <dd>{shop?.midnight}</dd>
        </div>
      </dl>
    </a>
  </li>
  {/each}
</ul>
```
</details>


## 環境変数の読み込み
`.env`ファイルに設定した環境変数を`$env/static/private`で呼び出します。
```typescript:src/routes/+page.server.ts
import type { PageServerLoad } from './$types';
import { HOTPEPPER_GOURMET_API_KEY, DEV_CORS_PROXY_URL } from "$env/static/private";

const mode = import.meta.env.MODE;

export const load: PageServerLoad = async ({ url, route, params }) => {
  return {
    mode: mode,
    api_key: HOTPEPPER_GOURMET_API_KEY,
    proxy_url: DEV_CORS_PROXY_URL,
  }
};
```

`+page.server.ts`のload関数から返されたデータは
`+page.svelte`ファイルから`$page.data`でアクセスすることができます。
```html:src/routes/+page.svelte
<script lang="ts">
  import { page } from '$app/stores';
  /* ... */
  const requestURL = (query: Query): string => {
    let requestUrl;
    const key = $page.data.api_key;
    const proxyUrl = $page.data.proxy_url;
    /* ... */
  };
</script>
```

## Cloudflare Pages でサクッと公開

サイトのデプロイは [Cloudflare Pages](https://developers.cloudflare.com/pages/) を利用します。
（SvelteKitは他にも [Vercel](https://vercel.com/) や [Netlify](https://www.netlify.com/) などのデプロイをサポートしています。）

### Gitと連携

Gitリポジトリと連携することで
リモートにプッシュするだけで簡単にアプリケーションを公開することができます✨

連携についてはこちらの記事がわかりやすいので見てみてください！

https://zenn.dev/yetly/articles/6f7bc7068960a6#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB


## 後段

課題に再挑戦してみて
稚拙ながら自分の成長を感じられました。
(あの頃は無かったAIの恩恵もバリバリ受けましたが…)

喉につっかえていたものが取れて、
やっと心残りにもさよならバイバイできてよかったです。

いつもいつでもうまくいくという
保証はどこにもないですが
こんなふうに失敗も糧にしていけたらなと思います。

　
最後まで見ていただきありがとうございました🙇🏻‍♂️