Make cards behavior working somewhat properly

pull/113/head
火焚 富良 2023-12-12 16:09:29 +08:00 committed by Egor Savkin
parent ec2c0a3b80
commit 6b92bf9145
13 changed files with 356 additions and 418 deletions

View File

@ -9,17 +9,13 @@ import {useShopStore} from "./shop_store";
*/ */
export function Backlog() { export function Backlog() {
const { const {
currency,
data, data,
items, items,
onClickAddItem,
onClickToggleMobileSideMenu, onClickToggleMobileSideMenu,
isMobile, isMobile,
} = useShopStore(state=> ({ } = useShopStore(state=> ({
currency: state.currency,
data: state.groups, data: state.groups,
items: state.cards, items: state.cards,
onClickAddItem: state.addCardFromBacklog,
onClickToggleMobileSideMenu: state.switchSideMenu, onClickToggleMobileSideMenu: state.switchSideMenu,
isMobile: state.isMobile isMobile: state.isMobile
})); }));
@ -46,20 +42,7 @@ export function Backlog() {
{group.items.map(item => { {group.items.map(item => {
item_index++; item_index++;
return ( return (
<ProductItem <ProductItem card_index={item_index}/>
key={item.id}
id={uuidv4()}
index={item_index}
name={`${item.name_number} ${item.name}`}
name_codename={item.name_codename}
price={item.price}
currency={currency}
image={`/images/${item.image}`}
specs={item.specs}
datasheet_file={item.datasheet_file}
datasheet_name={item.datasheet_name}
onClickAddItem={onClickAddItem}
></ProductItem>
) )
})} })}
</div> </div>

View File

@ -4,29 +4,23 @@ import {cartStyle} from "./utils";
import {ProductCartItem} from "./ProductCartItem.jsx"; import {ProductCartItem} from "./ProductCartItem.jsx";
import {FakePlaceholder} from "./FakePlaceholder.jsx"; import {FakePlaceholder} from "./FakePlaceholder.jsx";
import {FillExtData} from "./options/utils"; import {FillExtData} from "./options/utils";
import {CountResources, crate_type_to_hp, hp_to_slots, resource_counters} from "./count_resources"; import {hp_to_slots, resource_counters} from "./count_resources";
import {useShopStore} from "./shop_store"; import {useShopStore} from "./shop_store";
import {TriggerCardWarnings} from "./warnings";
/** /**
* Component that displays a list of <ProductCartItem> * Component that displays a list of <ProductCartItem>
*/ */
export function Cart({crate_index}) { export function Cart({crate_index}) {
// isMobile, isTouch, crate, onToggleOverlayRemove, onClickRemoveItem, onCardUpdate, onClickItem const {crate, crateParams} = useShopStore(state => ({
const {crate} = useShopStore(state => ({ crate: state.crates[crate_index],
crate: state.crates[crate_index] crateParams: state.crateParams
})); }));
console.log(resource_counters, crate) const nbrOccupied = hp_to_slots(crate.occupiedHP);
const nbrSlots = hp_to_slots(crateParams(crate.crate_mode).hp);
const nbrOccupied = hp_to_slots(resource_counters.hp(crate.items, -1));
const nbrSlots = hp_to_slots(crate_type_to_hp(crate.crate_mode));
console.log(nbrOccupied, nbrSlots);
const products = crate.items.map((item, index) => { const products = crate.items.map((item, index) => {
const ext_data = FillExtData(crate.items, index); const ext_data = FillExtData(crate.items, index);
const resources = CountResources(crate.items, index);
const warnings = TriggerCardWarnings(crate.items, index, resources);
return ( return (
<ProductCartItem <ProductCartItem
card_index={index} card_index={index}
@ -34,8 +28,6 @@ export function Cart({crate_index}) {
ext_data={ext_data} ext_data={ext_data}
first={index === 0} first={index === 0}
last={index === crate.items.length - 1 && nbrOccupied >= nbrSlots} last={index === crate.items.length - 1 && nbrOccupied >= nbrSlots}
resources={resources}
warnings={warnings}
key={item.id}/> key={item.id}/>
); );
}); });

View File

@ -34,9 +34,7 @@ export function Crate({crate_index}) {
<Cart crate_index={crate_index}/> <Cart crate_index={crate_index}/>
{1 || (rules && rules.length > 0) && ( <CrateWarnings crate_index={crate_index} />
<CrateWarnings crate_index={crate_index} />
)}
</div> </div>
</div> </div>
); );

View File

@ -8,7 +8,7 @@ export function CrateMode({crate_index}) {
const {modes_order, crate_modes, crate, setMode} = useShopStore(state => ({ const {modes_order, crate_modes, crate, setMode} = useShopStore(state => ({
modes_order: state.modes_order, modes_order: state.modes_order,
crate_modes: state.crate_modes, crate_modes: state.crate_modes,
crate: state.crates[crate_index].crate_mode, crate: state.crates[crate_index],
setMode: state.setCrateMode setMode: state.setCrateMode
})) }))
return ( return (

View File

@ -1,16 +1,16 @@
import React from "react"; import React from "react";
import {TriggerCrateWarnings} from "./warnings"; import {LevelUI} from "./warnings";
import {useShopStore} from "./shop_store"; import {useShopStore} from "./shop_store";
export function CrateWarnings({crate_index}) { export function CrateWarnings({crate_index}) {
const crate = useShopStore(state => (state.crates[crate_index])) const crate = useShopStore(state => (state.crates[crate_index]))
const crate_warnings = TriggerCrateWarnings(crate); const crate_warnings = crate.warnings;
// TODO UI/colors // TODO UI/colors
return ( return (
<div className="crate-info"> <div className="crate-info">
{crate_warnings.map((rule, index) => ( {crate_warnings.map((rule, index) => (
<p key={index} className="rule" style={{'color': "red"}}> <p key={index} className="rule" style={{'color': LevelUI(rule.level).color}}>
<img src={`/images${rule.icon}`} /> <i><strong>{rule.name}:</strong> {rule.message}</i> <img src={LevelUI(rule.level).icon} /> <i>{rule.message}</i>
</p> </p>
))} ))}
</div> </div>

View File

@ -40,7 +40,7 @@ export function OrderPanel({title, description}) {
<CrateList/> <CrateList/>
<section className="summary"> <section className="summary">
<OrderSummary/>
<OrderForm/> <OrderForm/>
</section> </section>

View File

@ -1,190 +1,149 @@
import React, {PureComponent} from 'react'; import React from 'react';
import PropTypes from "prop-types";
import {SummaryPopup} from "./options/SummaryPopup.jsx"; import {SummaryPopup} from "./options/SummaryPopup.jsx";
import {formatMoney} from "./utils"; import {formatMoney} from "./utils";
import {WarningIndicator} from "./CardWarnings.jsx"; import {WarningIndicator} from "./CardWarnings.jsx";
import {total_order_price} from "./count_resources"; import {useShopStore} from "./shop_store";
import {data as shared_data} from "./utils";
/** /**
* Components that displays the list of card that are used in the crate. * Components that displays the list of card that are used in the crate.
* It is a summary of purchase * It is a summary of purchase
*/ */
export class OrderSummary extends PureComponent { export function OrderSummary() {
static get propTypes() { const {
return { currency,
currency: PropTypes.string, crates,
crates: PropTypes.object, total_price,
onDeleteItem: PropTypes.func, crateParams,
onDeleteAllItems: PropTypes.func, deleteCard,
onMouseEnterItem: PropTypes.func, setHighlight,
onMouseLeaveItem: PropTypes.func, resetHighlight,
onClickSelectItem: PropTypes.func, highlighted,
}; clearCrate,
} clearAll
} = useShopStore(state =>({
currency: state.currency,
crates: state.crates,
total_price: state.totalOrderPrice(),
crateParams: state.crateParams,
deleteCard: state.deleteCard,
setHighlight: state.highlightCard,
resetHighlight: state.highlightReset,
highlighted: state.highlighted,
clearAll: state.clearAll,
clearCrate: state.clearCrate
}));
constructor(props) { return (
super(props); <div className="summary-price">
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) { <table>
if (this.props.onDeleteItem) {
this.props.onDeleteItem(index);
}
e.preventDefault();
}
handleOnDeleteAllItems(e) { <thead>
if (this.props.onDeleteAllItems) { <tr>
this.props.onDeleteAllItems(); <td colSpan="2" className="summary-remove-all">
} <span className="item-card-name">Remove all cards</span>
e.preventDefault();
}
handleOnMouseEnterItem(id, e) { <button onClick={clearAll}>
if (this.props.onMouseEnterItem) { <img src="/images/shop/icon-remove.svg"/>
this.props.onMouseEnterItem(id); </button>
} </td>
e.preventDefault(); </tr>
} </thead>
handleOnMouseLeaveItem(e) { {crates.map((crate, _i) => {
if (this.props.onMouseLeaveItem) { let crate_type = crateParams(crate.crate_mode);
this.props.onMouseLeaveItem(); return (
} <tbody key={"summary_crate_body" + crate.id}>
e.preventDefault(); <tr key={"summary_crate_" + crate.id}>
} <td className="item-card-name">{crate_type.name}</td>
<td className="price">
<div>
{`${currency} ${formatMoney(crate_type.price)}`}
handleOnClickSelectItem(index, e) { <button style={{'opacity': '0', 'cursor': 'initial'}}>
if (e.target.tagName !== 'IMG') { <img src="/images/shop/icon-remove.svg"/>
if (this.props.onClickSelectItem) { </button>
this.props.onClickSelectItem(index); </div>
}
}
return e.preventDefault();
}
render() { <span style={{
const { 'display': 'inline-block',
currency, 'width': '30px',
crates }}>&nbsp;</span>
} = this.props; </td>
</tr>
{crate.items.map((item, index) => {
const options = item && item.options;
const options_data = item && item.options_data;
const warnings = item && item.show_warnings;
const selected = crate.id === highlighted.crate && index === highlighted.card;
const total_price = total_order_price(crates); return (<tr key={"summary_crate_" + crate.id + item.id}
className={`hoverable ${selected ? 'selected' : ''}`}
return ( onClick={() => setHighlight(crate.id, index)}
<div className="summary-price"> onMouseEnter={() => setHighlight(crate.id, index)}
onMouseLeave={() => resetHighlight()}>
<table> <td className="item-card-name">
<div>{`${item.name_number} ${item.name} ${item.name_codename}`}</div>
<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>
</thead>
{Object.entries(crates).map(([crate_id, crate], _i) => {
let crate_type = shared_data.crateModes[crate.crate_type];
return (
<tbody key={"summary_crate_body"+crate_id}>
<tr key={"summary_crate_"+crate_id}>
<td className="item-card-name">{crate_type.name}</td>
<td className="price">
<div>
{`${currency} ${formatMoney(crate_type.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> </td>
</tr>
{crate.items.map((item, index) => {
let options = item && item.options;
let options_data = item && item.options_data;
const warnings = item && item.show_warnings;
return (<tr key={"summary_crate_" + crate_id+item.id} <td className="price">
className={`hoverable ${item.selected ? 'selected' : ''}`} <div className="d-inline-flex align-content-center">
onClick={this.handleOnClickSelectItem.bind(this, index)} {`${currency} ${formatMoney(item.price)}`}
onMouseEnter={this.handleOnMouseEnterItem.bind(this, item.id)}
onMouseLeave={this.handleOnMouseLeaveItem}>
<td className="item-card-name">
<div>{`${item.name_number} ${item.name} ${item.name_codename}`}</div>
</td>
<td className="price"> <button onClick={() => deleteCard(crate.id, index)}>
<div className="d-inline-flex align-content-center"> <img src="/images/shop/icon-remove.svg"/>
{`${currency} ${formatMoney(item.price)}`} </button>
<button onClick={this.handleOnDeleteItem.bind(this, index)}> <div style={{'width': '45px', 'height': '20px'}}
<img src="/images/shop/icon-remove.svg" /> className="d-inline-flex align-content-center align-self-center justify-content-evenly">
</button> {(warnings && warnings.length > 0 ? (
<WarningIndicator warnings={warnings}/>
) : (
<span style={{
'display': 'inline-block',
'width': '20px',
}}>&nbsp;</span>
))}
{((options && options_data) ? (
<SummaryPopup id={item.id + "options"} options={options}
data={options_data}/>
) : (
<span style={{
'display': 'inline-block',
'width': '20px',
}}>&nbsp;</span>
))}
</div>
</div>
</td>
</tr>);
})}
</tbody>
)
})}
<div style={{'width': '45px', 'height': '20px'}} className="d-inline-flex align-content-center align-self-center justify-content-evenly"> <tfoot>
{(warnings && warnings.length > 0 ? ( <tr>
<WarningIndicator warnings={warnings}/> <td className="item-card-name">Price estimate</td>
) : ( <td className="price">
<span style={{ <div>
'display': 'inline-block', {currency} {formatMoney(total_price)}
'width': '20px', <button style={{'opacity': '0', 'cursor': 'initial'}}>
}}>&nbsp;</span> <img src="/images/shop/icon-remove.svg" alt="icon remove"/>
))} </button>
{((options && options_data) ? ( </div>
<SummaryPopup id={item.id + "options"} options={options} data={options_data} />
) : (
<span style={{
'display': 'inline-block',
'width': '20px',
}}>&nbsp;</span>
))}
</div>
</div>
</td>
</tr>);
})}
</tbody>
)})}
<tfoot> <span style={{
<tr> 'display': 'inline-block',
<td className="item-card-name">Price estimate</td> 'width': '30px',
<td className="price"> }}>&nbsp;</span>
<div> </td>
${currency} ${formatMoney(total_price)} </tr>
<button style={{'opacity': '0', 'cursor': 'initial'}}> </tfoot>
<img src="/images/shop/icon-remove.svg" alt="icon remove"/>
</button>
</div>
<span style={{ </table>
'display': 'inline-block',
'width': '30px',
}}>&nbsp;</span>
</td>
</tr>
</tfoot>
</table> </div>
);
</div>
);
}
} }

View File

@ -10,7 +10,7 @@ import {useShopStore} from "./shop_store";
* Component that renders a product. * Component that renders a product.
* Used in the crate * Used in the crate
*/ */
export function ProductCartItem({card_index, crate_index, ext_data, first, last, resources, warnings}) { export function ProductCartItem({card_index, crate_index, ext_data, first, last}) {
const {card, crate, highlighted, setHighlight, removeHighlight, onCardUpdate, onCardRemove} = useShopStore(state => ({ const {card, crate, highlighted, setHighlight, removeHighlight, onCardUpdate, onCardRemove} = useShopStore(state => ({
card: state.crates[crate_index].items[card_index], card: state.crates[crate_index].items[card_index],
highlighted: state.crates[crate_index].id === state.highlighted.crate && card_index === state.highlighted.card, highlighted: state.crates[crate_index].id === state.highlighted.crate && card_index === state.highlighted.card,
@ -22,7 +22,8 @@ export function ProductCartItem({card_index, crate_index, ext_data, first, last,
})) }))
let options, options_data; let options, options_data;
//const warnings = data && data.show_warnings; const warnings = card && card.show_warnings;
const resources = card && card.counted_resources;
if (card && card.options) { if (card && card.options) {
options = card.options; options = card.options;
@ -94,7 +95,7 @@ export function ProductCartItem({card_index, crate_index, ext_data, first, last,
<img <img
className='item-cart' className='item-cart'
src={`/images${card.image}`}/> src={card.image}/>
</div> </div>
{/* remove container */} {/* remove container */}

View File

@ -1,120 +1,84 @@
import React, {PureComponent} from 'react'; import React from 'react';
import PropTypes from "prop-types";
import {Draggable} from "@hello-pangea/dnd"; import {Draggable} from "@hello-pangea/dnd";
import {formatMoney, productStyle} from "./utils"; import {formatMoney, productStyle} from "./utils";
import {useShopStore} from "./shop_store";
/** /**
* Component that renders a product. * Component that renders a product.
* Used in the aside (e.g backlog of product) * Used in the aside (e.g backlog of product)
*/ */
export class ProductItem extends PureComponent { export function ProductItem({card_index}) {
const {card, currency, onAddCard} = useShopStore(state => ({
card: state.getCardDescription(card_index),
currency: state.currency,
onAddCard: state.addCardFromBacklog
}));
static get propTypes() {
return {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
name_codename: PropTypes.string,
price: PropTypes.number.isRequired,
currency: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
specs: PropTypes.array,
datasheet_file: PropTypes.string,
datasheet_name: PropTypes.string,
onClickAddItem: PropTypes.func,
};
}
constructor(props) { const render_specs = (card.specs && card.specs.length > 0 && (
super(props); <ul>
this.handleOnClickAddItem = this.handleOnClickAddItem.bind(this); {card.specs.map((spec, index) =>
} <li key={index}>{spec}</li>
)}
</ul>
));
handleOnClickAddItem(id, tap, e) { const render_datasheet_link = (card.datasheet_file && card.datasheet_name && (
if (this.props.onClickAddItem) { <div className="ds">
this.props.onClickAddItem(id, tap); <span className='doc-icon'></span>
} <a href={card.datasheet_file} target="_blank" rel="noopener noreferrer">
e.preventDefault(); {card.datasheet_name}
} </a>
</div>
));
render() { return (
const { <section className="productItem">
id,
index,
name,
name_codename,
price,
currency,
image,
specs,
datasheet_file,
datasheet_name,
} = this.props;
const render_specs = (specs && specs.length > 0 && ( <div className="content">
<ul> <h3 style={{'marginBottom': card.name_codename ? '5px' : '20px'}}>{card.name}</h3>
{specs.map((spec, index) => {card.name_codename ? (
<li key={index}>{spec}</li> <p>{card.name_codename}</p>
)} ) : null}
</ul>
));
const render_datasheet_link = (datasheet_file && datasheet_name && ( <div className="price">{`${currency} ${formatMoney(card.price)}`}</div>
<div className="ds">
<span className='doc-icon'></span> {render_specs}
<a href={datasheet_file} target="_blank" rel="noopener noreferrer">
{datasheet_name} {render_datasheet_link}
</a>
</div> </div>
));
return ( <div className="content">
<section className="productItem">
<div className="content"> <button onClick={() => onAddCard(null, card_index, null)}>
<h3 style={{ 'marginBottom': name_codename ? '5px' : '20px'}}>{name}</h3> <img src="/images/shop/icon-add.svg" alt="add"/>
{name_codename ? ( </button>
<p>{name_codename}</p>
) : null }
<div className="price">{`${currency} ${formatMoney(price)}`}</div> <Draggable draggableId={card.id} index={card_index}>
{(provided, snapshot) => (
{render_specs} <React.Fragment>
<img
{render_datasheet_link} ref={provided.innerRef}
</div> {...provided.draggableProps}
{...provided.dragHandleProps}
<div className="content"> style={productStyle(
provided.draggableProps.style,
<button onClick={this.handleOnClickAddItem.bind(this, index, true)}> snapshot,
<img src="/images/shop/icon-add.svg" alt="add" /> true, // hack: remove weird animation after a drop
</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> src={card.image}/>
)}
</Draggable>
</div> {/* Allows to simulate a clone */}
{snapshot.isDragging && (
<img className="simclone" src={card.image}/>
)}
</React.Fragment>
)}
</Draggable>
</div>
</section>
);
</section>
);
}
} }

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, {useEffect} from 'react';
import {DragDropContext} from "@hello-pangea/dnd"; import {DragDropContext} from "@hello-pangea/dnd";
@ -12,28 +12,13 @@ import {useShopStore} from "./shop_store";
*/ */
export function Shop() { export function Shop() {
const {addCardFromBacklog, moveCard, deleteCard} = useShopStore(state => ({ const {addCardFromBacklog, moveCard, deleteCard, cardIndexById} = useShopStore(state => ({
addCardFromBacklog: state.addCardFromBacklog, addCardFromBacklog: state.addCardFromBacklog,
moveCard: state.moveCard, moveCard: state.moveCard,
deleteCard: state.deleteCard deleteCard: state.deleteCard,
cardIndexById: state.cardIndexById
})); }));
const handleOnDragEnd = (drop_result, provided) => { const handleOnDragEnd = (drop_result, provided) => {
console.log(drop_result, provided)
//{
// "draggableId": "42dc17e9-9e75-45ee-ad27-2233b6f07a5e",
// "type": "DEFAULT",
// "source": {
// "index": 17,
// "droppableId": "backlog"
// },
// "reason": "DROP",
// "mode": "FLUID",
// "destination": {
// "droppableId": "crate0",
// "index": 0
// },
// "combine": null
// }
if (drop_result.source.droppableId === "backlog") if (drop_result.source.droppableId === "backlog")
addCardFromBacklog(drop_result.destination.droppableId, drop_result.source.index, drop_result.destination.index); addCardFromBacklog(drop_result.destination.droppableId, drop_result.source.index, drop_result.destination.index);
else if(drop_result.destination.droppableId === "backlog") else if(drop_result.destination.droppableId === "backlog")
@ -42,6 +27,10 @@ export function Shop() {
moveCard(drop_result.source.droppableId, drop_result.source.index, drop_result.destination.droppableId, drop_result.destination.index) moveCard(drop_result.source.droppableId, drop_result.source.index, drop_result.destination.droppableId, drop_result.destination.index)
} }
useEffect(() => {
addCardFromBacklog(null, [cardIndexById("kasli"), cardIndexById("eem_pwr_mod")], -1);
}, []);
return ( return (
<DragDropContext onDragEnd={handleOnDragEnd}> <DragDropContext onDragEnd={handleOnDragEnd}>
<Layout <Layout

View File

@ -1,7 +1,7 @@
'use strict'; 'use strict';
import {create} from "zustand"; import {create} from "zustand";
import {data, itemsUnfoldedList} from "./utils"; import {data as shared_data, itemsUnfoldedList} from "./utils";
import {true_type_of} from "./options/utils"; import {true_type_of} from "./options/utils";
import {v4 as uuidv4} from "uuid"; import {v4 as uuidv4} from "uuid";
import {FillResources} from "./count_resources"; import {FillResources} from "./count_resources";
@ -9,15 +9,18 @@ import {TriggerCrateWarnings, TriggerWarnings} from "./warnings";
const useBacklog = ((set, get) => ({ const useBacklog = ((set, get) => ({
cards: data.items, cards: shared_data.items,
groups: data.columns.backlog, groups: shared_data.columns.backlog,
cards_list: itemsUnfoldedList, cards_list: itemsUnfoldedList,
currency: data.currency currency: shared_data.currency,
getCardDescription: index => get().cards[get().cards_list[index]],
cardIndexById: card_id => get().cards_list.findIndex((element) => (card_id === element))
})); }));
const useCrateModes = ((set, get) => ({ const useCrateModes = ((set, get) => ({
crate_modes: data.crateModes, crate_modes: shared_data.crateModes,
modes_order: data.crateModeOrder modes_order: shared_data.crateModeOrder,
crateParams: mode => get().crate_modes[mode],
})); }));
const useLayout = ((set, get) => ({ const useLayout = ((set, get) => ({
@ -47,25 +50,41 @@ const useSubmitForm = ((set, get) => ({
isProcessingComplete: true, isProcessingComplete: true,
})); }));
const useCart = ((set, get) => ({ const useHighlighted = ((set, get) => ({
crates: data.columns.crates,
active_crate: "crate0",
highlighted: { highlighted: {
crate: "", crate: "",
card: 0 card: 0
}, },
highlightCard: (crate_id, index) => set(state => ({
highlighted: {
crate: crate_id,
card: index
}
})),
highlightReset: () => set(state => ({
highlighted: {
crate: "",
card: 0
}
})),
}));
newCrate: () => set((state) => ({crates: state.crates.concat({ const useCart = ((set, get) => ({
id: "crate"+state.crates.length, crates: shared_data.columns.crates,
active_crate: "crate0",
_newCrate: (crate_id) => set((state) => ({crates: state.crates.concat({
id: crate_id || "crate" + state.crates.length,
crate_mode: "rack", crate_mode: "rack",
items: [], items: [],
warnings: [] warnings: [],
occupiedHP: 0
})})), })})),
delCrate: (id) => set(state => ({ delCrate: (id) => set(state => ({
crates: state.crates.filter((crate => crate.id !== id)) crates: state.crates.filter((crate => crate.id !== id))
})), })),
setCrateMode: (id, mode) => set(state => ({ _setCrateMode: (id, mode) => set(state => ({
crates: state.crates.map((crate, _i) => { crates: state.crates.map((crate, _i) => {
if (crate.id === id) { if (crate.id === id) {
return { return {
@ -76,12 +95,13 @@ const useCart = ((set, get) => ({
}) })
})), })),
setActiveCrate: (id) => set(state => ({active_crate: id})), setActiveCrate: (id) => set(state => ({active_crate: id})),
addCardFromBacklog: (crate_to, index_from, index_to) => set(state => { _addCardFromBacklog: (crate_to, index_from, index_to) => set(state => {
const take_from = (true_type_of(index_from) === "array" ? index_from : [index_from]).map((item, _i) => (state.cards_list[item])); const take_from = (true_type_of(index_from) === "array" ? index_from : [index_from]).map((item, _i) => (state.cards_list[item]));
const dest = crate_to || state.active_crate; const dest = crate_to || state.active_crate;
return { return {
crates: state.crates.map((crate, _i) => { crates: state.crates.map((crate, _i) => {
if (dest === crate.id) { if (dest === crate.id) {
index_to = index_to != null ? index_to : crate.items.length;
return { return {
...crate, ...crate,
items: crate.items.toSpliced(index_to, 0, ...take_from.map((card_name, _) => { items: crate.items.toSpliced(index_to, 0, ...take_from.map((card_name, _) => {
@ -92,7 +112,8 @@ const useCart = ((set, get) => ({
}) })
} }
}), }),
moveCard: (crate_from, index_from, crate_to, index_to) => set(state => { _moveCard: (crate_from, index_from, crate_to, index_to) => set(state => {
console.log(crate_from, index_from, crate_to, index_to)
const the_card = state.crates.find((crate, _) => crate_from === crate.id ).items[index_from]; const the_card = state.crates.find((crate, _) => crate_from === crate.id ).items[index_from];
return { return {
crates: state.crates.map((crate, _i) => { crates: state.crates.map((crate, _i) => {
@ -102,7 +123,7 @@ const useCart = ((set, get) => ({
delete items_copy[index_from]; delete items_copy[index_from];
return { return {
...crate, ...crate,
items: items_copy.toSpliced(index_to, 0, the_card).filter((item, _) => !!item) items: items_copy.toSpliced(index_to+1, 0, the_card).filter((item, _) => !!item)
} }
} else if (crate_to === crate.id) { } else if (crate_to === crate.id) {
return { return {
@ -119,18 +140,18 @@ const useCart = ((set, get) => ({
}) })
} }
}), }),
deleteCard: (crate, index) => set(state => ({ _deleteCard: (crate_id, index) => set(state => ({
crates: state.crates.map((crate, _i) => { crates: state.crates.map((crate, _i) => {
if (crate === crate.id) { if (crate_id === crate.id) {
return { return {
...crate, ...crate,
items: crate.items.splice(index, 1) items: crate.items.toSpliced(index, 1)
} }
} }
else return crate; else return crate;
}) })
})), })),
clearCrate: (id) => set(state => ({ _clearCrate: (id) => set(state => ({
crates: state.crates.map((crate, _i) => { crates: state.crates.map((crate, _i) => {
if (id === crate.id) { if (id === crate.id) {
return { return {
@ -141,9 +162,12 @@ const useCart = ((set, get) => ({
else return crate; else return crate;
}) })
})), })),
updateOptions: (crate, index, new_options) => set(state => ({ clearAll: () => set(state => ({
crates: []
})),
_updateOptions: (crate_id, index, new_options) => set(state => ({
crates: state.crates.map((crate, _i) => { crates: state.crates.map((crate, _i) => {
if (crate === crate.id) { if (crate_id === crate.id) {
let itemsCopy = Array.from(crate.items); let itemsCopy = Array.from(crate.items);
itemsCopy[index].options_data = {...itemsCopy[index].options_data, ...new_options}; itemsCopy[index].options_data = {...itemsCopy[index].options_data, ...new_options};
return { return {
@ -154,37 +178,74 @@ const useCart = ((set, get) => ({
else return crate; else return crate;
}) })
})), })),
highlightCard: (crate, index) => set(state => ({
highlighted: {
crate: crate,
card: index
}
})),
highlightReset: () => set(state => ({
highlighted: {
crate: "",
card: 0
}
})),
fillWarnings: (crate) => set(state => ({ fillWarnings: (crate_id) => set(state => ({
// actually seems to be just render-time action, no need to put data in it,
// though needs to be optimized to be done only on real crate updates
crates: state.crates.map((crate, _i) => { crates: state.crates.map((crate, _i) => {
if (crate === crate.id) { if (crate_id === crate.id) {
let itemsCopy = Array.from(crate.items); let itemsCopy = Array.from(crate.items);
itemsCopy = FillResources(itemsCopy); itemsCopy = FillResources(itemsCopy);
itemsCopy = TriggerWarnings(itemsCopy); itemsCopy = TriggerWarnings(itemsCopy);
const crate_warnings = TriggerCrateWarnings(crate); const [crate_warnings, occupied] = TriggerCrateWarnings(crate);
return { return {
...crate, ...crate,
items: itemsCopy, items: itemsCopy,
warnings: crate_warnings warnings: crate_warnings,
occupiedHP: occupied
} }
} }
else return crate; else return crate;
}) })
})) })),
totalOrderPrice: () => {
let sum = 0;
get().crates.forEach( (crate, _i) => {
sum += get().crate_modes[crate.crate_mode].price;
crate.items.forEach((item, _) => {
sum += item.price;
});
});
return sum;
},
// Composite actions that require warnings recalculation:
newCrate: () => {
const crate_id = "crate" + get().crates.length;
get()._newCrate(crate_id)
get().fillWarnings(crate_id);
},
setCrateMode: (id, mode) => {
console.log("setCrateMode", id, mode)
get()._setCrateMode(id, mode)
get().fillWarnings(id);
},
addCardFromBacklog: (crate_to, index_from, index_to) => {
const dest = crate_to || get().active_crate;
get()._addCardFromBacklog(dest, index_from, index_to)
get().fillWarnings(dest);
},
moveCard: (crate_from, index_from, crate_to, index_to) => {
get()._moveCard(crate_from, index_from, crate_to, index_to);
get().fillWarnings(crate_to);
if (crate_from !== crate_to) get().fillWarnings(crate_from);
},
deleteCard: (crate_id, index) => {
get()._deleteCard(crate_id, index);
get().fillWarnings(crate_id);
},
clearCrate: (id) => {
get()._clearCrate(id);
get().fillWarnings(id);
},
updateOptions: (crate_id, index, new_options) => {
get()._updateOptions(crate_id, index, new_options);
get().fillWarnings(crate_id);
}
// TODO load and save jsons? // TODO load and save jsons?
})) }))
@ -195,5 +256,6 @@ export const useShopStore = create((...params) => ({
...useCrateModes(...params), ...useCrateModes(...params),
...useCart(...params), ...useCart(...params),
...useSubmitForm(...params), ...useSubmitForm(...params),
...useLayout(...params) ...useLayout(...params),
...useHighlighted(...params),
})) }))

View File

@ -7,10 +7,11 @@
import {crate_type_to_hp, item_occupied_counters, resource_counters} from "./count_resources"; import {crate_type_to_hp, item_occupied_counters, resource_counters} from "./count_resources";
import {data as shared_data} from "./utils"; import {data as shared_data} from "./utils";
import {useShopStore} from "./shop_store";
const Levels = { const Levels = {
"reminder": {priority: 1, icon: '/images/shop/icon-reminder.svg'}, "reminder": {priority: 1, icon: '/images/shop/icon-reminder.svg', color: "black"},
"warning": {priority: 2, icon: '/images/shop/icon-warning.svg'}, "warning": {priority: 2, icon: '/images/shop/icon-warning.svg', color: "#c75e5e"},
} }
const find_in_counters = (counters, name) => { const find_in_counters = (counters, name) => {
@ -97,21 +98,6 @@ const Types = {
} }
} }
export function TriggerCardWarnings(data, index, precounted) {
const element = data[index];
return (element.warnings && element.warnings
.map((warning, _) => {
if (!!Types[warning])
return Types[warning].trigger(data, index, precounted) ? {trigger: undefined, name: warning, ...Types[warning]} : null;
else
return Types.default;
})
.filter((warning, _) => {
return !!warning
}));
}
export function TriggerWarnings(data) { export function TriggerWarnings(data) {
return data.map((element, index) => { return data.map((element, index) => {
if (!element.warnings) return element; if (!element.warnings) return element;
@ -137,23 +123,26 @@ export function MaxLevel(warnings) {
return mx; return mx;
} }
export function LevelUI(warning_level) {
const warning_t = Levels[warning_level];
return {icon: warning_t.icon, color: warning_t.color};
}
const crate_warnings = { const crate_warnings = {
"overfit": { "overfit": {
message: "You have reached the maximum number of slots allowed for this crate. Consider removing cards.", message: "You have reached the maximum number of slots allowed for this crate. Consider removing cards.",
level: "warning", level: "warning",
trigger: (crate, occupied) => { trigger: (crate, occupied) => {
const nbrHP = crate_type_to_hp(crate.crate_type); const nbrHP = useShopStore.getState().crateParams(crate.crate_mode).hp;
return occupied > nbrHP; return occupied > nbrHP && nbrHP > 0;
} }
}, },
"underfit_rack": { "underfit_rack": {
message: "The selected cards fit in a 42hp desktop crate, consider switching to it for a more compact system", message: "The selected cards fit in a 42hp desktop crate, consider switching to it for a more compact system",
level: "reminder", level: "reminder",
trigger: (crate, occupied) => { trigger: (crate, occupied) => {
const nbrHPDesktop = shared_data.crateModes.desktop.hp; const nbrHPDesktop = useShopStore.getState().crate_modes.desktop.hp;
return crate.crate_type === shared_data.crateModes.rack.id && occupied < nbrHPDesktop; return crate.crate_mode === useShopStore.getState().crate_modes.rack.id && occupied < nbrHPDesktop;
} }
} }
} }
@ -164,5 +153,5 @@ export function TriggerCrateWarnings(crate) {
Object.entries(crate_warnings).forEach(([id, warning], _) => { Object.entries(crate_warnings).forEach(([id, warning], _) => {
if (warning.trigger(crate, nbrOccupied)) warnings.push({...warning, id: id, trigger: undefined}); if (warning.trigger(crate, nbrOccupied)) warnings.push({...warning, id: id, trigger: undefined});
}) })
return warnings; return [warnings, nbrOccupied];
} }

View File

@ -70,7 +70,7 @@ const shop_data = {
name_number: '1124', name_number: '1124',
name_codename: 'Kasli 2.0', name_codename: 'Kasli 2.0',
price: 3600, price: 3600,
image: '/shop/graphic-03_kasli.svg', image: '/images/shop/graphic-03_kasli.svg',
specs: [ specs: [
'FPGA core device, runs ARTIQ kernels, controls the EEMs.', 'FPGA core device, runs ARTIQ kernels, controls the EEMs.',
'4 SFP 6Gb/s slots for Ethernet or DRTIO.', '4 SFP 6Gb/s slots for Ethernet or DRTIO.',
@ -126,7 +126,7 @@ const shop_data = {
name_number: '1125', name_number: '1125',
name_codename: 'Kasli-SoC', name_codename: 'Kasli-SoC',
price: 5100, price: 5100,
image: '/shop/graphic-03_kaslisoc.svg', image: '/images/shop/graphic-03_kaslisoc.svg',
specs: [ specs: [
'Core device based on Zynq-7000 CPU+FPGA system-on-chip.', 'Core device based on Zynq-7000 CPU+FPGA system-on-chip.',
'Runs ARTIQ kernels on 1GHz Cortex-A9 CPU with hardware FPU.', 'Runs ARTIQ kernels on 1GHz Cortex-A9 CPU with hardware FPU.',
@ -194,7 +194,7 @@ const shop_data = {
name_number: '1008', name_number: '1008',
name_codename: '', name_codename: '',
price: 400, price: 400,
image: '/shop/graphic-03_VHDCI_carrier.svg', image: '/images/shop/graphic-03_VHDCI_carrier.svg',
specs: [ specs: [
'Passive adapter between VHDCI and EEMs.', 'Passive adapter between VHDCI and EEMs.',
'VHDCI (SCSI-3) cables can carry EEM signals over short distances between crates.', 'VHDCI (SCSI-3) cables can carry EEM signals over short distances between crates.',
@ -221,7 +221,7 @@ const shop_data = {
name_number: '2118', name_number: '2118',
name_codename: '', name_codename: '',
price: 450, price: 450,
image: '/shop/graphic-03_BNC-TTL.svg', image: '/images/shop/graphic-03_BNC-TTL.svg',
specs: [ specs: [
'Two banks of four digital channels each, with BNC connectors.', 'Two banks of four digital channels each, with BNC connectors.',
'Each bank with individual ground isolation.', 'Each bank with individual ground isolation.',
@ -293,7 +293,7 @@ const shop_data = {
name_number: '2128', name_number: '2128',
name_codename: '', name_codename: '',
price: 400, price: 400,
image: '/shop/graphic-03_SMA-TTL.svg', image: '/images/shop/graphic-03_SMA-TTL.svg',
specs: [ specs: [
'Same as above, but with SMA connectors.' 'Same as above, but with SMA connectors.'
], ],
@ -359,7 +359,7 @@ const shop_data = {
name_number: '2238', name_number: '2238',
name_codename: '', name_codename: '',
price: 600, price: 600,
image: '/shop/graphic-03_MCX-TTL.svg', image: '/images/shop/graphic-03_MCX-TTL.svg',
specs: [ specs: [
'16 single-ended digital signals on MCX connectors.', '16 single-ended digital signals on MCX connectors.',
'Direction selectable in banks of four signals.', 'Direction selectable in banks of four signals.',
@ -451,7 +451,7 @@ const shop_data = {
name_number: '2245', name_number: '2245',
name_codename: '', name_codename: '',
price: 390, price: 390,
image: '/shop/graphic-03_LVDS.svg', image: '/images/shop/graphic-03_LVDS.svg',
specs: [ specs: [
'Supplies 16 LVDS pairs via 4 front-panel RJ45 connectors.', 'Supplies 16 LVDS pairs via 4 front-panel RJ45 connectors.',
'Each RJ45 supplies 4 LVDS DIOs.', 'Each RJ45 supplies 4 LVDS DIOs.',
@ -538,7 +538,7 @@ const shop_data = {
name_number: '4410', name_number: '4410',
name_codename: 'Urukul', name_codename: 'Urukul',
price: 2350, price: 2350,
image: '/shop/graphic-03_Urukul.svg', image: '/images/shop/graphic-03_Urukul.svg',
specs: [ specs: [
'4 channel 1GS/s DDS.', '4 channel 1GS/s DDS.',
'Output frequency (-3 dB): <1 to >400 MHz.', 'Output frequency (-3 dB): <1 to >400 MHz.',
@ -599,7 +599,7 @@ const shop_data = {
name_number: '4412', name_number: '4412',
name_codename: 'Urukul', name_codename: 'Urukul',
price: 2350, price: 2350,
image: '/shop/graphic-03_Urukul-4412.svg', image: '/images/shop/graphic-03_Urukul-4412.svg',
specs: [ specs: [
'4 channel 1GS/s DDS.', '4 channel 1GS/s DDS.',
'Higher frequency resolution ~8 µHz (47 bit)', 'Higher frequency resolution ~8 µHz (47 bit)',
@ -635,7 +635,7 @@ const shop_data = {
name_number: '4624', name_number: '4624',
name_codename: 'Phaser', name_codename: 'Phaser',
price: 4260, price: 4260,
image: '/shop/graphic-03_Phaser.svg', image: '/images/shop/graphic-03_Phaser.svg',
specs: [ specs: [
'2x 1.25 GS/s IQ upconverters.', '2x 1.25 GS/s IQ upconverters.',
'dual IQ mixer + 0.3 GHz to 4.8 GHz VCO + PLL.', 'dual IQ mixer + 0.3 GHz to 4.8 GHz VCO + PLL.',
@ -667,7 +667,7 @@ const shop_data = {
name_number: '5432', name_number: '5432',
name_codename: 'Zotino', name_codename: 'Zotino',
price: 1600, price: 1600,
image: '/shop/graphic-03_Zotino.svg', image: '/images/shop/graphic-03_Zotino.svg',
specs: [ specs: [
'32-channel DAC.', '32-channel DAC.',
'16-bit resolution.', '16-bit resolution.',
@ -702,7 +702,7 @@ const shop_data = {
name_number: '5632', name_number: '5632',
name_codename: 'Fastino', name_codename: 'Fastino',
price: 3390, price: 3390,
image: '/shop/graphic-03_Fastino.svg', image: '/images/shop/graphic-03_Fastino.svg',
specs: [ specs: [
'32-channel DAC.', '32-channel DAC.',
'16-bit resolution.', '16-bit resolution.',
@ -731,7 +731,7 @@ const shop_data = {
name_number: '5518', name_number: '5518',
name_codename: '', name_codename: '',
price: 160, price: 160,
image: '/shop/graphic-03_IDC-BNC-adapter.svg', image: '/images/shop/graphic-03_IDC-BNC-adapter.svg',
specs: [ specs: [
'Breaks out analog signals from Zotino or HD68-IDC to BNC connectors.', 'Breaks out analog signals from Zotino or HD68-IDC to BNC connectors.',
'Each card provides 8 channels.', 'Each card provides 8 channels.',
@ -753,7 +753,7 @@ const shop_data = {
name_number: '5528', name_number: '5528',
name_codename: '', name_codename: '',
price: 160, price: 160,
image: '/shop/graphic-03_SMA-IDC.svg', image: '/images/shop/graphic-03_SMA-IDC.svg',
specs: [ specs: [
'Breaks out analog signals from Zotino or HD68-IDC to SMA connectors.', 'Breaks out analog signals from Zotino or HD68-IDC to SMA connectors.',
'Each card provides 8 channels.', 'Each card provides 8 channels.',
@ -775,7 +775,7 @@ const shop_data = {
name_number: '5538', name_number: '5538',
name_codename: '', name_codename: '',
price: 160, price: 160,
image: '/shop/graphic-03_MCX-IDC.svg', image: '/images/shop/graphic-03_MCX-IDC.svg',
specs: [ specs: [
'Breaks out analog signals from Zotino or HD68-IDC to MCX connectors.', 'Breaks out analog signals from Zotino or HD68-IDC to MCX connectors.',
'Each card provides 8 channels.', 'Each card provides 8 channels.',
@ -797,7 +797,7 @@ const shop_data = {
name_number: '5568', name_number: '5568',
name_codename: '', name_codename: '',
price: 150, price: 150,
image: '/shop/graphic-03_HD68.svg', image: '/images/shop/graphic-03_HD68.svg',
specs: [ specs: [
'Connects an external HD68 cable to IDC-BNC, IDC-SMA or IDC-MCX cards.', 'Connects an external HD68 cable to IDC-BNC, IDC-SMA or IDC-MCX cards.',
], ],
@ -823,7 +823,7 @@ const shop_data = {
name_number: '5108', name_number: '5108',
name_codename: '', name_codename: '',
price: 1600, price: 1600,
image: '/shop/graphic-03_Sampler.svg', image: '/images/shop/graphic-03_Sampler.svg',
specs: [ specs: [
'8-channel ADC.', '8-channel ADC.',
'16-bit resolution.', '16-bit resolution.',
@ -874,7 +874,7 @@ const shop_data = {
name_number: '6302', name_number: '6302',
name_codename: '', name_codename: '',
price: 550, price: 550,
image: '/shop/graphic-03_Grabber.svg', image: '/images/shop/graphic-03_Grabber.svg',
specs: [ specs: [
'Camera input interface card.', 'Camera input interface card.',
'Supports some EMCCD cameras.', 'Supports some EMCCD cameras.',
@ -901,7 +901,7 @@ const shop_data = {
name_number: '7210', name_number: '7210',
name_codename: '', name_codename: '',
price: 525, price: 525,
image: '/shop/graphic-03_Clocker.svg', image: '/images/shop/graphic-03_Clocker.svg',
specs: [ specs: [
'Distribute a low jitter clock signal among cards.', 'Distribute a low jitter clock signal among cards.',
'2 inputs.', '2 inputs.',
@ -936,7 +936,7 @@ const shop_data = {
name_number: '8452', name_number: '8452',
name_codename: 'Stabilizer', name_codename: 'Stabilizer',
price: 2000, price: 2000,
image: '/shop/graphic-03_Stabilizer.svg', image: '/images/shop/graphic-03_Stabilizer.svg',
specs: [ specs: [
'CPU-based dual-channel fast servo.', 'CPU-based dual-channel fast servo.',
'400MHz STM32H743ZIT6.', '400MHz STM32H743ZIT6.',
@ -968,7 +968,7 @@ const shop_data = {
name_number: '4456', name_number: '4456',
name_codename: 'Mirny', name_codename: 'Mirny',
price: 2660, price: 2660,
image: '/shop/graphic-03_Mirny.svg', image: '/images/shop/graphic-03_Mirny.svg',
specs: [ specs: [
'4-channel Wide-band PLL/VCO-based microwave frequency synthesiser.', '4-channel Wide-band PLL/VCO-based microwave frequency synthesiser.',
'53 MHz to >4 GHz.', '53 MHz to >4 GHz.',
@ -998,7 +998,7 @@ const shop_data = {
name_number: '4457', name_number: '4457',
name_codename: 'Mirny + Almazny', name_codename: 'Mirny + Almazny',
price: 3660, price: 3660,
image: '/shop/graphic-03_Almazny.svg', image: '/images/shop/graphic-03_Almazny.svg',
specs: [ specs: [
'Mirny with high frequency mezzanine.', 'Mirny with high frequency mezzanine.',
'Additional 4 channels up to 12 GHz.', 'Additional 4 channels up to 12 GHz.',
@ -1025,7 +1025,7 @@ const shop_data = {
name_number: '8453', name_number: '8453',
name_codename: '', name_codename: '',
price: 2600, price: 2600,
image: '/shop/graphic-03_Thermostat-EEM.svg', image: '/images/shop/graphic-03_Thermostat-EEM.svg',
specs: [ specs: [
'4 TEC channels.', '4 TEC channels.',
'Sensor channel count: 8 differential, 16 single-ended.', 'Sensor channel count: 8 differential, 16 single-ended.',
@ -1053,7 +1053,7 @@ const shop_data = {
name_number: '5716', name_number: '5716',
name_codename: 'Shuttler', name_codename: 'Shuttler',
price: 8500, price: 8500,
image: '/shop/graphic-03_Shuttler.svg', image: '/images/shop/graphic-03_Shuttler.svg',
specs: [ specs: [
'16-ch, 125 MSPS DAC EEM with remote analog front end board.', '16-ch, 125 MSPS DAC EEM with remote analog front end board.',
'High DC resolution (up to ~18 bits with sigma-delta modulation) for trap electrode bias.', 'High DC resolution (up to ~18 bits with sigma-delta modulation) for trap electrode bias.',
@ -1080,7 +1080,7 @@ const shop_data = {
name_number: '4459', name_number: '4459',
name_codename: 'Stabilizer + Pounder', name_codename: 'Stabilizer + Pounder',
price: 4460, price: 4460,
image: '/shop/graphic-03_Pounder.svg', image: '/images/shop/graphic-03_Pounder.svg',
specs: [ specs: [
'Stabilizer with Pounder daughter card.', 'Stabilizer with Pounder daughter card.',
'2-channel Pound Drever Hall (PDH) lock generator.', '2-channel Pound Drever Hall (PDH) lock generator.',
@ -1111,7 +1111,7 @@ const shop_data = {
name_number: '1106', name_number: '1106',
name_codename: '', name_codename: '',
price: 750, price: 750,
image: '/shop/graphic-03_eem_pwr_mod.svg', image: '/images/shop/graphic-03_eem_pwr_mod.svg',
specs: [ specs: [
"EEM AC power module.", "EEM AC power module.",
"400W with forced cooling (25CFM), 200W with free air convection.", "400W with forced cooling (25CFM), 200W with free air convection.",
@ -1189,7 +1189,8 @@ const shop_data = {
id: "crate0", id: "crate0",
crate_mode: "rack", crate_mode: "rack",
items: [], items: [],
warnings: [] warnings: [],
occupiedHP: 0,
}] }]
}, },