주문・결제

배송 정보 → 결제 → 확인 → 완료를 한 화면에서 단계별로 진행합니다. 앞 단계에서 입력한 내용은 마지막에 한꺼번에 보여 줍니다.

결제

핵심 포인트

  1. 1

    지금 몇 단계인지 숫자 하나로 기억하기

    1 = 배송 정보, 2 = 결제, 3 = 확인, 4 = 완료. 이 숫자로 보여 줄 화면을 바꿉니다. "다음"은 +1, "이전"은 −1입니다.

  2. 2

    다음으로 넘어가기 전에 지금 단계의 입력만 검증하기

    모든 단계의 검증 규칙은 schema 하나에 모아 두고, "다음"을 누르면 지금 단계의 입력란만 검증합니다(trigger). 오류가 있으면 넘어갈 수 없습니다.

  3. 3

    위쪽에 진행 상황 표시하기

    끝난 단계는 ✓, 지금 단계는 검은색, 아직인 단계는 회색입니다. 남은 단계 수를 알면 중간에 그만두는 일이 줄어듭니다.

사용한 도구

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

코드 고치기

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

import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "./components/ui/button"
import { Input } from "./components/ui/input"
import { Label } from "./components/ui/label"

// 입력 검사 규칙 (Zod): 모든 단계의 입력란을 하나의 schema에 모아서 작성
const schema = z.object({
  name: z.string().min(1, "받는 분 이름을 입력하세요"),
  zip: z.string().regex(/^\d{5}$/, "5자리 숫자로 입력하세요 (예: 04524)"), // 한국의 우편번호(국가기초구역번호)는 5자리 숫자
  address: z.string().min(1, "주소를 입력하세요"),
  cardNumber: z.string().regex(/^\d{4} ?\d{4} ?\d{4} ?\d{4}$/, "16자리 카드 번호를 입력하세요"),
  expiry: z.string().regex(/^\d{2} ?\/ ?\d{2}$/, "월 / 년 형식으로 입력하세요 (예: 12 / 28)"),
  cvc: z.string().regex(/^\d{3,4}$/, "3~4자리 숫자로 입력하세요"),
})

// 전각 숫자와 기호를 반각으로 바꾸기 (전각 모드로 입력해도 통과하도록)
function toHalfWidth(value) {
  return value
    .replace(/[\uff10-\uff19]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
    .replace(/[\uff0d\u30fc\u2010]/g, "-")
    .replace(/\uff0f/g, "/")
    .replace(/\u3000/g, " ")
}

// 카드 번호: 숫자만 남기고 4자리마다 공백 넣기 (4242424242424242 → 4242 4242 4242 4242)
function formatCardNumber(value) {
  const digits = toHalfWidth(value).replace(/\D/g, "").slice(0, 16)
  return digits.match(/.{1,4}/g)?.join(" ") ?? ""
}

// 유효기간: 숫자만 남기고 앞 2자리 뒤에 / 넣기 (1228 → 12 / 28)
function formatExpiry(value) {
  const digits = toHalfWidth(value).replace(/\D/g, "").slice(0, 4)
  return digits.length > 2 ? digits.slice(0, 2) + " / " + digits.slice(2) : digits
}

// 입력란에 '입력할 때마다 형식 맞추기' 기능 추가 (register 결과에 덧붙여 사용)
// IME로 조합 중일 때 값을 바꾸면 글자가 깨지므로, 조합이 끝난 뒤에 형식 맞추기
function withFormat(field, format) {
  function update(event) {
    event.target.value = format(event.target.value)
    field.onChange(event)
  }
  return {
    ...field,
    onChange: (event) => (event.nativeEvent.isComposing ? field.onChange(event) : update(event)),
    onCompositionEnd: update,
  }
}

// 단계별로 검사할 입력란
const stepFields = {
  1: ["name", "zip", "address"],
  2: ["cardNumber", "expiry", "cvc"],
}

const stepLabels = ["배송지", "결제", "확인"]

// 라벨, 입력란, 오류 메시지를 하나로 묶기
function Field({ id, label, error, children }) {
  return (
    <div className="flex flex-col gap-2">
      <Label htmlFor={id}>{label}</Label>
      {children}
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  )
}

// 상단 단계 표시 (① 배송지 — ② 결제 — ③ 확인)
function StepBar({ step }) {
  return (
    <div className="mb-6 flex items-center gap-2 text-xs">
      {stepLabels.map((label, index) => {
        const number = index + 1
        return (
          <div key={label} className="flex flex-1 items-center gap-2 last:flex-none">
            <span
              // 끝난 단계와 현재 단계(number <= step)는 진하게, 아직인 단계는 회색으로
              className={number <= step ? "bg-primary text-primary-foreground flex size-6 shrink-0 items-center justify-center rounded-full" : "flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"}
            >
              {/* 끝난 단계(number < step)는 ✓, 나머지는 번호 */}
              {number < step ? "✓" : number}
            </span>
            <span className="whitespace-nowrap">{label}</span>
            {number < stepLabels.length && <span className="h-px flex-1 bg-border" />}
          </div>
        )
      })}
    </div>
  )
}

export default function App() {
  // ① 현재 단계 (1 = 배송지, 2 = 결제, 3 = 확인, 4 = 완료)
  const [step, setStep] = useState(1)

  const { register, trigger, getValues, setValue, reset, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
    mode: "onChange",
  })

  // '다음': 현재 단계의 입력을 검사한 뒤 다음으로 이동
  async function goNext() {
    const ok = await trigger(stepFields[step]) // 현재 단계의 입력란만 검사 (오류가 있으면 빨간 메시지 표시)
    if (!ok) return
    setStep(step + 1)
  }

  // '이전'
  function goBack() {
    // 이전 단계로 돌아가기
    setStep(step - 1)
  }

  // '주문하기'
  function placeOrder() {
    // 완료(4) 단계로 이동
    setStep(4)
  }

  // '처음부터 다시 하기'
  function restart() {
    reset() // 입력 내용 모두 지우기
    // 1단계로 돌아가기
    setStep(1)
  }

  // 한국 주소 검색은 별도 서비스(우편번호 검색 팝업 등)가 필요하므로, 여기서는 주소를 자동으로 채우지 않음 (직접 입력)

  // 확인 단계에서 보여 줄 입력 내용
  const values = getValues()
  //console.log(step)

  return (
    <div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6 text-card-foreground">
      {/* step이 4일 때는 StepBar를 표시하지 않음 */}
      {step === 4 ? null : <StepBar step={step} />}

      {/* ① 배송지: step이 1일 때만 표시 */}
      {step === 1 && (
        <div className="flex flex-col gap-4">
          <p className="text-lg font-semibold">배송지</p>
          <Field id="name" label="받는 분" error={errors.name?.message}>
            <Input id="name" {...register("name")} />
          </Field>
          <Field id="zip" label="우편번호" error={errors.zip?.message}>
            <Input
              id="zip"
              placeholder="04524"
              inputMode="numeric"
              {...register("zip", { setValueAs: toHalfWidth })}
            />
          </Field>
          <Field id="address" label="주소" error={errors.address?.message}>
            <Input id="address" {...register("address")} />
          </Field>
          <div className="flex justify-end">
            <Button type="button" onClick={goNext}>다음</Button>
          </div>
        </div>
      )}

      {/* ② 결제: step이 2일 때만 표시 */}
      {step === 2 && (
        <div className="flex flex-col gap-4">
          <p className="text-lg font-semibold">결제</p>
          <Field id="cardNumber" label="카드 번호" error={errors.cardNumber?.message}>
            <Input
              id="cardNumber"
              placeholder="4242 4242 4242 4242"
              inputMode="numeric"
              autoComplete="cc-number"
              {...withFormat(register("cardNumber"), formatCardNumber)}
            />
          </Field>
          <div className="grid grid-cols-2 gap-3">
            <Field id="expiry" label="유효기간" error={errors.expiry?.message}>
              <Input
                id="expiry"
                placeholder="12 / 28"
                inputMode="numeric"
                autoComplete="cc-exp"
                {...withFormat(register("expiry"), formatExpiry)}
              />
            </Field>
            <Field id="cvc" label="CVC" error={errors.cvc?.message}>
              <Input id="cvc" placeholder="123" inputMode="numeric" autoComplete="cc-csc" {...withFormat(register("cvc"), (value) => toHalfWidth(value).replace(/\D/g, "").slice(0, 4))} />
            </Field>
          </div>
          <p className="text-xs text-muted-foreground">데모이므로 실제 카드 번호는 입력하지 마세요</p>
          <div className="flex justify-between">
            <Button type="button" variant="outline" onClick={goBack}>이전</Button>
            <Button type="button" onClick={goNext}>다음</Button>
          </div>
        </div>
      )}

      {/* ③ 확인: step이 3일 때만 표시 */}
      {step === 3 && (
        <div className="flex flex-col gap-4">
          <p className="text-lg font-semibold">주문 내용 확인</p>
          <dl className="space-y-3 text-sm">
            <div className="flex justify-between gap-4">
              <dt className="text-muted-foreground">배송지</dt>
              <dd className="text-right">
                {values.name}
                <br />({values.zip}) {values.address}
              </dd>
            </div>
            <div className="flex justify-between gap-4">
              <dt className="text-muted-foreground">결제</dt>
              {/* 카드 번호는 끝 4자리만 표시 */}
              <dd>카드 ****{values.cardNumber?.slice(-4)}</dd>
            </div>
            <div className="flex justify-between border-t pt-3 font-semibold">
              <dt>합계</dt>
              <dd>₩312,000</dd>
            </div>
          </dl>
          <div className="flex justify-between">
            <Button type="button" variant="outline" onClick={goBack}>이전</Button>
            <Button type="button" onClick={placeOrder}>주문하기</Button>
          </div>
        </div>
      )}

      {/* 완료: step이 4일 때만 표시 */}
      {step === 4 && (
        <div className="py-6 text-center">
          <div className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-xl text-emerald-600">✓</div>
          <p className="mt-4 text-lg font-semibold">주문해 주셔서 감사합니다</p>
          <p className="mt-1 text-sm text-muted-foreground">주문번호: A-20261003-0001</p>
          <Button type="button" variant="outline" className="mt-6" onClick={restart}>
            처음부터 다시 하기 (데모용)
          </Button>
        </div>
      )}
    </div>
  )
}