🔖 목표 : 상품 정보와 상품 옵션을 보여주고 옵션을 선택할 수 있는 컴포넌트를 만들어본다.
API_END_POINT = https://misc.edu-api.programmers.co.kr/
상품 정보 조회 API : https://misc.edu-api.programmers.co.kr/products?id=1
url
: products?id=1[ {"id":1, "name":"프로그래머스 마우스 패드", "basePrice":10000,"published_at":"2023-02-14T09:57:00.586Z", "created_at":"2023-02-14T09:29:26.262Z", "updated_at":"2023-02-14T09:57:00.594Z"} ]
상품 옵션 조회 API : https://misc.edu-api.programmers.co.kr/product-options?product.id=1
url
: product-options?product.id=1[ { "id": 1, "optionName": "기본형", "optionPrice": 0, "created_at": "2023-02-14T09:30:17.949Z", "updated_at": "2023-02-14T09:31:08.029Z" }, { "id": 2, "optionName": "무선충전기 일체형", "optionPrice": 15000, "created_at": "2023-02-14T09:31:30.086Z", "updated_at": "2023-02-14T09:31:30.090Z" }, { "id": 3, "optionName": "완전방수처리형", "optionPrice": 3000, "created_at": "2023-02-14T09:32:29.417Z", "updated_at": "2023-02-14T09:32:29.422Z" } ]
상품 옵션 수량 조회 API : https://misc.edu-api.programmers.co.kr/product-option-stocks?productOption.id=1
url
: product-option-stocks?productOption.id=1[ {"id":1, "stock":24, "created_at":"2023-02-14T09:34:05.545Z", "updated_at":"2023-02-14T09:34:05.549Z"} ]
import ProductOptions from "./productOptions.js"
const dummy_data = [
{
id: 1,
optionName: "기본형",
optionPrice: 0,
stock : 10
},
{
id: 2,
optionName: "나방형",
optionPrice: 0,
stock : 10
},
{
id: 3,
optionName: "응용형",
optionPrice: 0,
stock : 0
},
]
const $App = document.querySelector("#App")
new ProductOptions({
$target : $App,
initialState : dummy_data,
onSelect : (option) => {alert(option.optionName)}
})
export default function ProductOptions({$target,initialState,onSelect}){
const $select = document.createElement('select')
$target.appendChild($select)
// 상품옵션 이름 렌더링 시 [상품명 + 옵션명 + 재고 : n개] 이런 형식으로 보여줘야 함
// 재고가 0인 상품의 경우 옵션을 선택하지 못하게 함
this.state = initialState
this.setState = (nextState) =>{
this.state = nextState
this.render()
}
const createOptionFullName = ({optionName, optionPrice, stock}) => {
return `${optionName}
${optionPrice > 0 ? `(옵션가 : ${optionPrice}` : ''} |
${stock > 0 ? `재고 : ${stock}` :'재고 없음'}
`
}
$select.addEventListener('change',(e)=>{
const optionId = parseInt(e.target.value)
const option = this.state.find(option => option.id === optionId)
onSelect(option)
})
// 기본적으로 render 함수는 파라미터가 없어야 한다. 순수하게 state만을 보고 렌더링되어야 한다.
this.render = () => {
if(this.state && Array.isArray(this.state)){
// this.state가 제대로 들어있는지, 배열인지 아닌지 판별
$select.innerHTML = `
<option value='' disabled selected>선택하세요</option>
${this.state.map((option)=>
// <option disabled>
`<option value="${option.id}" ${option.stock === 0 ? 'disabled' : ''}>
${createOptionFullName(option)}</option>"`).join('')}
`
}
}
this.render()
}
import ProductOptions from "./productOptions.js"
import {request} from "./api.js"
const $App = document.querySelector("#App")
const fetchOptionData = (id) => {
return request(`products?id=${id}`)
.then(products => {
return request(`product-options?product.id=${id}`)
})
.then(productsOptions => {
return Promise.all([
Promise.resolve(productsOptions),
Promise.all(
productsOptions.map(productsOptions => productsOptions.id).map(id => {
return request(`product-option-stocks?productOption.id=${id}`)
})
)
])
})
.then(data => {
const [options, stocks] = data // Stocks [][][]
const optionData = options.map((options,i) =>{
const stock = stocks[i][0].stock
return {
optionID : options.id,
optionName : options.optionName,
optionPrice : options.optionPrice,
stock : stock
}
})
productOptionsComponent.setState(optionData)
})
}
const defaultId = 1
fetchOptionData(defaultId)
const productOptionsComponent = new ProductOptions({
$target : $App,
initialState : [],
onSelect : (option) => {alert(option.optionName)}
})
⇩ fetchOptionData
3번째 then의 data
를 콘솔에 출력한 사진
/* 서버를 여러개쓰면 서버 앞단의 공통된 도메인을 따로 뽑아 상수로 관리하는 것이 좋다. */
const API_END_POINT = "https://misc.edu-api.programmers.co.kr"
export const request = (url) => {
return fetch(`${API_END_POINT}/${url}`)
.then(res => {
if(res.ok){
return res.json()
}
throw new Error(`${res.state} Error`)
})
.catch(e => alert(e.message))
}
export default function ProductOptions({$target,initialState,onSelect}){
const $select = document.createElement('select')
$target.appendChild($select)
// 상품옵션 이름 렌더링 시 [상품명 + 옵션명 + 재고 : n개] 이런 형식으로 보여줘야 함
// 재고가 0인 상품의 경우 옵션을 선택하지 못하게 함
this.state = initialState
this.setState = (nextState) =>{
this.state = nextState
this.render()
}
const createOptionFullName = ({optionName, optionPrice, stock}) => {
return `${optionName}
${optionPrice > 0 ? `(옵션가 : ${optionPrice}` : '(옵션가 : 0'} |
${stock > 0 ? `재고 : ${stock})` :'(재고 없음)'}
`
}
$select.addEventListener('change',(e)=>{
const optionId = parseInt(e.target.value)
console.log(e.target)
const option = this.state.find(option => option.optionID === optionId)
onSelect(option)
})
// 기본적으로 render 함수는 파라미터가 없어야 한다. 순수하게 state만을 보고 렌더링되어야 한다.
this.render = () => {
if(this.state && Array.isArray(this.state)){
// this.state가 제대로 들어있는지, 배열인지 아닌지 판별
$select.innerHTML = `
<option value='' disabled selected>선택하세요</option>
${this.state.map((option)=>
// <option disabled>
`<option value="${option.optionID}"
${option.stock === 0 ? 'disabled' : ''}>
${createOptionFullName(option)}
</option>"`).join('')}
`
}
}
this.render()
}
/* state 구조
{
productID: 1, // 외부에서 받는 값
// api로 불러오는 data
product: product,
optionData: [] // 2단계에서 만든 data
}
*/
import ProductOptions from "./productOptions.js"
import {request} from "./api.js"
export default function productPage({
$target,
initialState
}){
const $product = document.createElement('div')
$target.appendChild($product)
this.state = initialState // initialState => { productID: 1 }
// productOptionsComponent 컴포넌트 생성
const productOptionsComponent = new ProductOptions({
$target : $product,
initialState : [],
onSelect : (option) => {alert(option.optionName)}
})
this.setState = (nextState) =>{
/* nextState.productId와 현재 this.state.productId가 맞지 않는 경우는
fetchOptionData에 nextState.productId를 넘겨준다. */
if(nextState.productID !== this.state.productID){
fetchOptionData(nextState.productId)
return
}
this.state = nextState
productOptionsComponent.setState(this.state.optionData)
// optionData가 fetch로 새로 들어오면 productOptionsComponent 상태도 같이 업데이트
this.render()
}
this.render = () => {
}
this.render()
// fetch 작업
const fetchOptionData = (id) => {
return request(`products?id=${id}`)
.then(products => {
this.setState({
...this.state,
products,
optionData : []
})
// this.state : { productID: 1 } => { productID: 1, products, optionData : [] }
// 여기서 products 값이 채워짐
return request(`product-options?product.id=${id}`)
})
.then(productsOptions => {
return Promise.all([
Promise.resolve(productsOptions),
Promise.all(
productsOptions.map(productsOptions => productsOptions.id).map(id => {
return request(`product-option-stocks?productOption.id=${id}`)
})
)
])
})
.then(data => {
const [options, stocks] = data // Stocks [][][]
const optionData = options.map((options,i) =>{
const stock = stocks[i][0].stock
return {
optionID : options.id,
optionName : options.optionName,
optionPrice : options.optionPrice,
stock : stock
}
})
this.setState({
...this.state,
optionData
// this.state의 optionData에 값을 넣어주는 작업
// 초기 id만 있던 상태에서 products, optinData를 모두 가진 this.state 완성!
})
})
}
fetchOptionData(this.state.productID) // 외부에서 받아온 id로 fetch 작업 실시
}
import productPage from "./productPage.js"
const $App = document.querySelector("#App")
// productID만 외부에서 받아옴
new productPage({
$target:$App,
initialState:{
productID: 1,
}
})
/* state 구조
{
productName: 상품 이름,
basePrice: 기본 가격,
selectedOption: [option]
}
*/
export default function Cart({$target, initialState, onRemove}){
const $cart = document.createElement('div')
$target.appendChild($cart)
this.state = initialState
this.setState = (nextState) => {
this.state = nextState
this.render()
}
const calculateTotalPrice = () => {
const {basePrice, selectedOption} = this.state
return selectedOption.reduce((acc, option) =>
acc + (basePrice + option.optionPrice) * option.ea, 0)
}
this.render = () => {
const {productName, basePrice, selectedOption} = this.state
$cart.innerHTML = `
<ul>
${Array.isArray(this.state.selectedOption) &&
this.state.selectedOption.map((option) =>
`<li>${productName} - ${option.optionName} |
${basePrice + option.optionPrice}, ${option.ea}개</li>`).join('') }
</ul>
<div>${calculateTotalPrice()}</div>`
}
this.render()
}
import Cart from "./cart.js"
export default function productPage({
$target,
initialState
}){
const $product = document.createElement('div')
$target.appendChild($product)
this.state = initialState // initialState => { productID: 1 }
// Cart 생성
// 우선 더미 데이터로 렌더링이 잘 되는지 확인
const cart = new Cart({
$target: $product,
initialState:{
productName: '상품 이름',
basePrice: 10000,
selectedOption: [
{
optionName: '옵션1',
optionPrice: 1000,
ea : 1
},
{
optionName: '옵션2',
optionPrice: 2000,
ea : 1
},
]
},
onRemove : () => {}
})
// 이하 생략
import productPage from "./productPage.js"
const $App = document.querySelector("#App")
new productPage({
$target:$App,
initialState:{
productID: 1
}
})
/* 서버를 여러개쓰면 서버 앞단의 공통된 도메인을 따로 뽑아 상수로 관리하는 것이 좋다. */
const API_END_POINT = "https://misc.edu-api.programmers.co.kr"
export const request = (url) => {
return fetch(`${API_END_POINT}/${url}`)
.then(res => {
if(res.ok){
return res.json()
}
throw new Error(`${res.state} Error`)
})
.catch(e => alert(e.message))
}
/* state 구조
{
productID: 1, // 외부에서 받는 값
// api로 불러오는 data
product: product,
optionData: [] // 2단계에서 만든 data
selectedOptions :
}
*/
import ProductOptions from "./productOptions.js"
import {request} from "./api.js"
import Cart from "./cart.js"
export default function productPage({
$target,
initialState
}){
const $product = document.createElement('div')
$target.appendChild($product)
this.state = initialState // initialState => { productID: 1 }
// productOptionsComponent 컴포넌트 생성
const productOptionsComponent = new ProductOptions({
$target : $product,
initialState : [],
onSelect : (option) => {
const nextState = {...this.state}
const {selectedOptions} = nextState
const selectedOptionIndex = selectedOptions.findIndex((selectedOption) =>
selectedOption.optionID === option.optionID)
if (selectedOptionIndex > -1){
nextState.selectedOptions[selectedOptionIndex].ea++
}else{
nextState.selectedOptions.push({
optionID: option.optionID,
optionName: option.optionName,
optionPrice: option.optionPrice,
ea : 1
})
}
this.setState(nextState)
}
})
// Cart 생성
const cart = new Cart({
$target: $product,
initialState:{
productName: '',
basePrice: 0,
selectedOptions: [ ]
},
onRemove : (selectedOptionIndex) => {
const nextState = {...this.state}
nextState.selectedOptions.splice(selectedOptionIndex,1)
this.setState(nextState)
}
})
this.setState = (nextState) =>{
/* nextState.productId와 현재 this.state.productId가 맞지 않는 경우는
fetchOptionData에 nextState.productId를 넘겨준다. */
if(nextState.productID !== this.state.productID){
fetchOptionData(nextState.productId)
return
}
this.state = nextState
const {optionData,products,selectedOptions} = this.state
productOptionsComponent.setState(optionData)
// optionData가 fetch로 새로 들어오면 productOptionsComponent 상태도 같이 업데이트
/* productOptionsComponent에서 onSelect가 실행되면
this.state(nextState)가 실행되면서 cart 상태를 업데이트 */
cart.setState({
productName: products[0].name,
basePrice: products[0].basePrice,
selectedOptions: selectedOptions
})
this.render()
}
this.render = () => {
}
this.render()
// fetch 작업
const fetchOptionData = (id) => {
return request(`products?id=${id}`)
.then(products => {
this.setState({
...this.state,
products,
optionData : [],
selectedOptions : []
})
// this.state : { productID: 1 } => { productID: 1, products, optionData : [] }
// 여기서 products 값이 채워짐
return request(`product-options?product.id=${id}`)
})
.then(productsOptions => {
return Promise.all([
Promise.resolve(productsOptions),
Promise.all(
productsOptions.map(productsOptions => productsOptions.id).map(id => {
return request(`product-option-stocks?productOption.id=${id}`)
})
)
])
})
.then(data => {
const [options, stocks] = data // Stocks [][][]
const optionData = options.map((options,i) =>{
const stock = stocks[i][0].stock
return {
optionID : options.id,
optionName : options.optionName,
optionPrice : options.optionPrice,
stock : stock
}
})
this.setState({
...this.state,
optionData
// this.state의 optionData에 값을 넣어주는 작업
// 초기 id만 있던 상태에서 products, optinData를 모두 가진 this.state 완성!
})
})
}
fetchOptionData(this.state.productID) // 외부에서 받아온 id로 fetch 작업 실시
}
export default function ProductOptions({$target,initialState,onSelect}){
const $select = document.createElement('select')
$target.appendChild($select)
// 상품옵션 이름 렌더링 시 [상품명 + 옵션명 + 재고 : n개] 이런 형식으로 보여줘야 함
// 재고가 0인 상품의 경우 옵션을 선택하지 못하게 함
this.state = initialState
this.setState = (nextState) =>{
this.state = nextState
this.render()
}
const createOptionFullName = ({optionName, optionPrice, stock}) => {
return `${optionName}
${optionPrice > 0 ? `(옵션가 : ${optionPrice}` : '(옵션가 : 0'} |
${stock > 0 ? `재고 : ${stock})` :'(재고 없음)'}
`
}
$select.addEventListener('change',(e)=>{
const optionId = parseInt(e.target.value)
const option = this.state.find(option => option.optionID === optionId)
onSelect(option)
})
// 기본적으로 render 함수는 파라미터가 없어야 한다. 순수하게 state만을 보고 렌더링되어야 한다.
this.render = () => {
if(this.state && Array.isArray(this.state)){
// this.state가 제대로 들어있는지, 배열인지 아닌지 판별
$select.innerHTML = `
<option value='' disabled selected>선택하세요</option>
${this.state.map((option)=>
// <option disabled>
`<option value="${option.optionID}" ${option.stock === 0 ? 'disabled' : ''}>
${createOptionFullName(option)}</option>"`).join('')}
`
}
}
this.render()
}
/* state 구조
{
productName: 상품 이름,
basePrice: 기본 가격,
selectedOption: [option]
}
*/
export default function Cart({$target, initialState, onRemove}){
const $cart = document.createElement('div')
$target.appendChild($cart)
this.state = initialState
this.setState = (nextState) => {
this.state = nextState
this.render()
}
const calculateTotalPrice = () => {
const {basePrice, selectedOptions} = this.state
return selectedOptions.reduce((acc, option) =>
acc + (basePrice + option.optionPrice) * option.ea, 0)
}
this.render = () => {
const {productName, basePrice, selectedOptions} = this.state
$cart.innerHTML = `
<ul>
${Array.isArray(selectedOptions) && selectedOptions.map((option,i) =>
`<li data-index="${i}" class='cartItem'>${productName} - ${option.optionName} |
${basePrice + option.optionPrice}, ${option.ea}개
<button class='remove'> x </button>
</li>`).join('') }
</ul>
<div>${calculateTotalPrice()}</div>`
$cart.querySelectorAll('.remove').forEach($button=>{
$button.addEventListener('click',(e)=>{
const $li = e.target.closest('li') // 버튼에서 가장 인접한 li 찾기
const { index } = $li.dataset
onRemove(parseInt(index))
})
})
}
this.render()
}