Проект на Vue.js. Часть 4. Router, слоты и проект на несколько страниц

Продолжаю работу со своим воображаемым магазином винила. В прошлых частях мы сделали вывод списка товаров, добавили корзину и разобрали весь код по компонентам. Но до сих пор у нас всего одна главная страница на сайте, и было бы неплохо добавить ещё парочку.

В этой статье разберёмся с ссылками и роутером в Vue.js (router), а потом добавим страницу о магазине со статическим содержимым, потом страницу с описанием одного товара и, наконец, страницу оформления заказа. Разберёмся со слотами, поработаем с передачей параметров по ссылке и научимся отправлять пользователя на страницу 404, если в адресе какая-то ошибка.

Как использовать router (краткий план)

Vue Router – это механизм, который позволяет приложению иметь несколько страниц без перезагрузки в браузере. Страница меняется не через переход на новый HTML-файл, а через замену компонента, который вставляется в <RouterView>.

Router следит за адресной строкой (/catalogue, /about, /checkout), после чего подбирает подходящий компонент; рендерит его вместо предыдущего и позволяет переходить между «страницами» через <RouterLink>.

Приложение выглядит как многостраничное, но всё работает внутри одной HTML-страницы.

Звучит интересно, и для начала кратко опишу шаги, которые нужно сделать в существующем приложении, чтобы использовать роутер.

  1. Установить vue-router
  2. Создать файл router/index.js, в котором будут прописаны все адреса.
  3. Подключить роутер в main.js
  4. Сделать меню с RouterLink и место для контента (RouterView)
  5. Разложить существующий код по страницам в папке views.
  6. Добавить ещё больше страниц.

Работа с vue-router

Теперь пройдёмся по шагам и добавим в приложение вторую страничку с информацией о магазине.

Для установки роутера нужно выполнить в папке проекта команду:

npm install vue-router@4

Далее создаём папку router в папке src и в ней нам нужен файл index.js

import { createRouter, createWebHistory } from 'vue-router'
import Catalogue from '../views/CataloguePage.vue'
import AboutPage from '../views/AboutPage.vue'

const routes = [
  {
    path: '/',
    redirect: '/catalogue',
  },
  {
    path: '/catalogue',
    name: 'catalogue',
    component: Catalogue,
  },
  {
    path: '/about',
    name: 'about',
    component: AboutPage,
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router

В этом файле сначала мы импортируем все «страницы», которые будут лежать в папке view. У меня пока планируются две страницы: каталог, который у нас по сути готов и страница About.

Далее перечисляем в массиве routes объекты, в которых есть поля name, path и component. В поле path прописываем адрес, на котором нужно показывать заданный компонент (из списка импортированных).

Несколько адресов могут вести на одну и ту же страницу, и даже можно задать редирект, как я сделал для path: ‘/’.

Чтобы роутер работал, нужно подключить его в main.js:

import './assets/style.css'

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

Теперь пропишем в App.vue ссылки на эти объявленные компоненты:

      <RouterLink to="/catalogue" class="nav-link">Catalogue</RouterLink>
      <RouterLink to="/about" class="nav-link">About</RouterLink>

Ссылки у нас всего две, адрес прописывается в атрибуте to. И нам нужен ещё один компонент <RouterView /> на странице, вместо которого будет отображаться нужный компонент по ссылке.

Так как каталог будет в отдельной странице, создадим компоненты для каждой «страницы» в папке views.

CataloguePage.vue будет содержать разметку списка товаров, корзину и всю логику, которую мы делали в прошлых трёх статьях (дублировать компоненты не буду, содержимое можно посмотреть в прошлых статьях).

В App.vue разметки каталога товаров не останется, и он у нас теперь будет выглядеть следующим образом:

<template>
  <div class="page">
    <Header />

    <nav class="main-nav">
      <RouterLink to="/catalogue" class="nav-link">Catalogue</RouterLink>
      <RouterLink to="/about" class="nav-link">About</RouterLink>
    </nav>

    <main class="page-content">
      <RouterView />
    </main>
  </div>
</template>

<script>
import Header from './components/HeaderBar.vue'

export default {
  name: 'App',
  components: {
    Header
  }
}
</script>

Почему мы убрали каталог из App.vue? Потому что это не страница, а корневой компонент-приложение, то есть рамка (layout), которая существует всегда.

В App.vue мы оставили элементы, которые должны всегда оставаться на странице: шапку, меню. Можно провести параллель с фреймами в старомодном HTML, когда главный фрейм всегда оставался одним и тем же, а в другие подгружалась страничка. Вот <RouterView/ > тут и есть такой фрейм, где будет появляться нужная страничка.

Если попытаться сделать App.vue страницей, то роутер потеряет точку входа или будет пытаться рендерить App.vue внутри App.vue и получится рекурсия.

Страницами являются обычные Vue-компоненты, которые мы регистрируем в роутере. Мы их разместили в папке view, и содержимое у них точно такое же, как у любого компонента: <template>, <script>, <style>, если необходимо.

Теперь, когда разобрались с сутью, добавим ещё AboutPage.vue.

<template>
  <section class="about-page">
    <h2 class="section-title">About Winylka Records</h2>

    <p class="about-text">
      Winylka Records — это воображаемый онлайн-магазин винила, где собрана
      коллекция пластинок. Здесь нет бесконечного каталога на тысячи позиций, 
      только те релизы, которые действительно хочется поставить на проигрыватель.
    </p>
  </section>
</template>

<script>
export default {
  name: 'AboutPage'
}
</script>

<style scoped>
<...>
</style>

Никакой логики здесь нет, только экспорт, и я добавил некоторые стили, которые применяются только на этой страничке. Контент статический, без всяких событий и данных, поэтому такая страница самая удачная для первых экспериментов.

На данный момент мы подключили роутер, добавили ссылки, сделали меню в главном компоненте, выделили пару страничек и готовы расширить проект. Добавим страницу с карточкой товара, а потом займёмся чекаутом, который можно начать из корзины.

Адреса с параметром в Router. Страница товара

Пока что мы посмотрели только на страницы, содержимое которых всегда одинаковое. Но что если вывод зависит от параметра? Например, мы захотим вывести информацию по одному товару. Тогда параметр (id) можно передавать в адресе.

Параметры в пути объявляются в роутере после двоеточия:

path: '/product/:id/edition/:edition',

Их, как видим, может быть несколько. Достаются они в компоненте с помощью ключевого слова $route и доступа к объекту params по имени:

this.$route.params.id
this.$route.params.edition

Можно также задавать параметры в query после знака вопроса в адресе ссылки и соединять их амперсандом:

/product?id=15&edition=3

Тогда путь в роутере остаётся обычным:

/path: '/product', component: ProductPage

А чтобы достать эти значения в компоненте, обращаемся к объекту query:

const id = Number(this.$route.query.id)
const edition = Number(this.$route.query.edition)

В моём проекте я буду смотреть на продукт по id, поэтому query мне не нужно, и хватит всего одного параметра в пути. Создам третью страницу (кроме каталога и о магазине) ProductPage.vue и добавлю её в роутер:

import Product from '../views/ProductPage.vue'

{
        path: '/product/:id',
        name: 'product',
        component: Product
      },

Здесь прямо в адресе мы ожидаем увидеть нужный нам параметр, по которому и будем определять, какой выводить продукт.

В компоненте карточки продукта в нашем каталоге ProductCard.vue добавим событие по клику:

<article class="product-card" @click="goToProduct">

И чтобы нажатие по товару не срабатывало при нажатии на кнопку добавления в корзину, отредактируем её событие. Добавим слово stop:

@click.stop="$emit('add', item)"

Добавим метод goToProduct():

 goToProduct() {
      this.$router.push(`/product/${this.item.id}`)
    } 

Таким образом по нажатию на товар в каталоге мы перейдём на страницу с этим товаром. Теперь можно и саму страницу наполнить:

<template>
  <section class="product-page" v-if="product">
    <div class="section-header">
      <h2 class="section-title">
        {{ product.artist }} — {{ product.name }}
      </h2>

      <Cart 
        :cart-objects="cartObjects"
        :display-cart="displayCart"
        :cart-total="cartTotal"
        @delete="deleteFromCart"
        @toggle="displayCart = !displayCart"
      />
    </div>

    <RouterLink to="/catalogue" class="product-back-link">
      ← Back to catalogue
    </RouterLink>

    <div class="product-layout">
      <div class="product-image-col">
        <div class="image-wrap product-image-wrap">
          <img :src="product.img" :alt="product.artist + ' — ' + product.name"  class="product-image-full" >
        </div>
      </div>

      <div class="product-info-col">
        <p class="product-catno">
          <span class="product-label">Cat. No:</span>
          <span class="product-value">{{ product.catNo }}</span>
        </p>

        <p class="product-meta">
          <span class="product-label">Format:</span>
          <span class="product-value">
            {{ product.format }} — {{ product.note }}
          </span>
        </p>

        <p class="product-price-line">
          <span class="product-label">Price:</span>
          <span class="product-price-main">
            {{ currency(product.price) }}
          </span>
        </p>

        <div class="product-qty-row">
          <label class="product-label">
            Quantity
            <input v-model.number="qty" type="number" min="1" class="product-qty-input">
          </label>

          <span v-if="inCartCount > 0" class="product-in-cart-hint" >
            In cart: {{ inCartCount }}
          </span>
        </div>

        <div class="product-actions">
          <button type="button" class="product-add-btn" @click="addToCart">
            Add to cart
          </button>

          <RouterLink to="/catalogue" class="product-secondary-link">
            Back to catalogue
          </RouterLink>
        </div>
      </div>
    </div>
  </section>
</template>

<script>
import { formatCurrency } from '../utils/formatters'
import Cart from '../components/Cart.vue'

export default {
  name: 'ProductPage',
  components: { 
    Cart
  },
  props: {
    cartObjects: {
      type: Array,
      required: true
    },
    cartTotal: {
      type: Number,
      required: true
    }
  },
  emits: ['add-to-cart', 'delete-from-cart', 'update-cart'],
  data() {
    return {
      product: null,
      qty: 1,
      loading: false,
      error: '',
      displayCart: false
    }
  },
  created() {
    const id = Number(this.$route.params.id)
    this.loadProduct(id)
  },
 methods: {
    currency(value) {
      return formatCurrency(value)
    },
    async loadProduct(id) {
      this.loading = true
      this.error = ''
      try {
        const response = await fetch('/data/products.json')
        const data = await response.json()
        const found = data.find(item => item.id === id)
        this.product = found || null
      } catch (e) {
        console.error(e)
        this.error = 'Failed to load product.'
      } finally {
        this.loading = false
      }
    },
    addToCart() {
      if (!this.product || this.qty < 1) return
      const count = Number.isNaN(this.qty) ? 1 : this.qty
      for (let i = 0; i < count; i++) {
        this.$emit('add-to-cart', this.product)
      }
      this.qty = 1
    },
    deleteFromCart(product) {
      this.$emit('delete-from-cart', product)
    }
  },
    computed: {
      inCartCount() {
        if (!this.product) return 0
        if (!Array.isArray(this.cartObjects)) return 0
        
        const line = this.cartObjects.find(line => {
          return line.item && line.item.id === this.product.id
        })

        return line ? line.amount : 0
      }
  }
}
</script>

<style scoped>
…
</style>

На этой странице рядом с заголовком мы так же выводим компонент с корзиной, поэтому указываем, что эта страница может пробрасывать все события, которые мы уже прописывали для корзины в прошлой статье на странице каталога.

Для загрузки продукта используем метод loadProduct, в котором сейчас берём все данные из json файла и ищем подходящий по id. В будущем, конечно, можно будет заменить это на обращение к бэку, который по id сам достанет информацию, например, из базы данных.

Метод addToCart здесь добавляет необходимое количество пластинок в корзину в цикле (если пользователь выберет 2 или 3, то он дважды или трижды прокинем событие add-to-cart родителю).

И вычисляемое свойство inCartCount считает, сколько на данный момент в корзине этого продукта. Выводим мы на экран метку, только если свойство больше нуля. Информация о количестве у нас уже есть в cartObjects.

Компонент получился довольно большим, но подобную логику мы уже разбирали в прошлой статье, и и из новенького и самого главного здесь только то, что мы поработали с параметрами из адреса.

Страница 404

На данный момент, если мы попытаемся зайти на страницу, которая не существует в нашем проекте, мы просто получим пустую страничку с рамкой главного элемента. Но неплохо было бы говорить, что сайт работает и мы просто попали куда-то не туда – сделать страницу 404.

Создам простенькую страницу NotFoundPage.vue:

<template>
    <h1>404: Page Not Found</h1>
</template> 

<script>
export default {
    name: "NotFoundPage"
}
</script>

И тогда в index.js добавим информацию об этом компоненте:

import NotFound from '../views/NotFoundPage.vue'

{
    path: '/:pathMatch(.*)*',
    component: NotFound
  }

В path пишем выражение, которое охватит все страницы. И нужно эту страницу добавить в самый конец списка, чтобы в неё роутер ушёл только если не нашёл никакого совпадения по адресам выше.

И всё, готово! Теперь, если пользователь сам введёт какую-нибудь ерунду, то попадёт на эту страницу.

Но! Наша страница с продуктом всё ещё будет показываться пустой, если мы в неё передадим несуществующий id, потому что она всё ещё совпадает с заданным в роутере адресом. Роутер заранее не знает для каких продуктов сработает /product/:id, а каких нет в базе. Да и сама страница этого не знает до того, как попробует получить продукт из данных.

Поэтому первый элемент в <template> можно снабдить директивой v-if, которая будет проверять, не null ли продукт в данных. И далее добавить ещё один такой элемент, который проверит обратное: если элемент null, то вместо всей разметки выведем компонент NotFoundPage.

 <section class="product-page" v-if="product"> 
 …
 </section> 
   <section v-if="!product">
    <NotFoundPage />
  </section>

Конечно, для этого нужно импортировать элемент и упомянуть его в списке компонентов:

import NotFoundPage from '../views/NotFoundPage.vue'

export default {
  name: 'ProductPage',
  components: { 
    Cart,
    NotFoundPage
  },

В этом случае адрес страницы в строке браузера останется тем же, и мы просто на экране выведем сообщение об ошибке. Но мне такая тактика не очень нравится, потому что тогда в каждом подобном компоненте нужно будет добавлять дополнительную логику, импортировать этот компонент ненайденной страницы… В общем, много суеты. Лучше сделать изящнее и перенаправлять пользователя на страницу 404 в случае ошибки прямо из метода loadProduct.

Добавим адрес 404 в index.js:

{
    path: '/404',
    name: 'not-found',
    component: NotFound
  },

И вместо тактики с v-if добавим редирект в метод на ProductPage:

async loadProduct(id) {
      this.loading = true
      this.error = ''
      try {
        const response = await fetch('/data/products.json')
        const data = await response.json()
        const found = data.find(item => item.id === id)

         if (!found) {
          this.$router.replace({ name: 'not-found' })
          return
        }
        this.product = found || null
      } catch (e) {
        console.error(e)
        this.error = 'Failed to load product.'
      } finally {
        this.loading = false
      }
    },

Перенаправление происходит по методу replace и далее пишем имя пути, которое мы указали в index.js.

Слоты, вложенные маршруты. Страница чекаута из корзины

Ещё раз пробежимся по шагам и добавим страницу оформления заказа (чекаута), на которой будем выводить всё содержимое корзины, позволим менять количество элементов и отправлять заказ.

Создам файл CheckoutPage.vue в папке views, и привяжу её к адресу в файле index.js:

import Checkout from '../views/Checkout.vue'
  {
    path: '/checkout',
    name: 'checkout',
    component: CheckoutPage,
  },

Но перед тем, как наполнить файл, исправим одну ошибку с данными. На данный момент корзина хранится на странице каталога, и как только мы уйдём с неё, мы потеряем все данные. Поэтому сейчас на странице ProductPage.vue мы также не видим предыдущего состояния корзины.

Нужно сделать это состояние более глобальным. Неплохо было бы создать ещё один промежуточный слой между App.Vue и страницами Shop.vue. Внутри него будут жить все страницы, связанные с покупками, и он будет вместо App.vue или CataloguePage.vue хранить эти данные.

В принципе, провернуть это всё можно было бы и в App.vue, чтобы корзина не очищалась при переходе на страницу About, но я в учебных целях выберу такую несовершенную тактику. Чтобы мы могли познакомиться не только со слотами, но и с вложенными маршрутами.

С дополнительным слоем или без, для реализации нам в любом случае понадобятся слоты.

Слоты

Слот – это место внутри компонента, куда родитель может вставить свой HTML-контент. Например, компонент Modal.vue

<template>
  <div class="modal">
    <slot></slot>
  </div>
</template>

Использование в родителе:

<Modal>
<p>Текст внутри модалки</p>
</Modal>

То есть это уже не привычный нам единичный тэг <Modal/>, а два тэга с информацией между ними.

В этом проекте нам нужно не просто разместить текст внутри тэга, а дать всю логику работы с корзиной, то есть вместо текста туда подставить компонент. Это работает по той же логике, но вместо <p> размещаем внутри <component>, и чтобы это сработало, используем директиву v-slot.

Директива v-slot

Обычно RouterView просто рендерит компонент, соответствующий адресу. Мы указываем место, где будет этот компонент и на этом наши полномочия всё:

<RouterView />

Но на этот раз нужно не просто отрисовать страницу – нам нужно добавить к ней пропсы и события, чтобы она могла работать с корзиной. Как же это сделать?

Если добавить директиву v-slot=»{ Component }», то ситуация меняется. Родитель спрашивает у ребёнка: вы компонентов рендерите? — нет, просто показываю — красивое!

Вместо рендеринга компонент показывают родителю (например CataloguePage, ProductPage или CheckoutPage), а он уже наводит там красоту. Мы видим этот компонент в переменной Component и внутри <RouterView v-slot=»{Component}»></RouterView> можем отрисовать его вручную, как мы делали выше с простыми слотами:

<component :is="Component" />

Надеюсь, понятно объяснил, потому что ситуация для меня была одной из самых сложных во всём Vue.

Все события и пропсы с этим компонентом связываем, как это делали всегда с обычными компонентами, единственной дополнение, это этот :is=»Component». Файл Shop.vue будет выглядеть так:

<template>
  <RouterView v-slot="{ Component }">
    <component
      :is="Component"
      :cart-objects="cartObjects"
      :cart-total="cartTotal"
      :is-in-cart="isInCart"
      @add-to-cart="addToCart"
      @delete-from-cart="deleteFromCart"
      @update-cart="updateCart"
    />
  </RouterView>
</template>

<script>
import { formatCurrency } from '../utils/formatters'

export default {
  name: 'Shop',
  data() {
    return {
      cart: []
    }
  },
  computed: {
    cartTotal() {
      return this.cart.reduce((sum, item) => sum + Number(item.price), 0)
    },
    cartObjects() {
      const map = new Map()
      this.cart.forEach(product => {
        if (!map.has(product.id)) {
          map.set(product.id, { item: product, amount: 1 })
        } else {
          map.get(product.id).amount++
        }
      })
      return Array.from(map.values())
    }
  },
  methods: {
    currency(value) {
      return formatCurrency(value)
    },
    addToCart(product) {
      this.cart.push(product)
    },
    deleteFromCart(product) {
      const index = this.cart.findIndex(item => item.id === product.id)
      if (index !== -1) this.cart.splice(index, 1)
    },
    updateCart(lines) {
      const next = []
      lines.forEach(line => {
        for (let i = 0; i < line.amount; i++) {
          next.push(line.item)
        }
      })
      this.cart = next
    },
    isInCart(product) {
      return this.cart.some(item => item.id === product.id)
    }
  }
}
</script>

То есть сюда мы прокинули информацию о всей логике корзины, и передаём её в каждый компонент, так как мы знаем, что она будет нужна в каждом.

Ниже напомню код дочерних компонентов для Shop.vue:

Посмотреть полный код CataloguePage.vue
<template>
  <div class="catalogue-page">
    <div class="section-header">
      <h2 class="section-title">Catalogue</h2>

      <Cart 
        :cart-objects="cartObjects"
        :display-cart="displayCart"
        :cart-total="cartTotal"
        @delete="deleteFromCart"
        @toggle="displayCart = !displayCart"
      />
    </div>

    <div class="filter-bar">
      <div class="filter-left">
        <label for="max-price" class="filter-label">
          Max price:
          <span class="filter-value">{{ currency(max) }}</span>
        </label>

        <input
          id="max-price"
          v-model.number="max"
          type="range"
          min="0"
          max="200"
          class="filter-range"
        >
      </div>

      <div class="filter-right">
        <label class="search-label">
          Search:
          <input
            v-model="search"
            type="text"
            class="search-input"
            placeholder="Artist or album"
          >
        </label>

        <span class="result-badge">
          {{ filteredProducts.length }} records
        </span>
      </div>
    </div>

    <div class="grid">
      <ProductCard
        v-for="item in filteredProducts"
        :key="item.id"
        :item="item"
        :cheap="cheap"
        :sale="sale"
        :is-in-cart="isInCart"
        @add="addToCart"
      />
    </div>
  </div>
</template>

<script>
import ProductCard from '../components/ProductCard.vue'
import Cart from '../components/Cart.vue'
import { formatCurrency } from '../utils/formatters'

export default {
  name: 'Catalogue',
  components: {
    ProductCard,
    Cart
  },
  props: {
    cartObjects: {
      type: Array,
      required: true
    },
    cartTotal: {
      type: Number,
      required: true
    }
  },
  emits: ['add-to-cart', 'delete-from-cart', 'update-cart'],
  data() {
    return {
      max: 200,
      cheap: 25,
      sale: 20,
      displayCart: false,
      items: [],
      search: ''
    }
  },
  created() {
    fetch("/data/products.json")
      .then(response => response.json())
      .then(data => {
        this.items = data;
      })
  },
  methods: {
    currency(value) {
      return formatCurrency(value);
    },
    isInCart(product) {
      this.$emit('is-in-cart', product);
    },
    addToCart(product) {
      this.$emit('add-to-cart', product);
    },
    deleteFromCart(product) {
      this.$emit('delete-from-cart', product);
    }
  },
  computed: {
    filteredProducts() {
      const q = this.search.trim().toLowerCase();

      return this.items.filter(item => {
        const matchesPrice = item.price < this.max;

        if (!q) {
          return matchesPrice;
        }

        const haystack = `${item.artist} ${item.name}`.toLowerCase();
        const matchesSearch = haystack.includes(q);

        return matchesPrice && matchesSearch;
      });
    }
  }
}
</script>
Посмотреть полный код ProductPage.vue
<template>
  <section class="product-page" v-if="product">
    <div class="section-header">
      <h2 class="section-title">
        {{ product.artist }} — {{ product.name }}
      </h2>

      <Cart 
        :cart-objects="cartObjects"
        :display-cart="displayCart"
        :cart-total="cartTotal"
        @delete="deleteFromCart"
        @toggle="displayCart = !displayCart"
      />
    </div>

    <RouterLink to="/catalogue" class="product-back-link">
      ← Back to catalogue
    </RouterLink>

    <div class="product-layout">
      <div class="product-image-col">
        <div class="image-wrap product-image-wrap">
          <img
            :src="product.img"
            :alt="product.artist + ' — ' + product.name"
            class="product-image-full"
          >
        </div>
      </div>

      <div class="product-info-col">
        <p class="product-catno">
          <span class="product-label">Cat. No:</span>
          <span class="product-value">{{ product.catNo }}</span>
        </p>

        <p class="product-meta">
          <span class="product-label">Format:</span>
          <span class="product-value">
            {{ product.format }} — {{ product.note }}
          </span>
        </p>

        <p class="product-price-line">
          <span class="product-label">Price:</span>
          <span class="product-price-main">
            {{ currency(product.price) }}
          </span>
        </p>

        <div class="product-qty-row">
          <label class="product-label">
            Quantity
            <input
              v-model.number="qty"
              type="number"
              min="1"
              class="product-qty-input"
            >
          </label>

          <span
            v-if="inCartCount > 0"
            class="product-in-cart-hint"
          >
            In cart: {{ inCartCount }}
          </span>
        </div>

        <div class="product-actions">
          <button
            type="button"
            class="product-add-btn"
            @click="addToCart"
          >
            Add to cart
          </button>

          <RouterLink to="/catalogue" class="product-secondary-link">
            Back to catalogue
          </RouterLink>
        </div>
      </div>
    </div>
  </section>
</template>

<script>
import { formatCurrency } from '../utils/formatters'
import Cart from '../components/Cart.vue'

export default {
  name: 'ProductPage',
  components: { 
    Cart
  },
  props: {
    cartObjects: {
      type: Array,
      required: true
    },
    cartTotal: {
      type: Number,
      required: true
    }
  },
  emits: ['add-to-cart', 'delete-from-cart', 'update-cart'],
  data() {
    return {
      product: null,
      qty: 1,
      loading: false,
      error: '',
      displayCart: false
    }
  },
  created() {
    const id = Number(this.$route.params.id)
    this.loadProduct(id)
  },
 methods: {
    currency(value) {
      return formatCurrency(value)
    },
    async loadProduct(id) {
      this.loading = true
      this.error = ''
      try {
        const response = await fetch('/data/products.json')
        const data = await response.json()
        const found = data.find(item => item.id === id)

         if (!found) {
          this.$router.replace({ name: 'not-found' })
          return
        }
        this.product = found || null
      } catch (e) {
        console.error(e)
        this.error = 'Failed to load product.'
      } finally {
        this.loading = false
      }
    },
    addToCart() {
      if (!this.product || this.qty < 1) return

      const count = Number.isNaN(this.qty) ? 1 : this.qty

      for (let i = 0; i < count; i++) {
        this.$emit('add-to-cart', this.product)
      }

      this.qty = 1
    },
    deleteFromCart(product) {
      this.$emit('delete-from-cart', product)
    }
  },
    computed: {
      inCartCount() {
        if (!this.product) return 0
        if (!Array.isArray(this.cartObjects)) return 0
        
        const line = this.cartObjects.find(line => {
          return line.item && line.item.id === this.product.id
        })

        return line ? line.amount : 0
      }
  }
}
</script>

<style scoped>
...
</style

Вложенные маршруты (children)

Мы хотим получить иерархию:

App.vue
│
└── Shop.vue  (в нём живёт корзина)
    │
    ├── CataloguePage
    ├── CheckoutPage
    └── ProductPage
│
└── AboutPage (никаких данных о корзине)

То есть в App.vue мы рисуем хэдер и общую рамку. А в следующем слое Shop.vue хотим добавлять данные, которые характерны только для страниц магазина.

App.vue рисуется всегда первым по умолчанию. Теперь нам нужно, чтобы перед отрисовкой страниц магазина отрисовывался Shop.vue. Чтобы эту иерархию подчеркнуть в index.js, используем Children маршруты. Объявляем компонент Shop и указываем его детей:

const routes = [
  {
    path: '/',
    redirect: '/catalogue'
  },
  {
    path: '/',
    component: Shop,
    children: [
      {
        path: 'catalogue',
        name: 'catalogue',
        component: Catalogue
      },
      {
        path: 'product/:id',
        name: 'product',
        component: Product
      },
      {
        path: 'checkout',
        name: 'checkout',
        component: Checkout
      }
    ]
  },
  {
    path: '/about',
    name: 'about',
    component: AboutPage
  },
  {
    path: '/404',
    name: 'not-found',
    component: NotFound
  },
  {
    path: '/:pathMatch(.*)*',
    component: NotFound
  }
]

Здесь мы как раз говорим: когда адрес начинается с /, нужно сначала отрисовать компонент Shop. А внутри него уже будут отображаться дочерние страницы.

Адреса детей складываются с адресом родителя, поэтому мы убираем / из их адресов.

Если бы у родителя был путь /shop, а у ребёнка ‘catalogue’, полный адрес получился бы /shop/catalogue.

Я создал такую иерархию немного искусственно, но в жизни она используется как раз когда хочется держать состояние только для группы страниц (как мы сделали с корзиной), или когда разные страницы имеют разный внешний вид — это позволит оставить App.vue максимально чистым.

Страница оформления заказа

Теперь, когда мы переработали иерархию, можем вернуться к CheckoutPage.vue. В этом компоненте будем работать с корзиной так же, как и в других элементах. Почти полный код компонента:

Посмотреть код компонента CheckoutPage.vue
<template>
  <section class="checkout-page">
    <div class="section-header">
      <h2 class="section-title">Checkout</h2>
    </div>

    <div v-if="!cartObjects.length" class="checkout-empty">
      <p class="cart-note">
        Your cart is empty. Go back to the catalogue to add some records.
      </p>
      <RouterLink to="/catalogue" class="checkout-link">
        Back to catalogue
      </RouterLink>
    </div>

    <div v-else class="checkout-layout">
      <form class="checkout-form" @submit.prevent="submitOrder">
        <fieldset class="checkout-block">
          <legend class="checkout-block-title">Contact details</legend>
	<...>
        </fieldset>

        <fieldset class="checkout-block">
          <legend class="checkout-block-title">Shipping address</legend>
	<...>
          </label>
        </fieldset>

        <label class="checkout-field">
          <span class="checkout-label">Comment to order</span>
          <textarea v-model.trim="comment" rows="3" class="checkout-textarea"  placeholder="Any wishes or additional info"/>
        </label>

        <p v-if="formError" class="checkout-error">
          {{ formError }}
        </p>

        <button class="checkout-submit" type="submit"  :disabled="submitting" >
          {{ submitting ? 'Placing order...' : 'Place order' }}
        </button>
      </form>

      <aside class="checkout-summary">
        <h3 class="checkout-summary-title">Order summary</h3>

        <ul class="checkout-items">
          <li
            v-for="line in localLines"
            :key="line.item.id"
            class="checkout-item"
          >
            <div class="checkout-item-main">
              <span class="checkout-item-name">
                {{ line.item.artist }} — {{ line.item.name }}
              </span>
              <span class="checkout-item-price">
                {{ currency(line.item.price * line.amount) }}
              </span>
            </div>

            <div class="checkout-item-controls">
              <button
                type="button"
                class="qty-btn"
                @click="changeAmount(line, line.amount - 1)"
              >
                −
              </button>
              <input type="number" min="1" class="qty-input" v-model.number="line.amount" @change="changeAmount(line, line.amount)">
              <button type="button" class="qty-btn" @click="changeAmount(line, line.amount + 1)">
                +
              </button>

              <button type="button" class="remove-btn" @click="removeLine(line)"  title="Remove from cart">
                🗑
              </button>
            </div>
          </li>
        </ul>

        <dl class="checkout-totals">
          <div class="checkout-totals-row">
            <dt>Items total</dt>
            <dd>{{ currency(itemsTotal) }}</dd>
          </div>
          <div class="checkout-totals-row checkout-totals-row--grand">
            <dt>Total</dt>
            <dd>{{ currency(itemsTotal) }}</dd>
          </div>
        </dl>
      </aside>
    </div>
  </section>
</template>

<script>
import { formatCurrency } from '../utils/formatters'

export default {
  name: 'CheckoutPage',
  props: {
    cartObjects: {
      type: Array,
      default: () => []
    }
  },
  emits: ['place-order', 'update-cart'],
  data() {
    return {
      localLines: [],
      comment: '',
      submitting: false,
      formError: ''
    }
  },
  computed: {
    itemsTotal() {
      return this.localLines.reduce(
        (sum, line) => sum + Number(line.item.price) * line.amount,
        0
      )
    }
  },
  watch: {
    cartObjects: {
      immediate: true,
      handler(newVal) {
        this.localLines = newVal.map(line => ({
          item: line.item,
          amount: line.amount
        }))
      }
    }
  },
  methods: {
    currency(value) {
      return formatCurrency(value)
    },
    changeAmount(line, newAmount) {
      if (newAmount < 1 || Number.isNaN(newAmount)) {
        newAmount = 1
      }
      line.amount = newAmount
      this.$emit('update-cart', this.localLines)
    },
    removeLine(line) {
      this.localLines = this.localLines.filter(
        l => l.item.id !== line.item.id
      )
      this.$emit('update-cart', this.localLines)
    },
    submitOrder() {
      this.formError = ''
      if (!this.localLines.length) {
        this.formError = 'Cart is empty.'
        return
      }

      const order = {
        customer: {
          ...
        },
        shipping: {
          ...
        },
        comment: this.comment,
        items: this.localLines,
        totals: {
          itemsTotal: this.itemsTotal
        },
        createdAt: new Date().toISOString()
      }

      this.submitting = true
      this.$emit('place-order', order)
      this.submitting = false
    }
  }
}
</script>

<style scoped>
…
</style> 

Я для краткости выпустил из этого кода форму с заполнением данных пользователя и адресом, а также убрал эти элементы из модели, проверки и сбора данных.

В корзине добавим ссылку на чекаут:

 <RouterLink to="/checkout" class="checkout-link" @click="$emit('toggle')">
              Go to checkout
 </RouterLink>

И ещё один момент: мы пока не принимаем нигде оформленный заказ. Имитируем его создание, проверку формы и на этом всё. Пожалуй, это тема для следующей статьи?


А сегодня остановимся на этом. В этой статье мы разобрались с роутером, переходом по ссылкам между компонентами, передачей параметров и созданием дочерних компонентов. В проекте появились страницы о магазине, страница товара, и чекаут корзины.

Оставить комментарий