상품 상세

사진을 바꿔 보고, 색상과 수량을 골라 장바구니에 담습니다. 고를 수 있는 범위는 재고 수에 따라 달라집니다.

상품 상세

핵심 포인트

  1. 1

    지금 고른 것을 한눈에 알 수 있게 하기

    보고 있는 사진에는 테두리를 두르고, 고른 색상은 검은 버튼으로 표시합니다. 무엇을 골랐는지 바로 알 수 있습니다.

  2. 2

    누를 수 없는 이유가 있는 버튼은 누를 수 없게 하기

    수량은 1보다 적게, 재고보다 많게 할 수 없습니다. 재고가 없는 색상은 고를 수 없습니다. 누른 뒤에 오류를 보여 주는 것보다 처음부터 누를 수 없게 하는 편이 이해하기 쉽습니다.

  3. 3

    장바구니에 담으면 개수와 알림으로 알려 주기

    위쪽 장바구니 개수가 늘고, 오른쪽 아래에 "N개를 장바구니에 담았습니다"가 표시됩니다. 화면이 바뀌지 않는 작업은 결과를 보여 줍니다.

사용한 도구

누르면 제2부의 비교 페이지로

코드 고치기

코드를 고치면 오른쪽 미리보기가 바로 바뀝니다.

import { useState } from "react"
import { MinusIcon, PlusIcon, ShoppingCartIcon } from "lucide-react"
import { toast } from "sonner"
import { Button } from "./components/ui/button"
import { Toaster } from "./components/ui/sonner"

// 상품 데이터 (실제 서비스에서는 서버에서 가져옴)
const product = {
  name: "무선 이어폰",
  category: "가전",
  price: 110500,
  rating: 4.5,
  reviews: 128,
  description: "노이즈 캔슬링으로 음악에 집중할 수 있는 이어폰. 한 번 충전으로 최대 8시간 사용할 수 있습니다.",
  // 상품 사진 대신 쓰는 색 (4장)
  images: ["#0ea5e9", "#0284c7", "#7dd3fc", "#e0f2fe"],
  // 색상별 재고
  colors: [
    { name: "블랙", stock: 5 },
    { name: "화이트", stock: 2 },
    { name: "블루", stock: 0 },
  ],
}

export default function App() {
  // ① 화면이 기억할 값 (state)
  //const imageIndex = 0 // 크게 보여 주는 사진의 번호 (0~3)
  const [imageIndex, setImageIndex] = useState(0)
  const [color, setColor] = useState("블랙") // 선택한 색상
  const [quantity, setQuantity] = useState(1) // 수량
  const [cartCount, setCartCount] = useState(0) // 장바구니에 담긴 개수
  const [colors, setColors] = useState(product.colors) // 색상별 재고 (장바구니에 담으면 줄어듦)

  // 선택한 색상의 재고 수
  const stock = colors.find((c) => c.name === color)?.stock ?? 0

  // '장바구니 담기'를 눌렀을 때
  function addToCart() {
    // 장바구니 개수를 선택한 수량만큼 늘리기
    setCartCount(cartCount + quantity)
    setColors(colors.map((c) => (c.name === color ? { ...c, stock: c.stock - quantity } : c)))
    setQuantity(1)

    toast.success(`${product.name} (${color}) ${quantity}개를 장바구니에 담았습니다`)
  }

  return (
    <div className="mx-auto my-6 w-full max-w-3xl rounded-xl border bg-card text-card-foreground">
      {/* 상단 바: 쇼핑몰 이름과 장바구니 개수 */}
      <header className="flex items-center justify-between border-b px-6 py-3 text-sm">
        <span className="font-semibold">쇼핑몰</span>
        {/* 오른쪽: 장바구니 개수와 '장바구니 비우기' */}
        <div className="flex items-center gap-2">
          <span className="flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1">
            <ShoppingCartIcon className="size-4" />
            장바구니 <b>{cartCount}</b>
          </span>
          <Button type="button" size="sm" variant="ghost" disabled={cartCount === 0} onClick={() => { setCartCount(0); setColors(product.colors) }}>
            장바구니 비우기
          </Button>
        </div>

      </header>

      <div className="grid gap-8 p-6 md:grid-cols-2">
        {/* 사진: 큰 사진 1장과 작은 사진 4장 */}
        <div>
          <div className="aspect-square rounded-lg" style={{ backgroundColor: product.images[imageIndex] }} />
          <div className="mt-3 grid grid-cols-4 gap-2">
            {product.images.map((image, index) => (
              <button
                key={image}
                type="button"
                className={imageIndex === index ? "aspect-square rounded-md ring-2 ring-foreground ring-offset-2" : "aspect-square rounded-md"}
                style={{ backgroundColor: image }}
                onClick={() => setImageIndex(index)}

              />
            ))}
          </div>
        </div>

        {/* 상품 정보 */}
        <div>
          <p className="text-xs text-muted-foreground">{product.category}</p>
          <p className="mt-1 text-2xl font-semibold">{product.name}</p>
          <p className="mt-1 text-sm text-amber-500">
            ★ {product.rating} <span className="text-muted-foreground">(리뷰 {product.reviews}개)</span>
          </p>
          <p className="mt-4 text-2xl font-semibold">₩{product.price.toLocaleString()}</p>
          <p className="mt-4 text-sm leading-relaxed text-muted-foreground">{product.description}</p>

          {/* 색상 */}
          <p className="mt-6 text-sm font-medium">색상</p>
          <div className="mt-2 flex flex-wrap gap-2">
            {colors.map((option) => (
              <Button
                key={option.name}
                type="button"
                size="sm"
                variant={color === option.name ? "default" : "outline"}
                disabled={option.stock === 0}
                /* 누르면 이 색상을 선택 / 선택한 색상은 강조 / 재고가 없는 색상은 누를 수 없음 */
                onClick={() => {
                  setColor(option.name)
                  setQuantity(1)
                }}
              >
                {option.name}
              </Button>
            ))}
          </div>

          {/* 수량 */}
          <p className="mt-5 text-sm font-medium">수량</p>
          <div className="mt-2 flex items-center gap-3">
            <div className="inline-flex items-center rounded-md border">
              <Button type="button" size="icon" variant="ghost" aria-label="1개 줄이기"
                disabled={quantity <= 1} onClick={() => setQuantity(quantity - 1)}
              >
                <MinusIcon />
              </Button>
              <span className="w-10 text-center text-sm">{quantity}</span>
              <Button type="button" size="icon" variant="ghost" aria-label="1개 늘리기"
                disabled={quantity >= stock} onClick={() => setQuantity(quantity + 1)}
              >
                <PlusIcon />
              </Button>
            </div>
            <span className="text-xs text-muted-foreground">남은 재고 {stock}개</span>
          </div>

          <Button type="button" className="mt-6 w-full" disabled={stock === 0} onClick={addToCart}>
            장바구니 담기
          </Button>
        </div>
      </div>

      <Toaster position="bottom-right" />
    </div>
  )
}