diff --git a/frontend/src/components/Board/BuyParts/Bom.module.scss b/frontend/src/components/Board/BuyParts/Bom.module.scss index f6e9123d02..fa8f5f81d5 100644 --- a/frontend/src/components/Board/BuyParts/Bom.module.scss +++ b/frontend/src/components/Board/BuyParts/Bom.module.scss @@ -14,4 +14,18 @@ font-size: 14px !important; background-color: white !important; } + + .expandWrapper { + padding-left: 1px; + padding-right: 1px; + } + + .expandTable { + border-top: 0px; + cursor: pointer; + } + + .expandTableRow { + border-top: 0px; + } } diff --git a/frontend/src/components/Board/BuyParts/Bom.jsx b/frontend/src/components/Board/BuyParts/Bom.tsx similarity index 76% rename from frontend/src/components/Board/BuyParts/Bom.jsx rename to frontend/src/components/Board/BuyParts/Bom.tsx index 955942b56f..faa56b71c0 100644 --- a/frontend/src/components/Board/BuyParts/Bom.jsx +++ b/frontend/src/components/Board/BuyParts/Bom.tsx @@ -2,11 +2,10 @@ import React, { useEffect, useState } from 'react' import { Icon, Table } from 'semantic-ui-react' import DoubleScrollBar from 'react-double-scrollbar' -import { array, bool, func, number, string } from 'prop-types' import styles from './Bom.module.scss' import TsvTable from './TsvTable' -const Bom = ({ length, parts, tsv }) => { +const Bom = ({ length, parts, tsv }: BomProps) => { const [diff, setDiff] = useState(0) const [collapsed, setCollapsed] = useState(true) @@ -32,7 +31,7 @@ const Bom = ({ length, parts, tsv }) => { ) } -const ExpandBom = ({ diff, collapsed, setCollapsed }) => { +const ExpandBom = ({ diff, collapsed, setCollapsed }: ExpandBomProps) => { const summary = diff > 0 && collapsed ? ( @@ -44,13 +43,13 @@ const ExpandBom = ({ diff, collapsed, setCollapsed }) => { ) : null return ( -
+
{ > {summary} - + {collapsed ? 'View all' : 'Hide'} @@ -71,16 +70,16 @@ const ExpandBom = ({ diff, collapsed, setCollapsed }) => { ) } -Bom.propTypes = { - length: number.isRequired, - parts: array.isRequired, - tsv: string.isRequired, +interface BomProps { + length: number + parts: Array + tsv: string } -ExpandBom.propTypes = { - diff: number.isRequired, - collapsed: bool.isRequired, - setCollapsed: func.isRequired, +interface ExpandBomProps { + diff: number + collapsed: boolean + setCollapsed: (collapsed: boolean) => void } export default Bom diff --git a/frontend/src/components/Board/BuyParts/DirectStores.jsx b/frontend/src/components/Board/BuyParts/DirectStores.jsx deleted file mode 100644 index bc3fde2579..0000000000 --- a/frontend/src/components/Board/BuyParts/DirectStores.jsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react' - -import { array, number } from 'prop-types' -import DigikeyData from '1-click-bom-minimal/lib/data/digikey.json' -import FarnellData from '1-click-bom-minimal/lib/data/farnell.json' -import countriesData from '1-click-bom-minimal/lib/data/countries.json' - -const DirectStores = ({ items, multiplier }) => { - const [countryCode, setCountryCode] = useState('Other') - const [digikeyParts, setDigikeyParts] = useState([]) - const [farnellParts, setFarnellParts] = useState([]) - const [newarkParts, setNewarkParts] = useState([]) - - const getParts = useCallback( - retailer => - items - .filter( - part => retailer in part.retailers && part.retailers[retailer] !== '', - ) - .map(part => ({ - sku: part.retailers[retailer], - reference: part.reference, - quantity: Math.ceil(multiplier * part.quantity), - })), - [items, multiplier], - ) - - const getLocation = async () => { - const usedCountryCodes = Object.keys(countriesData).map( - key => countriesData[key], - ) - const freegeoipEndpoint = 'https://freegeoip.kitspace.org' - - try { - const res = await fetch(freegeoipEndpoint) - const body = await res.json() - const { country_code: code } = body - if (code === 'GB') { - return 'UK' - } - if (usedCountryCodes.indexOf(code) < 0) { - return 'Other' - } - return code - } catch (err) { - console.error(err) - return 'Other' - } - } - - useEffect(() => { - if (typeof window !== 'undefined') { - getLocation().then(code => setCountryCode(code)) - } - - setDigikeyParts(getParts('Digikey')) - setFarnellParts(getParts('Farnell')) - setNewarkParts(getParts('Newark')) - }, [getParts]) - - const tildeDelimiter = part => `${part.sku}~${part.quantity}` - - const digikeyPartRenderer = (part, index) => { - index += 1 - return ( - - - - - - ) - } - - const digikey = (code, parts) => { - const site = DigikeyData.sites[DigikeyData.lookup[code]] - return ( -
- {parts?.map(digikeyPartRenderer)} - - ) - } - - const farnell = (code, parts) => { - const site = FarnellData.sites[FarnellData.lookup[code]] - const queryString = parts.map(tildeDelimiter).join('~') - return ( -
- - - - - ) - } - - const newark = parts => { - const queryString = parts.map(tildeDelimiter).join('~') - return ( -
- - - - - ) - } - - return ( - - {[ - digikey(countryCode, digikeyParts), - farnell(countryCode, farnellParts), - newark(newarkParts), - ]} - - ) -} - -DirectStores.propTypes = { - items: array.isRequired, - multiplier: number.isRequired, -} -export default DirectStores diff --git a/frontend/src/components/Board/BuyParts/DirectStores.tsx b/frontend/src/components/Board/BuyParts/DirectStores.tsx new file mode 100644 index 0000000000..e885c20a53 --- /dev/null +++ b/frontend/src/components/Board/BuyParts/DirectStores.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState, useCallback } from 'react' + +import DigikeyData from '1-click-bom-minimal/lib/data/digikey.json' +import countriesData from '1-click-bom-minimal/lib/data/countries.json' + +const DirectStores = ({ items, multiplier }: DirectStoresProps) => { + const [countryCode, setCountryCode] = useState('Other') + const [digikeyParts, setDigikeyParts] = useState([]) + + const getParts = useCallback( + retailer => + items + .filter( + part => retailer in part.retailers && part.retailers[retailer] !== '', + ) + .map(part => ({ + sku: part.retailers[retailer], + reference: part.reference, + quantity: Math.ceil(multiplier * part.quantity), + })), + [items, multiplier], + ) + + useEffect(() => { + const abortController = new AbortController() + const signal = abortController.signal + if (typeof window !== 'undefined') { + getLocation(signal).then(code => { + if (code && !signal.aborted) { + setCountryCode(code) + } + }) + } + setDigikeyParts(getParts('Digikey')) + return () => { + abortController.abort() + } + }, [getParts]) + + const digikeyPartRenderer = (part, index) => { + index += 1 + return ( + + + + + + ) + } + + const digikey = (code, parts) => { + const site = DigikeyData.sites[DigikeyData.lookup[code]] + return ( +
+ {parts?.map(digikeyPartRenderer)} + + ) + } + + return {[digikey(countryCode, digikeyParts)]} +} + +const getLocation = async (signal: AbortSignal) => { + const usedCountryCodes = Object.keys(countriesData).map(key => countriesData[key]) + const freegeoipEndpoint = 'https://freegeoip.kitspace.org' + + try { + const res = await fetch(freegeoipEndpoint, { signal }) + const body = await res.json() + const { country_code: code } = body + if (code === 'GB') { + return 'UK' + } + if (usedCountryCodes.indexOf(code) < 0) { + return 'Other' + } + return code + } catch (err) { + if (!signal.aborted) { + console.error(err) + } + return 'Other' + } +} + +interface DirectStoresProps { + items: Array + multiplier: number +} +export default DirectStores diff --git a/frontend/src/components/Board/BuyParts/InstallPrompt.jsx b/frontend/src/components/Board/BuyParts/InstallPrompt.jsx deleted file mode 100644 index 13546be102..0000000000 --- a/frontend/src/components/Board/BuyParts/InstallPrompt.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useEffect, useState } from 'react' -import Link from 'next/link' - -import { Icon, Message } from 'semantic-ui-react' -import { func, string } from 'prop-types' - -import BrowserVersion from '@utils/getBrowserVersion' -import styles from './InstallPrompt.module.scss' - -export const install1ClickBOM = () => { - const version = BrowserVersion() - let onClick - if (/Chrome/.test(version)) { - onClick = () => { - window.plausible('Install Extension') - window.open( - 'https://chrome.google.com/webstore/detail/kitspace-1-click-bom/mflpmlediakefinapghmabapjeippfdi', - ) - } - } else if (/Firefox/.test(version)) { - onClick = () => { - window.plausible('Install Extension') - window.open('https://addons.mozilla.org/en-US/firefox/addon/1clickbom') - } - } else { - onClick = () => { - window.plausible('Install Extension') - window.open('/1-click-bom', '_self') - } - } - return onClick() -} - -const InstallPrompt = ({ extensionPresence }) => { - const [isCompatible, setIsCompatible] = useState(true) - const [timedOut, setTimedOut] = useState(false) - - const getCompatibility = () => { - if (typeof navigator === 'undefined') { - return true - } - if (/Mobile/i.test(navigator.userAgent)) { - return false - } - const version = BrowserVersion() - return /Chrome/.test(version) || /Firefox/.test(version) - } - - useEffect(() => { - setTimedOut(() => { - setTimedOut(true) - setIsCompatible(getCompatibility()) - }, 5000) - }, []) - - if (extensionPresence === 'present') { - return
- } - if (timedOut) { - return isCompatible ? ( - - ) : ( - - ) - } - return null -} - -const PleaseInstall = ({ install1ClickBOMCallback }) => ( - - - Please{' '} - {' '} - make full use of this feature. - -) - -const NotCompatible = () => ( - - - Sorry, the{' '} - - 1-click BOM extension - {' '} - is not yet available for your browser. Only the Digikey add-to-cart links work - fully, Farnell and Newark should work but the references will not be added as - line-notes. - -) - -InstallPrompt.propTypes = { - extensionPresence: string.isRequired, -} - -PleaseInstall.propTypes = { - install1ClickBOMCallback: func.isRequired, -} - -export default InstallPrompt diff --git a/frontend/src/components/Board/BuyParts/InstallPrompt.module.scss b/frontend/src/components/Board/BuyParts/InstallPrompt.module.scss deleted file mode 100644 index 7345ea0159..0000000000 --- a/frontend/src/components/Board/BuyParts/InstallPrompt.module.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import '../../colors.scss'; - -.extensionLinkButton { - background: none!important; - border: none; - padding: 0!important; - color:$mine-shaft; - font-weight: bold; - cursor: pointer; -} - -.extensionLinkButton:hover { - text-decoration: underline; -} \ No newline at end of file diff --git a/frontend/src/components/Board/BuyParts/index.jsx b/frontend/src/components/Board/BuyParts/index.jsx deleted file mode 100644 index 1ddcbab8cf..0000000000 --- a/frontend/src/components/Board/BuyParts/index.jsx +++ /dev/null @@ -1,317 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { array, bool, func, number, string } from 'prop-types' -import OneClickBom from '1-click-bom-minimal' -import { Header, Icon, Segment, Input, Button } from 'semantic-ui-react' - -import Bom from './Bom' -import InstallPrompt, { install1ClickBOM } from './InstallPrompt' -import DirectStores from './DirectStores' -import styles from './index.module.scss' - -const BuyParts = ({ projectFullName, lines, parts }) => { - const [extensionPresence, setExtensionPresence] = useState('unknown') - const [buyMultiplier, setBuyMultiplier] = useState(1) - const [mult, setMult] = useState(1) - const [buyAddPercent, setBuyAddPercent] = useState(0) - const [adding, setAdding] = useState({}) - - const buyParts = distributor => { - window.plausible('Buy Parts', { - props: { - project: projectFullName, - vendor: distributor, - multiplier: mult, - }, - }) - window.postMessage( - { - from: 'page', - message: 'quickAddToCart', - value: { - retailer: distributor, - multiplier: mult, - }, - }, - '*', - ) - } - - const retailerList = OneClickBom.getRetailers() - const retailerButtons = retailerList - .map(name => { - const [numberOfLines, numberOfParts] = lines.reduce( - ([numOfLines, numOfParts], line) => { - if (line.retailers[name]) { - return [numOfLines + 1, numOfParts + Math.ceil(mult * line.quantity)] - } - return [numOfLines, numOfParts] - }, - [0, 0], - ) - if (numberOfLines > 0) { - return ( - buyParts(name)} - extensionPresence={name === 'Digikey' ? 'absent' : extensionPresence} - install1ClickBOM={install1ClickBOM} - name={name} - numberOfLines={numberOfLines} - numberOfParts={numberOfParts} - totalLines={lines.length} - /> - ) - } - return null - }) - .filter(l => l != null) - - useEffect(() => { - // extension communication - window.addEventListener( - 'message', - event => { - if (event.source !== window) { - return - } - if (event.data.from === 'extension') { - setExtensionPresence('present') - switch (event.data.message) { - case 'updateAddingState': - setAdding(event.data.value) - break - default: - break - } - } - }, - false, - ) - }, []) - - useEffect(() => { - const multi = buyMultiplier - if (Number.isNaN(multi) || multi < 1) { - setMult(1) - } - const percent = buyAddPercent - if (Number.isNaN(percent) || percent < 1) { - setMult(0) - } - setMult(multi + multi * (percent / 100)) - }, [buyMultiplier, buyAddPercent]) - - const linesToTsv = () => { - const linesMult = lines.map(line => ({ - ...line, - quantity: Math.ceil(line.quantity * mult), - })) - return OneClickBom.writeTSV(linesMult) - } - const hasPurchasableParts = retailerButtons.length !== 0 - return ( -
-
- - Buy Parts -
- {hasPurchasableParts ? ( - <> - - setBuyAddPercent(v)} - setBuyMultiplier={v => setBuyMultiplier(v)} - /> - - {retailerButtons} - - - - ) : ( - - )} - -
- ) -} - -const NoPurchasableParts = () => ( - -

- No parts to buy have been specified in this project's BOM yet. -

-
-) - -const AdjustQuantity = ({ - buyMultiplier, - setBuyMultiplier, - buyAddPercent, - setBuyAddPercent, -}) => ( - - Adjust quantity: - - { - const v = buyMultiplier - if (Number.isNaN(v) || v < 1) { - setBuyMultiplier(1) - } - }} - onChange={e => { - const v = parseFloat(e.target.value) - setBuyMultiplier(v) - }} - /> - - { - const v = buyAddPercent - if (Number.isNaN(v) || v < 0) { - setBuyAddPercent(0) - } - }} - onChange={e => { - const v = parseFloat(e.target.value) - setBuyAddPercent(v) - }} - /> - - % - - -) - -const RetailerButton = ({ - name, - buyParts, - install1ClickBOM, - extensionPresence, - numberOfLines, - totalLines, - numberOfParts, - adding, -}) => { - let onClick = buyParts - // if the extension is not here fallback to direct submissions - if (extensionPresence !== 'present' && typeof document !== 'undefined') { - onClick = () => { - const form = document.getElementById(`${name}Form`) - if (form) { - form.submit() - } else { - install1ClickBOM() - } - } - } - const color = numberOfLines === totalLines ? 'green' : 'pink' - return ( -
- } - label={{ - as: 'a', - className: `${styles.retailerLabel} ${styles[color]} `, - content: ( -
-
- {numberOfLines}/{totalLines} lines ({numberOfParts} parts) -
-
- - -
-
- ), - }} - labelPosition="right" - loading={adding} - onClick={onClick} - /> - ) -} - -const StoreIcon = ({ retailer }) => { - const imgHref = `/distributor_icons/${retailer.toLowerCase()}.png` - return ( - /* - * Styling this as a Next Image is unnecessarily complicated. - * Also, the next optimizations for this image aren't that useful. - * see https://github.com/vercel/next.js/discussions/22861. - */ - // eslint-disable-next-line @next/next/no-img-element - {retailer} - ) -} - -BuyParts.propTypes = { - projectFullName: string.isRequired, - lines: array.isRequired, - parts: array.isRequired, -} - -AdjustQuantity.propTypes = { - buyMultiplier: number.isRequired, - setBuyMultiplier: func.isRequired, - buyAddPercent: number.isRequired, - setBuyAddPercent: func.isRequired, -} - -RetailerButton.propTypes = { - name: string.isRequired, - buyParts: func.isRequired, - install1ClickBOM: func.isRequired, - extensionPresence: string.isRequired, - numberOfLines: number.isRequired, - totalLines: number.isRequired, - numberOfParts: number.isRequired, - adding: bool, -} -RetailerButton.defaultProps = { - adding: false, -} - -StoreIcon.propTypes = { - retailer: string.isRequired, -} - -export default BuyParts diff --git a/frontend/src/components/Board/BuyParts/index.module.scss b/frontend/src/components/Board/BuyParts/index.module.scss index d62ee2a050..e408472406 100644 --- a/frontend/src/components/Board/BuyParts/index.module.scss +++ b/frontend/src/components/Board/BuyParts/index.module.scss @@ -1,8 +1,10 @@ @import '../../breakpoint.scss'; .notSelectable { + -webkit-user-select: none; user-select: none; cursor: default; + margin-left: 5px; } .BuyParts { @@ -16,6 +18,7 @@ display: flex; justify-content: space-evenly; flex-wrap: wrap; + gap: 2rem; } .buttonText { @@ -51,6 +54,12 @@ min-width: 110px; } + .retailerLabelContent { + width: 100%; + display: flex; + justify-content: space-between; + } + .retailerButton.pink { background: #da8282 !important; } diff --git a/frontend/src/components/Board/BuyParts/index.tsx b/frontend/src/components/Board/BuyParts/index.tsx new file mode 100644 index 0000000000..f817af9cb3 --- /dev/null +++ b/frontend/src/components/Board/BuyParts/index.tsx @@ -0,0 +1,451 @@ +import React, { useEffect, useState } from 'react' +import OneClickBom from '1-click-bom-minimal' +import { Header, Icon, Segment, Input, Button } from 'semantic-ui-react' + +import Bom from './Bom' +import DirectStores from './DirectStores' +import styles from './index.module.scss' + +const BuyParts = ({ projectFullName, lines, parts }: BuyPartsProps) => { + const [buyMultiplier, setBuyMultiplier] = useState(1) + const [multiplier, setMultiplier] = useState(1) + const [buyAddPercent, setBuyAddPercent] = useState(0) + + const downloadBomOrRedirectToRetailer = (retailer: Retailer) => { + window.plausible('Buy Parts', { + props: { + project: projectFullName, + vendor: retailer, + multiplier: multiplier, + }, + }) + + if (retailer === 'Digikey') { + redirectToStore(retailer) + return + } + + downloadBomCSV({ + name: projectFullName.replace('/', '-'), + lines, + multiplier, + buyAddPercent, + retailer, + }) + } + + const retailerList: Array = OneClickBom.getRetailers() + const retailerButtons = retailerList + .map((retailerName: string): React.ReactElement | null => { + const [numberOfLines, numberOfParts] = lines.reduce( + ([numOfLines, numOfParts], line) => { + if (line.retailers[retailerName]) { + return [ + numOfLines + 1, + numOfParts + Math.ceil(multiplier * line.quantity), + ] + } + return [numOfLines, numOfParts] + }, + [0, 0], + ) + + if (numberOfLines > 0) { + return ( + + downloadBomOrRedirectToRetailer(retailerName as Retailer) + } + name={retailerName} + numberOfLines={numberOfLines} + numberOfParts={numberOfParts} + totalLines={lines.length} + /> + ) + } + return null + }) + .filter(l => l != null) + + useEffect(() => { + const multi = buyMultiplier + if (Number.isNaN(multi) || multi < 1) { + setMultiplier(1) + } + const percent = buyAddPercent + if (Number.isNaN(percent) || percent < 1) { + setMultiplier(0) + } + setMultiplier(multi + multi * (percent / 100)) + }, [buyMultiplier, buyAddPercent]) + + const linesToTsv = () => { + const linesMult = lines.map(line => ({ + ...line, + quantity: Math.ceil(line.quantity * multiplier), + })) + return OneClickBom.writeTSV(linesMult) + } + const hasPurchasableParts = retailerButtons.length !== 0 + + return ( +
+
+ + Buy Parts +
+ {hasPurchasableParts ? ( + <> + setBuyAddPercent(v)} + setBuyMultiplier={v => setBuyMultiplier(v)} + /> + + {retailerButtons} + + + + ) : ( + + )} + +
+ ) +} + +const NoPurchasableParts = () => ( + +

+ No parts to buy have been specified in this project's BOM yet. +

+
+) + +const AdjustQuantity = ({ + buyMultiplier, + setBuyMultiplier, + buyAddPercent, + setBuyAddPercent, +}: AdjustQuantityProps) => ( + + Adjust quantity: + + { + const v = buyMultiplier + if (Number.isNaN(v) || v < 1) { + setBuyMultiplier(1) + } + }} + onChange={e => { + const v = parseFloat(e.target.value) + setBuyMultiplier(v) + }} + /> + + { + const v = buyAddPercent + if (Number.isNaN(v) || v < 0) { + setBuyAddPercent(0) + } + }} + onChange={e => { + const v = parseFloat(e.target.value) + setBuyAddPercent(v) + }} + /> + % + +) + +const RetailerButton = ({ + name, + downloadBomOrRedirectToRetailer, + numberOfLines, + totalLines, + numberOfParts, +}: RetailerButtonProps) => { + const color = 'green' + return ( +