【個人開発】動物好き必見！動物とのふれあいに特化した検索サービス『FLUFF MAPS』を作りました！

![top3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2253662/4ed74182-39f9-020b-a1bd-0959e9631f03.jpeg)

この度「動物とふれあうことができる施設を簡単に検索できるサービス【FLUFF MAPS】」をリリースしました！

__サービスURL (現在一時的にクローズしています)__
https://www.fluff-maps.com

__GitHub__
https://github.com/kazuki1025okumura/fluff_navi

## はじめに
はじめまして！奥村一貴と申します。
私自身、動物が大好きでよく牧場や動物園にふれあいに行きます。

ネットでそういった施設を探していて、「条件を絞り込んで検索できたらな」だったり「ふれあうことができる動物が一覧で見れたらな」と感じていて、それなら自分で作ってしまおう！と思い製作しました。

## このアプリへの思い

<details><summary>長いですが思いを書きました！</summary>

<br>
サービス作成開始前に「動物とふれあうのは好きですか？」というアンケートを行ったのですが７０％以上の方が「好き」または「大好き」という回答でした。「どちらかといえば好きと」いう回答を含めると９０%以上になります。
この結果からもやはり多くの方は動物が好きなことを再認識しました。

しかし動物関連の産業には「飼えなくなり捨てられるペットの問題」だったり、「動物のストレスを考慮しない動物カフェ」など、悲しい現実があります。

本サービスでは、施設情報や評判を細かくチェックした上で掲載したり、__施設詳細ページに報告機能__ をつけるなどの工夫をして動物に配慮が足りない施設をなるべく掲載しない努力をしています。

さらに __保護猫カフェ__ など、動物の保護活動を行なっていたり、動物の里親になることができる施設を特に力を入れて掲載しています。

このアプリを通じて１匹でも多くの保護動物が新しい家族に出会えたり、小さなお子さんが動物の可愛さや尊さを学べる機会を作ることができたら幸いです。
</details>

## サービス概要、使い方

#### １、 条件を指定して検索する
カテゴリーと都道府県（現在東京都と神奈川のみ）を選択して検索します。
- 検索結果をマップ上で見れます
- 検索結果一覧でふれあえる動物の種類が分かります
[![Image from Gyazo](https://i.gyazo.com/58d0cb7bf529ec54e9d554b70e383d50.gif)](https://gyazo.com/58d0cb7bf529ec54e9d554b70e383d50)

#### ２、フリーワードで検索する
入力フォームにキーワードを入力して検索します
- 施設名や地名など複数ワードを指定できます
- スペースは半角、全角どちらも対応しています
[![Image from Gyazo](https://i.gyazo.com/64fb1224d740893383e8e1b5f48b732d.gif)](https://gyazo.com/64fb1224d740893383e8e1b5f48b732d)

#### ３、施設の詳細情報を見る
- 詳細情報や、ふれあえる動物の種類を見れます
- ログインすることで施設の報告をすることができたり、施設の写真を投稿することができます
[![Image from Gyazo](https://i.gyazo.com/32fc1850e029f4b448b4d9f41f2cb4d3.gif)](https://gyazo.com/32fc1850e029f4b448b4d9f41f2cb4d3)

#### ４、カテゴリー、動物の種類を選択して該当する施設を一覧で表示する
[![Image from Gyazo](https://i.gyazo.com/d452377d14c891a09776b5f1ef87ce22.gif)](https://gyazo.com/d452377d14c891a09776b5f1ef87ce22)


## こだわりポイントや苦労したこと



### １、ユーザーのお気に入りの情報を元にオススメを表示するロジック

<br>

##### プロフィール編集画面で設定したお気に入りの動物やカテゴリーを元にトップページにオススメの施設を紹介する機能を作成しました。
コードの書き方や処理の方法にかなり悩みました。

```ruby

# ユーザーのお気に入りの動物のidを元に関連する施設をすべて取得する
scope :favo_animal, ->(animal) { Facility.joins(:managements).where('animal_id = ?', animal.id) }
scope :favo_category, ->(category) { Facility.joins(:facility_categories).where('category_id = ?', category.id) }

# 実際にビューやコントローラで使うメソッド
def suggest_to_login_user(user)
  animal = user.animal
  category = user.category
  recommend_data(animal, category)
end

# オススメの施設を選定するロジック
def recommend_data(animal, category)
  facilities = Facility.all
  if !animal && !category
    facilities.where(suggest: 1).includes(%i[facility_categories categories managements animals]).sample(3)
  else
    suggest_animal_facilities = animal ? facilities.favo_animal(animal).to_a : []
    suggest_category_facilities = category ? facilities.favo_category(category).to_a : []
    suggest_animal_facilities.concat(suggest_category_facilities).uniq.sample(3)
  end
end
```

<br>

- まず最上部の`scope`は動物やカテゴリーのidを元に、関連する施設をすべて取得します
```ruby
scope :favo_animal, ->(animal) { Facility.joins(:managements).where('animal_id = ?', animal.id) }
scope :favo_category, ->(category) { Facility.joins(:facility_categories).where('category_id = ?', category.id) }
```

<br>

- 次に実際にビューやコントローラで使うメソッドです
  - メソッドの引数に`current_user`を渡すことでそのユーザーがお気に入りに登録している動物やカテゴリーを変数に格納します
  - そしてオススメを選定するメソッドの引数にそれらを渡します
```ruby
def suggest_to_login_user(user)
  animal = user.animal
  category = user.category
  recommend_data(animal, category)
end
```

<br>

#### 最後にオススメ情報のロジックです ⬇︎

- `if`  __ユーザーがお気に入りに何も登録していない場合__
  - 施設のおすすめ度のカラムが`suggest`の施設をすべて取得してランダムに３つ返します

- `else`  __動物かカテゴリのどちらか、または両方がお気に入りに設定されている場合__
  - `scope`の式を元に関連する施設のレコードを取得して配列にします。設定されていない場合は空配列を返します
  - 取得したレコードの２つの配列を`concat`で結合して`uniq`で重複した値を削除した後、ランダムで３つ返します
```ruby
def recommend_data(animal, category)
  facilities = Facility.all  # 施設をすべて取得して変数に格納
  if !animal && !category
    facilities.where(suggest: 1).includes(%i[facility_categories categories managements animals]).sample(3)
  else
    suggest_animal_facilities = animal ? facilities.favo_animal(animal).to_a : []
    suggest_category_facilities = category ? facilities.favo_category(category).to_a : []
    suggest_animal_facilities.concat(suggest_category_facilities).uniq.sample(3)
  end
end
```
このようなかんじでトップページやプロフィール画面にオススメの施設を表示しています。
直感的ではなくて読みにくく、もっといい方法があると思うので今後も改良していきたいと思います。

<br>

<br>



#### ⬇︎ まだまだありますが長くなったので折りたたみました✨ 

<br>

<details><summary>２、プロフィール編集機能</summary>

#### １、プロフィール編集機能
##### Userモデルの`form_with`内で多対多の関係でアソシエーションを組んだ関連モデルの設定をする。
プロフィール編集画面でお気に入りの動物やカテゴリーを設定できる機能を実装しました。意外にもやることが多くて苦労しました。
[![Image from Gyazo](https://i.gyazo.com/39f5ca015a47bbcb46fe6034648e582b.png)](https://gyazo.com/39f5ca015a47bbcb46fe6034648e582b)
今回はそれぞれ一つずつしか設定できませんが、いつか複数登録できるように変更したくなった時のために多対多の関係でアソシエーションを設定しました。

１、まずユーザーと動物、カテゴリーの中間テーブルを作成してモデルに多対多のアソシエーションを設定します。

２、途中までは[Railsガイド](https://railsguides.jp/form_helpers.html#%E3%83%91%E3%83%A9%E3%83%A1%E3%83%BC%E3%82%BF%E3%81%AE%E5%91%BD%E5%90%8D%E3%83%AB%E3%83%BC%E3%83%AB%E3%82%92%E7%90%86%E8%A7%A3%E3%81%99%E3%82%8B-fields-for%E3%83%98%E3%83%AB%E3%83%91%E3%83%BC:~:text=%E3%81%AE%E6%96%B9%E3%81%A7%E3%81%99%E3%80%82-,8.3%20fields_for%E3%83%98%E3%83%AB%E3%83%91%E3%83%BC,-%E3%81%9F%E3%81%A8%E3%81%88%E3%81%B0%E3%80%81%E5%80%8B%E4%BA%BA%E3%81%AE)を参考にしたらそれなりに進めることができました。


３、ビューファイルの`form_with`内で`fields_for`ヘルパーを使用して関連モデルのセレクトボックスを作成します。

```erb:profiles/edit.html.erb
<%= form_with model: @user, url: profile_path, local: true do |f| %>

      、、、省略、、、

  <%= f.fields_for :favorite_animal do |animal_form| %>
    <%= animal_form.select :animal_id, Animal.pluck(:name, :id) %>
  <% end %>
  <%= f.fields_for :favorite_category do |category_form| %>
    <%= category_form.select :category_id, Category.pluck(:name, :id) %>
  <% end %>
<% end %>
```
#### ここがつまずきポイントでした
Railsガイドを参考に上記のコードを書いたのですがこれではフォームが表示されません。
`f.fields_for`の行を以下のように修正することで解決できました。
```erb
<%= f.fields_for :favorite_animal, (current_user.favorite_animal || FavoriteAnimal.new) do |animal_form| %>
<%= f.fields_for :favorite_category, (current_user.favorite_category || FavoriteCategory.new) do |category_form| %>
```
- `current_user.favorite_〇〇`で現在のユーザーのお気に入りのオブジェクトを設定
- これだけだとまだ設定していない場合にセレクトボックスが表示されないので`モデル名.new`でインスタンスを作成

<br>

<br>

</details>

<br>

<details><summary>３、Googleマップの吹き出しに多対多の関係のモデルを一覧で表示する</summary>

##### マップに表示されるマーカーをクリックすると吹き出しが出るのですが、施設名と住所だけだと何の施設なのか分かりにくいので関連モデルのカテゴリーを繰り返し処理で表示しました
[![Image from Gyazo](https://i.gyazo.com/24ad326ac53bb71069fcdef1f211cb75.png)](https://gyazo.com/24ad326ac53bb71069fcdef1f211cb75)

#### 苦労した点

- `ActiveRecord`のアソシエーションで設定したメソッドがそのままでは`JavaScript`では使えない


今回コントローラで取得した施設のデータを変数に格納し、`gon`というgemを使ってマップの処理を記述している`JavaScript`のファイルに渡すというやり方をしました。
```ruby
  def show
    @facility = Facility.find(params[:id])
    gon.facilities = [@facility]
    # これでjsファイルで facilities という変数が使える
    # 一覧と同じマップを使うので配列にする
  end
```
しかしこのままでは`JavaScript`ファイルに関連モデルを渡すことができずエラーが起きました。

##### 解決方法

- コントローラ側で関連モデルの情報をハッシュ形式の`json`に変換する必要があります


- 以下のようにすることで`JavaScript`ファイルに関連モデルを渡すことができ、アソシエーションで定義した`categories`というメソッドが使えるようになります ⬇︎ [関連記事](https://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html#method-i-as_json)
```ruby
  def show
    @facility = Facility.find(params[:id])
    gon.facilities = [@facility].as_json(include: :categories)
  end
```

<br>

次にマーカーを作成する`for`文の中でカテゴリー名を表示する`for`文を書く
（例）⬇︎
```javascript:google_maps.js
  // カテゴリーを配列で定数cに代入
  const c = facilities[i]['categories']

  // カテゴリーの名前だけを取り出して変数に代入
  var categoriesHtml = '<ul>'
  for (let j = 0; j < c.length; j++) {
    categoriesHtml += `<li>${c[j].name}</li>`
  };
  categoriesHtml += '</ul>'
```
最後にカテゴリーの名前を格納した変数を吹き出しの処理に加えて完成です！


検証ツールで`@facilities`の中身を見てみると、しっかりカテゴリーがあることが分かります ⬇︎
[![Image from Gyazo](https://i.gyazo.com/b60d8c3268f25ab0b1e7edca8d1605a6.png)](https://gyazo.com/b60d8c3268f25ab0b1e7edca8d1605a6)

<br>

</details>

<br>

<details><summary>４、GooglePlacesAPIを使って施設情報を取得</summary>

##### 本サービスでは本番データを作成するために [GooglePlacesAPI](https://developers.google.com/maps/documentation/places/web-service/overview)を使用しました

#### 処理の流れ ⬇︎

- １回目のリクエストで`place_id`を取得するために`predections`を取り出して返す
```ruby
  def get_data(word) # 引数には施設名を渡します
    query = URI.encode_www_form(
      input: word,
      language: 'ja',
      key: ENV['API_KEY']
    )

    url = URI("https://maps.googleapis.com/maps/api/place/autocomplete/json?#{query}")

    https = Net::HTTP.new(url.host, url.port)
    https.use_ssl = true

    request = Net::HTTP::Get.new(url)

    response = https.request(request)
    search_result = JSON.parse(response.read_body)
    search_result['predictions']
  end
```

<br>

- 次に上記のメソッドを使って`place_id`を取り出し、２回目のリクエストを行う（詳細情報を取得するため）

  - 取得したJSON形式のデータを`JSON.parse`でrubyのハッシュ形式に変換する


  - 必要な値を取り出し、空配列`data`に格納していく
```ruby
  def detailed_search(prefecture, a)
    data = []

    # 検索結果の place_id を取り出しそれを元に詳細情報を検索する
    place = get_data(a).first['place_id']
    place_id = place

    place_id_query = URI.encode_www_form(
      place_id: place_id,
      language: 'ja',
      key: ENV['API_KEY']
    )
    id_search_url = URI("https://maps.googleapis.com/maps/api/place/details/json?#{place_id_query}")
    id_search_https = Net::HTTP.new(id_search_url.host, id_search_url.port)
    id_search_https.use_ssl = true
    id_search_request = Net::HTTP::Get.new(id_search_url)

    new_response = id_search_https.request(id_search_request)

    # JSON形式のデータをrubyのハッシュ形式に変換する
    new_data = JSON.parse(new_response.read_body)

    name = new_data['result']['name']
    address = new_data['result']['formatted_address']
    longitude = new_data['result']['geometry']['location']['lng']
    latitude = new_data['result']['geometry']['location']['lat']

    # 省略

    prefecture_id = prefecture

    # 空配列にデータを格納する
    data << [name, address, longitude, latitude, tel_number, opening_hours, web_site, prefecture_id]
    data
  end
```

<br>

- 配列に格納したデータをCSVファイルに出力する

後は別途ファイルを作成してCSVファイルに出力するメソッドを作成、処理を実行して完成です！
```ruby
  def csv_generate
    CSV.open(Rails.root.join('lib/csv/tokyo.csv'), 'a', force_quotes: true) do |csv|
      detailed_search('都道府県のid', '施設名').each do |d|
        csv << d
      end
    end
  end
```

<br>

</details>

<br>

<details><summary>５、Google広告を出して検証したり、SEO対策などをして将来的なマーケティングを意識</summary>

##### 実際に使ってもらうためにはUI/UXやSEO対策が大事だと考え、サービスの将来的な成長を常に意識していました

#### 行なった取り組み

### １、SEO対策

- ページのタイトルや`h1`タグを適切に設定する ⬇︎

[Keyword Tool](https://keywordtool.io/jp) や [キーワードプランナー](https://ads.google.com/intl/ja_jp/getstarted/?subid=jp-ja-ha-awa-sk-c-k00!o3~CjwKCAiAjs2bBhACEiwALTBWZU84fzFIUShYdpQdCTiCy5fYSWcr8W8dS5SZC0e_5VwfjLe4d9gMghoCLUoQAvD_BwE~130687383341~kwd-87791948946~16380805420~584102955520&gclid=CjwKCAiAjs2bBhACEiwALTBWZU84fzFIUShYdpQdCTiCy5fYSWcr8W8dS5SZC0e_5VwfjLe4d9gMghoCLUoQAvD_BwE&gclsrc=aw.ds)などのツールを使い、キーワードの候補を決めたりキーワードの月間検索ボリュームを調査しました。

例えば __「東京 動物 ふれあい」__ や __「神奈川 動物 ふれあい」__ などは月間１０００回以上検索されているキーワードで、競合もあまり多くないです。
このようなかんじで __ターゲットの検索キーワード__ を決め、タイトルや`h1`タグ、キーワードなどを動的に設定しました。

#### 例） 施設詳細画面の`h1`タグ
```html
<h1>
  <span><%= @facility.name %></span><%= "#{@facility.animals.pluck(:name).slice(0,3).join('や')}とふれあおう！営業時間などの詳細情報も紹介しています" %>
</h1>
```
⬇︎ こんなかんじで動的に出力されます
[![Image from Gyazo](https://i.gyazo.com/45643f09c4bbb4d3cd224286c7c75fc8.png)](https://gyazo.com/45643f09c4bbb4d3cd224286c7c75fc8)


- `h2` `h3`タグを`h1`タグに基づいた文章でしっかり設定



### ２、Google広告に出稿

##### １日の利用金額の上限を決めてGoogle広告を出しました
[![Image from Gyazo](https://i.gyazo.com/78e0b6b4d23ecff8a2b8a95ae8602a06.png)](https://gyazo.com/78e0b6b4d23ecff8a2b8a95ae8602a06)

#### 目的１

総PV数やUU数はGoogleアナリティクスで管理していますが、実際にGoogle検索でヒットした場合はどれくらいの方にクリックしていただけるのかを検証したい
⬇︎
- ターゲットが適切か
- タイトルや説明が適切か & 分かりやすいか

#### 結果

広告を出してから４日目で __表示回数 ６０回__ 、 __クリック数 ９回__ という結果でした。
表示回数のうち、自分が５回ほど表示したことを考慮すると５、６回表示されたら１回はクリックされるということになります。

[![Image from Gyazo](https://i.gyazo.com/25311064e2c57b585e6e251836e3dd2c.png)](https://gyazo.com/25311064e2c57b585e6e251836e3dd2c)
広告と言ってもまだ検索結果のトップではなく、２ページ目以降に表示されることを考えるとそこまで悪くない数字だと思いました。

やはりターゲットのキーワードの選定は悪くなかったと思います。


#### 目的２

実際にGoogle検索から訪れてくれたユーザーの滞在時間や、実際にどの機能を使ってもらえるのか検証したい
⬇︎

- 実際にアプリ内で検索をしてもらえているとしたらサービスとしてはなりたっている
- 滞在時間を伸ばすことが目標
    - ユーザーを飽きさせない仕掛けや魅力的な何かがあれば平均の滞在時間は伸びていくと思うため

#### 結果

##### 広告から訪れてくれたユーザーさんの動きを検証してみると、ほとんどの方がアプリ内で実際に検索したり、トップページのリンクをクリックしていただいていることが確認できた。
- このことからずっと意識してきた直感的な使いやすさや最低限のUIはクリアしているかなと思いました



##### ページごとや全体の滞在時間を見てみるとかなり短いなという印象。

- ユーザーのニーズに合っていてもUIが魅力的じゃなかったり、夢中になる仕掛けが無かったりするとやはりすぐ飽きてしまうと思うので今後の大きな課題です

<br>

</details>





など


## 実装した機能一覧
#### ログインユーザー
- ユーザー登録、ログイン

- パスワードリセット機能
- プロフィール編集機能
- プロフィール画面に投稿した画像を一覧表示
  - 投稿した画像の削除（非同期通信）
- お気に入りの動物、カテゴリを設定
- お気に入りを元にオススメの施設を表示する機能
- 写真投稿機能（詳細ページに一覧で表示される）
- 写真が投稿されると施設のメイン画像をデフォルト画像から投稿された画像に変える
- 施設のお気に入り機能（非同期通信）
- 施設の報告機能
  - 報告が完了すると管理者にメールが送信されるように設定


#### 全ユーザー
- ユーザー登録、ログイン、ログアウト機能


- Twitterログイン機能
- 施設の検索
  - セレクトボックス
  - フリーワード検索
- 施設情報の閲覧
- マップを表示
  - 現在地を取得して表示
- Twitter、LINEで施設を共有する機能

など


## 使用技術

#### バックエンド

- Ruby 3.1.2
- Rails 6.1.6

#### 主要ライブラリ

- Sorcery（ログイン）
- SeedFu（データ作成）
- CarrierWave(画像アップロード)
- ActiveHash（擬似的なテーブル作成）
- MetaTags(SEO)
- RSpec(テスト)
- RuboCop(リントチェック)

#### フロントエンド

- HTML/SCSS/JavaScript
- CSSフレームワーク
  - Tailwind CSS
  - daisyUI
  
#### インフラ

- Heroku
- PostgreSQL（データベース）
- Amazon S3（本番環境の画像アップロード）
- SendGrid（本番環境のメール送信）

#### その他

- TwitterAPI（Twitterログイン）
- GoogleMapsAPI
- GooglePlacesAPI（施設データ取得）

## ER図
[![Image from Gyazo](https://i.gyazo.com/e473ce7c0cc8dd0e4f226a5ab7796474.png)](https://gyazo.com/e473ce7c0cc8dd0e4f226a5ab7796474)

## おわりに

最後までご覧いただきありがとうございました！

実際に自分でサービスを作成する過程は本当に楽しくて夢中になってしまいました。
デザインやSEO対策、冗長なコードなど課題は多いですがこれからもサービスを改善していったり、新しい技術にチャレンジしていきたいと思います！


▼ FLUFF MAPS
https://www.fluff-maps.com

▼ Twitterアカウント
https://twitter.com/kazuki_okumura

## 追記（2024年3月31日）
おかげさまでUU数34,000、PV数88,000を突破しました！
SEO対策の効果が日に日に出てきて、多い日ではGoogle検索から200以上クリックされる日もあります。

クラウドの利用料金の影響などもあり、一時期サービスの提供をストップしていた時期もありましたがもっとたくさんの方々に使っていただけるようにサービスの発展に力を入れていこうと思います。
これからもFLUFF MAPSをよろしくお願いいたします！🐥✨