This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
useCallback, | |
useEffect, | |
useMemo, | |
useReducer, | |
useRef, | |
useState | |
} from "react"; | |
import { createPortal } from "react-dom"; | |
import "./styles.css"; | |
function fetchItems() { | |
return Promise.resolve([ | |
{ id: 1, name: "Product 1", price: "£9.99" }, | |
{ id: 2, name: "Product 2", price: "£19.99" }, | |
{ id: 3, name: "Product 3", price: "£39.99" }, | |
{ id: 4, name: "Product 4", price: "£199.99" }, | |
{ id: 5, name: "Product 5", price: "£399.99" }, | |
{ id: 6, name: "Product 6", price: "£899.99" }, | |
{ id: 7, name: "Product 7", price: "£1099.99" } | |
]); | |
} | |
function shoppingCartReducer(state, action) { | |
switch (action.type) { | |
case "ADD_ITEM": { | |
return { | |
...state, | |
[action.payload]: { | |
count: state[action.payload]?.count | |
? state[action.payload].count + 1 | |
: 1 | |
} | |
}; | |
} | |
case "REMOVE_ITEM": { | |
return Object.fromEntries( | |
Object.entries(state) | |
.map(([itemId, metadata]) => { | |
if (action.payload !== itemId) { | |
return [itemId, metadata]; | |
} | |
return metadata.count - 1 === 0 | |
? [] | |
: [itemId, { ...metadata, count: metadata.count - 1 }]; | |
}) | |
.filter((item) => item.length !== 0) | |
); | |
} | |
default: { | |
return state; | |
} | |
} | |
} | |
function countShoppingCartItems(shoppingCart) { | |
return Object.values(shoppingCart).reduce( | |
(acc, metadata) => acc + metadata.count, | |
0 | |
); | |
} | |
const priceToFloat = (price) => parseFloat(price.replaceAll(/[^\d.]*/g, "")); | |
function ShoppingCartModal({ items = [], shoppingCart = {} }) { | |
const totalPrice = useMemo(() => { | |
const itemIdToPrice = new Map( | |
items.map((item) => [item.id, priceToFloat(item.price)]) | |
); | |
return Object.entries(shoppingCart).reduce( | |
(acc, [itemId, metadata]) => | |
acc + metadata.count * itemIdToPrice.get(parseInt(itemId, 10)), | |
0 | |
); | |
}, [items, shoppingCart]); | |
const totalCount = useMemo(() => countShoppingCartItems(shoppingCart), [ | |
shoppingCart | |
]); | |
return ( | |
<div className="shopping-cart-modal"> | |
<div> | |
{items.map((item) => ( | |
<> | |
{item.id in shoppingCart ? ( | |
<div key={item.id}> | |
{item.name} ({shoppingCart[item.id].count}x) £ | |
{( | |
shoppingCart[item.id].count * priceToFloat(item.price) | |
).toFixed(2)} | |
</div> | |
) : null} | |
</> | |
))} | |
</div> | |
<hr /> | |
<div>Total count: {totalCount}</div> | |
<div>Total price: £{totalPrice.toFixed(2)}</div> | |
</div> | |
); | |
} | |
const noop = () => undefined; | |
function Portal({ children, onClose = noop }) { | |
const ref = useRef(null); | |
const [container, setContainer] = useState(null); | |
useEffect(() => { | |
const ownerDocument = ref.current ? ref.current.ownerDocument : null; | |
const body = ownerDocument ? ownerDocument.body : null; | |
const div = ownerDocument ? ownerDocument.createElement("div") : null; | |
if (body && div) { | |
div.classList.add("portal"); | |
div.addEventListener("click", () => { | |
onClose(); | |
}); | |
body.appendChild(div); | |
setContainer(div); | |
} | |
return () => { | |
if (body && div) { | |
body.removeChild(div); | |
} | |
setContainer(null); | |
}; | |
}, [onClose]); | |
return ( | |
<> | |
<div ref={ref} /> | |
{container ? createPortal(children, container) : null} | |
</> | |
); | |
} | |
export default function App() { | |
const [items, setItems] = useState([]); | |
const [showPortal, setShowPortal] = useState(false); | |
const [shoppingCart, dispatch] = useReducer(shoppingCartReducer, {}); | |
const totalCount = useMemo(() => countShoppingCartItems(shoppingCart), [ | |
shoppingCart | |
]); | |
useEffect(() => { | |
let ignoreLoad = false; | |
async function loadItems() { | |
const newItems = await fetchItems(); | |
if (ignoreLoad) { | |
return; | |
} | |
setItems(newItems); | |
} | |
loadItems(); | |
return () => { | |
ignoreLoad = true; | |
}; | |
}, []); | |
const handleRemove = useCallback(function handleRemove(event) { | |
const itemId = event.currentTarget.dataset["itemId"]; | |
dispatch({ type: "REMOVE_ITEM", payload: itemId }); | |
}, []); | |
const handleAdd = useCallback(function handleAdd(event) { | |
const itemId = event.currentTarget.dataset["itemId"]; | |
dispatch({ type: "ADD_ITEM", payload: itemId }); | |
}, []); | |
return ( | |
<> | |
{showPortal ? ( | |
<Portal onClose={() => setShowPortal(false)}> | |
<ShoppingCartModal | |
items={items} | |
shoppingCart={shoppingCart} | |
onClose={() => setShowPortal(false)} | |
/> | |
</Portal> | |
) : null} | |
<div> | |
<div className="header"> | |
<button className="basket" onClick={() => setShowPortal((t) => !t)}> | |
<span role="img" aria-label="Basket"> | |
🧺{totalCount ? ` (${totalCount})` : ""} | |
</span> | |
</button> | |
</div> | |
<div className="rows"> | |
{items.map((item) => ( | |
<div key={item.id} className="row"> | |
<div>{item.name}</div> | |
<div className="actions"> | |
<div className="action__add"> | |
{item.id && shoppingCart[item.id] ? ( | |
<span>{shoppingCart[item.id].count}</span> | |
) : ( | |
"" | |
)} | |
<button | |
onClick={handleRemove} | |
data-item-id={item.id} | |
disabled={ | |
!(item.id in shoppingCart) || | |
(item.id && | |
shoppingCart[item.id] && | |
shoppingCart[item.id].count === 0) | |
} | |
> | |
- | |
</button> | |
<button onClick={handleAdd} data-item-id={item.id}> | |
+ | |
</button> | |
</div> | |
</div> | |
</div> | |
))} | |
</div> | |
</div> | |
</> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment