この度「動物とふれあうことができる施設を簡単に検索できるサービス【FLUFF MAPS】」をリリースしました!
サービスURL (現在一時的にクローズしています)
https://www.fluff-maps.com
GitHub
https://github.com/kazuki1025okumura/fluff_navi
はじめに
はじめまして!奥村一貴と申します。
私自身、動物が大好きでよく牧場や動物園にふれあいに行きます。
ネットでそういった施設を探していて、「条件を絞り込んで検索できたらな」だったり「ふれあうことができる動物が一覧で見れたらな」と感じていて、それなら自分で作ってしまおう!と思い製作しました。
このアプリへの思い
長いですが思いを書きました!
サービス作成開始前に「動物とふれあうのは好きですか?」というアンケートを行ったのですが70%以上の方が「好き」または「大好き」という回答でした。「どちらかといえば好きと」いう回答を含めると90%以上になります。 この結果からもやはり多くの方は動物が好きなことを再認識しました。
しかし動物関連の産業には「飼えなくなり捨てられるペットの問題」だったり、「動物のストレスを考慮しない動物カフェ」など、悲しい現実があります。
本サービスでは、施設情報や評判を細かくチェックした上で掲載したり、施設詳細ページに報告機能 をつけるなどの工夫をして動物に配慮が足りない施設をなるべく掲載しない努力をしています。
さらに 保護猫カフェ など、動物の保護活動を行なっていたり、動物の里親になることができる施設を特に力を入れて掲載しています。
このアプリを通じて1匹でも多くの保護動物が新しい家族に出会えたり、小さなお子さんが動物の可愛さや尊さを学べる機会を作ることができたら幸いです。
サービス概要、使い方
1、 条件を指定して検索する
カテゴリーと都道府県(現在東京都と神奈川のみ)を選択して検索します。
2、フリーワードで検索する
入力フォームにキーワードを入力して検索します
3、施設の詳細情報を見る
4、カテゴリー、動物の種類を選択して該当する施設を一覧で表示する
こだわりポイントや苦労したこと
1、ユーザーのお気に入りの情報を元にオススメを表示するロジック
プロフィール編集画面で設定したお気に入りの動物やカテゴリーを元にトップページにオススメの施設を紹介する機能を作成しました。
コードの書き方や処理の方法にかなり悩みました。
# ユーザーのお気に入りの動物の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
- まず最上部の
scope
は動物やカテゴリーの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) }
- 次に実際にビューやコントローラで使うメソッドです
- メソッドの引数に
current_user
を渡すことでそのユーザーがお気に入りに登録している動物やカテゴリーを変数に格納します - そしてオススメを選定するメソッドの引数にそれらを渡します
- メソッドの引数に
def suggest_to_login_user(user)
animal = user.animal
category = user.category
recommend_data(animal, category)
end
最後にオススメ情報のロジックです ⬇︎
-
if
ユーザーがお気に入りに何も登録していない場合- 施設のおすすめ度のカラムが
suggest
の施設をすべて取得してランダムに3つ返します
- 施設のおすすめ度のカラムが
-
else
動物かカテゴリのどちらか、または両方がお気に入りに設定されている場合-
scope
の式を元に関連する施設のレコードを取得して配列にします。設定されていない場合は空配列を返します - 取得したレコードの2つの配列を
concat
で結合してuniq
で重複した値を削除した後、ランダムで3つ返します
-
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
このようなかんじでトップページやプロフィール画面にオススメの施設を表示しています。
直感的ではなくて読みにくく、もっといい方法があると思うので今後も改良していきたいと思います。
⬇︎ まだまだありますが長くなったので折りたたみました✨
2、プロフィール編集機能
1、プロフィール編集機能
Userモデルのform_with
内で多対多の関係でアソシエーションを組んだ関連モデルの設定をする。
プロフィール編集画面でお気に入りの動物やカテゴリーを設定できる機能を実装しました。意外にもやることが多くて苦労しました。
今回はそれぞれ一つずつしか設定できませんが、いつか複数登録できるように変更したくなった時のために多対多の関係でアソシエーションを設定しました。
1、まずユーザーと動物、カテゴリーの中間テーブルを作成してモデルに多対多のアソシエーションを設定します。
2、途中まではRailsガイドを参考にしたらそれなりに進めることができました。
3、ビューファイルのform_with
内でfields_for
ヘルパーを使用して関連モデルのセレクトボックスを作成します。
<%= 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
の行を以下のように修正することで解決できました。
<%= 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
でインスタンスを作成
3、Googleマップの吹き出しに多対多の関係のモデルを一覧で表示する
マップに表示されるマーカーをクリックすると吹き出しが出るのですが、施設名と住所だけだと何の施設なのか分かりにくいので関連モデルのカテゴリーを繰り返し処理で表示しました
苦労した点
-
ActiveRecord
のアソシエーションで設定したメソッドがそのままではJavaScript
では使えない
今回コントローラで取得した施設のデータを変数に格納し、gon
というgemを使ってマップの処理を記述しているJavaScript
のファイルに渡すというやり方をしました。
def show
@facility = Facility.find(params[:id])
gon.facilities = [@facility]
# これでjsファイルで facilities という変数が使える
# 一覧と同じマップを使うので配列にする
end
しかしこのままではJavaScript
ファイルに関連モデルを渡すことができずエラーが起きました。
解決方法
-
コントローラ側で関連モデルの情報をハッシュ形式の
json
に変換する必要があります -
以下のようにすることで
JavaScript
ファイルに関連モデルを渡すことができ、アソシエーションで定義したcategories
というメソッドが使えるようになります ⬇︎ 関連記事
def show
@facility = Facility.find(params[:id])
gon.facilities = [@facility].as_json(include: :categories)
end
次にマーカーを作成するfor
文の中でカテゴリー名を表示するfor
文を書く
(例)⬇︎
// カテゴリーを配列で定数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
の中身を見てみると、しっかりカテゴリーがあることが分かります ⬇︎
4、GooglePlacesAPIを使って施設情報を取得
本サービスでは本番データを作成するために GooglePlacesAPIを使用しました
処理の流れ ⬇︎
- 1回目のリクエストで
place_id
を取得するためにpredections
を取り出して返す
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
-
次に上記のメソッドを使って
place_id
を取り出し、2回目のリクエストを行う(詳細情報を取得するため)-
取得したJSON形式のデータを
JSON.parse
でrubyのハッシュ形式に変換する -
必要な値を取り出し、空配列
data
に格納していく
-
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
- 配列に格納したデータをCSVファイルに出力する
後は別途ファイルを作成してCSVファイルに出力するメソッドを作成、処理を実行して完成です!
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
5、Google広告を出して検証したり、SEO対策などをして将来的なマーケティングを意識
実際に使ってもらうためにはUI/UXやSEO対策が大事だと考え、サービスの将来的な成長を常に意識していました
行なった取り組み
1、SEO対策
- ページのタイトルや
h1
タグを適切に設定する ⬇︎
Keyword Tool や キーワードプランナーなどのツールを使い、キーワードの候補を決めたりキーワードの月間検索ボリュームを調査しました。
例えば 「東京 動物 ふれあい」 や 「神奈川 動物 ふれあい」 などは月間1000回以上検索されているキーワードで、競合もあまり多くないです。
このようなかんじで ターゲットの検索キーワード を決め、タイトルやh1
タグ、キーワードなどを動的に設定しました。
例) 施設詳細画面のh1
タグ
<h1>
<span><%= @facility.name %></span><%= "#{@facility.animals.pluck(:name).slice(0,3).join('や')}とふれあおう!営業時間などの詳細情報も紹介しています" %>
</h1>
-
h2
h3
タグをh1
タグに基づいた文章でしっかり設定
2、Google広告に出稿
1日の利用金額の上限を決めてGoogle広告を出しました
目的1
総PV数やUU数はGoogleアナリティクスで管理していますが、実際にGoogle検索でヒットした場合はどれくらいの方にクリックしていただけるのかを検証したい
⬇︎
- ターゲットが適切か
- タイトルや説明が適切か & 分かりやすいか
結果
広告を出してから4日目で 表示回数 60回 、 クリック数 9回 という結果でした。
表示回数のうち、自分が5回ほど表示したことを考慮すると5、6回表示されたら1回はクリックされるということになります。
広告と言ってもまだ検索結果のトップではなく、2ページ目以降に表示されることを考えるとそこまで悪くない数字だと思いました。
やはりターゲットのキーワードの選定は悪くなかったと思います。
目的2
実際にGoogle検索から訪れてくれたユーザーの滞在時間や、実際にどの機能を使ってもらえるのか検証したい
⬇︎
- 実際にアプリ内で検索をしてもらえているとしたらサービスとしてはなりたっている
- 滞在時間を伸ばすことが目標
- ユーザーを飽きさせない仕掛けや魅力的な何かがあれば平均の滞在時間は伸びていくと思うため
結果
広告から訪れてくれたユーザーさんの動きを検証してみると、ほとんどの方がアプリ内で実際に検索したり、トップページのリンクをクリックしていただいていることが確認できた。
- このことからずっと意識してきた直感的な使いやすさや最低限のUIはクリアしているかなと思いました
ページごとや全体の滞在時間を見てみるとかなり短いなという印象。
- ユーザーのニーズに合っていてもUIが魅力的じゃなかったり、夢中になる仕掛けが無かったりするとやはりすぐ飽きてしまうと思うので今後の大きな課題です
など
実装した機能一覧
ログインユーザー
-
ユーザー登録、ログイン
-
パスワードリセット機能
-
プロフィール編集機能
-
プロフィール画面に投稿した画像を一覧表示
- 投稿した画像の削除(非同期通信)
-
お気に入りの動物、カテゴリを設定
-
お気に入りを元にオススメの施設を表示する機能
-
写真投稿機能(詳細ページに一覧で表示される)
-
写真が投稿されると施設のメイン画像をデフォルト画像から投稿された画像に変える
-
施設のお気に入り機能(非同期通信)
-
施設の報告機能
- 報告が完了すると管理者にメールが送信されるように設定
全ユーザー
-
ユーザー登録、ログイン、ログアウト機能
-
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図
おわりに
最後までご覧いただきありがとうございました!
実際に自分でサービスを作成する過程は本当に楽しくて夢中になってしまいました。
デザインや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をよろしくお願いいたします!🐥✨