@sayren/mcp 0.5.2 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +33 -3
- package/package.json +3 -3
- package/template/.template-meta.json +3 -2
- package/template/README.md +87 -55
- package/template/package.json +2 -2
- package/template/public/favicon.svg +1 -0
- package/template/src/components/product-card.tsx +2 -0
- package/template/src/components/site-header.tsx +16 -6
- package/template/src/components/submit-button.tsx +4 -0
- package/template/src/lib/config.server.ts +1 -1
- package/template/src/lib/guest-order.test.ts +18 -0
- package/template/src/lib/guest-order.ts +13 -0
- package/template/src/lib/order-status.ts +17 -0
- package/template/src/lib/page-title.test.ts +19 -0
- package/template/src/lib/page-title.ts +23 -0
- package/template/src/lib/payment-options.test.ts +61 -0
- package/template/src/lib/payment-options.ts +39 -0
- package/template/src/routeTree.gen.ts +42 -0
- package/template/src/routes/__root.tsx +40 -6
- package/template/src/routes/account.tsx +166 -4
- package/template/src/routes/cart.tsx +9 -2
- package/template/src/routes/checkout.complete.tsx +22 -10
- package/template/src/routes/checkout.index.tsx +40 -17
- package/template/src/routes/checkout.return.tsx +132 -20
- package/template/src/routes/guest-order.tsx +183 -0
- package/template/src/routes/index.tsx +2 -3
- package/template/src/routes/login.tsx +23 -3
- package/template/src/routes/orders.$orderId.tsx +13 -8
- package/template/src/routes/orders.index.tsx +2 -1
- package/template/src/routes/products.$productId.tsx +12 -5
- package/template/src/routes/products.index.tsx +6 -1
- package/template/src/routes/signup.tsx +235 -0
|
@@ -15,6 +15,8 @@ import { ProductThumb } from "../components/product-thumb";
|
|
|
15
15
|
import { SubmitButton } from "../components/submit-button";
|
|
16
16
|
import { apiFor } from "../lib/api.server";
|
|
17
17
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
18
|
+
import { orderItemStatusLabel } from "../lib/order-status";
|
|
19
|
+
import { pageTitle } from "../lib/page-title";
|
|
18
20
|
import { readToken } from "../lib/session.server";
|
|
19
21
|
|
|
20
22
|
const getOrder = createServerFn({ method: "GET" })
|
|
@@ -62,24 +64,26 @@ const createClaim = createServerFn({ method: "POST" })
|
|
|
62
64
|
});
|
|
63
65
|
|
|
64
66
|
function claimErrorMessage(error: unknown): string {
|
|
65
|
-
if (!(error instanceof ApiError)) return "접수하지 못했습니다. 잠시 후 다시
|
|
67
|
+
if (!(error instanceof ApiError)) return "접수하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
66
68
|
switch (error.code) {
|
|
67
69
|
case "INVALID_QUANTITY":
|
|
68
|
-
return "남은 수량보다 많이 신청했습니다. 수량을
|
|
70
|
+
return "남은 수량보다 많이 신청했습니다. 수량을 확인해 주십시오.";
|
|
69
71
|
case "CLAIM_IN_PROGRESS":
|
|
70
72
|
return "이미 진행 중인 신청이 있습니다.";
|
|
71
73
|
case "INVALID_STATUS":
|
|
72
|
-
return "지금은 신청할 수 없는 상태입니다. 주문 상태를
|
|
74
|
+
return "지금은 신청할 수 없는 상태입니다. 주문 상태를 확인해 주십시오.";
|
|
73
75
|
case "PERIOD_EXPIRED":
|
|
74
76
|
return "반품·교환 가능 기간이 지났습니다.";
|
|
75
77
|
default:
|
|
76
|
-
return error.message || "접수하지 못했습니다. 잠시 후 다시
|
|
78
|
+
return error.message || "접수하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
export const Route = createFileRoute("/orders/$orderId")({
|
|
81
83
|
loader: ({ params }) => getOrder({ data: { orderId: params.orderId } }),
|
|
82
|
-
head: ({ params }) => ({
|
|
84
|
+
head: ({ matches, params }) => ({
|
|
85
|
+
meta: [{ title: pageTitle(matches, `주문 ${params.orderId}`) }],
|
|
86
|
+
}),
|
|
83
87
|
component: OrderDetail,
|
|
84
88
|
});
|
|
85
89
|
|
|
@@ -171,6 +175,7 @@ function OrderDetail() {
|
|
|
171
175
|
<div className="flex items-center gap-4">
|
|
172
176
|
<ProductThumb
|
|
173
177
|
src={item.thumbnailUrl}
|
|
178
|
+
alt={item.productName}
|
|
174
179
|
className="size-16 rounded-md bg-chip object-cover"
|
|
175
180
|
/>
|
|
176
181
|
<span className="flex flex-1 flex-col gap-1 text-sm">
|
|
@@ -179,7 +184,7 @@ function OrderDetail() {
|
|
|
179
184
|
<span className="text-muted text-xs">{item.optionName}</span>
|
|
180
185
|
) : null}
|
|
181
186
|
<span className="text-muted text-xs">
|
|
182
|
-
{item.status} · 수량 {item.quantity}개
|
|
187
|
+
{orderItemStatusLabel(item.status)} · 수량 {item.quantity}개
|
|
183
188
|
{item.canceledQuantity > 0
|
|
184
189
|
? ` · 취소·반품 ${item.canceledQuantity}개 · 남은 수량 ${item.activeQuantity}개`
|
|
185
190
|
: null}
|
|
@@ -271,9 +276,9 @@ function ClaimForm({
|
|
|
271
276
|
const quantity = Number(quantityText);
|
|
272
277
|
const quantityError =
|
|
273
278
|
quantityText.trim() === "" || !Number.isInteger(quantity)
|
|
274
|
-
? "수량을
|
|
279
|
+
? "수량을 입력해 주십시오."
|
|
275
280
|
: quantity < 1 || quantity > item.activeQuantity
|
|
276
|
-
? `수량은 1개 이상 ${item.activeQuantity}개 이하로
|
|
281
|
+
? `수량은 1개 이상 ${item.activeQuantity}개 이하로 입력해 주십시오.`
|
|
277
282
|
: null;
|
|
278
283
|
return (
|
|
279
284
|
<form
|
|
@@ -2,6 +2,7 @@ import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
|
|
2
2
|
import { createServerFn } from "@tanstack/react-start";
|
|
3
3
|
import { apiFor } from "../lib/api.server";
|
|
4
4
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
5
|
+
import { pageTitle } from "../lib/page-title";
|
|
5
6
|
import { readToken } from "../lib/session.server";
|
|
6
7
|
|
|
7
8
|
const getOrders = createServerFn({ method: "GET" }).handler(async () => {
|
|
@@ -12,7 +13,7 @@ const getOrders = createServerFn({ method: "GET" }).handler(async () => {
|
|
|
12
13
|
|
|
13
14
|
export const Route = createFileRoute("/orders/")({
|
|
14
15
|
loader: () => getOrders(),
|
|
15
|
-
head: () => ({ meta: [{ title: "주문 내역" }] }),
|
|
16
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "주문 내역") }] }),
|
|
16
17
|
component: Orders,
|
|
17
18
|
});
|
|
18
19
|
|
|
@@ -10,6 +10,7 @@ import { useTrack } from "../lib/analytics";
|
|
|
10
10
|
import { apiFor } from "../lib/api.server";
|
|
11
11
|
import { readCartToken, writeCartToken } from "../lib/cart-session.server";
|
|
12
12
|
import { formatPrice } from "../lib/format";
|
|
13
|
+
import { pageTitle } from "../lib/page-title";
|
|
13
14
|
import { readToken } from "../lib/session.server";
|
|
14
15
|
|
|
15
16
|
const getProduct = createServerFn({ method: "GET" })
|
|
@@ -52,7 +53,9 @@ const addToCart = createServerFn({ method: "POST" })
|
|
|
52
53
|
|
|
53
54
|
export const Route = createFileRoute("/products/$productId")({
|
|
54
55
|
loader: ({ params }) => getProduct({ data: { productId: params.productId } }),
|
|
55
|
-
head: ({ loaderData }) => ({
|
|
56
|
+
head: ({ matches, loaderData }) => ({
|
|
57
|
+
meta: [{ title: pageTitle(matches, loaderData ? loaderData.name : "상품") }],
|
|
58
|
+
}),
|
|
56
59
|
component: ProductDetail,
|
|
57
60
|
});
|
|
58
61
|
|
|
@@ -75,12 +78,16 @@ function ProductDetail() {
|
|
|
75
78
|
return (
|
|
76
79
|
<div className="grid gap-8 md:grid-cols-2">
|
|
77
80
|
<div className="space-y-3">
|
|
78
|
-
<ProductThumb
|
|
79
|
-
|
|
81
|
+
<ProductThumb
|
|
82
|
+
src={product.thumbnailUrl}
|
|
83
|
+
alt={product.name}
|
|
84
|
+
className="w-full rounded-lg bg-chip"
|
|
85
|
+
/>
|
|
86
|
+
{product.images.slice(0, 4).map((image, index) => (
|
|
80
87
|
<img
|
|
81
88
|
key={image}
|
|
82
89
|
src={image}
|
|
83
|
-
alt=
|
|
90
|
+
alt={`${product.name} 이미지 ${index + 1}`}
|
|
84
91
|
className="w-full rounded-lg bg-chip"
|
|
85
92
|
loading="lazy"
|
|
86
93
|
/>
|
|
@@ -107,7 +114,7 @@ function ProductDetail() {
|
|
|
107
114
|
setError(null);
|
|
108
115
|
addToCart({ data: { productId: product.productId, optionId, quantity } })
|
|
109
116
|
.then(() => navigate({ to: "/cart" }))
|
|
110
|
-
.catch(() => setError("장바구니에 담지 못했습니다. 다시
|
|
117
|
+
.catch(() => setError("장바구니에 담지 못했습니다. 다시 시도해 주십시오"))
|
|
111
118
|
.finally(() => setSubmitting(false));
|
|
112
119
|
}}
|
|
113
120
|
>
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { ProductCard } from "../components/product-card";
|
|
5
5
|
import { useTrack } from "../lib/analytics";
|
|
6
6
|
import { apiFor } from "../lib/api.server";
|
|
7
|
+
import { pageTitle } from "../lib/page-title";
|
|
7
8
|
|
|
8
9
|
/** 목록·검색 조건 — URL에 둔다. 공유·뒤로가기가 그대로 동작한다 */
|
|
9
10
|
const productsSearch = z.object({
|
|
@@ -29,7 +30,7 @@ export const Route = createFileRoute("/products/")({
|
|
|
29
30
|
validateSearch: productsSearch,
|
|
30
31
|
loaderDeps: ({ search }) => search,
|
|
31
32
|
loader: ({ deps }) => searchProducts({ data: deps }),
|
|
32
|
-
head: () => ({ meta: [{ title: "전체 상품" }] }),
|
|
33
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "전체 상품") }] }),
|
|
33
34
|
component: Products,
|
|
34
35
|
});
|
|
35
36
|
|
|
@@ -54,6 +55,9 @@ function Products() {
|
|
|
54
55
|
|
|
55
56
|
return (
|
|
56
57
|
<div className="space-y-6">
|
|
58
|
+
<h1 className="font-bold text-2xl">
|
|
59
|
+
{applied.keyword ? `"${applied.keyword}" 검색 결과` : "전체 상품"}
|
|
60
|
+
</h1>
|
|
57
61
|
{/* 하이드레이션 전에는 GET 폼으로 그대로 제출되고, 뒤에는 화면을 새로 그리지 않고 이동한다 */}
|
|
58
62
|
<form
|
|
59
63
|
action="/products"
|
|
@@ -70,6 +74,7 @@ function Products() {
|
|
|
70
74
|
<input
|
|
71
75
|
type="search"
|
|
72
76
|
name="keyword"
|
|
77
|
+
aria-label="상품 검색"
|
|
73
78
|
defaultValue={applied.keyword ?? ""}
|
|
74
79
|
placeholder="상품 검색"
|
|
75
80
|
className="h-11 flex-1 rounded-md border border-line px-3"
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { ApiError, signupRequestSchema } from "@sayren/storefront-sdk";
|
|
2
|
+
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
4
|
+
import { useId, useState } from "react";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SubmitButton } from "../components/submit-button";
|
|
7
|
+
import { apiFor, authFor } from "../lib/api.server";
|
|
8
|
+
import { clearCartToken, readCartToken } from "../lib/cart-session.server";
|
|
9
|
+
import { pageTitle } from "../lib/page-title";
|
|
10
|
+
import { safeRedirect } from "../lib/safe-redirect";
|
|
11
|
+
import { signIn } from "../lib/session.server";
|
|
12
|
+
|
|
13
|
+
const signupSearch = z.object({
|
|
14
|
+
redirectTo: z.string().optional().catch(undefined),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 가입 화면은 이메일·비밀번호 로그인이 켜진 스토어에서만 연다. 꺼져 있으면 서버가 403
|
|
19
|
+
* `PASSWORD_LOGIN_DISABLED`로 거절하므로 로그인 화면(소셜 로그인)으로 보낸다.
|
|
20
|
+
*/
|
|
21
|
+
const getSignupAvailable = createServerFn({ method: "GET" }).handler(async () => {
|
|
22
|
+
const store = await apiFor().store.get();
|
|
23
|
+
if (!store.loginMethods.includes("password")) throw redirect({ to: "/login" });
|
|
24
|
+
return null;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 가입 — 약관·개인정보 수집이용 동의는 필수이고 마케팅 수신은 선택이다. 서버가 가입과 같은 트랜잭션에서
|
|
29
|
+
* 동의 이력을 남긴다. 비회원으로 담아 둔 장바구니 토큰을 함께 보내면 회원 장바구니로 합쳐지고 그 토큰은 닫힌다.
|
|
30
|
+
*/
|
|
31
|
+
const signUp = createServerFn({ method: "POST" })
|
|
32
|
+
.validator(
|
|
33
|
+
z.object({
|
|
34
|
+
email: z.string(),
|
|
35
|
+
password: z.string(),
|
|
36
|
+
name: z.string(),
|
|
37
|
+
phone: z.string(),
|
|
38
|
+
terms: z.boolean(),
|
|
39
|
+
privacy: z.boolean(),
|
|
40
|
+
marketing: z.boolean(),
|
|
41
|
+
redirectTo: z.unknown(),
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
.handler(async ({ data }) => {
|
|
45
|
+
const cartToken = readCartToken();
|
|
46
|
+
// 입력 규칙(비밀번호 조합·휴대폰 형식)은 SDK 스키마가 원천이다 — 서버에 보내기 전에 같은 규칙으로 안내한다
|
|
47
|
+
const parsed = signupRequestSchema.safeParse({
|
|
48
|
+
email: data.email.trim(),
|
|
49
|
+
password: data.password,
|
|
50
|
+
name: data.name.trim(),
|
|
51
|
+
phone: data.phone.trim() || undefined,
|
|
52
|
+
agreements: { terms: data.terms, privacy: data.privacy, marketing: data.marketing },
|
|
53
|
+
cartToken: cartToken ?? undefined,
|
|
54
|
+
});
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
const issue = parsed.error.issues[0];
|
|
57
|
+
const field = issue?.path[0];
|
|
58
|
+
if (field === "agreements") return { redirectTo: null, error: "필수 약관에 동의해 주십시오" };
|
|
59
|
+
if (field === "email") return { redirectTo: null, error: "이메일 형식을 확인해 주십시오" };
|
|
60
|
+
if (field === "name") return { redirectTo: null, error: "이름을 입력해 주십시오" };
|
|
61
|
+
return { redirectTo: null, error: issue?.message ?? "입력한 내용을 확인해 주십시오" };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
signIn(await authFor().signUp(parsed.data));
|
|
65
|
+
if (cartToken) clearCartToken();
|
|
66
|
+
// 어디서 왔는지 모르면 홈으로 간다
|
|
67
|
+
return { redirectTo: safeRedirect(data.redirectTo ?? "/"), error: null };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { redirectTo: null, error: signupErrorMessage(error) };
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function signupErrorMessage(error: unknown): string {
|
|
74
|
+
if (error instanceof ApiError) {
|
|
75
|
+
if (error.code === "EMAIL_ALREADY_EXISTS") return "이미 가입된 이메일입니다. 로그인해 주십시오";
|
|
76
|
+
if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인으로만 가입합니다";
|
|
77
|
+
if (error.status === 400) return "입력한 내용을 확인해 주십시오";
|
|
78
|
+
if (error.status === 429) return "요청이 많습니다. 잠시 후 다시 시도해 주십시오";
|
|
79
|
+
}
|
|
80
|
+
return "가입하지 못했습니다. 잠시 후 다시 시도해 주십시오";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const Route = createFileRoute("/signup")({
|
|
84
|
+
validateSearch: signupSearch,
|
|
85
|
+
loader: () => getSignupAvailable(),
|
|
86
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "회원가입") }] }),
|
|
87
|
+
component: Signup,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function Signup() {
|
|
91
|
+
const { redirectTo } = Route.useSearch();
|
|
92
|
+
const [error, setError] = useState<string | null>(null);
|
|
93
|
+
const [pending, setPending] = useState(false);
|
|
94
|
+
|
|
95
|
+
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
const form = new FormData(event.currentTarget);
|
|
98
|
+
const text = (name: string) => String(form.get(name) ?? "");
|
|
99
|
+
setPending(true);
|
|
100
|
+
setError(null);
|
|
101
|
+
void signUp({
|
|
102
|
+
data: {
|
|
103
|
+
email: text("email"),
|
|
104
|
+
password: text("password"),
|
|
105
|
+
name: text("name"),
|
|
106
|
+
phone: text("phone"),
|
|
107
|
+
terms: form.get("terms") === "on",
|
|
108
|
+
privacy: form.get("privacy") === "on",
|
|
109
|
+
marketing: form.get("marketing") === "on",
|
|
110
|
+
redirectTo,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
.then((result) => {
|
|
114
|
+
// 로그인 상태가 모든 화면에 새로 반영되도록 문서째 이동한다
|
|
115
|
+
if (result.redirectTo) window.location.assign(result.redirectTo);
|
|
116
|
+
else {
|
|
117
|
+
setError(result.error);
|
|
118
|
+
setPending(false);
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
.catch(() => {
|
|
122
|
+
setError("가입하지 못했습니다. 잠시 후 다시 시도해 주십시오");
|
|
123
|
+
setPending(false);
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="mx-auto max-w-sm space-y-5 py-10">
|
|
129
|
+
<h1 className="font-bold text-2xl">회원가입</h1>
|
|
130
|
+
<form onSubmit={submit} className="space-y-3">
|
|
131
|
+
<TextField label="이메일" name="email" type="email" autoComplete="email" required />
|
|
132
|
+
<TextField
|
|
133
|
+
label="비밀번호"
|
|
134
|
+
name="password"
|
|
135
|
+
type="password"
|
|
136
|
+
autoComplete="new-password"
|
|
137
|
+
required
|
|
138
|
+
hint="10자 이상, 영문·숫자·특수문자 중 2종 이상"
|
|
139
|
+
/>
|
|
140
|
+
<TextField label="이름" name="name" autoComplete="name" required />
|
|
141
|
+
<TextField
|
|
142
|
+
label="휴대폰 번호"
|
|
143
|
+
name="phone"
|
|
144
|
+
type="tel"
|
|
145
|
+
autoComplete="tel"
|
|
146
|
+
placeholder="01012345678"
|
|
147
|
+
/>
|
|
148
|
+
|
|
149
|
+
<fieldset className="space-y-2 rounded-md border border-line p-3 text-sm">
|
|
150
|
+
<legend className="px-1 font-medium">약관 동의</legend>
|
|
151
|
+
<label className="flex items-center gap-2">
|
|
152
|
+
<input type="checkbox" name="terms" required />
|
|
153
|
+
이용약관 동의 (필수)
|
|
154
|
+
</label>
|
|
155
|
+
<label className="flex items-center gap-2">
|
|
156
|
+
<input type="checkbox" name="privacy" required />
|
|
157
|
+
개인정보 수집이용 동의 (필수)
|
|
158
|
+
</label>
|
|
159
|
+
<label className="flex items-center gap-2">
|
|
160
|
+
<input type="checkbox" name="marketing" />
|
|
161
|
+
마케팅 수신 동의 (선택)
|
|
162
|
+
</label>
|
|
163
|
+
</fieldset>
|
|
164
|
+
|
|
165
|
+
<SubmitButton
|
|
166
|
+
disabled={pending}
|
|
167
|
+
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
168
|
+
>
|
|
169
|
+
가입하기
|
|
170
|
+
</SubmitButton>
|
|
171
|
+
</form>
|
|
172
|
+
{error ? (
|
|
173
|
+
<p role="alert" className="text-point text-sm">
|
|
174
|
+
{error}
|
|
175
|
+
</p>
|
|
176
|
+
) : null}
|
|
177
|
+
<p className="text-center text-muted text-sm">
|
|
178
|
+
이미 회원이십니까?{" "}
|
|
179
|
+
<Link to="/login" search={{ redirectTo }} className="text-ink underline">
|
|
180
|
+
로그인
|
|
181
|
+
</Link>
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function TextField({
|
|
188
|
+
label,
|
|
189
|
+
name,
|
|
190
|
+
type = "text",
|
|
191
|
+
autoComplete,
|
|
192
|
+
placeholder,
|
|
193
|
+
required,
|
|
194
|
+
hint,
|
|
195
|
+
}: {
|
|
196
|
+
label: string;
|
|
197
|
+
name: string;
|
|
198
|
+
type?: string;
|
|
199
|
+
autoComplete?: string;
|
|
200
|
+
placeholder?: string;
|
|
201
|
+
required?: boolean;
|
|
202
|
+
hint?: string;
|
|
203
|
+
}) {
|
|
204
|
+
const hintId = useId();
|
|
205
|
+
return (
|
|
206
|
+
<label className="block space-y-1">
|
|
207
|
+
<span className="text-sm">
|
|
208
|
+
{label}
|
|
209
|
+
{/* 필수 표시는 눈으로만 본다 — 입력의 required가 보조 기술에 필수임을 알린다 */}
|
|
210
|
+
{required ? (
|
|
211
|
+
<span aria-hidden="true" className="text-point">
|
|
212
|
+
{" "}
|
|
213
|
+
*
|
|
214
|
+
</span>
|
|
215
|
+
) : null}
|
|
216
|
+
</span>
|
|
217
|
+
<input
|
|
218
|
+
type={type}
|
|
219
|
+
name={name}
|
|
220
|
+
autoComplete={autoComplete}
|
|
221
|
+
placeholder={placeholder}
|
|
222
|
+
required={required}
|
|
223
|
+
// 이름은 라벨 글자 그대로다(필수 표시·안내 문구는 빼고) — 안내는 aria-describedby로 잇는다
|
|
224
|
+
aria-label={label}
|
|
225
|
+
aria-describedby={hint ? hintId : undefined}
|
|
226
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
227
|
+
/>
|
|
228
|
+
{hint ? (
|
|
229
|
+
<span id={hintId} className="block text-muted text-xs">
|
|
230
|
+
{hint}
|
|
231
|
+
</span>
|
|
232
|
+
) : null}
|
|
233
|
+
</label>
|
|
234
|
+
);
|
|
235
|
+
}
|