a full stack app for ice cream reviews

live app: link

code repo: Link

Throughout my graduate degree I have taken advantage of the fact that my tuition is covered by a fellowship as well as the flexibility of my schedule to continue to take classes beyond the requirements of my degree. This past spring I took a course in the computer science department called “Internet Programming,” which as the name suggests, covers an introduction to web development.

I learned a ton and made several projects throughout the course for the homework assignments. Unfortunately, we were warned that if those projects ever made their way onto a public github repo we would earn a retroactive F grade (couldn’t they just make new projects each year?).

Wanting to apply my new web skills I brainstormed for websites to make until I remembered a spreadsheet I had made with my fiancee tracking our grades for all the ice cream shops we visited in Minnesota. How much cooler would that be as a website?

So here’s what it would need:

I picked the technologies I had some experience with, which meant Node.js and Express for the framework, Bootstrap for the frontend (still learning React…), and MongoDB for the connected database.

I spent a lot of the project learning to use Bootstrap effectively, and once I figured it out it actually makes front end work pretty effortless - especially with forms and responsive web design.

There are a few neat features I’d like to highlight.

Autocomplete

The first is an autocomplete feature on the new shop form. I didn’t want duplicate shops in the database so I added an autocomplete feature to the form that’ll tell you if the shop is already in the database. Additionally, the site throws an error if you try and add a duplicate shop. I handled the autocomplete request using the following route:

router.get('/shop_name_autocomplete', checkAuth.checkAuthentication, async (request, response) => {

  try {

    const collection = Connection.db.db("icecream_mn").collection("icecream_shops");

    let result = await collection.aggregate([
      {
        "$search": {
          "index": "name_index",
          "autocomplete": {
            "query": `${request.query.query}`,
            "path": "name",
            "fuzzy": {
              "maxEdits": 2,
              "prefixLength": 3
            }
          }
        }
      }
    ]).toArray();
    if ( result.length > 0 ) {
      response.send(result)
    } else {
      response.send([{"name": "None found...", "_id":"0"}])
    }
  } catch (e) {
    response.status(500).send({ message: e.message });
  }
});

The only extra step on the database side was I had to set up an index on the shop name field. On the front end I took advantage of the jQuery autocomplete helper but I actually think it would’ve been about as difficult just to add the event listener by hand. This is the client side code:

$(document).ready(function () {
  $("#shop_name_input").autocomplete({
    source: async function(request, response) {
      let data = await fetch(`/reviews/shop_name_autocomplete?query=${request.term}`)
        .then(results => results.json())
        .then(results => results.map(result => {
          return { label: result.name, value: result.name, id: result._id};
        }));
      response(data);
    },
    minlength: 2
  });
});

Login

Another feature I wanted was a login system so that only me and my fiancee could leave reviews. This is mostly because I didn’t want to have to moderate reviews from anyone on the internet - I wasn’t trying to make a Yelp! clone. I ended up using Passport.js which was a bit of a pain to figure out as their docs aren’t very specific. The following code handled the login feature:

passport.use(new LocalStrategy({
  passReqToCallback: true
},
  function(req, username, password, done) {
    const User = Connection.db.db("icecream_mn").collection("users");

    User.findOne({ username: username }, function(err, user) {
      if (err) { return done(err); }
      if (!user) {
        return done(null, false, { message: req.flash('warning', 'Username Not Found') });
      }
      if (!bcrypt.compareSync(password, user.password)) {
        return done(null, false, { message: req.flash('warning', 'Incorrect password.') });
      }
      return done(null, user);
    });
  }
));

Maybe the most interesting part is the system of reusable warnings I built into the app with the req.flash helpers. Anytime I wanted to display a warning - I could send this req.flash() as part of the response. Then I could send the warning and the message to the front end with this piece of middleware:

router.get('*', function(req,res,next){
  res.locals.successes = req.flash('success');
  res.locals.dangers = req.flash('danger');
  res.locals.warnings = req.flash('warning');
  next();
});

Finally on the front end templates (using pug) I could loop through the warnings (if they existed) and display the message with this code:

each warning in warnings 
  div(class='header alert alert-danger alert-dismissible')
    <strong><i class="fa fa-check-circle"></i> Error:</strong> #{warning}
    a(href="#" class='close' data-dismiss="alert" aria-label="close") 
    i(class='fa fa-times')

The Map

The centerpiece of the application home page is a map with markers for all the ice cream shops on it. This is a pure front end exercise. The map is initialized with the following code:

function initMap() {
  map = new google.maps.Map(document.getElementById("map"), {
    center: { lat: 44.9727, lng: -93.23540000000003 },
    zoom: 11,
  });
  fetch("/shops/get_shop_info")
    .then(response => response.json())
    .then(data => add_shop_markers(map, data));  
}

Which calls the get_shop_info route so that I can place all the markers. The code to add the actual markers is:

function add_shop_markers(resultsMap, data) {
  data_length = data.length;
  let infowindow = new google.maps.InfoWindow();
  for (let i = 0; i < data_length; i++) {
    let shop_address = data[i].address;
    let shop_name = data[i].name;
    let shop_website = data[i].website;

    const marker = new google.maps.Marker({
      map: resultsMap,
      position: data[i].latlng,
      icon: "images/cream.png",
    });
    marker_list.push(marker);
    let info_content = shop_name + "<br>" + "<a href='" + shop_website + "'>Website</a><br>" + "Address: " + shop_address;
    attach_info_window(marker, infowindow, info_content, shop_name, data[i].reviews);
  };
  return marker_list;
}

Originally I had it so this function was making a call to the Google Maps Geocoder API for each ice cream shop address to get the lat/lng coordinates, but that could end up being a lot of calls! I instead rearranged it so that the Geocoder API is called once for each shop as it is created and entered into the database. Then the call to get_shop_info returns the lat/lng coordinates and I can place the markers there, no sweat. Finally the following code displays an info window (and calls the function that displays all the reviews for the shop below the map) for each shop when clicked:

function attach_info_window(marker, infowindow, info_content, shop_name, reviews) {
  marker.addListener("click", () => {
    console.log(reviews);
    document.getElementById("review-title").innerHTML = `	
      <div class="col-md-12">
        <h3>Reviews for ${shop_name}</h3>
      </div>
    ` 
    document.getElementById("review-container").style.display="block";
    display_reviews(reviews);
    infowindow.setContent(info_content);
    infowindow.open(marker.get("map"), marker);
  });
}

Final thoughts

There’s a lot more to the app - plenty of forms and routes to connect it all together which you can see in the GitHub repo linked at the top of the page or on the Heroku hosted live version also linked.