#root
View Compiled
$breakpointDesktop: 700px;
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap');
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
ul {
list-style: none;
}
a {
text-decoration: none;
color: rgba(black, 0.92);
}
select {
padding: 5px 8px;
border-radius: 50em;
border: 1px solid rgba(black, 0.12);
&:focus {
outline: 0;
box-shadow: 0px 2px 4px rgba(black, 0.1);
}
}
select {
background: url("data:image/svg+xml,<svg height='10px' width='10px' viewBox='0 0 16 16' fill='%23000000' xmlns='http://www.w3.org/2000/svg'><path d='M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/></svg>") no-repeat;
background-position: calc(100% - 0.75rem) center;
appearance: none;
}
body {
font-family: Noto Sans JP, sans-serif;
}
label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
display: block;
margin-bottom: 0.5rem;
}
.appHeaderWrapper {
position: sticky;
top: 0;
padding: 0 16px 1rem 16px;
margin-bottom: 1rem;
background: white;
box-shadow: 0px 2px 3px rgba(black, 0.12);
width: 100%;
overflow: auto;
}
.appTitle {
white-space: pre;
margin: 0.5rem 0;
}
.appHeader {
display: flex;
align-items: center;
width: fit-content;
}
.inputWrapper {
display: flex;
flex-direction: column;
margin-right: 1rem;
&:last-of-type {
margin-right: 0;
}
}
.gameItem {
border-bottom: 1px solid rgba(black, 0.1);
padding: 0 12px;
}
.gameLink {
/* display: flex; */
}
.gameInfo {
display: flex;
align-items: center;
padding: 12px 0;
}
.gameStats {
padding: 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.gameRanking {
padding: 0 0 12px 0;
}
.desktopGameScore {
display: none;
flex-direction: column;
align-items: flex-end;
@media (min-width: $breakpointDesktop) {
display: flex;
}
}
.mobileGameScore {
padding-top: 0.5rem;
padding-bottom: 2rem;
@media (min-width: $breakpointDesktop) {
display: none;
}
}
.gamePrices {
@media (min-width: $breakpointDesktop) {
display: flex;
}
li {
@media (min-width: $breakpointDesktop) {
padding-right: 1rem;
margin-right: 1rem;
border-right: 1px solid rgba(black, 0.15);
}
&:first-of-type {
@media (min-width: $breakpointDesktop) {
padding-left: 1rem;
border-left: 1px solid rgba(black, 0.15);
}
}
&:last-of-type {
margin-right: 0;
}
}
}
View Compiled
console.clear()
// -------- Constants --------
const CHEAP_SHARK_API_URL = 'https://www.cheapshark.com/api/1.0/deals'
const METACRITIC_BASE_URL = 'https://www.metacritic.com'
const POSSIBLE_SORT_VALUES = [
'Deal Rating',
'Title',
'Savings',
'Price',
'Metacritic',
'Reviews',
'Release',
'recent'
]
const DEFAULT_SORT_VALUE = 'recent'
const REQUEST_THROTTLE_TIMEOUT = 100
// -------- List view to render games --------
const ListView = ({ items }) => {
return (
<ul>
{items.map(({
dealID,
thumb,
title,
normalPrice,
salePrice,
metacriticLink,
metacriticScore,
}) => {
const normalPriceFloat = parseFloat(normalPrice)
const salePriceFloat = parseFloat(salePrice)
const salesDropPercent = Math.round(100 - (salePriceFloat / normalPriceFloat) * 100)
const metacrticiURL = `${METACRITIC_BASE_URL}/${metacriticLink}`
return (
<li className="gameItem" key={dealID}>
<a
href={metacrticiURL}
target="_blank"
rel="noopener noreferrer"
class="gameLink"
>
<div className="gameInfo">
<div className="gamePoster">
<img
src={thumb}
alt={`Poster for "${title}"`}
/>
</div>
<div className="gameStats">
<div className="gameTitle">
<h3>{title}</h3>
</div>
<div className="desktopGameScore">
<h5>Metacritic score</h5>
{`${metacriticScore}%`}
</div>
</div>
</div>
<div className="gameRanking">
<ul className="gamePrices">
<li>
Original Price: <strong>{normalPrice} USD</strong>
</li>
<li>
Sales Price: <strong>{salePrice} USD</strong>
</li>
<li>
Saving: <strong>{`${salesDropPercent}%`}</strong>
</li>
</ul>
</div>
<div className="mobileGameScore">
<h5>Metacritic score</h5>
{`${metacriticScore}%`}
</div>
</a>
</li>
)
})}
</ul>
)
}
// -------- Main app --------
const App = () => {
const [items, setItems] = React.useState([])
const [lowerPrice, setLowerPrice] = React.useState(0)
const [upperPrice, setUpperPrice] = React.useState(20)
const [sortByString, setSortByString] = React.useState(DEFAULT_SORT_VALUE)
const [isActiveFetch, setIsActiveFetch] = React.useState(false)
const abortController = React.useRef()
React.useEffect(() => {
const queryParams = new URLSearchParams()
queryParams.append('storeID', 1)
queryParams.append('lowerPrice', lowerPrice)
queryParams.append('upperPrice', upperPrice)
queryParams.append('sortBy', sortByString)
const url = `${CHEAP_SHARK_API_URL}?${queryParams.toString()}`
if (abortController.current) {
abortController.current.abort()
}
abortController.current = new AbortController()
const { signal } = abortController.current
fetch(url, { signal })
.then(res => res.json())
.then(res => {
const items = Object.values(res)
setItems(items)
})
.catch((err) => {
if (err.name === 'AbortError') {
console.error('user canceled the fetch')
}
})
}, [abortController, sortByString, upperPrice, lowerPrice])
const onLowerPriceChange = React.useCallback(_.throttle((e) => {
const lowerPrice = parseInt(e.target.value)
setLowerPrice(lowerPrice)
console.log(lowerPrice, upperPrice)
if (lowerPrice >= upperPrice) {
setUpperPrice(lowerPrice + 1)
}
}, REQUEST_THROTTLE_TIMEOUT), [upperPrice])
const onUpperPriceChange = React.useCallback(_.throttle((e) => {
const upperPrice = parseInt(e.target.value)
setUpperPrice(upperPrice)
if (upperPrice <= lowerPrice) {
setLowerPrice(upperPrice - 1)
}
}, REQUEST_THROTTLE_TIMEOUT), [lowerPrice])
const onSortByChange = React.useCallback(_.throttle((e) => {
setSortByString(e.target.value)
}, REQUEST_THROTTLE_TIMEOUT), [])
return (
<div>
<header className="appHeaderWrapper">
<h1 className="appTitle">Steam game deals</h1>
<div className="appHeader">
<div className="inputWrapper">
<label for="lowerPrice">
Price Low:
${lowerPrice}
</label>
<input
type="range"
id="lowerPrice"
name="lowerPrice"
min="0"
step="1"
max="99"
value={lowerPrice}
onChange={onLowerPriceChange}
/>
</div>
<div className="inputWrapper">
<label for="upperPrice">
Price High:
${upperPrice}
</label>
<input
type="range"
id="upperPrice"
name="upperPrice"
min="1"
step="1"
max="100"
value={upperPrice}
onChange={onUpperPriceChange}
/>
</div>
<div className="inputWrapper">
<label for="sortBy">Sort By</label>
<select
id="sortBy"
name="sortBy"
value={sortByString}
onChange={onSortByChange}
>
{POSSIBLE_SORT_VALUES.map(sortByOption => (
<option value={sortByOption}>
{sortByOption}
</option>
))}
</select>
</div>
</div>
</header>
<ListView
items={items.map(({
dealID,
thumb,
title,
normalPrice,
salePrice,
metacriticLink,
metacriticScore,
}) => ({
dealID,
thumb,
title,
normalPrice,
salePrice,
metacriticLink,
metacriticScore,
}))}
/>
</div>
)
}
// -------- Render out React app to the DOM --------
ReactDOM.render(<App />, document.getElementById('root'))
View Compiled
This Pen doesn't use any external CSS resources.