From 990deb509d15915d9ef516d716c3e9d2d938ce48 Mon Sep 17 00:00:00 2001 From: f Date: Tue, 1 Jun 2021 18:33:49 -0300 Subject: [PATCH] tienda --- _config.yml | 1 + _data/en.yml | 19 ++ _data/es.yml | 19 ++ _data/forms/contacto.yml | 51 ++-- _data/forms/shipping_address.yml | 94 +++++++ _data/forms/user.yml | 13 + _data/layouts/cart.yml | 198 +++++++++++++ _data/layouts/confirmation.yml | 68 +++++ _data/layouts/email.yml | 56 ++++ _data/layouts/payment.yml | 114 ++++++++ _data/layouts/product.yml | 142 ++++++++++ _data/layouts/shipment.yml | 126 +++++++++ _includes/cart_add.html | 8 + _includes/cart_controller.html | 10 + _includes/country.html | 53 ++++ _includes/input.html | 16 +- _includes/postal_code.html | 45 +++ _includes/submit.html | 2 +- _layouts/cart.html | 72 +++++ _layouts/confirmation.html | 17 ++ _layouts/page.html | 5 + _layouts/payment.html | 21 ++ _layouts/shipment.html | 52 ++++ _packs/controllers/cart_base_controller.js | 249 +++++++++++++++++ .../cart_confirmation_controller.js | 39 +++ _packs/controllers/cart_contact_controller.js | 24 ++ _packs/controllers/cart_controller.js | 264 ++++++++++++++++++ _packs/controllers/cart_counter_controller.js | 25 ++ .../cart_payment_methods_controller.js | 89 ++++++ .../cart_paypal_confirmation_controller.js | 42 +++ .../controllers/cart_shipping_controller.js | 111 ++++++++ _packs/controllers/country_controller.js | 101 +++++++ _packs/controllers/order_controller.js | 68 +++++ _packs/controllers/postal_code_controller.js | 38 +++ _packs/controllers/state_controller.js | 91 ++++++ _packs/controllers/stock_controller.js | 71 +++++ _packs/endpoints/sutty.js | 80 ++++++ _packs/entry.js | 6 + env.js | 3 +- package.json | 1 + sutty-base-jekyll-theme.gemspec | 2 + yarn.lock | 41 ++- 42 files changed, 2516 insertions(+), 31 deletions(-) create mode 100644 _data/forms/shipping_address.yml create mode 100644 _data/forms/user.yml create mode 100644 _data/layouts/cart.yml create mode 100644 _data/layouts/confirmation.yml create mode 100644 _data/layouts/email.yml create mode 100644 _data/layouts/payment.yml create mode 100644 _data/layouts/product.yml create mode 100644 _data/layouts/shipment.yml create mode 100644 _includes/cart_add.html create mode 100644 _includes/cart_controller.html create mode 100644 _includes/country.html create mode 100644 _includes/postal_code.html create mode 100644 _layouts/cart.html create mode 100644 _layouts/confirmation.html create mode 100644 _layouts/page.html create mode 100644 _layouts/payment.html create mode 100644 _layouts/shipment.html create mode 100644 _packs/controllers/cart_base_controller.js create mode 100644 _packs/controllers/cart_confirmation_controller.js create mode 100644 _packs/controllers/cart_contact_controller.js create mode 100644 _packs/controllers/cart_controller.js create mode 100644 _packs/controllers/cart_counter_controller.js create mode 100644 _packs/controllers/cart_payment_methods_controller.js create mode 100644 _packs/controllers/cart_paypal_confirmation_controller.js create mode 100644 _packs/controllers/cart_shipping_controller.js create mode 100644 _packs/controllers/country_controller.js create mode 100644 _packs/controllers/order_controller.js create mode 100644 _packs/controllers/postal_code_controller.js create mode 100644 _packs/controllers/state_controller.js create mode 100644 _packs/controllers/stock_controller.js create mode 100644 _packs/endpoints/sutty.js diff --git a/_config.yml b/_config.yml index 651d41e..28289af 100644 --- a/_config.yml +++ b/_config.yml @@ -58,6 +58,7 @@ locales: - es ignored_layouts: - menu +- email linked_fields: - post - item diff --git a/_data/en.yml b/_data/en.yml index ddcde21..6bc7a3b 100644 --- a/_data/en.yml +++ b/_data/en.yml @@ -1,5 +1,19 @@ --- locale: English +countries: + validation: 'Choose a country from the list' +states: + validation: 'Choose a state from the list' +recover_order: 'Recover order' +alerts: + incorrect_password: "You're registered but your password is incorrect, you can try again or continue shopping as a guest." + successful_login: "You're logged in, your order will be added to your shopping history." + successful_signup: "Thanks for signing up!" + error: "There was an error contacting the store, please try again in a few minutes :(" + no_response_error: "There was an error contacting the store, please try again in a few minutes :(" + spree_error: "There was an error contacting the store, please try again in a few minutes :(" + recover_order: "We can't find your order" + select_format: "Choose a format:" date: format: '%m/%d/%Y' abbr_day_names: @@ -49,6 +63,11 @@ time: pm: pm layouts: post: Article + cart: Cart + confirmation: Order confirmation + payment: Payment + shipment: Shipment + email: Confirmation E-mail menu: Menu about: About this site menu: diff --git a/_data/es.yml b/_data/es.yml index 358e94b..8e14f53 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,6 +63,11 @@ time: pm: pm layouts: post: Artículo + cart: Carrito + confirmation: Confirmación de compra + payment: Pago + shipment: Envío + email: Correo de confirmación menu: Menú about: Información del sitio menu: 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..022177c --- /dev/null +++ b/_data/layouts/cart.yml @@ -0,0 +1,198 @@ +--- +meta: + limit: 1 + help: + es: 'Personalización de varios elementos del carrito. Si hay varios artículos, se toma el primero de la lista.' + en: 'Personalization for several cart elements. If there are several articles, the first in the list is used.' +title: + type: string + required: true + label: + es: Título + en: Title + help: + es: 'El título de la página del carrito de compras' + en: 'Page title for cart page' + default: + es: 'Carrito de compras' + en: 'Cart' +content: + type: 'content' + label: + es: 'Contenido' + en: 'Content' + help: + es: 'Texto que quieras agregar al carrito, forma de funcionamiento, anuncios, tiempos de entrega, etc.' + en: 'Any text you want to add to the cart: how it works, announcements, etc.' +product: + type: 'string' + required: true + label: + es: 'Nombre del ítem en venta' + en: 'Name of the item for sale' + help: + es: 'El nombre que tienen los productos o servicios que vendés, por ejemplo Libros' + en: "The name of the product or service you're selling, for example Books" + default: + es: 'Producto' + en: 'Product' +currency: + type: 'string' + required: true + label: + es: 'Moneda' + en: 'Currency' + help: + es: 'Código de tres letras de la moneda en la que se expresan los precios' + en: 'Three letter code to denote currency used in pricing' + default: + es: 'ARS' + en: 'USD' +currency_alternate: + type: 'string' + required: true + label: + es: 'Moneda alternativa' + en: 'Alternative currency' + help: + es: 'Nombre alternativo de la moneda, para mostrar, por ejemplo "$", "pesos", "pe", etc' + en: 'Alternative name of currency to be shown, for example "$", "dollars", etc' + default: + es: 'ARS' + en: 'USD' +price: + type: 'string' + required: true + label: + es: 'Precio' + en: 'Price' + help: + es: '' + en: '' + default: + es: 'Precio' + en: 'Price' +quantity: + type: 'string' + required: true + label: + es: 'Cantidad' + en: 'Quantity' + help: + es: 'Nombre del selector de cantidades' + en: 'Name of quantity selector' + default: + es: 'Cantidad' + en: 'Quantity' +subtotal: + type: 'string' + required: true + label: + es: 'Subtotal' + en: 'Subtotal' + help: + es: 'Nombre de la suma de precios por ítem de venta' + en: 'Name for the price sum per sale item' + default: + es: 'Subtotal' + en: 'Subtotal' +total: + type: 'string' + required: true + label: + es: 'Total' + en: 'Total' + help: + es: 'Nombre de la suma total del pedido' + en: 'Name of total sum' + default: + es: 'Total' + en: 'Total' +add: + type: 'string' + required: true + label: + es: 'Botón de agregar libro' + en: 'Add to cart button' + help: + es: 'El texto del botón de agregar al carrito' + en: 'Text on Add to cart button' + default: + es: 'Agregar al carrito' + en: 'Add to cart' +added: + type: 'string' + required: true + label: + es: 'Producto agregado' + en: 'Product added to cart' + help: + es: 'Texto para avisar que el producto fue agregado al carrito' + en: 'Text to notify when a product has been added to the cart' + default: + es: 'Agregado al carrito' + en: 'Product added to cart' +out_of_stock: + type: 'string' + required: true + label: + es: 'Sin stock' + en: 'Sold out' + help: + es: 'Texto del botón de agregar al carrito cuando el libro está agotado' + en: 'Text for grayed-out Add to cart button when no stock is available' + default: + es: 'Agotado' + en: 'Sold out' +remove: + type: 'string' + required: true + label: + es: 'Botón de quitar producto' + en: 'Remove product button' + help: + es: 'El texto del botón de quitar del carrito' + en: 'Text for Remove product button' + default: + es: 'Quitar del carrito' + en: 'Remove product' +back: + type: 'string' + required: true + label: + es: 'Volver a la tienda' + en: 'Back' + help: + es: 'Nombre del botón para volver a la tienda' + en: 'Text for Back to store button' + default: + es: 'Volver a la tienda' + en: 'Back' +permalink: + type: 'permalink' + required: true + label: + es: 'Dirección de la página' + en: '' + help: + es: 'La dirección de la página del carrito dentro del sitio' + en: 'Address for cart page inside site' + default: + es: 'carrito/' + en: 'cart/' +draft: + type: 'boolean' + label: + es: 'Borrador' + en: 'Draft' + help: + es: 'Este artículo aun no está listo para publicar' + en: "This post isn't ready to be published yet" +order: + type: 'order' + label: + es: 'Orden' + en: 'Order' + help: + es: 'La posición del artículo en la lista de artículos' + en: 'The post position in the posts list' 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/payment.yml b/_data/layouts/payment.yml new file mode 100644 index 0000000..2fd1705 --- /dev/null +++ b/_data/layouts/payment.yml @@ -0,0 +1,114 @@ +--- +meta: + limit: 1 + help: + en: 'Página de confirmación de pago. Si hay varias se usa la primera de la lista.' + es: 'Confirmation of payment page. If there are several, the first in list will be used.' +title: + type: string + required: true + label: + es: Título + en: Title + help: + es: 'El título de la página de medios de pago' + en: 'Title for payment options page' + default: + es: 'Medios de pago' + en: 'Payment options' +content: + type: 'content' + label: + es: 'Contenido' + en: 'Content' + help: + es: 'Puedes agregar texto opcional aquí' + en: 'You may add optional text here' +total: + type: 'string' + required: true + label: + es: 'Total' + en: 'Total' + help: + es: 'El precio total de la compra' + en: 'Total price' + default: + es: 'Total' + en: 'Total' +special_instructions: + type: 'string' + required: true + label: + es: 'Instrucciones especiales' + en: 'Special instructions' + help: + es: '' + en: 'Title text for Special instructions section' + default: + es: 'Instrucciones especiales' + en: 'Special instructions' +special_instructions_help: + type: 'string' + required: true + label: + es: 'Ayuda de las instrucciones especiales' + en: 'Help for special instructions section' + help: + es: '' + en: 'You might want to give examples of special instructions that can be added' + default: + es: 'Horas específicas de entrega, etc.' + en: 'Specific delivery hours, etc.' +permalink: + type: 'permalink' + required: true + label: + es: 'Dirección de la página' + en: 'Payment page address' + help: + es: 'La dirección de la página de medios de pago dentro del sitio' + en: 'The address the payment page hold within the site url' + default: + es: 'pago/' + en: 'payment/' +back: + type: 'string' + required: true + label: + es: 'Volver a métodos de envío' + en: 'Back to shipping options' + help: + es: 'Nombre del botón para volver a los métodos de envío' + en: 'Button text for Back to shipping options' + default: + es: 'Volver al métodos de envío' + en: 'Back to shipping options' +next_step: + type: 'string' + required: true + label: + es: 'Siguiente paso' + en: 'Next step' + help: + es: 'Texto del botón de pasar al siguiente paso de la compra (el pago)' + en: 'Text for next step in purchase button' + default: + es: 'Pagar' + en: 'Checkout' +draft: + type: 'boolean' + label: + es: 'Borrador' + en: 'Draft' + help: + es: 'Este artículo aun no está listo para publicar' + en: "This post isn't ready to be published yet" +order: + type: 'order' + label: + es: 'Orden' + en: 'Order' + help: + es: 'La posición del artículo en la lista de artículos' + en: 'The post position in the posts list' diff --git a/_data/layouts/product.yml b/_data/layouts/product.yml new file mode 100644 index 0000000..d14a7cb --- /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 milímetros' + en: 'In millimeters' +height: + type: 'number' + writable: 'once' + label: + es: 'Alto' + en: 'Height' + help: + es: 'En milímetros' + en: 'In millimeters' +depth: + type: 'number' + writable: 'once' + label: + es: 'Profundidad (Lomo)' + en: 'Depth' + help: + es: 'En milímetros' + en: 'In millimeters' +weight: + type: 'number' + required: true + writable: 'once' + label: + es: 'Peso' + en: 'Weight' + help: + es: 'En gramos, se utiliza para calcular el costo de envío' + en: 'In grams, used to calculate shipping rates' +order: + type: 'order' + label: + es: 'Orden' + en: 'Order' + help: + es: 'La posición del artículo en la lista de artículos' + en: 'The post position in the posts list' +draft: + type: 'boolean' + label: + es: 'Borrador' + en: 'Draft' + help: + es: 'Este artículo aun no está listo para publicar' + en: "This post isn't ready to be published yet" 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/_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..115249b --- /dev/null +++ b/_includes/cart_controller.html @@ -0,0 +1,10 @@ +data-controller="cart" +data-target="stock.product" +data-cart-url="{{ include.product.url }}" +data-cart-variant-id="{{ include.product.variant_id }}" +data-cart-image="{{ include.product.image.path | thumbnail: 212, 300 }}" +data-cart-title="{{ include.product.title }}" +data-cart-price="{{ include.product.price }}" +data-cart-in-stock="{{ include.product.in_stock }}" +data-cart-stock="{{ include.product.stock }}" +data-cart-extra="{{ include.extra | join: '|' }}" 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/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/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/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..265be89 --- /dev/null +++ b/_layouts/confirmation.html @@ -0,0 +1,17 @@ +--- +layout: default +--- + +
+
+

{{ page.title }}

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

+ + {{ page.back }} +
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..af3ff98 --- /dev/null +++ b/_layouts/payment.html @@ -0,0 +1,21 @@ +--- +layout: default +--- + +
+
+

{{ page.title }}

+
+ +
+ {{ 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..5458396 --- /dev/null +++ b/_packs/controllers/cart_base_controller.js @@ -0,0 +1,249 @@ +import { Controller } from 'stimulus' +import { Liquid } from 'liquidjs' + +/* + * Base controller, shared methods go here and other classes extend from + * this. + */ +export class CartBaseController extends Controller { + get spree () { + return window.spree + } + + /* + * Gets all products from storage + * + * @return [Array] + */ + get products () { + if (!this.cart) return [] + + return this.cart.data.relationships.variants.data.map(x => JSON.parse(this.storage.getItem(`cart:item:${x.id}`))).filter(x => x !== null) + } + + /* + * The storage + */ + get storage () { + return window.localStorage + } + + /* + * Temporary storage + */ + get storageTemp () { + return window.sessionStorage + } + + get cart () { + return JSON.parse(this.storage.getItem('cart')) + } + + set cart (response) { + this.storage.setItem('cart', JSON.stringify(response.success())) + } + + get email () { + return this.storageTemp.getItem('email') + } + + set email (email) { + this.storageTemp.setItem('email', email) + } + + /* + * The orderToken + * + * @return [String] + */ + get token () { + return this.storage.getItem('token') + } + + /* + * The bearerToken + * + * @return [String] + */ + get bearerToken () { + return this.storageTemp.getItem('bearerToken') + } + + set bearerToken (token) { + this.storageTemp.setItem('bearerToken', token) + } + + /* + * Liquid renderer + * + * @return Liquid + */ + get engine () { + if (!window.liquid) window.liquid = new Liquid() + + return window.liquid + } + + /* + * Site config (actually just translation strings) + * + * @return [Object] + */ + async site () { + if (!window.site) { + const data = await fetch('assets/data/site.json') + window.site = await data.json() + } + + return window.site + } + + /* + * Updates the item counter + */ + counterUpdate () { + const item_count = this.cart.data.attributes.item_count + + window.dispatchEvent(new CustomEvent('cart:counter', { detail: { item_count }})) + this.storage.setItem('cart:counter', item_count) + } + + /* + * Removes the brackets from the name or returns the name + * + * @return [String] + */ + idFromInputName (input) { + const matches = input.name.match(/\[([^\]]+)\]$/) + + return (matches === null) ? input.name : matches[1] + } + + async handleFailure (response) { + const data = { type: 'primary' } + let template = 'alert' + + if (!window.airbrake) window.airbrake.notify(response.fail()) + + const site = await this.site() + + switch (response.fail().name) { + case 'MisconfigurationError': + data.content = response.fail().message + break + case 'NoResponseError': + data.content = site.i18n.alerts.no_response_error + break + case 'SpreeError': + data.content = site.i18n.alerts.spree_error + break + case 'BasicSpreeError': + // XXX: The order is missing, we need to start a new one + if (response.fail().serverResponse.status === 404) { + template = 'recover_order' + data.content = site.i18n.alerts.recover_order + } else { + data.content = response.fail().summary + } + + break + case 'ExpandedSpreeError': + data.content = response.fail().summary + + break + default: + data.content = response.fail().message + } + + console.error(response.fail().name, data.content) + + window.dispatchEvent(new CustomEvent('notification', { detail: { template, data } })) + } + + set formDisabled (state) { + if (!this.hasFormTarget) return + + this.formTarget.elements.forEach(x => x.disabled = !!state) + } + + assignShippingAddress () { + if (!this.bearerToken) return + + const bearerToken = this.bearerToken + const orderToken = this.token + + this.spree.sutty.assignOrderShippingAddress({ bearerToken }, { orderToken }) + } + + notify (data = {}) { + window.dispatchEvent(new CustomEvent('notification', { detail: { template: 'alert', data } })) + } + + /* + * Visita una página con soporte para Turbolinks + * + * @param [String] URL + */ + visit (url) { + try { + Turbolinks.visit(url) + } catch { + window.location = url + } + } + + async firstAddress (bearerToken) { + if (!this._firstAddress) { + const addresses = await this.spree.account.addressesList({ bearerToken }) + + if (addresses.isFail()) { + this.handleFailure(addresses) + return + } + + // XXX: Asumimos que si se registró tiene una dirección que vamos + // a actualizar. + this._firstAddress = addresses.success().data[0] + } + + return this._firstAddress + } + + async updateAddress(bearerToken, id, address) { + const updateAddress = await this.spree.account.updateAddress({ bearerToken }, id, { address }) + + if (updateAddress.isFail()) { + this.handleFailure(updateAddress) + return + } + + return updateAddress.success() + } + + async shippingMethods(orderToken) { + const shippingMethods = await this.spree.checkout.shippingMethods({ orderToken }, { include: 'shipping_rates' }) + + if (shippingMethods.isFail()) { + this.handleFailure(shippingMethods) + return + } + + return shippingMethods.success() + } + + fireCajon (state = 'open', cajon = 'cajon') { + window.dispatchEvent(new CustomEvent('cajon', { detail: { cajon, state }})) + } + + formDataToObject (formData) { + const object = {} + + for (const field of formData) { + if (field[0].startsWith('_ignore_')) continue + + object[field[0]] = field[1] + } + + return object + } +} diff --git a/_packs/controllers/cart_confirmation_controller.js b/_packs/controllers/cart_confirmation_controller.js new file mode 100644 index 0000000..74e4d48 --- /dev/null +++ b/_packs/controllers/cart_confirmation_controller.js @@ -0,0 +1,39 @@ +import { CartBaseController } from './cart_base_controller' + +export default class extends CartBaseController { + static targets = [ 'order' ] + + async connect () { + if (this.clear) this.storage.clear() + + if (!this.template) return + + const order = this.cart.data.attributes + const products = this.products + const site = await this.site() + const shipping_address = JSON.parse(this.storage.getItem('shipping_address')) + + const data = { + order, + products, + site, + shipping_address + } + + this.render(data) + } + + render (data = {}) { + fetch(this.template).then(r => r.text()).then(template => { + this.engine.parseAndRender(template, data).then(html => this.orderTarget.innerHTML = html) + }) + } + + get template () { + return this.element.dataset.template + } + + get clear () { + return this.element.dataset.clear + } +} diff --git a/_packs/controllers/cart_contact_controller.js b/_packs/controllers/cart_contact_controller.js new file mode 100644 index 0000000..b663c3e --- /dev/null +++ b/_packs/controllers/cart_contact_controller.js @@ -0,0 +1,24 @@ +import { CartBaseController } from './cart_base_controller' + +export default class extends CartBaseController { + static targets = [ 'form', 'username' ] + + connect () { + if (!this.hasUsernameTarget) return + if (!this.hasFormTarget) return + + this.formTarget.addEventListener('focusout', event => { + if (!this.formTarget.checkValidity()) { + this.formTarget.classList.add('was-validated') + return + } + + this.formTarget.classList.remove('was-validated') + + const username = this.usernameTarget.value.trim() + if (username.length === 0) return + + this.email = username + }) + } +} diff --git a/_packs/controllers/cart_controller.js b/_packs/controllers/cart_controller.js new file mode 100644 index 0000000..e711bd8 --- /dev/null +++ b/_packs/controllers/cart_controller.js @@ -0,0 +1,264 @@ +import { CartBaseController } from './cart_base_controller' + +/* + * Manages the cart and its contents. + * + * We need to create an order in Spree API to get a token that allows to + * make changes to it. + * + * The order contains attributes for the order and other data can be + * included, like line items, variants, payments, promotions, shipments, + * billing and shipping addresses, and the user. + * + * Variants are products added to the cart. To remove an item or change + * its quantity, a line item for the variant must be found. We store + * this information into localStorage so we don't have to make annoying + * queries to JSON:API everytime. + */ + +export default class extends CartBaseController { + static targets = [ 'quantity', 'subtotal', 'addedQuantity' ] + + connect () { + if (!this.hasQuantityTarget) return + + /* + * When the quantity selector changes, we update the order to have + * that amount of items. + * + * TODO: Go back to previous amount if there's not enough. + */ + this.quantityTarget.addEventListener('change', async (event) => { + const quantity = event.target.value + + if (quantity < 1) return; + + const orderToken = await this.tokenGetOrCreate() + + event.target.disabled = true + + const response = await this.spree.cart.setQuantity({ orderToken }, { + line_item_id: this.product.line_item.id, + quantity, + include: 'line_items' + }) + + event.target.disabled = false + event.target.focus() + + // If we're failing here it could be due to a missing order, so we + // ask the user to decide what they want to do about it + if (response.isFail()) { + this.handleFailure(response) + return + } + + this.cart = response + this.subtotalUpdate() + this.counterUpdate() + await this.itemStore() + + if (!this.hasSubtotalTarget) return + + this.subtotalTarget.innerText = this.product.line_item.attributes.discounted_amount + }) + } + + subtotalUpdate () { + window.dispatchEvent(new Event('cart:subtotal:update')) + } + + /* + * Creates an order and stores the data into localStorage. + * + * @return [String] + */ + async cartCreate () { + const response = await this.spree.cart.create() + + // If we fail here it's probably a server error, so we inform the + // user. + if (response.isFail()) { + this.handleFailure(response) + return + } + + this.cart = response + this.storage.setItem('token', response.success().data.attributes.token) + + return this.token + } + + /* + * Gets the order token and creates a cart if it doesn't exist. + * + * @return [String] + */ + async tokenGetOrCreate () { + let token = this.storage.getItem('token') + + return token || await this.cartCreate() + } + + /* + * The variant ID is used to identify products + * + * @return [String] + */ + get variantId () { + return this.data.get('variantId') + } + + get product () { + return JSON.parse(this.storage.getItem(this.storageId)) + } + + /* + * Obtains the line_item_id by a variant_id by inspecting the cart and + * its included items + * + * @return [Object] + */ + findLineItem () { + const line_item = this.cart.included.find(x => (x.type === 'line_item' && x.relationships.variant.data.id == this.variantId)) + + return (line_item || {}) + } + + get storageId () { + return `cart:item:${this.variantId}` + } + + /* + * Stores an item for later usage. + * + * @see {./order_controller.js} + */ + itemStore () { + this.storage.setItem(this.storageId, JSON.stringify({ + variant_id: this.variantId, + line_item: this.findLineItem(), + image: this.data.get('image'), + title: this.data.get('title'), + url: this.data.get('url'), + stock: this.data.get('stock'), + in_stock: this.data.get('inStock'), + extra: this.data.get('extra') ? this.data.get('extra').split('|') : [] + })) + } + + /* + * Adds item to cart. This is meant to be used by an "Add to cart" + * button. If the item already exists in the cart it updates the + * quantity by +1. + * + * The item needs a variant ID to be added. + */ + async add(event, quantity = 1, floating_alert = true) { + const addedQuantity = this.addedQuantity() + if (addedQuantity > 1) quantity = addedQuantity + + const orderToken = await this.tokenGetOrCreate() + const response = await this.spree.cart.addItem({ orderToken }, { variant_id: this.variantId, quantity, include: 'line_items' }) + + if (response.isFail()) { + this.handleFailure(response) + return + } + + this.cart = response + this.itemStore() + this.counterUpdate() + this.fireCajon() + + if (floating_alert) { + const site = await this.site() + const content = site.cart.added + window.dispatchEvent(new CustomEvent('floating:alert', { detail: { content }})) + } + } + + /* + * Remove the element from the cart. It contacts the API and if the + * item is removed, it removes itself from the page and the storage. + */ + async remove () { + if (!this.product.line_item) return + + const orderToken = this.token + const response = await this.spree.cart.removeItem({ orderToken }, this.product.line_item.id, { include: 'line_items' }) + + if (response.isFail()) { + this.handleFailure(response) + return + } + + this.cart = response + this.storage.removeItem(this.storageId) + this.element.remove() + this.subtotalUpdate() + this.counterUpdate() + } + + /* + * Shows variants + */ + async variants () { + const template = 'variants' + const data = { + product: { + variant_id: this.data.get('variantId'), + digital_variant_id: this.data.get('digitalVariantId'), + image: this.data.get('image'), + title: this.data.get('title'), + price: this.data.get('price'), + digital_price: this.data.get('digitalPrice'), + in_stock: this.data.get('inStock'), + extra: this.data.get('extra').split('|') + } + } + + window.dispatchEvent(new CustomEvent('notification', { detail: { template, data } })) + } + + /* + * Recovers the order if something failed + */ + async recover () { + // Removes the failing token + this.storage.removeItem('token') + + // Stores the previous cart + const cart = this.cart + + // Get a new token and cart + await this.tokenGetOrCreate() + + // Add previous items and their quantities to the new cart by + // mimicking user's actions + // + // XXX: We don't use forEach because it's not async + for (const variant of cart.data.relationships.variants.data) { + this.data.set('variantId', variant.id) + + const product = this.product + + this.data.set('image', product.image) + this.data.set('title', product.title) + this.data.set('extra', product.extra.join('|')) + + await this.add(null, product.line_item.attributes.quantity, false) + } + } + + /* + * Si le compradore aumenta la cantidad antes de agregar + */ + addedQuantity () { + if (!this.hasAddedQuantityTarget) return 0 + + const addedQuantity = parseInt(this.addedQuantityTarget.value) + + return (isNaN(addedQuantity) ? 0 : addedQuantity) + } +} diff --git a/_packs/controllers/cart_counter_controller.js b/_packs/controllers/cart_counter_controller.js new file mode 100644 index 0000000..0db1abd --- /dev/null +++ b/_packs/controllers/cart_counter_controller.js @@ -0,0 +1,25 @@ +import { CartBaseController } from './cart_base_controller' + +export default class extends CartBaseController { + static targets = [ 'counter' ] + + connect () { + if (!this.hasCounterTarget) { + console.error("Missing counter target") + return + } + + window.addEventListener('cart:counter', event => this.counter = event.detail.item_count) + window.addEventListener('storage', event => { + if (event.key == 'cart:counter') this.counter = event.newValue + }) + + if (!this.cart) return + + this.counter = this.cart.data.attributes.item_count + } + + set counter (quantity) { + this.counterTarget.innerText = quantity + } +} diff --git a/_packs/controllers/cart_payment_methods_controller.js b/_packs/controllers/cart_payment_methods_controller.js new file mode 100644 index 0000000..e6b5217 --- /dev/null +++ b/_packs/controllers/cart_payment_methods_controller.js @@ -0,0 +1,89 @@ +import { CartBaseController } from './cart_base_controller' + +/* + * Retrieves payment methods and redirect to external checkouts + */ +export default class extends CartBaseController { + static targets = [ 'form', 'submit' ] + + async connect () { + const orderToken = this.token + const response = await this.spree.checkout.paymentMethods({ orderToken }) + + if (response.isFail()) { + this.handleFailure(response) + return + } + + const payment_methods = response.success().data + const site = await this.site() + const cart = this.cart + const next = { url: this.data.get('nextUrl') } + const back = { url: this.data.get('backUrl') } + + this.render({ payment_methods, site, cart, next, back }) + } + + async render (data = {}) { + const request = await fetch(this.data.get('template')) + const template = await request.text() + + this.element.innerHTML = await this.engine.parseAndRender(template, data) + + if (!this.hasSubmitTarget) return + this.formTarget.elements.forEach(p => p.addEventListener('change', e => this.submitTarget.disabled = false)) + } + + async pay (event) { + event.preventDefault() + event.stopPropagation() + + this.formDisabled = true + + const payment_method_id = this.formTarget.elements.payment_method_id.value + const orderToken = this.token + + // XXX: Currently SpreeClient expects us to send payment source + // attributes as if it were a credit card. + let response = await this.spree.checkout.orderUpdate({ orderToken }, + { + order: { payments_attributes: [{ payment_method_id }] }, + payment_source: { + [payment_method_id]: { + name: 'Pepitx', + month: 12, + year: 2020 + } + } + }) + + if (response.isFail()) { + this.handleFailure(response) + this.formDisabled = false + return + } + + this.cart = response + + response = await this.spree.checkout.complete({ orderToken }) + + if (response.isFail()) { + this.handleFailure(response) + this.formDisabled = false + return + } + + this.cart = response + + const checkoutUrls = await this.spree.sutty.getCheckoutURL({ orderToken }) + let redirectUrl = this.data.get('nextUrl') + + if (checkoutUrls.data.length > 0) redirectUrl = checkoutUrls.data[0] + + try { + Turbolinks.visit(redirectUrl) + } catch { + window.location = redirectUrl + } + } +} diff --git a/_packs/controllers/cart_paypal_confirmation_controller.js b/_packs/controllers/cart_paypal_confirmation_controller.js new file mode 100644 index 0000000..3410eb3 --- /dev/null +++ b/_packs/controllers/cart_paypal_confirmation_controller.js @@ -0,0 +1,42 @@ +import { CartBaseController } from './cart_base_controller' + +/* + * Replaces checkout.js. + * + * When the customer is redirected back from the approval URL, there's + * three params attached to the URL. We need paymentId and PayerID to + * execute the payment and later capture it via IPN. The token can be + * discarded. + */ +export default class extends CartBaseController { + async connect () { + if (this.params.PayerID === undefined) return + + this.site = await this.site() + this.element.innerHTML = this.site.i18n.cart.layouts.paypal.confirming + + fetch(this.executeURL) + .then(r => this.element.innerHTML = this.site.i18n.cart.layouts.paypal[(r.ok ? 'confirmed' : 'failure')]) + .catch(e => this.element.innerHTML = this.site.i18n.cart.layouts.paypal.failure) + } + + /* + * Convert URL params to Object + * + * @return [Object] + */ + get params () { + if (this._params) return this._params + + this._params = Object.fromEntries(decodeURIComponent(window.location.search.replace('?', '')).split('&').map(x => x.split('='))) + + return this._params + } + + /* + * URL to contact the store and execute the payment. + */ + get executeURL () { + return [ window.spree.host, 'paypal', 'execute', this.params.orderId, this.params.paymentId, this.params.PayerID ].join('/') + } +} diff --git a/_packs/controllers/cart_shipping_controller.js b/_packs/controllers/cart_shipping_controller.js new file mode 100644 index 0000000..d78d906 --- /dev/null +++ b/_packs/controllers/cart_shipping_controller.js @@ -0,0 +1,111 @@ +import { CartBaseController } from './cart_base_controller' + +export default class extends CartBaseController { + static targets = [ 'methods', 'rates', 'form' ] + + connect () { + this.formTarget.addEventListener('formdata', event => this.processShippingAddress(event.formData)) + } + + async rates (event) { + event.preventDefault() + event.stopPropagation() + + if (!this.formTarget.checkValidity()) { + this.adressTarget.classList.add('was-validated') + return + } + + this.formTarget.classList.remove('was-validated') + + // FormDataEvent es muy reciente + if (window.FormDataEvent) { + // Esto lanza el evento formdata en el formulario + new FormData(event.target) + } else { + // Fallback + this.processShippingAddress(new FormData(event.target)) + } + } + + payment (event) { + event.preventDefault() + event.stopPropagation() + + // FormDataEvent es muy reciente + if (window.FormDataEvent) { + // Esto lanza el evento formdata en el formulario + new FormData(event.target) + } else { + this.processShippingRate(new FormData(event.target)) + } + } + + async processShippingAddress (formData) { + this.formDisabled = true + + const email = this.email + const orderToken = this.token + + const ship_address_attributes = this.formDataToObject(formData) + const bill_address_attributes = ship_address_attributes + + const response = await this.spree.checkout.orderUpdate({ orderToken }, { order: { email, ship_address_attributes, bill_address_attributes }}) + + if (response.isFail()) { + this.handleFailure(response) + this.formDisabled = false + return + } + + const shippingMethods = await this.shippingMethods(orderToken) + + if (!shippingMethods) { + this.formDisabled = false + return + } + + const shipping_rates = shippingMethods.included.filter(x => x.type == 'shipping_rate') + // XXX: No hay varios paquetes + const shipping_method = shippingMethods.data[0] + const site = await this.site() + + await this.render({ shipping_method, shipping_rates, site }) + + const nextStep = document.querySelector(`#${this.element.dataset.scrollTo}`) + if (nextStep) nextStep.scrollIntoView() + } + + async processShippingRate (formData) { + const rate = this.formDataToObject(formData) + const orderToken = this.token + + // XXX: Deshabilitar el formulario después del evento FormData, de + // lo contrario el objeto queda vacío. + this.ratesTarget.elements.forEach(x => x.disabled = true) + + const response = await window.spree.checkout.orderUpdate({ orderToken }, { order: { shipments_attributes: [{ ...rate }] } }) + + if (response.isFail()) { + this.handleFailure(response) + return + } + + this.cart = response + + // Continue to next step + try { + Turbolinks.visit(this.data.get('next')) + } catch { + window.location = this.data.get('next') + } + } + + async render (data = {}) { + const request = await fetch(this.data.get('template')) + const template = await request.text() + + this.methodsTarget.innerHTML = await this.engine.parseAndRender(template, data) + this.ratesTarget.addEventListener('formdata', event => this.processShippingRate(event.formData)) + } +} diff --git a/_packs/controllers/country_controller.js b/_packs/controllers/country_controller.js new file mode 100644 index 0000000..3f8476f --- /dev/null +++ b/_packs/controllers/country_controller.js @@ -0,0 +1,101 @@ +import { CartBaseController } from './cart_base_controller' + +/* + * Populates a country field where users can type to filter and select + * from a predefined list. + */ +export default class extends CartBaseController { + // All are required! + static targets = [ 'id', 'iso', 'list', 'name' ] + + async connect () { + const countries = await this.countries() + + countries.forEach(country => { + const option = document.createElement('option') + + option.value = country.attributes.name + option.dataset.id = country.id + option.dataset.iso = country.attributes.iso + option.dataset.statesRequired = country.attributes.states_required + option.dataset.zipcodeRequired = country.attributes.zipcode_required + + this.listTarget.appendChild(option) + }) + + const site = await this.site() + + // Only allow names on this list + this.nameTarget.pattern = countries.map(x => x.attributes.name).join('|') + this.nameTarget.addEventListener('input', event => this.nameTarget.setCustomValidity('')) + this.nameTarget.addEventListener('invalid', event => this.nameTarget.setCustomValidity(site.i18n.countries.validation)) + + // When the input changes we update the actual value and also the + // state list via an Event + this.nameTarget.addEventListener('change', event => { + const value = this.nameTarget.value.trim() + + if (value === '') return + + const options = Array.from(this.nameTarget.list.options) + const option = options.find(x => x.value == value) + + // TODO: If no option is found, mark the field as invalid + if (!option) return + + this.idTarget.value = option.dataset.id + this.isoTarget.value = option.dataset.iso + + this.idTarget.dispatchEvent(new Event('change')) + this.isoTarget.dispatchEvent(new Event('change')) + + this.dispatchChangedEvent(option.dataset) + + // XXX: Prevent mixing data + delete this.nameTarget.dataset.selectedState + delete this.nameTarget.dataset.selectedZipcode + }) + + // The input is disabled at this point + this.nameTarget.disabled = false + // Load data if the input is autocompleted + if (this.nameTarget.value.trim() !== '') this.nameTarget.dispatchEvent(new CustomEvent('change')) + } + + /* + * Sends a `cart:country:update` event so other controllers can + * subscribe to changes. + */ + dispatchChangedEvent (data = {}) { + const event = new CustomEvent('cart:country:update', { + detail: { + id: this.idTarget.value, + iso: this.isoTarget.value, + group: this.data.get('group'), + selectedState: this.nameTarget.dataset.selectedState, + selectedZipcode: this.nameTarget.dataset.selectedZipcode, + data + } + }) + + window.dispatchEvent(event) + } + + /* + * Fetch the country list from storage or from API + */ + async countries () { + const countries = JSON.parse(this.storageTemp.getItem('countries')) + + if (countries) return countries + + const response = await this.spree.countries.list() + + // TODO: Show error message + if (!response.success()) return + + this.storageTemp.setItem('countries', JSON.stringify(response.success().data)) + + return response.success().data + } +} diff --git a/_packs/controllers/order_controller.js b/_packs/controllers/order_controller.js new file mode 100644 index 0000000..dea675f --- /dev/null +++ b/_packs/controllers/order_controller.js @@ -0,0 +1,68 @@ +import { CartBaseController } from './cart_base_controller' + +/* + * Renders the order table. All products are stored on localStorage, so + * we just fetch that information and render the cart contents using + * Liquid. + */ +export default class extends CartBaseController { + static targets = [ 'cart', 'subtotal', 'itemCount' ] + + async connect () { + const products = this.products + const site = await this.site() + + this.render({ products, site }) + this.subtotalUpdate() + this.itemCountUpdate() + this.subscribe() + } + + /* + * Subscribe to change on the storage to update the cart. + */ + subscribe () { + window.addEventListener('storage', async event => { + if (!event.key.startsWith('cart:item:')) return + + const products = this.products + const site = await this.site() + + this.render({ products, site }) + this.subtotalUpdate() + this.itemCountUpdate() + }) + + window.addEventListener('cart:subtotal:update', event => { + this.itemCountUpdate() + this.subtotalUpdate() + }) + } + + /* + * Download the item template and render the order + */ + render (data = {}) { + fetch(this.data.get('itemTemplate')).then(r => r.text()).then(template => { + this.engine.parseAndRender(template, data).then(html => this.cartTarget.innerHTML = html) + }) + } + + /* + * Updates the subtotal + * + * XXX: This also updates the currency + */ + subtotalUpdate () { + if (!this.cart) return + + this.subtotalTarget.innerText = this.cart.data.attributes.display_total + } + + itemCountUpdate () { + if (!this.hasItemCountTarget) return + if (!this.cart) return + + this.itemCountTarget.innerText = this.cart.data.attributes.item_count + } +} diff --git a/_packs/controllers/postal_code_controller.js b/_packs/controllers/postal_code_controller.js new file mode 100644 index 0000000..9a65db5 --- /dev/null +++ b/_packs/controllers/postal_code_controller.js @@ -0,0 +1,38 @@ +import { Controller } from 'stimulus' + +/* + * Subscribes to the country change event and changes the validation + * pattern of its input. + */ +export default class extends Controller { + static targets = [ 'code' ] + + /* + * Twitter CLDR is pretty big and we only need the postal codes + * patterns. + * + * @see {https://github.com/twitter/twitter-cldr-npm/blob/4388dfc55900b0feb80eafcac030f9f26981a41d/full/core.js#L1999} + */ + postal_codes = {"ad":"^AD\\d{3}$","am":"^(37)?\\d{4}$","ar":"^([A-HJ-NP-Z])?\\d{4}([A-Z]{3})?$","as":"^96799$","at":"^\\d{4}$","au":"^\\d{4}$","ax":"^22\\d{3}$","az":"^\\d{4}$","ba":"^\\d{5}$","bb":"^(BB\\d{5})?$","bd":"^\\d{4}$","be":"^\\d{4}$","bg":"^\\d{4}$","bh":"^((1[0-2]|[2-9])\\d{2})?$","bm":"^[A-Z]{2}[ ]?[A-Z0-9]{2}$","bn":"^[A-Z]{2}[ ]?\\d{4}$","br":"^\\d{5}[\\-]?\\d{3}$","by":"^\\d{6}$","ca":"^[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z][ ]?\\d[ABCEGHJ-NPRSTV-Z]\\d$","cc":"^6799$","ch":"^\\d{4}$","ck":"^\\d{4}$","cl":"^\\d{7}$","cn":"^\\d{6}$","cr":"^\\d{4,5}|\\d{3}-\\d{4}$","cs":"^\\d{5}$","cv":"^\\d{4}$","cx":"^6798$","cy":"^\\d{4}$","cz":"^\\d{3}[ ]?\\d{2}$","de":"^\\d{5}$","dk":"^\\d{4}$","do":"^\\d{5}$","dz":"^\\d{5}$","ec":"^([A-Z]\\d{4}[A-Z]|(?:[A-Z]{2})?\\d{6})?$","ee":"^\\d{5}$","eg":"^\\d{5}$","es":"^\\d{5}$","et":"^\\d{4}$","fi":"^\\d{5}$","fk":"^FIQQ 1ZZ$","fm":"^(9694[1-4])([ \\-]\\d{4})?$","fo":"^\\d{3}$","fr":"^\\d{2}[ ]?\\d{3}$","gb":"^GIR[ ]?0AA|((AB|AL|B|BA|BB|BD|BH|BL|BN|BR|BS|BT|CA|CB|CF|CH|CM|CO|CR|CT|CV|CW|DA|DD|DE|DG|DH|DL|DN|DT|DY|E|EC|EH|EN|EX|FK|FY|G|GL|GY|GU|HA|HD|HG|HP|HR|HS|HU|HX|IG|IM|IP|IV|JE|KA|KT|KW|KY|L|LA|LD|LE|LL|LN|LS|LU|M|ME|MK|ML|N|NE|NG|NN|NP|NR|NW|OL|OX|PA|PE|PH|PL|PO|PR|RG|RH|RM|S|SA|SE|SG|SK|SL|SM|SN|SO|SP|SR|SS|ST|SW|SY|TA|TD|TF|TN|TQ|TR|TS|TW|UB|W|WA|WC|WD|WF|WN|WR|WS|WV|YO|ZE)(\\d[\\dA-Z]?[ ]?\\d[ABD-HJLN-UW-Z]{2}))|BFPO[ ]?\\d{1,4}$","ge":"^\\d{4}$","gf":"^9[78]3\\d{2}$","gg":"^GY\\d[\\dA-Z]?[ ]?\\d[ABD-HJLN-UW-Z]{2}$","gl":"^39\\d{2}$","gn":"^\\d{3}$","gp":"^9[78][01]\\d{2}$","gr":"^\\d{3}[ ]?\\d{2}$","gs":"^SIQQ 1ZZ$","gt":"^\\d{5}$","gu":"^969[123]\\d([ \\-]\\d{4})?$","gw":"^\\d{4}$","hm":"^\\d{4}$","hn":"^(?:\\d{5})?$","hr":"^\\d{5}$","ht":"^\\d{4}$","hu":"^\\d{4}$","id":"^\\d{5}$","il":"^\\d{5}$","im":"^IM\\d[\\dA-Z]?[ ]?\\d[ABD-HJLN-UW-Z]{2}$","in":"^\\d{6}$","io":"^BBND 1ZZ$","iq":"^\\d{5}$","is":"^\\d{3}$","it":"^\\d{5}$","je":"^JE\\d[\\dA-Z]?[ ]?\\d[ABD-HJLN-UW-Z]{2}$","jo":"^\\d{5}$","jp":"^\\d{3}-\\d{4}$","ke":"^\\d{5}$","kg":"^\\d{6}$","kh":"^\\d{5}$","kr":"^\\d{3}[\\-]\\d{3}$","kw":"^\\d{5}$","kz":"^\\d{6}$","la":"^\\d{5}$","lb":"^(\\d{4}([ ]?\\d{4})?)?$","li":"^(948[5-9])|(949[0-7])$","lk":"^\\d{5}$","lr":"^\\d{4}$","ls":"^\\d{3}$","lt":"^\\d{5}$","lu":"^\\d{4}$","lv":"^\\d{4}$","ma":"^\\d{5}$","mc":"^980\\d{2}$","md":"^\\d{4}$","me":"^8\\d{4}$","mg":"^\\d{3}$","mh":"^969[67]\\d([ \\-]\\d{4})?$","mk":"^\\d{4}$","mn":"^\\d{6}$","mp":"^9695[012]([ \\-]\\d{4})?$","mq":"^9[78]2\\d{2}$","mt":"^[A-Z]{3}[ ]?\\d{2,4}$","mu":"^(\\d{3}[A-Z]{2}\\d{3})?$","mv":"^\\d{5}$","mx":"^\\d{5}$","my":"^\\d{5}$","nc":"^988\\d{2}$","ne":"^\\d{4}$","nf":"^2899$","ng":"^(\\d{6})?$","ni":"^((\\d{4}-)?\\d{3}-\\d{3}(-\\d{1})?)?$","nl":"^\\d{4}[ ]?[A-Z]{2}$","no":"^\\d{4}$","np":"^\\d{5}$","nz":"^\\d{4}$","om":"^(PC )?\\d{3}$","pf":"^987\\d{2}$","pg":"^\\d{3}$","ph":"^\\d{4}$","pk":"^\\d{5}$","pl":"^\\d{2}-\\d{3}$","pm":"^9[78]5\\d{2}$","pn":"^PCRN 1ZZ$","pr":"^00[679]\\d{2}([ \\-]\\d{4})?$","pt":"^\\d{4}([\\-]\\d{3})?$","pw":"^96940$","py":"^\\d{4}$","re":"^9[78]4\\d{2}$","ro":"^\\d{6}$","rs":"^\\d{6}$","ru":"^\\d{6}$","sa":"^\\d{5}$","se":"^\\d{3}[ ]?\\d{2}$","sg":"^\\d{6}$","sh":"^(ASCN|STHL) 1ZZ$","si":"^\\d{4}$","sj":"^\\d{4}$","sk":"^\\d{3}[ ]?\\d{2}$","sm":"^4789\\d$","sn":"^\\d{5}$","so":"^\\d{5}$","sz":"^[HLMS]\\d{3}$","tc":"^TKCA 1ZZ$","th":"^\\d{5}$","tj":"^\\d{6}$","tm":"^\\d{6}$","tn":"^\\d{4}$","tr":"^\\d{5}$","tw":"^\\d{3}(\\d{2})?$","ua":"^\\d{5}$","us":"^\\d{5}([ \\-]\\d{4})?$","uy":"^\\d{5}$","uz":"^\\d{6}$","va":"^00120$","ve":"^\\d{4}$","vi":"^008(([0-4]\\d)|(5[01]))([ \\-]\\d{4})?$","wf":"^986\\d{2}$","xk":"^\\d{5}$","yt":"^976\\d{2}$","yu":"^\\d{5}$","za":"^\\d{4}$","zm":"^\\d{5}$"} + + connect () { + window.addEventListener('cart:country:update', event => { + if (this.data.get('group') !== event.detail.group) return + + const zipcodeRequired = event.detail.data.zipcodeRequired == 'true' + + this.codeTarget.value = '' + this.codeTarget.disabled = !zipcodeRequired + this.codeTarget.required = zipcodeRequired + + if (!zipcodeRequired) return + + this.codeTarget.pattern = this.postal_codes[event.detail.iso.toLowerCase()] || '.*' + + if (event.detail.selectedZipcode) { + this.codeTarget.value = event.detail.selectedZipcode + this.codeTarget.dispatchEvent(new Event('change')) + } + }) + } +} diff --git a/_packs/controllers/state_controller.js b/_packs/controllers/state_controller.js new file mode 100644 index 0000000..b0f3949 --- /dev/null +++ b/_packs/controllers/state_controller.js @@ -0,0 +1,91 @@ +import { CartBaseController } from './cart_base_controller' + +/* + * Populates a state field where users can type to filter and select + * from a predefined list. It waits for an `cart:country:update` event + * to become populated. + */ +export default class extends CartBaseController { + // All are required! + static targets = [ 'id', 'list', 'name' ] + + connect () { + window.addEventListener('cart:country:update', async event => { + if (this.data.get('group') !== event.detail.group) return + + this.idTarget.value = '' + this.nameTarget.value = '' + this.listTarget.innerHTML = '' + + const statesRequired = event.detail.data.statesRequired == 'true' + + this.nameTarget.disabled = !statesRequired + this.nameTarget.required = statesRequired + + if (!statesRequired) return + + const states = await this.states(event.detail.iso) + const site = await this.site() + + states.forEach(state => { + let option = document.createElement('option') + option.value = state.attributes.name + option.dataset.id = state.id + + this.listTarget.appendChild(option) + }) + + this.nameTarget.pattern = states.map(x => x.attributes.name).join('|') + this.nameTarget.addEventListener('input', event => this.nameTarget.setCustomValidity('')) + this.nameTarget.addEventListener('invalid', event => this.nameTarget.setCustomValidity(site.i18n.states.validation)) + + if (event.detail.selectedState) { + this.nameTarget.value = event.detail.selectedState + this.nameTarget.dispatchEvent(new Event('change')) + } + }) + + // When the input changes we update the actual value and also the + // state list via an Event + this.nameTarget.addEventListener('change', event => { + const options = Array.from(this.listTarget.options) + const option = options.find(x => x.value == this.nameTarget.value) + + // TODO: If no option is found, mark the field as invalid + if (!option) return + + this.idTarget.value = option.dataset.id + this.idTarget.dispatchEvent(new Event('change')) + }) + } + + /* + * Fetch the state list from storage or from API using a country ISO + * code + */ + async states (countryIso) { + const stateId = `states:${countryIso}` + let states = JSON.parse(this.storageTemp.getItem(stateId)) + + if (states) return states + + // There's no state query, but we can fetch the country and include + // its states. + const response = await this.spree.countries.show(countryIso, { include: 'states' }) + + // TODO: Show error message + if (response.isFail()) { + this.handleFailure(response) + return {} + } + + states = response.success().included + + // Order alphabetically by name + states.sort((x, y) => x.attributes.name > y.attributes.name) + + this.storageTemp.setItem(stateId, JSON.stringify(states)) + + return states + } +} diff --git a/_packs/controllers/stock_controller.js b/_packs/controllers/stock_controller.js new file mode 100644 index 0000000..b8cea93 --- /dev/null +++ b/_packs/controllers/stock_controller.js @@ -0,0 +1,71 @@ +import { Controller } from 'stimulus' + +/* + * Mantiene el stock actualizado, consultando a la API. + * + * * Obtiene todas las variantes en el controlador + * * Consulta a la API + * * Actualiza stock y precio + * * Deshabilita botón si no está en stock + */ +export default class extends Controller { + static targets = [ 'product' ] + + async connect () { + if (this.variant_ids.length === 0) return + + const ids = this.variant_ids.join(',') + const filter = { ids } + let response = await window.spree.products.list({ filter }) + + // TODO: Gestionar errores + if (response.isFail()) { + console.error(response.fail()) + return + } + + this.update_local_products(response.success().data) + + // Recorrer todas las páginas + // XXX: Podríamos usar next pero la página 1 siempre se devuelve a + // sí misma y entraríamos en un loop infinito. + for (let page = 2; page <= response.success().meta.total_pages; page++) { + response = await window.spree.products.list({ filter, page }) + + // TODO: Gestionar errores + if (response.isFail()) { + console.error(response.fail()) + continue + } + + this.update_local_products(response.success().data) + } + } + + /* + * La lista de todas las variantes incluidas en el controlador que no + * estén vacías. + * + * @return [Array] + */ + get variant_ids () { + if (!this._variant_ids) this._variant_ids = [...new Set(this.productTargets.map(p=> p.dataset.cartVariantId).filter(x => x.length > 0))] + + return this._variant_ids + } + + /* + * Los productos pueden estar duplicados así que buscamos todos. + */ + update_local_products (products) { + for (const product of products) { + for (const local of this.productTargets.filter(local => local.dataset.cartVariantId === product.id)) { + local.dataset.cartInStock = product.attributes.in_stock + local.dataset.cartPrice = product.attributes.price + + local.querySelectorAll('[data-stock-add]').forEach(button => button.disabled = !product.attributes.in_stock) + local.querySelectorAll('[data-stock-price]').forEach(price => price.innerText = product.attributes.display_price) + } + } + } +} diff --git a/_packs/endpoints/sutty.js b/_packs/endpoints/sutty.js new file mode 100644 index 0000000..3b9c834 --- /dev/null +++ b/_packs/endpoints/sutty.js @@ -0,0 +1,80 @@ +import Axios from 'axios' +import * as qs from 'qs' + +/* + * XXX: We're copying code from @spree/storefront-api-v2-sdk/src/Http.ts + * because we don't know how to mix Typescript :D + */ +export class Sutty { + constructor (host = 'http://localhost:3000') { + this.host = host + + this.axios = Axios.create({ + baseURL: this.host, + headers: { 'Content-Type': 'application/json' }, + paramsSerializer: params => qs.stringify(params, { arrayFormat: 'brackets' }) + }) + } + + async getCheckoutURL(tokens = {}) { + const headers = this.spreeOrderHeaders(tokens) + const axiosConfig = { + url: 'api/v2/storefront/checkout_redirect.json', + params: {}, + method: 'get', + headers + } + + return await this.axios(axiosConfig) + } + + async assignOrderOwnership(tokens = {}, params = {}) { + const headers = this.spreeOrderHeaders(tokens) + const axiosConfig = { + url: 'api/v2/storefront/assign_order_ownership.json', + params, + method: 'post', + headers + } + + return await this.axios(axiosConfig) + } + + async assignOrderShippingAddress(tokens = {}, params = {}) { + const headers = this.spreeOrderHeaders(tokens) + const axiosConfig = { + url: 'api/v2/storefront/assign_order_shipping_address.json', + params, + method: 'post', + headers + } + + return await this.axios(axiosConfig) + } + + async assignOrderBillingAddress(tokens = {}, params = {}) { + const headers = this.spreeOrderHeaders(tokens) + const axiosConfig = { + url: 'api/v2/storefront/assign_order_billing_address.json', + params, + method: 'post', + headers + } + + return await this.axios(axiosConfig) + } + + spreeOrderHeaders(tokens) { + const header = {} + + if (tokens.orderToken) { + header['X-Spree-Order-Token'] = tokens.orderToken + } + + if (tokens.bearerToken) { + header['Authorization'] = `Bearer ${tokens.bearerToken}` + } + + return header + } +} diff --git a/_packs/entry.js b/_packs/entry.js index 44fd39e..d2be181 100644 --- a/_packs/entry.js +++ b/_packs/entry.js @@ -19,6 +19,12 @@ const application = Application.start() const context = require.context("./controllers", true, /\.js$/) application.load(definitionsFromContext(context)) +import { makeClient } from '@spree/storefront-api-v2-sdk' +import { Sutty } from './endpoints/sutty' + +window.spree = makeClient({ host: window.env.SPREE_URL }) +window.spree.sutty = new Sutty(window.spree.host) + // Prevenir que Turbolinks interfiera con la navegación por anchors // https://github.com/turbolinks/turbolinks/issues/75#issuecomment-445325162 document.addEventListener('turbolinks:click', event => { diff --git a/env.js b/env.js index 6210493..74d8c59 100644 --- a/env.js +++ b/env.js @@ -4,5 +4,6 @@ window.env = { AIRBRAKE_PROJECT_ID: {{ site.env.AIRBRAKE_PROJECT_ID | default: 0 }}, AIRBRAKE_PROJECT_KEY: '{{ site.env.AIRBRAKE_PROJECT_KEY }}', - JEKYLL_ENV: '{{ site.env.JEKYLL_ENV }}' + JEKYLL_ENV: '{{ site.env.JEKYLL_ENV }}', + SPREE_URL: '{{ site.env.SPREE_URL }}' } diff --git a/package.json b/package.json index ae1140a..6dcf2f0 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@babel/core": "^7.10.4", "@babel/plugin-proposal-class-properties": "^7.10.4", "@babel/preset-env": "^7.10.4", + "@spree/storefront-api-v2-sdk": "~4.4", "axe-core": "^4.1.2", "babel-loader": "^8.1.0", "core-js": "^3.6.5", diff --git a/sutty-base-jekyll-theme.gemspec b/sutty-base-jekyll-theme.gemspec index 11f23b0..d1e8b24 100644 --- a/sutty-base-jekyll-theme.gemspec +++ b/sutty-base-jekyll-theme.gemspec @@ -68,6 +68,8 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'jekyll-commonmark', '~> 1.3' spec.add_runtime_dependency 'jekyll-dotenv', '>= 0.2' spec.add_runtime_dependency 'jekyll-feed', '~> 0.15' + spec.add_runtime_dependency 'jekyll-spree-client', '~> 0' + spec.add_runtime_dependency 'jekyll-write-and-commit-changes', '~> 0' spec.add_runtime_dependency 'jekyll-ignore-layouts', '~> 0' # Dependencias de desarrollo diff --git a/yarn.lock b/yarn.lock index ca44c87..a033e23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -885,6 +885,15 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== +"@spree/storefront-api-v2-sdk@~4.4": + version "4.4.4" + resolved "https://registry.yarnpkg.com/@spree/storefront-api-v2-sdk/-/storefront-api-v2-sdk-4.4.4.tgz#c3527e9e08d7c436892bdece614ce1b66093b1bf" + integrity sha512-rkBBGYhnup0VGOltyQb+uycT9+M32LZpA9K5SYukvexbkCCCj0JqMJlY9ojirwsDh/q71icRZ+PAjDfypbbm6g== + dependencies: + axios "^0.21.1" + lodash "^4.17.20" + qs "^6.6.0" + "@stimulus/core@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@stimulus/core/-/core-1.1.1.tgz#42b0cfe5b73ca492f41de64b77a03980bae92c82" @@ -1383,6 +1392,13 @@ axe-core@^4.1.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.2.1.tgz#2e50bcf10ee5b819014f6e342e41e45096239e34" integrity sha512-evY7DN8qSIbsW2H/TWQ1bX3sXN1d4MNb5Vb4n7BzPuCwRHdkZ1H2eNLuSh73EoQqkGKUtju2G2HCcjCfhvZIAA== +axios@^0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + babel-loader@^8.1.0: version "8.2.2" resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.2.2.tgz#9363ce84c10c9a40e6c753748e1441b60c8a0b81" @@ -3004,7 +3020,7 @@ flush-write-stream@^1.0.0, flush-write-stream@^1.0.2: inherits "^2.0.3" readable-stream "^2.3.6" -follow-redirects@^1.0.0: +follow-redirects@^1.0.0, follow-redirects@^1.10.0: version "1.14.1" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43" integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg== @@ -4248,7 +4264,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.17.11, lodash@^4.17.14: +lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.20: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4732,6 +4748,11 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" +object-inspect@^1.9.0: + version "1.10.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369" + integrity sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw== + object-is@^1.0.1: version "1.1.5" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" @@ -5196,6 +5217,13 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== +qs@^6.6.0: + version "6.10.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" + integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg== + dependencies: + side-channel "^1.0.4" + querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -5839,6 +5867,15 @@ shebang-regex@^1.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"