Skip to content

daaru00/shopify-theme-petite-vue

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Petite Vue Shopify Theme

This repository is an example that demonstrate how to use Petite Vue in a Shopify theme.

Theme cover

Getting started

The deploy and development procedure use the Shopify CLI. If you haven't already, install and configure it.

Initialize a new theme based on this example (replace <my theme name> with the name of your theme):

shopify theme init <my theme name> --clone-url https://github.com/daaru00/shopify-theme-petite-vue.git

Use the dev command to upload theme as a development theme and watch for changes:

shopify theme dev

If not already logged you will be asked to login with verification code.

Made your changes or use the example as it is, when you're ready publish change to your live theme:

shopify theme push

You can also specify the store via '--store' arguments:

shopify theme push --store example.myshopify.com

More information about theme development can be found at the official Shopify documentation.

Petite Vue integration

Petite Vue is super easy to include in the frontend using JavaScript modules:

<script type="module">
  import { createApp } from 'https://unpkg.com/petite-vue?module'
  createApp({
    // place here the root scope initialization
  }).mount("#page")
</script>
<section id="page">
  <!-- place here the template -->
</section>

this can be injected in any Shopify template page or sections without any other requirements.

Template interpolation syntax collision

The Liquid use the same syntax for variable interpolation as Vue, using something like this:

<script type="module">
  import { createApp } from 'https://unpkg.com/petite-vue?module'
  createApp({
    test: 'example value'
  }).mount("#page")
</script>
<section id="page">
  <span>{{ test }}</span>
</section>

will not works because Shopify will replace {{ test }} on server side with an empty value (because does test not exist in server scope) and Petite Vue ends up with an empty template:

<section id="page">
  <span></span>
</section>

There are few workaround for this:

  1. Surround any Petite Vue template with Liquid raw tag, this will tells to Liquid to skip interpolation and left the html intact for the frontend:
{% raw %}
<section id="page">
  <span>{{ test }}</span>
</section>
{% endraw %}
  1. Use v-text or v-html instead:
<section id="page">
  <span v-text="test"></span>
</section>
  1. Use custom delimiters for Petite Vue interpolation
createApp({
  $delimiters: ['${', '}']
}).mount()
<section id="page">
  <span>${test}</span>
</section>

Sharing state between Liquid and Petite Vue

It is also possible to pass Liquid variable values to Petite Vue scope:

<script type="module">
  import { createApp } from 'https://unpkg.com/petite-vue?module'

  createApp({
    product: JSON.parse('{{ product | json }}')
  }).mount("#page")
</script>
<section id="page">
  <h2 v-text="product.title"></h2>
  <h3 v-text="product.price"></h3>
</section>

Pay attention when you manage data in this way, the resulting HTML sent to the client will be without values:

<section id="page">
  <h2 v-text="product.title"><!-- missing information --></h2>
  <h3 v-text="product.price"><!-- missing information --></h3>
</section>

..and this is not SEO friendly at all!

You can emulate something like a "server side rendering and frontend side rehydration" combining scope variable initialization with server side variable interpolation in the Petite Vue template:

<script type="module">
  import { createApp } from 'https://unpkg.com/petite-vue?module'

  createApp({
    product: JSON.parse('{{ product | json }}')
  }).mount("#page")
</script>
<section id="page">
  <h2 v-text="product.title">{{ product.title }}</h2>
  <h3 v-text="product.price">{{ product.price }}</h3>
</section>

This will present to the client (especially for search crawlers) a template with already the values inside:

<script type="module">
  import { createApp } from 'https://unpkg.com/petite-vue?module'

  createApp({
    product: JSON.parse('{{ product | json }}')
  }).mount("#page")
</script>
<section id="page">
  <h2 v-text="product.title">My Product Title</h2>
  <h3 v-text="product.price">$40</h3>
</section>

Obviously from this example there are no benefits to use Petite Vue in this way, but taking into consideration the product variants selected or various cart information the real benefit comes up.

Reusable composable libraries

Some type of functionalities need to be included in different sections or simply reused across different pages.

With the used approach when something (a class, an object, a method, a function or even a reactive state) need to be reused is describe is a separate file under ./assets directory, for example ./assets/product.js:

import { reactive } from 'https://unpkg.com/petite-vue?module'

// shared reactive state
const store = reactive({
  product: {}
})

const useProduct = (productSerialized) => {
  Object.assign(store.product, JSON.parse(productSerialized || '{}'))

  const addToCart = () => {
    console.log(`Add product ${store.product.title} to cart`)
  }

  return {
    addToCart,
    get product() {
      return store.product
    }
  }
}

export { useProduct }

the library structure remand to Vue 3 composition API.

When used, the library is included via import statement pointing to remote file using Liquid interpolation for static assets file {{ "<file name in assets directory>" | asset_url }}:

<script type="module">
  import { createApp } from 'https://unpkg.com/petite-vue?module'
  import { useProduct } from '{{ "product.js" | asset_url }}'

  const { product, addToCart } = useProduct('{{ product | json }}')

  createApp({
    product,
    addToCart
  }).mount("#page")
</script>
<section id="page">
  <h2 v-text="product.title">{{ product.title }}</h2>
  <h3 v-text="product.price">{{ product.price }}</h3>
  <button class="primary" @click="addToCart()">
    {{ 'products.product.add_to_cart' | t }}
  </button>
</section>

Events

When two different Petite Vue app instances need to talk to each other they did it using an event-driven pattern.

Events are sent using dispatchEvent browser API:

window.dispatchEvent(new CustomEvent('cartchanged', { 
  detail: {
    type: 'add',
    id: 'xxxxxxx',
    quantity: 5
  } 
}));

and received attaching listeners:

window.addEventListener('cartchanged', ({ detail }) => {
  console.log('cart changed!', detail)
});

This approach allows different applications to be able to execute actions to each other, in some situations it's just a little bit more comfortable then using v-effect approach.

Implemented Shopify functionalities

Just a few Shopify theme page are implemented: home, products list, product and cart page.

Header

In the header top bar the cart icon's badge indicate the number of product in the cart.

Header to bar

This is made using Petite Vue reactive: when executing operation against the cart, using the useCart composable library, the cart variable exported change:

import { createApp } from 'https://unpkg.com/petite-vue?module'
import { useCart } from '{{ "cart.js" | asset_url }}'

const { cart } = useCart('{{ cart | json }}')

createApp({
  cart,
  get isCartEmpty() {
    return cart.item_count === 0
  }
}).mount("#shopify-section-header")
<a class="cart-icon" v-cloak href="{{ routes.cart_url }}">

  <i v-if="!cart.loading && isCartEmpty">
    {% render 'icon-cart-empty' %}
  </i>
  <i v-else-if="!cart.loading && !isCartEmpty">
    {% render 'icon-cart' %}
  </i>
  <span v-else-if="cart.loading">
    ..
  </span>

  <span class="bubble" v-if="cart.item_count > 0" v-text="cart.item_count"></span>
</a>

Home

The home page is really simple and just implements a products grid (./sections/products-list.liquid).

Home page

Simple product

The product page just show basic product information like name and price:

<h1>{{ product.title }}</h1>

{% if product.has_only_default_variant %}
  <h3>{{ product.price | money }}</h3>
{% else %}

Product page

Product with variants

For products with variants it render the products variants selector:

{% else %}
  <h3 v-text="formatPrice(variant.price)">{{ product.selected_or_first_available_variant.price | money }}</h3>

  <p>
    <select v-model="variant" v-effect="onVariantSelected(variant)">
      <option
        v-for="variant in product.variants" 
        :key="variant.id"  
        :value="variant"
        v-text="variant.title">
      </option>
    </select>
  </p>
{% endif %}

Product page with variants

The price will be displayed according to the variant selected, in order to do that the useProduct composable library is initialized with the product and current variant data:

import { useProduct } from '{{ "product.js" | asset_url }}'

const { product, variant, updateVariantQueryParam } = useProduct('{{ product | json }}', '{{ product.selected_or_first_available_variant.id }}')

Current variant information, contained in the exported variant variable, is binded using v-model="variant" with select's value. This in order to correctly load the current variant on page load.

When a new variant is selected (binded with v-model="variant" to variant scope variable) the onVariantSelected method will be triggered thanks to Petite Vue v-effect directive.

The variable change is propagated to query parameters (in case the user will reload the page or use a pre-crafted url for a specific variable selection) using updateVariantQueryParam method exported by useProduct composable library.

import { createApp, reactive } from 'https://unpkg.com/petite-vue?module'

createApp({
  // ...
  onVariantSelected(variant) {
    updateVariantQueryParam(variant.id)
    window.dispatchEvent(new CustomEvent('variantchanged', { detail: variant }));
  }
  // ...
}).mount('#page')
<select v-model="variant" v-effect="onVariantSelected(variant)">
  <option
    v-for="variant in product.variants" 
    :key="variant.id"  
    :value="variant"
    v-text="variant.title">
  </option>
</select>

Cart

The cart page list all items in cart and implements quantity change and delete functionality.

Cart page

It also blocks other cart operations when one is already in progress:

<script type="module">
  import { createApp, reactive } from 'https://unpkg.com/petite-vue?module'
  import { useCart } from '{{ "cart.js" | asset_url }}'

  const { cart, updateVariantCartQuantity } = useCart()

  createApp({
    cart,
    async updateQuantity(item, quantity) {
      console.log('updating..', item.variant_id, quantity)
      await updateVariantCartQuantity(item.variant_id, quantity)
      console.log('updating!', item.variant_id, quantity)
    },
  }).mount('#page')
</script>
<section id="page">
  <button type="button" :disabled="cart.loading" @click="updateQuantity(item, item.quantity - 1)">
    {% render 'icon-minus' %}
  </button>
</section>

The cart.loading flag is centrally controlled by useCart composable library:

import { reactive } from 'https://unpkg.com/petite-vue?module'

const cart = reactive({
  loading: false
})

const useCart = () => {

  const updateVariantCartQuantity = async (id, quantity) => {
    cart.loading = true

    // update cart

    cart.loading = false
  }

  return {
    cart,
    updateVariantCartQuantity
  }
}

export { useCart }

Considerations

Is this suitable for a production/live environment? Yes! It works pretty well, but the Petite Vue repository's disclaimer scares a little:

This is pretty new. There are probably bugs and there might still be API changes, so use at your own risk. Is it usable though? Very much.

Petite Vue is really small (~6kb) and does not affect so much the page load, in fact quite nothing.

Vanilla JavaScript is cool, ok, but with Petite Vue some functionalities like one/two way binding, reactive state, components managements are already available.

In a simple Shopify theme, frontend reactive it's not mandatory, in fact the Shopify default theme dawn can works without JavaScript enabled in the browser. In case it is necessary to implement something dynamic on the frontend side, to increase the user clutter or improve the UX, this solution could be more than valid.