Website: Product Configurator and Bootstrap 4 (#15965)

- Refactored Homepage with customisable Hero Section
- New Homepage Section to add content on Homepage as cards or using Custom HTML
- Products page at "/all-products" with customisable filters
- Item Configure dialog to find an Item Variant filtered by attribute values
- Contact Us dialog on Item page
- Customisable Item page content using the Website Content field
This commit is contained in:
Faris Ansari
2019-03-19 11:48:32 +05:30
committed by GitHub
parent f060831cce
commit 5f8b358fd4
93 changed files with 5057 additions and 1622 deletions

View File

@@ -1,143 +0,0 @@
{% extends "templates/web.html" %}
{% block title %} {{ title }} {% endblock %}
{% block breadcrumbs %}
{% include "templates/includes/breadcrumbs.html" %}
{% endblock %}
{% block page_content %}
{% from "erpnext/templates/includes/macros.html" import product_image %}
<div class="item-content">
<div class="product-page-content" itemscope itemtype="http://schema.org/Product">
<div class="row">
<div class="row">
{% if slideshow %}
{% set slideshow_items = frappe.get_list(doctype="Website Slideshow Item", fields=["image"], filters={ "parent": doc.slideshow }) %}
<div class="col-md-1">
{%- for slideshow_item in slideshow_items -%}
{% set image_src = slideshow_item['image'] %}
{% if image_src %}
<div class="item-alternative-image border">
<img src="{{ image_src }}" height="50" weight="50" />
</div>
{% endif %}
{% endfor %}
</div>
<div class="col-md-5">
<div class="item-image">
{% set first_image = slideshow_items[0]['image'] %}
{{ product_image(first_image, "product-full-image") }}
</div>
</div>
{% else %}
<div class="col-md-6">
{{ product_image(website_image, "product-full-image") }}
</div>
{% endif %}
<div class="col-sm-6">
<h2 itemprop="name">{{ item_name }}</h2>
<p class="text-muted">
{{ _("Item Code") }}: <span itemprop="productID">{{ variant and variant.name or name }}</span>
</p>
<br>
<div class="item-attribute-selectors">
{% if has_variants and attributes %}
{% for d in attributes %}
{% if attribute_values[d.attribute] -%}
<div class="item-view-attribute {% if (attribute_values[d.attribute] | len)==1 -%} hidden {%- endif %}"
style="margin-bottom: 10px;">
<h6 class="text-muted">{{ _(d.attribute) }}</h6>
<select class="form-control"
style="max-width: 140px"
data-attribute="{{ d.attribute }}">
{% for value in attribute_values[d.attribute] %}
<option value="{{ value }}"
{% if selected_attributes and selected_attributes[d.attribute]==value -%}
selected
{%- elif disabled_attributes and value in disabled_attributes.get(d.attribute, []) -%}
disabled
{%- endif %}>
{{ _(value) }}
</option>
{% endfor %}
</select>
</div>
{%- endif %}
{% endfor %}
{% endif %}
</div>
<br>
<div>
<div itemprop="offers" itemscope itemtype="http://schema.org/Offer">
<h4 class="item-price hide" itemprop="price"></h4>
<div class="item-stock hide" itemprop="availability"></div>
</div>
<div class="item-cart hide">
<div id="item-spinner">
<span style="display: inline-block">
<div class="input-group number-spinner">
<span class="input-group-btn">
<button class="btn btn-default cart-btn" data-dir="dwn">
</button>
</span>
<input class="form-control text-right cart-qty" value="1">
<span class="input-group-btn">
<button class="btn btn-default cart-btn" data-dir="up" style="margin-left:-2px;">
+</button>
</span>
</div>
</span>
</div>
<div id="item-add-to-cart">
<button class="btn btn-primary btn-sm">
{{ _("Add to Cart") }}</button>
</div>
<div id="item-update-cart" style="display: none;">
<a href="/cart" class='btn btn-sm btn-default'>
<i class='octicon octicon-check'></i>
{{ _("View in Cart") }}</a>
</div>
</div>
</div>
</div>
</div>
<div class="row item-website-description margin-top">
<div class="col-md-12">
<div class="h6 text-uppercase">{{ _("Description") }}</div>
<div itemprop="description" class="item-desc">
{{ web_long_description or description or _("No description given") }}
</div>
</div>
</div>
{% if website_specifications -%}
<div class="row item-website-specification margin-top">
<div class="col-md-12">
<div class="h6 text-uppercase">{{ _("Specifications") }}</div>
<table class="table">
{% for d in website_specifications -%}
<tr>
<td class="text-muted" style="width: 30%;">{{ d.label }}</td>
<td>{{ d.description }}</td>
</tr>
{%- endfor %}
</table>
</div>
</div>
{%- endif %}
</div>
</div>
</div>
<script>
{% include "templates/includes/product_page.js" %}
{% if variant_info %}
window.variant_info = {{ variant_info }};
{% else %}
window.variant_info = null;
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "templates/web.html" %}
{% block title %} {{ title }} {% endblock %}
{% block breadcrumbs %}
{% include "templates/includes/breadcrumbs.html" %}
{% endblock %}
{% block page_content %}
{% from "erpnext/templates/includes/macros.html" import product_image %}
<div class="item-content">
<div class="product-page-content" itemscope itemtype="http://schema.org/Product">
<div class="row mb-5">
{% include "templates/generators/item/item_image.html" %}
{% include "templates/generators/item/item_details.html" %}
</div>
{% include "templates/generators/item/item_specifications.html" %}
{{ doc.website_content or '' }}
</div>
</div>
{% endblock %}
{% block base_scripts %}
<!-- js should be loaded in body! -->
<script type="text/javascript" src="/assets/frappe/js/lib/jquery/jquery.min.js"></script>
<script type="text/javascript" src="/assets/js/frappe-web.min.js"></script>
<script type="text/javascript" src="/assets/js/control.min.js"></script>
<script type="text/javascript" src="/assets/js/dialog.min.js"></script>
<script type="text/javascript" src="/assets/js/bootstrap-4-web.min.js"></script>
{% endblock %}

View File

@@ -0,0 +1,67 @@
{% if shopping_cart and shopping_cart.cart_settings.enabled %}
{% set cart_settings = shopping_cart.cart_settings %}
{% set product_info = shopping_cart.product_info %}
<div class="item-cart row mt-2" data-variant-item-code="{{ item_code }}">
<div class="col-md-12">
{% if cart_settings.show_price and product_info.price %}
<h4>
{{ product_info.price.formatted_price_sales_uom }}
<small class="text-muted">({{ product_info.price.formatted_price }} / {{ product_info.uom }})</small>
</h4>
{% endif %}
{% if cart_settings.show_stock_availability %}
<div>
{% if product_info.in_stock == 0 %}
<span class="text-danger">
{{ _('Not in stock') }}
</span>
{% elif product_info.in_stock == 1 %}
<span class="text-success">
{{ _('In stock') }}
{% if product_info.show_stock_qty and product_info.stock_qty %}
({{ product_info.stock_qty[0][0] }})
{% endif %}
</span>
{% endif %}
</div>
{% endif %}
<div class="mt-3">
<a href="/cart"
class="btn btn-light btn-view-in-cart {% if not product_info.qty %}hidden{% endif %}"
role="button"
>
{{ _("View in Cart") }}
</a>
<button
data-item-code="{{item_code}}"
class="btn btn-outline-primary btn-add-to-cart {% if product_info.qty %}hidden{% endif %}"
>
{{ _("Add to Cart") }}
</button>
</div>
</div>
</div>
<script>
frappe.ready(() => {
$('.page_content').on('click', '.btn-add-to-cart', (e) => {
const $btn = $(e.currentTarget);
$btn.prop('disabled', true);
const item_code = $btn.data('item-code');
erpnext.shopping_cart.update_cart({
item_code,
qty: 1,
callback(r) {
$btn.prop('disabled', false);
if (r.message) {
$('.btn-add-to-cart, .btn-view-in-cart').toggleClass('hidden');
}
}
});
});
});
</script>
{% endif %}

View File

@@ -0,0 +1,23 @@
{% if shopping_cart and shopping_cart.cart_settings.enabled %}
{% set cart_settings = shopping_cart.cart_settings %}
<div class="mt-3">
{% if cart_settings.show_configure_button | int %}
<button class="btn btn-primary btn-configure"
data-item-code="{{ doc.name }}"
data-item-name="{{ doc.item_name }}"
>
{{ _('Configure') }}
</button>
{% endif %}
{% if cart_settings.show_contact_us_button | int %}
<button class="btn btn-link btn-inquiry" data-item-code="{{ doc.name }}">
{{ _('Contact Us') }}
</button>
{% endif %}
</div>
<script>
{% include "templates/generators/item/item_configure.js" %}
{% include "templates/generators/item/item_inquiry.js" %}
</script>
{% endif %}

View File

@@ -0,0 +1,318 @@
class ItemConfigure {
constructor(item_code, item_name) {
this.item_code = item_code;
this.item_name = item_name;
this.get_attributes_and_values()
.then(attribute_data => {
this.attribute_data = attribute_data;
this.show_configure_dialog();
});
}
show_configure_dialog() {
const fields = this.attribute_data.map(a => {
return {
fieldtype: 'Select',
label: a.attribute,
fieldname: a.attribute,
options: a.values.map(v => {
return {
label: v,
value: v
};
}),
change: (e) => {
this.on_attribute_selection(e);
}
};
});
this.dialog = new frappe.ui.Dialog({
title: __('Configure {0}', [this.item_name]),
fields,
on_hide: () => {
set_continue_configuration();
}
});
this.attribute_data.forEach(a => {
const field = this.dialog.get_field(a.attribute);
const $a = $(`<a href>${__("Clear")}</a>`);
$a.on('click', (e) => {
e.preventDefault();
this.dialog.set_value(a.attribute, '');
});
field.$wrapper.find('.help-box').append($a);
});
this.append_status_area();
this.dialog.show();
this.dialog.set_values(JSON.parse(localStorage.getItem(this.get_cache_key())));
$('.btn-configure').prop('disabled', false);
}
on_attribute_selection(e) {
if (e) {
const changed_fieldname = $(e.target).data('fieldname');
this.show_range_input_if_applicable(changed_fieldname);
} else {
this.show_range_input_for_all_fields();
}
const values = this.dialog.get_values();
if (Object.keys(values).length === 0) {
this.clear_status();
localStorage.removeItem(this.get_cache_key());
return;
}
// save state
localStorage.setItem(this.get_cache_key(), JSON.stringify(values));
// show
this.set_loading_status();
this.get_next_attribute_and_values(values)
.then(data => {
const {
valid_options_for_attributes,
} = data;
this.set_item_found_status(data);
for (let attribute in valid_options_for_attributes) {
const valid_options = valid_options_for_attributes[attribute];
const options = this.dialog.get_field(attribute).df.options;
const new_options = options.map(o => {
o.disabled = !valid_options.includes(o.value);
return o;
});
this.dialog.set_df_property(attribute, 'options', new_options);
this.dialog.get_field(attribute).set_options();
}
});
}
show_range_input_for_all_fields() {
this.dialog.fields.forEach(f => {
this.show_range_input_if_applicable(f.fieldname);
});
}
show_range_input_if_applicable(fieldname) {
const changed_field = this.dialog.get_field(fieldname);
const changed_value = changed_field.get_value();
if (changed_value && changed_value.includes(' to ')) {
// possible range input
let numbers = changed_value.split(' to ');
numbers = numbers.map(number => parseFloat(number));
if (!numbers.some(n => isNaN(n))) {
numbers.sort((a, b) => a - b);
if (changed_field.$input_wrapper.find('.range-selector').length) {
return;
}
const parent = $('<div class="range-selector">')
.insertBefore(changed_field.$input_wrapper.find('.help-box'));
const control = frappe.ui.form.make_control({
df: {
fieldtype: 'Int',
label: __('Enter value betweeen {0} and {1}', [numbers[0], numbers[1]]),
change: () => {
const value = control.get_value();
if (value < numbers[0] || value > numbers[1]) {
control.$wrapper.addClass('was-validated');
control.set_description(
__('Value must be between {0} and {1}', [numbers[0], numbers[1]]));
control.$input[0].setCustomValidity('error');
} else {
control.$wrapper.removeClass('was-validated');
control.set_description('');
control.$input[0].setCustomValidity('');
this.update_range_values(fieldname, value);
}
}
},
render_input: true,
parent
});
control.$wrapper.addClass('mt-3');
}
}
}
update_range_values(attribute, range_value) {
this.range_values = this.range_values || {};
this.range_values[attribute] = range_value;
}
show_remaining_optional_attributes() {
// show all attributes if remaining
// unselected attributes are all optional
const unselected_attributes = this.dialog.fields.filter(df => {
const value_selected = this.dialog.get_value(df.fieldname);
return !value_selected;
});
const is_optional_attribute = df => {
const optional_attributes = this.attribute_data
.filter(a => a.optional).map(a => a.attribute);
return optional_attributes.includes(df.fieldname);
};
if (unselected_attributes.every(is_optional_attribute)) {
unselected_attributes.forEach(df => {
this.dialog.fields_dict[df.fieldname].$wrapper.show();
});
}
}
set_loading_status() {
this.dialog.$status_area.html(`
<div class="alert alert-warning d-flex justify-content-between align-items-center" role="alert">
${__('Loading...')}
</div>
`);
}
set_item_found_status(data) {
const html = this.get_html_for_item_found(data);
this.dialog.$status_area.html(html);
}
clear_status() {
this.dialog.$status_area.empty();
}
get_html_for_item_found({ filtered_items_count, filtered_items, exact_match, product_info }) {
const exact_match_message = __('1 exact match.');
const one_item = exact_match.length === 1 ?
exact_match[0] :
filtered_items_count === 1 ?
filtered_items[0] : '';
const item_add_to_cart = one_item ? `
<div class="alert alert-success d-flex justify-content-between align-items-center" role="alert">
<div>
<div>${one_item} ${product_info && product_info.price ? '(' + product_info.price.formatted_price_sales_uom + ')' : ''}</div>
</div>
<a href data-action="btn_add_to_cart" data-item-code="${one_item}">
${__('Add to cart')}
</a>
</div>
`: '';
const items_found = filtered_items_count === 1 ?
__('{0} item found.', [filtered_items_count]) :
__('{0} items found.', [filtered_items_count]);
const item_found_status = `
<div class="alert alert-warning d-flex justify-content-between align-items-center" role="alert">
<span>
${exact_match.length === 1 ? '' : items_found}
${exact_match.length === 1 ? `<span>${exact_match_message}</span>` : ''}
</span>
<a href data-action="btn_clear_values">
${__('Clear values')}
</a>
</div>
`;
return `
${item_add_to_cart}
${item_found_status}
`;
}
btn_add_to_cart(e) {
if (frappe.session.user !== 'Guest') {
localStorage.removeItem(this.get_cache_key());
}
const item_code = $(e.currentTarget).data('item-code');
const additional_notes = Object.keys(this.range_values || {}).map(attribute => {
return `${attribute}: ${this.range_values[attribute]}`;
}).join('\n');
erpnext.shopping_cart.update_cart({
item_code,
additional_notes,
qty: 1
});
this.dialog.hide();
}
btn_clear_values() {
this.dialog.fields_list.forEach(f => {
f.df.options = f.df.options.map(option => {
option.disabled = false;
return option;
});
});
this.dialog.clear();
this.on_attribute_selection();
}
append_status_area() {
this.dialog.$status_area = $('<div class="status-area">');
this.dialog.$wrapper.find('.modal-body').prepend(this.dialog.$status_area);
this.dialog.$wrapper.on('click', '[data-action]', (e) => {
e.preventDefault();
const $target = $(e.currentTarget);
const action = $target.data('action');
const method = this[action];
method.call(this, e);
});
this.dialog.$body.css({ maxHeight: '75vh', overflow: 'auto', overflowX: 'hidden' });
}
get_next_attribute_and_values(selected_attributes) {
return this.call('erpnext.portal.product_configurator.utils.get_next_attribute_and_values', {
item_code: this.item_code,
selected_attributes
});
}
get_attributes_and_values() {
return this.call('erpnext.portal.product_configurator.utils.get_attributes_and_values', {
item_code: this.item_code
});
}
get_cache_key() {
return `configure:${this.item_code}`;
}
call(method, args) {
// promisified frappe.call
return new Promise((resolve, reject) => {
frappe.call(method, args)
.then(r => resolve(r.message))
.fail(reject);
});
}
}
function set_continue_configuration() {
const $btn_configure = $('.btn-configure');
const { itemCode } = $btn_configure.data();
if (localStorage.getItem(`configure:${itemCode}`)) {
$btn_configure.text(__('Continue Configuration'));
} else {
$btn_configure.text(__('Configure'));
}
}
frappe.ready(() => {
const $btn_configure = $('.btn-configure');
if (!$btn_configure.length) return;
const { itemCode, itemName } = $btn_configure.data();
set_continue_configuration();
$btn_configure.on('click', () => {
$btn_configure.prop('disabled', true);
new ItemConfigure(itemCode, itemName);
});
});

View File

@@ -0,0 +1,22 @@
<div class="col-md-8">
<!-- title -->
<h1 itemprop="name">
{{ item_name }}
</h1>
<p class="text-muted">
<span>{{ _("Item Code") }}:</span>
<span itemprop="productID">{{ doc.name }}</span>
</p>
<!-- description -->
<div itemprop="description">
{{ doc.web_long_description or doc.description or _("No description given") | safe }}
</div>
{% if has_variants %}
<!-- configure template -->
{% include "templates/generators/item/item_configure.html" %}
{% else %}
<!-- add variant to cart -->
{% include "templates/generators/item/item_add_to_cart.html" %}
{% endif %}
</div>

View File

@@ -0,0 +1,107 @@
<div class="col-md-4 h-100">
{% if slides %}
{{ product_image(slides[0].image, 'product-image') }}
<div class="item-slideshow">
{% for item in slides %}
<img class="item-slideshow-image mt-2 {% if loop.first %}active{% endif %}"
src="{{ item.image }}" alt="{{ item.heading }}">
{% endfor %}
</div>
<!-- Simple image slideshow -->
<script>
frappe.ready(() => {
$('.page_content').on('click', '.item-slideshow-image', (e) => {
const $img = $(e.currentTarget);
const link = $img.prop('src');
const $product_image = $('.product-image');
$product_image.find('a').prop('href', link);
$product_image.find('img').prop('src', link);
$('.item-slideshow-image').removeClass('active');
$img.addClass('active');
});
})
</script>
{% else %}
{{ product_image(website_image or image or 'no-image.jpg') }}
{% endif %}
<!-- Simple image preview -->
<div class="image-zoom-view" style="display: none;">
<button type="button" class="close" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<style>
.website-image {
cursor: pointer;
}
.image-zoom-view {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.8);
z-index: 1080;
}
.image-zoom-view img {
max-height: 100%;
max-width: 100%;
}
.image-zoom-view button {
position: absolute;
right: 3rem;
top: 2rem;
}
.image-zoom-view svg {
color: var(--white);
}
</style>
<script>
frappe.ready(() => {
const $zoom_wrapper = $('.image-zoom-view');
$('.website-image').on('click', (e) => {
e.preventDefault();
const $img = $(e.target);
const src = $img.prop('src');
if (!src) return;
show_preview(src);
});
$zoom_wrapper.on('click', 'button', hide_preview);
$(document).on('keydown', (e) => {
if (e.key === 'Escape') {
hide_preview();
}
});
function show_preview(src) {
$zoom_wrapper.show();
const $img = $(`<img src="${src}">`)
$zoom_wrapper.append($img);
}
function hide_preview() {
$zoom_wrapper.find('img').remove();
$zoom_wrapper.hide();
}
})
</script>

View File

@@ -0,0 +1,70 @@
frappe.ready(() => {
const d = new frappe.ui.Dialog({
title: __('Contact Us'),
fields: [
{
fieldtype: 'Data',
label: __('Full Name'),
fieldname: 'lead_name',
reqd: 1
},
{
fieldtype: 'Data',
label: __('Organization Name'),
fieldname: 'company_name',
},
{
fieldtype: 'Data',
label: __('Email'),
fieldname: 'email_id',
options: 'Email',
reqd: 1
},
{
fieldtype: 'Data',
label: __('Subject'),
fieldname: 'subject',
reqd: 1
},
{
fieldtype: 'Text',
label: __('Message'),
fieldname: 'message',
reqd: 1
}
],
primary_action: send_inquiry,
primary_action_label: __('Send')
});
function send_inquiry() {
const values = d.get_values();
const doc = Object.assign({}, values);
delete doc.subject;
delete doc.message;
d.hide();
frappe.call('erpnext.shopping_cart.cart.create_lead_for_item_inquiry', {
lead: doc,
subject: values.subject,
message: values.message
}).then(r => {
if (r.message) {
d.clear();
}
});
}
$('.btn-inquiry').click((e) => {
const $btn = $(e.target);
const item_code = $btn.data('item-code');
d.set_value('subject', 'Inquiry about ' + item_code);
if (!['Administrator', 'Guest'].includes(frappe.session.user)) {
d.set_value('email_id', frappe.session.user);
d.set_value('lead_name', frappe.get_cookie('full_name'));
}
d.show();
});
});

View File

@@ -0,0 +1,16 @@
{% if doc.website_specifications -%}
<div class="row item-website-specification mt-5">
<div class="col-md-12">
<h6 class="text-uppercase text-muted">{{ _("Specifications") }}</h6>
<table class="table table-bordered">
{% for d in doc.website_specifications -%}
<tr>
<td class="text-muted" style="width: 30%;">{{ d.label }}</td>
<td>{{ d.description }}</td>
</tr>
{%- endfor %}
</table>
</div>
</div>
{%- endif %}

View File

@@ -9,29 +9,32 @@
{% include "templates/includes/slideshow.html" %}
{% endif %}
{% if description %}<!-- description -->
<div itemprop="description">{{ description or ""}}</div>
<div class="mb-3" itemprop="description">{{ description or ""}}</div>
{% endif %}
</div>
<div>
{% if items %}
<div id="search-list" {% if not products_as_list -%} class="row" {%- endif %}>
{% for i in range(0, page_length) %}
{% if items[i] %}
{{ items[i] }}
<div class="row">
<div class="col-md-8">
{% if items %}
<div id="search-list">
{% for i in range(0, page_length) %}
{% if items[i] %}
{%- set item = items[i] %}
{% include "erpnext/www/all-products/item_row.html" %}
{% endif %}
{% endfor %}
</div>
<div class="item-group-nav-buttons">
{% if frappe.form_dict.start|int > 0 %}
<a class="btn btn-outline-secondary" href="/{{ pathname }}?start={{ frappe.form_dict.start|int - page_length }}">{{ _("Prev") }}</a>
{% endif %}
{% endfor %}
</div>
<div class="text-center item-group-nav-buttons">
{% if frappe.form_dict.start|int > 0 %}
<a class="btn btn-default" href="/{{ pathname }}?start={{ frappe.form_dict.start|int - page_length }}">{{ _("Prev") }}</a>
{% endif %}
{% if items|length > page_length %}
<a class="btn btn-default" href="/{{ pathname }}?start={{ frappe.form_dict.start|int + page_length }}">{{ _("Next") }}</a>
{% endif %}
</div>
{% else %}
{% if items|length > page_length %}
<a class="btn btn-outline-secondary" href="/{{ pathname }}?start={{ frappe.form_dict.start|int + page_length }}">{{ _("Next") }}</a>
{% endif %}
</div>
{% else %}
<div class="text-muted">{{ _("No items listed") }}.</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endblock %}