DEV Community

Cover image for Building an Online Menu using Apostrophe Headless + Nuxt / Nginx: Part 2
The Apostrophe Team for Apostrophe

Posted on • Edited on

Building an Online Menu using Apostrophe Headless + Nuxt / Nginx: Part 2

In this tutorial, we'll demonstrate how to use Apostrophe Headless with Nuxt for the frontend and Nginx as a reverse-proxy, as well as optional Docker instructions for Docker users. We'll be creating an online storefront for a restaurant that will allow customers to register and place an order.

In Part 1...

In Part One, we covered the minimum steps to get started with Apostrophe and Nuxt, set our restaurant up with a few dummy menu items. In this section, we'll finish setting up the customer registration process and create an order form so we can start taking orders!

Registering Customers

On http://localhost (or http://localhost:3333 if not using Docker), choose "Register" in the admin bar to create a new user. Populate the email and password fields and save. Now click on the "Login" button and enter the credentials you have just used. A welcome message is displayed on success.

How does this work?

For the registration, in frontend/components/Register.vue, you'll see that the component calls /modules/apostrophe-users/register when submitting the form.

On the backend, this custom route is located in the apostrophe-users module in backend/lib/modules/apostrophe-users/index.js:

self.route('post', 'register', async (req, res) => { ... }

For the login, in frontend/nuxt.config.js, there is a Nuxt plugin for authentication, indicating which route to use for the login.

// frontend/nuxt.config.js
auth: {
  plugins: ['~/plugins/auth.js'],
  rewriteRedirects: true,
  fullPathRedirect: true,
  watchLoggedIn: false,
  strategies: {
    local: {
      endpoints: {
        login: { url: '/api/v1/login', method: 'post', propertyName: 'bearer' },
        logout: { url: '/api/v1/logout', method: 'post' },
        user: false,
      },
    },
  },
},
Enter fullscreen mode Exit fullscreen mode

/api/v1/login is a route automatically created by Apostrophe-Headless

In frontend/components/Login.vue, the component uses the Nuxt auth plugin to trigger the login action.

// frontend/components/Login.vue
const response = await this.$auth.loginWith('local', {
  data: {
    username: this.email,
    password: this.password,
  },
})
Enter fullscreen mode Exit fullscreen mode

Apostrophe replies to this action by checking the password with its saved hash and sends back a bearer token.

In backend/lib/modules/apostrophe-users/index.js, pay attention to the other custom routes.

self.route('get', 'user', async (req, res) => { ... })

The following is used during the login process in frontend/components/Login.vue:

const aposUser = await this.$axios.$get('/modules/apostrophe-users/user', {})

This backend custom route /modules/apostrophe-users/user receives a request with a bearer token (generated when the user sends his credentials). Apostrophe recognizes it as a legitimate request because it compares this token to the tokens kept in its database. Then, it sends back the _id of the current user. This way, later, when the user will order, it will be identified by its ID.

Creating an Order

Create a new folder under backend/lib/modules and name it orders. Create an index.js file in it with this content:

// backend/lib/modules
module.exports = {
  extend: 'apostrophe-pieces',
  name: 'order',
  alias: 'order',
  restApi: true,
  addFields: [
    {
      name: 'date',
      type: 'date',
      required: true,
    },
    {
      name: '_menuItems',
      type: 'joinByArray',
      withType: 'menu-item',
      required: true,
      relationship: [
        {
          name: 'quantity',
          label: 'Quantity',
          type: 'integer',
        }
      ],
    },
    {
      name: '_customer',
      type: 'joinByOne',
      withType: 'apostrophe-user',
      required: true,
    },
  ],
  arrangeFields: [
    {
      name: 'basics',
      label: 'Basics',
      fields: ['title', 'date', '_menuItems', '_customer', 'published'],
    },
  ],
  removeFields: ['slug', 'tags'],
}
Enter fullscreen mode Exit fullscreen mode

In this module, there are 2 joins: one for menu items (_menuItems) and one for the customer who ordered them (_customer). You can add multiple dishes to order because it is a joinByArray but only one customer through joinByOne.

Again, this module is RESTified because of the restApi parameter.

Activate this module by adding it to backend/app.js:

// backend/app.js
module.exports = require('apostrophe')({
  ...
  modules: {
    ...
    'menu-items': {},
    orders: {},
  }
})
Enter fullscreen mode Exit fullscreen mode

Now, when http://localhost/cms (or http://localhost:1337/cms if not using Docker) is reloaded, there is a new "Orders" item in the admin bar:

admin bar

When a customer creates an order, their apostrophe-user account will be used to authenticate the call in the backend. The users are automatically part of the customer users group (see the register route in backend/lib/modules/apostrophe-users/index.js we mentioned earlier). Currently, this group has no editing permissions.

Add the edit-order permission to this group in backend/lib/modules/apostrophe-users/index.js:

// backend/lib/modules/apostrophe-users/index.js
module.exports = {
  groups: [
    {
      title: 'customer',
      permissions: ['edit-order'],
    },
    ...
  ]
  ...
}
Enter fullscreen mode Exit fullscreen mode

Apostrophe has default permissions. When a admin-name-of-the-module permission is added to a group of users, they can manage all documents relative to this module. However, the edit-name-of-the-module permission restricts modifications to the documents they created individually. This is exactly what we need. In our case, a customer will only manage its own orders.

Let's create a Vue component to add orders in the frontend.

Start by creating a state order and a mutation in frontend/store/index.js:

// frontend/store/index.js
import Vue from 'vue'

export const state = () => ({
  order: {},
})


export const mutations = {
  addToOrder(state, payload) {
    Vue.set(state.order, payload.slug, {
      ...payload,
      quantity: state.order[payload.slug] ? state.order[payload.slug].quantity + 1 : 1,
    })
  },
}
Enter fullscreen mode Exit fullscreen mode

Here, we declare an empty order, and each time addToOrder is called it adds a new item to the order. For more details on how this works, consult the Vuex documentation.

Import the mutation in frontend/pages/index.vue and add it to the methods used in this component. Do not forget to add the LoginModal component too:

// frontend/pages/index.vue
<script>
  import { mapMutations } from 'vuex'
  import LoginModal from '~/components/LoginModal'

  export default {
    components: {
      LoginModal,
    },

    async asyncData({ $axios }) {
      ...
    },

    methods: {
      ...mapMutations(['addToOrder']),
      add(itel) {
        this.addToOrder(item)
      },
    },
  }
</script>
Enter fullscreen mode Exit fullscreen mode

In the same file, add 2 elements to the template part, under the img tag:

<!-- frontend/pages/index.vue -->
<v-btn v-if="$store.state.auth && $store.state.auth.loggedIn" color="primary" class="white-text" @click="add(item)">Order</v-btn>
<LoginModal v-else classes="primary white-text" :block="true" :redirect-to="$route.fullPath" label="Order" />
Enter fullscreen mode Exit fullscreen mode

The template should look like this:

<!-- frontend/pages/index.vue -->
<template>
  <section class="home">
    <!-- eslint-disable-next-line vue/no-v-html -->
    <div v-html="content"></div>
    <div class="home-menu-items">
      <div v-for="item in menuItems" :key="item._id" class="home-menu-items__item">
        <img :src="item.picture._urls['one-third']" />
        <v-btn
          v-if="$store.state.auth && $store.state.auth.loggedIn"
          color="primary"
          class="white-text"
          @click="add(item)"
        >
          Order
        </v-btn>
        <LoginModal v-else classes="primary white-text" :block="true" :redirect-to="$route.fullPath" label="Order" />
        <span>{{ item.description }}</span>
      </div>
    </div>
  </section>
</template>
Enter fullscreen mode Exit fullscreen mode

When logged in, the user will see an "Order" button under every menu item on the homepage. This button triggers the Vuex mutation addToOrder.

login restaurant

That is great. But the customer needs to see how many menu items they added to their order. Let's add a badge in the top bar to display a counter. For this, we will use the wonderful Vue components library added to the project: Vuetify. We already used a lot of Vuetify components in our frontend code. In fact, every v-xxx component is from Vuetify (v-toolbar, v-list, v-btn, ...). For badges, here is the documentation: https://vuetifyjs.com/en/components/badges

Add a Vuetify badge next to "My Order", in the top bar. Go to frontend/components/Nav.vue, look for the words "My Order" in the template and replace the line by the following:

<!-- frontend/components/Nav.vue -->
<v-btn text to="/order" nuxt>
  <v-badge color="green" :content="counter">My Order</v-badge>
</v-btn>
Enter fullscreen mode Exit fullscreen mode

Then, modify the computed part in <script> to match:

// frontend/components/Nav.vue
computed: {
  ...mapState(['auth', 'order']),
  counter() {
    if (!Object.values(this.order).length) {
      return '0'
    }
    return Object.values(this.order).reduce((acc, cur) => (acc += cur.quantity), 0)
  },
},
Enter fullscreen mode Exit fullscreen mode

Finally, add a scss rule to <style> to render the badge correctly:

// frontend/components/Nav.vue
.v-badge__badge {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}
Enter fullscreen mode Exit fullscreen mode

The entire Nav.vue component should look like this:

// frontend/components/Nav.vue
<template>
  <v-app-bar app hide-on-scroll flat>
    <!-- small mobile screens only -->
    <template v-if="$vuetify.breakpoint.xsOnly">
      <v-menu offset-y>
        <template #activator="{ on }">
          <v-app-bar-nav-icon v-on="on" />
        </template>
        <v-list>
          <v-list-item>
            <v-btn class="v-btn--mobile v-btn--home" text to="/" nuxt block> Home </v-btn>
          </v-list-item>
        </v-list>
      </v-menu>
    </template>

    <!-- large smartphones, tablets and desktop view -->
    <template v-else>
      <v-toolbar-items>
        <v-btn class="v-btn--home" text to="/" nuxt> Home </v-btn>
      </v-toolbar-items>
    </template>

    <v-spacer />

    <v-toolbar-items>
      <template v-if="auth.loggedIn">
        <v-btn text to="/order" nuxt>
          <v-badge color="green" :content="counter">My Order</v-badge>
        </v-btn>
        <v-btn text @click="logout">Logout</v-btn>
      </template>
      <template v-else>
        <RegisterModal />
        <LoginModal :redirect-to="$route.fullPath" />
      </template>
    </v-toolbar-items>
  </v-app-bar>
</template>

<script>
import { mapState } from 'vuex'
import LoginModal from '~/components/LoginModal'
import RegisterModal from '~/components/RegisterModal'

export default {
  components: {
    LoginModal,
    RegisterModal,
  },

  computed: {
    ...mapState(['auth', 'order']),
    counter() {
      if (!Object.values(this.order).length) {
        return '0'
      }
      return Object.values(this.order).reduce((acc, cur) => (acc += cur.quantity), 0)
    },
  },

  methods: {
    logout() {
      this.$auth.logout()
    },
  },
}
</script>

<style lang="scss">
.v-btn--mobile:hover {
  height: 100%;
}
.v-btn--home::before {
  opacity: 0 !important;
}
.v-toolbar__content {
  padding: 0 !important;
}
.v-badge__badge {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}
</style>
Enter fullscreen mode Exit fullscreen mode

You should see the badge now (be sure to be logged in as the registered customer we created in the front end earlier).

order logout

As the Vuex state is updated through the mutation addToOrder, components that listen to the order state are aware of the change. This updates the badge next to "My Order", in the top bar. Each time a dish is added to the order, the badge number increases, indicating how many items the user has in the cart.

That would be nice to have the list of dishes we put in this order. For that, create a page by adding order.vue file in frontend/pages. Nuxt is smart enough to understand it has to update its internal router and add a route when a file is added into pages. By adding an order Vue component, it will automatically create the /order route.

Copy the code below and paste it into order.vue:

// frontend/pages/order.vue
<template>
  <v-card>
    <v-list two-line>
      <v-list-item-group multiple>
        <template v-for="(item, index) in Object.values(order)">
          <v-list-item :key="item.title">
            <v-list-item-content>
              <v-list-item-title v-text="item.title"></v-list-item-title>

              <v-list-item-subtitle class="text--primary" v-text="item.description"></v-list-item-subtitle>
            </v-list-item-content>

            <div class="order-list">
              <v-text-field
                outlined
                class="order-quantity"
                :value="item.quantity"
                color="primary"
                required
                @input="changeQuantity($event, item)"
              />
              <div class="order-actions">
                <v-btn icon @click="add(item)"><v-icon>add</v-icon></v-btn>
                <v-btn icon @click="remove(item)"><v-icon>remove</v-icon></v-btn>
              </div>
            </div>
          </v-list-item>

          <v-divider v-if="index < Object.values(order).length - 1" :key="index"></v-divider>
        </template>
      </v-list-item-group>
    </v-list>
    <v-card-actions>
      <v-btn v-if="Object.values(order).length" block color="primary" :loading="loading" @click="proceed">
        Proceed
      </v-btn>
    </v-card-actions>
  </v-card>
</template>

<script>
import { mapActions, mapMutations, mapState } from 'vuex'

export default {
  data() {
    return {
      loading: false,
    }
  },

  head: {
    titleTemplate: '%s - My Order',
  },

  computed: {
    ...mapState(['auth', 'order']),
  },

  methods: {
    ...mapActions('snackbar', ['displaySnack']),
    ...mapMutations(['addToOrder', 'removeFromOrder', 'updateQuantity', 'emptyOrder']),
    changeQuantity(quantity, item) {
      this.updateQuantity({ ...item, quantity })
    },
    add(item) {
      this.addToOrder(item)
    },
    remove(item) {
      this.removeFromOrder(item)
    },
    async proceed() {
      this.loading = true
      try {
        const date = Date.now()
        const arr = Object.values(this.order)
        await this.$axios.post('/api/v1/orders', {
          title: `${this.auth.user.email} - ${date}`,
          customerId: this.auth.user._id,
          menuItemsIds: arr.map(item => item._id),
          menuItemsRelationships: arr.reduce((acc, cur) => ({ ...acc, [cur._id]: { quantity: cur.quantity } }), {}),
          date,
        })

        this.loading = false
        this.emptyOrder()
        this.$router.push('/')
        this.displaySnack({ message: 'Order received. It will be ready soon.' })
      } catch (error) {
        this.loading = false
        this.displaySnack({ message: 'Something went wrong', color: 'error' })
      }
    },
  },
}
</script>

<style lang="scss" scoped>
.order-list {
  display: flex;
  position: relative;
  top: 20px;
}

.order-quantity {
  width: 50px;
  margin-right: 40px;
}

.order-actions {
  display: flex;
  flex-direction: column;
}

.v-card__actions {
  position: absolute;
  bottom: 0;
  width: 100%;
  padding: 0;

  .v-btn {
    padding: 0;
  }
}
</style>
Enter fullscreen mode Exit fullscreen mode

We used three additional mutations in this component. Add them to frontend/store/index.js:

// frontend/store/index.js
export const mutations = {
  addToOrder(state, payload) { ... },

  updateQuantity(state, payload) {
    state.order[payload.slug].quantity = payload.quantity
  },

  removeFromOrder(state, payload) {
    if (state.order[payload.slug].quantity > 0) {
      state.order[payload.slug].quantity--
    }
  },

  emptyOrder(state) {
    state.order = {}
  },
}
Enter fullscreen mode Exit fullscreen mode

The order page is ready. Order food on the homepage, click multiple times on an "Order" button to add the same dish several times. Now, click on "My Order" in the top bar, you are being redirected to /order and should see a page similar to this:

my order

You can adjust the quantities here as well.

Choosing "Proceed" will generate a POST request and contact the backend REST API. Apostrophe will handle that and create the corresponding order. You can go to the backend and check that by clicking on the "Orders" button in the Apostrophe admin bar on http://localhost/cms (or http://localhost:1337/cms).

edit order

You can even click on the "Relationship" button on a joined menu item, and see the right quantity was sent.

products

edit relationship

This works thanks to the "relationship" field in Apostrophe. In the frontend call we have:

menuItemsRelationships: arr.reduce((acc, cur) => ({ ... }), {})
Enter fullscreen mode Exit fullscreen mode

indicating to add an object menuItemsRelationships to the new order. Apostrophe understands this as a property of the joined field _menuItems as specified in the backend/order/index.js schema:

// backend/order/index.js
{
  name: '_menuItems',
  type: 'joinByArray',
  required: true,
  relationship: [
    {
      name: 'quantity',
      label: 'Quantity',
      type: 'integer',
    }
  ],
},
Enter fullscreen mode Exit fullscreen mode

Success! The restaurant has all it needs to handle orders from online customers. Our goal with this tutorial was to demonstrate how nicely Apostrophe can interact with front-end frameworks such as Vue/Nuxt in a Docker environment. We'll stop here to keep it simple. You can access the project files and full tutorial on Github.

We could have added email notifications, online payments, and many options available in the numerous plugins available for Apostrophe. You can find more by browsing the online documentation, exploring plugins to extend our open-source CMS, or by joining our community channels.

This post was written by Apostrophe’s resident philosopher, Senior Software Engineer, and all-around French family man. We also call him Anthony.

Top comments (0)