sayren 0.13.1 → 0.15.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/boilerplate/.claude/skills/sayren/SKILL.md +18 -0
- package/dist/boilerplate/.mcp.json +8 -0
- package/dist/boilerplate/CLAUDE.md +26 -0
- package/dist/boilerplate/README.md +12 -0
- package/dist/boilerplate/_gitignore +8 -0
- package/dist/boilerplate/biome.json +45 -0
- package/dist/boilerplate/messages/en.json +9 -0
- package/dist/boilerplate/messages/ko.json +9 -0
- package/dist/boilerplate/package.json +46 -0
- package/dist/boilerplate/public/_headers +5 -0
- package/dist/boilerplate/public/favicon.svg +1 -0
- package/dist/boilerplate/src/hooks/use-catalog.ts +28 -0
- package/dist/boilerplate/src/i18n/index.ts +99 -0
- package/dist/boilerplate/src/i18n/locale.server.ts +10 -0
- package/dist/boilerplate/src/lib/analytics.ts +60 -0
- package/dist/boilerplate/src/lib/api.server.ts +27 -0
- package/dist/boilerplate/src/lib/api.ts +7 -0
- package/dist/boilerplate/src/lib/config.server.ts +23 -0
- package/dist/boilerplate/src/lib/config.test.ts +16 -0
- package/dist/boilerplate/src/lib/config.ts +19 -0
- package/dist/boilerplate/src/lib/cookies.server.ts +35 -0
- package/dist/boilerplate/src/lib/format.ts +19 -0
- package/dist/boilerplate/src/lib/queries.ts +61 -0
- package/dist/boilerplate/src/routeTree.gen.ts +69 -0
- package/dist/boilerplate/src/router.tsx +44 -0
- package/dist/boilerplate/src/routes/__root.tsx +119 -0
- package/dist/boilerplate/src/routes/index.tsx +52 -0
- package/dist/boilerplate/src/start.ts +20 -0
- package/dist/boilerplate/src/styles.css +29 -0
- package/dist/boilerplate/src/theme.css +38 -0
- package/dist/boilerplate/tsconfig.json +16 -0
- package/dist/boilerplate/vite.config.ts +12 -0
- package/dist/boilerplate/vitest.config.ts +5 -0
- package/dist/index.mjs +1098 -114
- package/package.json +6 -5
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sayren
|
|
3
|
+
description: Load before adding, implementing or changing any feature or page of this sayren storefront. Lists the current sayren skills and loads the ones the task needs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# sayren skills
|
|
7
|
+
|
|
8
|
+
The sayren skills are maintained centrally and change without updating this project.
|
|
9
|
+
Do not rely on memory or earlier copies. Load them at the start of each task.
|
|
10
|
+
|
|
11
|
+
1. List the skills (name and when to use each):
|
|
12
|
+
`npx -y sayren@latest skill list`
|
|
13
|
+
2. Read every skill that matches the task, in full, and follow it:
|
|
14
|
+
`npx -y sayren@latest skill <name>`
|
|
15
|
+
|
|
16
|
+
If the sayren MCP server is connected, the tools `list_skills` and `get_skill` return the same content.
|
|
17
|
+
|
|
18
|
+
Follow the project's `CLAUDE.md` first. The skills add the sayren-specific details.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# CLAUDE.md — sayren 스토어프론트 보일러플레이트
|
|
2
|
+
|
|
3
|
+
이 폴더는 sayren 스토어프론트 보일러플레이트다(`npx sayren create`, 호스팅 「처음부터」). 인프라와 상점 이름·상품 목록을 보이는 홈만 있고 화면은 쇼핑몰마다 새로 만든다.
|
|
4
|
+
상점·상품·주문 데이터는 sayren이 보관하고, 이 앱은 스토어프론트 SDK(`@sayren/storefront-sdk`)로 읽는다.
|
|
5
|
+
|
|
6
|
+
## 이미 있는 것
|
|
7
|
+
- TanStack Start + React 19 + Tailwind v4(`src/theme.css`의 `@theme` 토큰). 라우트는 `src/routes/`(파일 기반).
|
|
8
|
+
- SDK 연결: 서버 `src/lib/api.server.ts`(`apiFor()`), 브라우저 `src/lib/api.ts`, 설정 `src/lib/config.server.ts`(환경 변수 `SAYREN_API_URL`·`SAYREN_STORE_CODE`, 로컬은 `.env`).
|
|
9
|
+
- 홈: `src/routes/index.tsx` — 상점 이름과 상품 목록(첫 페이지). 금액 표시는 `src/lib/format.ts`(`formatMoney`).
|
|
10
|
+
- 기본 조회와 훅: `src/lib/queries.ts`(queryOptions) · `src/hooks/use-catalog.ts`(`useStore`·`useCategories`·`useProducts`·`useProduct`).
|
|
11
|
+
- 문구: `messages/ko.json` + `src/i18n`(`m.키()`). 화면 문구는 코드에 쓰지 않고 이 파일에 둔다.
|
|
12
|
+
- 방문 분석: 루트가 `startAnalytics`로 시작한다(`src/lib/analytics.ts`). 서버 쿠키는 `src/lib/cookies.server.ts` 한 곳이다.
|
|
13
|
+
- 스크롤 연출용 `gsap`·`lenis`가 설치돼 있다.
|
|
14
|
+
|
|
15
|
+
## 만들 때의 규칙
|
|
16
|
+
- 셀러가 요청한 화면만 만든다. 장바구니·주문서·결제·구매자 로그인은 셀러가 요청할 때까지 만들지 않는다.
|
|
17
|
+
요청을 받으면 SDK의 장바구니·주문서·`/payments`·`/auth` 모듈로 만들고 `sdk-cart`·`sdk-checkout`·`sdk-payments`·`sdk-buyer-auth` 스킬과 규칙 검증(`verify_site`)을 따른다.
|
|
18
|
+
- 데이터는 라우트 loader에서 `queryClient.ensureQueryData(...)`로 먼저 받고 화면은 훅으로 읽는다. `fetch`로 api를 직접 부르지 않는다.
|
|
19
|
+
- 금액·재고·주문 가능 여부는 응답 값을 그대로 보인다. 화면에서 계산하지 않는다.
|
|
20
|
+
- `process.env`는 `*.server.ts`에서만 읽는다. 모듈 최상위에서 `window`·`document`를 읽지 않는다.
|
|
21
|
+
- 색·글꼴·모서리는 `src/theme.css` 토큰으로만 쓴다.
|
|
22
|
+
- 기능·화면을 추가·구현·변경하기 전에 `sayren` 스킬(`.claude/skills/sayren`)을 먼저 쓴다. 중앙 스킬 목록(`npx -y sayren@latest skill list`)에서 작업에 맞는 스킬을 골라 본문(`npx -y sayren@latest skill <이름>`)을 읽고 따른다(스킬은 이 프로젝트를 고치지 않고 바뀐다).
|
|
23
|
+
|
|
24
|
+
## 확인
|
|
25
|
+
- `pnpm typecheck` · `pnpm test` · `pnpm build`
|
|
26
|
+
- 원격 MCP(`.mcp.json`, sayren 계정 로그인)로 상점 데이터를 읽고 `verify_site`로 규칙을 확인한다.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# sayren 스토어프론트 보일러플레이트
|
|
2
|
+
|
|
3
|
+
`npx sayren create <이름>`으로 만든 sayren 스토어프론트입니다. 상점 이름과 상품 목록을 보이는 홈만 있고, 화면은 쇼핑몰에 맞게 새로 만듭니다.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm install
|
|
7
|
+
pnpm dev
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
상점 연결 값(`SAYREN_STORE_CODE`·`SAYREN_API_URL`)은 `.env`에 있습니다.
|
|
11
|
+
|
|
12
|
+
작업 규칙은 `CLAUDE.md`에 있습니다. 기준 문서(스킬)는 sayren이 중앙에서 관리하며 `.claude/skills/sayren`이 읽는 방법을 안내합니다.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
|
|
3
|
+
"root": true,
|
|
4
|
+
"vcs": {
|
|
5
|
+
"enabled": true,
|
|
6
|
+
"clientKind": "git",
|
|
7
|
+
"useIgnoreFile": true
|
|
8
|
+
},
|
|
9
|
+
"files": {
|
|
10
|
+
"includes": [
|
|
11
|
+
"**",
|
|
12
|
+
"!**/node_modules",
|
|
13
|
+
"!**/dist",
|
|
14
|
+
"!**/.output",
|
|
15
|
+
"!**/.tanstack",
|
|
16
|
+
"!**/.nitro",
|
|
17
|
+
"!**/.vinxi",
|
|
18
|
+
"!**/.wrangler",
|
|
19
|
+
"!**/routeTree.gen.ts"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"formatter": {
|
|
23
|
+
"enabled": true,
|
|
24
|
+
"indentStyle": "space",
|
|
25
|
+
"indentWidth": 2,
|
|
26
|
+
"lineWidth": 100
|
|
27
|
+
},
|
|
28
|
+
"linter": {
|
|
29
|
+
"enabled": true,
|
|
30
|
+
"rules": {
|
|
31
|
+
"preset": "recommended"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"javascript": {
|
|
35
|
+
"formatter": {
|
|
36
|
+
"quoteStyle": "double",
|
|
37
|
+
"semicolons": "always"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"css": {
|
|
41
|
+
"parser": {
|
|
42
|
+
"tailwindDirectives": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
3
|
+
"format_price": "{amount}",
|
|
4
|
+
"home_no_products": "No products yet.",
|
|
5
|
+
"root_error_title": "Couldn't load this page",
|
|
6
|
+
"root_go_home": "Go to home",
|
|
7
|
+
"root_not_found_title": "Page not found",
|
|
8
|
+
"site_store_fallback": "Store"
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
3
|
+
"format_price": "{amount}원",
|
|
4
|
+
"home_no_products": "등록된 상품이 없습니다",
|
|
5
|
+
"root_error_title": "화면을 불러오지 못했습니다",
|
|
6
|
+
"root_go_home": "홈으로",
|
|
7
|
+
"root_not_found_title": "페이지를 찾을 수 없습니다",
|
|
8
|
+
"site_store_fallback": "쇼핑몰"
|
|
9
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sayren/cli-boilerplate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite dev --port 4010 --strictPort",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"start": "vite preview --port 4010 --strictPort",
|
|
10
|
+
"lint": "biome check --write . && tsc --noEmit",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "vitest run"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@avarlabs/editor": "^0.1.3",
|
|
16
|
+
"@hookform/resolvers": "^5.9.1",
|
|
17
|
+
"@sayren/storefront-sdk": "^0.35.0",
|
|
18
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
19
|
+
"@tanstack/react-query": "^5.103.1",
|
|
20
|
+
"@tanstack/react-router": "^1.170.38",
|
|
21
|
+
"@tanstack/react-router-ssr-query": "^1.167.3",
|
|
22
|
+
"@tanstack/react-start": "^1.168.56",
|
|
23
|
+
"embla-carousel-react": "^8.6.0",
|
|
24
|
+
"gsap": "^3.15.0",
|
|
25
|
+
"lenis": "^1.3.26",
|
|
26
|
+
"lucide-react": "^1.50.0",
|
|
27
|
+
"nuqs": "^2.10.1",
|
|
28
|
+
"query-string": "^9.5.1",
|
|
29
|
+
"react": "^19.3.0",
|
|
30
|
+
"react-daum-postcode": "^4.0.0",
|
|
31
|
+
"react-dom": "^19.3.0",
|
|
32
|
+
"react-hook-form": "^7.88.0",
|
|
33
|
+
"tailwindcss": "^4.3.3",
|
|
34
|
+
"zod": "^4.6.5"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@biomejs/biome": "^2.5.14",
|
|
38
|
+
"@types/node": "^24.13.5",
|
|
39
|
+
"@types/react": "^19.3.0",
|
|
40
|
+
"@types/react-dom": "^19.3.0",
|
|
41
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"vite": "^8.3.0",
|
|
44
|
+
"vitest": "^5.0.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Workers 정적 자산 응답 헤더(https://developers.cloudflare.com/workers/static-assets/headers/).
|
|
2
|
+
# /assets/는 빌드가 만든 파일 이름에 내용 해시가 붙은 파일만 둔다 — 내용이 바뀌면 이름이 바뀌므로 1년 동안 다시 묻지 않는다.
|
|
3
|
+
# 해시 없는 파일(파비콘 등)과 서버가 그리는 HTML은 기본값(max-age=0, must-revalidate)이다. public/assets/에 파일을 두지 마십시오.
|
|
4
|
+
/assets/*
|
|
5
|
+
Cache-Control: public, max-age=31536000, immutable
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><title>상점</title><rect width="32" height="32" rx="7" fill="#202429"/><path d="M9 12h14l-1.4 11.2a2 2 0 0 1-2 1.8h-7.2a2 2 0 0 1-2-1.8z" fill="#fff"/><path d="M12.5 12V10.5a3.5 3.5 0 0 1 7 0V12" fill="none" stroke="#fff" stroke-width="1.8"/></svg>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useSuspenseQuery } from "@tanstack/react-query";
|
|
2
|
+
import {
|
|
3
|
+
categoriesQuery,
|
|
4
|
+
type ProductSearch,
|
|
5
|
+
productQuery,
|
|
6
|
+
productsQuery,
|
|
7
|
+
storeQuery,
|
|
8
|
+
} from "../lib/queries";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 기본 훅 — 화면은 이 훅으로 데이터를 읽는다. 라우트 loader에서 같은 queryOptions로 `ensureQueryData`를 먼저 부르면
|
|
12
|
+
* 서버 렌더에 데이터가 실려 첫 화면이 바로 그려진다(`loader-first` 규칙).
|
|
13
|
+
*/
|
|
14
|
+
export function useStore() {
|
|
15
|
+
return useSuspenseQuery(storeQuery()).data;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useCategories() {
|
|
19
|
+
return useSuspenseQuery(categoriesQuery()).data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useProducts(search: ProductSearch = {}) {
|
|
23
|
+
return useSuspenseQuery(productsQuery(search)).data;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useProduct(productId: string) {
|
|
27
|
+
return useSuspenseQuery(productQuery(productId)).data;
|
|
28
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import en from "../../messages/en.json";
|
|
2
|
+
import ko from "../../messages/ko.json";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 화면 문구(이슈 #73) — 문구는 `messages/{locale}.json`에만 쓰고 코드에서는 `m.키()`로 부른다.
|
|
6
|
+
* 키는 `{영역}_{이름}`이고 값의 `{name}`은 인자로 채운다(`m.cart_count({ count: 3 })`).
|
|
7
|
+
* 형식은 inlang 메시지 형식이라 Paraglide 같은 도구로 옮겨도 그대로 읽힌다.
|
|
8
|
+
*
|
|
9
|
+
* 언어를 더하려면 `messages/en.json`을 만들고 아래 `CATALOGS`와 `LOCALES`에 더한다. 빠진 키는 기본 언어(ko) 문구를 쓴다.
|
|
10
|
+
* 언어는 요청마다 `?lang=` → `Accept-Language` → `ko` 순서로 정한다(`src/start.ts`, 서버). 브라우저는 서버가 정한
|
|
11
|
+
* `<html lang>`을 읽어 같은 언어로 이어 그린다.
|
|
12
|
+
*
|
|
13
|
+
* 모듈 최상위에서 `m.*()`를 부르지 않는다 — 서버에서는 요청마다 언어가 다르다. 라벨 표는 `lazyMessages`로 만든다.
|
|
14
|
+
*/
|
|
15
|
+
export const LOCALES = ["ko", "en"] as const;
|
|
16
|
+
export type Locale = (typeof LOCALES)[number];
|
|
17
|
+
export const BASE_LOCALE: Locale = "ko";
|
|
18
|
+
export const LOCALE_QUERY_PARAM = "lang";
|
|
19
|
+
|
|
20
|
+
type Catalog = typeof ko;
|
|
21
|
+
export type MessageKey = Exclude<keyof Catalog, "$schema">;
|
|
22
|
+
export type MessageArgs = Readonly<Record<string, string | number>>;
|
|
23
|
+
|
|
24
|
+
const CATALOGS: Record<Locale, Partial<Record<MessageKey, string>>> = { ko, en };
|
|
25
|
+
|
|
26
|
+
/** `ko-KR`·`KO` → `ko`. 지원하지 않으면 undefined */
|
|
27
|
+
export function toLocale(value: string | null | undefined): Locale | undefined {
|
|
28
|
+
if (!value) return undefined;
|
|
29
|
+
const tag = value.trim().toLowerCase().replace("_", "-");
|
|
30
|
+
return (LOCALES as readonly string[]).find((l) => l === tag || l === tag.split("-")[0]) as
|
|
31
|
+
| Locale
|
|
32
|
+
| undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 요청 언어 — `?lang=` → `Accept-Language`(q값 순) → 기본 언어 */
|
|
36
|
+
export function resolveLocale(url: URL, acceptLanguage: string | null): Locale {
|
|
37
|
+
const query = toLocale(url.searchParams.get(LOCALE_QUERY_PARAM));
|
|
38
|
+
if (query) return query;
|
|
39
|
+
const accepted = (acceptLanguage ?? "")
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((part) => {
|
|
42
|
+
const [tag, ...params] = part.trim().split(";");
|
|
43
|
+
const q = Number(params.find((p) => p.trim().startsWith("q="))?.split("=")[1] ?? 1);
|
|
44
|
+
return { tag: tag ?? "", q: Number.isFinite(q) ? q : 0 };
|
|
45
|
+
})
|
|
46
|
+
.filter((entry) => entry.tag && entry.q > 0)
|
|
47
|
+
.sort((a, b) => b.q - a.q);
|
|
48
|
+
for (const { tag } of accepted) {
|
|
49
|
+
const locale = toLocale(tag);
|
|
50
|
+
if (locale) return locale;
|
|
51
|
+
}
|
|
52
|
+
return BASE_LOCALE;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let serverLocale: (() => Locale | undefined) | undefined;
|
|
56
|
+
|
|
57
|
+
/** 서버의 요청 언어 저장소를 연결한다(`locale.server.ts`) */
|
|
58
|
+
export function setServerLocaleGetter(getter: () => Locale | undefined): void {
|
|
59
|
+
serverLocale = getter;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 지금 언어 — 서버는 요청 언어, 브라우저는 서버가 정한 `<html lang>` */
|
|
63
|
+
export function getLocale(): Locale {
|
|
64
|
+
if (typeof document === "undefined") return serverLocale?.() ?? BASE_LOCALE;
|
|
65
|
+
return toLocale(document.documentElement.lang) ?? BASE_LOCALE;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `{name}` 자리를 채운다. 값이 없는 자리는 그대로 둔다 */
|
|
69
|
+
export function formatMessage(template: string, args?: MessageArgs): string {
|
|
70
|
+
if (!args) return template;
|
|
71
|
+
return template.replace(/\{(\w+)\}/g, (whole, name: string) =>
|
|
72
|
+
Object.hasOwn(args, name) ? String(args[name]) : whole,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function t(key: MessageKey, args?: MessageArgs): string {
|
|
77
|
+
const template = CATALOGS[getLocale()][key] ?? CATALOGS[BASE_LOCALE][key] ?? key;
|
|
78
|
+
return formatMessage(template, args);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `m.키(인자)` — 키 오타는 타입 검사가 잡는다 */
|
|
82
|
+
export const m = Object.fromEntries(
|
|
83
|
+
(Object.keys(ko) as (keyof Catalog)[])
|
|
84
|
+
.filter((key): key is MessageKey => key !== "$schema")
|
|
85
|
+
.map((key) => [key, (args?: MessageArgs) => t(key, args)]),
|
|
86
|
+
) as { readonly [K in MessageKey]: (args?: MessageArgs) => string };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 라벨 표 — 값을 읽을 때마다 문구를 만든다(서버에서 요청 언어를 따른다). 타입은 `Record<K, string>`이다.
|
|
90
|
+
*/
|
|
91
|
+
export function lazyMessages<K extends string>(
|
|
92
|
+
table: Record<K, () => string>,
|
|
93
|
+
): Readonly<Record<K, string>> {
|
|
94
|
+
const labels = {} as Record<K, string>;
|
|
95
|
+
for (const key of Object.keys(table) as K[]) {
|
|
96
|
+
Object.defineProperty(labels, key, { enumerable: true, get: () => table[key]() });
|
|
97
|
+
}
|
|
98
|
+
return Object.freeze(labels);
|
|
99
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { type Locale, setServerLocaleGetter } from "./index";
|
|
3
|
+
|
|
4
|
+
/** 요청마다 정한 언어를 그 요청이 끝날 때까지 유지한다(동시에 들어온 요청끼리 섞이지 않는다) */
|
|
5
|
+
const storage = new AsyncLocalStorage<Locale>();
|
|
6
|
+
setServerLocaleGetter(() => storage.getStore());
|
|
7
|
+
|
|
8
|
+
export function runWithLocale<T>(locale: Locale, fn: () => T): T {
|
|
9
|
+
return storage.run(locale, fn);
|
|
10
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Analytics,
|
|
3
|
+
type AnalyticsOptions,
|
|
4
|
+
type AnalyticsTrackInput,
|
|
5
|
+
createAnalytics,
|
|
6
|
+
} from "@sayren/storefront-sdk/analytics";
|
|
7
|
+
import { useEffect } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 방문 분석 — 브라우저 전용. 루트가 한 번 시작하고, 화면은 `useTrack`으로 행동을 남긴다.
|
|
11
|
+
*
|
|
12
|
+
* 장바구니·결제 시작·구매는 여기서 보내지 않는다. 스토어프론트 API가 요청을 처리하면서 서버에서
|
|
13
|
+
* 기록한다(`api.server.ts`가 방문자·세션 쿠키를 헤더로 싣는다).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 루트 loader(서버 함수)가 내려주는 값 */
|
|
17
|
+
export interface AnalyticsConfig {
|
|
18
|
+
apiBaseUrl: string;
|
|
19
|
+
storeCode: string;
|
|
20
|
+
/** localhost에서도 보낸다(`SAYREN_ANALYTICS_DEBUG=1`) */
|
|
21
|
+
debug: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let instance: Analytics | null = null;
|
|
25
|
+
/**
|
|
26
|
+
* 시작 전에 들어온 행동. React는 자식 effect를 부모보다 먼저 돌리므로 첫 화면의 `useTrack`이
|
|
27
|
+
* 루트의 시작보다 앞선다.
|
|
28
|
+
*/
|
|
29
|
+
let pending: AnalyticsTrackInput[] = [];
|
|
30
|
+
|
|
31
|
+
export function startAnalytics(config: AnalyticsConfig): Analytics {
|
|
32
|
+
if (instance) return instance;
|
|
33
|
+
const options: AnalyticsOptions = {
|
|
34
|
+
baseUrl: config.apiBaseUrl,
|
|
35
|
+
storeCode: config.storeCode,
|
|
36
|
+
debug: config.debug,
|
|
37
|
+
// 쿠키 동의 배너를 붙이는 상점은 "pending"으로 시작하고 배너에서 setConsent를 부른다
|
|
38
|
+
consent: "granted",
|
|
39
|
+
};
|
|
40
|
+
instance = createAnalytics(options);
|
|
41
|
+
for (const input of pending) instance.track(input);
|
|
42
|
+
pending = [];
|
|
43
|
+
return instance;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function track(input: AnalyticsTrackInput) {
|
|
47
|
+
if (instance) instance.track(input);
|
|
48
|
+
else pending.push(input);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 화면이 보일 때 행동을 한 번 남긴다. 내용이 같으면 리렌더·StrictMode 이중 실행에도 다시 보내지
|
|
53
|
+
* 않는다(SDK도 같은 페이지뷰 안의 중복을 거른다). `null`이면 아무것도 보내지 않는다.
|
|
54
|
+
*/
|
|
55
|
+
export function useTrack(input: AnalyticsTrackInput | null) {
|
|
56
|
+
const key = input ? JSON.stringify(input) : "";
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (key) track(JSON.parse(key) as AnalyticsTrackInput);
|
|
59
|
+
}, [key]);
|
|
60
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { analyticsIdsFromCookie, createStorefrontClient } from "@sayren/storefront-sdk";
|
|
2
|
+
import { getRequest } from "@tanstack/react-start/server";
|
|
3
|
+
import { API_BASE_URL, resolveStoreCode } from "./config.server";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 서버(서버 함수·서버 라우트)에서 쓰는 스토어프론트 API 클라이언트.
|
|
7
|
+
*
|
|
8
|
+
* 테넌트는 `X-Store-Code` 헤더로 보낸다. 토큰은 요청마다 넘긴다 — 모듈 전역에 담으면 서버 렌더에서
|
|
9
|
+
* 다른 사용자의 요청에 섞인다. 방문 분석 쿠키(방문자·세션)를 헤더로 실어 서버가 기록하는 이벤트를 방문과 잇는다.
|
|
10
|
+
*/
|
|
11
|
+
export function apiFor(
|
|
12
|
+
options: {
|
|
13
|
+
accessToken?: string | null;
|
|
14
|
+
cartToken?: string | null;
|
|
15
|
+
onCartToken?: (token: string) => void;
|
|
16
|
+
} = {},
|
|
17
|
+
) {
|
|
18
|
+
const request = getRequest();
|
|
19
|
+
return createStorefrontClient({
|
|
20
|
+
baseUrl: API_BASE_URL,
|
|
21
|
+
storeCode: resolveStoreCode(),
|
|
22
|
+
accessToken: options.accessToken ?? undefined,
|
|
23
|
+
cartToken: options.cartToken ?? undefined,
|
|
24
|
+
onCartToken: options.onCartToken,
|
|
25
|
+
...analyticsIdsFromCookie(request.headers.get("cookie")),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createStorefrontClient } from "@sayren/storefront-sdk";
|
|
2
|
+
import type { PublicConfig } from "./config";
|
|
3
|
+
|
|
4
|
+
/** 브라우저용 — 서버 함수가 내려준 설정으로 만든다(결제창·결제 승인·결제 상태 조회에 쓴다) */
|
|
5
|
+
export function browserApi(config: PublicConfig) {
|
|
6
|
+
return createStorefrontClient({ baseUrl: config.apiBaseUrl, storeCode: config.storeCode });
|
|
7
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getRequestHeader } from "@tanstack/react-start/server";
|
|
2
|
+
import { storeCodeFromHost } from "./config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 서버 전용 설정 — `.server.ts`라 브라우저 번들에 들어가지 않는다. `process.env`를 여기에만 둔다.
|
|
6
|
+
* 브라우저에 필요한 값은 서버 함수가 내려준다(`PublicConfig`).
|
|
7
|
+
*/
|
|
8
|
+
export const API_BASE_URL = process.env.SAYREN_API_URL ?? "https://api.sayren.io/storefront/v1";
|
|
9
|
+
|
|
10
|
+
/** localhost에서도 방문 분석을 보낸다 — 기본은 개발 트래픽을 섞지 않으려고 끈다 */
|
|
11
|
+
export const ANALYTICS_DEBUG = process.env.SAYREN_ANALYTICS_DEBUG === "1";
|
|
12
|
+
|
|
13
|
+
const FIXED_STORE_CODE = process.env.SAYREN_STORE_CODE ?? "";
|
|
14
|
+
|
|
15
|
+
/** 이 요청의 테넌트 — 고정 값이 먼저이고, 없으면 호스트 서브도메인에서 뽑는다 */
|
|
16
|
+
export function resolveStoreCode(): string {
|
|
17
|
+
if (FIXED_STORE_CODE) return FIXED_STORE_CODE;
|
|
18
|
+
const fromHost = storeCodeFromHost(getRequestHeader("host"));
|
|
19
|
+
if (fromHost) return fromHost;
|
|
20
|
+
throw new Error(
|
|
21
|
+
"상점 코드를 알 수 없습니다. SAYREN_STORE_CODE 환경변수를 설정하거나 서브도메인으로 서비스해 주십시오",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { storeCodeFromHost } from "./config";
|
|
3
|
+
|
|
4
|
+
describe("상점 코드 해석", () => {
|
|
5
|
+
it("서브도메인이 있으면 첫 라벨을 쓴다", () => {
|
|
6
|
+
expect(storeCodeFromHost("mystore.example.com")).toBe("mystore");
|
|
7
|
+
expect(storeCodeFromHost("mystore.example.com:443")).toBe("mystore");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("서브도메인이 없거나 로컬·IP면 알 수 없다", () => {
|
|
11
|
+
expect(storeCodeFromHost("example.com")).toBeNull();
|
|
12
|
+
expect(storeCodeFromHost("localhost:4010")).toBeNull();
|
|
13
|
+
expect(storeCodeFromHost("127.0.0.1:4010")).toBeNull();
|
|
14
|
+
expect(storeCodeFromHost(null)).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 브라우저와 서버가 함께 쓰는 값만 둔다. `process.env`는 여기에 두지 않는다 —
|
|
3
|
+
* 브라우저 번들에 들어가면 `process is not defined`로 화면이 깨진다(서버 값은 `config.server.ts`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 호스트에서 서브도메인을 뽑는다(`mystore.example.com` → `mystore`) */
|
|
7
|
+
export function storeCodeFromHost(host: string | null | undefined): string | null {
|
|
8
|
+
if (!host) return null;
|
|
9
|
+
const hostname = host.split(":")[0] ?? "";
|
|
10
|
+
if (hostname === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return null;
|
|
11
|
+
const labels = hostname.split(".");
|
|
12
|
+
return labels.length >= 3 ? (labels[0] ?? null) : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 서버 함수가 브라우저로 내려주는 값 */
|
|
16
|
+
export interface PublicConfig {
|
|
17
|
+
apiBaseUrl: string;
|
|
18
|
+
storeCode: string;
|
|
19
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BASE_CURRENCY, currencySpecOf, minorToMajor } from "@sayren/storefront-sdk";
|
|
2
|
+
import { m } from "../i18n";
|
|
3
|
+
|
|
4
|
+
const KRW = new Intl.NumberFormat("ko-KR");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 통화 금액 — 금액은 그 통화의 최소 단위 정수다(API 응답 그대로, 화면에서 계산하지 않는다). 원화는 「12,000원」,
|
|
8
|
+
* 그 밖의 통화는 `Intl` 통화 형식이다(「US$10.99」). 서버와 브라우저 결과가 같도록 로케일을 고정한다
|
|
9
|
+
*/
|
|
10
|
+
export function formatMoney(amount: number, currency: string = BASE_CURRENCY): string {
|
|
11
|
+
if (currency === BASE_CURRENCY) return m.format_price({ amount: KRW.format(amount) });
|
|
12
|
+
const { digits } = currencySpecOf(currency);
|
|
13
|
+
return new Intl.NumberFormat("ko-KR", {
|
|
14
|
+
style: "currency",
|
|
15
|
+
currency,
|
|
16
|
+
minimumFractionDigits: digits,
|
|
17
|
+
maximumFractionDigits: digits,
|
|
18
|
+
}).format(minorToMajor(amount, currency));
|
|
19
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 기본 조회 — TanStack Query의 queryOptions 팩토리다. 라우트 loader(`ensureQueryData`)와 화면(`useSuspenseQuery`, `src/hooks`)이
|
|
9
|
+
* 같은 팩토리를 써서 queryKey가 갈라지지 않는다. 데이터는 서버 함수로 받는다(첫 요청은 서버가 받아 HTML에 싣는다).
|
|
10
|
+
* 금액·재고·주문 가능 여부는 응답 값을 그대로 보인다 — 화면에서 계산하지 않는다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const getStore = createServerFn({ method: "GET" }).handler(() => apiFor().store.get());
|
|
14
|
+
|
|
15
|
+
const listCategories = createServerFn({ method: "GET" }).handler(() =>
|
|
16
|
+
apiFor().catalog.listCategories(),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export const productSearchSchema = z.object({
|
|
20
|
+
keyword: z.string().optional(),
|
|
21
|
+
categoryId: z.string().optional(),
|
|
22
|
+
sort: productSortSchema.optional(),
|
|
23
|
+
page: z.number().int().min(1).optional(),
|
|
24
|
+
size: z.number().int().min(1).max(60).optional(),
|
|
25
|
+
});
|
|
26
|
+
export type ProductSearch = z.infer<typeof productSearchSchema>;
|
|
27
|
+
|
|
28
|
+
const searchProducts = createServerFn({ method: "GET" })
|
|
29
|
+
.validator(productSearchSchema)
|
|
30
|
+
.handler(({ data }) =>
|
|
31
|
+
apiFor().catalog.searchProducts({ ...data, page: data.page ?? 1, size: data.size ?? 24 }),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const getProduct = createServerFn({ method: "GET" })
|
|
35
|
+
.validator(z.object({ productId: z.string().min(1) }))
|
|
36
|
+
.handler(({ data }) => apiFor().catalog.getProduct(data.productId));
|
|
37
|
+
|
|
38
|
+
/** 상점 이름·로고·연락처 — 상점 플랫폼 설정 › 상점 정보 값 */
|
|
39
|
+
export const storeQuery = () =>
|
|
40
|
+
queryOptions({ queryKey: ["store"], queryFn: () => getStore(), staleTime: 5 * 60_000 });
|
|
41
|
+
|
|
42
|
+
/** 카테고리는 자주 바뀌지 않는다 — 5분 동안 다시 받지 않는다 */
|
|
43
|
+
export const categoriesQuery = () =>
|
|
44
|
+
queryOptions({
|
|
45
|
+
queryKey: ["catalog", "categories"],
|
|
46
|
+
queryFn: () => listCategories(),
|
|
47
|
+
staleTime: 5 * 60_000,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const productsQuery = (search: ProductSearch) =>
|
|
51
|
+
queryOptions({
|
|
52
|
+
queryKey: ["catalog", "products", search],
|
|
53
|
+
queryFn: () => searchProducts({ data: search }),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** 상품 하나 — 상품 id 또는 상점별 상품번호 */
|
|
57
|
+
export const productQuery = (productId: string) =>
|
|
58
|
+
queryOptions({
|
|
59
|
+
queryKey: ["catalog", "product", productId],
|
|
60
|
+
queryFn: () => getProduct({ data: { productId } }),
|
|
61
|
+
});
|