diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website_sale/static/src | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website_sale/static/src')
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 Binary files differnew file mode 100644 index 00000000..26f107a7 --- /dev/null +++ b/addons/website_sale/static/src/img/AZERTY.jpg diff --git a/addons/website_sale/static/src/img/accessory1.jpg b/addons/website_sale/static/src/img/accessory1.jpg Binary files differnew file mode 100644 index 00000000..145c5761 --- /dev/null +++ b/addons/website_sale/static/src/img/accessory1.jpg diff --git a/addons/website_sale/static/src/img/accessory1_features.png b/addons/website_sale/static/src/img/accessory1_features.png Binary files differnew file mode 100644 index 00000000..14492050 --- /dev/null +++ b/addons/website_sale/static/src/img/accessory1_features.png diff --git a/addons/website_sale/static/src/img/anywhere_anything.png b/addons/website_sale/static/src/img/anywhere_anything.png Binary files differnew file mode 100644 index 00000000..22c0ac65 --- /dev/null +++ b/addons/website_sale/static/src/img/anywhere_anything.png diff --git a/addons/website_sale/static/src/img/apps.png b/addons/website_sale/static/src/img/apps.png Binary files differnew file mode 100644 index 00000000..f2057b8c --- /dev/null +++ b/addons/website_sale/static/src/img/apps.png diff --git a/addons/website_sale/static/src/img/bluetooth.jpg b/addons/website_sale/static/src/img/bluetooth.jpg Binary files differnew file mode 100644 index 00000000..18e5681c --- /dev/null +++ b/addons/website_sale/static/src/img/bluetooth.jpg diff --git a/addons/website_sale/static/src/img/buds_closeup.png b/addons/website_sale/static/src/img/buds_closeup.png Binary files differnew file mode 100644 index 00000000..4c842a5d --- /dev/null +++ b/addons/website_sale/static/src/img/buds_closeup.png diff --git a/addons/website_sale/static/src/img/design.png b/addons/website_sale/static/src/img/design.png Binary files differnew file mode 100644 index 00000000..ce8a21fb --- /dev/null +++ b/addons/website_sale/static/src/img/design.png diff --git a/addons/website_sale/static/src/img/imac1.png b/addons/website_sale/static/src/img/imac1.png Binary files differnew file mode 100644 index 00000000..59fc3de8 --- /dev/null +++ b/addons/website_sale/static/src/img/imac1.png diff --git a/addons/website_sale/static/src/img/imac2.png b/addons/website_sale/static/src/img/imac2.png Binary files differnew file mode 100644 index 00000000..894ed174 --- /dev/null +++ b/addons/website_sale/static/src/img/imac2.png diff --git a/addons/website_sale/static/src/img/ipad_experience.png b/addons/website_sale/static/src/img/ipad_experience.png Binary files differnew file mode 100644 index 00000000..387fc2d2 --- /dev/null +++ b/addons/website_sale/static/src/img/ipad_experience.png diff --git a/addons/website_sale/static/src/img/ipad_why.png b/addons/website_sale/static/src/img/ipad_why.png Binary files differnew file mode 100644 index 00000000..11015696 --- /dev/null +++ b/addons/website_sale/static/src/img/ipad_why.png diff --git a/addons/website_sale/static/src/img/keyboard.png b/addons/website_sale/static/src/img/keyboard.png Binary files differnew file mode 100644 index 00000000..308d5c55 --- /dev/null +++ b/addons/website_sale/static/src/img/keyboard.png diff --git a/addons/website_sale/static/src/img/mighty.png b/addons/website_sale/static/src/img/mighty.png Binary files differnew file mode 100644 index 00000000..c9270a24 --- /dev/null +++ b/addons/website_sale/static/src/img/mighty.png diff --git a/addons/website_sale/static/src/img/more_features.png b/addons/website_sale/static/src/img/more_features.png Binary files differnew file mode 100644 index 00000000..4cf5a8c6 --- /dev/null +++ b/addons/website_sale/static/src/img/more_features.png diff --git a/addons/website_sale/static/src/img/overview_design_silver.png b/addons/website_sale/static/src/img/overview_design_silver.png Binary files differnew file mode 100644 index 00000000..50e37e9e --- /dev/null +++ b/addons/website_sale/static/src/img/overview_design_silver.png diff --git a/addons/website_sale/static/src/img/overview_hero.png b/addons/website_sale/static/src/img/overview_hero.png Binary files differnew file mode 100644 index 00000000..f6e4fd3e --- /dev/null +++ b/addons/website_sale/static/src/img/overview_hero.png diff --git a/addons/website_sale/static/src/img/planner_product_page.png b/addons/website_sale/static/src/img/planner_product_page.png Binary files differnew file mode 100644 index 00000000..ec8e6106 --- /dev/null +++ b/addons/website_sale/static/src/img/planner_product_page.png 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 Binary files differnew file mode 100644 index 00000000..0364ed84 --- /dev/null +++ b/addons/website_sale/static/src/img/play_where_you_play.jpg diff --git a/addons/website_sale/static/src/img/promo_headphones.png b/addons/website_sale/static/src/img/promo_headphones.png Binary files differnew file mode 100644 index 00000000..21bc935f --- /dev/null +++ b/addons/website_sale/static/src/img/promo_headphones.png diff --git a/addons/website_sale/static/src/img/purple.png b/addons/website_sale/static/src/img/purple.png Binary files differnew file mode 100644 index 00000000..b7f33aa4 --- /dev/null +++ b/addons/website_sale/static/src/img/purple.png diff --git a/addons/website_sale/static/src/img/redcross.png b/addons/website_sale/static/src/img/redcross.png Binary files differnew file mode 100644 index 00000000..92a87401 --- /dev/null +++ b/addons/website_sale/static/src/img/redcross.png 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 Binary files differnew file mode 100644 index 00000000..0cb7a5cf --- /dev/null +++ b/addons/website_sale/static/src/img/website_sale_chart_demo.png 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 Binary files differnew file mode 100644 index 00000000..4338a722 --- /dev/null +++ b/addons/website_sale/static/src/img/website_sale_dashboard_sales_demo.png diff --git a/addons/website_sale/static/src/img/wireless.png b/addons/website_sale/static/src/img/wireless.png Binary files differnew file mode 100644 index 00000000..a379ffcf --- /dev/null +++ b/addons/website_sale/static/src/img/wireless.png 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">×</button> ' + data.warning + '</div>'); + } + else { + cart_alert.html('<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</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> |
