【個人開発】面倒な二次会の店選びをサポートするサービスをリリースしました

# はじめに
はじめまして、たつやと申します。

二次会の店選びって面倒じゃないでしょうか？面子にもよると思いますが個人的には「高くなければどこでもいいなー」と思ってしまいます。そんな人が利用するサービス**Tsugicoco**を開発・リリースしました。

直近に飲み会があって二次会の店選びが面倒なときは使ってみていただけると嬉しいです！

https://tsugicoco.com/

# 画面

:::message
黒く伏せているところには店名と電話番号、住所です。
:::

![](https://storage.googleapis.com/zenn-user-upload/beeb5f88d669-20250502.png)

# サービス概要
私のような二次会の店選びが面倒な人用のサービスです。現在地から500mあるいは1km圏内の営業時間内の居酒屋からランダムで1件ピックアップします。ユーザは電話番号から電話をして、空いていれば案内開始ボタンを押して店に向かいます。

たったこれだけのサービスです。

:::message
Webサービスですが`GPS`を使う都合上、エージェントを偽装しない限りモバイルからしか動作しないようになっています。
:::

# 技術スタック
### フロントエンド
**★ [React](https://ja.react.dev/)**

**★ [Tailwind CSS](https://tailwindcss.com/)**

**★ [TypeScript](https://typescriptbook.jp/#:~:text=TypeScript%E3%81%A8%E3%81%AF%E2%80%8B,%E3%82%AA%E3%83%BC%E3%83%97%E3%83%B3%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%A7%E5%85%AC%E9%96%8B%E3%81%97%E3%81%9F%E3%80%82)**

### バックエンド
**★ [Next.js](https://nextjs.org/)**

### サーバー
**★ [CloudFlare Pages](https://www.cloudflare.com/ja-jp/developer-platform/products/pages/)**

`Next.js`を使うと`CloudFlare Pages`にデプロイする際に`edge runtime`関連でエラーを吐くことは確認していましたが、このサービスは`SSR`を使っていないため発生しませんでした。

もし発生した場合は`Next.js`のバージョンを下げることで解決できます。

https://zenn.dev/tacchan5424/articles/7a85aa6172e566


# Nearby Search
現在地周辺の店情報を取得するために`Google`の[Nearby Search](https://developers.google.com/maps/documentation/places/web-service/nearby-search?hl=ja)というサービスを利用しています。

このサービスでは以下例のように周辺情報を取得します。
※ほぼ実際に使っているコードで、一部加工しています。

```javascript:サンプルコード
const data = await fetch(
  "https://places.googleapis.com/v1/places:searchNearby",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Goog-Api-Key": `${process.env.API_KEY}`,
      "X-Goog-FieldMask":
        "places.displayName,places.formattedAddress,places.priceRange,places.nationalPhoneNumber,places.priceRange,places.regularOpeningHours",
    },
    body: JSON.stringify({
      includedTypes: ["bar", "japanese_restaurant"],
      excludedTypes: [
        "ramen_restaurant"
      ],
      languageCode: "ja",
      rankPreference: "DISTANCE",
      locationRestriction: {
        circle: {
          center: {
            latitude: Number(latitude),
            longitude: Number(longitude),
          },
          radius: Number(distance),
        },
      },
    }),
  },
);
```

### ヘッダー
- `X-Goog-Api-Key`：
  APIキーを設定するためのものです。
- `X-Goog-FieldMask`：
  レスポンスとして必要なフィールド名を設定するためのものです。例えば`places.nationalPhoneNumber`を設定することで電話番号が取得できます。取得するフィールドに応じて課金のされ方が異なり、最も高い課金体系のフィールドに依存した課金が行われます。例では`places.nationalPhoneNumber`ベースで課金されます。詳しくは[こちら](https://developers.google.com/maps/documentation/places/web-service/nearby-search?hl=ja#fieldmask)と[こちら](https://developers.google.com/maps/billing-and-pricing/pricing?hl=ja&_gl=1*st6tcb*_up*MQ..*_ga*NzgxNjMwNzA5LjE3NDYxNjM2Nzc.*_ga_NRWSTWS78N*MTc0NjE2MzY3Ny4xLjEuMTc0NjE2MzY3Ny4wLjAuMA..#places-pricing)。


### ボディ
- `includedTypes`：
  設定されているタグを持つ周辺の情報を取得します。`bar`だけでは居酒屋がほとんどヒットしせず、`japanese_restaurant`もセットするとかなりヒットするようになりました。`or`条件で取得します。
- `excludedTypes`：
  取得しないタグを設定します。`japanese_restaurant`というタグを設定したことによって、居酒屋以外のあらゆる店がヒットするようになりました。そのため、居酒屋以外の飲食店が持っているタグを設定して除外しています。例では`ramen_restaurant`のみですが、カレー屋や焼き肉屋、すし屋などもヒットしてしまうので実際には他にもいくつか設定しています。
- `rankPreference`：
  取得順です。距離順で取得するようにしていますが、ランダムで1件抽出しているためこのサービスとしては不要なパラメータです。
- `locationRestriction`：
  現在地と周辺を表すパラメータです。この例では`GPS`から取得した緯度経度を使って、かつ画面から設定した距離(500m or 1km)圏内を指定しています。

### 困りごと
記載した通り居酒屋固有のタグがないため、居酒屋だけを簡単に抽出することができません。居酒屋と居酒屋以外の飲食店で同じタグが設定されているケースが多くあるため、タグだけで制御しきれませんでした。例えば某餃子とカレーの店もヒットしてしまいます。
そのためBANリスト的なものを作って、ヒットしてしまった居酒屋以外の飲食店を追加でフィルタリングしています。私が確認した飲食店やお問い合わせのあった飲食店をBANリストに入れていって、少しずつ完璧に近づけていけたらと思っています。

他にも、ヒットするものの電話番号がないデータや金額の情報がないデータなどこのサービス上で表示するべきではない居酒屋もヒットしています。これらについても追加でフィルタリングしています。

# モバイル限定
このようにエージェントでモバイルかどうかを判断しています。サービスのレイアウトもモバイル用にしか調整していません。**二次会の居酒屋選び**に特化して利用してもらいたいサービスであるためです。

```react
const userAgent = req.headers.get("user-agent") || "";
const isMobile = /iPhone|Android|Mobile/.test(userAgent);
```

# 案内
案内開始ボタンを押すことで`Google Map`が開いて案内までできます。これを実現するにあたって以下のようにURLを組み立てています。

```javascript
const mapsUrl = `https://www.google.com/maps/dir/?api=1&origin=${緯度},${経度}&destination=${店名}&travelmode=walking`
```

実際店名だけでは特定できないケースがあるので、`encodeURIComponent(``${name} ${address}``)`のようにして店名+住所をエンコードしています。
ボタン押下で`window.open(mapsUrl, "_blank");`を実行するようにすれば完成です。以下のようなイメージです。

```react
<button
  className="w-1/3 h-10 rounded-lg bg-[#96B6C5] active:bg-[#ADC4CE] text-white font-extrabold"
  onClick={() => {
    const mapsUrl = `https://www.google.com/maps/dir/?api=1&origin=${緯度},${経度}&destination=${encodeURIComponent(`${name} ${address}`)}&travelmode=walking`;
    window.open(mapsUrl, "_blank");
  }}
>
  案内開始
</button>
```

# おわりに
小さなサービスなのであまりためになることをまとめられませんでしたが、一部でも誰かのためになれば幸いです。

もしよければXのフォローや他サービスも触っていただけると嬉しいです！

**▼ Xアカウント**

https://x.com/Mw1FIDsnqdLbyDs

**▼ Webサービス**

https://bloomsurvey.com/

https://wikimiru.com/

**▼ 本**

https://zenn.dev/tacchan5424/books/22d87ed6bc8550