├── Block ├── Adminhtml │ └── Form │ │ └── Field │ │ └── Discounts.php ├── AllInstallments.php └── Script.php ├── Helper └── Data.php ├── LICENSE ├── Model └── Config │ ├── ConfigProvider.php │ ├── Path.php │ └── Source │ ├── InterestType.php │ └── MaximumQuantityInstallments.php ├── README.md ├── composer.json ├── etc ├── acl.xml ├── adminhtml │ └── system.xml ├── config.xml └── module.xml ├── i18n └── pt_BR.csv ├── registration.php └── view └── frontend ├── layout ├── catalog_product_view.xml └── default.xml ├── requirejs-config.js ├── templates ├── all_installments.phtml └── script.phtml └── web ├── css └── source │ └── _module.less └── js ├── installment.js └── price-box-mixin.js /Block/Adminhtml/Form/Field/Discounts.php: -------------------------------------------------------------------------------- 1 | addColumn( 16 | 'name', 17 | [ 18 | 'label' => __('Name'), 19 | 'class' => 'required-entry' 20 | ] 21 | ); 22 | $this->addColumn( 23 | 'percentage', 24 | [ 25 | 'label' => __('Percentage'), 26 | 'class' => 'required-entry validate-currency-dollar' 27 | ] 28 | ); 29 | $this->_addAfter = false; 30 | $this->_addButtonLabel = __('Add'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Block/AllInstallments.php: -------------------------------------------------------------------------------- 1 | _helperData = $HelperData; 56 | parent::__construct( 57 | $context, 58 | $urlEncoder, 59 | $jsonEncoder, 60 | $stringUtils, 61 | $productHelper, 62 | $productTypeConfig, 63 | $localeFormat, 64 | $customerSession, 65 | $productRepository, 66 | $priceCurrency, 67 | $data 68 | ); 69 | } 70 | 71 | /** 72 | * @return bool 73 | */ 74 | public function renderAllInstallments(): bool 75 | { 76 | return $this->_helperData->showAllInstallments($this->getProduct()); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Block/Script.php: -------------------------------------------------------------------------------- 1 | _helperData = $HelperData; 29 | parent::__construct($Context, $Data); 30 | } 31 | 32 | /** 33 | * @return string 34 | */ 35 | public function getConfig(): string 36 | { 37 | return json_encode($this->_helperData->getConfig()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Helper/Data.php: -------------------------------------------------------------------------------- 1 | _configProvider = $ConfigProvider; 36 | $this->_priceCurrency = $PriceCurrency; 37 | } 38 | 39 | /** 40 | * @return array 41 | */ 42 | public function getConfig(): array 43 | { 44 | $config = []; 45 | $config['enabled'] = $this->_configProvider->isModuleEnable(); 46 | $config['interest_type'] = $this->_configProvider->getInterestType(); 47 | $config['maximum_quantity_installments'] = $this->_configProvider->getMaximumQuantityInstallments(); 48 | $config['minimum_installment_value'] = $this->_configProvider->getMinimumInstallmentValue(); 49 | $config['discounts'] = $this->_configProvider->getDiscounts(); 50 | $config['best_installment_in_cart'] = $this->_configProvider->bestInstallmentInCart(); 51 | $config['interest'] = []; 52 | $config['currency_symbol'] = $this->_priceCurrency->getCurrencySymbol(); 53 | $config['show_first_installment'] = $this->_configProvider->showFirstInstallment(); 54 | $config['templates'] = [ 55 | 'catalog_category_view' => $this->_configProvider->getPriceTemplate('catalog_category_view'), 56 | 'catalogsearch_result_index' => $this->_configProvider->getPriceTemplate('catalogsearch_result_index'), 57 | 'catalog_product_view' => $this->_configProvider->getPriceTemplate('catalog_product_view'), 58 | 'discount_template' => $this->_configProvider->getPriceTemplate('discount_template'), 59 | 'all_installment_template' => $this->_configProvider->getPriceTemplate('all_installment_template'), 60 | 'in_cart_template' => $this->_configProvider->getPriceTemplate('in_cart_template'), 61 | 'text_free_interest' => $this->_configProvider->getPriceTemplate('text_free_interest'), 62 | 'text_with_interest' => $this->_configProvider->getPriceTemplate('text_with_interest') 63 | ]; 64 | 65 | for ($i = 1; $i < 13; $i++) { 66 | $config['interest'][$i] = (double)$this->_configProvider->getInterestRate($i); 67 | } 68 | 69 | return $config; 70 | } 71 | 72 | /** 73 | * @param $product 74 | * @return bool 75 | */ 76 | public function showAllInstallments($product): bool 77 | { 78 | if (!$this->_configProvider->isModuleEnable() || !$this->_configProvider->showAllInstallments()) { 79 | return false; 80 | } 81 | 82 | if (!$product) { 83 | $this->_logger->alert(__('Missing product parameter on showAllInstallments()')); 84 | return false; 85 | } 86 | 87 | $productPrice = $this->getProductPrice($product); 88 | $minInstallment = $this->_configProvider->getMinimumInstallmentValue(); 89 | 90 | if (($productPrice < $minInstallment)) { 91 | return false; 92 | } 93 | 94 | return true; 95 | } 96 | 97 | /** 98 | * @param $product 99 | * @return float 100 | */ 101 | private function getProductPrice($product): float 102 | { 103 | return (float)($product->getMinimalPrice()) ? $product->getMinimalPrice() : $product->getPriceInfo()->getPrice('final_price')->getAmount()->getValue(); 104 | } 105 | 106 | /** 107 | * @return array 108 | */ 109 | public function getStyles(): array 110 | { 111 | return $this->_configProvider->getStyles(); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Backendorf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Model/Config/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | _configPath = $ConfigPath; 46 | $this->_scopeConfig = $ScopeConfig; 47 | $this->_unserialize = $Unserialize; 48 | $this->_serializerJson = $SerializerJson; 49 | } 50 | 51 | /** 52 | * @return bool 53 | */ 54 | public function isModuleEnable(): bool 55 | { 56 | return $this->_scopeConfig->isSetFlag($this->_configPath::MODULE_ENABLE); 57 | } 58 | 59 | /** 60 | * @return int 61 | */ 62 | public function getMaximumQuantityInstallments(): int 63 | { 64 | return (int)$this->_scopeConfig->getValue($this->_configPath::MAXIMUM_QUANTITY_INSTALLMENTS); 65 | } 66 | 67 | /** 68 | * @return float 69 | */ 70 | public function getMinimumInstallmentValue(): float 71 | { 72 | return (float)$this->_scopeConfig->getValue($this->_configPath::MINIMUM_INSTALLMENT_VALUE); 73 | } 74 | 75 | /** 76 | * @return bool 77 | */ 78 | public function showAllInstallments(): bool 79 | { 80 | return $this->_scopeConfig->isSetFlag($this->_configPath::SHOW_ALL_INSTALLMENTS_ON_PRODUCT_PAGE); 81 | } 82 | 83 | /** 84 | * @return string 85 | */ 86 | public function getInterestType(): string 87 | { 88 | return (string)$this->_scopeConfig->getValue($this->_configPath::INTEREST_TYPE); 89 | } 90 | 91 | /** 92 | * @param int $x 93 | * @return float|null 94 | */ 95 | public function getInterestRate(int $x): ?float 96 | { 97 | return (float)$this->_scopeConfig->getValue($this->_configPath::INSTALLMENT_INTEREST_X . 'interest_' . $x); 98 | } 99 | 100 | /** 101 | * @return array 102 | */ 103 | public function getDiscounts(): array 104 | { 105 | $value = $this->_scopeConfig->getValue($this->_configPath::DISCOUNTS); 106 | 107 | if (empty($value)) { 108 | return []; 109 | } 110 | 111 | if ($this->isSerialized($value)) { 112 | $unserializer = $this->_unserialize; 113 | } else { 114 | $unserializer = $this->_serializerJson; 115 | } 116 | 117 | return $unserializer->unserialize($value); 118 | } 119 | 120 | /** 121 | * @param $value 122 | * @return bool 123 | */ 124 | private function isSerialized($value): bool 125 | { 126 | return (boolean)preg_match('/^((s|i|d|b|a|O|C):|N;)/', $value); 127 | } 128 | 129 | /** 130 | * @return bool 131 | */ 132 | public function showFirstInstallment(): bool 133 | { 134 | return $this->_scopeConfig->isSetFlag($this->_configPath::SHOW_FIRST_INSTALLMENT); 135 | } 136 | 137 | /** 138 | * @return bool 139 | */ 140 | public function bestInstallmentInCart(): bool 141 | { 142 | return $this->_scopeConfig->isSetFlag($this->_configPath::BEST_INSTALLMENT_IN_CART); 143 | } 144 | 145 | /** 146 | * @param string $page 147 | * @return string 148 | */ 149 | public function getPriceTemplate(string $page): string 150 | { 151 | return (string)$this->_scopeConfig->getValue(str_replace('{{page}}', $page, $this->_configPath::PRICE_TEMPLATE)); 152 | } 153 | 154 | /** 155 | * @return array 156 | */ 157 | public function getStyles(): array 158 | { 159 | return [ 160 | 'primary-color' => $this->_scopeConfig->getValue($this->_configPath::PRIMARY_COLOR), 161 | 'highlight-text-color' => $this->_scopeConfig->getValue($this->_configPath::HIGHLIGHT_TEXT_COLOR), 162 | 'highlight-text-font-weight' => $this->_scopeConfig->getValue($this->_configPath::HIGLIGHT_TEXT_FONT_WEIGHT) 163 | ]; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /Model/Config/Path.php: -------------------------------------------------------------------------------- 1 | 'simple', 18 | 'label' => __('Simple') 19 | ], 20 | [ 21 | 'value' => 'compound', 22 | 'label' => __('Compound') 23 | ] 24 | ]; 25 | } 26 | 27 | /** 28 | * @return array 29 | */ 30 | public function toArray(): array 31 | { 32 | return [ 33 | 'simples' => __('Simple'), 34 | 'compound' => __('Compound') 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Model/Config/Source/MaximumQuantityInstallments.php: -------------------------------------------------------------------------------- 1 | '1', 19 | 'label' => __('1') 20 | ], 21 | [ 22 | 'value' => '2', 23 | 'label' => __('2') 24 | ], 25 | [ 26 | 'value' => '3', 27 | 'label' => __('3') 28 | ], 29 | [ 30 | 'value' => '4', 31 | 'label' => __('4') 32 | ], 33 | [ 34 | 'value' => '5', 35 | 'label' => __('5') 36 | ], 37 | [ 38 | 'value' => '6', 39 | 'label' => __('6') 40 | ], 41 | [ 42 | 'value' => '7', 43 | 'label' => __('7') 44 | ], 45 | [ 46 | 'value' => '8', 47 | 'label' => __('8') 48 | ], 49 | [ 50 | 'value' => '9', 51 | 'label' => __('9') 52 | ], 53 | [ 54 | 'value' => '10', 55 | 'label' => __('10') 56 | ], 57 | [ 58 | 'value' => '11', 59 | 'label' => __('11') 60 | ], 61 | [ 62 | 'value' => '12', 63 | 'label' => __('12') 64 | ] 65 | ]; 66 | } 67 | 68 | /** 69 | * @return array 70 | */ 71 | public function toArray(): array 72 | { 73 | return [ 74 | '1' => __('1'), 75 | '2' => __('2'), 76 | '3' => __('3'), 77 | '4' => __('4'), 78 | '5' => __('5'), 79 | '6' => __('6'), 80 | '7' => __('7'), 81 | '8' => __('8'), 82 | '9' => __('9'), 83 | '10' => __('10'), 84 | '11' => __('11'), 85 | '12' => __('12') 86 | ]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | Basically, this module makes it possible to display the installment values and discounts on the frontend. 3 | 4 | ## Installation 5 | > - `composer require backendorf/module-installment` 6 | > - `bin/magento setup:upgrade && bin/magento setup:di:compile` 7 | > - Navigate to "Shop->Configuration->Backendorf->Installment" and configure the options. 8 | > - Clear the cache. 9 | 10 | ## Prerequisites 11 | > - The module is compatible with Magento 2.x.x 12 | 13 | ## Dependencies 14 | > - Magento_Catalog 15 | 16 | # Features 17 | > - Maximum number of installments; 18 | > - Minimum installment value; 19 | > - Show all installment options on the product page; 20 | > - Show first installment; 21 | > - Interest calculation type (simple or compound); 22 | > - Percentage of interest on each installment (up to 12th); 23 | > - Show the lowest parcel in the cart; 24 | > - Multiple discounts; 25 | > - Configure price templates for the main catalog pages; 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backendorf/module-installment", 3 | "description": "Add installment feature to Magento 2", 4 | "type": "magento2-module", 5 | "minimum-stability": "dev", 6 | "require": {}, 7 | "autoload": { 8 | "psr-4": { 9 | "Backendorf\\Installment\\": "" 10 | }, 11 | "files": [ 12 | "registration.php" 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /etc/acl.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /etc/adminhtml/system.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 |
10 | 11 | backendorf 12 | Backendorf_Installment::config_backendorf_installment 13 | 14 | 15 | 17 | 18 | 19 | Magento\Config\Model\Config\Source\Yesno 20 | 21 | 23 | 24 | 25 | Backendorf\Installment\Model\Config\Source\MaximumQuantityInstallments 26 | 27 | 29 | 30 | required-entry validate-currency-dollar 31 | 32 | 33 | 35 | 36 | 37 | Magento\Config\Model\Config\Source\Yesno 38 | 39 | 41 | 42 | 43 | Magento\Config\Model\Config\Source\Yesno 44 | 45 | 47 | 48 | 49 | Magento\Config\Model\Config\Source\Yesno 50 | 51 | 52 | 53 | 54 | 56 | 57 | 58 | Backendorf\Installment\Model\Config\Source\InterestType 59 | 60 | 62 | 63 | required-entry validate-currency-dollar 64 | 65 | 66 | 68 | 69 | required-entry validate-currency-dollar 70 | 71 | 72 | 74 | 75 | required-entry validate-currency-dollar 76 | 77 | 78 | 80 | 81 | required-entry validate-currency-dollar 82 | 83 | 84 | 86 | 87 | required-entry validate-currency-dollar 88 | 89 | 90 | 92 | 93 | required-entry validate-currency-dollar 94 | 95 | 96 | 98 | 99 | required-entry validate-currency-dollar 100 | 101 | 102 | 104 | 105 | required-entry validate-currency-dollar 106 | 107 | 108 | 110 | 111 | required-entry validate-currency-dollar 112 | 113 | 114 | 116 | 117 | required-entry validate-currency-dollar 118 | 119 | 120 | 122 | 123 | required-entry validate-currency-dollar 124 | 125 | 126 | 127 | 128 | 129 | 131 | 132 | Backendorf\Installment\Block\Adminhtml\Form\Field\Discounts 133 | Magento\Config\Model\Config\Backend\Serialized\ArraySerialized 134 | 135 | 136 | 138 | 139 | 141 | 142 | required-entry 143 | 144 | 145 | 147 | 148 | required-entry 149 | 150 | 151 | 153 | 154 | required-entry 155 | 156 | 157 | 159 | 160 | required-entry 161 | 162 | 163 | 165 | 166 | required-entry 167 | 168 | 169 | 171 | 172 | required-entry 173 | 174 | 175 | 177 | 178 | required-entry 179 | 180 | 181 | 183 | 184 | required-entry 185 | 186 | 187 | 188 | 190 | 191 | 193 | 194 | required-entry 195 | 196 | 198 | 199 | required-entry 200 | 201 | 202 | 204 | 205 | required-entry 206 | 207 | 208 |
209 |
210 |
211 | -------------------------------------------------------------------------------- /etc/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 1 8 | 12 9 | 5 10 | 1 11 | 0 12 | 1 13 | 14 | 15 | 16 | 0 17 | 0 18 | 0 19 | 0 20 | 0 21 | 0 22 | 0 23 | 0 24 | 0 25 | 0 26 | 0 27 | 28 | 29 | 30 | 31 | {{qty}}X de {{value}}{{interest}} {{discounts}}]]> 32 | 33 | {{qty}}X de {{value}}{{interest}} {{discounts}}]]> 34 | 35 | {{qty}}X de {{value}}{{interest}} {{discounts}}]]> 36 | 37 | {{valueWithDiscount}} {{percentage}} no {{name}}]]> 38 | 39 | {{qty}}X de {{value}} {{interest}}]]> 40 | 41 | {{qty}}x de {{value}} {{interest}}]]> 42 | 43 | (taxa de {{rate}} = {{total_interest}}, totalizando {{amount}})]]> 44 | sem juros]]> 45 | 46 | 47 | #1979c3 48 | #1979c3 49 | 700 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /etc/module.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /i18n/pt_BR.csv: -------------------------------------------------------------------------------- 1 | Name,Nome 2 | Percentage,Percentual 3 | Add,Adicionar 4 | "Missing product parameter on showAllInstallments()","Missing product parameter on showAllInstallments()" 5 | Simple,Simples 6 | Compound,Composto 7 | 1,1 8 | 2,2 9 | 3,3 10 | 4,4 11 | 5,5 12 | 6,6 13 | 7,7 14 | 8,8 15 | 9,9 16 | 10,10 17 | 11,11 18 | 12,12 19 | "More installment options","Mais opções de parcelamento" 20 | Calculating...,Calculando... 21 | "The values presented are for consultation only, the real value of the parcel will be displayed at the end of the order.","Os valores apresentados são apenas para consulta, o valor real da parcela será exibido no fechamento do pedido." 22 | Backendorf,Backendorf 23 | Installment,Preços adicionais 24 | General,Geral 25 | Enabled,Habilitado 26 | "Maximum quantity installments","Quantidade máxima de parcelas" 27 | "Minimum installment value","Valor mínimo da parcela" 28 | "Show all installments on product page","Mostrar todas as opções de parcelamento na página de produto" 29 | "Show the first installment","Mostrar a primeira parcela" 30 | "Best installment in cart","Mostrar a parcela mais baixa no carrinho" 31 | Interest,Juros 32 | "Interest type","Tipo de juros" 33 | 2x,2x 34 | 3x,3x 35 | 4x,4x 36 | 5x,5x 37 | 6x,6x 38 | 7x,7x 39 | 8x,8x 40 | 9x,9x 41 | 10x,10x 42 | 11x,11x 43 | 12x,12x 44 | Discounts,Descontos 45 | Discount,Desconto 46 | "Price Templates","Templates de preço" 47 | "On Category Page","Na página de category" 48 | "On Search Results","Nos resultados de busca" 49 | "On Product View","Na página do produto" 50 | "Discount Template","Template de preço para descontos" 51 | "In Cart Template","Template de preço no carrinho" 52 | "All Installment Template","Template de preço para todas as opções de parcelamento" 53 | "Text with interest","Texto com juros" 54 | "Text free interest","Texto sem juros" 55 | Styles,Estilos 56 | "Primary color","Cor primária" 57 | "Highlight text color","Cor dos textos em destaque" 58 | "Highlight text font weight","Peso da fonte dos textos em destaque" 59 | -------------------------------------------------------------------------------- /registration.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /view/frontend/layout/default.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /view/frontend/requirejs-config.js: -------------------------------------------------------------------------------- 1 | var config = { 2 | map: { 3 | '*': { 4 | installment: 'Backendorf_Installment/js/installment' 5 | } 6 | }, 7 | config: { 8 | mixins: { 9 | 'Magento_Catalog/js/price-box': { 10 | 'Backendorf_Installment/js/price-box-mixin': true 11 | } 12 | } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /view/frontend/templates/all_installments.phtml: -------------------------------------------------------------------------------- 1 | renderAllInstallments()) { 7 | ?> 8 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | 24 | 25 | 26 |
27 |
28 | 31 | -------------------------------------------------------------------------------- /view/frontend/templates/script.phtml: -------------------------------------------------------------------------------- 1 | helper(Data::class)->getStyles(); 9 | ?> 10 | 17 | 24 | -------------------------------------------------------------------------------- /view/frontend/web/css/source/_module.less: -------------------------------------------------------------------------------- 1 | .installments { 2 | .best-installment { 3 | font-size: 14px; 4 | line-height: 16px; 5 | font-weight: 400; 6 | margin: 10px 0; 7 | 8 | strong { 9 | color: var(--bi-highlight-text-color); 10 | font-weight: var(--bi-highlight-text-font-weight); 11 | } 12 | } 13 | 14 | .discounts ul { 15 | list-style: none; 16 | padding: 0; 17 | 18 | li.item { 19 | strong { 20 | color: var(--bi-highlight-text-color); 21 | font-weight: var(--bi-highlight-text-font-weight); 22 | } 23 | } 24 | } 25 | } 26 | 27 | #installments-accordion { 28 | [data-role="collapsible"].active [data-role="trigger"] { 29 | border-radius: 4px 4px 0 0; 30 | } 31 | 32 | [data-role="trigger"] { 33 | display: block; 34 | background-color: var(--bi-primary-color); 35 | border-radius: 4px; 36 | color: @color-white; 37 | cursor: pointer; 38 | text-align: center; 39 | text-transform: uppercase; 40 | padding: 16px 32px; 41 | 42 | h5 { 43 | margin: 0; 44 | } 45 | } 46 | 47 | [data-role="content"] { 48 | border: 1px solid @color-gray-light5; 49 | padding: 16px; 50 | border-radius: 0 0 4px 4px; 51 | 52 | ul.options { 53 | list-style: none; 54 | padding: 0; 55 | font-size: 14px; 56 | line-height: 16px; 57 | 58 | li { 59 | &:not(:last-child) { 60 | margin-bottom: 10px; 61 | } 62 | 63 | strong { 64 | color: var(--bi-highlight-text-color); 65 | font-weight: var(--bi-highlight-text-font-weight); 66 | } 67 | } 68 | } 69 | 70 | .footer-message { 71 | color: @color-gray-light3; 72 | font-size: 10px; 73 | line-height: 12px; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /view/frontend/web/js/installment.js: -------------------------------------------------------------------------------- 1 | define([ 2 | "jquery", 3 | 'Magento_Catalog/js/price-utils', 4 | 'mage/translate', 5 | 'Magento_Customer/js/customer-data' 6 | ], function ($, priceUtils, $t, customerData) { 7 | 'use strict'; 8 | $.widget('mage.installment', { 9 | 'options': { 10 | 'discounts': [], 11 | 'interest_type': null, 12 | 'minimum_installment_value': null, 13 | 'maximum_quantity_installments': null, 14 | 'interest': null, 15 | 'best_installment_in_cart': null, 16 | 'currency_symbol': '', 17 | 'show_first_installment': false, 18 | 'templates': { 19 | 'in_cart_template': null, 20 | 'discount_template': null, 21 | 'catalog_category_view': null, 22 | 'catalog_product_view': null, 23 | 'catalogsearch_result_index': null, 24 | 'all_installment_template': null, 25 | 'text_with_interest': null, 26 | 'text_free_interest': null 27 | } 28 | }, 29 | _create: function () { 30 | let widget = this; 31 | 32 | if (this.options.enabled) { 33 | try { 34 | $('body').on('afterReloadPrice', function (e, data) { 35 | if (!$(data.element).hasClass('price-tier_price')) { 36 | widget.renderPrices(data.element, {amount: data.price.final}); 37 | 38 | if ($(data.element).parents('.product-info-price').length > 0) { 39 | widget.updateAllInstallments(data.element, {amount: data.price.final}); 40 | } 41 | } 42 | }).on('products-loaded', function () { 43 | $('.price-box.price-final_price').each(function (i, element) { 44 | widget.renderPrices(element); 45 | }); 46 | }); 47 | 48 | $('.price-box.price-final_price').each(function (i, element) { 49 | widget.renderPrices(element); 50 | if ($(element).parents('.product-info-price').length > 0) { 51 | widget.updateAllInstallments(element); 52 | } 53 | }); 54 | 55 | if (this.options.best_installment_in_cart && $('#cart-totals').length > 0) { 56 | widget.initPricesInCart(); 57 | require([ 58 | 'Magento_Checkout/js/model/quote' 59 | ], function (quote) { 60 | quote.totals.subscribe(function (data) { 61 | widget.initPricesInCart(data.grand_total); 62 | }); 63 | }); 64 | } 65 | } catch (e) { 66 | console.info(e); 67 | } 68 | } 69 | }, 70 | initPricesInCart: function (total = null) { 71 | $('#cart-totals .installments').remove(); 72 | total = (total) ? total : this.getTotal(); 73 | if (total) { 74 | let installments = this.getInstallments(total); 75 | if (installments) { 76 | let bestInstallment = this.getBestInstallment(installments); 77 | const template = this.options.templates.in_cart_template; 78 | 79 | let html = template.replace('{{qty}}', bestInstallment.installments_qty) 80 | .replace('{{value}}', bestInstallment.installment_value) 81 | .replace('{{interest}}', (this.renderInterest(bestInstallment))) 82 | $('#cart-totals').append('
' + html + '
'); 83 | } 84 | } 85 | 86 | }, 87 | /** 88 | * 89 | * @param productPrice 90 | * @returns {{}} 91 | */ 92 | getInstallments: function (productPrice) { 93 | let widget = this; 94 | const type_interest = this.options.interest_type; 95 | const info_interest = this.options.interest; 96 | const min_installment = this.options.minimum_installment_value; 97 | const max_installment = this.options.maximum_quantity_installments; 98 | 99 | let json_installments = {}; 100 | if (this.options.show_first_installment) { 101 | json_installments[1] = { 102 | 'installments_qty': 1, 103 | 'installment_value': widget.formatPrice(productPrice), 104 | 'total_installment': widget.formatPrice(productPrice), 105 | 'total_interest': 0, 106 | 'has_interest': 0 107 | }; 108 | } else { 109 | if ((productPrice < min_installment) || ((productPrice / 2) < min_installment)) { 110 | console.info('Installment not available for this product. Very low price:' + this.formatPrice(productPrice)); 111 | return null; 112 | } 113 | } 114 | 115 | let max_div = (productPrice / min_installment); 116 | 117 | if (max_div > max_installment) { 118 | max_div = max_installment; 119 | } else { 120 | if (max_div > 12) { 121 | max_div = 12; 122 | } 123 | } 124 | 125 | let limit = max_div; 126 | for (let installment_number in info_interest) { 127 | installment_number = parseInt(installment_number); 128 | if (installment_number <= max_div) { 129 | let rate = info_interest[installment_number] / 100; 130 | let amount = 0; 131 | let installment_value = 0; 132 | 133 | if (rate > 0) { 134 | if (type_interest === "compound") { 135 | amount = productPrice * Math.pow((1 + rate), installment_number); 136 | installment_value = amount / installment_number; 137 | } else { 138 | amount = productPrice + (productPrice * rate * installment_number); 139 | installment_value = (productPrice * (1 + rate * installment_number)) / installment_number; 140 | } 141 | 142 | let total_interest = amount - productPrice; 143 | 144 | if (installment_value > 5 && installment_value > min_installment) { 145 | json_installments[installment_number] = { 146 | 'installments_qty': installment_number, 147 | 'installment_value': widget.formatPrice(installment_value), 148 | 'total_interest': widget.formatPrice(total_interest), 149 | 'amount': widget.formatPrice(amount), 150 | 'has_interest': true, 151 | 'rate': info_interest[installment_number] + "%" 152 | }; 153 | } 154 | } else { 155 | if (productPrice > 0 && installment_number > 0) { 156 | json_installments[installment_number] = { 157 | 'installments_qty': installment_number, 158 | 'installment_value': widget.formatPrice((productPrice / installment_number)), 159 | 'total_interest': widget.formatPrice(0), 160 | 'amount': widget.formatPrice(productPrice), 161 | 'has_interest': false, 162 | 'rate': info_interest[installment_number] + "%" 163 | }; 164 | } 165 | } 166 | } 167 | } 168 | 169 | _.each(json_installments, function (key) { 170 | if (key > limit) { 171 | delete json_installments[key]; 172 | } 173 | }); 174 | 175 | return json_installments; 176 | }, 177 | /** 178 | * 179 | * @param installment 180 | * @returns {*} 181 | */ 182 | getBestInstallment: function (installment) { 183 | let best = null; 184 | for (let i = this.options.maximum_quantity_installments; i >= 1; i--) { 185 | if (installment[i]) { 186 | if (!best) { 187 | best = installment[i]; 188 | } 189 | 190 | if (!installment[i]['has_interest']) { 191 | return installment[i]; 192 | } 193 | } 194 | } 195 | 196 | return best; 197 | }, 198 | /** 199 | * 200 | * @param price 201 | * @returns {[]} 202 | */ 203 | getDiscounts: function (price) { 204 | for (let i in this.options.discounts) { 205 | let discount = this.options.discounts[i]; 206 | if (discount.hasOwnProperty('percentage')) { 207 | discount.value = this.formatPrice(price - ((price * discount.percentage) / 100)); 208 | this.options.discounts[i] = discount; 209 | } 210 | } 211 | return this.options.discounts; 212 | 213 | }, 214 | /** 215 | * 216 | * @param discounts 217 | * @returns {string} 218 | */ 219 | renderDiscounts: function (discounts) { 220 | let html = ''; 228 | return html; 229 | }, 230 | /** 231 | * 232 | * @param priceElement 233 | * @param prices 234 | */ 235 | updateAllInstallments: function (priceElement, prices = null) { 236 | let price = (prices) ? prices.amount : this.getElmPrice(priceElement); 237 | let installments = this.getInstallments(price); 238 | if (installments) { 239 | let template = this.options.templates.all_installment_template; 240 | 241 | let html = ''; 248 | $('#installments-accordion .all-installments-content').html(html); 249 | } 250 | }, 251 | /** 252 | * 253 | * @returns {null} 254 | */ 255 | getTemplate() { 256 | if ($('body.catalog-category-view').length > 0) { 257 | return this.options.templates.catalog_category_view; 258 | } else if ($('body.catalog-product-view').length > 0) { 259 | return this.options.templates.catalog_product_view; 260 | } else if ($('body.catalogsearch-result-index').length > 0) { 261 | return this.options.templates.catalogsearch_result_index; 262 | } else { 263 | return this.options.templates.catalog_product_view; 264 | } 265 | }, 266 | /** 267 | * 268 | * @param priceElement 269 | * @param prices 270 | */ 271 | renderPrices: function (priceElement, prices = null) { 272 | let template = this.getTemplate(); 273 | let price = (prices) ? prices.amount : this.getElmPrice(priceElement); 274 | 275 | if (price === 0) { 276 | return; 277 | } 278 | 279 | let installments = this.getInstallments(price); 280 | 281 | let installmentDiv = ($(priceElement).closest('.installments').length > 0) ? $(priceElement).closest('.installments') : null; 282 | if (installments) { 283 | let data = { 284 | 'bestInstallment': this.getBestInstallment(installments), 285 | 'discounts': this.renderDiscounts(this.getDiscounts(price)) 286 | }; 287 | 288 | template = template 289 | .replace('{{default}}', '
') 290 | .replace('{{qty}}', '
' + data.bestInstallment.installments_qty) 291 | .replace('{{value}}', data.bestInstallment.installment_value) 292 | .replace('{{interest}}', (this.renderInterest(data.bestInstallment)) + '
') 293 | .replace('{{discounts}}', '
' + data.discounts + '
'); 294 | if (installmentDiv) { 295 | $(priceElement).insertBefore($(installmentDiv)); 296 | $(installmentDiv).html(template); 297 | } else { 298 | installmentDiv = $('
' + template + '
'); 299 | installmentDiv.insertBefore(priceElement); 300 | } 301 | $(installmentDiv).find('.default').replaceWith(priceElement); 302 | $(installmentDiv).find(".best-installment, .discounts").show(); 303 | $(".wrap-collabsible.installments").show(); 304 | } else { 305 | $(installmentDiv).find(".best-installment, .discounts").hide(); 306 | $(".wrap-collabsible.installments").hide(); 307 | } 308 | }, 309 | /** 310 | * 311 | * @param elm 312 | * @returns {number} 313 | */ 314 | getElmPrice: function (elm) { 315 | let price = 0; 316 | price = ($(elm).find('.price-wrapper[data-price-type="finalPrice"]').length > 0) ? 317 | parseFloat($(elm).find('.price-wrapper[data-price-type="finalPrice"]').attr('data-price-amount')) : 0; 318 | 319 | if (price === 0) { 320 | price = ($(elm).find('.price-to .price-wrapper').length > 0) ? parseFloat($(elm).find('.price-to .price-wrapper').attr('data-price-amount')) : 0; 321 | } 322 | return price; 323 | }, 324 | /** 325 | * 326 | * @returns {number} 327 | */ 328 | getTotal: function () { 329 | let grandTotal = 0; 330 | let cartData = customerData.get('cart-data')(); 331 | 332 | if (cartData.totals && cartData.totals.base_grand_total) { 333 | grandTotal = parseFloat(cartData.totals.base_grand_total); 334 | } 335 | return grandTotal; 336 | }, 337 | /** 338 | * @param price 339 | * @returns {*} 340 | */ 341 | formatPrice: function (price) { 342 | let format = { 343 | decimalSymbol: ",", 344 | groupLength: 3, 345 | groupSymbol: ".", 346 | integerRequired: false, 347 | pattern: this.options.currency_symbol + "%s", 348 | precision: 2, 349 | requiredPrecision: 2 350 | }; 351 | 352 | format = (window.checkoutConfig && window.checkoutConfig.priceFormat) ? window.checkoutConfig.priceFormat : format; 353 | return priceUtils.formatPrice(price, format); 354 | }, 355 | renderInterest: function (installment) { 356 | let template = (installment.has_interest) ? this.options.templates.text_with_interest : this.options.templates.text_free_interest; 357 | template = template.replace('{{amount}}', installment.amount) 358 | .replace('{{total_interest}}', installment.total_interest) 359 | .replace('{{rate}}', installment.rate) 360 | return ' ' + template; 361 | } 362 | }); 363 | return $.mage.installment; 364 | }); 365 | -------------------------------------------------------------------------------- /view/frontend/web/js/price-box-mixin.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'jquery', 3 | 'Magento_Catalog/js/price-utils', 4 | 'underscore', 5 | 'mage/template', 6 | 'jquery/ui' 7 | ], function ($, utils, _, mageTemplate) { 8 | 'use strict'; 9 | 10 | return function (widget) { 11 | $.widget('mage.priceBox', widget, 12 | { 13 | reloadPrice: function reDrawPrices() { 14 | let priceFormat = (this.options.priceConfig && this.options.priceConfig.priceFormat) || {}, 15 | priceTemplate = mageTemplate(this.options.priceTemplate); 16 | 17 | _.each(this.cache.displayPrices, function (price, priceCode) { 18 | price.final = _.reduce(price.adjustments, function (memo, amount) { 19 | return memo + amount; 20 | }, price.amount); 21 | 22 | price.formatted = utils.formatPrice(price.final, priceFormat); 23 | 24 | $('[data-price-type="' + priceCode + '"]', this.element).html(priceTemplate({ 25 | data: price 26 | })); 27 | 28 | if (priceCode === 'finalPrice') { 29 | let element = this.element; 30 | $('body').trigger('afterReloadPrice', {price, element}); 31 | } 32 | }, this); 33 | } 34 | }); 35 | return $.mage.priceBox; 36 | } 37 | }); 38 | --------------------------------------------------------------------------------