-
Install Elixir
- https://elixir-lang.org/install.html
elixir -v
-
Install Phoenix
mix archive.install hex phx_new 1.4.1
-
Create project
restaurants
mix phx.new restaurants --no-ecto
(create Phoenix project without DB setup — not needed for this project)- Fetch and install dependencies? [Yn] y
cd restaurants
-
Run initial project
- Install
nodejs
cd assets && npm install
iex -S mix phx.server
- Open browser
http://localhost:4000
- Install
- Communicate with the Zomato API
-
Add
tomato
library tomix.exs
defp deps do [ ... {:tomato, "~> 0.1.1"}, ... ] end
-
Configure
tomato
appconfig :tomato, zomato_api_key: "06f7e6b2df1c9baf49e3a69a3defac49", zomato_api_uri: "https://developers.zomato.com/api/v2.1/"
-
Restart web app
<Ctrl-C><Ctrl-C> iex -S mix phx.server
-
Explore the API of the
tomato
library and see how it maps to the Zomato REST API -
Try to find restaurants by geolocation
Tomato.geocode(38.733563, -9.144688)
-
-
Create
/restaurants
endpoint in router# lib/restaurants_web/router.ex scope "/", RestaurantsWeb do pipe_through :browser get "/", PageController, :index get "/restaurants", RestaurantController, :index end
-
Create controller
# lib/restaurants_web/controllers/restaurant_controller.ex defmodule RestaurantsWeb.RestaurantController do use RestaurantsWeb, :controller @lisbon_id 82 def index(conn, _params) do {:ok, restaurants} = Tomato.search(%{entity_type: "city", entity_id: @lisbon_id}) render(conn, "index.html", restaurants: restaurants) end end
-
Create view
# lib/restaurants_web/views/restaurant_view.ex defmodule RestaurantsWeb.RestaurantView do use RestaurantsWeb, :view end
-
Create template
# lib/restaurants_web/templates/restaurant/index.html.eex <%= for restaurant <- @restaurants do %> <li><%= restaurant.name %></li> <% end %>
-
Visit
http://localhost:4000/restaurants
and you should see something like the following:
-
Add Leaflet.js library to render map
-
Add
<link>
and<script>
tags to app layout file
# lib/restaurants_web/templates/layout/app.html.eex <head> ... <link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.4/dist/leaflet.css" integrity="sha512-puBpdR0798OZvTTbP4A8Ix/l+A4dHDD0DGqYW6RQ+9jxkRFclaxxQb/SJAWZfWAkuyeQUytO7+7N4QKrDh+drA==" crossorigin=""/> <script src="https://unpkg.com/leaflet@1.3.4/dist/leaflet.js" integrity="sha512-nMMmRyTVoLYqjP9hrbed9S+FzjZHW5gY1TWCHA5ckwXZBadntCNs8kEqAWdrb9O7rxbCaA4lKTIWjDXZxflOcA==" crossorigin=""></script> </head>
-
Update restaurant view to display Leaflet.js map
# lib/restaurants_web/templates/restaurant/index.html.eex <div style="height:720px; width:1280px;" id="mapid"></div> <script type="text/javascript"> window.mymap = L.map('mapid').setView([38.71667, -9.16667], 13); L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', { attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CartoDB</a>', reuseTiles: true, detectRetina: true, maxZoom: 18, minZoom: 1, noWrap: true }).addTo(window.mymap); </script>
-
Update max width on main container
# assets/css/phoenix.css # ... .container { # ... max-width: 133.0rem; # ... } # ...
-
Visit
http://localhost:4000/restaurants
and you should see something like the following:
-
Add markers for each restaurant on top of Leaflet.js map
# lib/restaurants_web/templates/restaurant/index.html.eex window.mymap = ... <%= for restaurant <- @restaurants do %> var marker = L.marker([ <%= restaurant.location.latitude %>, <%= restaurant.location.longitude %>, ]).addTo(mymap); marker.bindPopup("<a href=\"<%= restaurant.url %>\"><%= restaurant.name %></a></br>"); <% end %> L.tileLayer(... ...
-
Visit
http://localhost:4000/restaurants
and you should see something like the following:
-
Add user channel to communicate with front-end via WebSockets
# lib/restaurants_web/channels/user_socket.ex channel "user:*", RestaurantsWeb.UserChannel
# lib/restaurants_web/channels/user_channel.ex defmodule RestaurantsWeb.UserChannel do use RestaurantsWeb, :channel require Logger def join("user:" <> user_id, _auth, socket) do Logger.info("Receiving connection for user #{user_id}") {:ok, socket} end end
-
Change socket connection to subscribe to user topic
// assets/js/socket.js ... const channel = socket.channel("user:123", {}) channel.join() .receive("ok", resp => { console.log("Joined successfully", resp) }) .receive("error", resp => { console.log("Unable to join", resp) }) ... export default socket export {channel}
-
Create dynamic restaurants module (used in next step)
// assets/js/dynamic_restaurants.js import {channel} from "./socket" let DynamicRestaurants = { init() { console.log("Init DynamicRestaurants") }, } export default DynamicRestaurants
-
Glue everything together by actually calling
DynamicRestaurants.init()
// assets/js/app.js ... import DynamicRestaurants from "./dynamic_restaurants" DynamicRestaurants.init()
-
Visit
http://localhost:4000/restaurants
and you should see something like the following:
-
Remove rendering of restaurants via MVC model
# lib/restaurants_web/templates/restaurant/index.html.eex # Remove the following code ... <%= for restaurant <- @restaurants do %> var marker = L.marker([ <%= restaurant.location.latitude %>, <%= restaurant.location.longitude %>, ]).addTo(mymap); marker.bindPopup("<a href=\"<%= restaurant.url %>\"><%= restaurant.name %></a></br>"); <% end %> ...
-
Visit
http://localhost:4000/restaurants
and you should see something like the following:
-
Render restaurants via WebSockets using Phoenix channels
-
Server side (back-end):
# lib/restaurants_web/channels/user_channel.ex ... def handle_in("get_restaurants", %{"coords" => %{"lat" => lat, "lng" => lng}}, socket) do {:ok, %{"nearby_restaurants" => restaurants}} = Tomato.geocode(lat, lng) response = restaurants |> Enum.map(&render_restaurant/1) {:reply, {:ok, %{restaurants: response}}, socket} end def render_restaurant(%{"restaurant" => restaurant}) do %{ name: restaurant["name"], url: restaurant["url"], latitude: restaurant["location"]["latitude"], longitude: restaurant["location"]["longitude"] } end
-
Client side (front-end):
// assets/js/dynamic_restaurants.js ... let DynamicRestaurants = { init() { this.getRestaurants(window.mymap.getCenter()) }, getRestaurants(coords) { channel.push("get_restaurants", {coords: coords}).receive( "ok", ({restaurants}) => { restaurants.forEach(r => { DynamicRestaurants.renderRestaurant(r.latitude, r.longitude, r.name, r.url) }); } ) }, renderRestaurant(lat, long, name, url) { const marker = window.L.marker(window.L.latLng(lat, long)).addTo(window.mymap); marker.bindPopup(`<a href="${url}">${name}</a></br>`) } }
-
-
Visit
http://localhost:4000/restaurants
and you should see something like the following:
-
Add handler for map move
// assets/js/dynamic_restaurants.js init() { window.mymap.on('moveend', this.handleMapMove) this.getRestaurants(window.mymap.getCenter()) }, ... handleMapMove() { DynamicRestaurants.getRestaurants(window.mymap.getCenter()) }
-
Now clear previous markers on each move
# lib/restaurants_web/templates/restaurant/index.html.eex ... window.mymap = L.map('mapid').setView([38.71667, -9.16667], 13) window.layerGroup = L.layerGroup().addTo(window.mymap) ...
// assets/js/dynamic_restaurants.js let DynamicRestaurants = { ... renderRestaurant(lat, long, name, url) { const marker = window.L.marker(window.L.latLng(lat, long)).addTo(window.layerGroup) marker.bindPopup(`<a href="${url}">${name}</a></br>`) }, handleMapMove() { window.layerGroup.clearLayers() DynamicRestaurants.getRestaurants(window.mymap.getCenter()) } }
-
Visit
http://localhost:4000/restaurants
and you should see something like the following:
-
Add handler for
get_favorite_restaurants
message// assets/js/dynamic_restaurants.js let DynamicRestaurants = { init() { window.mymap.on('moveend', this.handleMapMove); channel.on("get_favorite_restaurants", ({restaurants}) => restaurants.forEach(r => { this.renderFavoriteRestaurant(r.latitude, r.longitude, r.name, r.url) }) ) // ... renderFavoriteRestaurant(lat, long, name, url) { const icon = window.L.divIcon({ iconAnchor: [6, 60], popupAnchor: [0, -50], className: 'no-background', html: '<div class="fa fa-star fa-big orange"></div>' }); const favorite_marker = window.L.marker(L.latLng(lat, long), { icon }).addTo(window.layerGroup); favorite_marker.bindPopup(`<a href="${url}">${name}</a></br>`) }, // ...
-
Update
app.css
with necessary classes to render stars above markers/* assets/css/app.css */ /* ... */ .orange { color: orange; } .fa-big { transform: scale(1.5, 1.5); } .no-background { background: transparent; } @import "./phoenix.css";
-
Add
link
tag to FontAwesome (to user the star icon)# lib/restaurants_web/templates/layout/app.html.eex # ... <head> # ... <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous"> # ... </head> # ...
-
Asynchronously process favorite restaurants in the server (and broadcast them)
# lib/restaurants_web/channels/user_channel.ex # ... def handle_in("get_restaurants", %{"coords" => %{"lat" => lat, "lng" => lng}}, socket) do # ... spawn_link(__MODULE__, :process_favorite_restaurants, [socket, restaurants]) # ... def process_favorite_restaurants(socket, restaurants) do favorite_restaurants = restaurants |> Enum.filter(fn restaurant -> restaurant |> Map.get("restaurant") |> Map.get("user_rating") |> Map.get("aggregate_rating") |> String.to_float() |> Kernel.>(4.7) end) |> Enum.map(&render_restaurant/1) broadcast!(socket, "get_favorite_restaurants", %{restaurants: favorite_restaurants}) end end
-
Visit
http://localhost:4000/restaurants
and you should see something like the following:
The End.
We hope you enjoyed the workshop :) Thank you!