summaryrefslogtreecommitdiff
path: root/addons/website_sale/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website_sale/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website_sale/static/src')
-rw-r--r--addons/website_sale/static/src/img/AZERTY.jpgbin0 -> 27543 bytes
-rw-r--r--addons/website_sale/static/src/img/accessory1.jpgbin0 -> 7136 bytes
-rw-r--r--addons/website_sale/static/src/img/accessory1_features.pngbin0 -> 68094 bytes
-rw-r--r--addons/website_sale/static/src/img/anywhere_anything.pngbin0 -> 157256 bytes
-rw-r--r--addons/website_sale/static/src/img/apps.pngbin0 -> 229328 bytes
-rw-r--r--addons/website_sale/static/src/img/bluetooth.jpgbin0 -> 14195 bytes
-rw-r--r--addons/website_sale/static/src/img/buds_closeup.pngbin0 -> 52725 bytes
-rw-r--r--addons/website_sale/static/src/img/design.pngbin0 -> 253345 bytes
-rw-r--r--addons/website_sale/static/src/img/imac1.pngbin0 -> 282790 bytes
-rw-r--r--addons/website_sale/static/src/img/imac2.pngbin0 -> 825682 bytes
-rw-r--r--addons/website_sale/static/src/img/ipad_experience.pngbin0 -> 198111 bytes
-rw-r--r--addons/website_sale/static/src/img/ipad_why.pngbin0 -> 316562 bytes
-rw-r--r--addons/website_sale/static/src/img/keyboard.pngbin0 -> 87402 bytes
-rw-r--r--addons/website_sale/static/src/img/mighty.pngbin0 -> 321536 bytes
-rw-r--r--addons/website_sale/static/src/img/more_features.pngbin0 -> 59933 bytes
-rw-r--r--addons/website_sale/static/src/img/overview_design_silver.pngbin0 -> 179281 bytes
-rw-r--r--addons/website_sale/static/src/img/overview_hero.pngbin0 -> 395471 bytes
-rw-r--r--addons/website_sale/static/src/img/planner_product_page.pngbin0 -> 331907 bytes
-rw-r--r--addons/website_sale/static/src/img/play_where_you_play.jpgbin0 -> 79148 bytes
-rw-r--r--addons/website_sale/static/src/img/promo_headphones.pngbin0 -> 7794 bytes
-rw-r--r--addons/website_sale/static/src/img/purple.pngbin0 -> 90150 bytes
-rw-r--r--addons/website_sale/static/src/img/redcross.pngbin0 -> 669 bytes
-rw-r--r--addons/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg99
-rw-r--r--addons/website_sale/static/src/img/snippets_thumbs/s_products_searchbar.svg40
-rw-r--r--addons/website_sale/static/src/img/snippets_thumbs/s_products_searchbar_inline.svg35
-rw-r--r--addons/website_sale/static/src/img/website_sale_chart_demo.pngbin0 -> 14944 bytes
-rw-r--r--addons/website_sale/static/src/img/website_sale_dashboard_sales_demo.pngbin0 -> 56319 bytes
-rw-r--r--addons/website_sale/static/src/img/wireless.pngbin0 -> 181057 bytes
-rw-r--r--addons/website_sale/static/src/js/tours/website_sale_shop.js75
-rw-r--r--addons/website_sale/static/src/js/tours/website_sale_shop_backend.js8
-rw-r--r--addons/website_sale/static/src/js/tours/website_sale_shop_frontend.js11
-rw-r--r--addons/website_sale/static/src/js/variant_mixin.js23
-rw-r--r--addons/website_sale/static/src/js/website_sale.editor.js698
-rw-r--r--addons/website_sale/static/src/js/website_sale.js827
-rw-r--r--addons/website_sale/static/src/js/website_sale_backend.js127
-rw-r--r--addons/website_sale/static/src/js/website_sale_form_editor.js28
-rw-r--r--addons/website_sale/static/src/js/website_sale_payment.js48
-rw-r--r--addons/website_sale/static/src/js/website_sale_recently_viewed.js243
-rw-r--r--addons/website_sale/static/src/js/website_sale_tracking.js113
-rw-r--r--addons/website_sale/static/src/js/website_sale_utils.js59
-rw-r--r--addons/website_sale/static/src/js/website_sale_validate.js51
-rw-r--r--addons/website_sale/static/src/js/website_sale_video_field_preview.js28
-rw-r--r--addons/website_sale/static/src/scss/primary_variables.scss1
-rw-r--r--addons/website_sale/static/src/scss/website_mail.scss48
-rw-r--r--addons/website_sale/static/src/scss/website_sale.editor.scss40
-rw-r--r--addons/website_sale/static/src/scss/website_sale.scss582
-rw-r--r--addons/website_sale/static/src/scss/website_sale_backend.scss72
-rw-r--r--addons/website_sale/static/src/scss/website_sale_dashboard.scss81
-rw-r--r--addons/website_sale/static/src/scss/website_sale_frontend.scss140
-rw-r--r--addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js58
-rw-r--r--addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml8
-rw-r--r--addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js109
-rw-r--r--addons/website_sale/static/src/snippets/s_products_searchbar/000.js136
-rw-r--r--addons/website_sale/static/src/xml/website_sale.xml11
-rw-r--r--addons/website_sale/static/src/xml/website_sale_dashboard.xml162
-rw-r--r--addons/website_sale/static/src/xml/website_sale_recently_viewed.xml49
-rw-r--r--addons/website_sale/static/src/xml/website_sale_utils.xml49
57 files changed, 4059 insertions, 0 deletions
diff --git a/addons/website_sale/static/src/img/AZERTY.jpg b/addons/website_sale/static/src/img/AZERTY.jpg
new file mode 100644
index 00000000..26f107a7
--- /dev/null
+++ b/addons/website_sale/static/src/img/AZERTY.jpg
Binary files differ
diff --git a/addons/website_sale/static/src/img/accessory1.jpg b/addons/website_sale/static/src/img/accessory1.jpg
new file mode 100644
index 00000000..145c5761
--- /dev/null
+++ b/addons/website_sale/static/src/img/accessory1.jpg
Binary files differ
diff --git a/addons/website_sale/static/src/img/accessory1_features.png b/addons/website_sale/static/src/img/accessory1_features.png
new file mode 100644
index 00000000..14492050
--- /dev/null
+++ b/addons/website_sale/static/src/img/accessory1_features.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/anywhere_anything.png b/addons/website_sale/static/src/img/anywhere_anything.png
new file mode 100644
index 00000000..22c0ac65
--- /dev/null
+++ b/addons/website_sale/static/src/img/anywhere_anything.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/apps.png b/addons/website_sale/static/src/img/apps.png
new file mode 100644
index 00000000..f2057b8c
--- /dev/null
+++ b/addons/website_sale/static/src/img/apps.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/bluetooth.jpg b/addons/website_sale/static/src/img/bluetooth.jpg
new file mode 100644
index 00000000..18e5681c
--- /dev/null
+++ b/addons/website_sale/static/src/img/bluetooth.jpg
Binary files differ
diff --git a/addons/website_sale/static/src/img/buds_closeup.png b/addons/website_sale/static/src/img/buds_closeup.png
new file mode 100644
index 00000000..4c842a5d
--- /dev/null
+++ b/addons/website_sale/static/src/img/buds_closeup.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/design.png b/addons/website_sale/static/src/img/design.png
new file mode 100644
index 00000000..ce8a21fb
--- /dev/null
+++ b/addons/website_sale/static/src/img/design.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/imac1.png b/addons/website_sale/static/src/img/imac1.png
new file mode 100644
index 00000000..59fc3de8
--- /dev/null
+++ b/addons/website_sale/static/src/img/imac1.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/imac2.png b/addons/website_sale/static/src/img/imac2.png
new file mode 100644
index 00000000..894ed174
--- /dev/null
+++ b/addons/website_sale/static/src/img/imac2.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/ipad_experience.png b/addons/website_sale/static/src/img/ipad_experience.png
new file mode 100644
index 00000000..387fc2d2
--- /dev/null
+++ b/addons/website_sale/static/src/img/ipad_experience.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/ipad_why.png b/addons/website_sale/static/src/img/ipad_why.png
new file mode 100644
index 00000000..11015696
--- /dev/null
+++ b/addons/website_sale/static/src/img/ipad_why.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/keyboard.png b/addons/website_sale/static/src/img/keyboard.png
new file mode 100644
index 00000000..308d5c55
--- /dev/null
+++ b/addons/website_sale/static/src/img/keyboard.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/mighty.png b/addons/website_sale/static/src/img/mighty.png
new file mode 100644
index 00000000..c9270a24
--- /dev/null
+++ b/addons/website_sale/static/src/img/mighty.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/more_features.png b/addons/website_sale/static/src/img/more_features.png
new file mode 100644
index 00000000..4cf5a8c6
--- /dev/null
+++ b/addons/website_sale/static/src/img/more_features.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/overview_design_silver.png b/addons/website_sale/static/src/img/overview_design_silver.png
new file mode 100644
index 00000000..50e37e9e
--- /dev/null
+++ b/addons/website_sale/static/src/img/overview_design_silver.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/overview_hero.png b/addons/website_sale/static/src/img/overview_hero.png
new file mode 100644
index 00000000..f6e4fd3e
--- /dev/null
+++ b/addons/website_sale/static/src/img/overview_hero.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/planner_product_page.png b/addons/website_sale/static/src/img/planner_product_page.png
new file mode 100644
index 00000000..ec8e6106
--- /dev/null
+++ b/addons/website_sale/static/src/img/planner_product_page.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/play_where_you_play.jpg b/addons/website_sale/static/src/img/play_where_you_play.jpg
new file mode 100644
index 00000000..0364ed84
--- /dev/null
+++ b/addons/website_sale/static/src/img/play_where_you_play.jpg
Binary files differ
diff --git a/addons/website_sale/static/src/img/promo_headphones.png b/addons/website_sale/static/src/img/promo_headphones.png
new file mode 100644
index 00000000..21bc935f
--- /dev/null
+++ b/addons/website_sale/static/src/img/promo_headphones.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/purple.png b/addons/website_sale/static/src/img/purple.png
new file mode 100644
index 00000000..b7f33aa4
--- /dev/null
+++ b/addons/website_sale/static/src/img/purple.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/redcross.png b/addons/website_sale/static/src/img/redcross.png
new file mode 100644
index 00000000..92a87401
--- /dev/null
+++ b/addons/website_sale/static/src/img/redcross.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg b/addons/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg
new file mode 100644
index 00000000..a9bf77fe
--- /dev/null
+++ b/addons/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg
@@ -0,0 +1,99 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="82" height="60" viewBox="0 0 82 60">
+ <defs>
+ <linearGradient id="linearGradient-1" x1="50%" x2="50%" y1="0%" y2="100%">
+ <stop offset="0%" stop-color="#00A09D"/>
+ <stop offset="100%" stop-color="#00E2FF"/>
+ </linearGradient>
+ <path id="path-2" d="M16 19v1H8v-1h8zm17 0v1h-9v-1h9zm16 0v1h-9v-1h9z"/>
+ <filter id="filter-3" width="102.4%" height="300%" x="-1.2%" y="-50%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.0995137675 0"/>
+ </filter>
+ <polygon id="path-4" points="0 8.954 5.571 11.28 5.571 4.714 0 2.571"/>
+ <filter id="filter-5" width="117.9%" height="123%" x="-9%" y="-5.7%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/>
+ </filter>
+ <polygon id="path-6" points="6.429 11.28 12 8.954 12 2.571 6.429 4.714"/>
+ <filter id="filter-7" width="117.9%" height="123%" x="-9%" y="-5.7%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.292012675 0"/>
+ </filter>
+ <polygon id="path-8" points="0 8.954 5.571 11.28 5.571 4.714 0 2.571"/>
+ <filter id="filter-9" width="117.9%" height="123%" x="-9%" y="-5.7%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/>
+ </filter>
+ <polygon id="path-10" points="6.429 11.28 12 8.954 12 2.571 6.429 4.714"/>
+ <filter id="filter-11" width="117.9%" height="123%" x="-9%" y="-5.7%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.292012675 0"/>
+ </filter>
+ <polygon id="path-12" points="0 8.954 5.571 11.28 5.571 4.714 0 2.571"/>
+ <filter id="filter-13" width="117.9%" height="123%" x="-9%" y="-5.7%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/>
+ </filter>
+ <polygon id="path-14" points="6.429 11.28 12 8.954 12 2.571 6.429 4.714"/>
+ <filter id="filter-15" width="117.9%" height="123%" x="-9%" y="-5.7%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.292012675 0"/>
+ </filter>
+ </defs>
+ <g fill="none" fill-rule="evenodd" class="snippets_thumbs">
+ <g class="s_products_recently_viewed">
+ <rect width="82" height="60" class="bg"/>
+ <g class="group" transform="translate(13 20)">
+ <path fill="url(#linearGradient-1)" d="M17.154 15v2H7v-2h10.154zm16.923 0v2h-11v-2h11zM51 15v2H38.308v-2H51z" class="combined_shape"/>
+ <g class="combined_shape">
+ <use fill="#000" filter="url(#filter-3)" xlink:href="#path-2"/>
+ <use fill="#FFF" fill-opacity=".348" xlink:href="#path-2"/>
+ </g>
+ <g class="box_solid" transform="translate(6)">
+ <rect width="12" height="11.143" class="rectangle"/>
+ <polygon fill="#FFF" fill-opacity=".78" points="6 .429 0 2.061 6 4.286 12 2.061" class="path"/>
+ <g class="path">
+ <use fill="#000" filter="url(#filter-5)" xlink:href="#path-4"/>
+ <use fill="#FFF" fill-opacity=".95" xlink:href="#path-4"/>
+ </g>
+ <g class="path">
+ <use fill="#000" filter="url(#filter-7)" xlink:href="#path-6"/>
+ <use fill="#FFF" fill-opacity=".78" xlink:href="#path-6"/>
+ </g>
+ </g>
+ <g class="box_solid" transform="translate(38)">
+ <rect width="12" height="11.143" class="rectangle"/>
+ <polygon fill="#FFF" fill-opacity=".78" points="6 .429 0 2.061 6 4.286 12 2.061" class="path"/>
+ <g class="path">
+ <use fill="#000" filter="url(#filter-9)" xlink:href="#path-8"/>
+ <use fill="#FFF" fill-opacity=".95" xlink:href="#path-8"/>
+ </g>
+ <g class="path">
+ <use fill="#000" filter="url(#filter-11)" xlink:href="#path-10"/>
+ <use fill="#FFF" fill-opacity=".78" xlink:href="#path-10"/>
+ </g>
+ </g>
+ <g class="box_solid" transform="translate(22)">
+ <rect width="12" height="11.143" class="rectangle"/>
+ <polygon fill="#FFF" fill-opacity=".78" points="6 .429 0 2.061 6 4.286 12 2.061" class="path"/>
+ <g class="path">
+ <use fill="#000" filter="url(#filter-13)" xlink:href="#path-12"/>
+ <use fill="#FFF" fill-opacity=".95" xlink:href="#path-12"/>
+ </g>
+ <g class="path">
+ <use fill="#000" filter="url(#filter-15)" xlink:href="#path-14"/>
+ <use fill="#FFF" fill-opacity=".78" xlink:href="#path-14"/>
+ </g>
+ </g>
+ <path fill="#FFF" stroke="#FFF" d="M1.5 4.793v4.414L-.707 7 1.5 4.793zm53-1L56.707 6 54.5 8.207V3.793z" class="combined_shape"/>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/addons/website_sale/static/src/img/snippets_thumbs/s_products_searchbar.svg b/addons/website_sale/static/src/img/snippets_thumbs/s_products_searchbar.svg
new file mode 100644
index 00000000..e6317a00
--- /dev/null
+++ b/addons/website_sale/static/src/img/snippets_thumbs/s_products_searchbar.svg
@@ -0,0 +1,40 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="82" height="60" viewBox="0 0 82 60">
+ <defs>
+ <linearGradient id="linearGradient-1" x1="50%" x2="50%" y1="0%" y2="100%">
+ <stop offset="0%" stop-color="#00A09D"/>
+ <stop offset="100%" stop-color="#00E2FF"/>
+ </linearGradient>
+ <path id="path-2" d="M62 13.5c0-.55-.196-1.022-.587-1.413A1.926 1.926 0 0 0 60 11.5c-.55 0-1.022.196-1.413.587A1.926 1.926 0 0 0 58 13.5c0 .55.196 1.022.587 1.413.391.391.862.587 1.413.587.55 0 1.022-.196 1.413-.587.391-.391.587-.862.587-1.413zm2 3.462a.517.517 0 0 1-.16.378.517.517 0 0 1-.378.16.5.5 0 0 1-.38-.16l-1.442-1.439a2.88 2.88 0 0 1-1.678.522 2.91 2.91 0 0 1-1.151-.233 2.961 2.961 0 0 1-.947-.631 2.961 2.961 0 0 1-.63-.947 2.91 2.91 0 0 1-.234-1.15c0-.402.078-.785.233-1.151a2.98 2.98 0 0 1 .631-.947c.266-.265.581-.475.947-.63a2.91 2.91 0 0 1 1.15-.234c.402 0 .785.078 1.151.233.366.156.682.366.947.631.265.266.475.581.63.947.156.366.234.75.234 1.15a2.88 2.88 0 0 1-.522 1.679l1.443 1.443c.104.104.156.23.156.379z"/>
+ <filter id="filter-3" width="114.3%" height="128.6%" x="-7.1%" y="-7.1%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/>
+ </filter>
+ <path id="path-4" d="M55 10v8H34v-8h21zm-1 1H35v6h19v-6z"/>
+ <filter id="filter-5" width="104.8%" height="125%" x="-2.4%" y="-6.2%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.292012675 0"/>
+ </filter>
+ </defs>
+ <g fill="none" fill-rule="evenodd" class="snippets_thumbs">
+ <g class="s_products_searchbar">
+ <rect width="82" height="60" class="bg"/>
+ <g class="group" transform="translate(0 16)">
+ <rect width="82" height="28" fill="url(#linearGradient-1)" class="rectangle" opacity=".4"/>
+ <g class="box_solid" transform="translate(18 9.5)">
+ <rect width="11" height="10" class="rectangle"/>
+ <path fill="#FFF" fill-opacity=".95" d="M0 2.174L5.077 4.1V10L0 7.91V2.174zm11 0V7.91L5.923 10V4.1L11 2.174zM5.5 0L11 1.472 5.5 3.478 0 1.472 5.5 0z" class="combined_shape"/>
+ </g>
+ <g class="search">
+ <use fill="#000" filter="url(#filter-3)" xlink:href="#path-2"/>
+ <use fill="#FFF" fill-opacity=".95" xlink:href="#path-2"/>
+ </g>
+ <g class="combined_shape">
+ <use fill="#000" filter="url(#filter-5)" xlink:href="#path-4"/>
+ <use fill="#FFF" fill-opacity=".78" xlink:href="#path-4"/>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/addons/website_sale/static/src/img/snippets_thumbs/s_products_searchbar_inline.svg b/addons/website_sale/static/src/img/snippets_thumbs/s_products_searchbar_inline.svg
new file mode 100644
index 00000000..bfa9a706
--- /dev/null
+++ b/addons/website_sale/static/src/img/snippets_thumbs/s_products_searchbar_inline.svg
@@ -0,0 +1,35 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="82" height="60" viewBox="0 0 82 60">
+ <defs>
+ <path id="path-1" d="M46 4.5c0-.55-.196-1.022-.587-1.413A1.926 1.926 0 0 0 44 2.5c-.55 0-1.022.196-1.413.587A1.926 1.926 0 0 0 42 4.5c0 .55.196 1.022.587 1.413.391.391.862.587 1.413.587.55 0 1.022-.196 1.413-.587.391-.391.587-.862.587-1.413zm2 3.462a.517.517 0 0 1-.16.378.517.517 0 0 1-.378.16.5.5 0 0 1-.38-.16L45.64 6.901a2.88 2.88 0 0 1-1.678.522 2.91 2.91 0 0 1-1.151-.233 2.961 2.961 0 0 1-.947-.631 2.961 2.961 0 0 1-.63-.947A2.91 2.91 0 0 1 41 4.462c0-.402.078-.785.233-1.151a2.98 2.98 0 0 1 .631-.947c.266-.265.581-.475.947-.63a2.91 2.91 0 0 1 1.15-.234c.402 0 .785.078 1.151.233.366.156.682.366.947.631.265.266.475.581.63.947.156.366.234.75.234 1.15a2.88 2.88 0 0 1-.522 1.679l1.443 1.443c.104.104.156.23.156.379z"/>
+ <filter id="filter-2" width="114.3%" height="128.6%" x="-7.1%" y="-7.1%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/>
+ </filter>
+ <path id="path-3" d="M37 1v8H16V1h21zm-1 1H17v6h19V2z"/>
+ <filter id="filter-4" width="104.8%" height="125%" x="-2.4%" y="-6.2%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
+ <feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.292012675 0"/>
+ </filter>
+ </defs>
+ <g fill="none" fill-rule="evenodd" class="snippets_thumbs">
+ <g class="s_products_searchbar_inline">
+ <rect width="82" height="60" class="bg"/>
+ <g class="group" transform="translate(17 25)">
+ <g class="box_solid" transform="translate(0 .5)">
+ <rect width="11" height="10" class="rectangle"/>
+ <path fill="#FFF" fill-opacity=".95" d="M0 2.174L5.077 4.1V10L0 7.91V2.174zm11 0V7.91L5.923 10V4.1L11 2.174zM5.5 0L11 1.472 5.5 3.478 0 1.472 5.5 0z" class="combined_shape"/>
+ </g>
+ <g class="search">
+ <use fill="#000" filter="url(#filter-2)" xlink:href="#path-1"/>
+ <use fill="#FFF" fill-opacity=".95" xlink:href="#path-1"/>
+ </g>
+ <g class="combined_shape">
+ <use fill="#000" filter="url(#filter-4)" xlink:href="#path-3"/>
+ <use fill="#FFF" fill-opacity=".78" xlink:href="#path-3"/>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/addons/website_sale/static/src/img/website_sale_chart_demo.png b/addons/website_sale/static/src/img/website_sale_chart_demo.png
new file mode 100644
index 00000000..0cb7a5cf
--- /dev/null
+++ b/addons/website_sale/static/src/img/website_sale_chart_demo.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/website_sale_dashboard_sales_demo.png b/addons/website_sale/static/src/img/website_sale_dashboard_sales_demo.png
new file mode 100644
index 00000000..4338a722
--- /dev/null
+++ b/addons/website_sale/static/src/img/website_sale_dashboard_sales_demo.png
Binary files differ
diff --git a/addons/website_sale/static/src/img/wireless.png b/addons/website_sale/static/src/img/wireless.png
new file mode 100644
index 00000000..a379ffcf
--- /dev/null
+++ b/addons/website_sale/static/src/img/wireless.png
Binary files differ
diff --git a/addons/website_sale/static/src/js/tours/website_sale_shop.js b/addons/website_sale/static/src/js/tours/website_sale_shop.js
new file mode 100644
index 00000000..6edb36dd
--- /dev/null
+++ b/addons/website_sale/static/src/js/tours/website_sale_shop.js
@@ -0,0 +1,75 @@
+odoo.define("website_sale.tour_shop", function (require) {
+ "use strict";
+
+ var core = require("web.core");
+ var _t = core._t;
+
+ // return the steps, used for backend and frontend
+
+ return [{
+ trigger: "#new-content-menu > a",
+ content: _t("Let's create your first product."),
+ extra_trigger: ".js_sale",
+ position: "bottom",
+ }, {
+ trigger: "a[data-action=new_product]",
+ content: _t("Select <b>New Product</b> to create it and manage its properties to boost your sales."),
+ position: "bottom",
+ }, {
+ trigger: ".modal-dialog #editor_new_product input[type=text]",
+ content: _t("Enter a name for your new product"),
+ position: "right",
+ }, {
+ trigger: ".modal-footer button.btn-primary.btn-continue",
+ content: _t("Click on <em>Continue</em> to create the product."),
+ position: "right",
+ }, {
+ trigger: ".product_price .oe_currency_value:visible",
+ extra_trigger: ".editor_enable",
+ content: _t("Edit the price of this product by clicking on the amount."),
+ position: "bottom",
+ run: "text 1.99",
+ }, {
+ trigger: "#wrap img.product_detail_img",
+ extra_trigger: ".product_price .o_dirty .oe_currency_value:not(:containsExact(1.00))",
+ content: _t("Double click here to set an image describing your product."),
+ position: "top",
+ run: function (actions) {
+ actions.dblclick();
+ },
+ }, {
+ trigger: ".o_select_media_dialog .o_upload_media_button",
+ content: _t("Upload a file from your local library."),
+ position: "bottom",
+ run: function (actions) {
+ actions.auto(".modal-footer .btn-secondary");
+ },
+ }, {
+ trigger: "button.o_we_add_snippet_btn",
+ auto: true,
+ }, {
+ trigger: "#snippet_structure .oe_snippet:eq(3) .oe_snippet_thumbnail",
+ extra_trigger: "body:not(.modal-open)",
+ content: _t("Drag this website block and drop it in your page."),
+ position: "bottom",
+ run: "drag_and_drop",
+ }, {
+ trigger: "button[data-action=save]",
+ content: _t("Once you click on <b>Save</b>, your product is updated."),
+ position: "bottom",
+ }, {
+ trigger: ".js_publish_management .js_publish_btn .css_publish",
+ extra_trigger: "body:not(.editor_enable)",
+ content: _t("Click on this button so your customers can see it."),
+ position: "bottom",
+ }, {
+ trigger: ".o_main_navbar .o_menu_toggle, #oe_applications .dropdown-toggle",
+ content: _t("Let's now take a look at your administration dashboard to get your eCommerce website ready in no time."),
+ position: "bottom",
+ }, { // backend
+ trigger: '.o_apps > a[data-menu-xmlid="website.menu_website_configuration"], #oe_main_menu_navbar a[data-menu-xmlid="website.menu_website_configuration"]',
+ content: _t("Open your website app here."),
+ extra_trigger: ".o_apps,#oe_applications",
+ position: "bottom",
+ }];
+});
diff --git a/addons/website_sale/static/src/js/tours/website_sale_shop_backend.js b/addons/website_sale/static/src/js/tours/website_sale_shop_backend.js
new file mode 100644
index 00000000..ab605852
--- /dev/null
+++ b/addons/website_sale/static/src/js/tours/website_sale_shop_backend.js
@@ -0,0 +1,8 @@
+odoo.define("website_sale.tour_shop_backend", function (require) {
+"use strict";
+
+var tour = require("web_tour.tour");
+var steps = require("website_sale.tour_shop");
+tour.register("shop", {url: "/shop"}, steps);
+
+});
diff --git a/addons/website_sale/static/src/js/tours/website_sale_shop_frontend.js b/addons/website_sale/static/src/js/tours/website_sale_shop_frontend.js
new file mode 100644
index 00000000..afefddef
--- /dev/null
+++ b/addons/website_sale/static/src/js/tours/website_sale_shop_frontend.js
@@ -0,0 +1,11 @@
+odoo.define("website_sale.tour_shop_frontend", function (require) {
+"use strict";
+
+var tour = require("web_tour.tour");
+var steps = require("website_sale.tour_shop");
+tour.register("shop", {
+ url: "/shop",
+ sequence: 130,
+}, steps);
+
+});
diff --git a/addons/website_sale/static/src/js/variant_mixin.js b/addons/website_sale/static/src/js/variant_mixin.js
new file mode 100644
index 00000000..0e92564b
--- /dev/null
+++ b/addons/website_sale/static/src/js/variant_mixin.js
@@ -0,0 +1,23 @@
+odoo.define('website_sale.VariantMixin', function (require) {
+'use strict';
+
+var VariantMixin = require('sale.VariantMixin');
+
+/**
+ * Website behavior is slightly different from backend so we append
+ * "_website" to URLs to lead to a different route
+ *
+ * @private
+ * @param {string} uri The uri to adapt
+ */
+VariantMixin._getUri = function (uri) {
+ if (this.isWebsite){
+ return uri + '_website';
+ } else {
+ return uri;
+ }
+};
+
+return VariantMixin;
+
+});
diff --git a/addons/website_sale/static/src/js/website_sale.editor.js b/addons/website_sale/static/src/js/website_sale.editor.js
new file mode 100644
index 00000000..b60d15f2
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale.editor.js
@@ -0,0 +1,698 @@
+odoo.define('website_sale.add_product', function (require) {
+'use strict';
+
+var core = require('web.core');
+var wUtils = require('website.utils');
+var WebsiteNewMenu = require('website.newMenu');
+
+var _t = core._t;
+
+WebsiteNewMenu.include({
+ actions: _.extend({}, WebsiteNewMenu.prototype.actions || {}, {
+ new_product: '_createNewProduct',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Actions
+ //--------------------------------------------------------------------------
+
+ /**
+ * Asks the user information about a new product to create, then creates it
+ * and redirects the user to this new product.
+ *
+ * @private
+ * @returns {Promise} Unresolved if there is a redirection
+ */
+ _createNewProduct: function () {
+ var self = this;
+ return wUtils.prompt({
+ id: "editor_new_product",
+ window_title: _t("New Product"),
+ input: _t("Name"),
+ }).then(function (result) {
+ if (!result.val) {
+ return;
+ }
+ return self._rpc({
+ route: '/shop/add_product',
+ params: {
+ name: result.val,
+ },
+ }).then(function (url) {
+ window.location.href = url;
+ return new Promise(function () {});
+ });
+ });
+ },
+});
+});
+
+//==============================================================================
+
+odoo.define('website_sale.editor', function (require) {
+'use strict';
+
+var options = require('web_editor.snippets.options');
+var publicWidget = require('web.public.widget');
+const {Class: EditorMenuBar} = require('web_editor.editor');
+const {qweb} = require('web.core');
+
+EditorMenuBar.include({
+ custom_events: Object.assign(EditorMenuBar.prototype.custom_events, {
+ get_ribbons: '_onGetRibbons',
+ get_ribbon_classes: '_onGetRibbonClasses',
+ delete_ribbon: '_onDeleteRibbon',
+ set_ribbon: '_onSetRibbon',
+ set_product_ribbon: '_onSetProductRibbon',
+ }),
+
+ /**
+ * @override
+ */
+ async willStart() {
+ const _super = this._super.bind(this);
+ let ribbons = [];
+ if (this._isProductListPage()) {
+ ribbons = await this._rpc({
+ model: 'product.ribbon',
+ method: 'search_read',
+ fields: ['id', 'html', 'bg_color', 'text_color', 'html_class'],
+ });
+ }
+ this.ribbons = Object.fromEntries(ribbons.map(ribbon => [ribbon.id, ribbon]));
+ this.originalRibbons = Object.assign({}, this.ribbons);
+ this.productTemplatesRibbons = [];
+ this.deletedRibbonClasses = '';
+ return _super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async save() {
+ const _super = this._super.bind(this);
+ await this._saveRibbons();
+ return _super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Saves the ribbons in the database.
+ *
+ * @private
+ */
+ async _saveRibbons() {
+ if (!this._isProductListPage()) {
+ return;
+ }
+ const originalIds = Object.keys(this.originalRibbons).map(id => parseInt(id));
+ const currentIds = Object.keys(this.ribbons).map(id => parseInt(id));
+
+ const ribbons = Object.values(this.ribbons);
+ const created = ribbons.filter(ribbon => !originalIds.includes(ribbon.id));
+ const deletedIds = originalIds.filter(id => !currentIds.includes(id));
+ const modified = ribbons.filter(ribbon => {
+ if (created.includes(ribbon)) {
+ return false;
+ }
+ const original = this.originalRibbons[ribbon.id];
+ return Object.entries(ribbon).some(([key, value]) => value !== original[key]);
+ });
+
+ const proms = [];
+ let createdRibbonIds;
+ if (created.length > 0) {
+ proms.push(this._rpc({
+ method: 'create',
+ model: 'product.ribbon',
+ args: [created.map(ribbon => {
+ ribbon = Object.assign({}, ribbon);
+ delete ribbon.id;
+ return ribbon;
+ })],
+ }).then(ids => createdRibbonIds = ids));
+ }
+
+ modified.forEach(ribbon => proms.push(this._rpc({
+ method: 'write',
+ model: 'product.ribbon',
+ args: [[ribbon.id], ribbon],
+ })));
+
+ if (deletedIds.length > 0) {
+ proms.push(this._rpc({
+ method: 'unlink',
+ model: 'product.ribbon',
+ args: [deletedIds],
+ }));
+ }
+
+ await Promise.all(proms);
+ const localToServer = Object.assign(
+ this.ribbons,
+ Object.fromEntries(created.map((ribbon, index) => [ribbon.id, {id: createdRibbonIds[index]}])),
+ {'false': {id: false}},
+ );
+
+ // Building the final template to ribbon-id map
+ const finalTemplateRibbons = this.productTemplatesRibbons.reduce((acc, {templateId, ribbonId}) => {
+ acc[templateId] = ribbonId;
+ return acc;
+ }, {});
+ // Inverting the relationship so that we have all templates that have the same ribbon to reduce RPCs
+ const ribbonTemplates = Object.entries(finalTemplateRibbons).reduce((acc, [templateId, ribbonId]) => {
+ if (!acc[ribbonId]) {
+ acc[ribbonId] = [];
+ }
+ acc[ribbonId].push(parseInt(templateId));
+ return acc;
+ }, {});
+ const setProductTemplateRibbons = Object.entries(ribbonTemplates)
+ // If the ribbonId that the template had no longer exists, remove the ribbon (id = false)
+ .map(([ribbonId, templateIds]) => {
+ const id = currentIds.includes(parseInt(ribbonId)) ? ribbonId : false;
+ return [id, templateIds];
+ }).map(([ribbonId, templateIds]) => this._rpc({
+ method: 'write',
+ model: 'product.template',
+ args: [templateIds, {'website_ribbon_id': localToServer[ribbonId].id}],
+ }));
+ return Promise.all(setProductTemplateRibbons);
+ },
+ /**
+ * Checks whether the current page is the product list.
+ *
+ * @private
+ */
+ _isProductListPage() {
+ return $('#products_grid').length !== 0;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns a copy of this.ribbons through a callback.
+ *
+ * @private
+ */
+ _onGetRibbons(ev) {
+ ev.data.callback(Object.assign({}, this.ribbons));
+ },
+ /**
+ * Returns all ribbon classes, current and deleted, so they can be removed.
+ *
+ * @private
+ */
+ _onGetRibbonClasses(ev) {
+ const classes = Object.values(this.ribbons).reduce((classes, ribbon) => {
+ return classes + ` ${ribbon.html_class}`;
+ }, '') + this.deletedRibbonClasses;
+ ev.data.callback(classes);
+ },
+ /**
+ * Deletes a ribbon.
+ *
+ * @private
+ */
+ _onDeleteRibbon(ev) {
+ this.deletedRibbonClasses += ` ${this.ribbons[ev.data.id].html_class}`;
+ delete this.ribbons[ev.data.id];
+ },
+ /**
+ * Sets a ribbon;
+ *
+ * @private
+ */
+ _onSetRibbon(ev) {
+ const {ribbon} = ev.data;
+ const previousRibbon = this.ribbons[ribbon.id];
+ if (previousRibbon) {
+ this.deletedRibbonClasses += ` ${previousRibbon.html_class}`;
+ }
+ this.ribbons[ribbon.id] = ribbon;
+ },
+ /**
+ * Sets which ribbon is used by a product template.
+ *
+ * @private
+ */
+ _onSetProductRibbon(ev) {
+ const {templateId, ribbonId} = ev.data;
+ this.productTemplatesRibbons.push({templateId, ribbonId});
+ },
+});
+
+publicWidget.registry.websiteSaleCurrency = publicWidget.Widget.extend({
+ selector: '.oe_website_sale',
+ disabledInEditableMode: false,
+ edit_events: {
+ 'click .oe_currency_value:o_editable': '_onCurrencyValueClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCurrencyValueClick: function (ev) {
+ $(ev.currentTarget).selectContent();
+ },
+});
+
+function reload() {
+ if (window.location.href.match(/\?enable_editor/)) {
+ window.location.reload();
+ } else {
+ window.location.href = window.location.href.replace(/\?(enable_editor=1&)?|#.*|$/, '?enable_editor=1&');
+ }
+}
+
+options.registry.WebsiteSaleGridLayout = options.Class.extend({
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.ppg = parseInt(this.$target.closest('[data-ppg]').data('ppg'));
+ this.ppr = parseInt(this.$target.closest('[data-ppr]').data('ppr'));
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onFocus: function () {
+ var listLayoutEnabled = this.$target.closest('#products_grid').hasClass('o_wsale_layout_list');
+ this.$el.filter('.o_wsale_ppr_submenu').toggleClass('d-none', listLayoutEnabled);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for params
+ */
+ setPpg: function (previewMode, widgetValue, params) {
+ const ppg = parseInt(widgetValue);
+ if (!ppg || ppg < 1) {
+ return false;
+ }
+ this.ppg = ppg;
+ return this._rpc({
+ route: '/shop/change_ppg',
+ params: {
+ 'ppg': ppg,
+ },
+ }).then(() => reload());
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ setPpr: function (previewMode, widgetValue, params) {
+ this.ppr = parseInt(widgetValue);
+ this._rpc({
+ route: '/shop/change_ppr',
+ params: {
+ 'ppr': this.ppr,
+ },
+ }).then(reload);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'setPpg': {
+ return this.ppg;
+ }
+ case 'setPpr': {
+ return this.ppr;
+ }
+ }
+ return this._super(...arguments);
+ },
+});
+
+options.registry.WebsiteSaleProductsItem = options.Class.extend({
+ xmlDependencies: (options.Class.prototype.xmlDependencies || []).concat(['/website_sale/static/src/xml/website_sale_utils.xml']),
+ events: _.extend({}, options.Class.prototype.events || {}, {
+ 'mouseenter .o_wsale_soptions_menu_sizes table': '_onTableMouseEnter',
+ 'mouseleave .o_wsale_soptions_menu_sizes table': '_onTableMouseLeave',
+ 'mouseover .o_wsale_soptions_menu_sizes td': '_onTableItemMouseEnter',
+ 'click .o_wsale_soptions_menu_sizes td': '_onTableItemClick',
+ }),
+
+ /**
+ * @override
+ */
+ willStart: async function () {
+ const _super = this._super.bind(this);
+ this.ppr = this.$target.closest('[data-ppr]').data('ppr');
+ this.productTemplateID = parseInt(this.$target.find('[data-oe-model="product.template"]').data('oe-id'));
+ this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
+ return _super(...arguments);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._resetRibbonDummy();
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ onFocus: function () {
+ var listLayoutEnabled = this.$target.closest('#products_grid').hasClass('o_wsale_layout_list');
+ this.$el.find('.o_wsale_soptions_menu_sizes')
+ .toggleClass('d-none', listLayoutEnabled);
+ // Ribbons may have been edited or deleted in another products' option, need to make sure they're up to date
+ this.rerender = true;
+ },
+ /**
+ * @override
+ */
+ onBlur: function () {
+ // Since changes will not be saved unless they are validated, reset the
+ // previewed ribbon onBlur to communicate that to the user
+ this._resetRibbonDummy();
+ this._toggleEditingUI(false);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ selectStyle(previewMode, widgetValue, params) {
+ const proms = [this._super(...arguments)];
+ if (params.cssProperty === 'background-color' && params.colorNames.includes(widgetValue)) {
+ // Reset text-color when choosing a background-color class, so it uses the automatic text-color of the class.
+ proms.push(this.selectStyle(previewMode, '', {applyTo: '.o_wsale_ribbon_dummy', cssProperty: 'color'}));
+ }
+ return Promise.all(proms);
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ async setRibbon(previewMode, widgetValue, params) {
+ if (previewMode === 'reset') {
+ widgetValue = this.prevRibbonId;
+ } else {
+ this.prevRibbonId = this.$target[0].dataset.ribbonId;
+ }
+ this.$target[0].dataset.ribbonId = widgetValue;
+ this.trigger_up('set_product_ribbon', {
+ templateId: this.productTemplateID,
+ ribbonId: widgetValue || false,
+ });
+ const ribbon = this.ribbons[widgetValue] || {html: '', bg_color: '', text_color: '', html_class: ''};
+ const $ribbons = $(`[data-ribbon-id="${widgetValue}"] .o_ribbon:not(.o_wsale_ribbon_dummy)`);
+ $ribbons.html(ribbon.html);
+ let htmlClasses;
+ this.trigger_up('get_ribbon_classes', {callback: classes => htmlClasses = classes});
+ $ribbons.removeClass(htmlClasses);
+
+ $ribbons.addClass(ribbon.html_class || '');
+ $ribbons.css('color', ribbon.text_color);
+ $ribbons.css('background-color', ribbon.bg_color || '');
+
+ if (!this.ribbons[widgetValue]) {
+ $(`[data-ribbon-id="${widgetValue}"]`).each((index, product) => delete product.dataset.ribbonId);
+ }
+ this._resetRibbonDummy();
+ this._toggleEditingUI(false);
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ editRibbon(previewMode, widgetValue, params) {
+ this.saveMethod = 'modify';
+ this._toggleEditingUI(true);
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ createRibbon(previewMode, widgetValue, params) {
+ this.saveMethod = 'create';
+ this.$ribbon.html('Ribbon text');
+ this.$ribbon.addClass('bg-primary o_ribbon_left');
+ this._toggleEditingUI(true);
+ this.isCreating = true;
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ async deleteRibbon(previewMode, widgetValue, params) {
+ if (this.isCreating) {
+ // Ribbon doesn't exist yet, simply discard.
+ this.isCreating = false;
+ this._resetRibbonDummy();
+ return this._toggleEditingUI(false);
+ }
+ const {ribbonId} = this.$target[0].dataset;
+ this.trigger_up('delete_ribbon', {id: ribbonId});
+ this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
+ this.rerender = true;
+ await this.setRibbon(false, ribbonId);
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ async saveRibbon(previewMode, widgetValue, params) {
+ const text = this.$ribbon.html().trim();
+ if (!text) {
+ return;
+ }
+ const ribbon = {
+ 'html': text,
+ 'bg_color': this.$ribbon[0].style.backgroundColor,
+ 'text_color': this.$ribbon[0].style.color,
+ 'html_class': this.$ribbon.attr('class').split(' ')
+ .filter(c => !['d-none', 'o_wsale_ribbon_dummy', 'o_ribbon'].includes(c))
+ .join(' '),
+ };
+ ribbon.id = this.saveMethod === 'modify' ? parseInt(this.$target[0].dataset.ribbonId) : Date.now();
+ this.trigger_up('set_ribbon', {ribbon: ribbon});
+ this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
+ this.rerender = true;
+ await this.setRibbon(false, ribbon.id);
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ setRibbonHtml(previewMode, widgetValue, params) {
+ this.$ribbon.html(widgetValue);
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ setRibbonMode(previewMode, widgetValue, params) {
+ this.$ribbon[0].className = this.$ribbon[0].className.replace(/o_(ribbon|tag)_(left|right)/, `o_${widgetValue}_$2`);
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ setRibbonPosition(previewMode, widgetValue, params) {
+ this.$ribbon[0].className = this.$ribbon[0].className.replace(/o_(ribbon|tag)_(left|right)/, `o_$1_${widgetValue}`);
+ },
+ /**
+ * @see this.selectClass for params
+ */
+ changeSequence: function (previewMode, widgetValue, params) {
+ this._rpc({
+ route: '/shop/change_sequence',
+ params: {
+ id: this.productTemplateID,
+ sequence: widgetValue,
+ },
+ }).then(reload);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ updateUI: async function () {
+ await this._super.apply(this, arguments);
+
+ var sizeX = parseInt(this.$target.attr('colspan') || 1);
+ var sizeY = parseInt(this.$target.attr('rowspan') || 1);
+
+ var $size = this.$el.find('.o_wsale_soptions_menu_sizes');
+ $size.find('tr:nth-child(-n + ' + sizeY + ') td:nth-child(-n + ' + sizeX + ')')
+ .addClass('selected');
+
+ // Adapt size array preview to fit ppr
+ $size.find('tr td:nth-child(n + ' + parseInt(this.ppr + 1) + ')').hide();
+ if (this.rerender) {
+ this.rerender = false;
+ return this._rerenderXML();
+ }
+ },
+ /**
+ * @override
+ */
+ updateUIVisibility: async function () {
+ // Main updateUIVisibility will remove the d-none class because there are visible widgets
+ // inside of it. TODO: update this once updateUIVisibility can be used to compute visibility
+ // of arbitrary DOM elements and not just widgets.
+ const isEditing = this.$el.find('[data-name="ribbon_options"]').hasClass('d-none');
+ await this._super(...arguments);
+ this._toggleEditingUI(isEditing);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _renderCustomXML(uiFragment) {
+ const $select = $(uiFragment.querySelector('.o_wsale_ribbon_select'));
+ this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
+ if (!this.$ribbon) {
+ this._resetRibbonDummy();
+ }
+ const classes = this.$ribbon[0].className;
+ this.$ribbon[0].className = '';
+ const defaultTextColor = window.getComputedStyle(this.$ribbon[0]).color;
+ this.$ribbon[0].className = classes;
+ Object.values(this.ribbons).forEach(ribbon => {
+ const colorClasses = ribbon.html_class
+ .split(' ')
+ .filter(className => !/^o_(ribbon|tag)_(left|right)$/.test(className))
+ .join(' ');
+ $select.append(qweb.render('website_sale.ribbonSelectItem', {
+ ribbon,
+ colorClasses,
+ isTag: /o_tag_(left|right)/.test(ribbon.html_class),
+ isLeft: /o_(tag|ribbon)_left/.test(ribbon.html_class),
+ textColor: ribbon.text_color || colorClasses ? 'currentColor' : defaultTextColor,
+ }));
+ });
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetState(methodName, params) {
+ const classList = this.$ribbon[0].classList;
+ switch (methodName) {
+ case 'setRibbon':
+ return this.$target.attr('data-ribbon-id') || '';
+ case 'setRibbonHtml':
+ return this.$ribbon.html();
+ case 'setRibbonMode': {
+ if (classList.contains('o_ribbon_left') || classList.contains('o_ribbon_right')) {
+ return 'ribbon';
+ }
+ return 'tag';
+ }
+ case 'setRibbonPosition': {
+ if (classList.contains('o_tag_left') || classList.contains('o_ribbon_left')) {
+ return 'left';
+ }
+ return 'right';
+ }
+ }
+ return this._super(methodName, params);
+ },
+ /**
+ * Toggles the UI mode between select and create/edit mode.
+ *
+ * @private
+ * @param {Boolean} state true to activate editing UI, false to deactivate.
+ */
+ _toggleEditingUI(state) {
+ this.$el.find('[data-name="ribbon_options"]').toggleClass('d-none', state);
+ this.$el.find('[data-name="ribbon_customize_opt"]').toggleClass('d-none', !state);
+ this.$('.o_ribbon:not(.o_wsale_ribbon_dummy)').toggleClass('d-none', state);
+ this.$ribbon.toggleClass('d-none', !state);
+ },
+ /**
+ * Creates a copy of current ribbon to manipulate for edition/creation.
+ *
+ * @private
+ */
+ _resetRibbonDummy() {
+ if (this.$ribbon) {
+ this.$ribbon.remove();
+ }
+ const $original = this.$('.o_ribbon');
+ this.$ribbon = $original.clone().addClass('d-none o_wsale_ribbon_dummy').appendTo($original.parent());
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onTableMouseEnter: function (ev) {
+ $(ev.currentTarget).addClass('oe_hover');
+ },
+ /**
+ * @private
+ */
+ _onTableMouseLeave: function (ev) {
+ $(ev.currentTarget).removeClass('oe_hover');
+ },
+ /**
+ * @private
+ */
+ _onTableItemMouseEnter: function (ev) {
+ var $td = $(ev.currentTarget);
+ var $table = $td.closest("table");
+ var x = $td.index() + 1;
+ var y = $td.parent().index() + 1;
+
+ var tr = [];
+ for (var yi = 0; yi < y; yi++) {
+ tr.push("tr:eq(" + yi + ")");
+ }
+ var $selectTr = $table.find(tr.join(","));
+ var td = [];
+ for (var xi = 0; xi < x; xi++) {
+ td.push("td:eq(" + xi + ")");
+ }
+ var $selectTd = $selectTr.find(td.join(","));
+
+ $table.find("td").removeClass("select");
+ $selectTd.addClass("select");
+ },
+ /**
+ * @private
+ */
+ _onTableItemClick: function (ev) {
+ var $td = $(ev.currentTarget);
+ var x = $td.index() + 1;
+ var y = $td.parent().index() + 1;
+ this._rpc({
+ route: '/shop/change_size',
+ params: {
+ id: this.productTemplateID,
+ x: x,
+ y: y,
+ },
+ }).then(reload);
+ },
+});
+});
diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js
new file mode 100644
index 00000000..89bce50f
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale.js
@@ -0,0 +1,827 @@
+odoo.define('website_sale.cart', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var core = require('web.core');
+var _t = core._t;
+
+var timeout;
+
+publicWidget.registry.websiteSaleCartLink = publicWidget.Widget.extend({
+ selector: '#top_menu a[href$="/shop/cart"]',
+ events: {
+ 'mouseenter': '_onMouseEnter',
+ 'mouseleave': '_onMouseLeave',
+ 'click': '_onClick',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._popoverRPC = null;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.popover({
+ trigger: 'manual',
+ animation: true,
+ html: true,
+ title: function () {
+ return _t("My Cart");
+ },
+ container: 'body',
+ placement: 'auto',
+ template: '<div class="popover mycart-popover" role="tooltip"><div class="arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>'
+ });
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseEnter: function (ev) {
+ var self = this;
+ clearTimeout(timeout);
+ $(this.selector).not(ev.currentTarget).popover('hide');
+ timeout = setTimeout(function () {
+ if (!self.$el.is(':hover') || $('.mycart-popover:visible').length) {
+ return;
+ }
+ self._popoverRPC = $.get("/shop/cart", {
+ type: 'popover',
+ }).then(function (data) {
+ self.$el.data("bs.popover").config.content = data;
+ self.$el.popover("show");
+ $('.popover').on('mouseleave', function () {
+ self.$el.trigger('mouseleave');
+ });
+ });
+ }, 300);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseLeave: function (ev) {
+ var self = this;
+ setTimeout(function () {
+ if ($('.popover:hover').length) {
+ return;
+ }
+ if (!self.$el.is(':hover')) {
+ self.$el.popover('hide');
+ }
+ }, 1000);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClick: function (ev) {
+ // When clicking on the cart link, prevent any popover to show up (by
+ // clearing the related setTimeout) and, if a popover rpc is ongoing,
+ // wait for it to be completed before going to the link's href. Indeed,
+ // going to that page may perform the same computation the popover rpc
+ // is already doing.
+ clearTimeout(timeout);
+ if (this._popoverRPC && this._popoverRPC.state() === 'pending') {
+ ev.preventDefault();
+ var href = ev.currentTarget.href;
+ this._popoverRPC.then(function () {
+ window.location.href = href;
+ });
+ }
+ },
+});
+});
+
+odoo.define('website_sale.website_sale_category', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+
+publicWidget.registry.websiteSaleCategory = publicWidget.Widget.extend({
+ selector: '#o_shop_collapse_category',
+ events: {
+ 'click .fa-chevron-right': '_onOpenClick',
+ 'click .fa-chevron-down': '_onCloseClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onOpenClick: function (ev) {
+ var $fa = $(ev.currentTarget);
+ $fa.parent().siblings().find('.fa-chevron-down:first').click();
+ $fa.parents('li').find('ul:first').show('normal');
+ $fa.toggleClass('fa-chevron-down fa-chevron-right');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onCloseClick: function (ev) {
+ var $fa = $(ev.currentTarget);
+ $fa.parent().find('ul:first').hide('normal');
+ $fa.toggleClass('fa-chevron-down fa-chevron-right');
+ },
+});
+});
+
+odoo.define('website_sale.website_sale', function (require) {
+'use strict';
+
+var core = require('web.core');
+var config = require('web.config');
+var publicWidget = require('web.public.widget');
+var VariantMixin = require('sale.VariantMixin');
+var wSaleUtils = require('website_sale.utils');
+const wUtils = require('website.utils');
+require("web.zoomodoo");
+
+
+publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, {
+ selector: '.oe_website_sale',
+ events: _.extend({}, VariantMixin.events || {}, {
+ 'change form .js_product:first input[name="add_qty"]': '_onChangeAddQuantity',
+ 'mouseup .js_publish': '_onMouseupPublish',
+ 'touchend .js_publish': '_onMouseupPublish',
+ 'change .oe_cart input.js_quantity[data-product-id]': '_onChangeCartQuantity',
+ 'click .oe_cart a.js_add_suggested_products': '_onClickSuggestedProduct',
+ 'click a.js_add_cart_json': '_onClickAddCartJSON',
+ 'click .a-submit': '_onClickSubmit',
+ 'change form.js_attributes input, form.js_attributes select': '_onChangeAttribute',
+ 'mouseup form.js_add_cart_json label': '_onMouseupAddCartLabel',
+ 'touchend form.js_add_cart_json label': '_onMouseupAddCartLabel',
+ 'click .show_coupon': '_onClickShowCoupon',
+ 'submit .o_wsale_products_searchbar_form': '_onSubmitSaleSearch',
+ 'change select[name="country_id"]': '_onChangeCountry',
+ 'change #shipping_use_same': '_onChangeShippingUseSame',
+ 'click .toggle_summary': '_onToggleSummary',
+ 'click #add_to_cart, #buy_now, #products_grid .o_wsale_product_btn .a-submit': 'async _onClickAdd',
+ 'click input.js_product_change': 'onChangeVariant',
+ 'change .js_main_product [data-attribute_exclusions]': 'onChangeVariant',
+ 'change oe_optional_products_modal [data-attribute_exclusions]': 'onChangeVariant',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this._changeCartQuantity = _.debounce(this._changeCartQuantity.bind(this), 500);
+ this._changeCountry = _.debounce(this._changeCountry.bind(this), 500);
+
+ this.isWebsite = true;
+
+ delete this.events['change .main_product:not(.in_cart) input.js_quantity'];
+ delete this.events['change [data-attribute_exclusions]'];
+ },
+ /**
+ * @override
+ */
+ start() {
+ const def = this._super(...arguments);
+
+ this._applyHashFromSearch();
+
+ _.each(this.$('div.js_product'), function (product) {
+ $('input.js_product_change', product).first().trigger('change');
+ });
+
+ // This has to be triggered to compute the "out of stock" feature and the hash variant changes
+ this.triggerVariantChange(this.$el);
+
+ this.$('select[name="country_id"]').change();
+
+ core.bus.on('resize', this, function () {
+ if (config.device.size_class === config.device.SIZES.XL) {
+ $('.toggle_summary_div').addClass('d-none d-xl-block');
+ }
+ });
+
+ this._startZoom();
+
+ window.addEventListener('hashchange', () => {
+ this._applyHash();
+ this.triggerVariantChange(this.$el);
+ });
+
+ return def;
+ },
+ /**
+ * The selector is different when using list view of variants.
+ *
+ * @override
+ */
+ getSelectedVariantValues: function ($container) {
+ var combination = $container.find('input.js_product_change:checked')
+ .data('combination');
+
+ if (combination) {
+ return combination;
+ }
+ return VariantMixin.getSelectedVariantValues.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _applyHash: function () {
+ var hash = window.location.hash.substring(1);
+ if (hash) {
+ var params = $.deparam(hash);
+ if (params['attr']) {
+ var attributeIds = params['attr'].split(',');
+ var $inputs = this.$('input.js_variant_change, select.js_variant_change option');
+ _.each(attributeIds, function (id) {
+ var $toSelect = $inputs.filter('[data-value_id="' + id + '"]');
+ if ($toSelect.is('input[type="radio"]')) {
+ $toSelect.prop('checked', true);
+ } else if ($toSelect.is('option')) {
+ $toSelect.prop('selected', true);
+ }
+ });
+ this._changeColorAttribute();
+ }
+ }
+ },
+
+ /**
+ * Sets the url hash from the selected product options.
+ *
+ * @private
+ */
+ _setUrlHash: function ($parent) {
+ var $attributes = $parent.find('input.js_variant_change:checked, select.js_variant_change option:selected');
+ var attributeIds = _.map($attributes, function (elem) {
+ return $(elem).data('value_id');
+ });
+ history.replaceState(undefined, undefined, '#attr=' + attributeIds.join(','));
+ },
+ /**
+ * Set the checked color active.
+ *
+ * @private
+ */
+ _changeColorAttribute: function () {
+ $('.css_attribute_color').removeClass("active")
+ .filter(':has(input:checked)')
+ .addClass("active");
+ },
+ /**
+ * @private
+ */
+ _changeCartQuantity: function ($input, value, $dom_optional, line_id, productIDs) {
+ _.each($dom_optional, function (elem) {
+ $(elem).find('.js_quantity').text(value);
+ productIDs.push($(elem).find('span[data-product-id]').data('product-id'));
+ });
+ $input.data('update_change', true);
+
+ this._rpc({
+ route: "/shop/cart/update_json",
+ params: {
+ line_id: line_id,
+ product_id: parseInt($input.data('product-id'), 10),
+ set_qty: value
+ },
+ }).then(function (data) {
+ $input.data('update_change', false);
+ var check_value = parseInt($input.val() || 0, 10);
+ if (isNaN(check_value)) {
+ check_value = 1;
+ }
+ if (value !== check_value) {
+ $input.trigger('change');
+ return;
+ }
+ if (!data.cart_quantity) {
+ return window.location = '/shop/cart';
+ }
+ wSaleUtils.updateCartNavBar(data);
+ $input.val(data.quantity);
+ $('.js_quantity[data-line-id='+line_id+']').val(data.quantity).html(data.quantity);
+
+ if (data.warning) {
+ var cart_alert = $('.oe_cart').parent().find('#data_warning');
+ if (cart_alert.length === 0) {
+ $('.oe_cart').prepend('<div class="alert alert-danger alert-dismissable" role="alert" id="data_warning">'+
+ '<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button> ' + data.warning + '</div>');
+ }
+ else {
+ cart_alert.html('<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button> ' + data.warning);
+ }
+ $input.val(data.quantity);
+ }
+ });
+ },
+ /**
+ * @private
+ */
+ _changeCountry: function () {
+ if (!$("#country_id").val()) {
+ return;
+ }
+ this._rpc({
+ route: "/shop/country_infos/" + $("#country_id").val(),
+ params: {
+ mode: $("#country_id").attr('mode'),
+ },
+ }).then(function (data) {
+ // placeholder phone_code
+ $("input[name='phone']").attr('placeholder', data.phone_code !== 0 ? '+'+ data.phone_code : '');
+
+ // populate states and display
+ var selectStates = $("select[name='state_id']");
+ // dont reload state at first loading (done in qweb)
+ if (selectStates.data('init')===0 || selectStates.find('option').length===1) {
+ if (data.states.length || data.state_required) {
+ selectStates.html('');
+ _.each(data.states, function (x) {
+ var opt = $('<option>').text(x[1])
+ .attr('value', x[0])
+ .attr('data-code', x[2]);
+ selectStates.append(opt);
+ });
+ selectStates.parent('div').show();
+ } else {
+ selectStates.val('').parent('div').hide();
+ }
+ selectStates.data('init', 0);
+ } else {
+ selectStates.data('init', 0);
+ }
+
+ // manage fields order / visibility
+ if (data.fields) {
+ if ($.inArray('zip', data.fields) > $.inArray('city', data.fields)){
+ $(".div_zip").before($(".div_city"));
+ } else {
+ $(".div_zip").after($(".div_city"));
+ }
+ var all_fields = ["street", "zip", "city", "country_name"]; // "state_code"];
+ _.each(all_fields, function (field) {
+ $(".checkout_autoformat .div_" + field.split('_')[0]).toggle($.inArray(field, data.fields)>=0);
+ });
+ }
+
+ if ($("label[for='zip']").length) {
+ $("label[for='zip']").toggleClass('label-optional', !data.zip_required);
+ $("label[for='zip']").get(0).toggleAttribute('required', !!data.zip_required);
+ }
+ if ($("label[for='zip']").length) {
+ $("label[for='state_id']").toggleClass('label-optional', !data.state_required);
+ $("label[for='state_id']").get(0).toggleAttribute('required', !!data.state_required);
+ }
+ });
+ },
+ /**
+ * This is overridden to handle the "List View of Variants" of the web shop.
+ * That feature allows directly selecting the variant from a list instead of selecting the
+ * attribute values.
+ *
+ * Since the layout is completely different, we need to fetch the product_id directly
+ * from the selected variant.
+ *
+ * @override
+ */
+ _getProductId: function ($parent) {
+ if ($parent.find('input.js_product_change').length !== 0) {
+ return parseInt($parent.find('input.js_product_change:checked').val());
+ }
+ else {
+ return VariantMixin._getProductId.apply(this, arguments);
+ }
+ },
+ /**
+ * @private
+ */
+ _startZoom: function () {
+ // Do not activate image zoom for mobile devices, since it might prevent users from scrolling the page
+ if (!config.device.isMobile) {
+ var autoZoom = $('.ecom-zoomable').data('ecom-zoom-auto') || false,
+ attach = '#o-carousel-product';
+ _.each($('.ecom-zoomable img[data-zoom]'), function (el) {
+ onImageLoaded(el, function () {
+ var $img = $(el);
+ $img.zoomOdoo({event: autoZoom ? 'mouseenter' : 'click', attach: attach});
+ $img.attr('data-zoom', 1);
+ });
+ });
+ }
+
+ function onImageLoaded(img, callback) {
+ // On Chrome the load event already happened at this point so we
+ // have to rely on complete. On Firefox it seems that the event is
+ // always triggered after this so we can rely on it.
+ //
+ // However on the "complete" case we still want to keep listening to
+ // the event because if the image is changed later (eg. product
+ // configurator) a new load event will be triggered (both browsers).
+ $(img).on('load', function () {
+ callback();
+ });
+ if (img.complete) {
+ callback();
+ }
+ }
+ },
+ /**
+ * On website, we display a carousel instead of only one image
+ *
+ * @override
+ * @private
+ */
+ _updateProductImage: function ($productContainer, displayImage, productId, productTemplateId, newCarousel, isCombinationPossible) {
+ var $carousel = $productContainer.find('#o-carousel-product');
+ // When using the web editor, don't reload this or the images won't
+ // be able to be edited depending on if this is done loading before
+ // or after the editor is ready.
+ if (window.location.search.indexOf('enable_editor') === -1) {
+ var $newCarousel = $(newCarousel);
+ $carousel.after($newCarousel);
+ $carousel.remove();
+ $carousel = $newCarousel;
+ $carousel.carousel(0);
+ this._startZoom();
+ // fix issue with carousel height
+ this.trigger_up('widgets_start_request', {$target: $carousel});
+ }
+ $carousel.toggleClass('css_not_available', !isCombinationPossible);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAdd: function (ev) {
+ ev.preventDefault();
+ this.isBuyNow = $(ev.currentTarget).attr('id') === 'buy_now';
+ return this._handleAdd($(ev.currentTarget).closest('form'));
+ },
+ /**
+ * Initializes the optional products modal
+ * and add handlers to the modal events (confirm, back, ...)
+ *
+ * @private
+ * @param {$.Element} $form the related webshop form
+ */
+ _handleAdd: function ($form) {
+ var self = this;
+ this.$form = $form;
+
+ var productSelector = [
+ 'input[type="hidden"][name="product_id"]',
+ 'input[type="radio"][name="product_id"]:checked'
+ ];
+
+ var productReady = this.selectOrCreateProduct(
+ $form,
+ parseInt($form.find(productSelector.join(', ')).first().val(), 10),
+ $form.find('.product_template_id').val(),
+ false
+ );
+
+ return productReady.then(function (productId) {
+ $form.find(productSelector.join(', ')).val(productId);
+
+ self.rootProduct = {
+ product_id: productId,
+ quantity: parseFloat($form.find('input[name="add_qty"]').val() || 1),
+ product_custom_attribute_values: self.getCustomVariantValues($form.find('.js_product')),
+ variant_values: self.getSelectedVariantValues($form.find('.js_product')),
+ no_variant_attribute_values: self.getNoVariantAttributeValues($form.find('.js_product'))
+ };
+
+ return self._onProductReady();
+ });
+ },
+
+ _onProductReady: function () {
+ return this._submitForm();
+ },
+
+ /**
+ * Add custom variant values and attribute values that do not generate variants
+ * in the form data and trigger submit.
+ *
+ * @private
+ * @returns {Promise} never resolved
+ */
+ _submitForm: function () {
+ let params = this.rootProduct;
+ params.add_qty = params.quantity;
+
+ params.product_custom_attribute_values = JSON.stringify(params.product_custom_attribute_values);
+ params.no_variant_attribute_values = JSON.stringify(params.no_variant_attribute_values);
+
+ if (this.isBuyNow) {
+ params.express = true;
+ }
+
+ return wUtils.sendRequest('/shop/cart/update', params);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAddCartJSON: function (ev){
+ this.onClickAddCartJSON(ev);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeAddQuantity: function (ev) {
+ this.onChangeAddQuantity(ev);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseupPublish: function (ev) {
+ $(ev.currentTarget).parents('.thumbnail').toggleClass('disabled');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeCartQuantity: function (ev) {
+ var $input = $(ev.currentTarget);
+ if ($input.data('update_change')) {
+ return;
+ }
+ var value = parseInt($input.val() || 0, 10);
+ if (isNaN(value)) {
+ value = 1;
+ }
+ var $dom = $input.closest('tr');
+ // var default_price = parseFloat($dom.find('.text-danger > span.oe_currency_value').text());
+ var $dom_optional = $dom.nextUntil(':not(.optional_product.info)');
+ var line_id = parseInt($input.data('line-id'), 10);
+ var productIDs = [parseInt($input.data('product-id'), 10)];
+ this._changeCartQuantity($input, value, $dom_optional, line_id, productIDs);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSuggestedProduct: function (ev) {
+ $(ev.currentTarget).prev('input').val(1).trigger('change');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSubmit: function (ev, forceSubmit) {
+ if ($(ev.currentTarget).is('#add_to_cart, #products_grid .a-submit') && !forceSubmit) {
+ return;
+ }
+ var $aSubmit = $(ev.currentTarget);
+ if (!ev.isDefaultPrevented() && !$aSubmit.is(".disabled")) {
+ ev.preventDefault();
+ $aSubmit.closest('form').submit();
+ }
+ if ($aSubmit.hasClass('a-submit-disable')){
+ $aSubmit.addClass("disabled");
+ }
+ if ($aSubmit.hasClass('a-submit-loading')){
+ var loading = '<span class="fa fa-cog fa-spin"/>';
+ var fa_span = $aSubmit.find('span[class*="fa"]');
+ if (fa_span.length){
+ fa_span.replaceWith(loading);
+ } else {
+ $aSubmit.append(loading);
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeAttribute: function (ev) {
+ if (!ev.isDefaultPrevented()) {
+ ev.preventDefault();
+ $(ev.currentTarget).closest("form").submit();
+ }
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseupAddCartLabel: function (ev) { // change price when they are variants
+ var $label = $(ev.currentTarget);
+ var $price = $label.parents("form:first").find(".oe_price .oe_currency_value");
+ if (!$price.data("price")) {
+ $price.data("price", parseFloat($price.text()));
+ }
+ var value = $price.data("price") + parseFloat($label.find(".badge span").text() || 0);
+
+ var dec = value % 1;
+ $price.html(value + (dec < 0.01 ? ".00" : (dec < 1 ? "0" : "") ));
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClickShowCoupon: function (ev) {
+ $(ev.currentTarget).hide();
+ $('.coupon_form').removeClass('d-none');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onSubmitSaleSearch: function (ev) {
+ if (!this.$('.dropdown_sorty_by').length) {
+ return;
+ }
+ var $this = $(ev.currentTarget);
+ if (!ev.isDefaultPrevented() && !$this.is(".disabled")) {
+ ev.preventDefault();
+ var oldurl = $this.attr('action');
+ oldurl += (oldurl.indexOf("?")===-1) ? "?" : "";
+ var search = $this.find('input.search-query');
+ window.location = oldurl + '&' + search.attr('name') + '=' + encodeURIComponent(search.val());
+ }
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeCountry: function (ev) {
+ if (!this.$('.checkout_autoformat').length) {
+ return;
+ }
+ this._changeCountry();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeShippingUseSame: function (ev) {
+ $('.ship_to_other').toggle(!$(ev.currentTarget).prop('checked'));
+ },
+ /**
+ * Toggles the add to cart button depending on the possibility of the
+ * current combination.
+ *
+ * @override
+ */
+ _toggleDisable: function ($parent, isCombinationPossible) {
+ VariantMixin._toggleDisable.apply(this, arguments);
+ $parent.find("#add_to_cart").toggleClass('disabled', !isCombinationPossible);
+ $parent.find("#buy_now").toggleClass('disabled', !isCombinationPossible);
+ },
+ /**
+ * Write the properties of the form elements in the DOM to prevent the
+ * current selection from being lost when activating the web editor.
+ *
+ * @override
+ */
+ onChangeVariant: function (ev) {
+ var $component = $(ev.currentTarget).closest('.js_product');
+ $component.find('input').each(function () {
+ var $el = $(this);
+ $el.attr('checked', $el.is(':checked'));
+ });
+ $component.find('select option').each(function () {
+ var $el = $(this);
+ $el.attr('selected', $el.is(':selected'));
+ });
+
+ this._setUrlHash($component);
+
+ return VariantMixin.onChangeVariant.apply(this, arguments);
+ },
+ /**
+ * @private
+ */
+ _onToggleSummary: function () {
+ $('.toggle_summary_div').toggleClass('d-none');
+ $('.toggle_summary_div').removeClass('d-xl-block');
+ },
+ /**
+ * @private
+ */
+ _applyHashFromSearch() {
+ const params = $.deparam(window.location.search.slice(1));
+ if (params.attrib) {
+ const dataValueIds = [];
+ for (const attrib of [].concat(params.attrib)) {
+ const attribSplit = attrib.split('-');
+ const attribValueSelector = `.js_variant_change[name="ptal-${attribSplit[0]}"][value="${attribSplit[1]}"]`;
+ const attribValue = this.el.querySelector(attribValueSelector);
+ if (attribValue !== null) {
+ dataValueIds.push(attribValue.dataset.value_id);
+ }
+ }
+ if (dataValueIds.length) {
+ history.replaceState(undefined, undefined, `#attr=${dataValueIds.join(',')}`);
+ }
+ }
+ this._applyHash();
+ },
+});
+
+publicWidget.registry.WebsiteSaleLayout = publicWidget.Widget.extend({
+ selector: '.oe_website_sale',
+ disabledInEditableMode: false,
+ events: {
+ 'change .o_wsale_apply_layout': '_onApplyShopLayoutChange',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onApplyShopLayoutChange: function (ev) {
+ var switchToList = $(ev.currentTarget).find('.o_wsale_apply_list input').is(':checked');
+ if (!this.editableMode) {
+ this._rpc({
+ route: '/shop/save_shop_layout_mode',
+ params: {
+ 'layout_mode': switchToList ? 'list' : 'grid',
+ },
+ });
+ }
+ var $grid = this.$('#products_grid');
+ // Disable transition on all list elements, then switch to the new
+ // layout then reenable all transitions after having forced a redraw
+ // TODO should probably be improved to allow disabling transitions
+ // altogether with a class/option.
+ $grid.find('*').css('transition', 'none');
+ $grid.toggleClass('o_wsale_layout_list', switchToList);
+ void $grid[0].offsetWidth;
+ $grid.find('*').css('transition', '');
+ },
+});
+
+publicWidget.registry.websiteSaleCart = publicWidget.Widget.extend({
+ selector: '.oe_website_sale .oe_cart',
+ events: {
+ 'click .js_change_shipping': '_onClickChangeShipping',
+ 'click .js_edit_address': '_onClickEditAddress',
+ 'click .js_delete_product': '_onClickDeleteProduct',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClickChangeShipping: function (ev) {
+ var $old = $('.all_shipping').find('.card.border.border-primary');
+ $old.find('.btn-ship').toggle();
+ $old.addClass('js_change_shipping');
+ $old.removeClass('border border-primary');
+
+ var $new = $(ev.currentTarget).parent('div.one_kanban').find('.card');
+ $new.find('.btn-ship').toggle();
+ $new.removeClass('js_change_shipping');
+ $new.addClass('border border-primary');
+
+ var $form = $(ev.currentTarget).parent('div.one_kanban').find('form.d-none');
+ $.post($form.attr('action'), $form.serialize()+'&xhr=1');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClickEditAddress: function (ev) {
+ ev.preventDefault();
+ $(ev.currentTarget).closest('div.one_kanban').find('form.d-none').attr('action', '/shop/address').submit();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClickDeleteProduct: function (ev) {
+ ev.preventDefault();
+ $(ev.currentTarget).closest('tr').find('.js_quantity').val(0).trigger('change');
+ },
+});
+});
diff --git a/addons/website_sale/static/src/js/website_sale_backend.js b/addons/website_sale/static/src/js/website_sale_backend.js
new file mode 100644
index 00000000..7f8d92d8
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_backend.js
@@ -0,0 +1,127 @@
+odoo.define('website_sale.backend', function (require) {
+"use strict";
+
+var WebsiteBackend = require('website.backend.dashboard');
+var COLORS = ['#875a7b', '#21b799', '#E4A900', '#D5653E', '#5B899E', '#E46F78', '#8F8F8F'];
+
+WebsiteBackend.include({
+ jsLibs: [
+ '/web/static/lib/Chart/Chart.js',
+ ],
+
+ events: _.defaults({
+ 'click tr.o_product_template': 'on_product_template',
+ 'click .js_utm_selector': '_onClickUtmButton',
+ }, WebsiteBackend.prototype.events),
+
+ init: function (parent, context) {
+ this._super(parent, context);
+
+ this.graphs.push({'name': 'sales', 'group': 'sale_salesman'});
+ },
+
+ /**
+ * @override method from website backendDashboard
+ * @private
+ */
+ render_graphs: function() {
+ this._super();
+ this.utmGraphData = this.dashboards_data.sales.utm_graph;
+ this.utmGraphData && this._renderUtmGraph();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Method used to generate Pie chart, depending on user selected UTM option(campaign, medium, source)
+ *
+ * @private
+ */
+ _renderUtmGraph: function() {
+ var self = this;
+ this.$(".utm_button_name").html(this.btnName); // change drop-down button name
+ var utmDataType = this.utmType || 'campaign_id';
+ var graphData = this.utmGraphData[utmDataType];
+ if (graphData.length) {
+ this.$(".o_utm_no_data_img").hide();
+ this.$(".o_utm_data_graph").empty().show();
+ var $canvas = $('<canvas/>');
+ this.$(".o_utm_data_graph").append($canvas);
+ var context = $canvas[0].getContext('2d');
+ console.log(graphData);
+
+ var data = [];
+ var labels = [];
+ graphData.forEach(function(pt) {
+ data.push(pt.amount_total);
+ labels.push(pt.utm_type);
+ });
+ var config = {
+ type: 'pie',
+ data: {
+ labels: labels,
+ datasets: [{
+ data: data,
+ backgroundColor: COLORS,
+ }]
+ },
+ options: {
+ tooltips: {
+ callbacks: {
+ label: function(tooltipItem, data) {
+ var label = data.labels[tooltipItem.index] || '';
+ if (label) {
+ label += ': ';
+ }
+ var amount = data.datasets[0].data[tooltipItem.index];
+ amount = self.render_monetary_field(amount, self.data.currency);
+ label += amount;
+ return label;
+ }
+ }
+ },
+ legend: {display: false}
+ }
+ };
+ new Chart(context, config);
+ } else {
+ this.$(".o_utm_no_data_img").show();
+ this.$(".o_utm_data_graph").hide();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Onchange on UTM dropdown button, this method is called.
+ *
+ * @private
+ */
+ _onClickUtmButton: function(ev) {
+ this.utmType = $(ev.currentTarget).attr('name');
+ this.btnName = $(ev.currentTarget).text();
+ this._renderUtmGraph();
+ },
+
+ on_product_template: function (ev) {
+ ev.preventDefault();
+
+ var product_tmpl_id = $(ev.currentTarget).data('productId');
+ this.do_action({
+ type: 'ir.actions.act_window',
+ res_model: 'product.template',
+ res_id: product_tmpl_id,
+ views: [[false, 'form']],
+ target: 'current',
+ }, {
+ on_reverse_breadcrumb: this.on_reverse_breadcrumb,
+ });
+ },
+});
+return WebsiteBackend;
+
+});
diff --git a/addons/website_sale/static/src/js/website_sale_form_editor.js b/addons/website_sale/static/src/js/website_sale_form_editor.js
new file mode 100644
index 00000000..021fcf60
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_form_editor.js
@@ -0,0 +1,28 @@
+odoo.define('website_sale.form', function (require) {
+'use strict';
+
+var FormEditorRegistry = require('website_form.form_editor_registry');
+
+FormEditorRegistry.add('create_customer', {
+ formFields: [{
+ type: 'char',
+ modelRequired: true,
+ name: 'name',
+ string: 'Your Name',
+ }, {
+ type: 'email',
+ required: true,
+ name: 'email',
+ string: 'Your Email',
+ }, {
+ type: 'tel',
+ name: 'phone',
+ string: 'Phone Number',
+ }, {
+ type: 'char',
+ name: 'company_name',
+ string: 'Company Name',
+ }],
+});
+
+});
diff --git a/addons/website_sale/static/src/js/website_sale_payment.js b/addons/website_sale/static/src/js/website_sale_payment.js
new file mode 100644
index 00000000..4d6abd98
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_payment.js
@@ -0,0 +1,48 @@
+odoo.define('website_sale.payment', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+
+publicWidget.registry.WebsiteSalePayment = publicWidget.Widget.extend({
+ selector: '#wrapwrap:has(#checkbox_cgv)',
+ events: {
+ 'change #checkbox_cgv': '_onCGVCheckboxClick',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$checkbox = this.$('#checkbox_cgv');
+ this.$payButton = $('button#o_payment_form_pay');
+ this.$checkbox.trigger('change');
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptPayButton: function () {
+ var disabledReasons = this.$payButton.data('disabled_reasons') || {};
+ disabledReasons.cgv = !this.$checkbox.prop('checked');
+ this.$payButton.data('disabled_reasons', disabledReasons);
+
+ this.$payButton.prop('disabled', _.contains(disabledReasons, true));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCGVCheckboxClick: function () {
+ this._adaptPayButton();
+ },
+});
+});
diff --git a/addons/website_sale/static/src/js/website_sale_recently_viewed.js b/addons/website_sale/static/src/js/website_sale_recently_viewed.js
new file mode 100644
index 00000000..ea9fedec
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_recently_viewed.js
@@ -0,0 +1,243 @@
+odoo.define('website_sale.recently_viewed', function (require) {
+
+var concurrency = require('web.concurrency');
+var config = require('web.config');
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+var utils = require('web.utils');
+var wSaleUtils = require('website_sale.utils');
+
+var qweb = core.qweb;
+
+publicWidget.registry.productsRecentlyViewedSnippet = publicWidget.Widget.extend({
+ selector: '.s_wsale_products_recently_viewed',
+ xmlDependencies: ['/website_sale/static/src/xml/website_sale_recently_viewed.xml'],
+ disabledInEditableMode: false,
+ read_events: {
+ 'click .js_add_cart': '_onAddToCart',
+ 'click .js_remove': '_onRemove',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._dp = new concurrency.DropPrevious();
+ this.uniqueId = _.uniqueId('o_carousel_recently_viewed_products_');
+ this._onResizeChange = _.debounce(this._addCarousel, 100);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._dp.add(this._fetch()).then(this._render.bind(this));
+ $(window).resize(() => {
+ this._onResizeChange();
+ });
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super(...arguments);
+ this.$el.addClass('d-none');
+ this.$el.find('.slider').html('');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _fetch: function () {
+ return this._rpc({
+ route: '/shop/products/recently_viewed',
+ }).then(res => {
+ var products = res['products'];
+
+ // In edit mode, if the current visitor has no recently viewed
+ // products, use demo data.
+ if (this.editableMode && (!products || !products.length)) {
+ return {
+ 'products': [{
+ id: 0,
+ website_url: '#',
+ display_name: 'Product 1',
+ price: '$ <span class="oe_currency_value">750.00</span>',
+ }, {
+ id: 0,
+ website_url: '#',
+ display_name: 'Product 2',
+ price: '$ <span class="oe_currency_value">750.00</span>',
+ }, {
+ id: 0,
+ website_url: '#',
+ display_name: 'Product 3',
+ price: '$ <span class="oe_currency_value">750.00</span>',
+ }, {
+ id: 0,
+ website_url: '#',
+ display_name: 'Product 4',
+ price: '$ <span class="oe_currency_value">750.00</span>',
+ }],
+ };
+ }
+
+ return res;
+ });
+ },
+ /**
+ * @private
+ */
+ _render: function (res) {
+ var products = res['products'];
+ var mobileProducts = [], webProducts = [], productsTemp = [];
+ _.each(products, function (product) {
+ if (productsTemp.length === 4) {
+ webProducts.push(productsTemp);
+ productsTemp = [];
+ }
+ productsTemp.push(product);
+ mobileProducts.push([product]);
+ });
+ if (productsTemp.length) {
+ webProducts.push(productsTemp);
+ }
+
+ this.mobileCarousel = $(qweb.render('website_sale.productsRecentlyViewed', {
+ uniqueId: this.uniqueId,
+ productFrame: 1,
+ productsGroups: mobileProducts,
+ }));
+ this.webCarousel = $(qweb.render('website_sale.productsRecentlyViewed', {
+ uniqueId: this.uniqueId,
+ productFrame: 4,
+ productsGroups: webProducts,
+ }));
+ this._addCarousel();
+ this.$el.toggleClass('d-none', !(products && products.length));
+ },
+ /**
+ * Add the right carousel depending on screen size.
+ * @private
+ */
+ _addCarousel: function () {
+ var carousel = config.device.size_class <= config.device.SIZES.SM ? this.mobileCarousel : this.webCarousel;
+ this.$('.slider').html(carousel).css('display', ''); // Removing display is kept for compatibility (it was hidden before)
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add product to cart and reload the carousel.
+ * @private
+ * @param {Event} ev
+ */
+ _onAddToCart: function (ev) {
+ var self = this;
+ var $card = $(ev.currentTarget).closest('.card');
+ this._rpc({
+ route: "/shop/cart/update_json",
+ params: {
+ product_id: $card.find('input[data-product-id]').data('product-id'),
+ add_qty: 1
+ },
+ }).then(function (data) {
+ wSaleUtils.updateCartNavBar(data);
+ var $navButton = $('header .o_wsale_my_cart').first();
+ var fetch = self._fetch();
+ var animation = wSaleUtils.animateClone($navButton, $(ev.currentTarget).parents('.o_carousel_product_card'), 25, 40);
+ Promise.all([fetch, animation]).then(function (values) {
+ self._render(values[0]);
+ });
+ });
+ },
+
+ /**
+ * Remove product from recently viewed products.
+ * @private
+ * @param {Event} ev
+ */
+ _onRemove: function (ev) {
+ var self = this;
+ var $card = $(ev.currentTarget).closest('.card');
+ this._rpc({
+ route: "/shop/products/recently_viewed_delete",
+ params: {
+ product_id: $card.find('input[data-product-id]').data('product-id'),
+ },
+ }).then(function (data) {
+ self._render(data);
+ });
+ },
+});
+
+publicWidget.registry.productsRecentlyViewedUpdate = publicWidget.Widget.extend({
+ selector: '#product_detail',
+ events: {
+ 'change input.product_id[name="product_id"]': '_onProductChange',
+ },
+ debounceValue: 8000,
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._onProductChange = _.debounce(this._onProductChange, this.debounceValue);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Debounced method that wait some time before marking the product as viewed.
+ * @private
+ * @param {HTMLInputElement} $input
+ */
+ _updateProductView: function ($input) {
+ var productId = parseInt($input.val());
+ var cookieName = 'seen_product_id_' + productId;
+ if (! parseInt(this.el.dataset.viewTrack, 10)) {
+ return; // Is not tracked
+ }
+ if (utils.get_cookie(cookieName)) {
+ return; // Already tracked in the last 30min
+ }
+ if ($(this.el).find('.js_product.css_not_available').length) {
+ return; // Variant not possible
+ }
+ this._rpc({
+ route: '/shop/products/recently_viewed_update',
+ params: {
+ product_id: productId,
+ }
+ }).then(function (res) {
+ if (res && res.visitor_uuid) {
+ utils.set_cookie('visitor_uuid', res.visitor_uuid);
+ }
+ utils.set_cookie(cookieName, productId, 30 * 60);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Call debounced method when product change to reset timer.
+ * @private
+ * @param {Event} ev
+ */
+ _onProductChange: function (ev) {
+ this._updateProductView($(ev.currentTarget));
+ },
+});
+});
diff --git a/addons/website_sale/static/src/js/website_sale_tracking.js b/addons/website_sale/static/src/js/website_sale_tracking.js
new file mode 100644
index 00000000..92850707
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_tracking.js
@@ -0,0 +1,113 @@
+odoo.define('website_sale.tracking', function (require) {
+
+var publicWidget = require('web.public.widget');
+
+publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({
+ selector: '.oe_website_sale',
+ events: {
+ 'click form[action="/shop/cart/update"] a.a-submit': '_onAddProductIntoCart',
+ 'click a[href="/shop/checkout"]': '_onCheckoutStart',
+ 'click div.oe_cart a[href^="/web?redirect"][href$="/shop/checkout"]': '_onCustomerSignin',
+ 'click form[action="/shop/confirm_order"] a.a-submit': '_onOrder',
+ 'click form[target="_self"] button[type=submit]': '_onOrderPayment',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ // Watching a product
+ if (this.$el.is('#product_detail')) {
+ var productID = this.$('input[name="product_id"]').attr('value');
+ this._vpv('/stats/ecom/product_view/' + productID);
+ }
+
+ // ...
+ if (this.$('div.oe_website_sale_tx_status').length) {
+ this._trackGA('require', 'ecommerce');
+
+ var orderID = this.$('div.oe_website_sale_tx_status').data('order-id');
+ this._vpv('/stats/ecom/order_confirmed/' + orderID);
+
+ this._rpc({
+ route: '/shop/tracking_last_order/',
+ }).then(function (o) {
+ self._trackGA('ecommerce:clear');
+
+ if (o.transaction && o.lines) {
+ self._trackGA('ecommerce:addTransaction', o.transaction);
+ _.forEach(o.lines, function (line) {
+ self._trackGA('ecommerce:addItem', line);
+ });
+ }
+ self._trackGA('ecommerce:send');
+ });
+ }
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _trackGA: function () {
+ var websiteGA = window.ga || function () {};
+ websiteGA.apply(this, arguments);
+ },
+ /**
+ * @private
+ */
+ _vpv: function (page) { //virtual page view
+ this._trackGA('send', 'pageview', {
+ 'page': page,
+ 'title': document.title,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAddProductIntoCart: function () {
+ var productID = this.$('input[name="product_id"]').attr('value');
+ this._vpv('/stats/ecom/product_add_to_cart/' + productID);
+ },
+ /**
+ * @private
+ */
+ _onCheckoutStart: function () {
+ this._vpv('/stats/ecom/customer_checkout');
+ },
+ /**
+ * @private
+ */
+ _onCustomerSignin: function () {
+ this._vpv('/stats/ecom/customer_signin');
+ },
+ /**
+ * @private
+ */
+ _onOrder: function () {
+ if ($('#top_menu [href="/web/login"]').length) {
+ this._vpv('/stats/ecom/customer_signup');
+ }
+ this._vpv('/stats/ecom/order_checkout');
+ },
+ /**
+ * @private
+ */
+ _onOrderPayment: function () {
+ var method = $('#payment_method input[name=acquirer]:checked').nextAll('span:first').text();
+ this._vpv('/stats/ecom/order_payment/' + method);
+ },
+});
+});
diff --git a/addons/website_sale/static/src/js/website_sale_utils.js b/addons/website_sale/static/src/js/website_sale_utils.js
new file mode 100644
index 00000000..52c3e448
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_utils.js
@@ -0,0 +1,59 @@
+odoo.define('website_sale.utils', function (require) {
+'use strict';
+
+function animateClone($cart, $elem, offsetTop, offsetLeft) {
+ $cart.find('.o_animate_blink').addClass('o_red_highlight o_shadow_animation').delay(500).queue(function () {
+ $(this).removeClass("o_shadow_animation").dequeue();
+ }).delay(2000).queue(function () {
+ $(this).removeClass("o_red_highlight").dequeue();
+ });
+ return new Promise(function (resolve, reject) {
+ var $imgtodrag = $elem.find('img').eq(0);
+ if ($imgtodrag.length) {
+ var $imgclone = $imgtodrag.clone()
+ .offset({
+ top: $imgtodrag.offset().top,
+ left: $imgtodrag.offset().left
+ })
+ .addClass('o_website_sale_animate')
+ .appendTo(document.body)
+ .animate({
+ top: $cart.offset().top + offsetTop,
+ left: $cart.offset().left + offsetLeft,
+ width: 75,
+ height: 75,
+ }, 1000, 'easeInOutExpo');
+
+ $imgclone.animate({
+ width: 0,
+ height: 0,
+ }, function () {
+ resolve();
+ $(this).detach();
+ });
+ } else {
+ resolve();
+ }
+ });
+}
+
+/**
+ * Updates both navbar cart
+ * @param {Object} data
+ */
+function updateCartNavBar(data) {
+ var $qtyNavBar = $(".my_cart_quantity");
+ _.each($qtyNavBar, function (qty) {
+ var $qty = $(qty);
+ $qty.parents('li:first').removeClass('d-none');
+ $qty.html(data.cart_quantity).hide().fadeIn(600);
+ });
+ $(".js_cart_lines").first().before(data['website_sale.cart_lines']).end().remove();
+ $(".js_cart_summary").first().before(data['website_sale.short_cart_summary']).end().remove();
+}
+
+return {
+ animateClone: animateClone,
+ updateCartNavBar: updateCartNavBar,
+};
+});
diff --git a/addons/website_sale/static/src/js/website_sale_validate.js b/addons/website_sale/static/src/js/website_sale_validate.js
new file mode 100644
index 00000000..aaf48a20
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_validate.js
@@ -0,0 +1,51 @@
+odoo.define('website_sale.validate', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var core = require('web.core');
+var _t = core._t;
+
+publicWidget.registry.websiteSaleValidate = publicWidget.Widget.extend({
+ selector: 'div.oe_website_sale_tx_status[data-order-id]',
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ this._poll_nbr = 0;
+ this._paymentTransationPollStatus();
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _paymentTransationPollStatus: function () {
+ var self = this;
+ this._rpc({
+ route: '/shop/payment/get_status/' + parseInt(this.$el.data('order-id')),
+ }).then(function (result) {
+ self._poll_nbr += 1;
+ if (result.recall) {
+ if (self._poll_nbr < 20) {
+ setTimeout(function () {
+ self._paymentTransationPollStatus();
+ }, Math.ceil(self._poll_nbr / 3) * 1000);
+ } else {
+ var $message = $(result.message);
+ var $warning = $("<i class='fa fa-warning' style='margin-right:10px;'>");
+ $warning.attr("title", _t("We are waiting for confirmation from the bank or the payment provider"));
+ $message.find('span:first').prepend($warning);
+ result.message = $message.html();
+ }
+ }
+ self.$el.html(result.message);
+ });
+ },
+});
+});
diff --git a/addons/website_sale/static/src/js/website_sale_video_field_preview.js b/addons/website_sale/static/src/js/website_sale_video_field_preview.js
new file mode 100644
index 00000000..bd40b3b9
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_video_field_preview.js
@@ -0,0 +1,28 @@
+odoo.define('website_sale.video_field_preview', function (require) {
+"use strict";
+
+
+var AbstractField = require('web.AbstractField');
+var core = require('web.core');
+var fieldRegistry = require('web.field_registry');
+
+var QWeb = core.qweb;
+
+/**
+ * Displays preview of the video showcasing product.
+ */
+var FieldVideoPreview = AbstractField.extend({
+ className: 'd-block o_field_video_preview',
+
+ _render: function () {
+ this.$el.html(QWeb.render('productVideo', {
+ embedCode: this.value,
+ }));
+ },
+});
+
+fieldRegistry.add('video_preview', FieldVideoPreview);
+
+return FieldVideoPreview;
+
+});
diff --git a/addons/website_sale/static/src/scss/primary_variables.scss b/addons/website_sale/static/src/scss/primary_variables.scss
new file mode 100644
index 00000000..c19ec9aa
--- /dev/null
+++ b/addons/website_sale/static/src/scss/primary_variables.scss
@@ -0,0 +1 @@
+$o-wsale-products-layout-grid-ratio: 1.0 !default;
diff --git a/addons/website_sale/static/src/scss/website_mail.scss b/addons/website_sale/static/src/scss/website_mail.scss
new file mode 100644
index 00000000..26a45aa5
--- /dev/null
+++ b/addons/website_sale/static/src/scss/website_mail.scss
@@ -0,0 +1,48 @@
+/* ---- OpenChatter Website ---- */
+
+.oe_msg {
+ img.oe_msg_avatar {
+ width: 50px;
+ margin-right: 10px;
+ }
+}
+
+.oe_msg_attachment {
+ display: inline-block;
+ width: 120px;
+ margin: 4px 2px;
+ min-height: 80px;
+ position: relative;
+ border-radius: 3px;
+ text-align: center;
+ vertical-align: top;
+
+ a {
+ img.oe_attachment_embedded {
+ display: block;
+ position: relative;
+ margin: 0 0 0 10px;
+ width: 100px;
+ height: 80px;
+ border-radius: 1px;
+ border: solid 3px #FFF;
+ -webkit-box-shadow: 0 3px 10px rgba(0, 0, 0, 0.19);
+ -moz-box-shadow: 0 3px 10px rgba(0, 0, 0, 0.19);
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.19);
+ }
+
+ div.oe_attachment_name {
+ display: inline-block;
+ max-width: 100%;
+ padding: 1px 3px;
+ margin-top: 2px;
+ margin-bottom: 5px;
+ background: #F4F5FA;
+ overflow: hidden;
+ color: #4c4c4c;
+ text-shadow: none;
+ border-radius: 3px;
+ word-wrap: break-word;
+ }
+ }
+}
diff --git a/addons/website_sale/static/src/scss/website_sale.editor.scss b/addons/website_sale/static/src/scss/website_sale.editor.scss
new file mode 100644
index 00000000..77a6b7fb
--- /dev/null
+++ b/addons/website_sale/static/src/scss/website_sale.editor.scss
@@ -0,0 +1,40 @@
+.o_wsale_soptions_menu_sizes {
+ table {
+ margin: auto;
+
+ td {
+ margin: 0;
+ padding: 0;
+ width: 40px;
+ height: 40px;
+ border: 1px #dddddd solid;
+ cursor: pointer;
+
+ &.selected {
+ background-color: #B1D4F1;
+ }
+ }
+
+ &.oe_hover td {
+ &.selected {
+ background-color: transparent;
+ }
+ &.select {
+ background-color: #B1D4F1;
+ }
+ }
+ }
+}
+
+.o_wsale_color_preview {
+ width: 1em;
+ height: 1em;
+ border: 1px solid white;
+ display: inline-block;
+ vertical-align: middle;
+ border-radius: 50%;
+}
+
+.oe_drop_zone + .s_wsale_products_recently_viewed {
+ display: block !important;
+}
diff --git a/addons/website_sale/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale.scss
new file mode 100644
index 00000000..8b3c5fbd
--- /dev/null
+++ b/addons/website_sale/static/src/scss/website_sale.scss
@@ -0,0 +1,582 @@
+// Prevent grid gutter to be higher that bootstrap gutter width to make sure
+// the negative margin layout does not overflow on elements. This prevents the
+// use of an ugly overflow: hidden which would break box-shadows.
+$o-wsale-products-layout-grid-gutter-width: $grid-gutter-width / 2 !default;
+$o-wsale-products-layout-grid-gutter-width: min($grid-gutter-width / 2, $o-wsale-products-layout-grid-gutter-width);
+
+@mixin wsale-break-table-to-list() {
+ .o_wsale_products_grid_table_wrapper > table,
+ .o_wsale_products_grid_table_wrapper > table > tbody,
+ .o_wsale_products_grid_table_wrapper > table > tbody > tr,
+ .o_wsale_products_grid_table_wrapper > table > tbody > tr > td {
+ display: block;
+ width: 100%;
+ }
+}
+
+.oe_website_sale {
+ ul ul {
+ margin-left: 1.5rem;
+ }
+ .o_payment_form .card {
+ border-radius: 4px !important;
+ }
+ .address-inline address {
+ display: inline-block;
+ }
+ table#cart_products tr td, table#suggested_products tr td {
+ vertical-align: middle;
+ }
+
+ table#cart_products {
+ margin-bottom: 0;
+
+ td, th {
+ &:first-child {
+ padding-left: $grid-gutter-width*0.5;
+ }
+ }
+ }
+
+ h1[itemprop="name"], .td-product_name {
+ word-break: break-word;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ }
+
+ @include media-breakpoint-down(sm) {
+ .td-img {
+ display: none;
+ }
+ }
+
+ .toggle_summary_div {
+ @include media-breakpoint-up(xl) {
+ max-width: 400px;
+ }
+ }
+ input.js_quantity {
+ min-width: 48px;
+ text-align: center;
+ }
+ input.quantity {
+ padding: 0;
+ }
+}
+
+.o_alternative_product {
+ margin: auto;
+}
+
+// Base style for a product card with image/description
+.oe_product_cart {
+ .oe_product_image {
+ height: 0;
+ text-align: center;
+
+ img {
+ height: 100%;
+ width: 100%;
+ object-fit: scale-down;
+ }
+ }
+ .o_wsale_product_information {
+ position: relative;
+ flex: 0 0 auto;
+ transition: .3s ease;
+ }
+ .oe_subdescription {
+ max-height: 0;
+ overflow: hidden;
+ font-size: $font-size-sm;
+ margin-bottom: map-get($spacers, 1);
+ transform: scale(1, 0);
+ transition: all ease 0.3s;
+ }
+ .o_wsale_product_btn {
+ @include o-position-absolute(auto, 0, 100%, 0);
+ padding-bottom: map-get($spacers, 1);
+
+ .btn {
+ transform: scale(0);
+ transition: transform ease 200ms 0s;
+ }
+
+ &:empty {
+ display: none !important;
+ }
+ }
+
+ &:hover {
+ box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.1);
+
+ .o_wsale_product_information {
+ background-color: gray('200') !important;
+ }
+ .oe_subdescription {
+ max-height: $line-height-base * 1em; // Max 1 line
+ @include media-breakpoint-up(lg) {
+ max-height: $line-height-base * 2em; // Max 2 lines
+ }
+ @include media-breakpoint-up(xl) {
+ max-height: $line-height-base * 3em; // Max 3 lines
+ }
+ }
+ .oe_subdescription,
+ .o_wsale_product_btn .btn {
+ transform: scale(1);
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ &, &:hover {
+ .oe_subdescription {
+ max-height: $line-height-base * 3em; // Max 3 lines
+ }
+ }
+ .oe_subdescription,
+ .o_wsale_product_btn .btn {
+ transform: scale(1);
+ }
+ }
+}
+
+// Options relative to where the product card is put
+.oe_product {
+ // Image full option
+ &.oe_image_full {
+ .oe_product_image {
+ @include border-bottom-radius($card-inner-border-radius);
+ }
+ .o_wsale_product_information {
+ @include o-position-absolute(auto, 0, 0, 0); // The wrapper is always relatively positioned
+ }
+ }
+}
+
+#products_grid {
+ .o_wsale_products_grid_table_wrapper > .table {
+ table-layout: fixed;
+
+ > tbody {
+ > tr > td {
+ margin-top: $o-wsale-products-layout-grid-gutter-width; // For list and mobile design
+ padding: 0;
+
+ @if $o-wsale-products-layout-grid-gutter-width <= 0 {
+ border: $card-border-width solid $card-border-color;
+ }
+ }
+
+ > tr:first-child > td:first-child {
+ margin-top: 0; // For list and mobile design
+ }
+ }
+
+ .o_wsale_product_grid_wrapper {
+ position: relative;
+
+ @for $x from 1 through 4 {
+ @for $y from 1 through 4 {
+ &.o_wsale_product_grid_wrapper_#{$x}_#{$y} {
+ padding-top: 100% * $o-wsale-products-layout-grid-ratio * $y / $x;
+ }
+ }
+ }
+
+ > * {
+ $-pos: ($o-wsale-products-layout-grid-gutter-width / 2);
+ @include o-position-absolute($-pos, $-pos, $-pos, $-pos);
+
+ @if $o-wsale-products-layout-grid-gutter-width <= 0 {
+ &.card {
+ border: none;
+
+ &, .card-body {
+ border-radius: 0;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .o_wsale_products_grid_table_wrapper {
+ // Necessary to compensate the outer border-spacing of the table. No
+ // overflow will occur as the gutter width cannot be higher than the
+ // BS4 grid gutter and the vertical margins of the wrapper's parent are
+ // set accordingly.
+ // Note: a possible layout could also be ok by removing the wrapper
+ // related spacings and setting a background to it, thus including the
+ // outer border spacing as part of the design.
+ margin: (-$o-wsale-products-layout-grid-gutter-width / 2);
+ }
+
+ @include media-breakpoint-down(sm) {
+ @include wsale-break-table-to-list();
+
+ .table .o_wsale_product_grid_wrapper {
+ padding-top: 100% !important;
+ }
+ }
+
+ &.o_wsale_layout_list {
+ @include media-breakpoint-up(sm) {
+ @include wsale-break-table-to-list();
+
+ .o_wsale_products_grid_table_wrapper {
+ margin: 0;
+ }
+
+ .table .o_wsale_product_grid_wrapper {
+ padding-top: 0 !important;
+
+ > * {
+ @include o-position-absolute(0, 0, 0, 0);
+ position: relative;
+ }
+ }
+
+ .oe_product_cart {
+ $-wsale-list-layout-height: 10rem;
+
+ flex-flow: row nowrap;
+ min-height: $-wsale-list-layout-height;
+
+ .oe_product_image {
+ flex: 0 0 auto;
+ width: $-wsale-list-layout-height;
+ max-width: 35%;
+ min-width: 100px;
+ height: auto;
+ }
+ .o_wsale_product_information {
+ position: static;
+ display: flex;
+ flex: 1 1 auto;
+ text-align: left !important;
+ }
+ .o_wsale_product_information_text {
+ flex: 1 1 auto;
+ }
+ .o_wsale_product_btn {
+ flex: 0 0 auto;
+ position: static;
+ display: flex;
+ flex-flow: column nowrap;
+ align-items: center;
+ padding: map-get($spacers, 2);
+ background-color: gray('200');
+
+ .btn + .btn {
+ margin-top: map-get($spacers, 2);
+ }
+ }
+
+ .oe_subdescription {
+ max-height: none !important;
+ }
+ .oe_subdescription,
+ .o_wsale_product_btn .btn {
+ transform: scale(1) !important;
+ }
+
+ &:hover {
+ .o_wsale_product_information {
+ background-color: $white !important;
+ }
+ }
+ .o_ribbon_right {
+ @include o-ribbon-left();
+ }
+ .o_tag_right {
+ @include o-tag-left();
+ }
+ }
+ }
+ }
+}
+
+.o_wsale_products_main_row {
+ // Special case. Normally vertical margins would be set using the BS4
+ // mt-* / my-* / mb-* utility classes, but here we need to use the shop max
+ // grid gutter width to prevent the grid wrapper to overflow because of its
+ // negative margins.
+ margin-top: $grid-gutter-width / 2;
+ margin-bottom: $grid-gutter-width / 2;
+}
+
+.oe_cart {
+ table td:first-child {
+ min-width: 76px;
+ }
+
+ > .oe_structure {
+ clear: both;
+ }
+}
+
+div#payment_method {
+ div.list-group {
+ margin-left: 40px;
+ }
+
+ .list-group-item {
+ padding-top: 5px;
+ padding-bottom: 5px;
+ }
+}
+
+ul.wizard {
+ padding: 0;
+ margin-top: 20px;
+ list-style: none outside none;
+ border-radius: 4px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.065);
+
+ li {
+ border: 1px solid gray('200');
+ border-right-width: 0;
+ position: relative;
+ float: left;
+ padding: 0 10px 0 20px;
+ margin: 0;
+ line-height: 38px;
+ background: #fbfbfb;
+
+ .chevron {
+ position: absolute;
+ top: 0;
+ right: -10px;
+ z-index: 1;
+ display: block;
+ border: 20px solid transparent;
+ border-right: 0;
+ border-left: 10px solid gray('200');
+ }
+
+ .chevron:before {
+ position: absolute;
+ top: -20px;
+ right: 1px;
+ display: block;
+ border: 20px solid transparent;
+ border-right: 0;
+ border-left: 10px solid #fbfbfb;
+ content: "";
+ }
+
+ .o_link_disable {
+ text-decoration: none;
+ color: inherit;
+ cursor: text;
+ }
+
+ &.text-success {
+ background: #f3f4f5;
+ }
+
+ &.text-success .chevron:before {
+ border-left: 10px solid #f5f5f5;
+ }
+
+ &.text-primary {
+ background: #f1f6fc;
+ }
+
+ &.text-primary .chevron:before {
+ border-left: 10px solid #f1f6fc;
+ }
+
+ &:first-child {
+ padding-left: 15px;
+ border-radius: 4px 0 0 4px;
+ }
+
+ &:last-child {
+ border-radius: 0 4px 4px 0;
+ border-right-width: 1px;
+
+ .chevron {
+ display: none;
+ }
+ }
+ }
+}
+
+#o_shop_collapse_category li {
+ width: 100%;
+
+ a {
+ display: inline-block;
+ width: 80%;
+ padding-left: 3px;
+ }
+
+ i.fa {
+ cursor: pointer;
+ }
+}
+
+.mycart-popover {
+ max-width: 500px;
+ min-width: 250px;
+
+ .cart_line {
+ border-bottom: 1px #EEE solid;
+ }
+}
+
+tr#empty {
+ display: none;
+}
+
+.js_change_shipping {
+ cursor: pointer;
+}
+
+a.no-decoration {
+ cursor: pointer;
+ text-decoration: none !important;
+}
+
+#o-carousel-product {
+
+ &.css_not_available {
+ opacity: 0.2;
+ }
+
+ .carousel-outer {
+ height: 400px;
+ max-height: 90vh;
+
+ .carousel-inner {
+ img {
+ height: 100%;
+ width: 100%;
+ object-fit: scale-down;
+ }
+ }
+ }
+
+ .carousel-control-prev, .carousel-control-next {
+ height: 70%;
+ top: 15%;
+ opacity: 0.5;
+ cursor: pointer;
+ &:focus {
+ opacity: 0.65;
+ }
+ &:hover {
+ opacity: 0.8;
+ }
+ > span {
+ background: rgba(0, 0, 0, 0.8);
+ }
+ }
+
+ .carousel-indicators {
+ li {
+ width: 64px;
+ height: 64px;
+ text-indent: unset;
+ border: 1px solid gray('600');
+ opacity: 0.5;
+ position: relative;
+
+ .o_product_video_thumb {
+ @include o-position-absolute($top: 50%, $left: 50%);
+ transform: translate(-50%, -50%);
+ color: gray('400');
+ }
+ &.active {
+ opacity: 1;
+ border: 1px solid theme-color('primary');
+ }
+ }
+ }
+}
+
+.ecom-zoomable {
+ &:not(.ecom-autozoom) {
+ img[data-zoom] {
+ cursor: zoom-in;
+ }
+ }
+ &.ecom-autozoom {
+ img[data-zoom] {
+ cursor: crosshair;
+ }
+ }
+ .o_editable img[data-zoom] {
+ cursor: pointer;
+ }
+}
+
+#coupon_box form {
+ max-width: 300px;
+}
+
+.o_website_sale_animate {
+ opacity: 0.7;
+ position: absolute !important;
+ height: 150px;
+ width: 150px;
+ z-index: 1020;
+}
+
+.o_red_highlight {
+ background: theme-color('danger') !important;
+ box-shadow: 0 0 0 0 rgba(240,8,0,0.4);
+ transition: all 0.5s linear;
+}
+
+.o_shadow_animation {
+ box-shadow: 0 0 5px 10px rgba(240,8,0,0.4)!important;
+}
+
+/* product recently viewed snippet */
+
+.o_carousel_product_card {
+ .o_carousel_product_card_img_top {
+ object-fit: scale-down;
+ @include media-breakpoint-down(sm) {
+ height: 12rem;
+ }
+ @include media-breakpoint-up(md) {
+ height: 8rem;
+ }
+ @include media-breakpoint-up(lg) {
+ height: 12rem;
+ }
+ }
+ .o_carousel_product_img_link:hover + .o_carousel_product_remove {
+ display: block;
+ }
+}
+
+.o_carousel_product_card_wrap {
+ @include media-breakpoint-up(sm) {
+ float: left;
+ }
+}
+
+.o_carousel_product_control {
+ top: percentage(1/3);
+ bottom: percentage(1/3);
+ width: 2rem;
+ border-radius: 5px;
+ background-color: $o-enterprise-primary-color;
+}
+
+.o_carousel_product_remove {
+ position: absolute;
+ display: none;
+ cursor: pointer;
+ right: 5%;
+ top: 5%;
+}
+
+.o_carousel_product_remove:hover {
+ display: block;
+}
diff --git a/addons/website_sale/static/src/scss/website_sale_backend.scss b/addons/website_sale/static/src/scss/website_sale_backend.scss
new file mode 100644
index 00000000..857ae06a
--- /dev/null
+++ b/addons/website_sale/static/src/scss/website_sale_backend.scss
@@ -0,0 +1,72 @@
+.o_website_sale_image_list .o_kanban_view.o_kanban_ungrouped {
+ width: auto;
+
+ .o_kanban_record{
+ flex: 0 1 50%;
+ position: relative;
+
+ @include media-breakpoint-up(md) {
+ flex: 0 0 percentage(1/3);
+ }
+
+ @include media-breakpoint-up(lg) {
+ flex: 0 0 percentage(1/5);
+ }
+
+ @include media-breakpoint-up(xl) {
+ flex: 0 0 percentage(1/6);
+ }
+ // make the image square and in the center
+ .o_squared_image {
+ position: relative;
+ overflow: hidden;
+ padding-bottom: 100%;
+ > img {
+ position: absolute;
+ margin: auto;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ }
+ }
+
+ .o_product_image_size {
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+ }
+}
+
+.o_website_sale_image_modal {
+ .o_website_sale_image_modal_container {
+ border-left: 1px solid gray('400');
+
+ .o_field_image {
+ margin-bottom: 0;
+ box-shadow: 0 2px 10px gray('300');
+
+ > img {
+ border: 1px solid gray('400');
+ height: 200px;
+ width: auto;
+ }
+
+ .o_form_image_controls {
+ @include o-hover-opacity(0.7, 1);
+ padding: 2%;
+ }
+ }
+ }
+ .o_video_container {
+ height: 200px;
+ position: relative;
+ @include o-we-preview-box($text-muted);
+ .o_invalid_warning {
+ width: 90%;
+ @include o-position-absolute($top: 50%, $left: 50%);
+ transform: translate(-50%, -50%);
+ }
+ }
+}
diff --git a/addons/website_sale/static/src/scss/website_sale_dashboard.scss b/addons/website_sale/static/src/scss/website_sale_dashboard.scss
new file mode 100644
index 00000000..b509f220
--- /dev/null
+++ b/addons/website_sale/static/src/scss/website_sale_dashboard.scss
@@ -0,0 +1,81 @@
+.o_dashboard_sales {
+
+ h2 {
+ padding: 15px;
+ }
+ h4 { margin: 3px 0 4px 0 !important; }
+ .o_demo_background {
+ margin-top: 16px;
+ height: 350px;
+ background-size: 100% !important;
+ background: url("/website_sale/static/src/img/website_sale_dashboard_sales_demo.png") no-repeat;
+ position: relative;
+ opacity: 0.2;
+ }
+ .o_demo_message {
+ color: $o-main-color-muted;
+ width: 100%;
+ @include o-position-absolute($left: 0, $top: 12%);
+ display: flex;
+ justify-content: center;
+ }
+ .o_link_enable {
+ @include media-breakpoint-up(lg) {
+ border-right: 1px solid #ccc;
+ }
+ height: initial;
+ padding: 5px;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ a {
+ color: darken($o-brand-lightsecondary, 40%);
+ }
+ &:hover {
+ background-color: $o-brand-lightsecondary;
+ }
+ .o_highlight {
+ color: $o-brand-primary;
+ font-size: 20px;
+ font-weight: bold;
+ }
+ }
+ .o_link_disable {
+ @include media-breakpoint-up(lg) {
+ border-right: 1px solid #ccc;
+ }
+ height: initial;
+ padding: 5px;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin: 10px 0px;
+ color: darken($o-brand-lightsecondary, 40%);
+ .o_highlight {
+ font-size: 20px;
+ font-weight: bold;
+ }
+ }
+ .o_invisible_border {
+ border-right: 0px;
+ }
+ .o_top_margin {
+ margin-top: 20px;
+ }
+ .o_graph_canvas_container {
+ position: relative;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: 30em;
+ }
+ .o_dashboard_utms {
+ .utm_chart_image {
+ display: block;
+ margin: auto;
+ }
+ .utm_dropdown {
+ margin-top: -5px;
+ }
+ }
+} \ No newline at end of file
diff --git a/addons/website_sale/static/src/scss/website_sale_frontend.scss b/addons/website_sale/static/src/scss/website_sale_frontend.scss
new file mode 100644
index 00000000..613f247a
--- /dev/null
+++ b/addons/website_sale/static/src/scss/website_sale_frontend.scss
@@ -0,0 +1,140 @@
+//## Website Sale frontent design
+//## ----------------------------
+
+// Theming variables
+$o-wsale-wizard-thickness: 0.125rem;
+$o-wsale-wizard-dot-size: 0.625rem;
+$o-wsale-wizard-dot-active-glow: 0.25rem;
+
+$o-wsale-wizard-color-inner: white;
+$o-wsale-wizard-color-default: gray('200');
+
+$o-wsale-wizard-dot-active: theme-color('primary');
+$o-wsale-wizard-dot-completed: theme-color('success');
+
+$o-wsale-wizard-label-default: $text-muted;
+$o-wsale-wizard-label-active: $body-color;
+$o-wsale-wizard-label-completed: $success;
+
+.progress-wizard {
+ // Scoped variables
+ $tmp-dot-radius: ($o-wsale-wizard-dot-size + $o-wsale-wizard-thickness)*0.5;
+ $tmp-check-size: max($font-size-base, $o-wsale-wizard-dot-size + $o-wsale-wizard-thickness + $o-wsale-wizard-dot-active-glow*2);
+ $tmp-check-pos: $o-wsale-wizard-dot-size*0.5 - $tmp-check-size*0.5;
+
+ margin-top: $grid-gutter-width*0.5;
+ padding: 0 $grid-gutter-width*0.5;
+
+ @include media-breakpoint-up(md) {
+ padding: 0;
+ }
+
+ .progress-wizard-step {
+ position: relative;
+
+ @include media-breakpoint-up(md) {
+ margin-top: $tmp-dot-radius + $o-wsale-wizard-thickness*3.5;
+ float: left;
+ width: percentage(1/3);
+
+ .o_wizard_has_extra_step + & {
+ width: percentage(1/4);
+ }
+ }
+ @include media-breakpoint-down(sm) {
+ &.disabled, &.complete {
+ display:none;
+ }
+ }
+ .progress-wizard-dot {
+ width: $o-wsale-wizard-dot-size;
+ height: $o-wsale-wizard-dot-size;
+ position: relative;
+ display: inline-block;
+ background-color: $o-wsale-wizard-color-inner;
+ border-radius: 50%;
+ box-shadow: 0 0 0 $o-wsale-wizard-thickness $o-wsale-wizard-color-default;
+
+ @include media-breakpoint-up(md) {
+ @include o-position-absolute($left: 50%);
+ margin: (-$tmp-dot-radius) 0 0 (-$o-wsale-wizard-dot-size*0.5);
+ }
+ }
+
+ .progress-wizard-steplabel {
+ color: $o-wsale-wizard-label-default;
+ margin: 5px 0 5px 5px;
+ font-size: $font-size-base;
+ display: inline-block;
+
+ @include media-breakpoint-up(md) {
+ display: block;
+ margin: (0.625rem + $tmp-dot-radius) 0 20px 0;
+ }
+ @include media-breakpoint-down(sm) {
+ margin-left: -15px;
+ font-size: 24px;
+ }
+ }
+
+ .progress-wizard-bar {
+ height: $o-wsale-wizard-thickness;
+ background-color: $o-wsale-wizard-color-default;
+ }
+
+ &.active {
+ .progress-wizard-dot {
+ animation: fadeIn 1s ease 0s 1 normal none running;
+ background: $o-wsale-wizard-dot-active;
+ box-shadow: 0 0 0 ($o-wsale-wizard-dot-active-glow - 0.0625rem) $o-wsale-wizard-color-inner,
+ 0 0 0 $o-wsale-wizard-dot-active-glow rgba($o-wsale-wizard-dot-active, 0.5);
+ }
+
+ .progress-wizard-steplabel {
+ color: $o-wsale-wizard-label-active;
+ font-weight: bolder;
+ }
+ }
+
+ &.complete {
+ .progress-wizard-dot {
+ background: none;
+ box-shadow: none;
+
+ &:after {
+ @include o-position-absolute($tmp-check-pos, $left: $tmp-check-pos);
+ width: $tmp-check-size;
+ height: $tmp-check-size;
+ border-radius: 100%;
+
+ background: $o-wsale-wizard-color-inner;
+ color: $o-wsale-wizard-dot-completed;
+ text-align: center;
+ line-height: 1;
+ font-size: $tmp-check-size;
+ font-family: FontAwesome;
+
+ content: "\f058";
+ }
+ }
+
+ .progress-wizard-steplabel {
+ color: $o-wsale-wizard-label-completed;
+ }
+
+ &:hover:not(.disabled) {
+ .progress-wizard-dot:after {
+ color: $o-wsale-wizard-label-completed;
+ }
+
+ .progress-wizard-steplabel {
+ color: $o-wsale-wizard-label-active;
+ }
+ }
+ }
+
+ &.disabled {
+ cursor: default;
+ }
+ }
+}
diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js
new file mode 100644
index 00000000..2bd13284
--- /dev/null
+++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js
@@ -0,0 +1,58 @@
+odoo.define('website_sale.s_dynamic_snippet_products', function (require) {
+'use strict';
+
+const config = require('web.config');
+const core = require('web.core');
+const publicWidget = require('web.public.widget');
+const DynamicSnippetCarousel = require('website.s_dynamic_snippet_carousel');
+
+const DynamicSnippetProducts = DynamicSnippetCarousel.extend({
+ selector: '.s_dynamic_snippet_products',
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Method to be overridden in child components if additional configuration elements
+ * are required in order to fetch data.
+ * @override
+ * @private
+ */
+ _isConfigComplete: function () {
+ return this._super.apply(this, arguments) && this.$el.get(0).dataset.productCategoryId !== undefined;
+ },
+ /**
+ *
+ * @override
+ * @private
+ */
+ _mustMessageWarningBeHidden: function() {
+ const isInitialDrop = this.$el.get(0).dataset.templateKey === undefined;
+ // This snippet has default values obtained after the initial start and render after drop.
+ // Because of this there is an initial refresh happening right after.
+ // We want to avoid showing the incomplete config message before this refresh.
+ // Since the refreshed call will always happen with a defined templateKey,
+ // if it is not set yet, we know it is the drop call and we can avoid showing the message.
+ return isInitialDrop || this._super.apply(this, arguments);
+ },
+ /**
+ * Method to be overridden in child components in order to provide a search
+ * domain if needed.
+ * @override
+ * @private
+ */
+ _getSearchDomain: function () {
+ const searchDomain = this._super.apply(this, arguments);
+ const productCategoryId = parseInt(this.$el.get(0).dataset.productCategoryId);
+ if (productCategoryId >= 0) {
+ searchDomain.push(['public_categ_ids', 'child_of', productCategoryId]);
+ }
+ return searchDomain;
+ },
+
+});
+publicWidget.registry.dynamic_snippet_products = DynamicSnippetProducts;
+
+return DynamicSnippetProducts;
+});
diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml
new file mode 100644
index 00000000..f45bf3c5
--- /dev/null
+++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="website_sale.s_dynamic_snippet.products" inherit_id="website.s_dynamic_snippet.carousel">
+ <xpath expr="//div" position="attributes">
+ <attribute name="data-filter-id" add=""/>
+ </xpath>
+ </t>
+</templates>
diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js
new file mode 100644
index 00000000..3dabb014
--- /dev/null
+++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js
@@ -0,0 +1,109 @@
+odoo.define('website_sale.s_dynamic_snippet_products_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+const s_dynamic_snippet_carousel_options = require('website.s_dynamic_snippet_carousel_options');
+
+const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend({
+
+ /**
+ *
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.productCategories = {};
+ },
+ /**
+ *
+ * @override
+ */
+ onBuilt: function () {
+ this._super.apply(this, arguments);
+ this._rpc({
+ route: '/website_sale/snippet/options_filters'
+ }).then((data) => {
+ if (data.length) {
+ this.$target.get(0).dataset.filterId = data[0].id;
+ this.$target.get(0).dataset.numberOfRecords = this.dynamicFilters[data[0].id].limit;
+ this._refreshPublicWidgets();
+ // Refresh is needed because default values are obtained after start()
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * @override
+ * @private
+ */
+ _computeWidgetVisibility: function (widgetName, params) {
+ if (widgetName === 'filter_opt') {
+ return false;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Fetches product categories.
+ * @private
+ * @returns {Promise}
+ */
+ _fetchProductCategories: function () {
+ return this._rpc({
+ model: 'product.public.category',
+ method: 'search_read',
+ kwargs: {
+ domain: [],
+ fields: ['id', 'name'],
+ }
+ });
+ },
+ /**
+ *
+ * @override
+ * @private
+ */
+ _renderCustomXML: async function (uiFragment) {
+ await this._super.apply(this, arguments);
+ await this._renderProductCategorySelector(uiFragment);
+ },
+ /**
+ * Renders the product categories option selector content into the provided uiFragment.
+ * @private
+ * @param {HTMLElement} uiFragment
+ */
+ _renderProductCategorySelector: async function (uiFragment) {
+ const productCategories = await this._fetchProductCategories();
+ for (let index in productCategories) {
+ this.productCategories[productCategories[index].id] = productCategories[index];
+ }
+ const productCategoriesSelectorEl = uiFragment.querySelector('[data-name="product_category_opt"]');
+ return this._renderSelectUserValueWidgetButtons(productCategoriesSelectorEl, this.productCategories);
+ },
+ /**
+ * Sets default options values.
+ * @override
+ * @private
+ */
+ _setOptionsDefaultValues: function () {
+ this._super.apply(this, arguments);
+ const templateKeys = this.$el.find("we-select[data-attribute-name='templateKey'] we-selection-items we-button");
+ if (templateKeys.length > 0) {
+ this._setOptionValue('templateKey', templateKeys.attr('data-select-data-attribute'));
+ }
+ const productCategories = this.$el.find("we-select[data-attribute-name='productCategoryId'] we-selection-items we-button");
+ if (productCategories.length > 0) {
+ this._setOptionValue('productCategoryId', productCategories.attr('data-select-data-attribute'));
+ }
+ },
+
+});
+
+options.registry.dynamic_snippet_products = dynamicSnippetProductsOptions;
+
+return dynamicSnippetProductsOptions;
+});
diff --git a/addons/website_sale/static/src/snippets/s_products_searchbar/000.js b/addons/website_sale/static/src/snippets/s_products_searchbar/000.js
new file mode 100644
index 00000000..2aaafca2
--- /dev/null
+++ b/addons/website_sale/static/src/snippets/s_products_searchbar/000.js
@@ -0,0 +1,136 @@
+odoo.define('website_sale.s_products_searchbar', function (require) {
+'use strict';
+
+const concurrency = require('web.concurrency');
+const publicWidget = require('web.public.widget');
+
+const { qweb } = require('web.core');
+
+/**
+ * @todo maybe the custom autocomplete logic could be extract to be reusable
+ */
+publicWidget.registry.productsSearchBar = publicWidget.Widget.extend({
+ selector: '.o_wsale_products_searchbar_form',
+ xmlDependencies: ['/website_sale/static/src/xml/website_sale_utils.xml'],
+ events: {
+ 'input .search-query': '_onInput',
+ 'focusout': '_onFocusOut',
+ 'keydown .search-query': '_onKeydown',
+ },
+ autocompleteMinWidth: 300,
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this._dp = new concurrency.DropPrevious();
+
+ this._onInput = _.debounce(this._onInput, 400);
+ this._onFocusOut = _.debounce(this._onFocusOut, 100);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$input = this.$('.search-query');
+
+ this.order = this.$('.o_wsale_search_order_by').val();
+ this.limit = parseInt(this.$input.data('limit'));
+ this.displayDescription = !!this.$input.data('displayDescription');
+ this.displayPrice = !!this.$input.data('displayPrice');
+ this.displayImage = !!this.$input.data('displayImage');
+
+ if (this.limit) {
+ this.$input.attr('autocomplete', 'off');
+ }
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _fetch: function () {
+ return this._rpc({
+ route: '/shop/products/autocomplete',
+ params: {
+ 'term': this.$input.val(),
+ 'options': {
+ 'order': this.order,
+ 'limit': this.limit,
+ 'display_description': this.displayDescription,
+ 'display_price': this.displayPrice,
+ 'max_nb_chars': Math.round(Math.max(this.autocompleteMinWidth, parseInt(this.$el.width())) * 0.22),
+ },
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _render: function (res) {
+ var $prevMenu = this.$menu;
+ this.$el.toggleClass('dropdown show', !!res);
+ if (res) {
+ var products = res['products'];
+ this.$menu = $(qweb.render('website_sale.productsSearchBar.autocomplete', {
+ products: products,
+ hasMoreProducts: products.length < res['products_count'],
+ currency: res['currency'],
+ widget: this,
+ }));
+ this.$menu.css('min-width', this.autocompleteMinWidth);
+ this.$el.append(this.$menu);
+ }
+ if ($prevMenu) {
+ $prevMenu.remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInput: function () {
+ if (!this.limit) {
+ return;
+ }
+ this._dp.add(this._fetch()).then(this._render.bind(this));
+ },
+ /**
+ * @private
+ */
+ _onFocusOut: function () {
+ if (!this.$el.has(document.activeElement).length) {
+ this._render();
+ }
+ },
+ /**
+ * @private
+ */
+ _onKeydown: function (ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.ESCAPE:
+ this._render();
+ break;
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN:
+ ev.preventDefault();
+ if (this.$menu) {
+ let $element = ev.which === $.ui.keyCode.UP ? this.$menu.children().last() : this.$menu.children().first();
+ $element.focus();
+ }
+ break;
+ }
+ },
+});
+});
diff --git a/addons/website_sale/static/src/xml/website_sale.xml b/addons/website_sale/static/src/xml/website_sale.xml
new file mode 100644
index 00000000..2b9e9e9a
--- /dev/null
+++ b/addons/website_sale/static/src/xml/website_sale.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<templates xml:space="preserve">
+
+ <t t-name="productVideo">
+ <div class="embed-responsive embed-responsive-16by9 mt-2" t-if="embedCode">
+ <t t-raw="embedCode"/>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/website_sale/static/src/xml/website_sale_dashboard.xml b/addons/website_sale/static/src/xml/website_sale_dashboard.xml
new file mode 100644
index 00000000..61b2ad79
--- /dev/null
+++ b/addons/website_sale/static/src/xml/website_sale_dashboard.xml
@@ -0,0 +1,162 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates>
+ <t t-extend="website.dashboard_header">
+ <t t-jquery="div.o_dashboard_common" t-operation="append">
+ <div class="col-12 o_box" t-if="widget.dashboards_data.sales.summary.order_unpaid_count || widget.dashboards_data.sales.summary.order_to_invoice_count || widget.dashboards_data.sales.summary.payment_to_capture_count || widget.dashboards_data.sales.summary.order_carts_abandoned_count">
+ <div t-if="widget.dashboards_data.sales.summary.order_unpaid_count" class="o_inner_box o_dashboard_action" title="Confirm orders when you get paid." name="website_sale.action_unpaid_orders_ecommerce">
+ <div class="o_highlight"><t t-esc="widget.dashboards_data.sales.summary.order_unpaid_count"/></div>
+ Unpaid Orders
+ </div>
+ <div t-if="widget.dashboards_data.sales.summary.order_to_invoice_count" class="o_inner_box o_dashboard_action" title="Generate an invoice from orders ready for invoicing." name="website_sale.sale_order_action_to_invoice">
+ <div class="o_highlight"><t t-esc="widget.dashboards_data.sales.summary.order_to_invoice_count"/></div>
+ Orders to Invoice
+ </div>
+ <div t-if="widget.dashboards_data.sales.summary.payment_to_capture_count" class="o_inner_box o_dashboard_action" title="Capture order payments when the delivery is completed." name="website_sale.payment_transaction_action_payments_to_capture">
+ <div class="o_highlight"><t t-esc="widget.dashboards_data.sales.summary.payment_to_capture_count"/></div>
+ Payments to Capture
+ </div>
+ <div t-if="widget.dashboards_data.sales.summary.order_carts_abandoned_count" class="o_inner_box o_dashboard_action" title="Send a recovery email to visitors who haven't completed their order." name="website_sale.action_view_abandoned_tree">
+ <div class="o_highlight"><t t-esc="widget.dashboards_data.sales.summary.order_carts_abandoned_count"/></div>
+ Abandoned Carts
+ </div>
+ </div>
+ </t>
+ </t>
+
+ <t t-extend="website.dashboard_content">
+ <t t-jquery="div.o_website_dashboard_content" t-operation="prepend">
+ <div t-if="widget.groups.sale_salesman" class="row o_dashboard_sales">
+ <div class="col-12 row o_box">
+ <t t-if="widget.dashboards_data.sales.summary.order_count">
+ <h2 class="col-lg-7 col-12">
+ <t t-if="widget.date_range=='week'">
+ Sales Since Last Week
+ </t>
+ <t t-elif="widget.date_range=='month'">
+ Sales Since Last Month
+ </t>
+ <t t-elif="widget.date_range=='year'">
+ Sales Since Last Year
+ </t>
+ <t t-else="">Sales</t>
+ </h2>
+ <h4 class='col-lg-5 col-12'>AT A GLANCE</h4>
+ <div class="col-lg-7 col-12">
+ <div class="o_graph_sales" data-type="sales"/>
+ </div>
+ <div class="col-lg-5 col-12">
+ <t t-call="website_sale.products_table"/>
+ </div>
+ </t>
+ <t t-if="! widget.dashboards_data.sales.summary.order_count">
+ <t t-if="widget.date_range=='week'">
+ <h2>Sales Since Last Week</h2>
+ </t>
+ <t t-elif="widget.date_range=='month'">
+ <h2>Sales Since Last Month</h2>
+ </t>
+ <t t-elif="widget.date_range=='year'">
+ <h2>Sales Since Last Year</h2>
+ </t>
+ <t t-else=""><h2>Sales</h2></t>
+ <div class="col-lg-12 col-12">
+ <div class="o_demo_background">
+ </div>
+ <div class="o_demo_message">
+ <h3>There is no recent confirmed order.</h3>
+ </div>
+ </div>
+ </t>
+ </div>
+ </div>
+ </t>
+ </t>
+
+ <t t-name="website_sale.products_table">
+ <div class="row">
+ <a href="#" class="col-md-4 o_dashboard_action" name="website_sale.sale_report_action_dashboard">
+ <div class="o_link_enable" title="Orders">
+ <div class="o_highlight">
+ <t t-esc="widget.dashboards_data.sales.summary.order_count"/>
+ </div>
+ Orders
+ </div>
+ </a>
+ <a href="#" class="col-md-4 o_dashboard_action" name="website_sale.sale_report_action_dashboard">
+ <div class="o_link_enable" title="Untaxed Total Sold">
+ <div class="o_highlight">
+ <t t-esc="widget.render_monetary_field(widget.dashboards_data.sales.summary.total_sold, widget.data.currency)"/>
+ </div>
+ Sold
+ </div>
+ </a>
+ <a href="#" class="col-md-4 o_dashboard_action" name="website_sale.sale_report_action_carts">
+ <div class="o_link_enable o_invisible_border" title="Carts">
+ <div class="o_highlight"><t t-esc="widget.dashboards_data.sales.summary.order_carts_count"/></div>
+ Carts
+ </div>
+ </a>
+ <div class="col-md-4 o_link_disable" title="Orders/Day">
+ <div class="o_highlight"><t t-esc="widget.dashboards_data.sales.summary.order_per_day_ratio"/></div>
+ Orders/Day
+ </div>
+ <div class="col-md-4 o_link_disable" title="Average Order">
+ <div class="o_highlight"><t t-esc="widget.render_monetary_field(widget.dashboards_data.sales.summary.order_sold_ratio, widget.data.currency)"/></div>
+ Average Order
+ </div>
+ <div class="col-md-4 o_link_disable o_invisible_border" title="Conversion">
+ <div class="o_highlight"><t t-esc="widget.format_number(widget.dashboards_data.sales.summary.order_convertion_pctg, 'float', [3, 2], '%')"/></div>
+ Conversion
+ </div>
+ </div>
+ <div class="col-lg-12 col-12 o_top_margin">
+ <div class="row">
+ <div class="col-lg-12 col-12">
+ <h4>Best Sellers</h4>
+ <table class="table table-responsive table-hover">
+ <tr>
+ <th>Product</th>
+ <th>Quantity</th>
+ <th>Sold</th>
+ </tr>
+ <tr class="o_product_template" t-foreach="widget.dashboards_data.sales.best_sellers" t-as="product" t-att-data-product-id="product.id">
+ <td><t t-esc="product.name"/></td>
+ <td><t t-esc="product.qty"/></td>
+ <td><t t-esc="widget.render_monetary_field(product.sales, widget.data.currency)"/></td>
+ </tr>
+ </table>
+ </div>
+ </div>
+ </div>
+ </t>
+
+ <t t-extend="website_sale.products_table">
+ <t t-jquery=".o_top_margin .row .col-12" t-operation="attributes">
+ <attribute name="class" value="col-lg-6 col-12" />
+ </t>
+ <t t-jquery=".o_top_margin .row" t-operation="append">
+ <div class="col-lg-6 col-12 o_dashboard_utms">
+ <div>
+ <h4 class="float-left">REVENUE BY</h4>
+ <t t-call="website_sale.LinkTrackersDropDown"/>
+ </div>
+ <div class="o_utm_no_data_img">
+ <img src="website_sale/static/src/img/website_sale_chart_demo.png" alt="There isn't any UTM tag detected in orders" class="utm_chart_image image-responsive mt8"/>
+ </div>
+ <div class="o_utm_data_graph"/>
+ </div>
+ </t>
+ </t>
+
+ <t t-name="website_sale.LinkTrackersDropDown">
+ <div class="dropdown">
+ <button class="btn btn-secondary dropdown-toggle utm_dropdown ml4" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true"><span class="utm_button_name">Campaigns</span>
+ </button>
+ <div class="dropdown-menu" role="menu" aria-labelledby="utm_dropdown">
+ <a name="campaign_id" class="dropdown-item js_utm_selector" role="menuitem">Campaigns</a>
+ <a name="medium_id" class="dropdown-item js_utm_selector" role="menuitem">Medium</a>
+ <a name="source_id" class="dropdown-item js_utm_selector" role="menuitem">Sources</a>
+ </div>
+ </div>
+ </t>
+</templates>
diff --git a/addons/website_sale/static/src/xml/website_sale_recently_viewed.xml b/addons/website_sale/static/src/xml/website_sale_recently_viewed.xml
new file mode 100644
index 00000000..34b9ec0e
--- /dev/null
+++ b/addons/website_sale/static/src/xml/website_sale_recently_viewed.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <!-- Recently Viewed Products Carousel -->
+ <t t-name="website_sale.productsRecentlyViewed">
+ <div t-att-id="uniqueId" class="carousel slide o_not_editable" data-interval="false">
+ <div class="carousel-inner">
+ <t t-foreach="productsGroups" t-as="products">
+ <div t-attf-class="carousel-item #{!products_index and 'active' or ''}">
+ <div class="row">
+ <t t-foreach="products" t-as="product">
+ <div t-attf-class="o_carousel_product_card_wrap col-md-#{12 / productFrame}">
+ <div class="o_carousel_product_card card h-100">
+ <input type="hidden" name="product-id" t-att-data-product-id="product.id"/>
+ <a class="o_carousel_product_img_link" t-att-href="product.website_url">
+ <img class="o_carousel_product_card_img_top card-img-top" t-attf-src="/web/image/product.product/#{product.id}#{productFrame == 1 ? '/image_256' : '/image_512'}" t-att-alt="product.display_name"/>
+ </a>
+ <i class="fa fa-trash o_carousel_product_remove js_remove"></i>
+ <div class="o_carousel_product_card_body card-body border-top">
+ <a t-att-href="product.website_url" class="text-decoration-none">
+ <h6 class="card-title mb-0 text-truncate" t-raw="product.display_name"/>
+ </a>
+ <t t-if="product.rating" t-raw="product.rating"/>
+ </div>
+ <div class="o_carousel_product_card_footer card-footer d-flex align-items-center">
+ <div class="d-block font-weight-bold" t-raw="product.price"/>
+ <button type="button" role="button" class="btn btn-primary js_add_cart ml-auto" title="Add to Cart">
+ <i class="fa fa-fw fa-shopping-cart"/>
+ </button>
+ </div>
+ </div>
+ </div>
+ </t>
+ </div>
+ </div>
+ </t>
+ </div>
+ <t t-if='productsGroups.length > 1'>
+ <a class="o_carousel_product_control carousel-control-prev" t-att-href="'#' + uniqueId" role="button" data-slide="prev">
+ <span class="carousel-control-prev-icon"></span>
+ <span class="sr-only">Previous</span>
+ </a>
+ <a class="o_carousel_product_control carousel-control-next" t-att-href="'#' + uniqueId" role="button" data-slide="next">
+ <span class="carousel-control-next-icon"></span>
+ <span class="sr-only">Next</span>
+ </a>
+ </t>
+ </div>
+ </t>
+</templates>
diff --git a/addons/website_sale/static/src/xml/website_sale_utils.xml b/addons/website_sale/static/src/xml/website_sale_utils.xml
new file mode 100644
index 00000000..8a470613
--- /dev/null
+++ b/addons/website_sale/static/src/xml/website_sale_utils.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+<!-- Products Search Bar autocomplete item -->
+<div t-name="website_sale.productsSearchBar.autocomplete"
+ class="dropdown-menu show w-100">
+ <t t-if="!products.length">
+ <span class="dropdown-item-text text-muted">No results found. Please try another search.</span>
+ </t>
+ <a t-foreach="products" t-as="product"
+ t-att-href="product['website_url']" class="dropdown-item p-2 text-wrap">
+ <div class="media align-items-center o_search_product_item">
+ <t t-if="widget.displayImage">
+ <img t-attf-src="/web/image/product.template/#{product['id']}/image_128"
+ class="flex-shrink-0 o_image_64_contain"/>
+ </t>
+ <div class="media-body px-3">
+ <t t-set="description" t-value="widget.displayDescription and product['description_sale']"/>
+ <h6 t-attf-class="font-weight-bold #{description ? '' : 'mb-0'}" t-esc="product['name']"/>
+ <p t-if="description" class="mb-0" t-esc="description"/>
+ </div>
+ <div t-if="widget.displayPrice" class="flex-shrink-0">
+ <t t-if="product['has_discounted_price']">
+ <span class="text-danger text-nowrap" style="text-decoration: line-through;">
+ <t t-raw="product['list_price']"/>
+ </span>
+ <br/>
+ </t>
+ <b class="text-nowrap">
+ <t t-raw="product['price']"/>
+ </b>
+ </div>
+ </div>
+ </a>
+ <t t-if="hasMoreProducts">
+ <button type="submit" class="dropdown-item text-center text-primary">All results</button>
+ </t>
+</div>
+
+<!-- Products Search Bar autocomplete item -->
+<we-button t-name="website_sale.ribbonSelectItem" t-att-data-set-ribbon="ribbon.id">
+ <t t-raw="ribbon.html"/>
+ <span t-attf-class="fa fa-#{isTag ? 'tag' : 'bookmark'} ml-auto"></span>
+ <span t-attf-class="fa fa-arrow-#{isLeft ? 'left' : 'right'} ml-1"></span>
+ <span t-attf-class="o_wsale_color_preview #{colorClasses} ml-1" t-attf-style="background-color: #{ribbon.bg_color}"></span>
+ <span t-attf-class="o_wsale_color_preview #{colorClasses} ml-1" t-attf-style="background-color: #{textColor} !important;"></span>
+</we-button>
+
+</templates>