Skip to content

Instantly share code, notes, and snippets.

@panoply
Last active August 25, 2020 02:47
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save panoply/b2aeb205ddee3a275111941cc1a03c79 to your computer and use it in GitHub Desktop.
Save panoply/b2aeb205ddee3a275111941cc1a03c79 to your computer and use it in GitHub Desktop.
import m from 'mithril'
export default ({
lowStock,
remains,
name,
colour,
price,
pricing,
percent,
compare_at_price
}) => [
((compare_at_price === price)
? null
: m('.sale h5', `${Math.abs(percent)} ${percentOff}`)
),
((remains < 4)
? m('.low-stock h6', [
m('span.mr-1', `${remains} ${lowStock}`)
])
: null
),
m('ul', [
m('li.h5 mb-1', name),
m('li.h6 mt-0', colour),
(compare_at_price !== price ? [
m('li.price.h5.d-inline.mr-1', pricing),
m('li.price-old.h5.d-inline.ml-1', money(compare_at_price))
] : [
m('li.price.h5.d-inline.text-center', pricing)
])
])
]
export class Products extends Controller {
/**
* State - Keeps cache of fetched products
*
* @static
* @memberof Controller
*/
static state = new Map()
/**
* State - Keeps cache of fetched products
*
* @static
* @memberof Controller
*/
static cache = new Map()
/**
* Stimulus Values
*
* @static
* @memberof Controller
* @version 2.0
*/
static values = {
mount: String,
fetch: Boolean,
loadingIcon: Boolean,
active: Number,
breakpoint: String,
collectionPath: String,
productPath: String,
currentPage: Number,
totalPages: Number,
perPage: Number,
totalItems: Number,
fetchedItems: Number,
loadedItems: Number,
isVirtualQuickview: Boolean
}
/**
* Stimulus Targets
*/
static targets = [
'mount',
'product',
'quickview',
'pagination'
]
/**
* Stimulus Classes
*
* @static
* @memberof Dropdown
* @version 2.0
*/
static classes = [
'product'
]
initialize () {
if (isPreview() || this.totalItemsValue === 0) return
}
connect () {
if (isPreview() || this.totalItemsValue === 0) return
// if (App.screen.isActive('md')) smoothscroll.polyfill()
this.node = document.createElement('div')
this.scroll = new IntersectionObserver(this.paginate.bind(this))
if (!this.hasLoadedItemsValue) {
this.loadedItemsValue = this.perPageValue
}
m.mount(this.node, this.collectionVnode())
if (!this.hasMountValue) {
this.mountValue = 'initial'
}
}
disconnect () {
if (this.scroll) {
this.scroll.disconnect()
}
m.mount(this.node, null)
if (this.mountValue !== 'stored' && this.products?.length) {
this.mountValue = 'cached'
this.fetchedItemsValue = this.products.length
}
}
/**
* Paginate Observer Intersection Callback
*
* @returns {Promise<void>}
* @memberof Products
*/
paginate ([ entry ]) {
// console.log('in paginate', this)
if (!entry.isIntersecting) return
this.collectionFetch()
this.collectionFetch() // run 2 times
}
/**
* Fetch Collection - Leverages the Shopify public endpoint API
*
* @returns {Promise<void>}
* @memberof Products
*/
async collectionFetch () {
if (this.complete) {
this.mountValue = 'stored'
return null
}
this.fetchValue = false
this.currentPageValue = this.currentPageValue + 1
this.collectionPathValue = window.location.pathname
try {
const { products } = await m.request({
method: 'GET',
url: `${this.collectionPathValue}/products.json`,
params: {
page: this.currentPageValue,
limit: this.perPageValue * 2
}
})
this.products = products
this.loadedItemsValue = (this.loadedItemsValue + products.length)
this.fetchValue = true
} catch (e) {
console.log(e)
// return Turbolinks.visit(`${this.collectionPathValue}?page=${this.currentPageValue}`)
}
}
/**
* Render Collection Product Items
*
* @memberof Products
*/
collectionVnode () {
console.log(this.products)
return ({
oninit: () => this.scroll.observe(this.paginationTarget),
oncreate: () => this.collectionFetch(),
onupdate: ({ state }) => {
this.mountTarget.append(...this.node.children)
},
view: () => [
this.fetchValue ? m(Product, {
perPage: this.perPageValue,
className: this.productClass,
products: this.mountValue === 'cached'
? this.products.slice(this.fetchedItemsValue)
: this.products
}) : null
]
})
}
productPathValueChanged () {
// m.redraw()
}
/**
* Toggle Quickview open/close (used in touch devices)
*
* @param {Event} event
* @returns
* @memberof Quickview
*/
quickview (event) {
// skip when viewport is in desktop
if (!App.screen.isActive('sm')) return
event.preventDefault()
const index = parseInt(event.currentTarget.id)
const path = new URL(event.currentTarget.href).pathname
// index = isEven(index) ? index - 1 : index
// set state in dom
this.productPathValue = path
this.isVirtualQuickviewValue = false
// unmount any active quickviews
let node
if (this.activeValue > -1 && index !== this.activeValue) {
node = this.element.querySelector(`[data-${this.activeValue}]`)
node.classList.remove('opened')
node.classList.add('closed')
m.mount(node, null)
}
this.activeValue = index
// select view in grid
node = this.element.querySelector(`[data-${this.activeValue}]`)
node.classList.remove('closed')
node.classList.add('opened')
// mount virtual nodes
m.mount(node, this.quickviewVnode(node))
}
/**
* Fetch Product - Leverages the Shopify public endpoint API
*
* @returns {Promise<void>}
* @memberof Products
*/
async productFetch () {
if (this.exists) return null
this.product = null
try {
const { product } = await m.request({
method: 'GET',
url: this.productPathValue + '.json'
})
this.product = product
} catch (e) {
console.log(e)
// return Turbolinks.visit(this.productPathValue)
}
}
/**
* Render Collection Product Items
*
* @memberof Products
*/
quickviewVnode () {
return ({
oninit: () => this.productFetch(),
oncreate: ({ dom }) => dom.scrollIntoView({ behavior: 'smooth' }),
view: () => m('.product-viewer', this.product ? m(Quickview, this.product) : [
m('.aspect-ratio.d-block', this.loadingIconValue
? m('svg.loading', m('use[xlink:href="#loading"]'))
: null)
])
})
}
/**
* Check for completed pagination
*
* @memberof Products
*/
get complete () {
return ((
(this.loadedItemsValue + this.perPageValue) === this.totalItemsValue
) || this.currentPageValue === this.totalPagesValue)
}
/**
* Get Product - returns JSON response of single products
*
* @memberof Products
*/
get exists () {
return Products.state.has(this.productPathValue)
}
/**
* Get Product - returns JSON response of single products
*
* @memberof Products
*/
get product () {
return Products.state.get(this.productPathValue)
}
/**
* Set Product - Sets JSON response of a single product
*
* @memberof Products
*/
set product (data) {
Products.state.set(this.productPathValue, data)
}
/**
* Get Products
*
* @memberof Products
*/
get products () {
return Products.state.get(this.collectionPathValue)
}
/**
* Set Products
*
* @memberof Products
*/
set products (data) {
if (Products.state.has(this.collectionPathValue)) {
Products.state.get(this.collectionPathValue).push(...data)
} else {
Products.state.set(this.collectionPathValue, data)
}
}
}
import m from 'mithril'
import { Dropdown } from '../../controllers/Dropdown'
import { handleize, parse } from '../../application/Utilities'
export default {
view: ({
attrs: {
variants
}
}) => m('.dropdown', Dropdown.public.contoller, [
m('button.dropdown-button', Dropdown.public.button, [
m('span', Locale.product.selectSize),
m('svg.icon', m("use[xlink:href='#chevron-bottom']"))
]),
m('.dropdown-list[data-dropdown-target="list"]', [
m('ul[data-action="click->dropdown#options"]', [
variants.map(({
id,
title,
inventory_quantity
}) => inventory_quantity > 0 ? m('li.d-flex.ai-center', [
m(`label[id="${id}"][for=${handleize(title)}]`, {
'data-action': 'click->purchase#select'
}, title),
inventory_quantity <= 3
? m('span.ml-auto.pr-2', parse(Locale.product.stockRemain, inventory_quantity))
: null,
m(`input[type="hidden"][id=${id}][value="${id}"]`)
]) : m('li.d-flex.ai-center', [
m('label.disabled[data-disabled=][for=][id=]', title),
m('span.disabled.ml-auto.pr-2[data-disabled=]', Locale.product.outOfStock)
]))
])
])
])
}
import m from 'mithril'
import { splitTitle, money, parse } from '../../application/Utilities'
/**
* Pricing Virtual Node
*
* Generates product pricing vnode. Text locales are binded
* to `this` scope.
*
* @export
* @param {string} title
* @param {string} variants
* @param {{percentOff: string, lowStock: string }} param
* @returns
*/
export function Pricing (title, variants) {
let stock = variants.filter(({ available }) => available === true)
if (stock.length < 1) stock = variants
const [ { compare_at_price, price } ] = stock
const { name, colour } = splitTitle(title)
const remains = stock.length
const pricing = money(price)
const percent = Math.floor((compare_at_price - price) / compare_at_price * 100)
return {
header: () => [
m('.row.jc-between.ai-center.text-center.text-md-left', [
m('.col-12', [
m('h4.mt-3.mb-0', name),
m('span.font-weight-light.text-uppercase.text-muted', colour)
]),
m('.col-12.col-md-auto.py-3.py-md-0', [
m('h3.mb-0', pricing),
((compare_at_price > price)
? m('s', money(compare_at_price))
: m('span.percent', `${Math.abs(percent)} ${Locale.product.percentOff}`))
])
])
],
hover: () => [
((compare_at_price === price)
? null
: m('.sale h5', `${Math.abs(percent)} ${Locale.product.percentOff}`)
),
((remains < 4)
? m('.low-stock h6', [
m('span.mr-1', parse(Locale.product.stockRemain, remains))
])
: null
),
m('ul', [
m('li.d-block h5 mb-1', name),
m('li.d-block h6 mt-0', colour),
(compare_at_price !== price ? [
m('li.price h5 d-inline mr-1', pricing),
m('li.price-old h5 d-inline ml-1', money(compare_at_price))
] : [
m('li.price h5 d-inline text-center', pricing)
])
])
]
}
}
// mithril product collection item
import m from 'mithril'
import { Pricing } from './Pricing'
import { imgSrc, isEven } from './../../application/Utilities'
export default ({
oninit: ({ state }) => {
console.log(state)
state.index = -1
},
onupdate: () => App.images.observe(),
oncreate: () => App.images.observe(),
view: ({
state,
attrs: {
className,
products,
perPage
}
}) => products.map(({
id
, handle
, title
, variants
, images: [
{
alt,
src
}
]
}, index) => [
m('.' + className, [
m('a.d-block', {
id,
title,
href: `/products/${handle}`,
'data-index': (perPage / 2) + index,
'data-action': 'click->products#quickview'
}, [
m('.details.d-flex.ai-center.jc-center.text-center', (
Pricing(title, variants).hover()
)),
m('.aspect-ratio d-block', [
m('picture.image[data-src]', { alt, 'data-iesrc': imgSrc(src, 720) }, [
m('source', {
srcset: imgSrc(src, 1024),
media: '(min-width: 1640px)'
}),
m('source', {
srcset: imgSrc(src, 720),
media: '(min-width: 540px)'
}),
m('source', {
srcset: imgSrc(src, 420),
media: '(min-width: 320px)'
})
])
])
])
]),
!isEven(index)
? m(`.product-quickview.col-12.px-2.py-4.closed[data-${state.index}][data-${id}]`, {
'data-products-target': 'quickview'
}) : (state.index = id)
])
})
{%- unless grid -%}
{%- capture grid -%}
col-6 col-sm-4 col-xl-3 p-{{ settings.product_item_gutter }}
{%- endcapture -%}
{%- endunless -%}
<div class="product-item {{ grid }}" {{ target }} {%- if style -%} {{ style }} {%- endif -%}>
<a
class="d-block"
data-action="click->products#quickview"
data-index=" {{- index -}}"
href=" {{- product.url -}}"
id=" {{- product.id }}"
title=" {{- product.title }}">
<div class="details d-flex ai-center jc-center text-center" data-id=" {{- product.id }}">
{%- if quantity <= 4 -%}
<div class="low-stock h6">
Only
<span class="mr-1">{{ quantity }}</span>
{{- 'product_item.low_stock' | t -}}
</div>
{%- endif -%}
<ul>
<li class="d-block h5 mb-1">
{{ product.title | split: '–' | first }}
</li>
<li class="d-block h6 mt-0">
{{ product.title | split: '–' | last | strip }}
</li>
<!-- Product Discounted -->
{%- if product.compare_at_price > product.price -%}
<li class="price h5 d-inline mr-1">
{{ product.price | money }}
</li>
<li class="price-old h5 d-inline ml-1">
{{ product.compare_at_price | money }}
</li>
{%- else -%}
<li class="price h5 d-inline text-center">
{{ product.price | money }}
</li>
{%- endif -%}
</ul>
<!-- Product Highlight -->
{%- if product.metafields.product.highlight != null -%}
<div class="highlight h6">
<!-- Reverse the R for aesthetic -->
{%- if product.metafields.product.highlight contains 'Reversible'-%}
{{- product.metafields.product.highlight | replace: 'Reversible'
, 'Reve<span class="letter-flip">r</span>sible' -}}
{%- else -%}
{{- product.metafields.product.highlight -}}
{%- endif -%}
</div>
{%- endif -%}
</div>
<!-- Product Images -->
<div class="aspect-ratio">
<picture
class="image"
data-id=" {{- product.id -}}"
data-iesrc=" {{- product.featured_image.src | img_url: '640x' -}}"
data-src>
<source
media="(min-width: 1640px)"
srcset="{{- product.featured_image.src | img_url: '720x' -}}" />
<source
media="(min-width: 540px)"
srcset="{{- product.featured_image.src | img_url: '640x' -}}" />
<source
media="(min-width: 320px)"
srcset="{{- product.featured_image.src | img_url: '320x' -}}" />
<source
media="(max-width: 320px)"
srcset="{{- product.featured_image.src | img_url: '320x' -}}" />
<noscript>
<img
alt=" {{- product.title -}}"
class="img-fluid"
src=" {{- product.featured_image.src | img_url: '640x' -}}">
</noscript>
</picture>
</div>
</a>
</div>
import m from 'mithril'
import { Pricing } from './Pricing'
import Options from './Options'
import Images from './Images'
export default {
view: ({
attrs: {
title,
variants,
images,
handle
}
}) => [
m(Images, { images }),
Pricing(title, variants).header(),
m('.row.jc-center.no-gutter.mt-3.px-0.product-purchase', {
'data-controller': 'purchase',
'data-purchase-selected-value': 'selected'
}, [
m('.col-12.mb-2', m(Options, { variants })),
m('div.col-12', [
m('button.cart-button', {
disabled: 'true',
'data-action': 'click->purchase#addToCart',
'data-drawer': 'ajax-cart',
'data-purchase-target': 'addToCart'
}, [
m('span.add-to-cart', Locale.product.addToCart),
m('span.select-size', Locale.product.sizeRequired)
])
])
]),
m(`a.d-block.h5.my-4.text-center[href="${handle}"]`, Locale.collection.viewMore)
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment