This commit is contained in:
f 2021-06-01 18:33:49 -03:00
parent 5406d28a9d
commit 990deb509d
42 changed files with 2516 additions and 31 deletions

View file

@ -58,6 +58,7 @@ locales:
- es
ignored_layouts:
- menu
- email
linked_fields:
- post
- item

View file

@ -1,5 +1,19 @@
---
locale: English
countries:
validation: 'Choose a country from the list'
states:
validation: 'Choose a state from the list'
recover_order: 'Recover order'
alerts:
incorrect_password: "You're registered but your password is incorrect, you can try again or continue shopping as a guest."
successful_login: "You're logged in, your order will be added to your shopping history."
successful_signup: "Thanks for signing up!"
error: "There was an error contacting the store, please try again in a few minutes :("
no_response_error: "There was an error contacting the store, please try again in a few minutes :("
spree_error: "There was an error contacting the store, please try again in a few minutes :("
recover_order: "We can't find your order"
select_format: "Choose a format:"
date:
format: '%m/%d/%Y'
abbr_day_names:
@ -49,6 +63,11 @@ time:
pm: pm
layouts:
post: Article
cart: Cart
confirmation: Order confirmation
payment: Payment
shipment: Shipment
email: Confirmation E-mail
menu: Menu
about: About this site
menu:

View file

@ -1,5 +1,19 @@
---
locale: Castellano
countries:
validation: Elige un país de la lista
states:
validation: Elige una provincia/estado de la lista
recover_order: Rehacer el pedido
alerts:
incorrect_password: Estás registrade pero la contraseña es incorrecta, podés intentar de nuevo o continuar la compra como invitade.
successful_login: Iniciaste sesión, tu pedido se agregará a tu historia de compras.
successful_signup: ¡Gracias por registrarte!
error: 'Hubo un error al comunicarse con la tienda, te invitamos a intentar en unos minutos :('
no_response_error: 'Hubo un error al comunicarse con la tienda, te invitamos a intentar en unos minutos :('
spree_error: 'Hubo un error al comunicarse con la tienda, te invitamos a intentar en unos minutos :('
recover_order: 'No encontramos el pedido en la tienda'
select_format: 'Seleccionar formato:'
date:
format: '%d/%m/%Y'
abbr_day_names:
@ -49,6 +63,11 @@ time:
pm: pm
layouts:
post: Artículo
cart: Carrito
confirmation: Confirmación de compra
payment: Pago
shipment: Envío
email: Correo de confirmación
menu: Menú
about: Información del sitio
menu:

View file

@ -1,40 +1,41 @@
---
pronouns:
type: "string"
autocomplete: "sex"
type: 'string'
autocomplete: 'sex'
label:
es: "Pronombres"
en: "Pronouns"
es: 'Pronombres'
en: 'Pronouns'
placeholder:
es: "¿Qué pronombres usás?"
en: "What are your pronouns?"
es: '¿Qué pronombres usás?'
en: 'What are your pronouns?'
name:
type: "string"
autocomplete: "name"
type: 'string'
autocomplete: 'name'
label:
es: "Nombre"
en: "Name"
es: 'Nombre'
en: 'Name'
placeholder:
es: "Nombre, pseudónimo, alias"
en: "Name, pseudonym, alias"
es: 'Nombre, pseudónimo, alias'
en: 'Name, pseudonym, alias'
from:
type: "email"
autocomplete: "email"
type: 'email'
autocomplete: 'email'
label:
es: "Correo electrónico"
en: "E-mail address"
es: 'Correo electrónico'
en: 'E-mail address'
body:
type: text
type: 'text'
label:
es: Mensaje
en: Message
es: 'Mensaje'
en: 'Message'
consent:
type: boolean
type: 'boolean'
label:
es: Acepto las políticas de privacidad
en: I agree to the privacy policy
es: 'Acepto las políticas de privacidad'
en: 'I agree to the privacy policy'
submit:
type: submit
type: 'submit'
label:
es: Enviar
en: Send
es: 'Enviar'
en: 'Send'
extra: 'data-target="contact.submit"'

View file

@ -0,0 +1,94 @@
---
firstname:
type: 'string'
required: true
label:
es: 'Nombre'
en: 'Name'
autocomplete: 'given-name'
error:
es: 'El nombre es obligatorio'
en: 'Name is required'
lastname:
type: 'string'
required: true
label:
es: 'Apellido'
en: 'Last name'
autocomplete: 'family-name'
error:
es: 'El apellido es obligatorio'
en: 'Last name is required'
address1:
type: 'string'
required: true
label:
es: 'Calle y número'
en: 'Street and number'
autocomplete: 'address-line1'
error:
es: 'La dirección es obligatoria'
en: 'Address is required'
city:
type: 'string'
required: true
label:
es: 'Ciudad'
en: 'City'
autocomplete: 'city'
error:
es: 'La ciudad es obligatoria'
en: 'City is required'
country_id:
type: 'country'
required: true
group: 'shipping_address'
autocomplete: 'country-name'
label:
es: 'País'
en: 'Country'
help:
es: 'Comenzar a escribir para filtrar'
en: 'Start writing to filter'
error:
es: 'El país debe estar en la lista'
en: 'Country must be on the list'
state_id:
type: 'state'
required: true
group: 'shipping_address'
label:
es: 'Provincia / Estado'
en: 'Province / State'
help:
es: 'Comenzar a escribir para filtrar'
en: 'Start writing to filter'
error:
es: 'La provincia debe estar en la lista'
en: 'Province must be on the list'
zipcode:
type: 'postal_code'
group: 'shipping_address'
required: true
label:
es: 'Código postal'
en: 'Postal/ZIP code'
autocomplete: 'postal-code'
error:
es: 'El código postal no tiene el formato correcto'
en: 'Postal/ZIP code is not on the correct format'
phone:
type: 'string'
required: true
label:
es: 'Teléfono'
en: 'Phone number'
autocomplete: 'tel'
error:
es: 'El teléfono es obligatorio'
en: 'Phone number is required'
submit:
type: 'submit'
label:
es: 'Enviar'
en: 'Send'

13
_data/forms/user.yml Normal file
View file

@ -0,0 +1,13 @@
---
email:
required: true
autocomplete: 'email'
type: 'email'
label:
es: 'Correo electrónico'
en: 'E-mail'
extra:
input: 'data-target="cart-contact.username"'
error:
es: 'Debe ser una dirección válida'
en: 'E-mail address is not valid'

198
_data/layouts/cart.yml Normal file
View file

@ -0,0 +1,198 @@
---
meta:
limit: 1
help:
es: 'Personalización de varios elementos del carrito. Si hay varios artículos, se toma el primero de la lista.'
en: 'Personalization for several cart elements. If there are several articles, the first in the list is used.'
title:
type: string
required: true
label:
es: Título
en: Title
help:
es: 'El título de la página del carrito de compras'
en: 'Page title for cart page'
default:
es: 'Carrito de compras'
en: 'Cart'
content:
type: 'content'
label:
es: 'Contenido'
en: 'Content'
help:
es: 'Texto que quieras agregar al carrito, forma de funcionamiento, anuncios, tiempos de entrega, etc.'
en: 'Any text you want to add to the cart: how it works, announcements, etc.'
product:
type: 'string'
required: true
label:
es: 'Nombre del ítem en venta'
en: 'Name of the item for sale'
help:
es: 'El nombre que tienen los productos o servicios que vendés, por ejemplo Libros'
en: "The name of the product or service you're selling, for example Books"
default:
es: 'Producto'
en: 'Product'
currency:
type: 'string'
required: true
label:
es: 'Moneda'
en: 'Currency'
help:
es: 'Código de tres letras de la moneda en la que se expresan los precios'
en: 'Three letter code to denote currency used in pricing'
default:
es: 'ARS'
en: 'USD'
currency_alternate:
type: 'string'
required: true
label:
es: 'Moneda alternativa'
en: 'Alternative currency'
help:
es: 'Nombre alternativo de la moneda, para mostrar, por ejemplo "$", "pesos", "pe", etc'
en: 'Alternative name of currency to be shown, for example "$", "dollars", etc'
default:
es: 'ARS'
en: 'USD'
price:
type: 'string'
required: true
label:
es: 'Precio'
en: 'Price'
help:
es: ''
en: ''
default:
es: 'Precio'
en: 'Price'
quantity:
type: 'string'
required: true
label:
es: 'Cantidad'
en: 'Quantity'
help:
es: 'Nombre del selector de cantidades'
en: 'Name of quantity selector'
default:
es: 'Cantidad'
en: 'Quantity'
subtotal:
type: 'string'
required: true
label:
es: 'Subtotal'
en: 'Subtotal'
help:
es: 'Nombre de la suma de precios por ítem de venta'
en: 'Name for the price sum per sale item'
default:
es: 'Subtotal'
en: 'Subtotal'
total:
type: 'string'
required: true
label:
es: 'Total'
en: 'Total'
help:
es: 'Nombre de la suma total del pedido'
en: 'Name of total sum'
default:
es: 'Total'
en: 'Total'
add:
type: 'string'
required: true
label:
es: 'Botón de agregar libro'
en: 'Add to cart button'
help:
es: 'El texto del botón de agregar al carrito'
en: 'Text on Add to cart button'
default:
es: 'Agregar al carrito'
en: 'Add to cart'
added:
type: 'string'
required: true
label:
es: 'Producto agregado'
en: 'Product added to cart'
help:
es: 'Texto para avisar que el producto fue agregado al carrito'
en: 'Text to notify when a product has been added to the cart'
default:
es: 'Agregado al carrito'
en: 'Product added to cart'
out_of_stock:
type: 'string'
required: true
label:
es: 'Sin stock'
en: 'Sold out'
help:
es: 'Texto del botón de agregar al carrito cuando el libro está agotado'
en: 'Text for grayed-out Add to cart button when no stock is available'
default:
es: 'Agotado'
en: 'Sold out'
remove:
type: 'string'
required: true
label:
es: 'Botón de quitar producto'
en: 'Remove product button'
help:
es: 'El texto del botón de quitar del carrito'
en: 'Text for Remove product button'
default:
es: 'Quitar del carrito'
en: 'Remove product'
back:
type: 'string'
required: true
label:
es: 'Volver a la tienda'
en: 'Back'
help:
es: 'Nombre del botón para volver a la tienda'
en: 'Text for Back to store button'
default:
es: 'Volver a la tienda'
en: 'Back'
permalink:
type: 'permalink'
required: true
label:
es: 'Dirección de la página'
en: ''
help:
es: 'La dirección de la página del carrito dentro del sitio'
en: 'Address for cart page inside site'
default:
es: 'carrito/'
en: 'cart/'
draft:
type: 'boolean'
label:
es: 'Borrador'
en: 'Draft'
help:
es: 'Este artículo aun no está listo para publicar'
en: "This post isn't ready to be published yet"
order:
type: 'order'
label:
es: 'Orden'
en: 'Order'
help:
es: 'La posición del artículo en la lista de artículos'
en: 'The post position in the posts list'

View file

@ -0,0 +1,68 @@
---
meta:
limit: 1
help:
es: Página de confirmación de compra. Si hay varias se toma la primera de la lista.
en: ''
title:
type: string
required: true
label:
es: Título
en: Title
help:
es: 'Título de la confirmación'
en: 'Confirmation page title'
default:
es: '¡Gracias por tu compra!'
en: 'Thank you for your purchase!'
content:
type: 'content'
label:
es: 'Contenido'
en: 'Content'
help:
es: 'Puedes agregar información de contacto, tiempos de entrega, etc. aquí.'
en: 'You may add contact information, timeframe for delivery, etc. here'
default:
es: 'En breve te llegará un correo con la información de tu pedido.'
en: 'You will receive an email detailing your purchase soon.'
permalink:
type: 'permalink'
required: true
label:
es: 'Dirección de la página'
en: 'Confirmation page address'
help:
es: 'La dirección de la página de confirmación dentro del sitio'
en: 'The address the confirmation page holds within your site'
default:
es: 'confirmacion/'
en: 'confirmation/'
back:
type: 'string'
label:
es: 'Volver al sitio'
en: 'Back to site'
help:
es: 'Texto del botón de volver al sitio'
en: 'Text for Back to site button'
default:
es: 'Volver al sitio'
en: 'Back to site'
draft:
type: 'boolean'
label:
es: 'Borrador'
en: 'Draft'
help:
es: 'Este artículo aun no está listo para publicar'
en: "This post isn't ready to be published yet"
order:
type: 'order'
label:
es: 'Orden'
en: 'Order'
help:
es: 'La posición del artículo en la lista de artículos'
en: 'The post position in the posts list'

56
_data/layouts/email.yml Normal file
View file

@ -0,0 +1,56 @@
---
title:
type: string
required: true
label:
es: Título
en: Title
help:
es: ''
en: ''
default:
es: 'Correo de confirmación'
en: ''
intro:
type: 'html'
label:
es: 'Introducción'
en: ''
help:
es: 'Texto de apertura del correo de confirmación'
en: ''
thanks:
type: 'html'
label:
es: 'Pie'
en: ''
help:
es: 'Pie del correo'
en: ''
show_address:
type: 'boolean'
label:
es: 'Mostrar la dirección'
en: ''
help:
es: 'Agregar la dirección informada por le cliente en el correo'
en: ''
default:
es: true
en: true
order:
type: 'order'
label:
es: 'Orden'
en: ''
help:
es: 'La posicion del articulo en la lista de articulos'
en: ''
draft:
type: 'boolean'
label:
es: 'Borrador'
en: ''
help:
es: 'Tildar si no está listo para ser publicado'
en: ''

114
_data/layouts/payment.yml Normal file
View file

@ -0,0 +1,114 @@
---
meta:
limit: 1
help:
en: 'Página de confirmación de pago. Si hay varias se usa la primera de la lista.'
es: 'Confirmation of payment page. If there are several, the first in list will be used.'
title:
type: string
required: true
label:
es: Título
en: Title
help:
es: 'El título de la página de medios de pago'
en: 'Title for payment options page'
default:
es: 'Medios de pago'
en: 'Payment options'
content:
type: 'content'
label:
es: 'Contenido'
en: 'Content'
help:
es: 'Puedes agregar texto opcional aquí'
en: 'You may add optional text here'
total:
type: 'string'
required: true
label:
es: 'Total'
en: 'Total'
help:
es: 'El precio total de la compra'
en: 'Total price'
default:
es: 'Total'
en: 'Total'
special_instructions:
type: 'string'
required: true
label:
es: 'Instrucciones especiales'
en: 'Special instructions'
help:
es: ''
en: 'Title text for Special instructions section'
default:
es: 'Instrucciones especiales'
en: 'Special instructions'
special_instructions_help:
type: 'string'
required: true
label:
es: 'Ayuda de las instrucciones especiales'
en: 'Help for special instructions section'
help:
es: ''
en: 'You might want to give examples of special instructions that can be added'
default:
es: 'Horas específicas de entrega, etc.'
en: 'Specific delivery hours, etc.'
permalink:
type: 'permalink'
required: true
label:
es: 'Dirección de la página'
en: 'Payment page address'
help:
es: 'La dirección de la página de medios de pago dentro del sitio'
en: 'The address the payment page hold within the site url'
default:
es: 'pago/'
en: 'payment/'
back:
type: 'string'
required: true
label:
es: 'Volver a métodos de envío'
en: 'Back to shipping options'
help:
es: 'Nombre del botón para volver a los métodos de envío'
en: 'Button text for Back to shipping options'
default:
es: 'Volver al métodos de envío'
en: 'Back to shipping options'
next_step:
type: 'string'
required: true
label:
es: 'Siguiente paso'
en: 'Next step'
help:
es: 'Texto del botón de pasar al siguiente paso de la compra (el pago)'
en: 'Text for next step in purchase button'
default:
es: 'Pagar'
en: 'Checkout'
draft:
type: 'boolean'
label:
es: 'Borrador'
en: 'Draft'
help:
es: 'Este artículo aun no está listo para publicar'
en: "This post isn't ready to be published yet"
order:
type: 'order'
label:
es: 'Orden'
en: 'Order'
help:
es: 'La posición del artículo en la lista de artículos'
en: 'The post position in the posts list'

142
_data/layouts/product.yml Normal file
View file

@ -0,0 +1,142 @@
---
title:
type: 'string'
required: true
label:
es: 'Producto'
en: 'Product'
description:
type: 'text'
required: true
label:
es: 'Alerta de contenido o descripción del libro'
en: 'Content warning or book description'
help:
es: |
Resumen del contenido del libro, que también usarán redes
sociales y buscadores. Si el libro trata de violencias y otros
temas sensibles, te invitamos a usar este campo como alerta de
contenido, para que las personas puedan determinar cuándo quieren
abrirlo.
en: |
Summary of book contents, also used by social media and search
engines. If the book is about violence or other sensitive
topics, we invite you to use it as a content warning, so others
can decide when they want to read it.
image:
type: 'image'
path:
label:
es: 'Imagen principal'
en: 'Main image'
help:
es: 'Resolución recomendada: 1280 píxeles de ancho'
en: 'Recommended resolution: 1280 pixels wide'
description:
label:
es: 'Descripción de la imagen'
en: 'Main image description'
help:
es: Describe la cubierta para usuaries no videntes y buscadores
en: |
Describe the book cover for blind or partially sighted users and
search engines
content:
type: 'content'
label:
es: 'Contenido'
en: 'Content'
help:
es: 'Escribe aquí el artículo'
en: 'Write the post here'
price:
type: 'number'
required: true
writable: 'once'
label:
es: 'Precio'
en: 'Price'
help:
es: 'El precio se gestiona a través de la tienda'
en: 'Price is managed through the store'
stock:
type: 'number'
required: true
writable: 'once'
label:
es: 'Stock inicial'
en: 'Starting stock'
help:
es: 'El stock se gestiona automáticamente a través de la tienda'
en: 'Stock is automatically managed by the store'
sku:
type: 'sku'
required: true
label:
es: 'SKU'
en: 'SKU'
help:
es: 'Código único del producto'
en: 'Product code'
cost_price:
type: 'number'
writable: 'once'
label:
es: 'Precio de costo'
en: 'Cost price'
help:
es: ''
en: ''
width:
type: 'number'
writable: 'once'
label:
es: 'Ancho'
en: 'Width'
help:
es: 'En milímetros'
en: 'In millimeters'
height:
type: 'number'
writable: 'once'
label:
es: 'Alto'
en: 'Height'
help:
es: 'En milímetros'
en: 'In millimeters'
depth:
type: 'number'
writable: 'once'
label:
es: 'Profundidad (Lomo)'
en: 'Depth'
help:
es: 'En milímetros'
en: 'In millimeters'
weight:
type: 'number'
required: true
writable: 'once'
label:
es: 'Peso'
en: 'Weight'
help:
es: 'En gramos, se utiliza para calcular el costo de envío'
en: 'In grams, used to calculate shipping rates'
order:
type: 'order'
label:
es: 'Orden'
en: 'Order'
help:
es: 'La posición del artículo en la lista de artículos'
en: 'The post position in the posts list'
draft:
type: 'boolean'
label:
es: 'Borrador'
en: 'Draft'
help:
es: 'Este artículo aun no está listo para publicar'
en: "This post isn't ready to be published yet"

126
_data/layouts/shipment.yml Normal file
View file

@ -0,0 +1,126 @@
---
meta:
limit: 1
help:
en: 'Página del proceso de envío. Si hay varias se toma la primera de la lista.'
es: 'Shipment page. If there are several, the first in the list will be used.'
title:
type: string
required: true
label:
es: Título
en: Title
help:
es: 'El título de la página de envío'
en: 'Title for Shipment page'
default:
es: 'Envío'
en: 'Shipment'
content:
type: 'content'
label:
es: 'Contenido'
en: 'Content'
help:
es: 'Podés agregar instrucciones, demora en las entregas, etc. aquí'
en: 'You can add instructions, shipping delay announcements, etc. here'
user:
type: 'string'
required: true
label:
es: 'Cuenta'
en: 'User'
help:
es: 'Subtítulo de contacto'
en: 'Contact subtitle'
default:
es: 'Contacto'
en: 'Contact'
shipping_address:
type: 'string'
required: true
label:
es: 'Subtítulo de la dirección de envío'
en: 'Shipping address subtitle'
help:
es: 'Texto para subtítulo de Dirección de envío'
en: 'Text for Shipping address subtitle'
default:
es: 'Dirección de envío'
en: 'Shipping address'
shipping_methods:
type: 'string'
required: true
label:
es: 'Subtítulo de métodos de envío'
en: 'Shipping options subtitle'
help:
es: 'Texto para subtítulo de métodos de envío'
en: 'Text for shipping options subtitle'
default:
es: 'Métodos de envío'
en: 'Shipping options'
shipping_methods_help:
type: 'text'
required: true
label:
es: 'Ayuda para los métodos de envío'
en: 'Help text for shipping options'
help:
es: 'El texto de la sección'
en: 'Help the client choose a shipping method'
default:
es: 'Completa la dirección de entrega para calcular los métodos de envío.'
en: 'Complete your shipping address to calculate available shipping methods.'
back:
type: 'string'
required: true
label:
es: 'Volver al carrito'
en: 'Back to cart'
help:
es: 'Nombre del botón para volver al carrito'
en: 'Text for Back to cart button'
default:
es: 'Volver al carrito'
en: 'Back to cart'
next_step:
type: 'string'
required: true
label:
es: 'Siguiente paso'
en: 'Next step'
help:
es: 'Texto del botón de pasar al siguiente paso de la compra (el pago)'
en: 'Text for next step button'
default:
es: 'Pagar'
en: 'Payment'
permalink:
type: 'permalink'
required: true
label:
es: 'Dirección de la página'
en: 'Shipment page address'
help:
es: 'La dirección de la página del carrito dentro del sitio'
en: 'The address this page has inside the site url'
default:
es: 'envio/'
en: 'shipment/'
draft:
type: 'boolean'
label:
es: 'Borrador'
en: 'Draft'
help:
es: 'Este artículo aun no está listo para publicar'
en: "This post isn't ready to be published yet"
order:
type: 'order'
label:
es: 'Orden'
en: 'Order'
help:
es: 'La posición del artículo en la lista de artículos'
en: 'The post position in the posts list'

8
_includes/cart_add.html Normal file
View file

@ -0,0 +1,8 @@
<button
{{ include.product.in_stock | value_unless: 'disabled' }}
data-action="cart#add"
data-stock-add
class="btn btn-success btn-block btn-lg">
<span class="when-parent-disabled">{{ site.cart.out_of_stock }}</span>
<span class="when-parent-enabled">{{ site.cart.add }}</span>
</button>

View file

@ -0,0 +1,10 @@
data-controller="cart"
data-target="stock.product"
data-cart-url="{{ include.product.url }}"
data-cart-variant-id="{{ include.product.variant_id }}"
data-cart-image="{{ include.product.image.path | thumbnail: 212, 300 }}"
data-cart-title="{{ include.product.title }}"
data-cart-price="{{ include.product.price }}"
data-cart-in-stock="{{ include.product.in_stock }}"
data-cart-stock="{{ include.product.stock }}"
data-cart-extra="{{ include.extra | join: '|' }}"

53
_includes/country.html Normal file
View file

@ -0,0 +1,53 @@
{% if include.form %}
{%- assign name = include.field[0] | append: ']' | prepend: '[' | prepend: include.form -%}
{%- assign id = include.field[1].id | default: name | replace: '[', '_' | remove: ']' -%}
{% else %}
{%- assign name = include.field[0] -%}
{%- assign id = include.field[1].id | default: name %}
{% endif %}
{%- assign label = include.field[1].label[site.locale] -%}
{%- assign help = include.field[1].help[site.locale] -%}
{%- assign error = include.field[1].error[site.locale] -%}
{%- assign autocomplete = include.field[1].autocomplete -%}
<div class="form-group" data-controller="country" data-country-group="{{ include.field[1].group }}">
<label for="{{ id }}">
{{ label }}
{% if include.field[1].required %}*{% endif %}
</label>
<input data-target="country.id" type="hidden" name="{{ name }}" id="{{ id }}" value="" />
<input data-target="country.iso" type="hidden" name="{{ include.form }}_ignore_{{ include.field[0] }}_iso" value="" />
<input
data-target="country.name"
{% if help %}
aria-describedby="help-{{ id }}"
{% endif %}
{% if include.field[1].required %}
required
{% endif %}
type="{{ include.field[1].type }}"
{% if autocomplete %}
autocomplete="{{ autocomplete }}"
{% endif %}
name="{{ include.form }}_ignore_{{ include.field[0] }}"
id="{{ include.form }}_ignore_{{ include.field[0] }}"
disabled
list="list-{{ id }}"
class="form-control" />
{%- if error -%}
<div class="invalid-feedback">{{ error }}</div>
{%- endif -%}
{%- if help -%}
<small id="help-{{ id }}" class="form-text">
{{ help }}
</small>
{%- endif -%}
<datalist id="list-{{ id }}" data-target="country.list">
</datalist>
</div>

View file

@ -1,7 +1,14 @@
{%- assign name = include.field[0] -%}
{%- assign id = include.field[1].id | default: name -%}
{% if include.form %}
{%- assign name = include.field[0] | append: ']' | prepend: '[' | prepend: include.form -%}
{%- assign id = include.field[1].id | default: name | replace: '[', '_' | remove: ']' -%}
{% else %}
{%- assign name = include.field[0] -%}
{%- assign id = include.field[1].id | default: name %}
{% endif %}
{%- assign label = include.field[1].label[site.locale] -%}
{%- assign help = include.field[1].help[site.locale] -%}
{%- assign error = include.field[1].error[site.locale] -%}
{%- assign autocomplete = include.field[1].autocomplete -%}
<div class="form-group">
@ -11,6 +18,7 @@
</label>
<input
{{ include.field[1].extra.input }}
{% if help %}
aria-describedby="help-{{ id }}"
{% endif %}
@ -25,6 +33,10 @@
{% endif %}
class="form-control" />
{%- if error -%}
<div class="invalid-feedback">{{ error }}</div>
{%- endif -%}
{%- if help -%}
<small id="help-{{ id }}" class="form-text">
{{ help }}

View file

@ -0,0 +1,45 @@
{% if include.form %}
{%- assign name = include.field[0] | append: ']' | prepend: '[' | prepend: include.form -%}
{%- assign id = include.field[1].id | default: name | replace: '[', '_' | remove: ']' -%}
{% else %}
{%- assign name = include.field[0] -%}
{%- assign id = include.field[1].id | default: name %}
{% endif %}
{%- assign label = include.field[1].label[site.locale] -%}
{%- assign help = include.field[1].help[site.locale] -%}
{%- assign error = include.field[1].error[site.locale] -%}
{%- assign autocomplete = include.field[1].autocomplete -%}
<div class="form-group" data-controller="postal-code" data-postal-code-group="{{ include.field[1].group }}">
<label for="{{ id }}">
{{ label }}
{% if include.field[1].required %}*{% endif %}
</label>
<input
name="{{ name }}"
id="{{ id }}"
data-target="postal-code.code"
{% if help %}
aria-describedby="help-{{ id }}"
{% endif %}
{% if include.field[1].required %}
required
{% endif %}
type="{{ include.field[1].type }}"
{% if autocomplete %}
autocomplete="{{ autocomplete }}"
{% endif %}
class="form-control" />
{%- if error -%}
<div class="invalid-feedback">{{ error }}</div>
{%- endif -%}
{%- if help -%}
<small id="help-{{ id }}" class="form-text">
{{ help }}
</small>
{%- endif -%}
</div>

View file

@ -1,5 +1,5 @@
<input
data-target="contact.submit"
{{ include.field[1].extra }}
type="submit"
class="btn btn-success"
value="{{ include.field[1].label[site.locale] }}" />

72
_layouts/cart.html Normal file
View file

@ -0,0 +1,72 @@
---
layout: default
---
<section>
<h1>{{ page.title }}</h1>
<div class="content">
{{ content | replace: '<img ', '<img loading="lazy" ' | replace: '<iframe ', '<iframe loading="lazy" ' }}
</div>
<div
data-controller="order"
data-order-item-template="assets/templates/cart.html">
<div class="row no-gutters align-items-center font-weight-bold justify-content-center">
<div class="d-none d-lg-block col-lg-4 p-3">
<h4 class="m-0">{{ page.product }}</h4>
</div>
<div class="d-none d-lg-block col-lg-8">
<div class="row no-gutters align-items-center">
<div class="col-lg p-3">
<h4 class="m-0">{{ page.price }}</h4>
</div>
<div class="col-lg p-3">
<h4 class="m-0">{{ page.quantity }}</h4>
</div>
<div class="col-lg p-3">
<h4 class="m-0">{{ page.subtotal }}</h4>
</div>
<div class="col-lg-2 p-3">
<h4 class="m-0 sr-only">{{ page.remove }}</h4>
</div>
</div>
</div>
<div class="col-12" data-target="order.cart">
</div>
</div>
<div class="row no-gutters align-items-end">
<div class="order-last order-md-first col-12 col-md-6 col-lg-4 mt-3 mt-md-0">
<a href="?" class="btn btn-transparent black border text-uppercase">
{{ page.back }}
</a>
</div>
<div class="col-12 col-md-6 col-lg-8">
<div class="row no-gutters align-items-center">
<div class="col-lg"></div>
<div class="col-6 col-lg text-right">
<p class="font-weight-bold">{{ page.subtotal }}</p>
</div>
<div class="col-6 col-lg text-center">
<p class="font-weight-bold" data-target="order.subtotal">0</p>
</div>
<div class="col-lg-2"></div>
<div class="col-12">
<a href="{{ site.shipment.url }}" class="btn btn-block btn-primary text-uppercase">{{ site.shipment.title }}</a>
</div>
</div>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,17 @@
---
layout: default
---
<section data-controller="cart-confirmation" data-clear="true">
<header>
<h1>{{ page.title }}</h1>
</header>
<div class="content">
{{ content }}
</div>
<p data-controller="cart-paypal-confirmation"></p>
<a href="?" class="btn btn-primary text-uppercase">{{ page.back }}</a>
</section>

5
_layouts/page.html Normal file
View file

@ -0,0 +1,5 @@
---
layout: default
---
{{ content }}

21
_layouts/payment.html Normal file
View file

@ -0,0 +1,21 @@
---
layout: default
---
<section class="">
<header class="">
<h1>{{ page.title }}</h1>
</header>
<div class="content">
{{ content }}
</div>
<div
id="cart-payment-methods"
data-controller="cart-payment-methods"
data-cart-payment-methods-next-url="{{ site.confirmation.url }}"
data-cart-payment-methods-back-url="{{ site.shipment.url }}"
data-cart-payment-methods-template="assets/templates/payment_methods.html">
</div>
</section>

52
_layouts/shipment.html Normal file
View file

@ -0,0 +1,52 @@
---
layout: default
---
<section>
<header>
<h1>{{ page.title }}</h1>
</header>
<div class="content">
{{ content }}
</div>
<div id="user" data-controller="cart-contact">
<h2>{{ page.user }}</h2>
<form data-target="cart-contact.form">
{% for field in site.data.forms.user %}
{% assign template = field[1].type | append: '.html' %}
{% include {{ template }} field=field form='user' %}
{% endfor %}
</form>
</div>
<div
id="shipping-address"
class="mt-3"
data-controller="cart-shipping"
data-scroll-to="cart-shipping"
data-cart-shipping-next="{{ site.payment.url }}"
data-cart-shipping-template="assets/templates/shipping_methods.html">
<h2>{{ page.shipping_address }}</h2>
<form
data-target="cart-shipping.form"
data-action="cart-shipping#rates">
{% for field in site.data.forms.shipping_address %}
{% assign template = field[1].type | append: '.html' %}
{% include {{ template }} field=field %}
{% endfor %}
</form>
<div id="cart-shipping">
<h2 class="mt-3">{{ page.shipping_methods }}</h2>
<div data-target="cart-shipping.methods">
{{ page.shipping_methods_help | markdownify }}
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,249 @@
import { Controller } from 'stimulus'
import { Liquid } from 'liquidjs'
/*
* Base controller, shared methods go here and other classes extend from
* this.
*/
export class CartBaseController extends Controller {
get spree () {
return window.spree
}
/*
* Gets all products from storage
*
* @return [Array]
*/
get products () {
if (!this.cart) return []
return this.cart.data.relationships.variants.data.map(x => JSON.parse(this.storage.getItem(`cart:item:${x.id}`))).filter(x => x !== null)
}
/*
* The storage
*/
get storage () {
return window.localStorage
}
/*
* Temporary storage
*/
get storageTemp () {
return window.sessionStorage
}
get cart () {
return JSON.parse(this.storage.getItem('cart'))
}
set cart (response) {
this.storage.setItem('cart', JSON.stringify(response.success()))
}
get email () {
return this.storageTemp.getItem('email')
}
set email (email) {
this.storageTemp.setItem('email', email)
}
/*
* The orderToken
*
* @return [String]
*/
get token () {
return this.storage.getItem('token')
}
/*
* The bearerToken
*
* @return [String]
*/
get bearerToken () {
return this.storageTemp.getItem('bearerToken')
}
set bearerToken (token) {
this.storageTemp.setItem('bearerToken', token)
}
/*
* Liquid renderer
*
* @return Liquid
*/
get engine () {
if (!window.liquid) window.liquid = new Liquid()
return window.liquid
}
/*
* Site config (actually just translation strings)
*
* @return [Object]
*/
async site () {
if (!window.site) {
const data = await fetch('assets/data/site.json')
window.site = await data.json()
}
return window.site
}
/*
* Updates the item counter
*/
counterUpdate () {
const item_count = this.cart.data.attributes.item_count
window.dispatchEvent(new CustomEvent('cart:counter', { detail: { item_count }}))
this.storage.setItem('cart:counter', item_count)
}
/*
* Removes the brackets from the name or returns the name
*
* @return [String]
*/
idFromInputName (input) {
const matches = input.name.match(/\[([^\]]+)\]$/)
return (matches === null) ? input.name : matches[1]
}
async handleFailure (response) {
const data = { type: 'primary' }
let template = 'alert'
if (!window.airbrake) window.airbrake.notify(response.fail())
const site = await this.site()
switch (response.fail().name) {
case 'MisconfigurationError':
data.content = response.fail().message
break
case 'NoResponseError':
data.content = site.i18n.alerts.no_response_error
break
case 'SpreeError':
data.content = site.i18n.alerts.spree_error
break
case 'BasicSpreeError':
// XXX: The order is missing, we need to start a new one
if (response.fail().serverResponse.status === 404) {
template = 'recover_order'
data.content = site.i18n.alerts.recover_order
} else {
data.content = response.fail().summary
}
break
case 'ExpandedSpreeError':
data.content = response.fail().summary
break
default:
data.content = response.fail().message
}
console.error(response.fail().name, data.content)
window.dispatchEvent(new CustomEvent('notification', { detail: { template, data } }))
}
set formDisabled (state) {
if (!this.hasFormTarget) return
this.formTarget.elements.forEach(x => x.disabled = !!state)
}
assignShippingAddress () {
if (!this.bearerToken) return
const bearerToken = this.bearerToken
const orderToken = this.token
this.spree.sutty.assignOrderShippingAddress({ bearerToken }, { orderToken })
}
notify (data = {}) {
window.dispatchEvent(new CustomEvent('notification', { detail: { template: 'alert', data } }))
}
/*
* Visita una página con soporte para Turbolinks
*
* @param [String] URL
*/
visit (url) {
try {
Turbolinks.visit(url)
} catch {
window.location = url
}
}
async firstAddress (bearerToken) {
if (!this._firstAddress) {
const addresses = await this.spree.account.addressesList({ bearerToken })
if (addresses.isFail()) {
this.handleFailure(addresses)
return
}
// XXX: Asumimos que si se registró tiene una dirección que vamos
// a actualizar.
this._firstAddress = addresses.success().data[0]
}
return this._firstAddress
}
async updateAddress(bearerToken, id, address) {
const updateAddress = await this.spree.account.updateAddress({ bearerToken }, id, { address })
if (updateAddress.isFail()) {
this.handleFailure(updateAddress)
return
}
return updateAddress.success()
}
async shippingMethods(orderToken) {
const shippingMethods = await this.spree.checkout.shippingMethods({ orderToken }, { include: 'shipping_rates' })
if (shippingMethods.isFail()) {
this.handleFailure(shippingMethods)
return
}
return shippingMethods.success()
}
fireCajon (state = 'open', cajon = 'cajon') {
window.dispatchEvent(new CustomEvent('cajon', { detail: { cajon, state }}))
}
formDataToObject (formData) {
const object = {}
for (const field of formData) {
if (field[0].startsWith('_ignore_')) continue
object[field[0]] = field[1]
}
return object
}
}

View file

@ -0,0 +1,39 @@
import { CartBaseController } from './cart_base_controller'
export default class extends CartBaseController {
static targets = [ 'order' ]
async connect () {
if (this.clear) this.storage.clear()
if (!this.template) return
const order = this.cart.data.attributes
const products = this.products
const site = await this.site()
const shipping_address = JSON.parse(this.storage.getItem('shipping_address'))
const data = {
order,
products,
site,
shipping_address
}
this.render(data)
}
render (data = {}) {
fetch(this.template).then(r => r.text()).then(template => {
this.engine.parseAndRender(template, data).then(html => this.orderTarget.innerHTML = html)
})
}
get template () {
return this.element.dataset.template
}
get clear () {
return this.element.dataset.clear
}
}

View file

@ -0,0 +1,24 @@
import { CartBaseController } from './cart_base_controller'
export default class extends CartBaseController {
static targets = [ 'form', 'username' ]
connect () {
if (!this.hasUsernameTarget) return
if (!this.hasFormTarget) return
this.formTarget.addEventListener('focusout', event => {
if (!this.formTarget.checkValidity()) {
this.formTarget.classList.add('was-validated')
return
}
this.formTarget.classList.remove('was-validated')
const username = this.usernameTarget.value.trim()
if (username.length === 0) return
this.email = username
})
}
}

View file

@ -0,0 +1,264 @@
import { CartBaseController } from './cart_base_controller'
/*
* Manages the cart and its contents.
*
* We need to create an order in Spree API to get a token that allows to
* make changes to it.
*
* The order contains attributes for the order and other data can be
* included, like line items, variants, payments, promotions, shipments,
* billing and shipping addresses, and the user.
*
* Variants are products added to the cart. To remove an item or change
* its quantity, a line item for the variant must be found. We store
* this information into localStorage so we don't have to make annoying
* queries to JSON:API everytime.
*/
export default class extends CartBaseController {
static targets = [ 'quantity', 'subtotal', 'addedQuantity' ]
connect () {
if (!this.hasQuantityTarget) return
/*
* When the quantity selector changes, we update the order to have
* that amount of items.
*
* TODO: Go back to previous amount if there's not enough.
*/
this.quantityTarget.addEventListener('change', async (event) => {
const quantity = event.target.value
if (quantity < 1) return;
const orderToken = await this.tokenGetOrCreate()
event.target.disabled = true
const response = await this.spree.cart.setQuantity({ orderToken }, {
line_item_id: this.product.line_item.id,
quantity,
include: 'line_items'
})
event.target.disabled = false
event.target.focus()
// If we're failing here it could be due to a missing order, so we
// ask the user to decide what they want to do about it
if (response.isFail()) {
this.handleFailure(response)
return
}
this.cart = response
this.subtotalUpdate()
this.counterUpdate()
await this.itemStore()
if (!this.hasSubtotalTarget) return
this.subtotalTarget.innerText = this.product.line_item.attributes.discounted_amount
})
}
subtotalUpdate () {
window.dispatchEvent(new Event('cart:subtotal:update'))
}
/*
* Creates an order and stores the data into localStorage.
*
* @return [String]
*/
async cartCreate () {
const response = await this.spree.cart.create()
// If we fail here it's probably a server error, so we inform the
// user.
if (response.isFail()) {
this.handleFailure(response)
return
}
this.cart = response
this.storage.setItem('token', response.success().data.attributes.token)
return this.token
}
/*
* Gets the order token and creates a cart if it doesn't exist.
*
* @return [String]
*/
async tokenGetOrCreate () {
let token = this.storage.getItem('token')
return token || await this.cartCreate()
}
/*
* The variant ID is used to identify products
*
* @return [String]
*/
get variantId () {
return this.data.get('variantId')
}
get product () {
return JSON.parse(this.storage.getItem(this.storageId))
}
/*
* Obtains the line_item_id by a variant_id by inspecting the cart and
* its included items
*
* @return [Object]
*/
findLineItem () {
const line_item = this.cart.included.find(x => (x.type === 'line_item' && x.relationships.variant.data.id == this.variantId))
return (line_item || {})
}
get storageId () {
return `cart:item:${this.variantId}`
}
/*
* Stores an item for later usage.
*
* @see {./order_controller.js}
*/
itemStore () {
this.storage.setItem(this.storageId, JSON.stringify({
variant_id: this.variantId,
line_item: this.findLineItem(),
image: this.data.get('image'),
title: this.data.get('title'),
url: this.data.get('url'),
stock: this.data.get('stock'),
in_stock: this.data.get('inStock'),
extra: this.data.get('extra') ? this.data.get('extra').split('|') : []
}))
}
/*
* Adds item to cart. This is meant to be used by an "Add to cart"
* button. If the item already exists in the cart it updates the
* quantity by +1.
*
* The item needs a variant ID to be added.
*/
async add(event, quantity = 1, floating_alert = true) {
const addedQuantity = this.addedQuantity()
if (addedQuantity > 1) quantity = addedQuantity
const orderToken = await this.tokenGetOrCreate()
const response = await this.spree.cart.addItem({ orderToken }, { variant_id: this.variantId, quantity, include: 'line_items' })
if (response.isFail()) {
this.handleFailure(response)
return
}
this.cart = response
this.itemStore()
this.counterUpdate()
this.fireCajon()
if (floating_alert) {
const site = await this.site()
const content = site.cart.added
window.dispatchEvent(new CustomEvent('floating:alert', { detail: { content }}))
}
}
/*
* Remove the element from the cart. It contacts the API and if the
* item is removed, it removes itself from the page and the storage.
*/
async remove () {
if (!this.product.line_item) return
const orderToken = this.token
const response = await this.spree.cart.removeItem({ orderToken }, this.product.line_item.id, { include: 'line_items' })
if (response.isFail()) {
this.handleFailure(response)
return
}
this.cart = response
this.storage.removeItem(this.storageId)
this.element.remove()
this.subtotalUpdate()
this.counterUpdate()
}
/*
* Shows variants
*/
async variants () {
const template = 'variants'
const data = {
product: {
variant_id: this.data.get('variantId'),
digital_variant_id: this.data.get('digitalVariantId'),
image: this.data.get('image'),
title: this.data.get('title'),
price: this.data.get('price'),
digital_price: this.data.get('digitalPrice'),
in_stock: this.data.get('inStock'),
extra: this.data.get('extra').split('|')
}
}
window.dispatchEvent(new CustomEvent('notification', { detail: { template, data } }))
}
/*
* Recovers the order if something failed
*/
async recover () {
// Removes the failing token
this.storage.removeItem('token')
// Stores the previous cart
const cart = this.cart
// Get a new token and cart
await this.tokenGetOrCreate()
// Add previous items and their quantities to the new cart by
// mimicking user's actions
//
// XXX: We don't use forEach because it's not async
for (const variant of cart.data.relationships.variants.data) {
this.data.set('variantId', variant.id)
const product = this.product
this.data.set('image', product.image)
this.data.set('title', product.title)
this.data.set('extra', product.extra.join('|'))
await this.add(null, product.line_item.attributes.quantity, false)
}
}
/*
* Si le compradore aumenta la cantidad antes de agregar
*/
addedQuantity () {
if (!this.hasAddedQuantityTarget) return 0
const addedQuantity = parseInt(this.addedQuantityTarget.value)
return (isNaN(addedQuantity) ? 0 : addedQuantity)
}
}

View file

@ -0,0 +1,25 @@
import { CartBaseController } from './cart_base_controller'
export default class extends CartBaseController {
static targets = [ 'counter' ]
connect () {
if (!this.hasCounterTarget) {
console.error("Missing counter target")
return
}
window.addEventListener('cart:counter', event => this.counter = event.detail.item_count)
window.addEventListener('storage', event => {
if (event.key == 'cart:counter') this.counter = event.newValue
})
if (!this.cart) return
this.counter = this.cart.data.attributes.item_count
}
set counter (quantity) {
this.counterTarget.innerText = quantity
}
}

View file

@ -0,0 +1,89 @@
import { CartBaseController } from './cart_base_controller'
/*
* Retrieves payment methods and redirect to external checkouts
*/
export default class extends CartBaseController {
static targets = [ 'form', 'submit' ]
async connect () {
const orderToken = this.token
const response = await this.spree.checkout.paymentMethods({ orderToken })
if (response.isFail()) {
this.handleFailure(response)
return
}
const payment_methods = response.success().data
const site = await this.site()
const cart = this.cart
const next = { url: this.data.get('nextUrl') }
const back = { url: this.data.get('backUrl') }
this.render({ payment_methods, site, cart, next, back })
}
async render (data = {}) {
const request = await fetch(this.data.get('template'))
const template = await request.text()
this.element.innerHTML = await this.engine.parseAndRender(template, data)
if (!this.hasSubmitTarget) return
this.formTarget.elements.forEach(p => p.addEventListener('change', e => this.submitTarget.disabled = false))
}
async pay (event) {
event.preventDefault()
event.stopPropagation()
this.formDisabled = true
const payment_method_id = this.formTarget.elements.payment_method_id.value
const orderToken = this.token
// XXX: Currently SpreeClient expects us to send payment source
// attributes as if it were a credit card.
let response = await this.spree.checkout.orderUpdate({ orderToken },
{
order: { payments_attributes: [{ payment_method_id }] },
payment_source: {
[payment_method_id]: {
name: 'Pepitx',
month: 12,
year: 2020
}
}
})
if (response.isFail()) {
this.handleFailure(response)
this.formDisabled = false
return
}
this.cart = response
response = await this.spree.checkout.complete({ orderToken })
if (response.isFail()) {
this.handleFailure(response)
this.formDisabled = false
return
}
this.cart = response
const checkoutUrls = await this.spree.sutty.getCheckoutURL({ orderToken })
let redirectUrl = this.data.get('nextUrl')
if (checkoutUrls.data.length > 0) redirectUrl = checkoutUrls.data[0]
try {
Turbolinks.visit(redirectUrl)
} catch {
window.location = redirectUrl
}
}
}

View file

@ -0,0 +1,42 @@
import { CartBaseController } from './cart_base_controller'
/*
* Replaces checkout.js.
*
* When the customer is redirected back from the approval URL, there's
* three params attached to the URL. We need paymentId and PayerID to
* execute the payment and later capture it via IPN. The token can be
* discarded.
*/
export default class extends CartBaseController {
async connect () {
if (this.params.PayerID === undefined) return
this.site = await this.site()
this.element.innerHTML = this.site.i18n.cart.layouts.paypal.confirming
fetch(this.executeURL)
.then(r => this.element.innerHTML = this.site.i18n.cart.layouts.paypal[(r.ok ? 'confirmed' : 'failure')])
.catch(e => this.element.innerHTML = this.site.i18n.cart.layouts.paypal.failure)
}
/*
* Convert URL params to Object
*
* @return [Object]
*/
get params () {
if (this._params) return this._params
this._params = Object.fromEntries(decodeURIComponent(window.location.search.replace('?', '')).split('&').map(x => x.split('=')))
return this._params
}
/*
* URL to contact the store and execute the payment.
*/
get executeURL () {
return [ window.spree.host, 'paypal', 'execute', this.params.orderId, this.params.paymentId, this.params.PayerID ].join('/')
}
}

View file

@ -0,0 +1,111 @@
import { CartBaseController } from './cart_base_controller'
export default class extends CartBaseController {
static targets = [ 'methods', 'rates', 'form' ]
connect () {
this.formTarget.addEventListener('formdata', event => this.processShippingAddress(event.formData))
}
async rates (event) {
event.preventDefault()
event.stopPropagation()
if (!this.formTarget.checkValidity()) {
this.adressTarget.classList.add('was-validated')
return
}
this.formTarget.classList.remove('was-validated')
// FormDataEvent es muy reciente
if (window.FormDataEvent) {
// Esto lanza el evento formdata en el formulario
new FormData(event.target)
} else {
// Fallback
this.processShippingAddress(new FormData(event.target))
}
}
payment (event) {
event.preventDefault()
event.stopPropagation()
// FormDataEvent es muy reciente
if (window.FormDataEvent) {
// Esto lanza el evento formdata en el formulario
new FormData(event.target)
} else {
this.processShippingRate(new FormData(event.target))
}
}
async processShippingAddress (formData) {
this.formDisabled = true
const email = this.email
const orderToken = this.token
const ship_address_attributes = this.formDataToObject(formData)
const bill_address_attributes = ship_address_attributes
const response = await this.spree.checkout.orderUpdate({ orderToken }, { order: { email, ship_address_attributes, bill_address_attributes }})
if (response.isFail()) {
this.handleFailure(response)
this.formDisabled = false
return
}
const shippingMethods = await this.shippingMethods(orderToken)
if (!shippingMethods) {
this.formDisabled = false
return
}
const shipping_rates = shippingMethods.included.filter(x => x.type == 'shipping_rate')
// XXX: No hay varios paquetes
const shipping_method = shippingMethods.data[0]
const site = await this.site()
await this.render({ shipping_method, shipping_rates, site })
const nextStep = document.querySelector(`#${this.element.dataset.scrollTo}`)
if (nextStep) nextStep.scrollIntoView()
}
async processShippingRate (formData) {
const rate = this.formDataToObject(formData)
const orderToken = this.token
// XXX: Deshabilitar el formulario después del evento FormData, de
// lo contrario el objeto queda vacío.
this.ratesTarget.elements.forEach(x => x.disabled = true)
const response = await window.spree.checkout.orderUpdate({ orderToken }, { order: { shipments_attributes: [{ ...rate }] } })
if (response.isFail()) {
this.handleFailure(response)
return
}
this.cart = response
// Continue to next step
try {
Turbolinks.visit(this.data.get('next'))
} catch {
window.location = this.data.get('next')
}
}
async render (data = {}) {
const request = await fetch(this.data.get('template'))
const template = await request.text()
this.methodsTarget.innerHTML = await this.engine.parseAndRender(template, data)
this.ratesTarget.addEventListener('formdata', event => this.processShippingRate(event.formData))
}
}

View file

@ -0,0 +1,101 @@
import { CartBaseController } from './cart_base_controller'
/*
* Populates a country field where users can type to filter and select
* from a predefined list.
*/
export default class extends CartBaseController {
// All are required!
static targets = [ 'id', 'iso', 'list', 'name' ]
async connect () {
const countries = await this.countries()
countries.forEach(country => {
const option = document.createElement('option')
option.value = country.attributes.name
option.dataset.id = country.id
option.dataset.iso = country.attributes.iso
option.dataset.statesRequired = country.attributes.states_required
option.dataset.zipcodeRequired = country.attributes.zipcode_required
this.listTarget.appendChild(option)
})
const site = await this.site()
// Only allow names on this list
this.nameTarget.pattern = countries.map(x => x.attributes.name).join('|')
this.nameTarget.addEventListener('input', event => this.nameTarget.setCustomValidity(''))
this.nameTarget.addEventListener('invalid', event => this.nameTarget.setCustomValidity(site.i18n.countries.validation))
// When the input changes we update the actual value and also the
// state list via an Event
this.nameTarget.addEventListener('change', event => {
const value = this.nameTarget.value.trim()
if (value === '') return
const options = Array.from(this.nameTarget.list.options)
const option = options.find(x => x.value == value)
// TODO: If no option is found, mark the field as invalid
if (!option) return
this.idTarget.value = option.dataset.id
this.isoTarget.value = option.dataset.iso
this.idTarget.dispatchEvent(new Event('change'))
this.isoTarget.dispatchEvent(new Event('change'))
this.dispatchChangedEvent(option.dataset)
// XXX: Prevent mixing data
delete this.nameTarget.dataset.selectedState
delete this.nameTarget.dataset.selectedZipcode
})
// The input is disabled at this point
this.nameTarget.disabled = false
// Load data if the input is autocompleted
if (this.nameTarget.value.trim() !== '') this.nameTarget.dispatchEvent(new CustomEvent('change'))
}
/*
* Sends a `cart:country:update` event so other controllers can
* subscribe to changes.
*/
dispatchChangedEvent (data = {}) {
const event = new CustomEvent('cart:country:update', {
detail: {
id: this.idTarget.value,
iso: this.isoTarget.value,
group: this.data.get('group'),
selectedState: this.nameTarget.dataset.selectedState,
selectedZipcode: this.nameTarget.dataset.selectedZipcode,
data
}
})
window.dispatchEvent(event)
}
/*
* Fetch the country list from storage or from API
*/
async countries () {
const countries = JSON.parse(this.storageTemp.getItem('countries'))
if (countries) return countries
const response = await this.spree.countries.list()
// TODO: Show error message
if (!response.success()) return
this.storageTemp.setItem('countries', JSON.stringify(response.success().data))
return response.success().data
}
}

View file

@ -0,0 +1,68 @@
import { CartBaseController } from './cart_base_controller'
/*
* Renders the order table. All products are stored on localStorage, so
* we just fetch that information and render the cart contents using
* Liquid.
*/
export default class extends CartBaseController {
static targets = [ 'cart', 'subtotal', 'itemCount' ]
async connect () {
const products = this.products
const site = await this.site()
this.render({ products, site })
this.subtotalUpdate()
this.itemCountUpdate()
this.subscribe()
}
/*
* Subscribe to change on the storage to update the cart.
*/
subscribe () {
window.addEventListener('storage', async event => {
if (!event.key.startsWith('cart:item:')) return
const products = this.products
const site = await this.site()
this.render({ products, site })
this.subtotalUpdate()
this.itemCountUpdate()
})
window.addEventListener('cart:subtotal:update', event => {
this.itemCountUpdate()
this.subtotalUpdate()
})
}
/*
* Download the item template and render the order
*/
render (data = {}) {
fetch(this.data.get('itemTemplate')).then(r => r.text()).then(template => {
this.engine.parseAndRender(template, data).then(html => this.cartTarget.innerHTML = html)
})
}
/*
* Updates the subtotal
*
* XXX: This also updates the currency
*/
subtotalUpdate () {
if (!this.cart) return
this.subtotalTarget.innerText = this.cart.data.attributes.display_total
}
itemCountUpdate () {
if (!this.hasItemCountTarget) return
if (!this.cart) return
this.itemCountTarget.innerText = this.cart.data.attributes.item_count
}
}

View file

@ -0,0 +1,38 @@
import { Controller } from 'stimulus'
/*
* Subscribes to the country change event and changes the validation
* pattern of its input.
*/
export default class extends Controller {
static targets = [ 'code' ]
/*
* Twitter CLDR is pretty big and we only need the postal codes
* patterns.
*
* @see {https://github.com/twitter/twitter-cldr-npm/blob/4388dfc55900b0feb80eafcac030f9f26981a41d/full/core.js#L1999}
*/
postal_codes = {"ad":"^AD\\d{3}$","am":"^(37)?\\d{4}$","ar":"^([A-HJ-NP-Z])?\\d{4}([A-Z]{3})?$","as":"^96799$","at":"^\\d{4}$","au":"^\\d{4}$","ax":"^22\\d{3}$","az":"^\\d{4}$","ba":"^\\d{5}$","bb":"^(BB\\d{5})?$","bd":"^\\d{4}$","be":"^\\d{4}$","bg":"^\\d{4}$","bh":"^((1[0-2]|[2-9])\\d{2})?$","bm":"^[A-Z]{2}[ ]?[A-Z0-9]{2}$","bn":"^[A-Z]{2}[ ]?\\d{4}$","br":"^\\d{5}[\\-]?\\d{3}$","by":"^\\d{6}$","ca":"^[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z][ ]?\\d[ABCEGHJ-NPRSTV-Z]\\d$","cc":"^6799$","ch":"^\\d{4}$","ck":"^\\d{4}$","cl":"^\\d{7}$","cn":"^\\d{6}$","cr":"^\\d{4,5}|\\d{3}-\\d{4}$","cs":"^\\d{5}$","cv":"^\\d{4}$","cx":"^6798$","cy":"^\\d{4}$","cz":"^\\d{3}[ ]?\\d{2}$","de":"^\\d{5}$","dk":"^\\d{4}$","do":"^\\d{5}$","dz":"^\\d{5}$","ec":"^([A-Z]\\d{4}[A-Z]|(?:[A-Z]{2})?\\d{6})?$","ee":"^\\d{5}$","eg":"^\\d{5}$","es":"^\\d{5}$","et":"^\\d{4}$","fi":"^\\d{5}$","fk":"^FIQQ 1ZZ$","fm":"^(9694[1-4])([ \\-]\\d{4})?$","fo":"^\\d{3}$","fr":"^\\d{2}[ ]?\\d{3}$","gb":"^GIR[ ]?0AA|((AB|AL|B|BA|BB|BD|BH|BL|BN|BR|BS|BT|CA|CB|CF|CH|CM|CO|CR|CT|CV|CW|DA|DD|DE|DG|DH|DL|DN|DT|DY|E|EC|EH|EN|EX|FK|FY|G|GL|GY|GU|HA|HD|HG|HP|HR|HS|HU|HX|IG|IM|IP|IV|JE|KA|KT|KW|KY|L|LA|LD|LE|LL|LN|LS|LU|M|ME|MK|ML|N|NE|NG|NN|NP|NR|NW|OL|OX|PA|PE|PH|PL|PO|PR|RG|RH|RM|S|SA|SE|SG|SK|SL|SM|SN|SO|SP|SR|SS|ST|SW|SY|TA|TD|TF|TN|TQ|TR|TS|TW|UB|W|WA|WC|WD|WF|WN|WR|WS|WV|YO|ZE)(\\d[\\dA-Z]?[ ]?\\d[ABD-HJLN-UW-Z]{2}))|BFPO[ ]?\\d{1,4}$","ge":"^\\d{4}$","gf":"^9[78]3\\d{2}$","gg":"^GY\\d[\\dA-Z]?[ ]?\\d[ABD-HJLN-UW-Z]{2}$","gl":"^39\\d{2}$","gn":"^\\d{3}$","gp":"^9[78][01]\\d{2}$","gr":"^\\d{3}[ ]?\\d{2}$","gs":"^SIQQ 1ZZ$","gt":"^\\d{5}$","gu":"^969[123]\\d([ \\-]\\d{4})?$","gw":"^\\d{4}$","hm":"^\\d{4}$","hn":"^(?:\\d{5})?$","hr":"^\\d{5}$","ht":"^\\d{4}$","hu":"^\\d{4}$","id":"^\\d{5}$","il":"^\\d{5}$","im":"^IM\\d[\\dA-Z]?[ ]?\\d[ABD-HJLN-UW-Z]{2}$","in":"^\\d{6}$","io":"^BBND 1ZZ$","iq":"^\\d{5}$","is":"^\\d{3}$","it":"^\\d{5}$","je":"^JE\\d[\\dA-Z]?[ ]?\\d[ABD-HJLN-UW-Z]{2}$","jo":"^\\d{5}$","jp":"^\\d{3}-\\d{4}$","ke":"^\\d{5}$","kg":"^\\d{6}$","kh":"^\\d{5}$","kr":"^\\d{3}[\\-]\\d{3}$","kw":"^\\d{5}$","kz":"^\\d{6}$","la":"^\\d{5}$","lb":"^(\\d{4}([ ]?\\d{4})?)?$","li":"^(948[5-9])|(949[0-7])$","lk":"^\\d{5}$","lr":"^\\d{4}$","ls":"^\\d{3}$","lt":"^\\d{5}$","lu":"^\\d{4}$","lv":"^\\d{4}$","ma":"^\\d{5}$","mc":"^980\\d{2}$","md":"^\\d{4}$","me":"^8\\d{4}$","mg":"^\\d{3}$","mh":"^969[67]\\d([ \\-]\\d{4})?$","mk":"^\\d{4}$","mn":"^\\d{6}$","mp":"^9695[012]([ \\-]\\d{4})?$","mq":"^9[78]2\\d{2}$","mt":"^[A-Z]{3}[ ]?\\d{2,4}$","mu":"^(\\d{3}[A-Z]{2}\\d{3})?$","mv":"^\\d{5}$","mx":"^\\d{5}$","my":"^\\d{5}$","nc":"^988\\d{2}$","ne":"^\\d{4}$","nf":"^2899$","ng":"^(\\d{6})?$","ni":"^((\\d{4}-)?\\d{3}-\\d{3}(-\\d{1})?)?$","nl":"^\\d{4}[ ]?[A-Z]{2}$","no":"^\\d{4}$","np":"^\\d{5}$","nz":"^\\d{4}$","om":"^(PC )?\\d{3}$","pf":"^987\\d{2}$","pg":"^\\d{3}$","ph":"^\\d{4}$","pk":"^\\d{5}$","pl":"^\\d{2}-\\d{3}$","pm":"^9[78]5\\d{2}$","pn":"^PCRN 1ZZ$","pr":"^00[679]\\d{2}([ \\-]\\d{4})?$","pt":"^\\d{4}([\\-]\\d{3})?$","pw":"^96940$","py":"^\\d{4}$","re":"^9[78]4\\d{2}$","ro":"^\\d{6}$","rs":"^\\d{6}$","ru":"^\\d{6}$","sa":"^\\d{5}$","se":"^\\d{3}[ ]?\\d{2}$","sg":"^\\d{6}$","sh":"^(ASCN|STHL) 1ZZ$","si":"^\\d{4}$","sj":"^\\d{4}$","sk":"^\\d{3}[ ]?\\d{2}$","sm":"^4789\\d$","sn":"^\\d{5}$","so":"^\\d{5}$","sz":"^[HLMS]\\d{3}$","tc":"^TKCA 1ZZ$","th":"^\\d{5}$","tj":"^\\d{6}$","tm":"^\\d{6}$","tn":"^\\d{4}$","tr":"^\\d{5}$","tw":"^\\d{3}(\\d{2})?$","ua":"^\\d{5}$","us":"^\\d{5}([ \\-]\\d{4})?$","uy":"^\\d{5}$","uz":"^\\d{6}$","va":"^00120$","ve":"^\\d{4}$","vi":"^008(([0-4]\\d)|(5[01]))([ \\-]\\d{4})?$","wf":"^986\\d{2}$","xk":"^\\d{5}$","yt":"^976\\d{2}$","yu":"^\\d{5}$","za":"^\\d{4}$","zm":"^\\d{5}$"}
connect () {
window.addEventListener('cart:country:update', event => {
if (this.data.get('group') !== event.detail.group) return
const zipcodeRequired = event.detail.data.zipcodeRequired == 'true'
this.codeTarget.value = ''
this.codeTarget.disabled = !zipcodeRequired
this.codeTarget.required = zipcodeRequired
if (!zipcodeRequired) return
this.codeTarget.pattern = this.postal_codes[event.detail.iso.toLowerCase()] || '.*'
if (event.detail.selectedZipcode) {
this.codeTarget.value = event.detail.selectedZipcode
this.codeTarget.dispatchEvent(new Event('change'))
}
})
}
}

View file

@ -0,0 +1,91 @@
import { CartBaseController } from './cart_base_controller'
/*
* Populates a state field where users can type to filter and select
* from a predefined list. It waits for an `cart:country:update` event
* to become populated.
*/
export default class extends CartBaseController {
// All are required!
static targets = [ 'id', 'list', 'name' ]
connect () {
window.addEventListener('cart:country:update', async event => {
if (this.data.get('group') !== event.detail.group) return
this.idTarget.value = ''
this.nameTarget.value = ''
this.listTarget.innerHTML = ''
const statesRequired = event.detail.data.statesRequired == 'true'
this.nameTarget.disabled = !statesRequired
this.nameTarget.required = statesRequired
if (!statesRequired) return
const states = await this.states(event.detail.iso)
const site = await this.site()
states.forEach(state => {
let option = document.createElement('option')
option.value = state.attributes.name
option.dataset.id = state.id
this.listTarget.appendChild(option)
})
this.nameTarget.pattern = states.map(x => x.attributes.name).join('|')
this.nameTarget.addEventListener('input', event => this.nameTarget.setCustomValidity(''))
this.nameTarget.addEventListener('invalid', event => this.nameTarget.setCustomValidity(site.i18n.states.validation))
if (event.detail.selectedState) {
this.nameTarget.value = event.detail.selectedState
this.nameTarget.dispatchEvent(new Event('change'))
}
})
// When the input changes we update the actual value and also the
// state list via an Event
this.nameTarget.addEventListener('change', event => {
const options = Array.from(this.listTarget.options)
const option = options.find(x => x.value == this.nameTarget.value)
// TODO: If no option is found, mark the field as invalid
if (!option) return
this.idTarget.value = option.dataset.id
this.idTarget.dispatchEvent(new Event('change'))
})
}
/*
* Fetch the state list from storage or from API using a country ISO
* code
*/
async states (countryIso) {
const stateId = `states:${countryIso}`
let states = JSON.parse(this.storageTemp.getItem(stateId))
if (states) return states
// There's no state query, but we can fetch the country and include
// its states.
const response = await this.spree.countries.show(countryIso, { include: 'states' })
// TODO: Show error message
if (response.isFail()) {
this.handleFailure(response)
return {}
}
states = response.success().included
// Order alphabetically by name
states.sort((x, y) => x.attributes.name > y.attributes.name)
this.storageTemp.setItem(stateId, JSON.stringify(states))
return states
}
}

View file

@ -0,0 +1,71 @@
import { Controller } from 'stimulus'
/*
* Mantiene el stock actualizado, consultando a la API.
*
* * Obtiene todas las variantes en el controlador
* * Consulta a la API
* * Actualiza stock y precio
* * Deshabilita botón si no está en stock
*/
export default class extends Controller {
static targets = [ 'product' ]
async connect () {
if (this.variant_ids.length === 0) return
const ids = this.variant_ids.join(',')
const filter = { ids }
let response = await window.spree.products.list({ filter })
// TODO: Gestionar errores
if (response.isFail()) {
console.error(response.fail())
return
}
this.update_local_products(response.success().data)
// Recorrer todas las páginas
// XXX: Podríamos usar next pero la página 1 siempre se devuelve a
// sí misma y entraríamos en un loop infinito.
for (let page = 2; page <= response.success().meta.total_pages; page++) {
response = await window.spree.products.list({ filter, page })
// TODO: Gestionar errores
if (response.isFail()) {
console.error(response.fail())
continue
}
this.update_local_products(response.success().data)
}
}
/*
* La lista de todas las variantes incluidas en el controlador que no
* estén vacías.
*
* @return [Array]
*/
get variant_ids () {
if (!this._variant_ids) this._variant_ids = [...new Set(this.productTargets.map(p=> p.dataset.cartVariantId).filter(x => x.length > 0))]
return this._variant_ids
}
/*
* Los productos pueden estar duplicados así que buscamos todos.
*/
update_local_products (products) {
for (const product of products) {
for (const local of this.productTargets.filter(local => local.dataset.cartVariantId === product.id)) {
local.dataset.cartInStock = product.attributes.in_stock
local.dataset.cartPrice = product.attributes.price
local.querySelectorAll('[data-stock-add]').forEach(button => button.disabled = !product.attributes.in_stock)
local.querySelectorAll('[data-stock-price]').forEach(price => price.innerText = product.attributes.display_price)
}
}
}
}

80
_packs/endpoints/sutty.js Normal file
View file

@ -0,0 +1,80 @@
import Axios from 'axios'
import * as qs from 'qs'
/*
* XXX: We're copying code from @spree/storefront-api-v2-sdk/src/Http.ts
* because we don't know how to mix Typescript :D
*/
export class Sutty {
constructor (host = 'http://localhost:3000') {
this.host = host
this.axios = Axios.create({
baseURL: this.host,
headers: { 'Content-Type': 'application/json' },
paramsSerializer: params => qs.stringify(params, { arrayFormat: 'brackets' })
})
}
async getCheckoutURL(tokens = {}) {
const headers = this.spreeOrderHeaders(tokens)
const axiosConfig = {
url: 'api/v2/storefront/checkout_redirect.json',
params: {},
method: 'get',
headers
}
return await this.axios(axiosConfig)
}
async assignOrderOwnership(tokens = {}, params = {}) {
const headers = this.spreeOrderHeaders(tokens)
const axiosConfig = {
url: 'api/v2/storefront/assign_order_ownership.json',
params,
method: 'post',
headers
}
return await this.axios(axiosConfig)
}
async assignOrderShippingAddress(tokens = {}, params = {}) {
const headers = this.spreeOrderHeaders(tokens)
const axiosConfig = {
url: 'api/v2/storefront/assign_order_shipping_address.json',
params,
method: 'post',
headers
}
return await this.axios(axiosConfig)
}
async assignOrderBillingAddress(tokens = {}, params = {}) {
const headers = this.spreeOrderHeaders(tokens)
const axiosConfig = {
url: 'api/v2/storefront/assign_order_billing_address.json',
params,
method: 'post',
headers
}
return await this.axios(axiosConfig)
}
spreeOrderHeaders(tokens) {
const header = {}
if (tokens.orderToken) {
header['X-Spree-Order-Token'] = tokens.orderToken
}
if (tokens.bearerToken) {
header['Authorization'] = `Bearer ${tokens.bearerToken}`
}
return header
}
}

View file

@ -19,6 +19,12 @@ const application = Application.start()
const context = require.context("./controllers", true, /\.js$/)
application.load(definitionsFromContext(context))
import { makeClient } from '@spree/storefront-api-v2-sdk'
import { Sutty } from './endpoints/sutty'
window.spree = makeClient({ host: window.env.SPREE_URL })
window.spree.sutty = new Sutty(window.spree.host)
// Prevenir que Turbolinks interfiera con la navegación por anchors
// https://github.com/turbolinks/turbolinks/issues/75#issuecomment-445325162
document.addEventListener('turbolinks:click', event => {

3
env.js
View file

@ -4,5 +4,6 @@
window.env = {
AIRBRAKE_PROJECT_ID: {{ site.env.AIRBRAKE_PROJECT_ID | default: 0 }},
AIRBRAKE_PROJECT_KEY: '{{ site.env.AIRBRAKE_PROJECT_KEY }}',
JEKYLL_ENV: '{{ site.env.JEKYLL_ENV }}'
JEKYLL_ENV: '{{ site.env.JEKYLL_ENV }}',
SPREE_URL: '{{ site.env.SPREE_URL }}'
}

View file

@ -12,6 +12,7 @@
"@babel/core": "^7.10.4",
"@babel/plugin-proposal-class-properties": "^7.10.4",
"@babel/preset-env": "^7.10.4",
"@spree/storefront-api-v2-sdk": "~4.4",
"axe-core": "^4.1.2",
"babel-loader": "^8.1.0",
"core-js": "^3.6.5",

View file

@ -68,6 +68,8 @@ Gem::Specification.new do |spec|
spec.add_runtime_dependency 'jekyll-commonmark', '~> 1.3'
spec.add_runtime_dependency 'jekyll-dotenv', '>= 0.2'
spec.add_runtime_dependency 'jekyll-feed', '~> 0.15'
spec.add_runtime_dependency 'jekyll-spree-client', '~> 0'
spec.add_runtime_dependency 'jekyll-write-and-commit-changes', '~> 0'
spec.add_runtime_dependency 'jekyll-ignore-layouts', '~> 0'
# Dependencias de desarrollo

View file

@ -885,6 +885,15 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
"@spree/storefront-api-v2-sdk@~4.4":
version "4.4.4"
resolved "https://registry.yarnpkg.com/@spree/storefront-api-v2-sdk/-/storefront-api-v2-sdk-4.4.4.tgz#c3527e9e08d7c436892bdece614ce1b66093b1bf"
integrity sha512-rkBBGYhnup0VGOltyQb+uycT9+M32LZpA9K5SYukvexbkCCCj0JqMJlY9ojirwsDh/q71icRZ+PAjDfypbbm6g==
dependencies:
axios "^0.21.1"
lodash "^4.17.20"
qs "^6.6.0"
"@stimulus/core@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@stimulus/core/-/core-1.1.1.tgz#42b0cfe5b73ca492f41de64b77a03980bae92c82"
@ -1383,6 +1392,13 @@ axe-core@^4.1.2:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.2.1.tgz#2e50bcf10ee5b819014f6e342e41e45096239e34"
integrity sha512-evY7DN8qSIbsW2H/TWQ1bX3sXN1d4MNb5Vb4n7BzPuCwRHdkZ1H2eNLuSh73EoQqkGKUtju2G2HCcjCfhvZIAA==
axios@^0.21.1:
version "0.21.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
dependencies:
follow-redirects "^1.10.0"
babel-loader@^8.1.0:
version "8.2.2"
resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.2.2.tgz#9363ce84c10c9a40e6c753748e1441b60c8a0b81"
@ -3004,7 +3020,7 @@ flush-write-stream@^1.0.0, flush-write-stream@^1.0.2:
inherits "^2.0.3"
readable-stream "^2.3.6"
follow-redirects@^1.0.0:
follow-redirects@^1.0.0, follow-redirects@^1.10.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==
@ -4248,7 +4264,7 @@ lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
lodash@^4.17.11, lodash@^4.17.14:
lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.20:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -4732,6 +4748,11 @@ object-copy@^0.1.0:
define-property "^0.2.5"
kind-of "^3.0.3"
object-inspect@^1.9.0:
version "1.10.3"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369"
integrity sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==
object-is@^1.0.1:
version "1.1.5"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac"
@ -5196,6 +5217,13 @@ qs@6.7.0:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
qs@^6.6.0:
version "6.10.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a"
integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==
dependencies:
side-channel "^1.0.4"
querystring-es3@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
@ -5839,6 +5867,15 @@ shebang-regex@^1.0.0:
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
side-channel@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
dependencies:
call-bind "^1.0.0"
get-intrinsic "^1.0.2"
object-inspect "^1.9.0"
signal-exit@^3.0.0, signal-exit@^3.0.2:
version "3.0.3"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"