#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:&nbsp;
              ${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:&nbsp;
              ${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

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js