문의하기

문의하기 폼입니다. 보내기 전에 입력을 검증하고, "보내는 중"과 "보내기 완료"를 화면에 보여 줍니다.

문의하기

핵심 포인트

  1. 1

    보내는 중에는 버튼을 다시 누를 수 없게 하기

    보내는 동안 버튼을 누를 수 없게 하고 "보내는 중…"을 표시합니다. 버튼을 누를 수 있으면 같은 문의가 두 번 도착할 수 있습니다.

  2. 2

    보내기가 끝나면 화면 전체를 바꾸기

    보내기가 끝나면 폼을 숨기고 "보냈습니다" 화면을 보여 줍니다. 언제쯤 답장할지도 적어 두면 상대방이 안심합니다.

  3. 3

    얼마나 쓸 수 있는지 미리 알려 주기

    본문 아래에 "현재 글자 수 / 최대"를 표시하고, 최대를 넘으면 빨간색으로 바꿉니다. 보낸 뒤에 "너무 깁니다"라고 듣는 것보다 친절합니다.

사용한 도구

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

코드 고치기

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

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

// 문의 유형
const kinds = ["사용 방법", "오류 신고", "기타"]

// 입력 검사 규칙(Zod)
const schema = z.object({
  name: z.string().min(1, "이름을 입력해 주세요"),
  email: z.string().email("올바른 이메일 주소를 입력해 주세요"),
  kind: z.enum(kinds, { error: "유형을 선택해 주세요" }),
  message: z.string().min(10, "10자 이상 입력해 주세요").max(500, "500자 이내로 입력해 주세요"),
})

// 보내는… 척을 하는 함수(1.5초 걸린다)
function send(values) {
  return new Promise((resolve) => setTimeout(resolve, 1500))
}

// 라벨, 입력란, 오류 메시지를 하나로 묶는다
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>
  )
}

export default function App() {
  // ① 보내기가 끝났는지(처음에는 false). state에 넣는다
  const [sent, setSent] = useState(false)

  const { register, handleSubmit, watch, reset, formState: { errors, isSubmitting } } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { name: "", email: "", kind: undefined, message: "" },
  })

  // 본문의 현재 글자 수를 세기 위해 본문을 지켜본다
  const message = watch("message")

  // '보내기'를 누르고 입력 검사를 통과하면 실행된다
  async function onSubmit(values) {
    await send(values) // 보내기가 끝날 때까지 기다린다(1.5초)
    // ② 보내기가 끝났으니 sent를 true로 한다
    setSent(true)
  }

  // ③ 보내기가 끝나면 완료 화면을 먼저 반환한다
  //    조건: 보내기가 끝났는가
  if (sent) {
    return (
      <div className="mx-auto my-6 w-full max-w-lg rounded-xl border bg-card p-10 text-center text-card-foreground">
        <CircleCheckIcon className="mx-auto size-12 text-emerald-500" />
        <p className="mt-4 text-lg font-semibold">보냈습니다</p>
        <p className="mt-2 text-sm text-muted-foreground">
          문의해 주셔서 감사합니다. 영업일 기준 2일 이내에 이메일로 답변드리겠습니다.
        </p>
        {/* ④ 누르면 입력을 비우고(reset) 폼 화면으로 돌아간다(sent를 false로) */}
        <Button variant="outline" className="mt-6" onClick={() => {
          reset()
          setSent(false)
        }}>
          다시 보내기(데모용)
        </Button>
      </div >
    )
  }
  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="mx-auto my-6 flex w-full max-w-lg flex-col gap-5 rounded-xl border bg-card p-6 text-card-foreground"
    >
      <div>
        <h1 className="text-lg font-semibold">문의하기</h1>
        <p className="mt-1 text-sm text-muted-foreground">질문이나 의견을 편하게 보내 주세요.</p>
      </div>

      <Field id="name" label="이름" error={errors.name?.message}>
        <Input id="name" placeholder="박지훈" {...register("name")} />
      </Field>
      <Field id="email" label="이메일" error={errors.email?.message}>
        <Input id="email" type="email" placeholder="jihoon@example.com" {...register("email")} />
      </Field>

      <div className="flex flex-col gap-2">
        <Label>유형</Label>
        <div className="flex flex-wrap gap-2">
          {kinds.map((kind) => (
            <label
              key={kind}
              className="cursor-pointer rounded-full border px-3 py-1.5 text-sm has-checked:border-primary has-checked:bg-primary has-checked:text-primary-foreground"
            >
              <input type="radio" value={kind} className="sr-only" {...register("kind")} />
              {kind}
            </label>
          ))}
        </div>
        {errors.kind && <p className="text-sm text-destructive">{errors.kind.message}</p>}
      </div>

      <Field id="message" label="문의 내용" error={errors.message?.message}>
        <textarea
          id="message"
          rows={5}
          placeholder="10자 이상 입력해 주세요"
          className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
          {...register("message")}
        />
        {/* ⑦ 500자를 넘으면 빨간색(text-destructive), 그 밖에는 회색(text-muted-foreground) */}
        <p className={message.length > 500 ? "text-right text-xs text-destructive" : "text-right text-xs text-muted-foreground"}>
          {/* ⑥ 본문의 현재 글자 수 */}
          {message.length}  / 500
        </p>
      </Field>

      {/* ⑤ 보내는 중(isSubmitting이 true)에는 누를 수 없다. 글자는 '보내는 중…', 그 밖에는 '보내기' */}
      <Button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "보내는 중…" : "보내기"}
      </Button>
    </form>
  )
}