@sayren/mcp 0.8.0 → 0.10.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 +9 -5
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +5 -1
- package/template/src/components/fulfillment-codes.tsx +1 -1
- package/template/src/components/fulfillment-downloads.tsx +1 -1
- package/template/src/components/site-header.tsx +1 -1
- package/template/src/lib/cart-session.server.ts +4 -10
- package/template/src/lib/cookies.server.test.ts +54 -0
- package/template/src/lib/cookies.server.ts +35 -0
- package/template/src/lib/order-status.test.ts +25 -10
- package/template/src/lib/order-status.ts +19 -6
- package/template/src/lib/session.server.ts +9 -10
- package/template/src/lib/social-login.server.ts +4 -10
- package/template/src/routes/__root.tsx +2 -2
- package/template/src/routes/guest-order.tsx +1 -1
- package/template/src/routes/orders.$orderId.tsx +5 -2
- package/template/src/routes/products.$productId.tsx +23 -7
- package/template/src/styles.css +5 -11
- package/template/src/theme.css +17 -0
package/dist/index.mjs
CHANGED
|
@@ -749,10 +749,10 @@ const RULES = [
|
|
|
749
749
|
id: "buyer-token-cookie",
|
|
750
750
|
title: "구매자 토큰 쌍은 서버에서 HttpOnly 쿠키에 담는다",
|
|
751
751
|
why: "브라우저 JS나 `localStorage`에 둔 토큰은 스크립트 하나로 새 나간다. 리프레시 토큰이 새면 그 구매자 계정으로 계속 로그인된다",
|
|
752
|
-
fix: "로그인·가입·소셜 콜백을 서버 함수·서버 라우트에서 처리하고 받은 토큰 쌍을 `httpOnly` 쿠키 하나에 담는다. 브라우저 저장소에 토큰을 쓰지 않고, 인증 모듈(`@sayren/storefront-sdk/auth`)은 `*.server.ts`에서만
|
|
753
|
-
example: "
|
|
752
|
+
fix: "로그인·가입·소셜 콜백을 서버 함수·서버 라우트에서 처리하고 받은 토큰 쌍을 `httpOnly` 쿠키 하나에 담는다. 브라우저 저장소에 토큰을 쓰지 않고, 인증 모듈(`@sayren/storefront-sdk/auth`)은 `*.server.ts`에서만 만든다. 프로덕션에서는 쿠키 이름에 `__Host-` 접두사를 붙이고 `secure`·`path: \"/\"`로 쓴다(`Domain`은 주지 않는다). 같은 상위 도메인의 다른 사이트가 심은 쿠키로 세션이 바뀌지 않는다",
|
|
753
|
+
example: "// 프로덕션은 __Host- 접두사 — Secure·Path=/·Domain 없음일 때만 브라우저가 받는다\nconst name = import.meta.env.PROD ? \"__Host-sayren_member\" : \"sayren_member\";\nsetCookie(name, JSON.stringify({ accessToken, refreshToken, expiresAt }), {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n secure: import.meta.env.PROD,\n});",
|
|
754
754
|
doc: "/guides/storefront-auth",
|
|
755
|
-
templateFile: "src/lib/
|
|
755
|
+
templateFile: "src/lib/cookies.server.ts"
|
|
756
756
|
},
|
|
757
757
|
{
|
|
758
758
|
id: "buyer-session-refresh",
|
|
@@ -870,6 +870,8 @@ function verifySources(files) {
|
|
|
870
870
|
if (BROWSER_TOKEN_STORAGE.test(file.content)) inFile("buyer-token-cookie", file, "토큰을 브라우저 저장소(localStorage·sessionStorage)에 쓴다. 서버에서 HttpOnly 쿠키에 담는다", BROWSER_TOKEN_STORAGE);
|
|
871
871
|
const cookieWrite = /setCookie\(/;
|
|
872
872
|
if (cookieWrite.test(file.content) && /refreshToken/.test(file.content) && !/httpOnly/.test(file.content)) inFile("buyer-token-cookie", file, "토큰 쌍을 쿠키에 담는데 `httpOnly`가 없다. 브라우저 JS가 읽을 수 있다", cookieWrite);
|
|
873
|
+
const cookieDomain = /\bdomain\s*:/;
|
|
874
|
+
if (cookieWrite.test(file.content) && cookieDomain.test(file.content)) inFile("buyer-token-cookie", file, "서버 쿠키에 `domain`을 준다. 호스트 전용 쿠키로 두고(`domain` 생략) 프로덕션은 `__Host-` 접두사를 붙인다", cookieDomain);
|
|
873
875
|
const analyticsFactory = /createAnalytics\(/;
|
|
874
876
|
if (isServer && analyticsFactory.test(file.content)) inFile("analytics-start", file, "서버 파일에서 방문 분석을 시작한다. 서버에서는 아무것도 하지 않으니 브라우저(effect)에서 부른다", analyticsFactory);
|
|
875
877
|
if (isServer && /createStorefrontClient\(/.test(file.content) && !/analyticsIdsFromCookie|visitorId/.test(file.content)) inFile("analytics-ids", file, "서버 API 클라이언트에 방문 식별자가 없다. `...analyticsIdsFromCookie(request.headers.get(\"cookie\"))`를 넘긴다", /createStorefrontClient\(/);
|
|
@@ -885,6 +887,8 @@ function verifySources(files) {
|
|
|
885
887
|
if (has(".result(") && !has("PROCESSING")) inProject("payment-processing-status", "결과 확인 중(`PROCESSING`)일 때 결제 상태를 다시 조회하는 코드가 없다");
|
|
886
888
|
if (has("startPayment(") && has("zipCode") && !has("quoteDelivery(")) inProject("delivery-quote-preview", "배송지 우편번호를 받지만 `checkout.quoteDelivery`로 배송비를 다시 계산하는 코드가 없다");
|
|
887
889
|
if (has("createStorefrontAuth(")) {
|
|
890
|
+
const cookieFiles = cleaned.filter((file) => /setCookie\(/.test(file.content));
|
|
891
|
+
if (cookieFiles.length > 0 && !cookieFiles.some((file) => /httpOnly\s*:\s*true/.test(file.content))) inProject("buyer-token-cookie", "구매자 로그인을 쓰는데 서버 쿠키를 `httpOnly: true`로 쓰는 코드가 없다. 토큰 쌍이 브라우저 JS에 노출된다");
|
|
888
892
|
if (!has(".refresh(")) inProject("buyer-session-refresh", "로그인은 하지만 액세스 토큰을 갱신하는 코드가 없다(`auth.refresh(refreshToken)`)");
|
|
889
893
|
if (has(".session(") && !has(".signUp(")) inProject("buyer-signup", "이메일·비밀번호 로그인(`auth.session`)은 있지만 가입(`auth.signUp`)을 부르는 코드가 없다");
|
|
890
894
|
if (!matches(/\b(auth|authFor\(\))\.signOut\(|\.signOut\(\s*accessToken\b/)) inProject("buyer-signout", "로그아웃에서 리프레시 토큰을 폐기하는 코드가 없다(`auth.signOut(accessToken)`)");
|
|
@@ -1111,8 +1115,8 @@ server.registerTool("get_scaffold_plan", {
|
|
|
1111
1115
|
},
|
|
1112
1116
|
extensionPoints: [
|
|
1113
1117
|
{
|
|
1114
|
-
what: "브랜드
|
|
1115
|
-
where: "src/
|
|
1118
|
+
what: "브랜드 색·서체·본문 폭",
|
|
1119
|
+
where: "src/theme.css의 @theme"
|
|
1116
1120
|
},
|
|
1117
1121
|
{
|
|
1118
1122
|
what: "헤더·전역 내비",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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/
|
|
16
|
-
"@sayren/
|
|
15
|
+
"@sayren/storefront-sdk": "^0.15.0",
|
|
16
|
+
"@sayren/store-sdk": "^0.19.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
package/template/README.md
CHANGED
|
@@ -97,7 +97,7 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
97
97
|
|
|
98
98
|
| 자리 | 파일 |
|
|
99
99
|
| --- | --- |
|
|
100
|
-
| 브랜드
|
|
100
|
+
| 브랜드 색·서체·본문 폭 | `src/theme.css`의 `@theme` |
|
|
101
101
|
| 헤더·전역 내비 | `src/components/site-header.tsx` |
|
|
102
102
|
| 파비콘 | `public/favicon.svg` |
|
|
103
103
|
| 상품 카드 | `src/components/product-card.tsx` |
|
|
@@ -127,6 +127,10 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
127
127
|
- `src/lib/session.server.ts`·`src/start.ts` — 구매자 세션 쿠키와 갱신입니다. 갱신은 요청마다 한 번이고, 같은
|
|
128
128
|
리프레시 토큰의 동시 갱신은 한 번으로 모읍니다. 리프레시 토큰은 한 번 쓰면 폐기되므로 각자 갱신하면 로그아웃됩니다.
|
|
129
129
|
로그아웃은 서버에서 `auth.signOut()`으로 리프레시 토큰을 폐기한 뒤 쿠키를 지웁니다.
|
|
130
|
+
- `src/lib/cookies.server.ts` — 서버 쿠키의 이름과 속성입니다. 프로덕션 빌드는 이름에 `__Host-` 접두사를 붙여
|
|
131
|
+
같은 상위 도메인의 다른 사이트가 심은 쿠키로 세션이 바뀌지 않게 합니다. 접두사를 빼거나 `Domain`을 주지 않습니다.
|
|
132
|
+
그래서 빌드 결과는 https(또는 localhost)에서만 로그인·장바구니가 유지됩니다. Safari는 http://localhost의 Secure 쿠키도
|
|
133
|
+
받지 않으므로 `pnpm start`로 확인할 때는 Chrome·Firefox를 쓰거나 https로 띄웁니다.
|
|
130
134
|
- loader가 서버 함수로 데이터를 받아 그리는 구조 — 컴포넌트에서 다시 받으면 검색 노출과 첫 화면이 죽습니다.
|
|
131
135
|
TanStack Query를 쓰는 화면도 loader의 `ensureQueryData`를 지우지 않습니다.
|
|
132
136
|
- 결제 도메인 — 셀러 콘솔 설정 › 결제의 **결제 도메인**은 선택형 허용 목록입니다. 비워 두면 https 복귀 주소를 모두 받고,
|
|
@@ -24,7 +24,7 @@ export function FulfillmentCodes({
|
|
|
24
24
|
const [pending, setPending] = useState(false);
|
|
25
25
|
const [error, setError] = useState<string | null>(null);
|
|
26
26
|
const codes = item.fulfillment?.status === "FULFILLED" ? (item.fulfillment.codes ?? []) : [];
|
|
27
|
-
if (item.
|
|
27
|
+
if (item.fulfillmentSnapshot.type !== "CODE" || codes.length === 0) return null;
|
|
28
28
|
|
|
29
29
|
const open = async () => {
|
|
30
30
|
setPending(true);
|
|
@@ -22,7 +22,7 @@ export function FulfillmentDownloads({
|
|
|
22
22
|
const [remaining, setRemaining] = useState<number | null>(null);
|
|
23
23
|
const [error, setError] = useState<string | null>(null);
|
|
24
24
|
const info = item.fulfillment?.status === "FULFILLED" ? item.fulfillment.download : null;
|
|
25
|
-
if (item.
|
|
25
|
+
if (item.fulfillmentSnapshot.type !== "DOWNLOAD" || !info) return null;
|
|
26
26
|
|
|
27
27
|
const left = remaining ?? Math.max(info.limit - info.count, 0);
|
|
28
28
|
const expired = info.expiresAt !== null && new Date(info.expiresAt).getTime() <= Date.now();
|
|
@@ -14,7 +14,7 @@ export function SiteHeader({ store }: { store: StoreBrand | null }) {
|
|
|
14
14
|
const name = store?.name ?? "스토어";
|
|
15
15
|
return (
|
|
16
16
|
<header className="border-line border-b">
|
|
17
|
-
<div className="mx-auto flex w-full max-w-
|
|
17
|
+
<div className="mx-auto flex w-full max-w-page flex-wrap items-center justify-between gap-3 px-4 py-4">
|
|
18
18
|
<Link to="/" className="flex items-center gap-2 font-bold text-lg">
|
|
19
19
|
{store?.logoUrl ? <img src={store.logoUrl} alt={name} className="h-8 w-auto" /> : name}
|
|
20
20
|
</Link>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clearServerCookie, readServerCookie, writeServerCookie } from "./cookies.server";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 비회원 장바구니 토큰 — 서버가 `X-Cart-Token` 헤더로 발급한다. 회원은 액세스 토큰으로
|
|
@@ -9,20 +9,14 @@ import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server
|
|
|
9
9
|
const CART_COOKIE = "sayren_cart";
|
|
10
10
|
|
|
11
11
|
export function readCartToken(): string | null {
|
|
12
|
-
return
|
|
12
|
+
return readServerCookie(CART_COOKIE) || null;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function writeCartToken(token: string) {
|
|
16
|
-
|
|
17
|
-
httpOnly: true,
|
|
18
|
-
sameSite: "lax",
|
|
19
|
-
path: "/",
|
|
20
|
-
secure: process.env.NODE_ENV === "production",
|
|
21
|
-
maxAge: 60 * 60 * 24 * 30,
|
|
22
|
-
});
|
|
16
|
+
writeServerCookie(CART_COOKIE, token, 60 * 60 * 24 * 30);
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
/** 로그인하며 회원 장바구니로 합쳐진 뒤 지운다 */
|
|
26
20
|
export function clearCartToken() {
|
|
27
|
-
|
|
21
|
+
clearServerCookie(CART_COOKIE);
|
|
28
22
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const calls = vi.hoisted(() => ({
|
|
4
|
+
set: [] as unknown[][],
|
|
5
|
+
get: [] as unknown[][],
|
|
6
|
+
delete: [] as unknown[][],
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("@tanstack/react-start/server", () => ({
|
|
10
|
+
setCookie: (...args: unknown[]) => calls.set.push(args),
|
|
11
|
+
getCookie: (...args: unknown[]) => {
|
|
12
|
+
calls.get.push(args);
|
|
13
|
+
return "v";
|
|
14
|
+
},
|
|
15
|
+
deleteCookie: (...args: unknown[]) => calls.delete.push(args),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
async function load(prod: boolean) {
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
vi.stubEnv("PROD", prod);
|
|
21
|
+
return import("./cookies.server");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.unstubAllEnvs();
|
|
26
|
+
calls.set.length = 0;
|
|
27
|
+
calls.get.length = 0;
|
|
28
|
+
calls.delete.length = 0;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("서버 쿠키", () => {
|
|
32
|
+
it("프로덕션은 __Host- 접두사 + Secure·Path=/·Domain 없음으로 쓰고 지운다", async () => {
|
|
33
|
+
const cookies = await load(true);
|
|
34
|
+
cookies.writeServerCookie("sayren_member", "x", 60);
|
|
35
|
+
cookies.readServerCookie("sayren_member");
|
|
36
|
+
cookies.clearServerCookie("sayren_member");
|
|
37
|
+
expect(calls.set[0]).toEqual([
|
|
38
|
+
"__Host-sayren_member",
|
|
39
|
+
"x",
|
|
40
|
+
{ httpOnly: true, sameSite: "lax", path: "/", secure: true, maxAge: 60 },
|
|
41
|
+
]);
|
|
42
|
+
expect(calls.get[0]).toEqual(["__Host-sayren_member"]);
|
|
43
|
+
// 옛 이름은 읽지 않는다 — 읽으면 다른 사이트가 심은 쿠키가 다시 통한다
|
|
44
|
+
expect(calls.get).toHaveLength(1);
|
|
45
|
+
expect(calls.delete[0]).toEqual(["__Host-sayren_member", { path: "/", secure: true }]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("개발 서버(http)는 접두사 없이 Secure 없이 쓴다", async () => {
|
|
49
|
+
const cookies = await load(false);
|
|
50
|
+
cookies.writeServerCookie("sayren_cart", "t", 10);
|
|
51
|
+
expect(calls.set[0]?.[0]).toBe("sayren_cart");
|
|
52
|
+
expect(calls.set[0]?.[2]).toMatchObject({ httpOnly: true, secure: false, path: "/" });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 서버가 쓰는 쿠키(구매자 세션·비회원 장바구니·소셜 로그인 왕복)의 이름과 속성을 한 곳에서 정한다.
|
|
5
|
+
*
|
|
6
|
+
* 프로덕션 빌드는 이름에 `__Host-` 접두사를 붙인다. 브라우저는 이 접두사의 쿠키를 `Secure` + `Path=/` +
|
|
7
|
+
* `Domain` 없음일 때만 받는다. 그래서 같은 상위 도메인을 쓰는 다른 사이트(예: `other.example.com`)가
|
|
8
|
+
* `Domain=example.com`으로 심은 쿠키가 이 이름으로 들어오지 못한다. 남이 심은 세션·로그인 상태로
|
|
9
|
+
* 구매자를 로그인시키는 쿠키 주입을 막는 장치다. 접두사 없는 옛 이름은 읽지 않는다
|
|
10
|
+
* (읽으면 주입된 쿠키가 다시 통한다). 옛 이름으로 로그인해 있던 구매자는 한 번 다시 로그인한다.
|
|
11
|
+
*
|
|
12
|
+
* 개발 서버(http)는 `Secure` 쿠키를 받지 못하는 브라우저가 있어 접두사 없이 쓴다.
|
|
13
|
+
*/
|
|
14
|
+
const SECURE = import.meta.env.PROD;
|
|
15
|
+
|
|
16
|
+
export const cookieName = (base: string): string => (SECURE ? `__Host-${base}` : base);
|
|
17
|
+
|
|
18
|
+
export function readServerCookie(base: string): string | undefined {
|
|
19
|
+
return getCookie(cookieName(base));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeServerCookie(base: string, value: string, maxAgeSec: number) {
|
|
23
|
+
setCookie(cookieName(base), value, {
|
|
24
|
+
httpOnly: true,
|
|
25
|
+
sameSite: "lax",
|
|
26
|
+
path: "/",
|
|
27
|
+
secure: SECURE,
|
|
28
|
+
maxAge: maxAgeSec,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 지울 때도 같은 속성을 준다 — `__Host-` 쿠키는 `Secure`·`Path=/`가 없으면 삭제 헤더도 거부된다 */
|
|
33
|
+
export function clearServerCookie(base: string) {
|
|
34
|
+
deleteCookie(cookieName(base), { path: "/", secure: SECURE });
|
|
35
|
+
}
|
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { orderItemStatusLabel, withdrawalRestrictedLabel } from "./order-status";
|
|
3
3
|
|
|
4
|
+
const SHIPPING = { type: "SHIPPING", requiresShipping: true } as const;
|
|
5
|
+
const SERVICE = { type: "SERVICE", requiresShipping: false } as const;
|
|
6
|
+
const PICKUP = { type: "PICKUP", requiresShipping: false } as const;
|
|
7
|
+
|
|
4
8
|
describe("주문 상품 상태 라벨", () => {
|
|
5
9
|
it("배송 상품은 배송 단계로 보인다", () => {
|
|
6
10
|
expect(orderItemStatusLabel("DELIVERED")).toBe("배송 완료");
|
|
7
|
-
expect(orderItemStatusLabel("DELIVERED",
|
|
8
|
-
expect(orderItemStatusLabel("CONFIRMED",
|
|
11
|
+
expect(orderItemStatusLabel("DELIVERED", SHIPPING)).toBe("배송 완료");
|
|
12
|
+
expect(orderItemStatusLabel("CONFIRMED", SHIPPING)).toBe("발송 준비");
|
|
9
13
|
});
|
|
10
14
|
|
|
11
15
|
it("배송이 없는 상품의 DELIVERED는 제공 완료, 방문 수령은 수령 완료다", () => {
|
|
12
|
-
expect(orderItemStatusLabel("DELIVERED",
|
|
13
|
-
expect(orderItemStatusLabel("DELIVERED",
|
|
14
|
-
expect(orderItemStatusLabel("CONFIRMED",
|
|
15
|
-
expect(orderItemStatusLabel("PAID",
|
|
16
|
+
expect(orderItemStatusLabel("DELIVERED", SERVICE)).toBe("제공 완료");
|
|
17
|
+
expect(orderItemStatusLabel("DELIVERED", PICKUP)).toBe("수령 완료");
|
|
18
|
+
expect(orderItemStatusLabel("CONFIRMED", SERVICE)).toBe("준비 중");
|
|
19
|
+
expect(orderItemStatusLabel("PAID", PICKUP)).toBe("결제 완료");
|
|
20
|
+
// 모르는 유형도 배송 여부로 가른다
|
|
21
|
+
expect(orderItemStatusLabel("DELIVERED", { type: "OTHER", requiresShipping: false })).toBe(
|
|
22
|
+
"제공 완료",
|
|
23
|
+
);
|
|
16
24
|
});
|
|
17
25
|
|
|
18
26
|
it("모르는 상태는 코드 그대로다", () => {
|
|
19
|
-
expect(orderItemStatusLabel("ON_HOLD",
|
|
27
|
+
expect(orderItemStatusLabel("ON_HOLD", SERVICE)).toBe("ON_HOLD");
|
|
20
28
|
});
|
|
21
29
|
});
|
|
22
30
|
|
|
23
31
|
describe("청약철회 제한 표시", () => {
|
|
24
32
|
it("제한 주문상품에만 라벨을 붙인다", () => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
const download = (withdrawalRestricted: boolean) => ({
|
|
34
|
+
fulfillmentSnapshot: {
|
|
35
|
+
type: "DOWNLOAD" as const,
|
|
36
|
+
requiresShipping: false as const,
|
|
37
|
+
download: { withdrawalRestricted },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
expect(withdrawalRestrictedLabel(download(true))).toBe("청약철회 제한 상품");
|
|
41
|
+
expect(withdrawalRestrictedLabel(download(false))).toBeNull();
|
|
42
|
+
expect(withdrawalRestrictedLabel({ fulfillmentSnapshot: SERVICE })).toBeNull();
|
|
28
43
|
});
|
|
29
44
|
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type MyOrderItemFulfillmentSnapshot,
|
|
3
|
+
type OrderItemStatus,
|
|
4
|
+
withdrawalRestrictedOfView,
|
|
5
|
+
} from "@sayren/storefront-sdk";
|
|
2
6
|
|
|
3
7
|
/** 주문 상품 상태 라벨 — 화면에 코드 대신 보인다. 서버가 상태를 더하면 코드 그대로 보인다 */
|
|
4
8
|
const ORDER_ITEM_STATUS_LABELS: Record<OrderItemStatus, string> = {
|
|
@@ -22,9 +26,16 @@ function nonShippingLabel(status: string, fulfillmentType: string): string | nul
|
|
|
22
26
|
return null;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
/**
|
|
30
|
+
* 주문 상품 상태 라벨. `fulfillment`는 주문상품의 `fulfillmentSnapshot`(주문 시점 이행)이다 — 배송이 없는 상품은 제공 단계로
|
|
31
|
+
* 보인다. 모르는 유형도 `requiresShipping`으로 가른다
|
|
32
|
+
*/
|
|
33
|
+
export function orderItemStatusLabel(
|
|
34
|
+
status: string,
|
|
35
|
+
fulfillment?: Pick<MyOrderItemFulfillmentSnapshot, "type" | "requiresShipping"> | null,
|
|
36
|
+
): string {
|
|
37
|
+
if (fulfillment && !fulfillment.requiresShipping) {
|
|
38
|
+
const label = nonShippingLabel(status, fulfillment.type);
|
|
28
39
|
if (label) return label;
|
|
29
40
|
}
|
|
30
41
|
return ORDER_ITEM_STATUS_LABELS[status as OrderItemStatus] ?? status;
|
|
@@ -34,8 +45,10 @@ export function orderItemStatusLabel(status: string, fulfillmentType?: string |
|
|
|
34
45
|
* 청약철회 제한 주문상품(이슈 #48) — 구매자가 결제 전에 동의했고, 제공이 시작되면(첫 다운로드·열람) 단순 변심 취소·반품을
|
|
35
46
|
* 받지 않는다. 주문 상세·비회원 주문 조회가 상품 옆에 붙인다.
|
|
36
47
|
*/
|
|
37
|
-
export function withdrawalRestrictedLabel(item: {
|
|
38
|
-
|
|
48
|
+
export function withdrawalRestrictedLabel(item: {
|
|
49
|
+
fulfillmentSnapshot: MyOrderItemFulfillmentSnapshot;
|
|
50
|
+
}): string | null {
|
|
51
|
+
return withdrawalRestrictedOfView(item.fulfillmentSnapshot) ? "청약철회 제한 상품" : null;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
/** 제공이 시작된 청약철회 제한 상품의 취소·반품 접수 거절(409 `WITHDRAWAL_RESTRICTED`) 안내 */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ApiError, type TokenPair } from "@sayren/storefront-sdk/auth";
|
|
2
2
|
import { getGlobalStartContext } from "@tanstack/react-start";
|
|
3
|
-
import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server";
|
|
4
3
|
import { authFor } from "./api.server";
|
|
4
|
+
import { clearServerCookie, readServerCookie, writeServerCookie } from "./cookies.server";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* 구매자 세션 — 로그인 토큰 쌍을 HttpOnly 쿠키 하나에 둔다. 브라우저 JS는 토큰을 읽지 못하고,
|
|
@@ -11,8 +11,12 @@ import { authFor } from "./api.server";
|
|
|
11
11
|
* 곧 끝나면 리프레시 토큰으로 새 쌍을 받아 쿠키를 갈아 끼운다. 리프레시 토큰이 거부되면(401: 14일 만료·폐기)
|
|
12
12
|
* 쿠키를 지워 로그아웃 상태가 된다. 네트워크 오류·5xx 같은 일시 장애에는 쿠키를 지우지 않는다 — 다음 요청에서
|
|
13
13
|
* 다시 갱신한다.
|
|
14
|
+
*
|
|
15
|
+
* 쿠키 이름은 프로덕션에서 `__Host-sayren_member`다(`cookies.server.ts`).
|
|
14
16
|
*/
|
|
15
17
|
const SESSION_COOKIE = "sayren_member";
|
|
18
|
+
/** 리프레시 토큰 수명(14일)과 맞춘다 */
|
|
19
|
+
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 14;
|
|
16
20
|
|
|
17
21
|
interface StoredSession {
|
|
18
22
|
accessToken: string;
|
|
@@ -53,7 +57,7 @@ function toStored(tokens: TokenPair): StoredSession {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
function readSession(): StoredSession | null {
|
|
56
|
-
const raw =
|
|
60
|
+
const raw = readServerCookie(SESSION_COOKIE);
|
|
57
61
|
if (!raw) return null;
|
|
58
62
|
let value: unknown;
|
|
59
63
|
try {
|
|
@@ -73,13 +77,8 @@ function readSession(): StoredSession | null {
|
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
function writeSession(session: StoredSession) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
sameSite: "lax",
|
|
79
|
-
path: "/",
|
|
80
|
-
secure: process.env.NODE_ENV === "production",
|
|
81
|
-
maxAge: 60 * 60 * 24 * 14,
|
|
82
|
-
});
|
|
80
|
+
// HttpOnly — 브라우저 JS는 토큰을 읽지 못한다(`writeServerCookie`가 httpOnly·sameSite·secure를 싣는다)
|
|
81
|
+
writeServerCookie(SESSION_COOKIE, JSON.stringify(session), SESSION_MAX_AGE_SEC);
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
/**
|
|
@@ -117,5 +116,5 @@ export function signIn(tokens: TokenPair) {
|
|
|
117
116
|
|
|
118
117
|
/** 로그아웃 — 쿠키를 지운다 */
|
|
119
118
|
export function signOut() {
|
|
120
|
-
|
|
119
|
+
clearServerCookie(SESSION_COOKIE);
|
|
121
120
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clearServerCookie, readServerCookie, writeServerCookie } from "./cookies.server";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 소셜 로그인 왕복 상태 — 공급자로 보내기 전에 만든 state·PKCE verifier·돌아갈 곳을 HttpOnly 쿠키에 잠깐 둔다.
|
|
@@ -15,17 +15,11 @@ export interface SocialLoginState {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function writeSocialLogin(value: SocialLoginState) {
|
|
18
|
-
|
|
19
|
-
httpOnly: true,
|
|
20
|
-
sameSite: "lax",
|
|
21
|
-
path: "/",
|
|
22
|
-
secure: process.env.NODE_ENV === "production",
|
|
23
|
-
maxAge: 60 * 10,
|
|
24
|
-
});
|
|
18
|
+
writeServerCookie(SOCIAL_COOKIE, JSON.stringify(value), 60 * 10);
|
|
25
19
|
}
|
|
26
20
|
|
|
27
21
|
export function readSocialLogin(): SocialLoginState | null {
|
|
28
|
-
const raw =
|
|
22
|
+
const raw = readServerCookie(SOCIAL_COOKIE);
|
|
29
23
|
if (!raw) return null;
|
|
30
24
|
let value: unknown;
|
|
31
25
|
try {
|
|
@@ -50,5 +44,5 @@ export function readSocialLogin(): SocialLoginState | null {
|
|
|
50
44
|
}
|
|
51
45
|
|
|
52
46
|
export function clearSocialLogin() {
|
|
53
|
-
|
|
47
|
+
clearServerCookie(SOCIAL_COOKIE);
|
|
54
48
|
}
|
|
@@ -82,11 +82,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|
|
82
82
|
<head>
|
|
83
83
|
<HeadContent />
|
|
84
84
|
</head>
|
|
85
|
-
<body className="min-h-screen bg-
|
|
85
|
+
<body className="min-h-screen bg-page text-ink">
|
|
86
86
|
{/* URL 검색 파라미터(목록 검색어·카테고리·정렬·페이지)는 nuqs로 읽고 바꾼다 */}
|
|
87
87
|
<NuqsAdapter>
|
|
88
88
|
<SiteHeader store={store} />
|
|
89
|
-
<main className="mx-auto w-full max-w-
|
|
89
|
+
<main className="mx-auto w-full max-w-page px-4 py-8">{children}</main>
|
|
90
90
|
</NuqsAdapter>
|
|
91
91
|
<Scripts />
|
|
92
92
|
</body>
|
|
@@ -215,7 +215,7 @@ function GuestOrder() {
|
|
|
215
215
|
<span className="text-muted text-xs">{item.optionName}</span>
|
|
216
216
|
) : null}
|
|
217
217
|
<span className="text-muted text-xs">
|
|
218
|
-
{orderItemStatusLabel(item.status, item.
|
|
218
|
+
{orderItemStatusLabel(item.status, item.fulfillmentSnapshot)} · 수량{" "}
|
|
219
219
|
{item.quantity}개
|
|
220
220
|
</span>
|
|
221
221
|
{withdrawalRestrictedLabel(item) ? (
|
|
@@ -230,7 +230,8 @@ function OrderDetail() {
|
|
|
230
230
|
<span className="text-muted text-xs">{item.optionName}</span>
|
|
231
231
|
) : null}
|
|
232
232
|
<span className="text-muted text-xs">
|
|
233
|
-
{orderItemStatusLabel(item.status, item.
|
|
233
|
+
{orderItemStatusLabel(item.status, item.fulfillmentSnapshot)} · 수량{" "}
|
|
234
|
+
{item.quantity}개
|
|
234
235
|
{item.canceledQuantity > 0
|
|
235
236
|
? ` · 취소·반품 ${item.canceledQuantity}개 · 남은 수량 ${item.activeQuantity}개`
|
|
236
237
|
: null}
|
|
@@ -313,7 +314,9 @@ function OrderDetail() {
|
|
|
313
314
|
* 배송 상품이거나 보여 줄 것이 없으면 그리지 않는다. 배송 상품이 아니므로 배송 조회 링크가 없다.
|
|
314
315
|
*/
|
|
315
316
|
function FulfillmentInfo({ item }: { item: MyOrderItem }) {
|
|
316
|
-
const
|
|
317
|
+
const snapshot = item.fulfillmentSnapshot;
|
|
318
|
+
// 수령 장소는 주문 시점 스냅샷이다 — 셀러가 나중에 수령지를 바꿔도 이 주문의 안내는 그대로다
|
|
319
|
+
const pickup = snapshot.type === "PICKUP" ? snapshot.pickup.location : null;
|
|
317
320
|
const note = item.fulfillment?.status === "FULFILLED" ? item.fulfillment.note : null;
|
|
318
321
|
if (!pickup && !note) return null;
|
|
319
322
|
return (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiError } from "@sayren/storefront-sdk";
|
|
1
|
+
import { ApiError, type ProductFulfillmentView } from "@sayren/storefront-sdk";
|
|
2
2
|
import { createFileRoute, Link, notFound, useNavigate } from "@tanstack/react-router";
|
|
3
3
|
import { createServerFn } from "@tanstack/react-start";
|
|
4
4
|
import { useState } from "react";
|
|
@@ -59,6 +59,27 @@ export const Route = createFileRoute("/products/$productId")({
|
|
|
59
59
|
component: ProductDetail,
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
/** 상품 상세의 받는 방법 한 줄 — 배송 상품은 배송비·예상 기간, 그 밖의 유형은 배송 없이 받는 방법이다 */
|
|
63
|
+
function deliveryNoteOf(fulfillment: ProductFulfillmentView): string {
|
|
64
|
+
switch (fulfillment.type) {
|
|
65
|
+
case "SHIPPING": {
|
|
66
|
+
const { deliveryType, deliveryFee, estimatedDays } = fulfillment.shipping;
|
|
67
|
+
const fee = deliveryType === "FREE" ? "무료배송" : `배송비 ${formatPrice(deliveryFee)}`;
|
|
68
|
+
return estimatedDays ? `${fee} · ${estimatedDays}` : fee;
|
|
69
|
+
}
|
|
70
|
+
case "DOWNLOAD":
|
|
71
|
+
return "결제 후 바로 내려받기";
|
|
72
|
+
case "CODE":
|
|
73
|
+
return "결제 후 코드 제공";
|
|
74
|
+
case "PICKUP":
|
|
75
|
+
return fulfillment.pickup.location
|
|
76
|
+
? `방문 수령 · ${fulfillment.pickup.location.name}`
|
|
77
|
+
: "방문 수령";
|
|
78
|
+
default:
|
|
79
|
+
return fulfillment.requiresShipping ? "택배 배송" : "배송 없이 제공";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
function ProductDetail() {
|
|
63
84
|
const product = Route.useLoaderData();
|
|
64
85
|
useTrack({ name: "product_view", productId: product.productId });
|
|
@@ -98,12 +119,7 @@ function ProductDetail() {
|
|
|
98
119
|
<div className="space-y-2">
|
|
99
120
|
<h1 className="font-bold text-2xl">{product.name}</h1>
|
|
100
121
|
<p className="font-bold text-xl">{formatPrice(price)}</p>
|
|
101
|
-
<p className="text-muted text-sm">
|
|
102
|
-
{product.delivery.type === "FREE"
|
|
103
|
-
? "무료배송"
|
|
104
|
-
: `배송비 ${formatPrice(product.delivery.fee)}`}
|
|
105
|
-
{product.delivery.estimatedDays ? ` · ${product.delivery.estimatedDays}` : ""}
|
|
106
|
-
</p>
|
|
122
|
+
<p className="text-muted text-sm">{deliveryNoteOf(product.fulfillment)}</p>
|
|
107
123
|
</div>
|
|
108
124
|
|
|
109
125
|
<form
|
package/template/src/styles.css
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/* 클래스는 src/만 훑는다 — 빌드 산출물(dist)·설치물을 훑으면 빌드마다 CSS가 달라지고 서버·브라우저 빌드의 파일 이름이 어긋난다 */
|
|
2
|
+
@import "tailwindcss" source(".");
|
|
2
3
|
/* 상세설명이 에디터가 내보낸 HTML이라 그 모양 규칙이 필요하다 — 사진 줄, 재생기 틀,
|
|
3
4
|
구분선. 활자는 product-description.tsx가 정하고 이 파일은 건드리지 않는다. */
|
|
4
5
|
@import "@avarlabs/editor/styles/reader.css";
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
|
-
* 확장 지점 — 브랜드
|
|
8
|
-
*
|
|
8
|
+
* 확장 지점 — 브랜드 색·서체·폭은 theme.css의 `@theme`에서 바꾼다. 화면 코드는 토큰만 쓰므로
|
|
9
|
+
* 그 파일만 고쳐도 전체 톤이 바뀐다.
|
|
9
10
|
*/
|
|
10
|
-
@theme
|
|
11
|
-
--color-ink: #202429;
|
|
12
|
-
--color-muted: #5f6773;
|
|
13
|
-
--color-line: #ededed;
|
|
14
|
-
--color-chip: #f5f5f5;
|
|
15
|
-
--color-point: #ff204b;
|
|
16
|
-
--font-sans: "Pretendard Variable", Pretendard, -apple-system, "Apple SD Gothic Neo", sans-serif;
|
|
17
|
-
}
|
|
11
|
+
@import "./theme.css";
|
|
18
12
|
|
|
19
13
|
html {
|
|
20
14
|
font-family: var(--font-sans);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 확장 지점 — 브랜드 색과 서체, 본문 폭은 여기서 바꾼다. 화면 코드는 토큰만 쓰므로
|
|
3
|
+
* 이 블록만 고쳐도 전체 톤이 바뀐다.
|
|
4
|
+
*
|
|
5
|
+
* - page: 페이지 바탕 · ink: 본문 글자와 주요 버튼 · muted: 보조 글자 · line: 구분선 · chip: 옅은 면 · point: 가격·할인 강조
|
|
6
|
+
* - container-page: 헤더와 본문의 최대 폭(`max-w-page`)
|
|
7
|
+
*/
|
|
8
|
+
@theme {
|
|
9
|
+
--color-page: #ffffff;
|
|
10
|
+
--color-ink: #202429;
|
|
11
|
+
--color-muted: #5f6773;
|
|
12
|
+
--color-line: #ededed;
|
|
13
|
+
--color-chip: #f5f5f5;
|
|
14
|
+
--color-point: #ff204b;
|
|
15
|
+
--font-sans: "Pretendard Variable", Pretendard, -apple-system, "Apple SD Gothic Neo", sans-serif;
|
|
16
|
+
--container-page: 64rem;
|
|
17
|
+
}
|