@sayren/mcp 0.6.0 → 0.8.0
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 +6 -4
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +32 -2
- package/template/package.json +6 -0
- package/template/src/components/fulfillment-codes.tsx +110 -0
- package/template/src/components/fulfillment-downloads.tsx +88 -0
- package/template/src/components/text-field.tsx +71 -0
- package/template/src/lib/catalog-queries.ts +47 -0
- package/template/src/lib/code-reveal.test.ts +30 -0
- package/template/src/lib/code-reveal.ts +32 -0
- package/template/src/lib/coupon-code.test.ts +19 -0
- package/template/src/lib/coupon-code.ts +60 -0
- package/template/src/lib/download.test.ts +41 -0
- package/template/src/lib/download.ts +41 -0
- package/template/src/lib/form-schemas.test.ts +144 -0
- package/template/src/lib/form-schemas.ts +131 -0
- 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.test.ts +29 -0
- package/template/src/lib/order-status.ts +28 -2
- package/template/src/lib/page-title.test.ts +19 -0
- package/template/src/lib/page-title.ts +23 -0
- package/template/src/lib/products-search.test.ts +25 -0
- package/template/src/lib/products-search.ts +49 -0
- package/template/src/router.tsx +26 -1
- package/template/src/routes/__root.tsx +13 -6
- package/template/src/routes/account.tsx +2 -1
- package/template/src/routes/auth.callback.ts +13 -9
- package/template/src/routes/cart.tsx +13 -6
- package/template/src/routes/checkout.complete.tsx +2 -1
- package/template/src/routes/checkout.index.tsx +441 -147
- package/template/src/routes/checkout.return.tsx +7 -1
- package/template/src/routes/guest-order.tsx +174 -72
- package/template/src/routes/login.tsx +2 -1
- package/template/src/routes/orders.$orderId.tsx +98 -3
- package/template/src/routes/orders.index.tsx +2 -1
- package/template/src/routes/products.$productId.tsx +4 -1
- package/template/src/routes/products.index.tsx +46 -47
- package/template/src/routes/signup.tsx +61 -90
package/dist/index.mjs
CHANGED
|
@@ -666,10 +666,10 @@ const RULES = [
|
|
|
666
666
|
},
|
|
667
667
|
{
|
|
668
668
|
id: "loader-first",
|
|
669
|
-
title: "데이터는 라우트 loader에서 받는다(서버 함수 `createServerFn`)",
|
|
670
|
-
why: "클라이언트에서 다시 받으면 첫 화면이 비고 검색 노출이 죽는다. SSR을 끄지
|
|
671
|
-
fix: "화면마다 `createFileRoute(...)({ loader })`를 두고 loader가 서버 함수를 부른다. `useEffect`에서 처음 데이터를 받지 않는다",
|
|
672
|
-
example: "const load = createServerFn().handler(() => apiFor(
|
|
669
|
+
title: "데이터는 라우트 loader에서 받는다(서버 함수 `createServerFn`, TanStack Query면 `ensureQueryData`)",
|
|
670
|
+
why: "클라이언트에서 다시 받으면 첫 화면이 비고 검색 노출이 죽는다. SSR을 끄지 않는다. TanStack Query의 `useQuery`만 쓰면 서버 렌더에는 데이터가 없다",
|
|
671
|
+
fix: "화면마다 `createFileRoute(...)({ loader })`를 두고 loader가 서버 함수를 부른다. TanStack Query를 쓰면 loader가 `context.queryClient.ensureQueryData(queryOptions)`로 미리 받고 화면은 같은 queryOptions로 `useSuspenseQuery`를 부른다(`setupRouterSsrQueryIntegration`이 캐시를 HTML에 싣는다). `useEffect`에서 처음 데이터를 받지 않는다",
|
|
672
|
+
example: "const load = createServerFn().handler(() => apiFor().catalog.searchProducts({ size: 24 }));\nconst productsQuery = () => queryOptions({ queryKey: [\"products\"], queryFn: () => load() });\nexport const Route = createFileRoute(\"/products/\")({\n loader: ({ context }) => context.queryClient.ensureQueryData(productsQuery()),\n component: Products, // 화면: useSuspenseQuery(productsQuery())\n});",
|
|
673
673
|
doc: "/guides/mcp-customize",
|
|
674
674
|
templateFile: "src/routes/products.index.tsx"
|
|
675
675
|
},
|
|
@@ -891,6 +891,8 @@ function verifySources(files) {
|
|
|
891
891
|
if (!has("reconsentRequired")) inProject("buyer-reconsent", "재동의가 필요한 구매자를 처리하는 코드가 없다(`reconsentRequired`)");
|
|
892
892
|
}
|
|
893
893
|
if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) inProject("analytics-start", "방문 분석을 시작하는 코드가 없다. `@sayren/storefront-sdk/analytics`의 createAnalytics를 브라우저에서 한 번 부른다");
|
|
894
|
+
const clientQuery = matches(/\buse(Suspense)?Query\(/);
|
|
895
|
+
if (clientQuery && !has("ensureQueryData(") && !has("prefetchQuery(")) inFile("loader-first", clientQuery, "TanStack Query로 데이터를 받지만 loader에서 `queryClient.ensureQueryData`로 미리 받는 코드가 없다. 서버 렌더에 데이터가 빠진다", /\buse(Suspense)?Query\(/);
|
|
894
896
|
if (has("createStorefrontClient(") && !has("loader")) inProject("loader-first", "loader에서 데이터를 받는 코드가 없다. 클라이언트 전용으로 그리면 SSR이 죽는다");
|
|
895
897
|
return findings;
|
|
896
898
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sayren-mcp": "./dist/index.mjs"
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
14
14
|
"zod": "^4.6.5",
|
|
15
|
-
"@sayren/store-sdk": "^0.
|
|
16
|
-
"@sayren/storefront-sdk": "^0.
|
|
15
|
+
"@sayren/store-sdk": "^0.17.0",
|
|
16
|
+
"@sayren/storefront-sdk": "^0.14.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
package/template/README.md
CHANGED
|
@@ -50,7 +50,34 @@ SAYREN_STORE_CODE=내_스토어_코드
|
|
|
50
50
|
화면 데이터는 라우트 `loader`가 서버 함수(`createServerFn`)를 불러 받습니다. 첫 요청은 서버에서 그리고, 이후 이동은
|
|
51
51
|
브라우저가 같은 서버 함수를 호출합니다. 담기·결제 요청·로그인 같은 쓰기도 서버 함수라서 토큰이 브라우저 JS에 나가지 않습니다.
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
## 기본 라이브러리
|
|
54
|
+
|
|
55
|
+
| 라이브러리 | 역할 | 쓰는 곳 |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `@tanstack/react-query` · `@tanstack/react-router-ssr-query` | 서버 함수 데이터 캐시. 서버가 채운 캐시를 HTML에 실어 브라우저가 이어 씁니다 | `src/router.tsx`, 상품 목록(`src/lib/catalog-queries.ts`) |
|
|
58
|
+
| `nuqs` | URL 상태(검색어·카테고리·정렬·페이지) | 상품 목록(`src/lib/products-search.ts`) |
|
|
59
|
+
| `react-hook-form` · `@hookform/resolvers` · `zod` | 폼 입력과 검증 | 회원가입 · 주문서 · 비회원 주문 조회(`src/lib/form-schemas.ts`) |
|
|
60
|
+
| `query-string` | 화면 밖(서버 라우트)에서 주소 만들기 | 소셜 로그인 복귀(`src/routes/auth.callback.ts`) |
|
|
61
|
+
|
|
62
|
+
**데이터(TanStack Query)**: 쿼리는 `queryOptions` 팩토리로 만들고 loader와 화면이 같은 팩토리를 씁니다. loader는
|
|
63
|
+
`context.queryClient.ensureQueryData(productsQuery(search))`로 서버에서 미리 받고, 화면은 `useSuspenseQuery(productsQuery(search))`로
|
|
64
|
+
캐시에서 읽습니다. `queryFn`은 서버 함수를 부릅니다. `useQuery`만 쓰면 서버 렌더에 데이터가 없어 첫 화면이 빕니다.
|
|
65
|
+
주문서(`/checkout`)는 들어올 때마다 주문서를 새로 만들어야 하므로 캐시하지 않고 loader가 서버 함수를 바로 부릅니다.
|
|
66
|
+
|
|
67
|
+
**URL 상태(nuqs)**: 파서(`productsSearchParams`)가 원천입니다. 라우트 `validateSearch`가 같은 파서로 주소를 읽어 loader에
|
|
68
|
+
넘기고, 화면은 `useQueryStates`로 검색어·정렬을 바꿉니다. 기본값(1페이지·추천순)은 주소에서 빠집니다. 페이지 이동처럼
|
|
69
|
+
자바스크립트 없이도 동작해야 하는 곳은 링크(`<Link search>`)로 둡니다.
|
|
70
|
+
|
|
71
|
+
**폼(react-hook-form + zod)**: 입력 규칙은 스토어프론트 SDK 스키마(`signupRequestSchema`·`shippingAddressInputSchema`·
|
|
72
|
+
`guestInfoSchema`·`cashReceiptRequestSchema`)를 재사용하고 안내 문구만 붙입니다. 입력 칸은 `src/components/text-field.tsx`를 씁니다.
|
|
73
|
+
브라우저 필수 검사(`required`)는 그대로 두어 빈 칸은 브라우저가 먼저 막습니다. 주문서는 결제창을 클릭 시점에 열어야 하므로
|
|
74
|
+
제출 핸들러에서 같은 스키마로 먼저 동기 검사한 뒤 `payments.prepareWindow()`를 부릅니다(`form.handleSubmit`은 검증을 기다립니다).
|
|
75
|
+
|
|
76
|
+
**주소 문자열(query-string)**: 화면 안의 URL 상태는 nuqs와 라우터가 맡습니다. 서버 라우트의 `Location` 헤더처럼 라우터 밖에서
|
|
77
|
+
쿼리 문자열을 만들 때만 `queryString.stringifyUrl`을 씁니다.
|
|
78
|
+
|
|
79
|
+
헤더와 브라우저 탭 제목은 셀러 콘솔 설정 › 스토어 정보의 스토어 이름·로고(`GET /store`)를 씁니다. 탭 제목은
|
|
80
|
+
`{화면} | {스토어 이름}`(예: `회원가입 | 나의첫번째몰`)이고 홈은 스토어 이름만 씁니다. 형식은 `src/lib/page-title.ts` 한 곳에서 바꿉니다.
|
|
54
81
|
|
|
55
82
|
## 방문 분석
|
|
56
83
|
|
|
@@ -101,6 +128,7 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
101
128
|
리프레시 토큰의 동시 갱신은 한 번으로 모읍니다. 리프레시 토큰은 한 번 쓰면 폐기되므로 각자 갱신하면 로그아웃됩니다.
|
|
102
129
|
로그아웃은 서버에서 `auth.signOut()`으로 리프레시 토큰을 폐기한 뒤 쿠키를 지웁니다.
|
|
103
130
|
- loader가 서버 함수로 데이터를 받아 그리는 구조 — 컴포넌트에서 다시 받으면 검색 노출과 첫 화면이 죽습니다.
|
|
131
|
+
TanStack Query를 쓰는 화면도 loader의 `ensureQueryData`를 지우지 않습니다.
|
|
104
132
|
- 결제 도메인 — 셀러 콘솔 설정 › 결제의 **결제 도메인**은 선택형 허용 목록입니다. 비워 두면 https 복귀 주소를 모두 받고,
|
|
105
133
|
등록하면 그 도메인에서만 결제가 됩니다. 등록했다면 이 쇼핑몰을 배포한 주소도 넣습니다. 배포 주소는 PG(토스페이먼츠·포트원)
|
|
106
134
|
가맹 정보에도 등록합니다. 테스트 결제는 localhost에서도 됩니다.
|
|
@@ -111,7 +139,9 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
111
139
|
매번 다시 계산해야 하기 때문입니다.
|
|
112
140
|
- 비회원 장바구니는 서버가 발급한 토큰으로만 찾습니다. 쿠키를 지우면 담은 상품이 사라집니다.
|
|
113
141
|
- 비회원 주문은 주문번호와 주문서에 적은 연락처·주문 조회 비밀번호로 찾습니다(`/guest-order`). 주문 완료 화면이 주문번호를
|
|
114
|
-
채워 보냅니다.
|
|
142
|
+
채워 보냅니다. 주문번호마다 10분에 5번까지 확인하고 5번째가 틀리면 30분 동안(하루 15번을 넘으면 그날 끝까지) `429 TOO_MANY_REQUESTS`라서,
|
|
143
|
+
화면은 남은 시간(`details.retryAfterSeconds`)을 분 단위로 안내합니다(`src/lib/guest-order.ts`). 주문이 없는 것과
|
|
144
|
+
연락처·비밀번호가 틀린 것은 같은 `404`입니다.
|
|
115
145
|
- 배송비는 배송 묶음마다 한 번 붙습니다. 같은 출고지 + 같은 배송 정책인 상품이 한 묶음이고, 제주·도서산간 추가
|
|
116
146
|
배송비는 묶음마다 더하며 무료배송이어도 부과합니다. 주문서를 만들 때는 배송지를 몰라 주문서의 `delivery.zipCode`가
|
|
117
147
|
null이고 추가 배송비가 0입니다. 주문서(`/checkout`)는 우편번호 입력을 마칠 때(blur) `quoteDelivery` 서버 함수로
|
package/template/package.json
CHANGED
|
@@ -13,12 +13,18 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@avarlabs/editor": "^0.1.3",
|
|
16
|
+
"@hookform/resolvers": "^5.9.1",
|
|
16
17
|
"@sayren/storefront-sdk": "workspace:*",
|
|
17
18
|
"@tailwindcss/vite": "^4.3.3",
|
|
19
|
+
"@tanstack/react-query": "^5.103.1",
|
|
18
20
|
"@tanstack/react-router": "^1.170.38",
|
|
21
|
+
"@tanstack/react-router-ssr-query": "^1.167.3",
|
|
19
22
|
"@tanstack/react-start": "^1.168.56",
|
|
23
|
+
"nuqs": "^2.10.1",
|
|
24
|
+
"query-string": "^9.5.1",
|
|
20
25
|
"react": "^19.3.0",
|
|
21
26
|
"react-dom": "^19.3.0",
|
|
27
|
+
"react-hook-form": "^7.88.0",
|
|
22
28
|
"tailwindcss": "^4.3.3",
|
|
23
29
|
"zod": "catalog:"
|
|
24
30
|
},
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { FulfillmentReveal, MyOrderItem } from "@sayren/storefront-sdk";
|
|
2
|
+
import { useHydrated } from "@tanstack/react-router";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { CodeRevealResult } from "../lib/code-reveal";
|
|
5
|
+
import { formatDateTime } from "../lib/format";
|
|
6
|
+
|
|
7
|
+
type RevealedCode = FulfillmentReveal["codes"][number];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 코드 발급 상품의 코드 — 가린 값을 보이고 「코드 보기」를 누르면 평문을 받아 이 컴포넌트 상태에만 둔다.
|
|
11
|
+
* 평문은 주소·스토리지에 남기지 않는다. 코드 발급 상품이 아니거나 받은 코드가 없으면 그리지 않는다.
|
|
12
|
+
*
|
|
13
|
+
* `reveal`은 서버 함수를 부르는 호출부 몫이다 — 회원은 액세스 토큰, 비회원은 조회에 쓴 연락처·비밀번호로 확인한다.
|
|
14
|
+
*/
|
|
15
|
+
export function FulfillmentCodes({
|
|
16
|
+
item,
|
|
17
|
+
reveal,
|
|
18
|
+
}: {
|
|
19
|
+
item: MyOrderItem;
|
|
20
|
+
reveal: () => Promise<CodeRevealResult>;
|
|
21
|
+
}) {
|
|
22
|
+
const hydrated = useHydrated();
|
|
23
|
+
const [revealed, setRevealed] = useState<RevealedCode[] | null>(null);
|
|
24
|
+
const [pending, setPending] = useState(false);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
const codes = item.fulfillment?.status === "FULFILLED" ? (item.fulfillment.codes ?? []) : [];
|
|
27
|
+
if (item.fulfillmentType !== "CODE" || codes.length === 0) return null;
|
|
28
|
+
|
|
29
|
+
const open = async () => {
|
|
30
|
+
setPending(true);
|
|
31
|
+
setError(null);
|
|
32
|
+
try {
|
|
33
|
+
const result = await reveal();
|
|
34
|
+
if (result.error !== null) setError(result.error);
|
|
35
|
+
else setRevealed(result.codes);
|
|
36
|
+
} catch {
|
|
37
|
+
setError("코드를 불러오지 못했습니다. 잠시 후 다시 시도해 주십시오");
|
|
38
|
+
} finally {
|
|
39
|
+
setPending(false);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<section aria-label="코드" className="space-y-2 rounded-md bg-chip p-3 text-xs">
|
|
45
|
+
<p className="font-semibold">코드</p>
|
|
46
|
+
{revealed ? (
|
|
47
|
+
<ul className="space-y-1.5">
|
|
48
|
+
{revealed.map((code) => (
|
|
49
|
+
<li key={code.codeId} className="flex flex-wrap items-center gap-2">
|
|
50
|
+
<code className="break-all rounded bg-white px-2 py-1 font-mono text-sm">
|
|
51
|
+
{code.code}
|
|
52
|
+
</code>
|
|
53
|
+
<CopyButton value={code.code} />
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
) : (
|
|
58
|
+
<>
|
|
59
|
+
<ul className="space-y-1">
|
|
60
|
+
{codes.map((code) => (
|
|
61
|
+
<li key={code.codeId} className="flex flex-wrap items-center gap-2">
|
|
62
|
+
<code className="font-mono text-sm">{code.hint}</code>
|
|
63
|
+
{code.revealedAt ? (
|
|
64
|
+
<span className="text-muted">처음 연 시각 {formatDateTime(code.revealedAt)}</span>
|
|
65
|
+
) : null}
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
disabled={!hydrated || pending}
|
|
73
|
+
onClick={() => void open()}
|
|
74
|
+
className="h-8 rounded-md bg-ink px-3 font-semibold text-white disabled:opacity-50"
|
|
75
|
+
>
|
|
76
|
+
코드 보기
|
|
77
|
+
</button>
|
|
78
|
+
<span className="text-muted">코드를 처음 연 시각이 기록됩니다.</span>
|
|
79
|
+
</div>
|
|
80
|
+
</>
|
|
81
|
+
)}
|
|
82
|
+
{error ? (
|
|
83
|
+
<p role="alert" className="text-point">
|
|
84
|
+
{error}
|
|
85
|
+
</p>
|
|
86
|
+
) : null}
|
|
87
|
+
</section>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function CopyButton({ value }: { value: string }) {
|
|
92
|
+
const [copied, setCopied] = useState(false);
|
|
93
|
+
return (
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => {
|
|
97
|
+
navigator.clipboard
|
|
98
|
+
.writeText(value)
|
|
99
|
+
.then(() => {
|
|
100
|
+
setCopied(true);
|
|
101
|
+
setTimeout(() => setCopied(false), 1500);
|
|
102
|
+
})
|
|
103
|
+
.catch(() => undefined);
|
|
104
|
+
}}
|
|
105
|
+
className="h-7 rounded-md border border-line bg-white px-2 text-xs"
|
|
106
|
+
>
|
|
107
|
+
{copied ? "복사함" : "복사"}
|
|
108
|
+
</button>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { MyOrderItem } from "@sayren/storefront-sdk";
|
|
2
|
+
import { useHydrated } from "@tanstack/react-router";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { type DownloadResult, formatFileSize } from "../lib/download";
|
|
5
|
+
import { formatDateTime } from "../lib/format";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 파일 다운로드 상품의 파일 — 남은 횟수·기한과 파일마다 「다운로드」 버튼을 보인다. 누르면 서명 주소(수명 300초)를 받아
|
|
9
|
+
* 브라우저가 바로 그 주소로 간다. 받을 때마다 횟수를 하나 쓴다. 파일 다운로드 상품이 아니거나 제공 전이면 그리지 않는다.
|
|
10
|
+
*
|
|
11
|
+
* `download`는 서버 함수를 부르는 호출부 몫이다 — 회원은 액세스 토큰, 비회원은 조회에 쓴 연락처·비밀번호로 확인한다.
|
|
12
|
+
*/
|
|
13
|
+
export function FulfillmentDownloads({
|
|
14
|
+
item,
|
|
15
|
+
download,
|
|
16
|
+
}: {
|
|
17
|
+
item: MyOrderItem;
|
|
18
|
+
download: (assetId: string) => Promise<DownloadResult>;
|
|
19
|
+
}) {
|
|
20
|
+
const hydrated = useHydrated();
|
|
21
|
+
const [pendingAssetId, setPendingAssetId] = useState<string | null>(null);
|
|
22
|
+
const [remaining, setRemaining] = useState<number | null>(null);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const info = item.fulfillment?.status === "FULFILLED" ? item.fulfillment.download : null;
|
|
25
|
+
if (item.fulfillmentType !== "DOWNLOAD" || !info) return null;
|
|
26
|
+
|
|
27
|
+
const left = remaining ?? Math.max(info.limit - info.count, 0);
|
|
28
|
+
const expired = info.expiresAt !== null && new Date(info.expiresAt).getTime() <= Date.now();
|
|
29
|
+
|
|
30
|
+
const start = async (assetId: string) => {
|
|
31
|
+
setPendingAssetId(assetId);
|
|
32
|
+
setError(null);
|
|
33
|
+
try {
|
|
34
|
+
const result = await download(assetId);
|
|
35
|
+
if (result.error !== null) {
|
|
36
|
+
setError(result.error);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setRemaining(result.remaining);
|
|
40
|
+
// 서명 주소는 곧 만료된다 — 저장하지 않고 바로 받는다
|
|
41
|
+
window.location.assign(result.url);
|
|
42
|
+
} catch {
|
|
43
|
+
setError("파일을 받지 못했습니다. 잠시 후 다시 시도해 주십시오");
|
|
44
|
+
} finally {
|
|
45
|
+
setPendingAssetId(null);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section aria-label="다운로드" className="space-y-2 rounded-md bg-chip p-3 text-xs">
|
|
51
|
+
<p className="font-semibold">다운로드</p>
|
|
52
|
+
<p className="text-muted">
|
|
53
|
+
남은 횟수 {left}/{info.limit}회
|
|
54
|
+
{info.expiresAt ? ` · 기한 ${formatDateTime(info.expiresAt)}` : ""}
|
|
55
|
+
</p>
|
|
56
|
+
{info.files.length === 0 ? (
|
|
57
|
+
<p className="text-muted">받을 파일이 없습니다.</p>
|
|
58
|
+
) : (
|
|
59
|
+
<ul className="space-y-1.5">
|
|
60
|
+
{info.files.map((file) => (
|
|
61
|
+
<li key={file.assetId} className="flex flex-wrap items-center gap-2">
|
|
62
|
+
<span className="break-all text-sm">{file.fileName}</span>
|
|
63
|
+
{file.sizeBytes !== null ? (
|
|
64
|
+
<span className="text-muted">{formatFileSize(file.sizeBytes)}</span>
|
|
65
|
+
) : null}
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
aria-label={`${file.fileName} 다운로드`}
|
|
69
|
+
disabled={!hydrated || pendingAssetId !== null || left <= 0 || expired}
|
|
70
|
+
onClick={() => void start(file.assetId)}
|
|
71
|
+
className="ml-auto h-8 rounded-md bg-ink px-3 font-semibold text-white disabled:opacity-50"
|
|
72
|
+
>
|
|
73
|
+
다운로드
|
|
74
|
+
</button>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ul>
|
|
78
|
+
)}
|
|
79
|
+
{left <= 0 ? <p className="text-muted">다운로드 횟수를 모두 사용했습니다.</p> : null}
|
|
80
|
+
{expired && left > 0 ? <p className="text-muted">다운로드 기간이 지났습니다.</p> : null}
|
|
81
|
+
{error ? (
|
|
82
|
+
<p role="alert" className="text-point">
|
|
83
|
+
{error}
|
|
84
|
+
</p>
|
|
85
|
+
) : null}
|
|
86
|
+
</section>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import type { UseFormRegisterReturn } from "react-hook-form";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* react-hook-form 입력 한 칸 — 라벨·필수 표시·안내·검증 오류를 함께 그린다.
|
|
6
|
+
*
|
|
7
|
+
* - 이름은 라벨 글자 그대로다(`aria-label`, 필수 표시·안내 문구는 빼고). 안내와 오류는 aria-describedby로 잇는다.
|
|
8
|
+
* - 프리필은 `defaultValue`로 준다. `register`는 값을 서버 HTML에 싣지 않는다.
|
|
9
|
+
* - 브라우저 필수 검사(`required`)를 그대로 둔다. 빈 칸은 브라우저가 먼저 막고, 형식은 zod 스키마가 안내한다.
|
|
10
|
+
*/
|
|
11
|
+
export function TextField({
|
|
12
|
+
label,
|
|
13
|
+
registration,
|
|
14
|
+
error,
|
|
15
|
+
type = "text",
|
|
16
|
+
defaultValue,
|
|
17
|
+
autoComplete,
|
|
18
|
+
placeholder,
|
|
19
|
+
required,
|
|
20
|
+
hint,
|
|
21
|
+
}: {
|
|
22
|
+
label: string;
|
|
23
|
+
registration: UseFormRegisterReturn;
|
|
24
|
+
error?: string;
|
|
25
|
+
type?: string;
|
|
26
|
+
defaultValue?: string;
|
|
27
|
+
autoComplete?: string;
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
required?: boolean;
|
|
30
|
+
hint?: string;
|
|
31
|
+
}) {
|
|
32
|
+
const hintId = useId();
|
|
33
|
+
const errorId = useId();
|
|
34
|
+
const describedBy = [hint ? hintId : null, error ? errorId : null].filter(Boolean).join(" ");
|
|
35
|
+
return (
|
|
36
|
+
<label className="block space-y-1">
|
|
37
|
+
<span className="text-sm">
|
|
38
|
+
{label}
|
|
39
|
+
{/* 필수 표시는 눈으로만 본다 — 입력의 required가 보조 기술에 필수임을 알린다 */}
|
|
40
|
+
{required ? (
|
|
41
|
+
<span aria-hidden="true" className="text-point">
|
|
42
|
+
{" "}
|
|
43
|
+
*
|
|
44
|
+
</span>
|
|
45
|
+
) : null}
|
|
46
|
+
</span>
|
|
47
|
+
<input
|
|
48
|
+
{...registration}
|
|
49
|
+
type={type}
|
|
50
|
+
defaultValue={defaultValue}
|
|
51
|
+
autoComplete={autoComplete}
|
|
52
|
+
placeholder={placeholder}
|
|
53
|
+
required={required}
|
|
54
|
+
aria-label={label}
|
|
55
|
+
aria-invalid={error ? true : undefined}
|
|
56
|
+
aria-describedby={describedBy || undefined}
|
|
57
|
+
className={`h-11 w-full rounded-md border px-3 ${error ? "border-point" : "border-line"}`}
|
|
58
|
+
/>
|
|
59
|
+
{hint ? (
|
|
60
|
+
<span id={hintId} className="block text-muted text-xs">
|
|
61
|
+
{hint}
|
|
62
|
+
</span>
|
|
63
|
+
) : null}
|
|
64
|
+
{error ? (
|
|
65
|
+
<span id={errorId} className="block text-point text-xs">
|
|
66
|
+
{error}
|
|
67
|
+
</span>
|
|
68
|
+
) : null}
|
|
69
|
+
</label>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { productSortSchema } from "@sayren/storefront-sdk";
|
|
2
|
+
import { queryOptions } from "@tanstack/react-query";
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { apiFor } from "./api.server";
|
|
6
|
+
import type { ProductsSearch } from "./products-search";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 카탈로그 쿼리 — TanStack Query의 queryOptions 팩토리다. 라우트 loader(`ensureQueryData`)와 화면
|
|
10
|
+
* (`useSuspenseQuery`)이 같은 팩토리를 써서 queryKey가 갈라지지 않는다.
|
|
11
|
+
*
|
|
12
|
+
* 데이터는 서버 함수로 받는다. 첫 요청은 서버가 받아 HTML에 실어 보내고(SSR 하이드레이션), 이후 이동은 브라우저가
|
|
13
|
+
* 같은 서버 함수를 부른다. 한 번 받은 조건은 캐시에서 바로 그려 뒤로가기가 빠르다.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const PRODUCTS_PAGE_SIZE = 24;
|
|
17
|
+
|
|
18
|
+
const searchProductsInput = z.object({
|
|
19
|
+
keyword: z.string().optional(),
|
|
20
|
+
categoryId: z.string().optional(),
|
|
21
|
+
sort: productSortSchema.optional(),
|
|
22
|
+
page: z.number().int().min(1).optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const searchProducts = createServerFn({ method: "GET" })
|
|
26
|
+
.validator(searchProductsInput)
|
|
27
|
+
.handler(({ data }) =>
|
|
28
|
+
apiFor().catalog.searchProducts({ ...data, page: data.page ?? 1, size: PRODUCTS_PAGE_SIZE }),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const listCategories = createServerFn({ method: "GET" }).handler(() =>
|
|
32
|
+
apiFor().catalog.listCategories(),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/** 카테고리는 자주 바뀌지 않는다 — 화면을 옮겨도 5분 동안 다시 받지 않는다 */
|
|
36
|
+
export const categoriesQuery = () =>
|
|
37
|
+
queryOptions({
|
|
38
|
+
queryKey: ["catalog", "categories"],
|
|
39
|
+
queryFn: () => listCategories(),
|
|
40
|
+
staleTime: 5 * 60_000,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const productsQuery = (search: ProductsSearch) =>
|
|
44
|
+
queryOptions({
|
|
45
|
+
queryKey: ["catalog", "products", search],
|
|
46
|
+
queryFn: () => searchProducts({ data: search }),
|
|
47
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ApiError } from "@sayren/storefront-sdk";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { codeRevealErrorMessage, normalizePhone } from "./code-reveal";
|
|
4
|
+
|
|
5
|
+
describe("codeRevealErrorMessage", () => {
|
|
6
|
+
it("열람할 코드가 없으면(404) 주문 상태를 확인하게 한다", () => {
|
|
7
|
+
expect(codeRevealErrorMessage(new ApiError(404, "FULFILLMENT_NOT_FOUND", "x"))).toBe(
|
|
8
|
+
"열람할 코드가 없습니다. 주문 상태를 확인해 주십시오",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("시도 한도(429)는 남은 시간을 알린다", () => {
|
|
13
|
+
const error = new ApiError(429, "TOO_MANY_REQUESTS", "x", undefined, undefined, {
|
|
14
|
+
retryAfterSeconds: 600,
|
|
15
|
+
});
|
|
16
|
+
expect(codeRevealErrorMessage(error)).toContain("10분 뒤");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("그 밖의 실패는 다시 시도하게 한다", () => {
|
|
20
|
+
expect(codeRevealErrorMessage(new Error("network"))).toBe(
|
|
21
|
+
"코드를 불러오지 못했습니다. 잠시 후 다시 시도해 주십시오",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("normalizePhone", () => {
|
|
27
|
+
it("숫자만 남긴다", () => {
|
|
28
|
+
expect(normalizePhone("010-1234 5678")).toBe("01012345678");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ApiError, type FulfillmentReveal } from "@sayren/storefront-sdk";
|
|
2
|
+
import { guestLookupLockedMessage } from "./guest-order";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 코드 발급 상품의 코드 열람 (sayren 이슈 #48).
|
|
6
|
+
*
|
|
7
|
+
* 주문 상세의 코드는 가린 값(`hint`)만 온다. 평문은 구매자가 「코드 보기」를 누를 때 서버 함수가
|
|
8
|
+
* `myOrders.revealCode`(회원)·`myOrders.revealGuestCode`(비회원)로 받아 그 화면에만 보인다. 평문은
|
|
9
|
+
* 주소·스토리지·쿠키에 두지 않는다. 처음 연 시각이 기록되고 셀러가 청약철회를 판단하는 근거가 된다.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 서버 함수 결과 — ApiError는 서버 함수 경계를 넘지 못하므로 문구로 바꿔 돌려준다 */
|
|
13
|
+
export type CodeRevealResult =
|
|
14
|
+
| { codes: FulfillmentReveal["codes"]; error: null }
|
|
15
|
+
| { codes: null; error: string };
|
|
16
|
+
|
|
17
|
+
/** 코드 열람 실패 → 구매자에게 보일 문구 */
|
|
18
|
+
export function codeRevealErrorMessage(error: unknown): string {
|
|
19
|
+
if (error instanceof ApiError) {
|
|
20
|
+
if (error.status === 404) {
|
|
21
|
+
return "열람할 코드가 없습니다. 주문 상태를 확인해 주십시오";
|
|
22
|
+
}
|
|
23
|
+
// 비회원 열람은 비회원 주문 조회와 같은 시도 한도를 쓴다
|
|
24
|
+
if (error.status === 429) return guestLookupLockedMessage(error.details?.retryAfterSeconds);
|
|
25
|
+
}
|
|
26
|
+
return "코드를 불러오지 못했습니다. 잠시 후 다시 시도해 주십시오";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 비회원 열람에 싣는 연락처 — 조회 화면과 같게 숫자만 남긴다 */
|
|
30
|
+
export function normalizePhone(phone: string): string {
|
|
31
|
+
return phone.replace(/[^0-9]/g, "");
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { COUPON_CODE_PATTERN, couponErrorMessage, couponRejectMessage } from "./coupon-code";
|
|
3
|
+
|
|
4
|
+
describe("쿠폰 코드", () => {
|
|
5
|
+
it("형식은 영문·숫자·-·_ 6~30자다", () => {
|
|
6
|
+
expect(COUPON_CODE_PATTERN.test("FALL-10")).toBe(true);
|
|
7
|
+
expect(COUPON_CODE_PATTERN.test("fall_2026")).toBe(true);
|
|
8
|
+
expect(COUPON_CODE_PATTERN.test("ABC")).toBe(false);
|
|
9
|
+
expect(COUPON_CODE_PATTERN.test("가을쿠폰코드")).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("오류 코드와 적용하지 못한 사유를 안내 문구로 바꾸고 모르는 값은 일반 안내다", () => {
|
|
13
|
+
expect(couponErrorMessage("COUPON_NOT_FOUND")).toContain("찾을 수 없습니다");
|
|
14
|
+
expect(couponErrorMessage("SOMETHING_NEW")).toContain("적용하지 못했습니다");
|
|
15
|
+
expect(couponRejectMessage("MIN_ORDER_AMOUNT")).toContain("최소 주문 금액");
|
|
16
|
+
expect(couponRejectMessage("NEW_REASON")).toBe("이 주문에는 쓸 수 없는 쿠폰입니다.");
|
|
17
|
+
expect(couponRejectMessage(null)).toBe("이 주문에는 쓸 수 없는 쿠폰입니다.");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { CouponResult } from "@sayren/storefront-sdk";
|
|
2
|
+
|
|
3
|
+
/** 적용한 쿠폰 코드 — 금액 미리보기가 적용을 확인한 값이다. 결제 시작이 같은 코드를 `coupons`로 보낸다 */
|
|
4
|
+
export interface AppliedCoupon {
|
|
5
|
+
code: string;
|
|
6
|
+
name: string;
|
|
7
|
+
discountAmount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 쿠폰 코드 형식 — 영문·숫자·`-`·`_` 6~30자. 서버가 다시 검사한다 */
|
|
11
|
+
export const COUPON_CODE_PATTERN = /^[A-Za-z0-9_-]{6,30}$/;
|
|
12
|
+
|
|
13
|
+
/** 쿠폰 API 오류 코드 → 안내 문구. 모르는 코드는 일반 안내다 */
|
|
14
|
+
export function couponErrorMessage(code: string | null): string {
|
|
15
|
+
switch (code) {
|
|
16
|
+
case "COUPON_NOT_FOUND":
|
|
17
|
+
return "쿠폰 코드를 찾을 수 없습니다. 코드를 다시 확인해 주십시오.";
|
|
18
|
+
case "TOO_MANY_REQUESTS":
|
|
19
|
+
return "쿠폰 코드를 너무 여러 번 입력했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
20
|
+
case "COUPONS_UNAVAILABLE":
|
|
21
|
+
return "지금은 쿠폰을 쓸 수 없습니다. 쿠폰 없이 결제할 수 있습니다.";
|
|
22
|
+
case "COUPON_IN_USE":
|
|
23
|
+
return "다른 결제에서 쓰고 있는 쿠폰입니다. 그 결제가 끝나면 다시 쓸 수 있습니다.";
|
|
24
|
+
case "COUPON_LIMIT_REACHED":
|
|
25
|
+
return "이 쿠폰을 쓸 수 있는 횟수를 모두 썼습니다. 진행 중인 결제가 있다면 그 결제가 끝나거나 10분이 지나면 다시 쓸 수 있습니다.";
|
|
26
|
+
case "COUPON_EXHAUSTED":
|
|
27
|
+
return "쿠폰이 모두 소진됐습니다. 진행 중인 결제가 있다면 그 결제가 끝나거나 10분이 지나면 다시 쓸 수 있습니다.";
|
|
28
|
+
case "COUPON_CHANGED":
|
|
29
|
+
return "쿠폰 조건이 바뀌었습니다. 쿠폰을 다시 적용해 주십시오.";
|
|
30
|
+
case "PAYMENT_AMOUNT_TOO_LOW":
|
|
31
|
+
return "쿠폰을 적용하면 결제 금액이 너무 적어 결제할 수 없습니다.";
|
|
32
|
+
default:
|
|
33
|
+
return "쿠폰을 적용하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 적용하지 못한 사유 → 안내 문구. 사유는 늘 수 있어 모르는 값은 일반 안내다 */
|
|
38
|
+
export function couponRejectMessage(reason: CouponResult["rejectReason"]): string {
|
|
39
|
+
switch (reason) {
|
|
40
|
+
case "MIN_ORDER_AMOUNT":
|
|
41
|
+
return "최소 주문 금액에 못 미쳐 쓸 수 없는 쿠폰입니다.";
|
|
42
|
+
case "NO_ELIGIBLE_ITEMS":
|
|
43
|
+
case "LINE_NOT_ELIGIBLE":
|
|
44
|
+
return "주문한 상품에는 쓸 수 없는 쿠폰입니다.";
|
|
45
|
+
case "MEMBER_ONLY":
|
|
46
|
+
return "회원 전용 쿠폰입니다. 로그인한 뒤 써 주십시오.";
|
|
47
|
+
case "NOT_STARTED":
|
|
48
|
+
return "아직 사용 기간이 아닌 쿠폰입니다.";
|
|
49
|
+
case "EXPIRED":
|
|
50
|
+
return "사용 기간이 지난 쿠폰입니다.";
|
|
51
|
+
case "IN_USE":
|
|
52
|
+
return "다른 결제에서 쓰고 있는 쿠폰입니다.";
|
|
53
|
+
case "LIMIT_REACHED":
|
|
54
|
+
return "이 쿠폰을 쓸 수 있는 횟수를 모두 썼습니다.";
|
|
55
|
+
case "EXHAUSTED":
|
|
56
|
+
return "쿠폰이 모두 소진됐습니다.";
|
|
57
|
+
default:
|
|
58
|
+
return "이 주문에는 쓸 수 없는 쿠폰입니다.";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ApiError } from "@sayren/storefront-sdk";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { downloadErrorMessage, formatFileSize } from "./download";
|
|
4
|
+
|
|
5
|
+
describe("downloadErrorMessage", () => {
|
|
6
|
+
it("횟수를 다 썼거나 기간이 지나면 그대로 알린다", () => {
|
|
7
|
+
expect(downloadErrorMessage(new ApiError(409, "DOWNLOAD_LIMIT_REACHED", "x"))).toBe(
|
|
8
|
+
"다운로드 횟수를 모두 사용했습니다",
|
|
9
|
+
);
|
|
10
|
+
expect(downloadErrorMessage(new ApiError(409, "DOWNLOAD_EXPIRED", "x"))).toBe(
|
|
11
|
+
"다운로드 기간이 지났습니다",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("받을 파일이 없으면(404) 주문 상태를 확인하게 한다", () => {
|
|
16
|
+
expect(downloadErrorMessage(new ApiError(404, "FULFILLMENT_NOT_FOUND", "x"))).toBe(
|
|
17
|
+
"받을 파일이 없습니다. 주문 상태를 확인해 주십시오",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("시도 한도(429)는 남은 시간을 알린다", () => {
|
|
22
|
+
const error = new ApiError(429, "TOO_MANY_REQUESTS", "x", undefined, undefined, {
|
|
23
|
+
retryAfterSeconds: 600,
|
|
24
|
+
});
|
|
25
|
+
expect(downloadErrorMessage(error)).toContain("10분 뒤");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("그 밖의 실패는 다시 시도하게 한다", () => {
|
|
29
|
+
expect(downloadErrorMessage(new Error("network"))).toBe(
|
|
30
|
+
"파일을 받지 못했습니다. 잠시 후 다시 시도해 주십시오",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("formatFileSize", () => {
|
|
36
|
+
it("1024 단위로 줄이고 모르면 빈 문자열이다", () => {
|
|
37
|
+
expect(formatFileSize(512)).toBe("512B");
|
|
38
|
+
expect(formatFileSize(1536)).toBe("1.5KB");
|
|
39
|
+
expect(formatFileSize(null)).toBe("");
|
|
40
|
+
});
|
|
41
|
+
});
|