@sayren/mcp 0.1.8 → 0.1.10

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.
Files changed (55) hide show
  1. package/dist/index.mjs +22 -19
  2. package/package.json +3 -3
  3. package/template/.template-meta.json +2 -2
  4. package/template/README.md +29 -12
  5. package/template/_gitignore +4 -2
  6. package/template/package.json +8 -10
  7. package/template/{app → src}/components/product-card.tsx +6 -2
  8. package/template/{app → src}/components/product-description.tsx +1 -1
  9. package/template/{app → src}/components/site-header.tsx +4 -1
  10. package/template/src/components/submit-button.tsx +32 -0
  11. package/template/{app → src}/lib/analytics.ts +1 -1
  12. package/template/{app → src}/lib/api.server.ts +6 -5
  13. package/template/{app → src}/lib/api.ts +1 -1
  14. package/template/src/lib/cart-session.server.ts +28 -0
  15. package/template/src/lib/checkout-intent.test.ts +27 -0
  16. package/template/{app → src}/lib/checkout-intent.ts +20 -8
  17. package/template/{app → src}/lib/config.server.ts +5 -4
  18. package/template/{app → src}/lib/config.ts +1 -1
  19. package/template/src/lib/format.test.ts +14 -0
  20. package/template/src/lib/format.ts +30 -0
  21. package/template/{app → src}/lib/safe-redirect.ts +1 -1
  22. package/template/src/lib/session.server.ts +121 -0
  23. package/template/src/lib/social-login.server.ts +54 -0
  24. package/template/src/routeTree.gen.ts +283 -0
  25. package/template/src/router.tsx +17 -0
  26. package/template/src/routes/__root.tsx +90 -0
  27. package/template/src/routes/account.tsx +213 -0
  28. package/template/src/routes/auth.callback.ts +70 -0
  29. package/template/{app → src}/routes/cart.tsx +94 -78
  30. package/template/{app/routes/checkout-complete.tsx → src/routes/checkout.complete.tsx} +25 -18
  31. package/template/{app/routes/checkout.tsx → src/routes/checkout.index.tsx} +134 -94
  32. package/template/{app/routes/home.tsx → src/routes/index.tsx} +19 -13
  33. package/template/src/routes/login.tsx +198 -0
  34. package/template/{app/routes/order-detail.tsx → src/routes/orders.$orderId.tsx} +25 -13
  35. package/template/{app/routes/orders.tsx → src/routes/orders.index.tsx} +16 -14
  36. package/template/{app/routes/product-detail.tsx → src/routes/products.$productId.tsx} +77 -55
  37. package/template/{app/routes/products.tsx → src/routes/products.index.tsx} +62 -38
  38. package/template/src/start.ts +18 -0
  39. package/template/tsconfig.json +3 -4
  40. package/template/vite.config.ts +3 -2
  41. package/template/vitest.config.ts +1 -1
  42. package/template/app/lib/cart-session.server.ts +0 -18
  43. package/template/app/lib/format.ts +0 -10
  44. package/template/app/lib/session.server.ts +0 -99
  45. package/template/app/lib/social-login.server.ts +0 -31
  46. package/template/app/root.tsx +0 -78
  47. package/template/app/routes/auth-callback.tsx +0 -44
  48. package/template/app/routes/login.tsx +0 -153
  49. package/template/app/routes.ts +0 -18
  50. package/template/react-router.config.ts +0 -6
  51. /package/template/{app → src}/lib/config.test.ts +0 -0
  52. /package/template/{app → src}/lib/payment-popup.ts +0 -0
  53. /package/template/{app → src}/lib/safe-redirect.test.ts +0 -0
  54. /package/template/{app → src}/lib/social-buttons.ts +0 -0
  55. /package/template/{app/app.css → src/styles.css} +0 -0
package/dist/index.mjs CHANGED
@@ -373,12 +373,12 @@ function readWorkspaceVersions(packagesDir) {
373
373
  */
374
374
  const HERE = dirname(fileURLToPath(import.meta.url));
375
375
  /** 번들에 복사된 template/ 이 먼저이고, 저장소에서 바로 실행할 때는 templates/ 를 쓴다 */
376
- const CANDIDATES = [join(HERE, "..", "template"), join(HERE, "..", "..", "..", "templates", "storefront-react-router")];
376
+ const CANDIDATES = [join(HERE, "..", "template"), join(HERE, "..", "..", "..", "templates", "storefront-tanstack-router")];
377
377
  const SKIP_DIRS = new Set([
378
378
  "node_modules",
379
- "build",
380
- ".react-router",
381
379
  "dist",
380
+ ".output",
381
+ ".tanstack",
382
382
  ".git"
383
383
  ]);
384
384
  /**
@@ -491,7 +491,7 @@ async function runCreate(argv) {
491
491
  const RULES = [
492
492
  {
493
493
  id: "server-only-env",
494
- title: "`process.env`는 `*.server.ts`에만 둔다",
494
+ title: "`process.env`는 `*.server.ts`에만 두고 서버 함수 안에서 읽는다",
495
495
  why: "브라우저 번들에 들어가면 `process is not defined`로 그 화면 모듈이 통째로 깨진다. 결제 화면에서 터지면 결제가 시작조차 안 된다"
496
496
  },
497
497
  {
@@ -506,7 +506,7 @@ const RULES = [
506
506
  },
507
507
  {
508
508
  id: "loader-first",
509
- title: "데이터는 loader에서 받는다",
509
+ title: "데이터는 라우트 loader에서 받는다(서버 함수 `createServerFn`)",
510
510
  why: "클라이언트에서 다시 받으면 첫 화면이 비고 검색 노출이 죽는다. SSR을 끄지 않는다"
511
511
  },
512
512
  {
@@ -516,7 +516,7 @@ const RULES = [
516
516
  },
517
517
  {
518
518
  id: "popup-client-submit",
519
- title: "주문서 제출은 클라이언트 제출(`<Form>`)로 한다",
519
+ title: "주문서 제출은 클라이언트 제출(`onSubmit` + 서버 함수)로 한다",
520
520
  why: "네이티브 폼은 문서를 다시 그려 팝업 제어 상태가 사라진다. 결제창이 빈 창으로 남는다"
521
521
  },
522
522
  {
@@ -551,6 +551,8 @@ function stripComments(source) {
551
551
  }
552
552
  const CLIENT_FILE = /\.(ts|tsx)$/;
553
553
  const SERVER_FILE = /\.server\.(ts|tsx)$/;
554
+ /** 문서를 다시 부르는 폼 — method나 action을 단 <form> */
555
+ const NATIVE_SUBMIT_FORM = /<form\s[^>]*\b(method|action)=/;
554
556
  /**
555
557
  * 만들어진 프로젝트의 소스를 규칙과 대조한다. 정적 검사라 모든 문제를 잡지는 못하지만,
556
558
  * 여기 있는 것들은 실제로 반복해서 깨지는 것들이다.
@@ -607,10 +609,10 @@ function verifySources(files) {
607
609
  file: file.path,
608
610
  detail: "서버 API 클라이언트에 방문 식별자가 없다. `...analyticsIdsFromCookie(request.headers.get(\"cookie\"))`를 넘긴다"
609
611
  });
610
- if (/requestPayment\(/.test(file.content) && /<form\s/.test(file.content)) findings.push({
612
+ if (/requestPayment\(/.test(file.content) && NATIVE_SUBMIT_FORM.test(file.content)) findings.push({
611
613
  ruleId: "popup-client-submit",
612
614
  file: file.path,
613
- detail: "결제 화면이 네이티브 <form>을 쓴다. react-router의 <Form>으로 바꾼다"
615
+ detail: "결제 화면이 네이티브 폼 제출(method·action)을 쓴다. onSubmit에서 preventDefault 후 서버 함수를 부른다"
614
616
  });
615
617
  }
616
618
  if (has("requestPayment(") && !has("Origin")) findings.push({
@@ -659,7 +661,7 @@ server.registerTool("get_store_context", {
659
661
  }, async () => text(await fetchStoreContext(config)));
660
662
  server.registerTool("get_scaffold_plan", {
661
663
  title: "스토어프론트 생성 계획",
662
- description: "React Router(SSR) 스토어프론트를 만드는 순서와 지켜야 할 규칙, 그리고 템플릿 파일 목록을 준다. 구현을 시작하기 전에 부른다.",
664
+ description: "TanStack Router(TanStack Start, SSR) 스토어프론트를 만드는 순서와 지켜야 할 규칙, 그리고 템플릿 파일 목록을 준다. 구현을 시작하기 전에 부른다.",
663
665
  inputSchema: {}
664
666
  }, async () => {
665
667
  const store = await fetchStoreContext(config).catch(() => null);
@@ -683,26 +685,27 @@ server.registerTool("get_scaffold_plan", {
683
685
  extensionPoints: [
684
686
  {
685
687
  what: "브랜드 색·서체",
686
- where: "app/app.css의 @theme"
688
+ where: "src/styles.css의 @theme"
687
689
  },
688
690
  {
689
691
  what: "헤더·전역 내비",
690
- where: "app/components/site-header.tsx"
692
+ where: "src/components/site-header.tsx"
691
693
  },
692
694
  {
693
695
  what: "상품 카드",
694
- where: "app/components/product-card.tsx"
696
+ where: "src/components/product-card.tsx"
695
697
  },
696
698
  {
697
699
  what: "화면 추가",
698
- where: "app/routes.ts"
700
+ where: "src/routes/ 에 파일 추가(파일 기반 라우트, routeTree.gen.ts는 dev·build가 다시 만든다)"
699
701
  }
700
702
  ],
701
703
  doNotTouch: [
702
- "app/lib/payment-popup.ts — 결제 팝업 규칙",
703
- "app/lib/api.server.ts — 테넌트 헤더·토큰 전달·부모 origin·방문 식별 쿠키",
704
- "app/lib/analytics.ts — 방문 분석 시작(브라우저 한 번)과 시작 전 이벤트 큐",
705
- "loader에서 데이터를 받는 구조"
704
+ "src/lib/payment-popup.ts — 결제 팝업 규칙",
705
+ "src/lib/api.server.ts — 테넌트 헤더·토큰 전달·부모 origin·방문 식별 쿠키",
706
+ "src/lib/session.server.ts·src/start.ts — 구매자 세션 쿠키와 요청마다 한 번 하는 토큰 갱신",
707
+ "src/lib/analytics.ts — 방문 분석 시작(브라우저 한 번)과 시작 전 이벤트 큐",
708
+ "라우트 loader가 서버 함수(createServerFn)로 데이터를 받는 구조"
706
709
  ],
707
710
  rules: RULES,
708
711
  templateFiles: listTemplateFiles()
@@ -841,9 +844,9 @@ server.registerTool("verify_storefront", {
841
844
  });
842
845
  const SKIP = new Set([
843
846
  "node_modules",
844
- "build",
845
847
  "dist",
846
- ".react-router",
848
+ ".output",
849
+ ".tanstack",
847
850
  ".git"
848
851
  ]);
849
852
  async function collectSources(dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/mcp",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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.5.0",
16
- "@sayren/storefront-sdk": "^0.6.0"
15
+ "@sayren/storefront-sdk": "^0.7.0",
16
+ "@sayren/store-sdk": "^0.7.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",
@@ -7,8 +7,8 @@
7
7
  "zod": "^4.6.5"
8
8
  },
9
9
  "versions": {
10
- "@sayren/store-sdk": "0.5.0",
11
- "@sayren/storefront-sdk": "0.6.0",
10
+ "@sayren/store-sdk": "0.7.0",
11
+ "@sayren/storefront-sdk": "0.7.0",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }
14
14
  }
@@ -1,4 +1,4 @@
1
- # 스토어프론트 템플릿 (React Router SSR)
1
+ # 스토어프론트 템플릿 (TanStack Router · TanStack Start SSR)
2
2
 
3
3
  sayren MCP가 내려주는 스토어프론트 시작점이다. 구매 흐름 전체가 이미 붙어 있다.
4
4
  홈, 목록·검색, 상세, 장바구니, 주문서·결제 팝업, 완료, 로그인, 주문 내역이다.
@@ -19,34 +19,51 @@ pnpm dev
19
19
  | `SAYREN_ANALYTICS_DEBUG` | `1`이면 localhost에서도 방문 분석을 보낸다 |
20
20
 
21
21
  `pnpm dev`는 프로젝트 루트의 `.env`에서 `SAYREN_*`를 읽는다. 셸에서 준 값이 우선한다.
22
- `pnpm start`(빌드 결과 실행)는 `.env`를 읽지 않으므로 환경변수로 넘긴다.
22
+ `pnpm build`는 `dist/`에 클라이언트와 서버 번들을 만들고, `pnpm start`는 그 결과를 띄워 확인한다.
23
+ 운영 배포는 호스팅에 맞는 TanStack Start 배포 설정(Node 서버, Cloudflare Workers, Vercel 등)을 더한다.
24
+
25
+ ## 구조
26
+
27
+ | 자리 | 하는 일 |
28
+ | --- | --- |
29
+ | `src/routes/` | 파일 기반 라우트. 파일 이름이 곧 경로다(`products.$productId.tsx` → `/products/:productId`) |
30
+ | `src/routeTree.gen.ts` | 라우트 트리. `pnpm dev`·`pnpm build`가 다시 만든다. 손으로 고치지 않는다 |
31
+ | `src/start.ts` | 전역 요청 미들웨어. 서버 함수 CSRF 검사와 구매자 세션 갱신 |
32
+ | `src/lib/*.server.ts` | 서버 전용 코드. `process.env`·쿠키·API 클라이언트는 여기에만 둔다 |
33
+
34
+ 화면 데이터는 라우트 `loader`가 서버 함수(`createServerFn`)를 불러 받는다. 첫 요청은 서버에서 그리고, 이후 이동은
35
+ 브라우저가 같은 서버 함수를 호출한다. 담기·결제 요청·로그인 같은 쓰기도 서버 함수라 토큰이 브라우저 JS에 나가지 않는다.
23
36
 
24
37
  ## 방문 분석
25
38
 
26
- `app/lib/analytics.ts`가 브라우저에서 방문 분석을 시작한다. 페이지뷰·체류는 자동으로 세고, 상품 조회·목록 노출·검색은
27
- 화면에서 `useTrack`으로 남긴다. 장바구니·결제·구매는 `app/lib/api.server.ts`가 방문자 쿠키를 API 요청 헤더에 실어
39
+ `src/lib/analytics.ts`가 브라우저에서 방문 분석을 시작한다. 페이지뷰·체류는 자동으로 세고, 상품 조회·목록 노출·검색은
40
+ 화면에서 `useTrack`으로 남긴다. 장바구니·결제·구매는 `src/lib/api.server.ts`가 방문자 쿠키를 API 요청 헤더에 실어
28
41
  서버가 기록한다. localhost에서는 보내지 않는다. 개발 중에 확인하려면 `SAYREN_ANALYTICS_DEBUG=1`을 준다.
29
42
  동의 기본값은 `granted`(바로 수집)다. 개인정보를 저장하지 않는 1st-party 쿠키라 국내 쇼핑몰 기준으로 정했다.
30
- EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는다면 `app/lib/analytics.ts`의 `consent`를 `"pending"`으로 바꾸고,
43
+ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는다면 `src/lib/analytics.ts`의 `consent`를 `"pending"`으로 바꾸고,
31
44
  쿠키 동의 배너에서 `setConsent("granted" | "denied")`를 부른다. 동의 전에는 쿠키를 만들지 않고 이벤트를 모아 두었다가 동의하면 보낸다.
32
45
 
33
46
  ## 확장 지점
34
47
 
35
48
  | 자리 | 파일 |
36
49
  | --- | --- |
37
- | 브랜드 색·서체 | `app/app.css`의 `@theme` |
38
- | 헤더·전역 내비 | `app/components/site-header.tsx` |
39
- | 상품 카드 | `app/components/product-card.tsx` |
40
- | 화면 추가 | `app/routes.ts` |
50
+ | 브랜드 색·서체 | `src/styles.css`의 `@theme` |
51
+ | 헤더·전역 내비 | `src/components/site-header.tsx` |
52
+ | 상품 카드 | `src/components/product-card.tsx` |
53
+ | 화면 추가 | `src/routes/`에 파일 추가 |
41
54
 
42
55
  ## 건드리지 않는 편이 좋은 것
43
56
 
44
- - `app/lib/payment-popup.ts` — 결제 팝업 규칙이다. 팝업은 클릭 콜스택에서 동기적으로 열고,
57
+ - `src/lib/payment-popup.ts` — 결제 팝업 규칙이다. 팝업은 클릭 콜스택에서 동기적으로 열고,
45
58
  postMessage는 origin과 source를 모두 검증하며, 결과 없이 닫히면 서버에 상태를 물어본다.
46
59
  하나라도 어기면 결제가 조용히 실패한다.
47
- - `app/lib/api.ts` — 테넌트 헤더와 토큰 전달 방식이다. 토큰을 모듈 전역에 담으면 서버 렌더에서
60
+ - `src/lib/api.server.ts` — 테넌트 헤더와 토큰 전달 방식이다. 토큰을 모듈 전역에 담으면 서버 렌더에서
48
61
  다른 사용자의 요청에 섞인다.
49
- - loader에서 데이터를 받아 그리는 구조 — 클라이언트에서 다시 받으면 검색 노출과 첫 화면이 죽는다.
62
+ - `src/lib/session.server.ts`·`src/start.ts` — 구매자 세션 쿠키와 갱신이다. 갱신은 요청마다 한 번이고, 같은
63
+ 리프레시 토큰의 동시 갱신은 한 번으로 모은다. 리프레시 토큰은 한 번 쓰면 폐기되므로 각자 갱신하면 로그아웃된다.
64
+ - loader가 서버 함수로 데이터를 받아 그리는 구조 — 컴포넌트에서 다시 받으면 검색 노출과 첫 화면이 죽는다.
65
+ - 주문서 제출 방식 — `onSubmit`에서 `preventDefault` 후 서버 함수를 부른다. 네이티브 폼 제출은 문서를 다시 불러
66
+ 열어 둔 결제 팝업의 제어를 잃는다.
50
67
 
51
68
  ## 주의
52
69
 
@@ -1,4 +1,6 @@
1
- .react-router/
2
- build/
3
1
  node_modules/
2
+ dist/
3
+ .output/
4
+ .tanstack/
5
+ .nitro/
4
6
  .env
@@ -1,35 +1,33 @@
1
1
  {
2
- "name": "@sayren/template-storefront-react-router",
2
+ "name": "@sayren/template-storefront-tanstack-router",
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "react-router dev --port 4010",
8
- "build": "react-router build",
9
- "start": "react-router-serve ./build/server/index.js",
7
+ "dev": "vite dev --port 4010",
8
+ "build": "vite build",
9
+ "start": "vite preview --port 4010",
10
10
  "lint": "biome check --write . && tsc --noEmit",
11
- "typecheck": "react-router typegen && tsc --noEmit",
11
+ "typecheck": "tsc --noEmit",
12
12
  "test": "vitest run"
13
13
  },
14
14
  "dependencies": {
15
15
  "@avarlabs/editor": "^0.1.0",
16
- "@react-router/node": "^8.4.0",
17
- "@react-router/serve": "^8.4.0",
18
16
  "@sayren/storefront-sdk": "workspace:*",
19
17
  "@tailwindcss/vite": "^4.3.3",
20
- "isbot": "^5.1.0",
18
+ "@tanstack/react-router": "^1.170.38",
19
+ "@tanstack/react-start": "^1.168.56",
21
20
  "react": "^19.3.0",
22
21
  "react-dom": "^19.3.0",
23
- "react-router": "^8.4.0",
24
22
  "tailwindcss": "^4.3.3",
25
23
  "zod": "catalog:"
26
24
  },
27
25
  "devDependencies": {
28
26
  "@biomejs/biome": "catalog:",
29
- "@react-router/dev": "^8.4.0",
30
27
  "@types/node": "^24.13.5",
31
28
  "@types/react": "^19.3.0",
32
29
  "@types/react-dom": "^19.3.0",
30
+ "@vitejs/plugin-react": "^6.1.1",
33
31
  "typescript": "catalog:",
34
32
  "vite": "^8.3.0",
35
33
  "vitest": "catalog:"
@@ -1,5 +1,5 @@
1
1
  import type { ProductCard as ProductCardData } from "@sayren/storefront-sdk";
2
- import { Link } from "react-router";
2
+ import { Link } from "@tanstack/react-router";
3
3
  import { formatPrice } from "../lib/format";
4
4
 
5
5
  /** 상품 카드 — 확장 지점: 찜 버튼·리뷰 별점 같은 요소는 여기에 붙인다 */
@@ -7,7 +7,11 @@ export function ProductCard({ product }: { product: ProductCardData }) {
7
7
  const price = product.discountedPrice ?? product.salePrice;
8
8
 
9
9
  return (
10
- <Link to={`/products/${product.productId}`} className="group flex flex-col gap-2">
10
+ <Link
11
+ to="/products/$productId"
12
+ params={{ productId: product.productId }}
13
+ className="group flex flex-col gap-2"
14
+ >
11
15
  <div className="relative aspect-square overflow-hidden rounded-lg bg-chip">
12
16
  <img
13
17
  src={product.thumbnailUrl}
@@ -10,7 +10,7 @@
10
10
  * 가 React가 이스케이프 렌더 → XSS·표시 깨짐이 모두 방지된다.
11
11
  *
12
12
  * 확장 지점 — 활자(본문 크기·제목·인용)는 아래 클래스가 정한다. 사진 줄·재생기 틀·
13
- * 구분선 같은 에디터 고유 모양은 `app.css`가 불러오는 reader.css가 맡는다.
13
+ * 구분선 같은 에디터 고유 모양은 `styles.css`가 불러오는 reader.css가 맡는다.
14
14
  */
15
15
  const EDITOR_HTML_START = /^\s*<(?:p|h[1-6]|ul|ol|hr|figure|table|blockquote|pre)[\s>/]/i;
16
16
 
@@ -1,4 +1,4 @@
1
- import { Link } from "react-router";
1
+ import { Link } from "@tanstack/react-router";
2
2
 
3
3
  /** 확장 지점 — 로고·전역 내비게이션은 여기서 바꾼다 */
4
4
  export function SiteHeader() {
@@ -18,6 +18,9 @@ export function SiteHeader() {
18
18
  <Link to="/orders" className="hover:text-point">
19
19
  주문 내역
20
20
  </Link>
21
+ <Link to="/account" className="hover:text-point">
22
+ 내 정보
23
+ </Link>
21
24
  </nav>
22
25
  </div>
23
26
  </header>
@@ -0,0 +1,32 @@
1
+ import { useHydrated } from "@tanstack/react-router";
2
+
3
+ /**
4
+ * 제출 버튼 — 하이드레이션 전 클릭은 onSubmit이 붙기 전이라 유실되거나 문서를 다시 부른다.
5
+ * 붙은 뒤에만 누를 수 있게 한다.
6
+ */
7
+ export function SubmitButton({
8
+ disabled,
9
+ className,
10
+ name,
11
+ value,
12
+ children,
13
+ }: {
14
+ disabled?: boolean;
15
+ className: string;
16
+ name?: string;
17
+ value?: string;
18
+ children: React.ReactNode;
19
+ }) {
20
+ const hydrated = useHydrated();
21
+ return (
22
+ <button
23
+ type="submit"
24
+ name={name}
25
+ value={value}
26
+ disabled={!hydrated || disabled}
27
+ className={className}
28
+ >
29
+ {children}
30
+ </button>
31
+ );
32
+ }
@@ -13,7 +13,7 @@ import { useEffect } from "react";
13
13
  * 기록한다(`api.server.ts`가 방문자·세션 쿠키를 헤더로 싣는다).
14
14
  */
15
15
 
16
- /** 루트 loader가 내려주는 값 */
16
+ /** 루트 loader(서버 함수)가 내려주는 값 */
17
17
  export interface AnalyticsConfig {
18
18
  apiBaseUrl: string;
19
19
  storeCode: string;
@@ -1,9 +1,10 @@
1
1
  import { analyticsIdsFromCookie, createStorefrontClient } from "@sayren/storefront-sdk";
2
2
  import { createStorefrontAuth } from "@sayren/storefront-sdk/auth";
3
+ import { getRequest } from "@tanstack/react-start/server";
3
4
  import { API_BASE_URL, resolveStoreCode } from "./config.server";
4
5
 
5
6
  /**
6
- * 서버(loader·action)에서 쓰는 스토어프론트 API 클라이언트.
7
+ * 서버(서버 함수·서버 라우트)에서 쓰는 스토어프론트 API 클라이언트.
7
8
  *
8
9
  * 테넌트는 `X-Store-Code` 헤더로 보낸다. 토큰은 요청마다 넘긴다 — 모듈 전역에 담으면 서버 렌더에서
9
10
  * 다른 사용자의 요청에 섞인다.
@@ -13,16 +14,16 @@ import { API_BASE_URL, resolveStoreCode } from "./config.server";
13
14
  * 비회원 장바구니 토큰은 서버가 새로 발급할 수 있다. `onCartToken`으로 받아 쿠키에 다시 심는다.
14
15
  */
15
16
  export function apiFor(
16
- request: Request,
17
17
  options: {
18
18
  accessToken?: string | null;
19
19
  cartToken?: string | null;
20
20
  onCartToken?: (token: string) => void;
21
21
  } = {},
22
22
  ) {
23
+ const request = getRequest();
23
24
  return createStorefrontClient({
24
25
  baseUrl: API_BASE_URL,
25
- storeCode: resolveStoreCode(request),
26
+ storeCode: resolveStoreCode(),
26
27
  accessToken: options.accessToken ?? undefined,
27
28
  cartToken: options.cartToken ?? undefined,
28
29
  onCartToken: options.onCartToken,
@@ -41,6 +42,6 @@ export function apiFor(
41
42
  * 구매자 인증 — 로그인·가입·비회원 세션·토큰 갱신. 화면은 이 앱이 그리고 계정은 sayren이 보관한다.
42
43
  * 결과 토큰은 `session.server.ts`가 HttpOnly 쿠키에 둔다.
43
44
  */
44
- export function authFor(request: Request) {
45
- return createStorefrontAuth({ baseUrl: API_BASE_URL, storeCode: resolveStoreCode(request) });
45
+ export function authFor() {
46
+ return createStorefrontAuth({ baseUrl: API_BASE_URL, storeCode: resolveStoreCode() });
46
47
  }
@@ -1,7 +1,7 @@
1
1
  import { createStorefrontClient } from "@sayren/storefront-sdk";
2
2
  import type { PublicConfig } from "./config";
3
3
 
4
- /** 브라우저용 — loader가 내려준 설정으로 만든다(결제 상태 폴링에 쓴다) */
4
+ /** 브라우저용 — 서버 함수가 내려준 설정으로 만든다(결제 상태 폴링에 쓴다) */
5
5
  export function browserApi(config: PublicConfig) {
6
6
  return createStorefrontClient({ baseUrl: config.apiBaseUrl, storeCode: config.storeCode });
7
7
  }
@@ -0,0 +1,28 @@
1
+ import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server";
2
+
3
+ /**
4
+ * 비회원 장바구니 토큰 — 서버가 `X-Cart-Token` 헤더로 발급한다. 회원은 액세스 토큰으로
5
+ * 장바구니를 찾지만 비회원은 이 토큰이 유일한 열쇠라, 잃어버리면 담은 상품이 사라진다.
6
+ *
7
+ * 서버 함수·서버 라우트 안에서만 부른다(요청 쿠키를 읽고 응답 쿠키를 쓴다).
8
+ */
9
+ const CART_COOKIE = "sayren_cart";
10
+
11
+ export function readCartToken(): string | null {
12
+ return getCookie(CART_COOKIE) || null;
13
+ }
14
+
15
+ export function writeCartToken(token: string) {
16
+ setCookie(CART_COOKIE, token, {
17
+ httpOnly: true,
18
+ sameSite: "lax",
19
+ path: "/",
20
+ secure: process.env.NODE_ENV === "production",
21
+ maxAge: 60 * 60 * 24 * 30,
22
+ });
23
+ }
24
+
25
+ /** 로그인하며 회원 장바구니로 합쳐진 뒤 지운다 */
26
+ export function clearCartToken() {
27
+ deleteCookie(CART_COOKIE, { path: "/" });
28
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { checkoutIntentFrom, checkoutSearch } from "./checkout-intent";
3
+
4
+ describe("주문서 진입 의도", () => {
5
+ it("장바구니 항목이 있으면 장바구니 주문이다", () => {
6
+ expect(checkoutIntentFrom(checkoutSearch.parse({ cartItemId: ["a", "b"] }))).toEqual({
7
+ cartItemIds: ["a", "b"],
8
+ });
9
+ expect(checkoutIntentFrom(checkoutSearch.parse({ cartItemId: "a" }))).toEqual({
10
+ cartItemIds: ["a"],
11
+ });
12
+ });
13
+
14
+ it("상품이 있으면 바로구매이고 수량은 1 이상 정수로 맞춘다", () => {
15
+ expect(checkoutIntentFrom(checkoutSearch.parse({ productId: "p", quantity: "2.7" }))).toEqual({
16
+ directItem: { productId: "p", optionId: undefined, quantity: 2 },
17
+ });
18
+ expect(
19
+ checkoutIntentFrom(checkoutSearch.parse({ productId: "p", optionId: "o", quantity: -1 })),
20
+ ).toEqual({ directItem: { productId: "p", optionId: "o", quantity: 1 } });
21
+ });
22
+
23
+ it("조건이 없으면 null이다", () => {
24
+ expect(checkoutIntentFrom(checkoutSearch.parse({}))).toBeNull();
25
+ expect(checkoutIntentFrom(checkoutSearch.parse({ cartItemId: [] }))).toBeNull();
26
+ });
27
+ });
@@ -1,5 +1,7 @@
1
+ import { z } from "zod";
2
+
1
3
  /**
2
- * 주문서 진입 의도 — URL 파라미터로 받는다.
4
+ * 주문서 진입 의도 — URL 검색 파라미터로 받는다.
3
5
  *
4
6
  * 스토어프론트 API에는 "만들어 둔 주문서 다시 조회"가 없다. 주문서 세션은 만들 때 한 번 내려오고
5
7
  * 짧게 만료된다. 그래서 주문서 화면은 들어올 때마다 세션을 새로 만든다. 조건이 URL에 있으면
@@ -8,22 +10,32 @@
8
10
  * 값을 사용자가 바꿀 수 있지만 위험하지 않다. 장바구니 항목은 장바구니 토큰으로 소유가 확인되고,
9
11
  * 가격·재고는 서버가 다시 계산한다.
10
12
  */
13
+ export const checkoutSearch = z.object({
14
+ cartItemId: z
15
+ .union([z.string(), z.array(z.string())])
16
+ .optional()
17
+ .catch(undefined),
18
+ productId: z.string().optional().catch(undefined),
19
+ optionId: z.string().optional().catch(undefined),
20
+ quantity: z.coerce.number().optional().catch(undefined),
21
+ });
22
+ export type CheckoutSearch = z.infer<typeof checkoutSearch>;
23
+
11
24
  export interface CheckoutIntent {
12
25
  cartItemIds?: string[];
13
26
  directItem?: { productId: string; optionId?: string; quantity: number };
14
27
  }
15
28
 
16
- export function checkoutIntentFrom(params: URLSearchParams): CheckoutIntent | null {
17
- const cartItemIds = params.getAll("cartItemId").filter(Boolean);
29
+ export function checkoutIntentFrom(search: CheckoutSearch): CheckoutIntent | null {
30
+ const cartItemIds = [search.cartItemId ?? []].flat().filter(Boolean);
18
31
  if (cartItemIds.length) return { cartItemIds };
19
32
 
20
- const productId = params.get("productId");
21
- if (!productId) return null;
22
- const quantity = Number(params.get("quantity") ?? 1);
33
+ if (!search.productId) return null;
34
+ const quantity = search.quantity ?? 1;
23
35
  return {
24
36
  directItem: {
25
- productId,
26
- optionId: params.get("optionId") ?? undefined,
37
+ productId: search.productId,
38
+ optionId: search.optionId || undefined,
27
39
  quantity: Number.isFinite(quantity) && quantity > 0 ? Math.floor(quantity) : 1,
28
40
  },
29
41
  };
@@ -1,8 +1,9 @@
1
+ import { getRequestHeader } from "@tanstack/react-start/server";
1
2
  import { storeCodeFromHost } from "./config";
2
3
 
3
4
  /**
4
5
  * 서버 전용 설정 — `.server.ts`라 브라우저 번들에 들어가지 않는다. `process.env`를 여기에만 둔다.
5
- * 브라우저에 필요한 값은 loader가 내려준다(`PublicConfig`).
6
+ * 브라우저에 필요한 값은 서버 함수가 내려준다(`PublicConfig`).
6
7
  */
7
8
  export const API_BASE_URL = process.env.SAYREN_API_URL ?? "https://api.sayren.app/storefront/v1";
8
9
 
@@ -11,10 +12,10 @@ export const ANALYTICS_DEBUG = process.env.SAYREN_ANALYTICS_DEBUG === "1";
11
12
 
12
13
  const FIXED_STORE_CODE = process.env.SAYREN_STORE_CODE ?? "";
13
14
 
14
- /** 요청에서 테넌트를 정한다 — 고정 값이 먼저이고, 없으면 호스트 서브도메인에서 뽑는다 */
15
- export function resolveStoreCode(request: Request): string {
15
+ /** 이 요청의 테넌트 — 고정 값이 먼저이고, 없으면 호스트 서브도메인에서 뽑는다 */
16
+ export function resolveStoreCode(): string {
16
17
  if (FIXED_STORE_CODE) return FIXED_STORE_CODE;
17
- const fromHost = storeCodeFromHost(request.headers.get("host"));
18
+ const fromHost = storeCodeFromHost(getRequestHeader("host"));
18
19
  if (fromHost) return fromHost;
19
20
  throw new Error(
20
21
  "스토어 코드를 알 수 없어요. SAYREN_STORE_CODE 환경변수를 설정하거나 서브도메인으로 서비스해주세요",
@@ -12,7 +12,7 @@ export function storeCodeFromHost(host: string | null | undefined): string | nul
12
12
  return labels.length >= 3 ? (labels[0] ?? null) : null;
13
13
  }
14
14
 
15
- /** loader가 브라우저로 내려주는 값 */
15
+ /** 서버 함수가 브라우저로 내려주는 값 */
16
16
  export interface PublicConfig {
17
17
  apiBaseUrl: string;
18
18
  storeCode: string;
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatDateTime, formatPrice } from "./format";
3
+
4
+ describe("표시 형식", () => {
5
+ it("가격은 천 단위로 끊고 원을 붙인다", () => {
6
+ expect(formatPrice(129000)).toBe("129,000원");
7
+ });
8
+
9
+ it("시각은 서울 시간 24시간제 숫자로 쓴다(서버·브라우저 결과가 같다)", () => {
10
+ expect(formatDateTime("2026-09-24T13:05:00.000Z")).toBe("2026. 9. 24. 22:05");
11
+ expect(formatDateTime("2026-09-24T15:00:00.000Z")).toBe("2026. 9. 25. 00:00");
12
+ expect(formatDateTime(null)).toBe("-");
13
+ });
14
+ });
@@ -0,0 +1,30 @@
1
+ const KRW = new Intl.NumberFormat("ko-KR");
2
+
3
+ export function formatPrice(amount: number): string {
4
+ return `${KRW.format(amount)}원`;
5
+ }
6
+
7
+ /**
8
+ * 서버가 그린 글자와 하이드레이션 결과가 같아야 한다. 그래서 시간대를 고정하고(서버는 대개 UTC), 로케일 문구
9
+ * ("오후" 같은 말)는 쓰지 않고 숫자만 조립한다. 로케일 데이터가 적게 들어간 Node 빌드에서도 결과가 같다.
10
+ * 다른 나라 구매자를 받으면 `TIME_ZONE`을 바꾼다.
11
+ */
12
+ const TIME_ZONE = "Asia/Seoul";
13
+
14
+ const DATE_PARTS = new Intl.DateTimeFormat("en-US", {
15
+ timeZone: TIME_ZONE,
16
+ year: "numeric",
17
+ month: "numeric",
18
+ day: "numeric",
19
+ hour: "2-digit",
20
+ minute: "2-digit",
21
+ hourCycle: "h23",
22
+ });
23
+
24
+ /** `2026. 9. 24. 22:05` */
25
+ export function formatDateTime(value: string | null | undefined): string {
26
+ if (!value) return "-";
27
+ const parts: Record<string, string> = {};
28
+ for (const part of DATE_PARTS.formatToParts(new Date(value))) parts[part.type] = part.value;
29
+ return `${parts.year}. ${parts.month}. ${parts.day}. ${parts.hour}:${parts.minute}`;
30
+ }
@@ -3,7 +3,7 @@
3
3
  * `//evil.com`과 `/\evil.com`은 브라우저가 외부 주소로 해석하므로 두 번째 글자가 `/`·`\`면 거부하고,
4
4
  * 제어 문자(탭·개행은 URL 파싱에서 지워진다)도 거부한다.
5
5
  */
6
- export function safeRedirect(value: FormDataEntryValue | null): string {
6
+ export function safeRedirect(value: unknown): string {
7
7
  const fallback = "/orders";
8
8
  if (typeof value !== "string" || !value.startsWith("/")) return fallback;
9
9
  if (value[1] === "/" || value[1] === "\\") return fallback;