@sayren/mcp 0.9.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 +1 -1
- package/template/README.md +5 -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/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/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 복귀 주소를 모두 받고,
|
|
@@ -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,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>
|
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
|
+
}
|