【個人開発】服好き・カフェ好きのためのショップ検索サービス「服カフェ」をリリースしました

## はじめに
こんにちは、@jinta_02と申します。
この度、服好き・カフェ好きのためのショップ検索サービス「服カフェ」をリリースいたしました！
完全未経験で初めての個人開発だったため苦労する点もありましたが、その中でも工夫した点をお伝えできればと思いますので、技術的な部分で誤りがありましたらコメント等いただけますと幸いです🙇‍♂️


## サービス名：[「服カフェ」](https://fuku-cafe.com)
※現在サービス停止中
[![ogp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600084/e5cb45ea-dc3f-5198-f249-7fd21289d1f8.png)](https://fuku-cafe.com)

▼サービスURL：https://fuku-cafe.com

▼Github:https://github.com/jinta-shimo02/fuku_cafe

▼告知ツイート：https://twitter.com/jin_XXX222/status/1718535233807736948

## 概要
「服カフェ」は、服好き・カフェ好きのためのショップ検索サービスです。マップ上からセレクトショップ・カフェの検索が可能で、気になるショップを作成したリストに保存することができます。また、訪れたショップのレビューも行うことができます。

## 主な機能
|マップ検索|フィルター検索|
|:--------:|:----------:|
|![search_map](https://github.com/jinta-shimo02/fuku_cafe/assets/100778581/6ad4017e-0bad-4afc-98bb-fb0d83008782)|![filter](https://github.com/jinta-shimo02/fuku_cafe/assets/100778581/a395f6f7-95e9-4db9-9615-04cf7567a99e)|
|マップをスワイプすると、サークル内で自動検索がかかります。|セレクトショップのみ、カフェのみでのフィルター検索が可能です。|

|ブランド検索|リスト保存|
|:---------:|:-------:|
|![brand_filter](https://github.com/jinta-shimo02/fuku_cafe/assets/100778581/73a7fe89-775f-44ba-b253-b8a7c9300195)|![save_list](https://github.com/jinta-shimo02/fuku_cafe/assets/100778581/8363ed39-0d00-4343-ada8-004f91d4d5b6)|
|特定のブランドを取り扱っているショップを検索することも可能です。|気になるショップを作成したリストへ保存することができます。|

|ショップレビュー|レコメンド|
|:-----------:|:------:|
|![review](https://github.com/jinta-shimo02/fuku_cafe/assets/100778581/8ac44c0b-d8b3-4455-a793-ea9791c94031)|![recommend](https://github.com/jinta-shimo02/fuku_cafe/assets/100778581/2d1eaffa-5687-4ccc-9e25-4643ee6edf02)|
|ショップへのレビューをすることができます。|ショップ詳細画面で、1番近くにあるショップをレコメンドします。|

### その他の機能
- マイページ（リスト編集、レビュー一覧）
- 現在地から検索（ピンの位置を現在地へ移動）
- 場所検索（ピンの位置を移動する）

## 工夫した点
### ①マップ検索機能（自動検索）
![search_map](https://github.com/jinta-shimo02/fuku_cafe/assets/100778581/6ad4017e-0bad-4afc-98bb-fb0d83008782)
マップ検索機能は、ユーザーに手軽に使っていただけるように、マップをスワイプしただけでサークル内で自動検索がかかり、ヒットしたショップが一覧で表示されるようにしました。
今回の開発の中で、この部分が1番苦労しました。

<br>

実装の流れは以下のとおりです。
1. マップをスワイプしたタイミングでイベント発火
2. スワイプ先のサークルの中心の緯度と経度を取得
3. fetchを使用して、取得した緯度と経度をパラメータとしてコントローラへリクエスト送信
4. コントローラ側で、パラメータの緯度と経度をもとに半径1キロの範囲内でショップデータを取得
5. 取得ショップを一覧で表示

```javascript:app/javascript/google_maps.js
var map;
var pin;
var circle;
var lat = gon.latitude;
var lng = gon.longitude;
var API_KEY = gon.api_key;

function initMap() {
  // GoogleMapの作成
  map = new google.maps.Map(document.getElementById("map"), {
    zoom: 14,
    center: new google.maps.LatLng(lat, lng),
    mapTypeId: 'roadmap'
  });
  
  // ピンの作成
  pin = new google.maps.Marker({
    map: map,
    draggable: true,
    position: new google.maps.LatLng(lat, lng),
  });
  
  // サークルの作成
  circle = new google.maps.Circle({
    map: map,
    center: new google.maps.LatLng(lat, lng),
    // 半径を指定（半径1キロ）
    radius: 1000,
    strokeColor: "#FF0000",
    strokeOpacity: 0.8,
    strokeWeight: 2,
    fillColor: "#FF0000",
    fillOpacity: 0.35,
  });
  
  // マップに対して、ドラッグ（スワイプ）することでイベントが発火するように設定
  map.addListener('dragend', updateSearch);
}
window.initMap = initMap;

function updateSearch() {
  // ピンがマップの中央に来るように設定
  pin.setPosition(map.getCenter());
  // サークルがマップの中央に来るように設定
  circle.setCenter(map.getCenter());
  // filterSearchの呼び出し
  filterSearch();
}

function filterSearch() {
  // サークルの中心の緯度と経度を取得
  var circleCenter = circle.getCenter();
  // 取得した緯度と経度を扱えるように、circleLatLngオブジェクトに格納
  var circleLatLng = {
    latitude: circleCenter.lat(),
    longitude: circleCenter.lng()
  };

  // fetchを使用して、homeコントローラへリクエストを送信する。この際に、クエリ文字列として緯度と経度をもたせる。
  // こうすることで、homeコントローラでparams[:latitude」の形で使用できる。
  fetch(`/home.json?latitude=${circleLatLng.latitude}&longitude=${circleLatLng.longitude}`)
    .then(response => response.json())
    .then(data => {
      clearMarkers();
      // updateShopListは、検索でヒットしたショップを表示し、マップ上にマーカーを表示する関数
      // 今回は説明を省きます。詳しくはGithubをご覧ください。
      updateShopList('clothes', data.clothes);
      updateShopList('cafes', data.cafes);
    })
    .catch(error => console.error('Error:', error));
}
```

```ruby:app/controllers/maps_controller.rb
class MapsController < ApplicationController
  # マップの初期値（緯度経度等）をconcernに切り分けて記述してます。
  include MapConcern
  skip_before_action :authenticate_user!

  def home
    # 取得した緯度経度を浮動小数点に変換して、正常に扱えるようにします。
    latitude = params[:latitude].to_f
    longitude = params[:longitude].to_f

    search_shops(latitude, longitude)

    respond_to do |format|
      format.html
      format.json do
        render json: {
          clothes: @clothes.as_json(include: :shop_images),
          cafes: @cafes.as_json(include: :shop_images)
        }
      end
    end
  end

  private

  def search_shops(latitude, longitude)
    # セレクトショップとカフェそれぞれで検索をかけます。
    @clothes = circle_search(Clothes, latitude, longitude)
    @cafes = circle_search(Cafe, latitude, longitude)
  end

  def circle_search(model, latitude, longitude)
    # withinは、gem 'geokit-rails'のメソッドで、中心の緯度経度から指定した半径（サークルの半径と合わせる）内でのレコード検索を可能にするものです。
    # by_distanceも、gem 'geokit-rails'のメソッドで、原点からの距離順にレコードを検索するものです。
    model.includes(:shop_images).within(1, origin: [latitude, longitude]).by_distance(origin: [latitude, longitude])
  end
end

```
※マップ検索に焦点を当てて説明している関係でコードを省略していますので、詳しくはGithubをご覧ください。


### ②リスト保存、レビュー機能
リスト保存機能とレビュー機能については、Hotwire（Turbo✖️Stimulus）を使用してSPA風に実装しました。

### リスト保存
![save_list.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600084/e6602e29-471c-f0ef-4e73-9bbd58501143.gif)
実装の流れについては、説明が長くなりそうなので、時間がある時に別で記事にしたいと思います。

<br>

### レビュー機能
![review.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600084/5e1780ce-9a86-32d9-892d-cfbb8b063fb0.gif)

▼レビュー機能の実装については、以下の記事で紹介しております。
[Turbo✖️Stimulusを使用したモーダルでのレビュー機能を実装してみた](https://qiita.com/jinta_02/items/7a4e14ba5973823551ad)


### ③ショップデータの収集
GoogleMapで「セレクトショップ」や「カフェ」と検索すると、昔ながらの呉服店や大量のチェーンカフェ店が出てくると思います。服カフェでは、必要のないショップを表示しないように、厳選したショップをデータベースに保存するようにしました。ショップの情報については、GooglePlacesAPIを使用して情報を取得し、データベースへ保存しています。

<br>

保存の流れは以下のとおりです。
1. 保存したいショップ情報をcsvファイルに書き出す。
2. csvファイルに記述したショップの電話番号から、place_idを取得
3. 取得したplace_idから、ショップの詳細情報を取得
4. 取得したショップ詳細情報をデータベースへ保存

▼詳細は記事にまとめましたのでこちらをご覧ください。
[GooglePlacesAPIで取得したデータをデータベースへ保存する](https://qiita.com/jinta_02/items/0736c356d30875f0225d)

▼また、GooglePlacesAPIの導入についても以下の記事にまとめてますので、これから使用する予定の方はぜひご覧ください。
[GoglePlacesAPIを使用して情報を取得（Rails）](https://qiita.com/jinta_02/items/d7c62be909ac4edca5d2)

## 主な使用技術
### バックエンド
- Ruby 3.2.2
- Ruby on Rails 7.0.6
- PostgreSQL
- gem
  - devise
  - google_places
  - gon
  - geokit-rails
  - kaminari
- API
  - Google Maps JavaScript API
  - Google Places API
  - Google Geolocation API

### フロントエンド
- Tailwind CSS
- JavaScript
- Hotwire(Turbo, Stimulus)

### インフラ
- Heroku

## ER図
![er.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600084/6409bdd7-c545-7d28-c772-eb9df9e2d43f.png)

## 画面遷移図
https://www.figma.com/file/nxcfgLn3ZBsy3X9eND5Yrw/%E6%9C%8D%E3%82%AB%E3%83%95%E3%82%A7?type=design&node-id=0%3A1&mode=design&t=evAGQB4W63ESGNiF-1

## 開発の期間
### ①MVPリリースまで（2023年7月23日〜2023年10月1日）
MVPリリース時点では、マップ検索機能、フィルター検索機能、リスト保存機能のみを実装しました。

### ②本リリースまで（2023年10月2日〜10月29日）
MVPリリースの際にいただいたフィードバックについて修正を行うとともに、レビュー機能、現在地検索機能、レコメンド機能を追加しました。また、ユーザーがアプリ操作を迷わないようUIの修正も行いました。
現職と並行しての開発でしたが、当初予定していた約3ヶ月という期間で本リリースまでできてよかったです。

## 今後の展開
今後、サービス面・技術面で、以下のとおり追加・修正を行う予定です。

### サービス面
- ショップデータの追加（北海道、神奈川、大阪、福岡）
※現在は東京のショップデータしか保存していないため、他県のショップデータを追加し、検索範囲を広げる予定です。
- UIの修正

### 技術面
- テストコードの追加
- JavaScriptで記述しているgoogle_maps.jsをTypescriptに修正

## 最後に
今回、初めて１からサービスを作りましたが、本当に学びの連続でした。途中、実装がうまくいかずに停滞してしまう期間がありましたが、なんとか乗り越えて、サービスをリリースすることができて本当によかったです。

「服カフェ」については、今後も引き続き更新していきますので、ぜひ使っていただけると嬉しいです🙇‍♂️

この記事が今後個人開発する方の参考になりますと幸いです！
最後までご覧いただきありがとうございました！🙇‍♂️