web2019/static/js/shop.jsx

1856 lines
48 KiB
JavaScript

'use strict';
const {
DragDropContext,
Draggable,
Droppable
} = window.ReactBeautifulDnd;
const data = window.shop_data;
const productStyle = (style, snapshot, removeAnim, hovered, selected) => {
const custom = {
opacity: snapshot.isDragging ? .7 : 1,
backgroundColor: (hovered || selected) ? '#eae7f7' : 'initial',
};
if (!snapshot.isDropAnimating) {
return { ...style, ...custom};
}
if (removeAnim) {
// cannot be 0, but make it super tiny
custom.transitionDuration = '0.001s';
}
return {
...style,
...custom,
};
}
const cartStyle = (style, snapshot) => {
const isDraggingOver = snapshot.isDraggingOver;
return {
...style,
...{
backgroundColor: isDraggingOver ? '#f2f2f2' : '#f9f9f9',
border: isDraggingOver ? '1px dashed #ccc' : '0',
},
};
}
const nbrConnectorsStyle = (data) => {
if (!data || !data.nbrCurrentSlot) {
return {};
}
let p = data.nbrCurrentSlot * 100 / data.nbrSlotMax;
if (p > 100) {
p = 100;
}
return {
width: `${p}%`,
}
};
const nbrClocksStyle = (data) => {
if (!data || !data.nbrCurrentClock) {
return {};
}
let p = data.nbrCurrentClock * 100 / data.nbrClockMax;
if (p > 100) {
p = 100;
}
return {
width: `${p}%`,
}
};
const copy = (
model,
source,
destination,
droppableSource,
droppableDestination
) => {
const sourceClone = Array.from(source.itemIds);
const destClone = Array.from(destination.items);
const item = sourceClone[droppableSource.index];
destClone.splice(droppableDestination.index, 0, {
...model[item],
id: uuidv4(),
});
return destClone;
};
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
const remove = (list, startIndex) => {
const result = Array.from(list);
result.splice(startIndex, 1);
return result;
};
const nbrOccupiedSlotsInCrate = (items) => {
return items.reduce((prev, next) => {
return prev + (next.hp === 8 ? 2 : 1);
}, 0);
};
function formatMoney(amount, decimalCount = 2, decimal = ".", thousands = ",") {
// https://stackoverflow.com/questions/149055/how-can-i-format-numbers-as-currency-string-in-javascript
// changes: return amount if error in order to avoid empty value
try {
decimalCount = Math.abs(decimalCount);
decimalCount = isNaN(decimalCount) ? 2 : decimalCount;
const negativeSign = amount < 0 ? "-" : "";
let i = parseInt(amount = Math.abs(Number(amount) || 0).toFixed(decimalCount)).toString();
let j = (i.length > 3) ? i.length % 3 : 0;
return negativeSign + (j ? i.substr(0, j) + thousands : '') + i.substr(j).replace(/(\d{3})(?=\d)/g, "$1" + thousands) + (decimalCount ? decimal + Math.abs(amount - i).toFixed(decimalCount).slice(2) : "");
} catch (e) {
return amount;
}
};
/**
* Component that provides a base layout (aside/main) for the page.
*/
class Layout extends React.PureComponent {
static get propTypes() {
return {
aside: PropTypes.any,
main: PropTypes.any,
mobileSideMenuShouldOpen: PropTypes.bool,
isMobile: PropTypes.bool,
newCardJustAdded: PropTypes.bool,
onClickToggleMobileSideMenu: PropTypes.func,
};
}
static get defaultProps() {
return {
mobileSideMenuShouldOpen: false,
};
}
render() {
const {
aside,
main,
mobileSideMenuShouldOpen,
isMobile,
newCardJustAdded,
onClickToggleMobileSideMenu
} = this.props;
return (
<div className="layout">
<aside className={'aside ' + (mobileSideMenuShouldOpen ? 'menu-opened' : '')}>{aside}</aside>
{mobileSideMenuShouldOpen ? (
<section className="main" onClick={onClickToggleMobileSideMenu}>{main}</section>
) : (
<section className="main">{main}</section>
)}
{isMobile && newCardJustAdded ? (
<div className="feedback-add-success">
added
</div>
) : null}
</div>
);
}
}
/**
* Component that renders a product.
* Used in the aside (e.g backlog of product)
*/
class ProductItem extends React.PureComponent {
static get propTypes() {
return {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
price: PropTypes.number.isRequired,
currency: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
specs: PropTypes.array,
onClickAddItem: PropTypes.func,
};
}
constructor(props) {
super(props);
this.handleOnClickAddItem = this.handleOnClickAddItem.bind(this);
}
handleOnClickAddItem(index, tap, e) {
if (this.props.onClickAddItem) {
this.props.onClickAddItem(index, tap);
}
e.preventDefault();
}
render() {
const {
id,
index,
name,
price,
currency,
image,
specs,
} = this.props;
const render_specs = (specs && specs.length > 0 && (
<ul>
{specs.map((spec, index) =>
<li key={index}>{spec}</li>
)}
</ul>
));
return (
<section className="productItem">
<div className="content">
<h3>{name}</h3>
<div className="price">{`${currency} ${formatMoney(price)}`}</div>
{render_specs}
</div>
<div className="content">
<button onClick={this.handleOnClickAddItem.bind(this, index, true)}>
<img src="/images/shop/icon-add.svg" alt="add" />
</button>
<Draggable draggableId={id} index={index}>
{(provided, snapshot) => (
<React.Fragment>
<img
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={productStyle(
provided.draggableProps.style,
snapshot,
true, // hack: remove weird animation after a drop
)}
src={image} />
{/* Allows to simulate a clone */}
{snapshot.isDragging && (
<img className="simclone" src={image} />
)}
</React.Fragment>
)}
</Draggable>
</div>
</section>
);
}
}
/**
* Component that renders a product.
* Used in the crate
*/
class ProductCartItem extends React.PureComponent {
static get propTypes() {
return {
isMobile: PropTypes.bool,
hovered: PropTypes.bool,
index: PropTypes.number.isRequired,
model: PropTypes.object.isRequired,
data: PropTypes.object,
onToggleProgress: PropTypes.func,
onToggleWarning: PropTypes.func,
onToggleOverlayRemove: PropTypes.func,
onClickRemoveItem: PropTypes.func,
};
}
static get defaultProps() {
return {
hovered: false,
};
}
constructor(props) {
super(props);
this.handleOnMouseEnterItem = this.handleOnMouseEnterItem.bind(this);
this.handleOnMouseLeaveItem = this.handleOnMouseLeaveItem.bind(this);
this.handleOnMouseEnterWarningItem = this.handleOnMouseEnterWarningItem.bind(this);
this.handleOnMouseLeaveWarningItem = this.handleOnMouseLeaveWarningItem.bind(this);
this.handleOnMouseEnterRemoveItem = this.handleOnMouseEnterRemoveItem.bind(this);
this.handleOnMouseLeaveRemoveItem = this.handleOnMouseLeaveRemoveItem.bind(this);
this.handleOnClickRemoveItem = this.handleOnClickRemoveItem.bind(this);
}
handleOnMouseEnterItem(index, e) {
if (this.props.onToggleProgress) {
this.props.onToggleProgress(index, true);
}
e.preventDefault();
}
handleOnMouseLeaveItem(index, e) {
if (this.props.onToggleProgress) {
this.props.onToggleProgress(index, false);
}
e.preventDefault();
}
handleOnMouseEnterWarningItem(index, isWarning, e) {
if (!isWarning) {
return;
}
if (this.props.onToggleWarning) {
this.props.onToggleWarning(index, true);
}
e.preventDefault();
}
handleOnMouseLeaveWarningItem(index, isWarning, e) {
if (!isWarning) {
return;
}
if (this.props.onToggleWarning) {
this.props.onToggleWarning(index, false);
}
e.preventDefault();
}
handleOnMouseEnterRemoveItem(index, e) {
if (this.props.onToggleOverlayRemove && !this.props.isMobile) {
this.props.onToggleOverlayRemove(index, true);
}
e.preventDefault();
}
handleOnMouseLeaveRemoveItem(index, e) {
if (this.props.onToggleOverlayRemove && !this.props.isMobile) {
this.props.onToggleOverlayRemove(index, false);
}
e.preventDefault();
}
handleOnClickRemoveItem(index, e) {
if (this.props.onClickRemoveItem) {
this.props.onClickRemoveItem(index);
}
}
render() {
const {
hovered,
model,
data,
index,
} = this.props;
let warning;
if (data && data.warnings) {
const warningsKeys = Object.keys(data.warnings);
if (warningsKeys && warningsKeys.length > 0) {
// we display only the first warning
warning = data.warnings[warningsKeys[0]];
}
}
let render_progress;
if (model.showProgress && data) {
switch(model.type) {
case 'kasli':
case 'kasli-backplane':
render_progress = (
<div className="k-popup-connectors">
<p>{`${data.nbrCurrentSlot}/${model.nbrSlotMax} EEM connectors used`}</p>
<p>{`${data.nbrCurrentClock}/${model.nbrClockMax} Clock connectors used`}</p>
</div>
);
break;
case 'zotino':
case 'hd68':
render_progress = (
<div className="k-popup-connectors">
<p>{`${data.nbrCurrentSlot}/${model.nbrSlotMax} connectors used`}</p>
</div>
);
break;
case 'clocker':
render_progress = (
<div className="k-popup-connectors">
<p>{`${data.nbrCurrentClock}/${model.nbrClockMax} Clock connectors used`}</p>
</div>
);
break;
default:
break;
}
}
return (
<Draggable draggableId={model.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{ ...productStyle(
provided.draggableProps.style,
snapshot,
true,
hovered ? true : false,
model.selected ? true : false,
)}}
onMouseEnter={this.handleOnMouseEnterRemoveItem.bind(this, index)}
onMouseLeave={this.handleOnMouseLeaveRemoveItem.bind(this, index)}
>
{/* warning container */}
<div
style={{'height': '24px'}}
className="progress-container warning"
onMouseEnter={this.handleOnMouseEnterWarningItem.bind(this, index, warning)}
onMouseLeave={this.handleOnMouseLeaveWarningItem.bind(this, index, warning)}>
{warning && (
<img className="alert-warning" src={warning ? `/images${warning.icon}` : null} />
)}
{warning && model.showWarning && (
<div className="k-popup-warning">
<p className="rule warning">
<i>{warning.message}</i>
</p>
</div>
)}
</div>
<h6>{model.name}</h6>
<div
style={{'height': '250px'}}
onMouseEnter={this.handleOnMouseEnterRemoveItem.bind(this, index)}
>
<img
className='item-cart'
src={`/images${model.image}`} />
</div>
{/* remove container */}
<div
style={{'display': model.showOverlayRemove ? 'flex':'none'}}
className="overlayRemove"
onClick={this.handleOnClickRemoveItem.bind(this, index)}>
<img src="/images/shop/icon-remove.svg" alt="rm"/>
<p>Remove</p>
</div>
{/* progression container */}
<div
style={{'height': '22px'}}
className="progress-container"
onMouseEnter={this.handleOnMouseEnterItem.bind(this, index)}
onMouseLeave={this.handleOnMouseLeaveItem.bind(this, index)}>
{model.nbrSlotMax > 0 && (
<div className="nbr-connectors">
<div style={{ ...nbrConnectorsStyle(data)}}></div>
</div>
)}
{model.nbrClockMax > 0 && (
<div className="nbr-clocks">
<div style={{ ...nbrClocksStyle(data)}}></div>
</div>
)}
{/* progress info when mouse over */}
{render_progress}
</div>
</div>
)}
</Draggable>
);
}
}
/**
* Component that displays a placeholder inside crate.
* Allows to display how it remains space for the current crate.
*/
class FakePlaceholder extends React.PureComponent {
static get propTypes() {
return {
isDraggingOver: PropTypes.bool,
nbrSlots: PropTypes.number.isRequired,
items: PropTypes.array.isRequired,
};
}
render() {
const {
isDraggingOver,
nbrSlots,
items,
} = this.props;
const fakePlaceholder = [];
const nbrOccupied = nbrOccupiedSlotsInCrate(items);
for (var i = (nbrSlots - nbrOccupied); i > 0; i--) {
fakePlaceholder.push(
<div key={i} style={{
display: isDraggingOver ? 'none' : 'block',
border: '1px dashed #ccc',
width: '45px',
marginBottom: '5px',
}}></div>
);
}
return (
<React.Fragment>
{fakePlaceholder}
</React.Fragment>
);
}
}
/**
* Component that displays a list of <ProductCartItem>
*/
class Cart extends React.PureComponent {
static get propTypes() {
return {
isMobile: PropTypes.bool,
nbrSlots: PropTypes.number,
itemHovered: PropTypes.string,
data: PropTypes.object.isRequired,
onToggleProgress: PropTypes.func,
onToggleWarning: PropTypes.func,
onToggleOverlayRemove: PropTypes.func,
onClickRemoveItem: PropTypes.func,
};
}
render() {
const {
isMobile,
nbrSlots,
itemHovered,
data,
onToggleProgress,
onToggleWarning,
onToggleOverlayRemove,
onClickRemoveItem,
} = this.props;
const products = data.items.map((item, index) => {
let itemData;
if (data.itemsData && index in data.itemsData) {
itemData = data.itemsData[index];
}
return (
<ProductCartItem
isMobile={isMobile}
hovered={item.id === itemHovered}
key={item.id}
index={index}
data={itemData}
onToggleProgress={onToggleProgress}
onToggleWarning={onToggleWarning}
onToggleOverlayRemove={onToggleOverlayRemove}
onClickRemoveItem={onClickRemoveItem}
model={item}>
</ProductCartItem>
);
});
return (
<Droppable droppableId={data.id} direction="horizontal">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
style={cartStyle(
provided.droppableProps.style,
snapshot,
)}
className="items-cart-list">
{products}
{provided.placeholder && (
<div style={{ display: 'none' }}>
{provided.placeholder}
</div>
)}
<FakePlaceholder
nbrSlots={nbrSlots}
items={data.items}
isDraggingOver={snapshot.isDraggingOver} />
</div>
)}
</Droppable>
);
}
}
/**
* Component that displays crate modes
*/
class CrateMode extends React.PureComponent {
static get propTypes() {
return {
items: PropTypes.array.isRequired,
mode: PropTypes.string.isRequired,
onClickMode: PropTypes.func,
};
}
constructor(props) {
super(props);
this.handleOnClickMode = this.handleOnClickMode.bind(this);
}
handleOnClickMode(mode, e) {
if (this.props.onClickMode) {
this.props.onClickMode(mode);
}
e.preventDefault();
}
render() {
const {
mode,
items,
} = this.props;
return (
<div className="crate-mode">
{items.map(item => (
<a
key={item.id}
className={mode == item.id ? 'active' : ''}
onClick={this.handleOnClickMode.bind(this, item.id)}
href="#"
role="button">{item.name}</a>
))}
</div>
);
}
}
/**
* Component that displays the main crate with reminder rules.
* It includes <Cart> and rules
*/
class Crate extends React.PureComponent {
static get propTypes() {
return {
rules: PropTypes.array,
cart: PropTypes.element,
};
}
render() {
const {
rules,
cart,
} = this.props;
return (
<div className="crate">
<div className="crate-products">
{cart}
{rules && rules.length > 0 && (
<div className="crate-info">
{rules.map((rule, index) => (
<p key={index} className="rule" style={{'color': rule.color ? rule.color : 'inherit'}}>
<img src={`/images${rule.icon}`} /> <i><strong>{rule.name}:</strong> {rule.message}</i>
</p>
))}
</div>
)}
</div>
</div>
);
}
}
/**
* Component that renders all things for order.
* It acts like-a layout, this component do nothing more.
*/
class OrderPanel extends React.PureComponent {
static get propTypes() {
return {
title: PropTypes.string,
description: PropTypes.string,
crateMode: PropTypes.element,
crate: PropTypes.element,
summaryPrice: PropTypes.element,
form: PropTypes.element,
isMobile: PropTypes.bool,
onClickToggleMobileSideMenu: PropTypes.func,
};
}
render() {
const {
title,
description,
crateMode,
crate,
summaryPrice,
form,
isMobile,
onClickToggleMobileSideMenu
} = this.props;
return (
<section className="panel">
<h2>{title}</h2>
<div className="control">
<p className="description">{description}</p>
{crateMode}
</div>
{isMobile ? (
<div className="mobileBtnDisplaySideMenu">
<button onClick={onClickToggleMobileSideMenu}>
<img src="/images/shop/icon-add.svg" alt="add" />
</button>
</div>
) : null}
{crate}
<section className="summary">
{summaryPrice}
{form}
</section>
</section>
);
}
}
/**
* Components that renders the form to request quote.
*/
class OrderForm extends React.PureComponent {
static get propTypes() {
return {
onClickSubmit: PropTypes.func,
};
}
constructor(props) {
super(props);
this.state = {note: ''};
this.handleNoteChange = this.handleNoteChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleNoteChange(event) {
this.setState({
note: event.target.value,
});
}
handleSubmit(event) {
if (this.props.onClickSubmit) {
this.props.onClickSubmit(this.state.note);
}
event.preventDefault();
}
render() {
return (
<div className="summary-form">
<form onSubmit={this.handleSubmit}>
<textarea
value={this.state.note}
onChange={this.handleNoteChange}
rows="5"
placeholder="Additional notes" />
<input type="submit" value="Request quote" />
This will open an email window. Send the email to make your request.
</form>
</div>
);
}
}
/**
* Components that displays the list of card that are used in the crate.
* It is a summary of purchase
*/
class OrderSumary extends React.PureComponent {
static get propTypes() {
return {
currency: PropTypes.string,
modes: PropTypes.array,
currentMode: PropTypes.string,
summary: PropTypes.array,
itemsData: PropTypes.array,
onDeleteItem: PropTypes.func,
onDeleteAllItems: PropTypes.func,
onMouseEnterItem: PropTypes.func,
onMouseLeaveItem: PropTypes.func,
onClickSelectItem: PropTypes.func,
};
}
constructor(props) {
super(props);
this.handleOnDeleteItem = this.handleOnDeleteItem.bind(this);
this.handleOnDeleteAllItems = this.handleOnDeleteAllItems.bind(this);
this.handleOnMouseEnterItem = this.handleOnMouseEnterItem.bind(this);
this.handleOnMouseLeaveItem = this.handleOnMouseLeaveItem.bind(this);
this.handleOnClickSelectItem = this.handleOnClickSelectItem.bind(this);
}
handleOnDeleteItem(index, e) {
if (this.props.onDeleteItem) {
this.props.onDeleteItem(index);
}
e.preventDefault();
}
handleOnDeleteAllItems(e) {
if (this.props.onDeleteAllItems) {
this.props.onDeleteAllItems();
}
e.preventDefault();
}
handleOnMouseEnterItem(id, e) {
if (this.props.onMouseEnterItem) {
this.props.onMouseEnterItem(id);
}
e.preventDefault();
}
handleOnMouseLeaveItem(e) {
if (this.props.onMouseLeaveItem) {
this.props.onMouseLeaveItem();
}
e.preventDefault();
}
handleOnClickSelectItem(index, e) {
if (e.target.tagName !== 'IMG') {
if (this.props.onClickSelectItem) {
this.props.onClickSelectItem(index);
}
}
return e.preventDefault();
}
render() {
const {
currency,
modes,
currentMode,
summary,
itemsData,
} = this.props;
const mode = modes.find(elem => elem.id === currentMode);
return (
<div className="summary-price">
<table>
<thead>
<tr>
<td colSpan="2" className="summary-remove-all">
<span className="item-card-name">Remove all cards</span>
<button onClick={this.handleOnDeleteAllItems}>
<img src="/images/shop/icon-remove.svg" />
</button>
</td>
</tr>
{mode && (
<tr>
<td className="item-card-name">{mode.name}</td>
<td className="price">
<div>
{`${currency} ${formatMoney(mode.price)}`}
<button style={{'opacity': '0', 'cursor': 'initial'}}>
<img src="/images/shop/icon-remove.svg" />
</button>
</div>
<span style={{
'display': 'inline-block',
'width': '30px',
}}>&nbsp;</span>
</td>
</tr>
)}
</thead>
<tbody>
{summary.map((item, index) => {
let alert;
let warning;
if (itemsData[index]) {
alert = itemsData[index];
const warningsKeys = Object.keys(alert.warnings);
if (warningsKeys && warningsKeys.length > 0) {
warning = alert.warnings[warningsKeys[0]];
}
}
return (
<tr key={item.id}
className={`hoverable ${item.selected ? 'selected' : ''}`}
onClick={this.handleOnClickSelectItem.bind(this, index)}
onMouseEnter={this.handleOnMouseEnterItem.bind(this, item.id)}
onMouseLeave={this.handleOnMouseLeaveItem}>
<td className="item-card-name">
<div>{item.name}</div>
</td>
<td className="price">
<div>
{`${currency} ${formatMoney(item.price)}`}
<button onClick={this.handleOnDeleteItem.bind(this, index)}>
<img src="/images/shop/icon-remove.svg" />
</button>
</div>
{warning && (
<img
style={{'marginLeft': '10px'}}
className="alert-warning"
src={`/images/${warning.icon}`}
/>
)}
{!warning && (
<span style={{
'display': 'inline-block',
'width': '30px',
}}>&nbsp;</span>
)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td className="item-card-name">Price estimate</td>
<td className="price">
<div>
{summary.length ? (
`${currency} ${formatMoney(summary.reduce(
(prev, next) => {
return prev + next.price;
}, 0
) + mode.price)}`
) : (
`${currency} ${formatMoney(mode.price)}`
)}
<button style={{'opacity': '0', 'cursor': 'initial'}}>
<img src="/images/shop/icon-remove.svg" alt="icon remove"/>
</button>
</div>
<span style={{
'display': 'inline-block',
'width': '30px',
}}>&nbsp;</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
}
}
/**
* Component that renders the backlog in the aside
*/
class Backlog extends React.PureComponent {
static get propTypes() {
return {
currency: PropTypes.string,
data: PropTypes.object.isRequired,
items: PropTypes.object,
isMobile: PropTypes.bool,
onClickAddItem: PropTypes.func,
onClickToggleMobileSideMenu: PropTypes.func,
};
}
static get defaultProps() {
return {
items: {},
};
}
render() {
const {
currency,
data,
items,
onClickAddItem,
onClickToggleMobileSideMenu,
isMobile,
} = this.props;
const ordered_items = data.itemIds.map(itemId => items[itemId]);
const products = ordered_items.map((item, index) => {
return (
<ProductItem
key={item.id}
id={item.id}
index={index}
name={item.name}
price={item.price}
currency={currency}
image={`/images/${item.image}`}
specs={item.specs}
onClickAddItem={onClickAddItem}
></ProductItem>
);
});
return (
<Droppable
droppableId={data.id}
isDropDisabled={true}>
{(provided) => (
<div
className="backlog-container"
ref={provided.innerRef}
{...provided.droppableProps}>
{isMobile ? (
<div className="mobileCloseMenu">
<button onClick={onClickToggleMobileSideMenu}>
<img src="/images/shop/icon-close-white.svg" alt="add" />
</button>
</div>
) : null}
{products}
{provided.placeholder && (
<div style={{ display: 'none' }}>
{provided.placeholder}
</div>
)}
</div>
)}
</Droppable>
);
}
}
/**
* Component that render the entire shop
*/
class Shop extends React.PureComponent {
static get propTypes() {
return {
data: PropTypes.object.isRequired,
};
}
constructor(props) {
super(props);
this.state = this.props.data;
this.handleCrateModeChange = this.handleCrateModeChange.bind(this);
this.handleOnDragEnd = this.handleOnDragEnd.bind(this);
this.handleDeleteItem = this.handleDeleteItem.bind(this);
this.handleDeleteAllItems = this.handleDeleteAllItems.bind(this);
this.handleMouseEnterItem = this.handleMouseEnterItem.bind(this);
this.handleMouseLeaveItem = this.handleMouseLeaveItem.bind(this);
this.handleClickAddItem = this.handleClickAddItem.bind(this);
this.checkAlerts = this.checkAlerts.bind(this);
this.handleToggleItemProgress = this.handleToggleItemProgress.bind(this);
this.handleToggleItemWarning = this.handleToggleItemWarning.bind(this);
this.handleClickSelectItem = this.handleClickSelectItem.bind(this);
this.handleClickSubmit = this.handleClickSubmit.bind(this);
this.handleToggleOverlayRemove = this.handleToggleOverlayRemove.bind(this);
this.handleClickToggleMobileSideMenu = this.handleClickToggleMobileSideMenu.bind(this);
this.timer = null;
}
componentDidMount() {
// index 0 is a Kasli, we place it as a default conf on the crate.
const source = {
droppableId: 'backlog',
index: 0,
};
const destination = {
droppableId: 'cart',
index: 0,
};
this.handleOnDragEnd({
source,
destination,
draggableId: null,
});
}
componentDidUpdate(prevProps, prevState) {
/**
* We check alerts (reminder + warning) only when items inside crate or
* crate mode change.
*
* In the function checkAlerts, we DO NOT want to change items as we will
* trigger again this function (componentDidUpdate) and thus,
* making an infinite loop.
*/
if (
(prevState.columns.cart.items !== this.state.columns.cart.items) ||
(prevState.currentMode !== this.state.currentMode)
) {
this.checkAlerts(
prevState.columns.cart.items,
this.state.columns.cart.items);
}
if (this.state.newCardJustAdded) {
this.timer = setTimeout(() => {
this.setState({
newCardJustAdded: false,
});
}, 2000);
}
}
componentWillUnmount() {
clearTimeout(this.timer);
}
handleCrateModeChange(mode) {
this.setState({
currentMode: mode,
});
}
handleDeleteItem(index) {
const cloned = Array.from(this.state.columns.cart.items);
cloned.splice(index, 1);
this.setState({
...this.state,
columns: {
...this.state.columns,
cart: {
...this.state.columns.cart,
items: cloned,
},
},
});
}
handleDeleteAllItems() {
this.setState({
...this.state,
columns: {
...this.state.columns,
cart: {
...this.state.columns.cart,
items: [],
},
},
});
}
handleMouseEnterItem(id) {
this.setState({
...this.state,
currentItemHovered: id,
});
}
handleMouseLeaveItem() {
this.setState({
...this.state,
currentItemHovered: null,
});
}
handleClickAddItem(index, tap) {
const source = {
droppableId: 'backlog',
index: index,
};
const destination = {
droppableId: 'cart',
index: this.state.columns.cart.items.length,
};
this.handleOnDragEnd({
source,
destination,
draggableId: null
}, tap);
}
handleToggleItemProgress(index, show) {
const itemsCloned = Array.from(this.state.columns.cart.items);
this.setState({
...this.state,
columns: {
...this.state.columns,
cart: {
...this.state.columns.cart,
items: itemsCloned.map((item, i) => {
return {
...item,
showProgress: i === index ? show : false,
showOverlayRemove: false,
showWarning: false,
};
}),
}
},
});
}
handleToggleItemWarning(index, show) {
const itemsCloned = Array.from(this.state.columns.cart.items);
this.setState({
...this.state,
columns: {
...this.state.columns,
cart: {
...this.state.columns.cart,
items: itemsCloned.map((item, i) => {
return {
...item,
showWarning: i === index ? show : false,
showProgress: false,
showOverlayRemove: false,
};
}),
}
},
});
}
handleClickSelectItem(index) {
const itemsCloned = Array.from(this.state.columns.cart.items);
this.setState({
...this.state,
columns: {
...this.state.columns,
cart: {
...this.state.columns.cart,
items: itemsCloned.map((item, id) => {
return {...item, selected: id === index ? true : false};
}),
}
},
});
}
handleToggleOverlayRemove(index, show) {
const itemsCloned = Array.from(this.state.columns.cart.items);
this.setState({
...this.state,
columns: {
...this.state.columns,
cart: {
...this.state.columns.cart,
items: itemsCloned.map((item, id) => {
return {
...item,
showOverlayRemove: id === index ? show : false,
showProgress: false,
showWarning: false,
};
}),
}
},
});
}
handleClickSubmit(note) {
const crate = {
items: [],
type: this.state.currentMode,
};
const clonedCart = Array.from(this.state.columns.cart.items);
for (const i in clonedCart) {
const item = clonedCart[i];
crate.items.push({
'name': item.name,
});
}
const a = document.createElement('a');
const num = (new Date()).getTime();
const subject = `[Order hardware] - Request Quote`;
let body = `Hello!\n\nI would like to request a quotation for my below configuration:\n\n${JSON.stringify(crate)}\n\n(Please do not edit the machine-readable representation above)\n\n`;
if (note) {
body = `${body}\n\nAdditional note:\n\n${note ? note.trim() : ''}`;
}
document.body.appendChild(a);
a.style = 'display: none';
a.href = `mailto:sales@m-labs.hk?subject=${subject}&body=${encodeURIComponent(body)}`;
a.click();
}
handleOnDragEnd(result, newAdded) {
const {
source,
destination,
draggableId,
} = result;
if (!destination) {
if (source.droppableId === 'cart') {
this.setState({
...this.state,
newCardJustAdded: false,
columns: {
...this.state.columns,
[source.droppableId]: {
...this.state.columns[source.droppableId],
items: remove(
this.state.columns[source.droppableId].items,
source.index,
),
},
},
});
}
return;
}
switch(source.droppableId) {
case 'backlog':
this.setState({
...this.state,
newCardJustAdded: newAdded ? true : false,
columns: {
...this.state.columns,
[destination.droppableId]: {
...this.state.columns[destination.droppableId],
items: copy(
this.state.items,
this.state.columns[source.droppableId],
this.state.columns[destination.droppableId],
source,
destination,
),
},
},
});
break;
case destination.droppableId:
this.setState({
...this.state,
newCardJustAdded: false,
columns: {
...this.state.columns,
[destination.droppableId]: {
...this.state.columns[destination.droppableId],
items: reorder(
this.state.columns[destination.droppableId].items,
source.index,
destination.index,
),
},
},
});
break;
default:
break;
}
}
handleClickToggleMobileSideMenu() {
this.setState({
...this.state,
mobileSideMenuShouldOpen: !this.state.mobileSideMenuShouldOpen,
});
}
checkAlerts(prevItems, newItems) {
console.log('--- START CHECKING CRATE WARNING ---');
const {
currentMode,
crateModeSlots,
crateRules,
} = this.state;
const itemsCloned = Array.from(newItems);
const itemsData = {};
const rules = {};
// check number of slot in crate
const nbrOccupied = nbrOccupiedSlotsInCrate(newItems);
if (nbrOccupied > crateModeSlots[currentMode]) {
rules[crateRules.maxSlot.type] = {...crateRules.maxSlot};
}
// check the number of EEM connectors available for all Kasli
const idxK = itemsCloned.reduce((prev, next, i) => {
if (next.type === 'kasli' || next.type === 'kasli-backplane') {
prev.push(i);
}
return prev;
}, []);
for (let i = 0; i <= idxK.length - 1; i++) {
let slots;
let nbUsedSlot = 0;
let nbrCurrentClock = 0;
let idx = idxK[i];
if (i !== idxK.length - 1) {
slots = itemsCloned.slice(idx + 1, idxK[i + 1]);
} else {
slots = itemsCloned.slice(idx + 1);
}
if (i == 0) {
const slots_need_resource = itemsCloned.slice(0, idx);
const idx_need = slots_need_resource.findIndex(e => (e.rules && e.rules.resources));
if (idx_need != -1) {
if (idx_need in itemsData) {
if ('warnings' in itemsData[idx_need]) {
itemsData[idx_need].warnings.resources = {...itemsCloned[idx_need].rules.resources};
} else {
itemsData[idx_need].warnings = {};
itemsData[idx_need].warnings.resources = {...itemsCloned[idx_need].rules.resources};
}
} else {
itemsData[idx_need] = {...itemsCloned[idx_need]};
itemsData[idx_need].warnings = {};
itemsData[idx_need].warnings.resources = {...itemsCloned[idx_need].rules.resources};
}
}
}
nbUsedSlot = slots
.filter(item => item.type !== 'idc-bnc')
.reduce((prev, next) => {
return prev + next.slotOccupied;
}, 0);
nbrCurrentClock = slots
.reduce((prev, next) => {
return next.type === 'clocker' ? prev + next.clockOccupied : prev;
}, 0);
if (idx in itemsData) {
itemsData[idx].nbrCurrentSlot = nbUsedSlot;
itemsData[idx].nbrCurrentClock = nbrCurrentClock;
if (!('warnings' in itemsData[idx])) {
itemsData[idx].warnings = {};
}
} else {
itemsData[idx] = {...itemsCloned[idx]};
itemsData[idx].nbrCurrentSlot = nbUsedSlot;
itemsData[idx].nbrCurrentClock = nbrCurrentClock;
itemsData[idx].warnings = {};
}
if (nbUsedSlot > itemsCloned[idx].nbrSlotMax) {
if (itemsCloned[idx].rules.maxSlot.message) {
rules[itemsCloned[idx].rules.maxSlot.type] = {...itemsCloned[idx].rules.maxSlot};
}
itemsData[idx].warnings.maxSlotWarning = {...itemsCloned[idx].rules.maxSlotWarning};
}
if (nbrCurrentClock > itemsCloned[idx].nbrClockMax) {
rules[itemsCloned[idx].rules.maxClock.type] = {...itemsCloned[idx].rules.maxClock};
itemsData[idx].warnings.maxClockWarning = {...itemsCloned[idx].rules.maxClockWarning};
}
if (itemsCloned.length > (idx + 1)) {
const ddkali = itemsCloned[idx + 1];
if (ddkali.type === 'kasli' || ddkali.type === 'kasli-backplane') {
rules[ddkali.rules.follow.type] = {...ddkali.rules.follow};
}
}
}
// check number of clock connector available
const idxC = itemsCloned.reduce((prev, next, i) => {
if (next.type === 'kasli' || next.type === 'kasli-backplane' || next.type === 'clocker') {
prev.push(i);
}
return prev;
}, []);
for (let i = 0; i <= idxC.length - 1; i++) {
let slots;
let nbrCurrentClock = 0;
let idx = idxC[i];
if (i !== idxC.length - 1) {
slots = itemsCloned.slice(idx + 1, idxC[i + 1]);
} else {
slots = itemsCloned.slice(idx + 1);
}
nbrCurrentClock = slots.reduce((prev, next) => {
return prev + next.clockOccupied;
}, 0);
if (idx in itemsData) {
if (itemsData[idx].nbrCurrentClock) {
itemsData[idx].nbrCurrentClock += nbrCurrentClock;
} else {
itemsData[idx].nbrCurrentClock = nbrCurrentClock;
}
} else {
itemsData[idx] = {...itemsCloned[idx]};
itemsData[idx].nbrCurrentClock = nbrCurrentClock;
itemsData[idx].warnings = {};
}
if (nbrCurrentClock > itemsCloned[idx].nbrClockMax) {
rules[itemsCloned[idx].rules.maxClock.type] = {...itemsCloned[idx].rules.maxClock};
itemsData[idx].warnings.maxClockWarning = {...itemsCloned[idx].rules.maxClockWarning};
}
}
// check for number of recommanded EEM connectors
['novo', 'urukul', 'koster'].map(_type => {
if (itemsCloned.find(elem => elem.type === _type)) {
rules[this.state.items[_type].rules.connectors.type] = {...this.state.items[_type].rules.connectors};
}
return _type;
});
// check if IDC-BNC is correctly positionned (after Zotino or HD68)
const idxIDCBNC = itemsCloned.reduce((prev, next, i) => {
if (next.type === 'idc-bnc') {
prev.push(i);
}
return prev;
}, []);
for (var i = idxIDCBNC.length - 1; i >= 0; i--) {
const ce = idxIDCBNC[i];
let shouldWarning = false;
if (ce == 0) {
shouldWarning = true;
} else if (ce >= 1) {
const pe = idxIDCBNC[i] - 1;
if (itemsCloned[pe].type !== 'zotino' &&
itemsCloned[pe].type !== 'hd68' &&
itemsCloned[pe].type !== 'idc-bnc') {
shouldWarning = true;
}
}
if (shouldWarning) {
itemsData[ce] = {...itemsCloned[ce]};
itemsData[ce].warnings = {};
itemsData[ce].warnings.wrong = {...itemsCloned[ce].rules.wrong};
}
}
// check number of IDC-BNC adapters for a Zotino and HD68-IDC
const idxZH = itemsCloned.reduce((prev, next, i) => {
if (next.type === 'zotino' || next.type === 'hd68') {
prev.push(i);
}
return prev;
}, []);
for (let i = 0; i <= idxZH.length - 1; i++) {
let slots;
let nbUsedSlot = 0;
let idx = idxZH[i];
if (i !== idxZH.length - 1) {
slots = itemsCloned.slice(idx + 1, idxZH[i + 1]);
} else {
slots = itemsCloned.slice(idx + 1);
}
let stopCount = false;
nbUsedSlot = slots.reduce((prev, next, ci, ca) => {
if (ci === 0 && next.type === 'idc-bnc') {
return prev + 1;
} else if (ca[0].type === 'idc-bnc' && ci > 0 && ca[ci - 1].type === 'idc-bnc') {
if (next.type !== 'idc-bnc') { stopCount = true; }
return prev + (next.type === 'idc-bnc' && !stopCount ? 1 : 0);
}
return prev;
}, 0);
if (idx in itemsData) {
itemsData[idx].nbrCurrentSlot = nbUsedSlot;
if (!('warnings' in itemsData[idx])) {
itemsData[idx].warnings = {};
}
} else {
itemsData[idx] = {...itemsCloned[idx]};
itemsData[idx].nbrCurrentSlot = nbUsedSlot;
itemsData[idx].warnings = {};
}
if (nbUsedSlot > 0) {
if (itemsCloned[idx].rules.maxSlot.message) {
rules[itemsCloned[idx].rules.maxSlot.type] = {...itemsCloned[idx].rules.maxSlot};
}
}
if (nbUsedSlot > itemsCloned[idx].nbrSlotMax) {
itemsData[idx].warnings.maxSlotWarning = {...itemsCloned[idx].rules.maxSlotWarning};
}
// check if HD68-IDC has at least 1 IDC-BNC adapter
if (itemsCloned[idx].type === 'hd68') {
let shouldWarning = false;
if (idx < itemsCloned.length - 1) {
if (itemsCloned[idx + 1].type !== 'idc-bnc') {
shouldWarning = true;
}
} else if (idx === itemsCloned.length - 1) {
shouldWarning = true;
}
if (shouldWarning) {
if (idx in itemsData) {
itemsData[idx].warnings.minAdapter = {...itemsCloned[idx].rules.minAdapter};
} else {
itemsData[idx] = {...itemsCloned[idx]};
itemsData[idx].warnings = {};
itemsData[idx].warnings.minAdapter = {...itemsCloned[idx].rules.minAdapter};
}
}
}
}
// update state with rules
this.setState({
...this.state,
columns: {
...this.state.columns,
cart: {
...this.state.columns.cart,
itemsData: {
...itemsData,
},
}
},
rules: {
...rules,
},
});
}
render() {
const {
currency,
currentItemHovered,
currentMode,
crateModeSlots,
crateModeItems,
items,
columns,
rules,
mobileSideMenuShouldOpen,
newCardJustAdded,
} = this.state;
const isMobile = window.deviceIsMobile();
return (
<DragDropContext onDragEnd={this.handleOnDragEnd}>
<Layout
className="shop"
mobileSideMenuShouldOpen={mobileSideMenuShouldOpen}
isMobile={isMobile}
newCardJustAdded={newCardJustAdded}
onClickToggleMobileSideMenu={this.handleClickToggleMobileSideMenu}
aside={
<Backlog
currency={currency}
items={items}
data={columns['backlog']}
onClickAddItem={this.handleClickAddItem}
onClickToggleMobileSideMenu={this.handleClickToggleMobileSideMenu}
isMobile={isMobile}>
</Backlog>
}
main={(
<OrderPanel
onClickToggleMobileSideMenu={this.handleClickToggleMobileSideMenu}
isMobile={isMobile}
title="Order hardware"
description="
Drag and drop the cards you want into the crate below to see how the combination would look like. If you have any issues with this ordering system, or if you need other configurations, email us directly anytime at sales@m-****.hk. The price is estimated and must be confirmed by a quote."
crateMode={
<CrateMode
items={crateModeItems}
mode={currentMode}
onClickMode={this.handleCrateModeChange}>
</CrateMode>}
crate={
<Crate
cart={
<Cart
nbrSlots={crateModeSlots[currentMode]}
data={columns['cart']}
isMobile={isMobile}
itemHovered={currentItemHovered}
onToggleProgress={this.handleToggleItemProgress}
onToggleWarning={this.handleToggleItemWarning}
onToggleOverlayRemove={this.handleToggleOverlayRemove}
onClickRemoveItem={this.handleDeleteItem}>
</Cart>
}
rules={Object.values(rules).filter(rule => rule)}>
</Crate>
}
summaryPrice={
<OrderSumary
currency={currency}
currentMode={currentMode}
modes={crateModeItems}
summary={columns['cart'].items}
itemsData={columns.cart.itemsData}
onMouseEnterItem={this.handleMouseEnterItem}
onMouseLeaveItem={this.handleMouseLeaveItem}
onDeleteItem={this.handleDeleteItem}
onDeleteAllItems={this.handleDeleteAllItems}
onClickSelectItem={this.handleClickSelectItem}>
</OrderSumary>
}
form={
<OrderForm
onClickSubmit={this.handleClickSubmit}>
</OrderForm>
}>
</OrderPanel>
)}>
</Layout>
</DragDropContext>
);
}
}
ReactDOM.render(
<Shop data={data} />,
document.querySelector('#root-shop'),
);