diff --git a/Makefile b/Makefile index ef48681..dc44c94 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,12 @@ npx: ## Correr comandos con npx (args="run") npm: ## Correr comandos con npm (args="install -g paquete") $(MAKE) hain args="npm $(args)" +prettier: ## Arreglar JS (por ahora) + $(MAKE) yarn args="prettier --write _packs/" + +format-check: ## Verificar JS + $(MAKE) yarn args="prettier --check _packs/" + serve: /etc/hosts $(hain)/run/nginx/nginx.pid ## Servidor de desarrollo @echo "Iniciado servidor web en https://$(domain):4000/" diff --git a/_config.yml b/_config.yml index 651d41e..5f1d1f4 100644 --- a/_config.yml +++ b/_config.yml @@ -11,6 +11,7 @@ plugins: - jekyll-data - jekyll-seo-tag - jekyll-images +- jekyll-embed-urls - sutty-liquid markdown: CommonMark commonmark: @@ -58,6 +59,7 @@ locales: - es ignored_layouts: - menu +- email linked_fields: - post - item diff --git a/_data/en.yml b/_data/en.yml index 60a0649..39ea154 100644 --- a/_data/en.yml +++ b/_data/en.yml @@ -63,8 +63,14 @@ 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 + theme: Customize theme menu: title: Menu share: diff --git a/_data/es.yml b/_data/es.yml index 358e94b..e2cd14e 100644 --- a/_data/es.yml +++ b/_data/es.yml @@ -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,8 +63,14 @@ 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 + theme: Personalizar plantilla menu: title: Menú share: diff --git a/_data/forms/contacto.yml b/_data/forms/contacto.yml index 0be3cab..5a246a5 100644 --- a/_data/forms/contacto.yml +++ b/_data/forms/contacto.yml @@ -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"' diff --git a/_data/forms/shipping_address.yml b/_data/forms/shipping_address.yml new file mode 100644 index 0000000..57899ec --- /dev/null +++ b/_data/forms/shipping_address.yml @@ -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' diff --git a/_data/forms/user.yml b/_data/forms/user.yml new file mode 100644 index 0000000..5645e21 --- /dev/null +++ b/_data/forms/user.yml @@ -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' diff --git a/_data/layouts/cart.yml b/_data/layouts/cart.yml new file mode 100644 index 0000000..3ecb444 --- /dev/null +++ b/_data/layouts/cart.yml @@ -0,0 +1,222 @@ +--- +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' +length_unit: + type: 'string' + required: true + label: + es: 'Unidad de medida de los productos' + en: 'Measurement unit for products' + help: + es: '' + en: '' + default: + es: 'mm' + en: 'mm' +weight_unit: + type: 'string' + required: true + label: + es: 'Unidad de medida para el peso de los productos' + en: 'Measurement unit for products weight' + help: + es: '' + en: '' + default: + es: 'gr' + en: 'gr' +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' diff --git a/_data/layouts/confirmation.yml b/_data/layouts/confirmation.yml new file mode 100644 index 0000000..cc38cd6 --- /dev/null +++ b/_data/layouts/confirmation.yml @@ -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' diff --git a/_data/layouts/email.yml b/_data/layouts/email.yml new file mode 100644 index 0000000..ed716ed --- /dev/null +++ b/_data/layouts/email.yml @@ -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: '' diff --git a/_data/layouts/menu.yml b/_data/layouts/menu.yml index 279f4eb..e3974b0 100644 --- a/_data/layouts/menu.yml +++ b/_data/layouts/menu.yml @@ -3,7 +3,7 @@ title: type: 'string' required: true label: - en: '' + en: 'Item name' es: 'Nombre del ítem' help: en: '' @@ -11,21 +11,29 @@ title: post: type: 'belongs_to' label: - en: '' + en: 'Link to this post' es: 'Artículo' help: en: '' es: 'Si el ítem lleva a un artículo fijo, asociarlo aquí' +link: + type: 'string' + label: + en: 'Link' + es: 'Vínculo' + help: + en: "If this item is a regular link, add it here" + es: 'Si el ítem lleva a una página o sección especial, asociarla aquí' item: type: 'belongs_to' inverse: items filter: layout: menu label: - en: '' + en: 'Main item' es: 'Ítem anterior' help: - en: '' + en: "If you're building a dropdown menu, add the main item here" es: 'Si es un sub ítem, asociar el ítem superior aquí' items: type: 'has_many' @@ -33,19 +41,11 @@ items: filter: layout: menu label: - en: '' + en: 'Sub items' es: 'Sub ítemes' help: - en: '' + en: "If you're building a dropdown menu, add the sub items here" es: 'Si el ítem tiene sub ítems, asociarlos aquí' -link: - type: 'string' - label: - en: '' - es: 'Vínculo' - help: - en: '' - es: 'Si el ítem lleva a una página o sección especial, asociarla aquí' categories: type: 'array' label: diff --git a/_data/layouts/payment.yml b/_data/layouts/payment.yml new file mode 100644 index 0000000..da9c652 --- /dev/null +++ b/_data/layouts/payment.yml @@ -0,0 +1,126 @@ +--- +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.' +promo_code: + type: 'string' + required: true + label: + es: 'Código de descuento' + en: 'Coupon code' + help: + es: '' + en: '' + default: + es: '¿Tenés un cupón de descuento?' + en: '' +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' diff --git a/_data/layouts/product.yml b/_data/layouts/product.yml new file mode 100644 index 0000000..7263585 --- /dev/null +++ b/_data/layouts/product.yml @@ -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 la unidad de medida que configuraste en el carrito' + en: 'In measurement units configured in cart' +height: + type: 'number' + writable: 'once' + label: + es: 'Alto' + en: 'Height' + help: + es: 'En la unidad de medida que configuraste en el carrito' + en: 'In measurement units configured in cart' +depth: + type: 'number' + writable: 'once' + label: + es: 'Profundidad' + en: 'Depth' + help: + es: 'En la unidad de medida que configuraste en el carrito' + en: 'In measurement units configured in cart' +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" diff --git a/_data/layouts/shipment.yml b/_data/layouts/shipment.yml new file mode 100644 index 0000000..886c5ec --- /dev/null +++ b/_data/layouts/shipment.yml @@ -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' diff --git a/_data/layouts/theme.yml b/_data/layouts/theme.yml new file mode 100644 index 0000000..3768ab5 --- /dev/null +++ b/_data/layouts/theme.yml @@ -0,0 +1,255 @@ +--- +title: + type: string + required: true + label: + en: Title + es: Título + help: + en: 'The name you want to give to this customization' + es: 'El nombre que quieras darle a esta personalización' +font_family_sans_serif: + type: predefined_value + label: + en: Select a typography for the site + es: Selecciona una tipografía para el sitio + help: + en: 'If you want us to add support for a typography, please send us an e-mail' + es: 'Si quieres que agreguemos una tipografía, por favor envíanos un e-mail' + default: + en: sans-serif + es: sans-serif + values: + en: + sans-serif: 'Sans Serif' + Roboto: 'Roboto' + es: + sans-serif: 'Sans Serif' + Roboto: 'Roboto' +headings_font_family: + type: predefined_value + label: + en: Select a typography for the headings + es: Selecciona una tipografía para los títulos del sitio + help: + en: 'If you want us to add support for a typography, please send us an e-mail' + es: 'Si quieres que agreguemos una tipografía, por favor envíanos un e-mail' + default: + en: sans-serif + es: sans-serif + values: + en: + sans-serif: 'Sans Serif' + Roboto: 'Roboto' + es: + sans-serif: 'Sans Serif' + Roboto: 'Roboto' +enable_rounded: + type: boolean + label: + en: 'Rounded corners' + es: 'Esquinas redondeadas' + help: + en: 'For buttons, form inputs, etc.' + es: 'De los botones, campos de formularios, etc.' + default: + es: true + en: true +enable_shadows: + type: boolean + label: + en: 'Shadows' + es: 'Sombras' + help: + en: 'Shadows behind elements' + es: 'Sombras en los elementos' + default: + es: true + en: true +body_bg: + type: color + label: + en: 'Background color' + es: 'Color de fondo' + help: + en: "Site's background color" + es: 'Color de fondo del sitio' + default: + es: '#FFFFFF' + en: '#FFFFFF' +body_color: + type: color + label: + en: 'Text color' + es: 'Color del texto' + help: + en: '' + es: '' + default: + es: '#212529' + en: '#212529' +primary: + type: color + label: + en: 'Primary color' + es: 'Color principal' + help: + en: 'Highlights certain elements' + es: 'Resalta algunos elementos' + default: + es: '#007bff' + en: '#007bff' +link_color: + type: color + label: + en: 'Link color' + es: 'Color de los vínculos' + help: + en: '' + es: '' + default: + es: '#007bff' + en: '#007bff' +link_hover_color: + type: color + label: + en: 'Link color when selected' + es: 'Color de los vínculos al seleccionarlos' + help: + en: '' + es: '' + default: + es: '#0056b3' + en: '#0056b3' +h1_font_size: + type: float + unit: rem + label: + en: 'Height for first level headings' + es: 'Altura de los títulos de primer nivel' + help: + en: 'Proportional to font base height. For instance, 2 is double height.' + es: 'En proporción al alto base de la tipografía. Por ejemplo 2 es el doble.' + default: + es: 2.5 + en: 2.5 +h2_font_size: + type: float + unit: rem + label: + en: 'Height for second level headings' + es: 'Altura de los títulos de segundo nivel' + help: + en: 'Proportional to font base height. For instance, 2 is double height.' + es: 'En proporción al alto base de la tipografía. Por ejemplo 2 es el doble.' + default: + es: 2 + en: 2 +h3_font_size: + type: float + unit: rem + label: + en: 'Height for third level headings' + es: 'Altura de los títulos de tercer nivel' + help: + en: 'Proportional to font base height. For instance, 2 is double height.' + es: 'En proporción al alto base de la tipografía. Por ejemplo 2 es el doble.' + default: + es: 1.75 + en: 1.75 +h4_font_size: + type: float + unit: rem + label: + en: 'Height for fourth level headings' + es: 'Altura de los títulos de cuarto nivel' + help: + en: 'Proportional to font base height. For instance, 2 is double height.' + es: 'En proporción al alto base de la tipografía. Por ejemplo 2 es el doble.' + default: + es: 1.5 + en: 1.5 +h5_font_size: + type: float + unit: rem + label: + en: 'Height for fifth level headings' + es: 'Altura de los títulos de quinto nivel' + help: + en: 'Proportional to font base height. For instance, 2 is double height.' + es: 'En proporción al alto base de la tipografía. Por ejemplo 2 es el doble.' + default: + es: 1.25 + en: 1.25 +h6_font_size: + type: float + unit: rem + label: + en: 'Height for sixth level headings' + es: 'Altura de los títulos de sexto nivel' + help: + en: 'Proportional to font base height. For instance, 2 is double height.' + es: 'En proporción al alto base de la tipografía. Por ejemplo 2 es el doble.' + default: + es: 1 + en: 1 +mark_bg: + type: color + label: + en: 'Highlight color' + es: 'Color de resaltado' + help: + en: 'Default color for highlighted text' + es: 'Color por defecto para el texto resaltado' + default: + es: '#fcf8e3' + en: '#fcf8e3' +navbar_light_color: + type: color + label: + en: 'Navigation bar item color' + es: 'Color de ítem en la barra de navegación' + help: + en: 'Text and icons' + es: 'Texto e íconos' + default: + es: '#ced4da' + en: '#ced4da' +navbar_light_hover_color: + type: color + label: + en: 'Navigation bar item color when selected' + es: 'Color de ítem seleccionado en la barra de navegación' + help: + en: 'Text and icons' + es: 'Texto e íconos' + default: + es: '#6c757d' + en: '#6c757d' +navbar_light_active_color: + type: color + label: + en: 'Navigation bar item color when active' + es: 'Color de ítem activo en la barra de navegación' + help: + en: 'Text and icons' + es: 'Texto e íconos' + default: + es: '#212529' + en: '#212529' +order: + type: order + label: + en: Order + es: Orden + help: + en: Position in articles list + es: La posición del artículo en la lista de artículos +draft: + type: boolean + label: + en: Draft + es: Borrador + help: + en: This post isn't ready to be published yet + es: Este artículo aun no está listo para publicar diff --git a/_includes/cart_add.html b/_includes/cart_add.html new file mode 100644 index 0000000..e3cce65 --- /dev/null +++ b/_includes/cart_add.html @@ -0,0 +1,8 @@ + diff --git a/_includes/cart_controller.html b/_includes/cart_controller.html new file mode 100644 index 0000000..424c86d --- /dev/null +++ b/_includes/cart_controller.html @@ -0,0 +1,11 @@ +data-controller="cart" +data-target="stock.product" +data-sku="{{ include.product.sku }}" +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: '|' }}" diff --git a/_includes/country.html b/_includes/country.html new file mode 100644 index 0000000..92f1284 --- /dev/null +++ b/_includes/country.html @@ -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 -%} + +
+ + + + + + + + {%- if error -%} +
{{ error }}
+ {%- endif -%} + + {%- if help -%} + + {{ help }} + + {%- endif -%} + + + +
diff --git a/_includes/donacion.html b/_includes/donacion.html new file mode 100644 index 0000000..5164821 --- /dev/null +++ b/_includes/donacion.html @@ -0,0 +1,125 @@ +{% comment %} +Traer la donación pasada como parámetro o la que se haya establecido por +defecto para el layout correspondiente. + +product = producto +page = post que incluye esta donación +icon = ícono del botón +class = clases extra para el botón +boton = texto opcional para el botón +{% endcomment %} +{%- assign product = include.product | default: include.page -%} +{%- assign donacion = include.page.donacion | default: product.donacion -%} + + + + +
+ + + + +
+
+
+ {{ donacion.content | markdownify }} +
+ +
+ {% if include.page.image.path %} + + + + + {% else %} + {% include_cached logo_horizontal.svg %} + {% endif %} +
+ +
+

{{ site.i18n.donacion.monto }}

+ + + +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ {% comment %} + Este input invisible es para que Bootstrap pueda mostrar el + error si querés mandar el formulario sin elegir una moneda. + {% endcomment %} + +
+ {{ site.i18n.donacion.currency }} +
+ +

+
+
+
+ +
+ + + {% if include.page.pdf.path %} + + {% endif %} +
+ +
+ +
+ {{ site.i18n.donacion.medios | markdownify | replace: '

', '

' | replace: '

', '

' }} +
+
+
+
diff --git a/_includes/floating_alert.html b/_includes/floating_alert.html index 87a5ed9..5a5043c 100644 --- a/_includes/floating_alert.html +++ b/_includes/floating_alert.html @@ -1,6 +1,6 @@
-
+
diff --git a/_includes/input.html b/_includes/input.html index cc674c2..2667aad 100644 --- a/_includes/input.html +++ b/_includes/input.html @@ -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 -%}
@@ -11,6 +18,7 @@ + {%- if error -%} +
{{ error }}
+ {%- endif -%} + {%- if help -%} {{ help }} diff --git a/_includes/notification.html b/_includes/notification.html new file mode 100644 index 0000000..b74b4c9 --- /dev/null +++ b/_includes/notification.html @@ -0,0 +1,5 @@ +
+
diff --git a/_includes/pay_what_you_can_controller.html b/_includes/pay_what_you_can_controller.html new file mode 100644 index 0000000..eabed0f --- /dev/null +++ b/_includes/pay_what_you_can_controller.html @@ -0,0 +1,13 @@ +data-controller="pay-what-you-can" +data-pay-what-you-can-download-value="{{ include.product.url | present }}" +data-pay-what-you-can-url-value="{{ include.url | default: include.product.url }}" +data-pay-what-you-can-variant-id-value="{{ include.product.variant_id }}" +data-pay-what-you-can-firstname-value="{{ include.product.title }}" +data-pay-what-you-can-currency-value="ARS" +data-pay-what-you-can-price-value="1" +data-image="{{ include.product.image.path | thumbnail: 300 }}" +data-title="{{ include.product.title }}" +data-price="{{ include.product.price }}" +data-in-stock="{{ include.product.in_stock }}" +data-stock="{{ include.product.stock }}" +data-extra="{{ include.extra | join: '|' }}" diff --git a/_includes/postal_code.html b/_includes/postal_code.html new file mode 100644 index 0000000..151c6ac --- /dev/null +++ b/_includes/postal_code.html @@ -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 -%} + +
+ + + + + {%- if error -%} +
{{ error }}
+ {%- endif -%} + + {%- if help -%} + + {{ help }} + + {%- endif -%} +
diff --git a/_includes/product.html b/_includes/product.html new file mode 100644 index 0000000..1b669f6 --- /dev/null +++ b/_includes/product.html @@ -0,0 +1,38 @@ +
+
+ + + + + + + + +
+ + +

+ {{ include.product.price }} + {{ site.cart.currency }} +

+ + +
+
+
diff --git a/_includes/state.html b/_includes/state.html new file mode 100644 index 0000000..11724f4 --- /dev/null +++ b/_includes/state.html @@ -0,0 +1,52 @@ +{% 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 -%} + +
+ + + + + + + {%- if error -%} +
{{ error }}
+ {%- endif -%} + + {%- if help -%} + + {{ help }} + + {%- endif -%} + + + +
diff --git a/_includes/submit.html b/_includes/submit.html index 6f82e96..863b3b3 100644 --- a/_includes/submit.html +++ b/_includes/submit.html @@ -1,5 +1,5 @@ diff --git a/_layouts/cart.html b/_layouts/cart.html new file mode 100644 index 0000000..8592a97 --- /dev/null +++ b/_layouts/cart.html @@ -0,0 +1,72 @@ +--- +layout: default +--- + +
+

{{ page.title }}

+
+ {{ content | replace: ' + +
+ +
+
+

{{ page.product }}

+
+ +
+
+
+

{{ page.price }}

+
+ +
+

{{ page.quantity }}

+
+ +
+

{{ page.subtotal }}

+
+ +
+

{{ page.remove }}

+
+
+
+ +
+
+
+ +
+ + +
+
+
+ +
+

{{ page.subtotal }}

+
+ +
+

0

+
+ +
+ + +
+
+
+
+
diff --git a/_layouts/confirmation.html b/_layouts/confirmation.html new file mode 100644 index 0000000..5857fe8 --- /dev/null +++ b/_layouts/confirmation.html @@ -0,0 +1,23 @@ +--- +layout: default +--- + +
+ +
+

{{ page.title }}

+
+ +
+ {{ content }} +
+ +
+ +

+ + {{ page.back }} +
diff --git a/_layouts/default.html b/_layouts/default.html index 3aec209..8994f1f 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -24,7 +24,7 @@ si no existe. {% endcomment %} - + {% include_cached pack.html %} {% comment %} @@ -46,12 +46,17 @@ {% feed_meta %} +
+ {%- include_cached notification.html -%} +
+ {%- include_cached menu.html active_cache_key=page.layout %} -
+
{{ content }}
{% include_cached footer.html %} + {% include_cached floating_alert.html %} diff --git a/_layouts/page.html b/_layouts/page.html new file mode 100644 index 0000000..5e71126 --- /dev/null +++ b/_layouts/page.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{{ content }} diff --git a/_layouts/payment.html b/_layouts/payment.html new file mode 100644 index 0000000..b3a72e3 --- /dev/null +++ b/_layouts/payment.html @@ -0,0 +1,21 @@ +--- +layout: default +--- + +
+
+

{{ page.title }}

+
+ +
+ {{ content }} +
+ +
+
+
diff --git a/_layouts/product.html b/_layouts/product.html new file mode 100644 index 0000000..20d8060 --- /dev/null +++ b/_layouts/product.html @@ -0,0 +1,110 @@ +--- +layout: default +--- + +
+
+
+ + {{ page.image.description | default: page.title }} + + +
+

{{ site.i18n.libro.ficha }}

+ +
    +
  • + +
  • + + {% if page.width > 0 %} +
  • + {{ site.data.layouts.product.width.label[site.lang] }} + {{ page.width }} + {{ site.cart.length_unit | default: site.data.layouts.cart.length_unit.default[site.lang] }} +
  • + {% endif %} + + {% if page.height > 0 %} +
  • + {{ site.data.layouts.product.height.label[site.lang] }} + {{ page.height }} + {{ site.cart.length_unit | default: site.data.layouts.cart.length_unit.default[site.lang] }} +
  • + {% endif %} + + {% if page.depth > 0 %} +
  • + {{ site.data.layouts.product.depth.label[site.lang] }} + {{ page.depth }} + {{ site.cart.length_unit | default: site.data.layouts.cart.length_unit.default[site.lang] }} +
  • + {% endif %} + + {% if page.weight > 0 %} +
  • + {{ site.data.layouts.product.weight.label[site.lang] }} + {{ page.weight }} + {{ site.cart.weight_unit | default: site.data.layouts.cart.weight_unit.default[site.lang] }} +
  • + {% endif %} +
+ +
+
+ + + + + + {% if page.in_stock %} + + {% else %} + + {% endif %} + +

+ {{ page.price | floor }} + {{ site.cart.currency }} +

+ + +
+
+
+
+ +
+
+

{{ page.title }}

+

{{ page.description }}

+
+ +
+ {{ content }} +
+
+
+ + + + +
diff --git a/_layouts/shipment.html b/_layouts/shipment.html new file mode 100644 index 0000000..9111a08 --- /dev/null +++ b/_layouts/shipment.html @@ -0,0 +1,52 @@ +--- +layout: default +--- + +
+
+

{{ page.title }}

+
+ +
+ {{ content }} +
+ +
+

{{ page.user }}

+ +
+ {% for field in site.data.forms.user %} + {% assign template = field[1].type | append: '.html' %} + {% include {{ template }} field=field form='user' %} + {% endfor %} +
+
+ +
+ +

{{ page.shipping_address }}

+ +
+ {% for field in site.data.forms.shipping_address %} + {% assign template = field[1].type | append: '.html' %} + {% include {{ template }} field=field %} + {% endfor %} +
+ +
+

{{ page.shipping_methods }}

+ +
+ {{ page.shipping_methods_help | markdownify }} +
+
+
+
diff --git a/_packs/controllers/cart_base_controller.js b/_packs/controllers/cart_base_controller.js new file mode 100644 index 0000000..f489a61 --- /dev/null +++ b/_packs/controllers/cart_base_controller.js @@ -0,0 +1,270 @@ +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; + } + + /* + * 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"; + let notify = true; + + const site = window.site; + const fail = response.fail(); + + switch (fail.name) { + case "MisconfigurationError": + data.content = 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 (fail.serverResponse.status === 404) { + template = "recover_order"; + data.content = site.i18n.alerts.recover_order; + } else { + data.content = response.fail().summary; + } + + break; + case "ExpandedSpreeError": + const content = []; + // XXX: La API devuelve los mensajes de error tal cual en la + // llave `error` pero el cliente solo nos da acceso a `errors`. + for (const field of Object.keys(fail.errors)) { + if (!site.i18n.errors?.fields[field]) { + console.error("El campo no tiene traducción", field); + } + + content.push( + `${site.i18n.errors?.fields[field]}: ${fail.errors[field].join( + ", " + )}` + ); + } + + data.content = content.join(". "); + notify = false; + + break; + default: + data.content = fail.message; + } + + if (notify) console.error(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; + } +} diff --git a/_packs/controllers/cart_confirmation_controller.js b/_packs/controllers/cart_confirmation_controller.js new file mode 100644 index 0000000..d58e4c2 --- /dev/null +++ b/_packs/controllers/cart_confirmation_controller.js @@ -0,0 +1,47 @@ +import { CartBaseController } from "./cart_base_controller"; + +export default class extends CartBaseController { + static targets = ["order"]; + + async connect() { + if (this.clear) { + this.storage.clear(); + window.dispatchEvent( + new CustomEvent("cart:counter", { detail: { item_count: 0 } }) + ); + } + + if (!this.template) return; + + if (this.storage.cart) { + const order = this.cart.data.attributes; + const products = this.products; + const site = window.site; + const shipping_address = JSON.parse( + this.storage.getItem("shipping_address") + ); + + const data = { order, products, site, shipping_address }; + + this.storage.setItem("confirmation", JSON.stringify(data)); + } else { + data = JSON.parse(this.storage.getItem("confirmation")); + } + + this.render(data); + } + + render(data = {}) { + this.engine + .parseAndRender(this.template, data) + .then((html) => (this.orderTarget.innerHTML = html)); + } + + get clear() { + return this.element.dataset.clear; + } + + get template() { + return window.templates[this.element.dataset.template]; + } +} diff --git a/_packs/controllers/cart_contact_controller.js b/_packs/controllers/cart_contact_controller.js new file mode 100644 index 0000000..2ee4c9f --- /dev/null +++ b/_packs/controllers/cart_contact_controller.js @@ -0,0 +1,32 @@ +import { CartBaseController } from "./cart_base_controller"; + +export default class extends CartBaseController { + static targets = ["form", "username"]; + + connect() { + this.focusout_event = this._focusout_event.bind(this); + + if (!this.hasUsernameTarget) return; + if (!this.hasFormTarget) return; + + this.formTarget.addEventListener("focusout", this.focusout_event); + } + + disconnect() { + this.formTarget.removeEventListener("focusout", this.focusout_event); + } + + _focusout_event(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; + } +} diff --git a/_packs/controllers/cart_controller.js b/_packs/controllers/cart_controller.js new file mode 100644 index 0000000..e23115e --- /dev/null +++ b/_packs/controllers/cart_controller.js @@ -0,0 +1,317 @@ +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; + + this.change_event = this._change_event.bind(this); + + /* + * 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", this.change_event); + } + + disconnect() { + this.quantityTarget.removeEventListener("change", this.change_event); + } + + async _change_event(event) { + const quantity = event.target.value; + + if (quantity < 1) return; + + const orderToken = await this.tokenGetOrCreate(); + const product = this.product; + + if (!product) return; + + event.target.disabled = true; + + const response = await this.spree.cart.setQuantity( + { orderToken }, + { + line_item_id: 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 = + 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() { + const product = JSON.parse(this.storage.getItem(this.storageId)); + + if (!product) { + console.error( + "El producto es nulo!", + this.storageId, + this.storage.length, + this.cart + ); + } + + return product; + } + + /* + * 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 = window.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() { + const product = this.product; + + if (!product) return; + if (!product.line_item) return; + + const orderToken = this.token; + const response = await this.spree.cart.removeItem( + { orderToken }, + 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() { + console.error("Recuperando pedido", this.token); + + // Removes the failing token + this.storage.removeItem("token"); + + // Get a new token and cart + await this.tokenGetOrCreate(); + + // Stores the previous cart + const cart = this.cart; + + if (!cart) return; + + // 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; + + if (!product) continue; + + 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; + } +} diff --git a/_packs/controllers/cart_counter_controller.js b/_packs/controllers/cart_counter_controller.js new file mode 100644 index 0000000..94d30d3 --- /dev/null +++ b/_packs/controllers/cart_counter_controller.js @@ -0,0 +1,39 @@ +import { CartBaseController } from "./cart_base_controller"; + +export default class extends CartBaseController { + static targets = ["counter"]; + + connect() { + if (!this.hasCounterTarget) { + console.error("Missing counter target"); + return; + } + + this.cart_count_event = this._cart_count_event.bind(this); + this.storage_event = this._storage_event.bind(this); + + window.addEventListener("cart:counter", this.cart_count_event); + window.addEventListener("storage", this.storage_event); + + if (!this.cart) return; + + this.counter = this.cart.data.attributes.item_count; + } + + disconnect() { + window.removeEventListener("cart:counter", this.cart_count_event); + window.removeEventListener("storage", this.storage_event); + } + + set counter(quantity) { + this.counterTarget.innerText = quantity; + } + + _cart_count_event(event) { + this.counter = event.detail.item_count; + } + + _storage_event(event) { + if (event.key == "cart:counter") this.counter = event.newValue; + } +} diff --git a/_packs/controllers/cart_coupon_controller.js b/_packs/controllers/cart_coupon_controller.js new file mode 100644 index 0000000..dcf12f6 --- /dev/null +++ b/_packs/controllers/cart_coupon_controller.js @@ -0,0 +1,81 @@ +import { CartBaseController } from "./cart_base_controller"; + +/* + * Retrieves shipping methods + */ +export default class extends CartBaseController { + static targets = ["couponCodeInvalid", "preDiscount", "total"]; + + connect() { + this.input_event = this._input_event.bind(this); + + this.couponCode.addEventListener("input", this.input_event); + } + + disconnect() { + this.couponCode.removeEventListener("input", this.input_event); + } + + _input_event(event) { + this.couponCode.parentElement.classList.remove("was-validated"); + this.couponCode.setCustomValidity(""); + } + + get couponCode() { + if (!this._couponCode) this._couponCode = this.element.elements.coupon_code; + + return this._couponCode; + } + + get couponCodeInvalid() { + return this.hasCouponCodeInvalidTarget + ? this.couponCodeInvalidTarget + : document.querySelector("#coupon-code-invalid"); + } + + get preDiscount() { + return this.hasPreDiscountTarget + ? this.preDiscountTarget + : document.querySelector("#pre-discount"); + } + + get total() { + return this.hasTotalTarget + ? this.totalTarget + : document.querySelector("#total"); + } + + set total(total) { + this.total.innerHTML = total; + } + + async apply(event = undefined) { + event?.preventDefault(); + event?.stopPropagation(); + + const orderToken = this.token; + const coupon_code = this.couponCode.value; + const include = "line_items"; + + const response = await window.spree.cart.applyCouponCode( + { orderToken }, + { coupon_code, include } + ); + + this.element.elements.forEach((x) => (x.disabled = true)); + + if (response.isFail()) { + this.couponCodeInvalid.innerHTML = response.fail().summary; + this.couponCode.setCustomValidity(response.fail().summary); + this.couponCode.parentElement.classList.add("was-validated"); + + this.element.elements.forEach((x) => (x.disabled = false)); + + return; + } + + this.cart = response; + this.total = response.success().data.attributes.total; + this.preDiscount.classList.remove("d-none"); + } +} diff --git a/_packs/controllers/cart_payment_methods_controller.js b/_packs/controllers/cart_payment_methods_controller.js new file mode 100644 index 0000000..c9ed0e9 --- /dev/null +++ b/_packs/controllers/cart_payment_methods_controller.js @@ -0,0 +1,106 @@ +import { CartBaseController } from "./cart_base_controller"; + +/* + * Retrieves payment methods and redirect to external checkouts + */ +export default class extends CartBaseController { + static targets = ["form", "submit", "specialInstructions"]; + + async connect() { + const orderToken = this.token; + const response = await this.spree.checkout.paymentMethods({ orderToken }); + + this.change_event = this._change_event.bind(this); + + if (response.isFail()) { + this.handleFailure(response); + return; + } + + const payment_methods = response.success().data; + const site = window.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 template = window.templates[this.data.get("template")]; + + this.element.innerHTML = await this.engine.parseAndRender(template, data); + + if (!this.hasSubmitTarget) return; + this.formTarget.elements.forEach((p) => + p.addEventListener("change", this.change_event) + ); + } + + disconnect() { + if (!this.hasSubmitTarget) return; + + this.formTarget.elements.forEach((p) => + p.removeEventListener("change", this.change_event) + ); + } + + _change_event(event) { + 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; + const special_instructions = this.specialInstructionsTarget.value.trim(); + + // 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: { + special_instructions, + 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]; + + window.location = redirectUrl; + } +} diff --git a/_packs/controllers/cart_paypal_confirmation_controller.js b/_packs/controllers/cart_paypal_confirmation_controller.js new file mode 100644 index 0000000..85c9f8a --- /dev/null +++ b/_packs/controllers/cart_paypal_confirmation_controller.js @@ -0,0 +1,60 @@ +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 = window.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("/"); + } +} diff --git a/_packs/controllers/cart_shipping_controller.js b/_packs/controllers/cart_shipping_controller.js new file mode 100644 index 0000000..593d7ee --- /dev/null +++ b/_packs/controllers/cart_shipping_controller.js @@ -0,0 +1,135 @@ +import { CartBaseController } from "./cart_base_controller"; + +export default class extends CartBaseController { + static targets = ["methods", "rates", "form"]; + + connect() { + this.formdata_event = this._formdata_event.bind(this); + + this.formTarget.addEventListener("formdata", this.formdata_event); + } + + disconnect() { + this.formTarget.removeEventListener("formdata", this.formdata_event); + } + + _formdata_event(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 = window.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 template = window.templates[this.data.get("template")]; + + this.methodsTarget.innerHTML = await this.engine.parseAndRender( + template, + data + ); + this.ratesTarget.addEventListener("formdata", (event) => + this.processShippingRate(event.formData) + ); + } +} diff --git a/_packs/controllers/contact_controller.js b/_packs/controllers/contact_controller.js index 108119d..593d302 100644 --- a/_packs/controllers/contact_controller.js +++ b/_packs/controllers/contact_controller.js @@ -1,42 +1,42 @@ -import { Controller } from 'stimulus' +import { Controller } from "stimulus"; /* * Sólo permite enviar el formulario de contacto después de unos * segundos, para evitar el spam. */ export default class extends Controller { - static targets = [ 'submit' ] + static targets = ["submit"]; - connect () { - if (!this.hasSubmitTarget) return + connect() { + if (!this.hasSubmitTarget) return; - this.submitTarget.disabled = true + this.submitTarget.disabled = true; - this._value = this.submitTarget.value + this._value = this.submitTarget.value; // Esperar un minuto desde que se carga la página hasta que se // permite enviar. this._interval = setInterval(() => { - const delay = this.delay + const delay = this.delay; if (this.delay == 0) { - clearInterval(this._interval) - this.submitTarget.disabled = false - this.submitTarget.value = this._value + clearInterval(this._interval); + this.submitTarget.disabled = false; + this.submitTarget.value = this._value; } else { - this.delay = delay - 1 + this.delay = delay - 1; } - }, 1000) + }, 1000); } - get delay () { - const delay = parseInt(this.element.dataset.delay) + get delay() { + const delay = parseInt(this.element.dataset.delay); - return isNaN(delay) ? 0 : delay + return isNaN(delay) ? 0 : delay; } - set delay (value) { - this.element.dataset.delay = value - this.submitTarget.value = `${this._value} (${value})` + set delay(value) { + this.element.dataset.delay = value; + this.submitTarget.value = `${this._value} (${value})`; } } diff --git a/_packs/controllers/country_controller.js b/_packs/controllers/country_controller.js new file mode 100644 index 0000000..f1adccb --- /dev/null +++ b/_packs/controllers/country_controller.js @@ -0,0 +1,124 @@ +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); + }); + + this.input_event = this._input_event.bind(this); + this.invalid_event = this._invalid_event.bind(this); + this.change_event = this._change_event.bind(this); + + // Only allow names on this list + this.nameTarget.pattern = countries.map((x) => x.attributes.name).join("|"); + this.nameTarget.addEventListener("input", this.input_event); + this.nameTarget.addEventListener("invalid", this.invalid_event); + + // When the input changes we update the actual value and also the + // state list via an Event + this.nameTarget.addEventListener("change", this.change_event); + + // 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")); + } + + disconnect() { + this.nameTarget.removeEventListener("input", this.input_event); + this.nameTarget.removeEventListener("invalid", this.invalid_event); + this.nameTarget.removeEventListener("change", this.change_event); + } + + _input_event(event) { + this.nameTarget.setCustomValidity(""); + } + + _invalid_event(event) { + const site = window.site; + this.nameTarget.setCustomValidity(site.i18n.countries.validation); + } + + _change_event(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; + } + + /* + * 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; + } +} diff --git a/_packs/controllers/floating_alert_controller.js b/_packs/controllers/floating_alert_controller.js index d50317e..71dd9aa 100644 --- a/_packs/controllers/floating_alert_controller.js +++ b/_packs/controllers/floating_alert_controller.js @@ -1,18 +1,42 @@ -import { Controller } from 'stimulus' +import { Controller } from "stimulus"; +// Ejemplo de uso: +// window.dispatchEvent( +// new CustomEvent("toast", { detail: { content: "¡Hola, usuarix!" } }) +// ); export default class extends Controller { - static targets = [ 'content' ] + static targets = ["content"]; - connect () { - window.addEventListener('toast', event => { - this.contentTarget.innerText = event.detail.content - this.element.classList.toggle('hide') - this.element.classList.toggle('show') + connect() { + this.toast_event = this._toast_event.bind(this); - setTimeout(() => { - this.element.classList.toggle('hide') - this.element.classList.toggle('show') - }, 3000) - }) + window.addEventListener("toast", this.toast_event); + } + + disconnect() { + window.removeEventListener("toast", this.toast_event); + } + + _toast_event(event) { + this.contentTarget.innerText = event.detail.content; + this.set(true); + + if (this.interval) { + clearTimeout(this.interval); + } + this.interval = setTimeout(() => { + this.set(false); + this.interval = null; + }, 3000); + } + + set(show) { + if (show) { + this.element.classList.remove("hide"); + this.element.classList.add("show"); + } else { + this.element.classList.add("hide"); + this.element.classList.remove("show"); + } } } diff --git a/_packs/controllers/menu_controller.js b/_packs/controllers/menu_controller.js index 3fe8794..2082fe5 100644 --- a/_packs/controllers/menu_controller.js +++ b/_packs/controllers/menu_controller.js @@ -1,29 +1,39 @@ -import { Controller } from 'stimulus' +import { Controller } from "stimulus"; export default class extends Controller { - static targets = [ 'item' ] + static targets = ["item"]; - connect () { - window.addEventListener('scroll:section', event => this.update(event.detail.id)) + connect() { + this.scroll_section_event = this._scroll_section_event.bind(this); + + window.addEventListener("scroll:section", this.scroll_section_event); } - get items () { + disconnect() { + window.removeEventListener("scroll:section", this.scroll_section_event); + } + + _scroll_section_event(event) { + this.update(event.detail.id); + } + + get items() { if (!this._items) { - this._items = {} + this._items = {}; for (const item of this.itemTargets) { - this._items[item.href.split('#')[1]] = item + this._items[item.href.split("#")[1]] = item; } } - return this._items + return this._items; } - update (id) { + update(id) { for (const item of Object.values(this.items)) { - item.classList.remove('active') + item.classList.remove("active"); } - if (this.items[id]) this.items[id].classList.add('active') + if (this.items[id]) this.items[id].classList.add("active"); } } diff --git a/_packs/controllers/notification_controller.js b/_packs/controllers/notification_controller.js index 17eaaa7..9dbff80 100644 --- a/_packs/controllers/notification_controller.js +++ b/_packs/controllers/notification_controller.js @@ -1,56 +1,47 @@ -import { Controller } from 'stimulus' -import { Liquid } from 'liquidjs' +import { Controller } from "stimulus"; +import { Liquid } from "liquidjs"; /* * Waits for notifications and shows them by fetching and rendering * a template. */ export default class extends Controller { - connect () { - window.addEventListener('notification', async event => await this.render(event.detail.template, event.detail.data)) + connect() { + window.addEventListener( + "notification", + async (event) => + await this.render(event.detail.template, event.detail.data) + ); } /* * Renders and replaces notification contents and then shows it. Does * nothing if the template isn't found. */ - async render (name, data = {}) { - const response = await fetch(this.template(name)) + async render(name, data = {}) { + data.site = window.site; - if (!response.ok) return + const template = window.templates.alert; + const html = await this.engine.parseAndRender(template, data); - data.site = await this.site() - - const template = await response.text() - const html = await this.engine.parseAndRender(template, data) - - this.element.innerHTML = html - this.show() - } - - /* - * Gets the template path from a name - * - * @return [String] - */ - template (name) { - return this.data.get('templates') + name + '.html' + this.element.innerHTML = html; + this.show(); } /* * Shows the notification */ - show () { - this.element.classList.add('show') - this.element.classList.remove('hide') + show() { + this.element.classList.add("show"); + this.element.classList.remove("hide"); } /* * Hides the notification */ - hide () { - this.element.classList.add('hide') - this.element.classList.remove('show') + hide() { + this.element.classList.add("hide"); + this.element.classList.remove("show"); } /* @@ -58,23 +49,9 @@ export default class extends Controller { * * @return Liquid */ - get engine () { - if (!window.liquid) window.liquid = new 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 + return window.liquid; } } diff --git a/_packs/controllers/order_controller.js b/_packs/controllers/order_controller.js new file mode 100644 index 0000000..5091a00 --- /dev/null +++ b/_packs/controllers/order_controller.js @@ -0,0 +1,82 @@ +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 = window.site; + + this.render({ products, site }); + this.subtotalUpdate(); + this.itemCountUpdate(); + + this.storage_event = this._storage_event.bind(this); + this.cart_subtotal_update_event = + this._cart_subtotal_update_event.bind(this); + + window.addEventListener("storage", this.storage_event); + window.addEventListener( + "cart:subtotal:update", + this.cart_subtotal_update_event + ); + } + + disconnect() { + window.removeEventListener("storage", this.storage_event); + window.removeEventListener( + "cart:subtotal:update", + this.cart_subtotal_update_event + ); + } + + async _storage_event(event) { + if (!event.key?.startsWith("cart:item:")) return; + + const products = this.products; + const site = window.site; + + this.render({ products, site }); + this.subtotalUpdate(); + this.itemCountUpdate(); + } + + _cart_subtotal_update_event(event) { + this.itemCountUpdate(); + this.subtotalUpdate(); + } + + /* + * Download the item template and render the order + */ + render(data = {}) { + const template = window.templates[this.data.get("itemTemplate")]; + + 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; + } +} diff --git a/_packs/controllers/pay_what_you_can_controller.js b/_packs/controllers/pay_what_you_can_controller.js new file mode 100644 index 0000000..053674e --- /dev/null +++ b/_packs/controllers/pay_what_you_can_controller.js @@ -0,0 +1,248 @@ +import { CartBaseController } from "./cart_base_controller"; + +/* + * Al pagar lo que podamos, primero hay que crear una orden y luego + * contactarse con la APIv2 para generar la variante con el precio que + * queramos agregar. Agregamos la variante al carrito y lanzamos el + * proceso de pago. + */ +export default class extends CartBaseController { + static targets = ["form"]; + static values = { + variantId: Number, + currency: String, + price: Number, + firstname: String, + }; + + connect() { + this.paymentMethodByCurrency = { + ARS: "Spree::PaymentMethod::MercadoPago", + USD: "Spree::PaymentMethod::Paypal", + }; + } + + store(event) { + const target = event.currentTarget || event.target; + + this[`${target.dataset.name}Value`] = target.value; + } + + set formDisable(disable) { + this.formTarget.elements.forEach((x) => (x.disabled = disable)); + } + + /* + * Realiza todos los pasos: + * + * * Crear pedido + * * Crear variante con el monto y moneda + * * Agregar al pedido + * * Agregar dirección al pedido + * * Obtener métodos de envío + * * Obtener métodos de pago + * * Pagar + * * Reenviar a confirmación + * * Ejecutar el pago (si aplica) + */ + async pay(event = undefined) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + if (!this.formTarget.checkValidity()) { + this.formTarget.classList.add("was-validated"); + return; + } + + this.formDisable = true; + + // Crear pedido. Todos los pedidos van a ser hechos desde + // Argentina, no hay forma de cambiar esto. + const orderToken = await this.tempCartCreate(); + const quantity = 1; + const include = "line_items"; + const currency = this.currencyValue; + const price = this.priceValue; + const email = "noreply@sutty.nl"; + const firstname = this.firstnameValue; + const lastname = "-"; + const address1 = "-"; + const country_id = 250; // XXX: Internet + const city = "-"; + const phone = "11111111"; + const zipcode = "1111"; + const ship_address_attributes = { + firstname, + lastname, + address1, + city, + country_id, + zipcode, + phone, + }; + const bill_address_attributes = ship_address_attributes; + const confirmation_delivered = true; + const custom_return_url = this.customReturnUrl(); + + let variant_id = this.variantIdValue; + + // Crear la variante + const payWhatYouCanResponse = await this.spree.sutty.payWhatYouCan( + { orderToken }, + { variant_id, price, currency, quantity } + ); + + variant_id = payWhatYouCanResponse.data.id; + + if (!variant_id) { + this.formDisable = false; + console.error("No se pudo generar la variante", { + variant_id, + price, + currency, + quantity, + }); + return; + } + + // Configurar la moneda del pedido + let response = await this.spree.sutty.updateOrder( + { orderToken }, + { currency, confirmation_delivered, custom_return_url } + ); + + if (response.status > 299) { + console.error(response); + this.formDisable = false; + return; + } + + // Agregar al carrito + response = await this.spree.cart.addItem( + { orderToken }, + { variant_id, quantity, include } + ); + + if (response.isFail()) { + this.handleFailure(response); + this.formDisable = false; + return; + } + + // Actualizar la dirección + response = await this.spree.checkout.orderUpdate( + { orderToken }, + { order: { email, ship_address_attributes, bill_address_attributes } } + ); + + if (response.isFail()) { + this.handleFailure(response); + this.formDisable = false; + return; + } + + // Obtener los medios de envío + response = await this.spree.checkout.shippingMethods( + { orderToken }, + { include: "shipping_rates" } + ); + + if (response.isFail()) { + this.handleFailure(response); + this.formDisable = false; + return; + } + + // Elegir medio de envío + response = await this.spree.checkout.orderUpdate( + { orderToken }, + { + order: { + shipments_attributes: [ + { + id: response.success().data[0].id, + selected_shipping_rate_id: response + .success() + .included.filter((x) => x.type == "shipping_rate")[0].id, + }, + ], + }, + } + ); + + // Elegir medio de pago + response = await this.spree.checkout.paymentMethods({ orderToken }); + + if (response.isFail()) { + this.handleFailure(response); + this.formDisable = false; + return; + } + + const payment_method_id = response + .success() + .data.find( + (x) => + this.paymentMethodByCurrency[this.currencyValue] == x.attributes.type + ).id; + + response = await this.spree.checkout.orderUpdate( + { orderToken }, + { + order: { payments_attributes: [{ payment_method_id }] }, + payment_source: { + [payment_method_id]: { + name: "Pepitx", + month: 12, + year: 2021, + }, + }, + } + ); + + if (response.isFail()) { + this.handleFailure(response); + this.formDisable = false; + return; + } + + response = await this.spree.checkout.complete({ orderToken }); + + if (response.isFail()) { + this.handleFailure(response); + this.formDisable = false; + return; + } + + // Reenviar al medio de pago + const checkoutUrls = await this.spree.sutty.getCheckoutURL({ orderToken }); + const redirectUrl = checkoutUrls.data[0]; + + Turbolinks.visit(redirectUrl); + + // Volver + } + + async tempCartCreate() { + 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; + } + + return response.success().data.attributes.token; + } + + // @return [String] + customReturnUrl() { + const url = new URL(window.location.href); + url.searchParams.set("open", ""); + + return url.toString(); + } +} diff --git a/_packs/controllers/postal_code_controller.js b/_packs/controllers/postal_code_controller.js new file mode 100644 index 0000000..1b639d0 --- /dev/null +++ b/_packs/controllers/postal_code_controller.js @@ -0,0 +1,212 @@ +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() { + this.cart_country_update_event = this._cart_country_update_event.bind(this); + + window.addEventListener( + "cart:country:update", + this.cart_country_update_event + ); + } + + disconnect() { + window.removeEventListener( + "cart:country:update", + this.cart_country_update_event + ); + } + + _cart_country_update_event(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")); + } + } +} diff --git a/_packs/controllers/scroll_controller.js b/_packs/controllers/scroll_controller.js index e6a6874..9e39831 100644 --- a/_packs/controllers/scroll_controller.js +++ b/_packs/controllers/scroll_controller.js @@ -1,4 +1,4 @@ -import { Controller } from 'stimulus' +import { Controller } from "stimulus"; /* * Al navegar por el sitio y llegar a ciertas secciones, se van @@ -8,34 +8,40 @@ import { Controller } from 'stimulus' * a medida que van apareciendo secciones actualizamos el menú. */ export default class extends Controller { - static targets = [ 'section' ] + static targets = ["section"]; - connect () { + connect() { for (const section of this.sectionTargets) { - this.observer.observe(section) + this.observer.observe(section); } } /* * Solo nos interesa la primera */ - get observer () { - if (!this._observer) this._observer = new IntersectionObserver((entries, observer) => this.update(entries), this.options) + get observer() { + if (!this._observer) + this._observer = new IntersectionObserver( + (entries, observer) => this.update(entries), + this.options + ); - return this._observer + return this._observer; } - get options () { - if (!this._options) this._options = { threshold: 0, rootMargin: '0px' } + get options() { + if (!this._options) this._options = { threshold: 0, rootMargin: "0px" }; - return this._options + return this._options; } - update (entries) { - const section = entries.find(x => x.isIntersecting) + update(entries) { + const section = entries.find((x) => x.isIntersecting); - if (!section) return + if (!section) return; - window.dispatchEvent(new CustomEvent('scroll:section', { detail: { id: section.target.id }})) + window.dispatchEvent( + new CustomEvent("scroll:section", { detail: { id: section.target.id } }) + ); } } diff --git a/_packs/controllers/search_controller.js b/_packs/controllers/search_controller.js index 955425b..fcc8449 100644 --- a/_packs/controllers/search_controller.js +++ b/_packs/controllers/search_controller.js @@ -1,91 +1,96 @@ -import { Controller } from 'stimulus' -import { Liquid } from 'liquidjs' +import { Controller } from "stimulus"; +import { Liquid } from "liquidjs"; -const lunr = require("lunr") -require("lunr-languages/lunr.stemmer.support")(lunr) -require("lunr-languages/lunr.es")(lunr) +const lunr = require("lunr"); +require("lunr-languages/lunr.stemmer.support")(lunr); +require("lunr-languages/lunr.es")(lunr); export default class extends Controller { - static targets = [ 'q' ] + static targets = ["q"]; - get q () { - if (!this.hasQTarget) return - if (!this.qTarget.value.trim().length === 0) return + get q() { + if (!this.hasQTarget) return; + if (!this.qTarget.value.trim().length === 0) return; - return this.qTarget.value.trim().replace(':', '') + return this.qTarget.value.trim().replaceAll(/[:~\*\^\+\-]/gi, ""); } - connect () { - const q = new URLSearchParams(window.location.search).get('q')?.trim() + connect() { + const q = new URLSearchParams(window.location.search).get("q")?.trim(); if (q) { - this.qTarget.value = q - this.search() + this.qTarget.value = q; + this.search(); } } - async search (event) { + async search(event) { // Detiene el envío del formulario if (event) { - event.preventDefault() - event.stopPropagation() + event.preventDefault(); + event.stopPropagation(); } - this.formDisable = true + this.formDisable = true; // Obtiene el término de búsqueda - const q = this.q + const q = this.q; // Si no hay término de búsqueda, no hay búsqueda if (q) { // Trae el índice de búsqueda - await this.fetch() + await this.fetch(); // Hasta que no haya índice no buscamos nada, esto evita que se // aprete enter dos veces y se fallen cosas. - if (!window.index) return + if (!window.index) return; } - const main = document.querySelector('main') - const results = window.index.search(q).map(r => window.data.find(a => a.id == r.ref)) - const site = await this.site() - const request = await fetch('assets/templates/results.html') - const template = await request.text() - const html = await this.engine.parseAndRender(template, { q, site, results }) - const title = `${site.i18n.search.title} - ${q}` - const query = new URLSearchParams({ q }) + const main = document.querySelector("main"); + const results = window.index + .search(q) + .map((r) => window.data.find((a) => a.id == r.ref)); + const site = window.site; + const template = window.templates.results; + const html = await this.engine.parseAndRender(template, { + q, + site, + results, + }); + const title = `${site.i18n.search.title} - ${q}`; + const query = new URLSearchParams({ q }); - window.history.pushState({ q }, title, `?${query.toString()}`) - document.title = title + window.history.pushState({ q }, title, `?${query.toString()}`); + document.title = title; - main.innerHTML = html - this.formDisable = false + main.innerHTML = html; + this.formDisable = false; } - async fetch () { + async fetch() { // Solo permite descargar uno a la vez - if (this.fetching) return + if (this.fetching) return; - this.fetching = true - let response + this.fetching = true; + let response; // Si no existe el índice, lo descarga y procesa Lunr if (!window.data) { - response = await fetch('data.json') - window.data = await response.json() + response = await fetch("data.json"); + window.data = await response.json(); } if (!window.index) { - response = await fetch('idx.json') - const idx = await response.json() - window.index = lunr.Index.load(idx) + response = await fetch("idx.json"); + const idx = await response.json(); + window.index = lunr.Index.load(idx); } // Permitir volver a ejecutar - this.fetching = false + this.fetching = false; } - set formDisable (disable) { - this.element.elements.forEach(x => x.disabled = disable) + set formDisable(disable) { + this.element.elements.forEach((x) => (x.disabled = disable)); } /* @@ -93,18 +98,9 @@ export default class extends Controller { * * @return Liquid */ - get engine () { - if (!window.liquid) window.liquid = new Liquid() + get engine() { + if (!window.liquid) window.liquid = new Liquid(); - return window.liquid - } - - async site () { - if (!window.site) { - const data = await fetch('assets/data/site.json') - window.site = await data.json() - } - - return window.site + return window.liquid; } } diff --git a/_packs/controllers/share_controller.js b/_packs/controllers/share_controller.js index b0d1486..4f45767 100644 --- a/_packs/controllers/share_controller.js +++ b/_packs/controllers/share_controller.js @@ -1,26 +1,26 @@ -import { Controller } from 'stimulus' +import { Controller } from "stimulus"; export default class extends Controller { static values = { title: String, text: String, - url: String - } + url: String, + }; - async share (event = undefined) { - event?.preventDefault() - event?.stopPropagation() + async share(event = undefined) { + event?.preventDefault(); + event?.stopPropagation(); - const title = this.titleValue - const text = this.textValue - const url = this.urlValue - const data = { title, text, url } + const title = this.titleValue; + const text = this.textValue; + const url = this.urlValue; + const data = { title, text, url }; - if ('share' in navigator) { + if ("share" in navigator) { if (navigator.canShare(data)) { - navigator.share(data) + navigator.share(data); } else { - console.error('No se puede compartir', data) + console.error("No se puede compartir", data); } } } diff --git a/_packs/controllers/slider_controller.js b/_packs/controllers/slider_controller.js index 60a31c2..6103210 100644 --- a/_packs/controllers/slider_controller.js +++ b/_packs/controllers/slider_controller.js @@ -1,64 +1,73 @@ -import { Controller } from 'stimulus' +import { Controller } from "stimulus"; /* * Slider con scroll automático */ export default class extends Controller { - static targets = [ 'control' ] + static targets = ["control"]; - connect () { - this.active(this.controlTargets.find(x => x.href.endsWith(window.location.hash))) + connect() { + this.active( + this.controlTargets.find((x) => x.href.endsWith(window.location.hash)) + ); - this.interval = setInterval(() => this.inViewport ? this.controlTargets[this.next].click() : null, this.duration * 1000) + this.interval = setInterval( + () => (this.inViewport ? this.controlTargets[this.next].click() : null), + this.duration * 1000 + ); } - get duration () { - const duration = parseInt(this.data.get('duration')) + get duration() { + const duration = parseInt(this.data.get("duration")); - return isNaN(duration) ? 15 : duration + return isNaN(duration) ? 15 : duration; } - disconnect () { - clearInterval(this.interval) + disconnect() { + clearInterval(this.interval); } - active (control) { - if (!control) return + active(control) { + if (!control) return; - this.controlTargets.forEach(other => other.classList.toggle('active', control.href === other.href)) - this.current = this.controlTargets.indexOf(control) + this.controlTargets.forEach((other) => + other.classList.toggle("active", control.href === other.href) + ); + this.current = this.controlTargets.indexOf(control); } - activate (event) { + activate(event) { // XXX: En Firefox, el target del evento también puede ser el // contenido del link :/ - let t = (event.target.href) ? event.target : event.target.parentElement + let t = event.target.href ? event.target : event.target.parentElement; - this.active(t) + this.active(t); } - get current () { - return parseInt(this.data.get('current')) || 0 + get current() { + return parseInt(this.data.get("current")) || 0; } - set current (value) { - this.data.set('current', value) + set current(value) { + this.data.set("current", value); } - get next () { - const next = this.current + 1 + get next() { + const next = this.current + 1; - return (this.controlTargets[next]) ? next : 0 + return this.controlTargets[next] ? next : 0; } - get inViewport () { + get inViewport() { const bounding = this.element.getBoundingClientRect(); return ( bounding.top >= 0 && bounding.left >= 0 && - bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && - bounding.right <= (window.innerWidth || document.documentElement.clientWidth) + bounding.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + bounding.right <= + (window.innerWidth || document.documentElement.clientWidth) ); - }; + } } diff --git a/_packs/controllers/state_controller.js b/_packs/controllers/state_controller.js new file mode 100644 index 0000000..cdfa11a --- /dev/null +++ b/_packs/controllers/state_controller.js @@ -0,0 +1,115 @@ +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() { + this.cart_country_update_event = this._cart_country_update_event.bind(this); + this.change_event = this._change_event.bind(this); + + window.addEventListener( + "cart:country:update", + this.cart_country_update_event + ); + + // When the input changes we update the actual value and also the + // state list via an Event + this.nameTarget.addEventListener("change", this.change_event); + } + + disconnect() { + window.removeEventListener( + "cart:country:update", + this.cart_country_update_event + ); + this.nameTarget.removeEventListener("change", this.change_event); + } + + async _cart_country_update_event(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 = window.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")); + } + } + + _change_event(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; + } +} diff --git a/_packs/controllers/stock_controller.js b/_packs/controllers/stock_controller.js new file mode 100644 index 0000000..736a480 --- /dev/null +++ b/_packs/controllers/stock_controller.js @@ -0,0 +1,118 @@ +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() { + const all_skus = this.skus; + + if (all_skus.length === 0) return; + + // El paginado es para prevenir que la petición se haga muy grande y + // falle entera. + const pages = Math.ceil(all_skus.length / this.per_page); + + let start = 0; + let end = this.per_page; + + for (let local_page = 1; local_page <= pages; local_page++) { + const skus = all_skus.slice(start, end).join(","); + + start = this.per_page * local_page; + end = start + this.per_page; + + const filter = { skus }; + let response = await window.spree.products.list({ filter }); + + 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 }); + + 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. Usamos los SKUs porque no tenemos forma de filtrar + * por ID. + * + * @return [Array] + */ + get skus() { + return [ + ...new Set( + this.productTargets + .map((p) => p.dataset.sku) + .filter((x) => x.length > 0) + ), + ]; + } + + /* + * La cantidad de productos por página que vamos a pedir + */ + get per_page() { + if (!this._per_page) { + this._per_page = parseInt(this.element.dataset.perPage); + if (isNaN(this._per_page)) this._per_page = 100; + } + + return this._per_page; + } + + /* + * Los productos pueden estar duplicados así que buscamos todos. + */ + update_local_products(products) { + for (const local of this.productTargets) { + for (const product of products.filter( + (p) => + local.dataset.cartVariantId === + p.relationships.default_variant.data.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 = parseInt(product.attributes.price)) + ); + local + .querySelectorAll("[data-stock-currency]") + .forEach( + (currency) => (currency.innerText = product.attributes.currency) + ); + } + } + } +} diff --git a/_packs/endpoints/sutty.js b/_packs/endpoints/sutty.js new file mode 100644 index 0000000..136f96c --- /dev/null +++ b/_packs/endpoints/sutty.js @@ -0,0 +1,81 @@ +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; + } +} diff --git a/_packs/entry.js b/_packs/entry.js index 5de0945..0665332 100644 --- a/_packs/entry.js +++ b/_packs/entry.js @@ -1,19 +1,25 @@ -import { Notifier } from '@airbrake/browser' +import BotDetector from "device-detector-js/dist/parsers/bot"; +import { Notifier } from "@airbrake/browser"; -window.airbrake = new Notifier({ - projectId: window.env.AIRBRAKE_PROJECT_ID, - projectKey: window.env.AIRBRAKE_PROJECT_KEY, - host: 'https://panel.sutty.nl' -}) +window.bot_detector = new BotDetector(); +const bot = window.bot_detector.parse(navigator.userAgent); -console.originalError = console.error -console.error = (...e) => { - window.airbrake.notify(e.join(' ')) - return console.originalError(...e) +if (!bot) { + window.airbrake = new Notifier({ + projectId: window.env.AIRBRAKE_PROJECT_ID, + projectKey: window.env.AIRBRAKE_PROJECT_KEY, + host: "https://panel.sutty.nl", + }); + + console.originalError = console.error; + console.error = (...e) => { + window.airbrake.notify(e.join(" ")); + return console.originalError(...e); + }; } -import 'core-js/stable' -import 'regenerator-runtime/runtime' +import "core-js/stable"; +import "regenerator-runtime/runtime"; // Turbo acelera la navegación al no tener que recargar todo el JS y CSS // de la página, con lo que se siente más rápida y "nativa". @@ -21,40 +27,49 @@ import 'regenerator-runtime/runtime' // Cambiamos de turbolinks a turbo porque turbo soporta la función // fetch(), que luego es interceptada por el SW para obtener las // direcciones localmente. -import * as Turbo from "@hotwired/turbo" -Turbo.start() +import * as Turbo from "@hotwired/turbo"; +Turbo.start(); -import { Application } from 'stimulus' -import { definitionsFromContext } from "stimulus/webpack-helpers" +import { Application } from "stimulus"; +import { definitionsFromContext } from "stimulus/webpack-helpers"; -const application = Application.start() -const context = require.context("./controllers", true, /\.js$/) -application.load(definitionsFromContext(context)) +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); try { - window.axe = require('axe-core/axe') -} catch(e) {} + window.axe = require("axe-core/axe"); +} catch (e) {} -if (window.axe) window.axe.configure({ locale: require('axe-core/locales/es.json') }) +if (window.axe) + window.axe.configure({ locale: require("axe-core/locales/es.json") }); -document.addEventListener('turbo:load', event => { - document.querySelectorAll("a[href^='http://'],a[href^='https://'],a[href^='//']").forEach(a => { - a.rel = "noopener" - a.target = "_blank" - }) +document.addEventListener("turbo:load", (event) => { + document + .querySelectorAll("a[href^='http://'],a[href^='https://'],a[href^='//']") + .forEach((a) => { + a.rel = "noopener"; + a.target = "_blank"; + }); - if (!window.axe) return + if (!window.axe) return; - window.axe.run().then(results => { - results.violations.forEach(violation => { - violation.nodes.forEach(node => { - node.target.forEach(target => { - document.querySelectorAll(target).forEach(element => { - element.classList.add('inaccesible') - element.ariaLabel = node.failureSummary - }) - }) - }) - }) - }) -}) + window.axe.run().then((results) => { + results.violations.forEach((violation) => { + violation.nodes.forEach((node) => { + node.target.forEach((target) => { + document.querySelectorAll(target).forEach((element) => { + element.classList.add("inaccesible"); + element.ariaLabel = node.failureSummary; + }); + }); + }); + }); + }); +}); diff --git a/_sass/content.scss b/_sass/content.scss new file mode 100644 index 0000000..6914190 --- /dev/null +++ b/_sass/content.scss @@ -0,0 +1,11 @@ +/// Todos los elementos dentro de .content tienen margen inferior, salvo +/// el último, para no modificar el padding y margin del contenedor. +.content { + & > * { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + } +} diff --git a/_sass/fonts.scss b/_sass/fonts.scss index e69de29..21aa3b5 100644 --- a/_sass/fonts.scss +++ b/_sass/fonts.scss @@ -0,0 +1,29 @@ +// https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400;1,700&display=swap +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url(../fonts/roboto/v27/KFOkCnqEu92Fr1Mu52xP-subset.woff2) format('woff2'); +} +@font-face { + font-family: 'Roboto'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url(../fonts/roboto/v27/KFOjCnqEu92Fr1Mu51TzBhc9-subset.woff2) format('woff2'); +} +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(../fonts/roboto/v27/KFOmCnqEu92Fr1Me5Q-subset.woff2) format('woff2'); +} +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(../fonts/roboto/v27/KFOlCnqEu92Fr1MmWUlvAw-subset.woff2) format('woff2'); +} diff --git a/_sass/helpers.scss b/_sass/helpers.scss index e6170da..673cc14 100644 --- a/_sass/helpers.scss +++ b/_sass/helpers.scss @@ -26,6 +26,39 @@ $directions: (top, right, bottom, left); /// `-md`, `-lg`, `-xl`. $infix: breakpoint-infix($breakpoint, $grid-breakpoints); +/// Grilla en CSS, soporta armar una cantidad de columnas, indicar las +/// columnas que ocupan los elementos descendientes e incluso +/// solapamiento. +/// +/// @example html +///
+///
+///
+///
+ .d#{$infix}-grid { + display: grid !important; + + @each $spacer, $_ in $spacers { + &.grid-cols#{$infix}-#{$spacer} { + grid-template-columns: repeat($spacer, 1fr) !important; + } + + & > .grid-row#{$infix}-#{$spacer} { + grid-row: $spacer !important; + } + + & > .grid-col#{$infix}-#{$spacer} { + grid-column: $spacer !important; + } + + @each $spacer_to, $_ in $spacers { + & > .grid-col#{$infix}-#{$spacer}-to-#{$spacer_to} { + grid-column: #{$spacer} / #{$spacer_to} !important; + } + } + } + } + /// Ocultar la barra de scroll, útil para sliders horizontales. /// /// @example html @@ -37,6 +70,18 @@ $directions: (top, right, bottom, left); &::-webkit-scrollbar { display: none; } } + :enabled { + .show-when-disabled#{$infix} { + display: none !important; + } + } + + :disabled { + .hide-when-disabled#{$infix} { + display: none !important; + } + } + /// Un elemento cuadrado /// /// @example html @@ -315,7 +360,7 @@ $directions: (top, right, bottom, left); /// /// @example html ///
- .#{$color} { + .#{$color}#{$infix} { color: var(--#{$color}); &:focus { diff --git a/assets/css/styles.scss b/assets/css/styles.scss index a03496c..6868ad7 100644 --- a/assets/css/styles.scss +++ b/assets/css/styles.scss @@ -6,11 +6,41 @@ /// @group Principal //// -/// Traemos el primer artículo de tipo `about` para obtener los valores +/// Traemos el primer artículo de tipo `theme` para obtener los valores /// personalizados. -/// -/// @todo Mover a su propio SCSS -{% assign about = site.posts | find: 'layout', 'about' %} + +{% comment %} + Los artículos con layout `theme` contienen variables utilizadas por + Bootstrap que pueden ser redefinidas por les usuaries de Sutty a través + del panel. +{% endcomment %} +{% assign theme = site.posts | find: 'layout', 'theme' %} +{% assign theme_defaults = site.data.layouts.theme %} +{% comment %} + Ignorar estos campos que no son variables. +{% endcomment %} +{% assign ignored_keys = 'title,draft,order,last_modified_at,uuid,layout,liquid,usuaries' | split: ',' %} +{% comment %} + Cada variable de Bootstrap viene desde la definición de `theme`. Por + convención usamos snake_case, pero Bootstrap usa guión medio, así que + las convertimos. + + Utilizamos los valores que vengan del artículo y si no existen, usamos + los valores por defecto de sutty-base. De lo contrario mantenemos los + de Bootstrap. +{% endcomment %} +{% for variable in theme_defaults %} +{% assign key = variable[0] %} +{% if ignored_keys contains key %}{% continue %}{% endif %} +{% assign default_value = variable[1].default[site.locale] %} +{% assign variable_name = key | replace: '_', '-' %} +{% if theme[key] or default_value %} +{% comment %} + Generamos una definición de variable de SASS +{% endcomment %} +${{ variable_name }}: {{ theme[key] | default: default_value }}{{ variable[1].unit }}; +{% endif %} +{% endfor %} /// El modo debug se desactiva en producción $debug: {{ jekyll.environment | not: 'production' }}; @@ -22,21 +52,6 @@ $vendor-prefixes: ("", "-webkit-", "-ms-", "-o-", "-moz-"); /// para generar animaciones. $bezier: cubic-bezier(0.75, 0, 0.25, 1); -/// Redefinir la tipografía aquí, o borrar si usamos las de Bootstrap -$font-family-sans-serif: sans-serif; -/// El color primario de la paleta, se trae desde el `about` o sea usa -/// el color por defecto. -/// -/// @link _data/layouts/about.yml -$primary: {{ about.primary | default: site.data.layouts.about.primary.default[site.locale] }}; - -/// El color secundario de la paleta, se trae desde el `about` o sea usa -/// el color por defecto. -/// -/// @link _data/layouts/about.yml -$secondary: {{ about.secondary | default: site.data.layouts.about.secondary.default[site.locale] }}; - - /// Agregamos los colores propios de la plantilla aquí. Bootstrap los /// agrega a su propia paleta de colores. Si usamos el mismo nombre /// podemos redefinir el color. @@ -62,8 +77,8 @@ $spacers: ( 10: ($spacer * 8), 11: ($spacer * 9), 12: ($spacer * 10), - 13: ($spacer * 11), - 14: ($spacer * 12), + 13: ($spacer * 11), + 14: ($spacer * 12), 15: ($spacer * 13), ); @@ -99,6 +114,9 @@ $paragraph-margin-bottom: 0; $headings-margin-bottom: 0; $label-margin-bottom: 0; +///tipografías responsive +$enable-responsive-font-sizes: true; + /// Redefinir variables de Boostrap acá. Se las puede ver en /// node_modules/bootstrap/scss/_variables.scss /// @@ -117,6 +135,9 @@ $label-margin-bottom: 0; @import "snap"; @import "editor"; @import "menu"; +@import "content"; +@import "fonts"; +@import "floating_alert"; /// La barra de progreso de Turbo tiene el color primario /// de la paleta, definido por Bootstrap o por nosotres. diff --git a/assets/data/site.json b/assets/data/site.json new file mode 100644 index 0000000..991b49c --- /dev/null +++ b/assets/data/site.json @@ -0,0 +1,25 @@ +--- +--- + +{% comment %} +Genera un site.json con las traducciones del sitio y los datos de los +pasos del carrito. No los extraemos directamente con el filtro +`jsonify` porque extraen demasiada información y el JSON se rompe. + +TODO: El contenido de los pasos del carrito no aparece. +{% endcomment %} + +{%- assign steps = 'cart,shipment,payment,confirmation' | split: ',' -%} + +{ + {%- for step in steps %} + {%- assign step_page = site.posts | find: 'layout', step -%} + "{{ step }}": { + {%- for attribute in site.data.layouts[step] -%} + {%- assign attribute_key = attribute[0] -%} + "{{ attribute_key }}": {{ step_page[attribute_key] | jsonify }}{% unless forloop.last %},{% endunless %} + {%- endfor -%} + }, + {% endfor %} + "i18n": {{ site.i18n | jsonify }} +} diff --git a/assets/fonts/roboto/v27/KFOjCnqEu92Fr1Mu51TzBhc9-subset.woff2 b/assets/fonts/roboto/v27/KFOjCnqEu92Fr1Mu51TzBhc9-subset.woff2 new file mode 100644 index 0000000..72f9cbf Binary files /dev/null and b/assets/fonts/roboto/v27/KFOjCnqEu92Fr1Mu51TzBhc9-subset.woff2 differ diff --git a/assets/fonts/roboto/v27/KFOkCnqEu92Fr1MmgWxP-subset.woff2 b/assets/fonts/roboto/v27/KFOkCnqEu92Fr1MmgWxP-subset.woff2 new file mode 100644 index 0000000..bddfb4e Binary files /dev/null and b/assets/fonts/roboto/v27/KFOkCnqEu92Fr1MmgWxP-subset.woff2 differ diff --git a/assets/fonts/roboto/v27/KFOkCnqEu92Fr1Mu52xP-subset.woff2 b/assets/fonts/roboto/v27/KFOkCnqEu92Fr1Mu52xP-subset.woff2 new file mode 100644 index 0000000..f630b95 Binary files /dev/null and b/assets/fonts/roboto/v27/KFOkCnqEu92Fr1Mu52xP-subset.woff2 differ diff --git a/assets/fonts/roboto/v27/KFOlCnqEu92Fr1MmWUlvAw-subset.woff2 b/assets/fonts/roboto/v27/KFOlCnqEu92Fr1MmWUlvAw-subset.woff2 new file mode 100644 index 0000000..3ac3e9c Binary files /dev/null and b/assets/fonts/roboto/v27/KFOlCnqEu92Fr1MmWUlvAw-subset.woff2 differ diff --git a/assets/fonts/roboto/v27/KFOmCnqEu92Fr1Me5Q-subset.woff2 b/assets/fonts/roboto/v27/KFOmCnqEu92Fr1Me5Q-subset.woff2 new file mode 100644 index 0000000..4b12e9d Binary files /dev/null and b/assets/fonts/roboto/v27/KFOmCnqEu92Fr1Me5Q-subset.woff2 differ diff --git a/assets/templates/alert.html b/assets/templates/alert.html index b520708..2e3d840 100644 --- a/assets/templates/alert.html +++ b/assets/templates/alert.html @@ -1,7 +1,9 @@ - +{% endraw %} diff --git a/assets/templates/cart.html b/assets/templates/cart.html new file mode 100644 index 0000000..e7612e5 --- /dev/null +++ b/assets/templates/cart.html @@ -0,0 +1,67 @@ +{% raw %} +{% for product in products %} +
+
+
+ {{ product.title }} + +
+

{{ product.title }}

+

{{ product.extra | join: ', ' }}

+
+
+
+ +
+
+
+

+ {{ site.cart.price }}: + + {{ product.line_item.attributes.price }} {{ product.line_item.attributes.currency }} + +

+
+ +
+
+ + +
+
+ +
+

+ {{ site.cart.subtotal }}: + + {{ product.line_item.attributes.discounted_amount }} + + + {{ product.line_item.attributes.currency }} +

+
+ +
+ +
+
+
+
+{% endfor %} +{% endraw %} diff --git a/assets/templates/confirmation.html b/assets/templates/confirmation.html new file mode 100644 index 0000000..9efc5cf --- /dev/null +++ b/assets/templates/confirmation.html @@ -0,0 +1,67 @@ +{% raw %} +
+
+
{{ site.confirmation.number }}
+
{{ order.number }}
+ +
{{ site.confirmation.delivery }}
+
{{ shipping_address.address1 }}, {{ shipping_address.city }} ({{ shipping_address.zipcode }})
+ +
{{ site.confirmation.email }}
+
{{ order.email }}
+
+
+ +
+

{{ site.confirmation.your_order }}

+
+
+

{{ site.cart.product }}

+
+ +
+

{{ site.cart.price }}

+
+ +
+

{{ site.cart.subtotal }}

+
+ + {% for product in products %} +
+

{{ site.cart.product }}

+

{{ product.title }}

+

{{ product.extra | join: ',' }}

+
+ +
+

{{ site.cart.price }}

+

+ {{ product.line_item.attributes.price }} + {{ product.line_item.attributes.currency }} +

+
+ +
+

{{ site.cart.subtotal }}

+

+ {{ product.line_item.attributes.discounted_amount }} + {{ product.line_item.attributes.currency }} +

+
+ {% endfor %} + +
+

+ {{ site.cart.subtotal }} +

+
+ +
+

+ {{ order.total }} +

+
+
+
+{% endraw %} diff --git a/assets/templates/payment_methods.html b/assets/templates/payment_methods.html new file mode 100644 index 0000000..450b47f --- /dev/null +++ b/assets/templates/payment_methods.html @@ -0,0 +1,64 @@ +{% raw %} +
+ +
+ {% for payment_method in payment_methods %} +
+ + + +
+ {% endfor %} + +
+ + +
+
+ {{ site.i18n.cart.layouts.payment.promo_code }} + +
+ {% comment %} + Estos elementos pertenecen al formulario de cupones + {% endcomment %} +
+ + +
+
+ +
+ +
+
+
+ + +
+
+
+{% endraw %} diff --git a/assets/templates/recover_order.html b/assets/templates/recover_order.html new file mode 100644 index 0000000..29e372f --- /dev/null +++ b/assets/templates/recover_order.html @@ -0,0 +1,13 @@ +{% raw %} + +{% endraw %} diff --git a/assets/templates/results.html b/assets/templates/results.html index 2473966..31d7d80 100644 --- a/assets/templates/results.html +++ b/assets/templates/results.html @@ -1,17 +1,19 @@ -
-
- {% for item in results %} - + {% endfor %} +
+
+{% endraw %} diff --git a/assets/templates/shipping_methods.html b/assets/templates/shipping_methods.html new file mode 100644 index 0000000..a2e5d5e --- /dev/null +++ b/assets/templates/shipping_methods.html @@ -0,0 +1,38 @@ +{% raw %} +
+ + +
+ {% for shipping_rate in shipping_rates %} +
+
+ + + +
+
+ {% endfor %} + +
+ +
+
+
+{% endraw %} diff --git a/env.js b/env.js index 6210493..cdd0683 100644 --- a/env.js +++ b/env.js @@ -4,5 +4,50 @@ 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 }}' +}; + +{% comment %} +Genera un site.json con las traducciones del sitio y los datos de los +pasos del carrito. No los extraemos directamente con el filtro +`jsonify` porque extraen demasiada información y el JSON se rompe. +{% endcomment %} +{%- assign steps = 'cart,shipment,payment,confirmation' | split: ',' -%} + +window.site = { + {%- for step in steps -%} + "{{ step }}": { + {%- for attribute in site.data.layouts[step] -%} + {%- assign attribute_key = attribute[0] -%} + "{{ attribute_key }}": {{ site[step][attribute_key] | jsonify }}{% unless forloop.last %},{% endunless %} + {%- endfor -%} + }, + {%- endfor -%} + "i18n": {{ site.i18n | jsonify }} +}; + +{%- comment -%} + Para agregar plantillas que se procesan con JS, las agregamos en + Liquid dentro de assets/templates/ y luego las importamos acá, de forma + que estén disponibles para JS sin tener que descargarlas. + + Es importante que la plantilla esté envuelta por el tag `raw`, + para que no sea procesado en el momento de generar env.js, sino en el + navegador. +{%- endcomment -%} +{%- capture alert %}{% include_relative assets/templates/alert.html %}{% endcapture -%} +{%- capture results %}{% include_relative assets/templates/results.html %}{% endcapture -%} +{%- capture cart %}{% include_relative assets/templates/cart.html %}{% endcapture -%} +{%- capture payment_methods %}{% include_relative assets/templates/payment_methods.html %}{% endcapture -%} +{%- capture recover_order %}{% include_relative assets/templates/recover_order.html %}{% endcapture -%} +{%- capture shipping_methods %}{% include_relative assets/templates/shipping_methods.html %}{% endcapture -%} + +window.templates = { + "alert": {{ alert | jsonify }}, + "results": {{ results | jsonify }}, + "cart": {{ cart | jsonify }}, + "payment_methods": {{ payment_methods | jsonify }}, + "recover_order": {{ recover_order | jsonify }}, + "shipping_methods": {{ shipping_methods | jsonify }}, +}; diff --git a/package.json b/package.json index 895cf0f..9ccebe5 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,14 @@ "@babel/plugin-proposal-class-properties": "^7.10.4", "@babel/preset-env": "^7.10.4", "@hotwired/turbo": "^7.0.0-rc.4", + "@spree/storefront-api-v2-sdk": "~4.4", "axe-core": "^4.1.2", "babel-loader": "^8.1.0", "core-js": "^3.6.5", + "device-detector-js": "^2.2.10", "dotenv-webpack": "^6.0.0", "liquidjs": "^9.14.0", + "prettier": "^2.4.1", "regenerator-runtime": "^0.13.5", "sassdoc": "^2.7.3", "sassdoc-theme-herman": "^4.0.2", diff --git a/sutty-base-jekyll-theme.gemspec b/sutty-base-jekyll-theme.gemspec index 11f23b0..816a99e 100644 --- a/sutty-base-jekyll-theme.gemspec +++ b/sutty-base-jekyll-theme.gemspec @@ -68,7 +68,10 @@ 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' + spec.add_runtime_dependency 'jekyll-embed-urls', '~> 0' # Dependencias de desarrollo spec.add_development_dependency 'bundler', '~> 2.1' diff --git a/yarn.lock b/yarn.lock index d441339..cffdfb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -890,6 +890,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" @@ -1393,6 +1402,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" @@ -2519,6 +2535,11 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== +device-detector-js@^2.2.10: + version "2.2.10" + resolved "https://registry.yarnpkg.com/device-detector-js/-/device-detector-js-2.2.10.tgz#a8fd47837ce89024d7647a4ddf18154d7a920538" + integrity sha512-zLcDSU10WIqbARXecaVJJxx0ZuGWq+MVhj9f9qehdBCFr9RMa5mQGTt2IZNIgKuCIind/j/DzRDViEdc2FfBGQ== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -3050,7 +3071,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== @@ -4294,7 +4315,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== @@ -4783,6 +4804,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" @@ -5135,6 +5161,11 @@ prepend-http@^2.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= +prettier@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.4.1.tgz#671e11c89c14a4cfc876ce564106c4a6726c9f5c" + integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA== + process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -5247,6 +5278,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" @@ -5890,6 +5928,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"