@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
@@ -0,0 +1,121 @@
1
+ import { ApiError, type TokenPair } from "@sayren/storefront-sdk/auth";
2
+ import { getGlobalStartContext } from "@tanstack/react-start";
3
+ import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server";
4
+ import { authFor } from "./api.server";
5
+
6
+ /**
7
+ * 구매자 세션 — 로그인 토큰 쌍을 HttpOnly 쿠키 하나에 둔다. 브라우저 JS는 토큰을 읽지 못하고,
8
+ * 서버(서버 함수·서버 라우트)만 쓴다.
9
+ *
10
+ * 액세스 토큰은 30분이라 `refreshBuyerSession`(전역 요청 미들웨어, `start.ts`)이 요청마다 만료를 보고,
11
+ * 곧 끝나면 리프레시 토큰으로 새 쌍을 받아 쿠키를 갈아 끼운다. 리프레시 토큰이 거부되면(401: 14일 만료·폐기)
12
+ * 쿠키를 지워 로그아웃 상태가 된다. 네트워크 오류·5xx 같은 일시 장애에는 쿠키를 지우지 않는다 — 다음 요청에서
13
+ * 다시 갱신한다.
14
+ */
15
+ const SESSION_COOKIE = "sayren_member";
16
+
17
+ interface StoredSession {
18
+ accessToken: string;
19
+ refreshToken: string;
20
+ /** 액세스 토큰 만료 시각(epoch ms) */
21
+ expiresAt: number;
22
+ }
23
+
24
+ /** 만료 이만큼 전에 미리 갱신한다 — 요청 도중 만료로 401이 나지 않게 */
25
+ const REFRESH_LEEWAY_MS = 60_000;
26
+
27
+ /**
28
+ * 같은 리프레시 토큰의 갱신을 한 번으로 모은다. 화면 이동 한 번에 서버 함수 요청이 여러 개 동시에 오는데,
29
+ * 리프레시 토큰은 한 번 쓰면 폐기되므로 각자 갱신하면 늦은 쪽이 401을 받아 로그아웃된다.
30
+ * 결과는 그 리프레시 토큰을 들고 온 요청에만 돌려준다(다른 구매자와 섞이지 않는다).
31
+ */
32
+ const refreshing = new Map<string, Promise<TokenPair>>();
33
+ const REFRESH_SHARE_MS = 10_000;
34
+
35
+ function refreshOnce(refreshToken: string): Promise<TokenPair> {
36
+ const inFlight = refreshing.get(refreshToken);
37
+ if (inFlight) return inFlight;
38
+ const next = authFor().refresh(refreshToken);
39
+ refreshing.set(refreshToken, next);
40
+ const forget = () => {
41
+ setTimeout(() => refreshing.delete(refreshToken), REFRESH_SHARE_MS);
42
+ };
43
+ next.then(forget, forget);
44
+ return next;
45
+ }
46
+
47
+ function toStored(tokens: TokenPair): StoredSession {
48
+ return {
49
+ accessToken: tokens.accessToken,
50
+ refreshToken: tokens.refreshToken,
51
+ expiresAt: Date.now() + tokens.expiresIn * 1000,
52
+ };
53
+ }
54
+
55
+ function readSession(): StoredSession | null {
56
+ const raw = getCookie(SESSION_COOKIE);
57
+ if (!raw) return null;
58
+ let value: unknown;
59
+ try {
60
+ value = JSON.parse(raw);
61
+ } catch {
62
+ return null;
63
+ }
64
+ if (!value || typeof value !== "object") return null;
65
+ const session = value as Partial<StoredSession>;
66
+ if (typeof session.accessToken !== "string" || typeof session.refreshToken !== "string")
67
+ return null;
68
+ return {
69
+ accessToken: session.accessToken,
70
+ refreshToken: session.refreshToken,
71
+ expiresAt: Number(session.expiresAt),
72
+ };
73
+ }
74
+
75
+ function writeSession(session: StoredSession) {
76
+ setCookie(SESSION_COOKIE, JSON.stringify(session), {
77
+ httpOnly: true,
78
+ sameSite: "lax",
79
+ path: "/",
80
+ secure: process.env.NODE_ENV === "production",
81
+ maxAge: 60 * 60 * 24 * 14,
82
+ });
83
+ }
84
+
85
+ /**
86
+ * 이 요청의 액세스 토큰을 정한다. 만료가 가까우면 갱신하고 응답에 새 쿠키를 싣는다.
87
+ * 전역 요청 미들웨어가 요청마다 한 번 부른다.
88
+ */
89
+ export async function refreshBuyerSession(): Promise<string | null> {
90
+ const stored = readSession();
91
+ if (!stored) return null;
92
+ // 만료 시각을 모르면(형식이 다른 쿠키) 만료로 본다
93
+ if (stored.expiresAt - REFRESH_LEEWAY_MS > Date.now()) return stored.accessToken;
94
+ try {
95
+ const refreshed = toStored(await refreshOnce(stored.refreshToken));
96
+ writeSession(refreshed);
97
+ return refreshed.accessToken;
98
+ } catch (error) {
99
+ if (error instanceof ApiError && error.status === 401) {
100
+ signOut();
101
+ return null;
102
+ }
103
+ // 일시 장애면 이번 요청은 기존 토큰으로 진행한다(만료 전 여유 1분 안이면 아직 유효하다)
104
+ return stored.accessToken;
105
+ }
106
+ }
107
+
108
+ /** 이 요청의 구매자 액세스 토큰 — 로그인하지 않았으면 null */
109
+ export function readToken(): string | null {
110
+ return getGlobalStartContext()?.accessToken ?? null;
111
+ }
112
+
113
+ /** 로그인·가입 직후 응답에 쿠키를 싣는다 */
114
+ export function signIn(tokens: TokenPair) {
115
+ writeSession(toStored(tokens));
116
+ }
117
+
118
+ /** 로그아웃 — 쿠키를 지운다 */
119
+ export function signOut() {
120
+ deleteCookie(SESSION_COOKIE, { path: "/" });
121
+ }
@@ -0,0 +1,54 @@
1
+ import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server";
2
+
3
+ /**
4
+ * 소셜 로그인 왕복 상태 — 공급자로 보내기 전에 만든 state·PKCE verifier·돌아갈 곳을 HttpOnly 쿠키에 잠깐 둔다.
5
+ * 돌아오면 state를 비교하고 verifier로 1회용 code를 토큰으로 바꾼 뒤 지운다. 10분이 지나면 사라진다.
6
+ */
7
+ const SOCIAL_COOKIE = "sayren_social";
8
+
9
+ export interface SocialLoginState {
10
+ state: string;
11
+ codeVerifier: string;
12
+ redirectTo: string;
13
+ /** 로그인한 구매자가 소셜 계정을 연결하는 흐름이면 그 공급자 — 콜백이 로그인 대신 연결로 마무리한다 */
14
+ linkProvider?: "kakao" | "naver" | "google";
15
+ }
16
+
17
+ export function writeSocialLogin(value: SocialLoginState) {
18
+ setCookie(SOCIAL_COOKIE, JSON.stringify(value), {
19
+ httpOnly: true,
20
+ sameSite: "lax",
21
+ path: "/",
22
+ secure: process.env.NODE_ENV === "production",
23
+ maxAge: 60 * 10,
24
+ });
25
+ }
26
+
27
+ export function readSocialLogin(): SocialLoginState | null {
28
+ const raw = getCookie(SOCIAL_COOKIE);
29
+ if (!raw) return null;
30
+ let value: unknown;
31
+ try {
32
+ value = JSON.parse(raw);
33
+ } catch {
34
+ return null;
35
+ }
36
+ if (!value || typeof value !== "object") return null;
37
+ const stored = value as Partial<SocialLoginState>;
38
+ if (typeof stored.state !== "string" || typeof stored.codeVerifier !== "string") return null;
39
+ return {
40
+ state: stored.state,
41
+ codeVerifier: stored.codeVerifier,
42
+ redirectTo: typeof stored.redirectTo === "string" ? stored.redirectTo : "/",
43
+ linkProvider:
44
+ stored.linkProvider === "kakao" ||
45
+ stored.linkProvider === "naver" ||
46
+ stored.linkProvider === "google"
47
+ ? stored.linkProvider
48
+ : undefined,
49
+ };
50
+ }
51
+
52
+ export function clearSocialLogin() {
53
+ deleteCookie(SOCIAL_COOKIE, { path: "/" });
54
+ }
@@ -0,0 +1,283 @@
1
+ /* eslint-disable */
2
+
3
+ // @ts-nocheck
4
+
5
+ // noinspection JSUnusedGlobalSymbols
6
+
7
+ // This file was automatically generated by TanStack Router.
8
+ // You should NOT make any changes in this file as it will be overwritten.
9
+ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
+
11
+ import { Route as rootRouteImport } from './routes/__root'
12
+ import { Route as IndexRouteImport } from './routes/index'
13
+ import { Route as AccountRouteImport } from './routes/account'
14
+ import { Route as CartRouteImport } from './routes/cart'
15
+ import { Route as LoginRouteImport } from './routes/login'
16
+ import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
17
+ import { Route as CheckoutIndexRouteImport } from './routes/checkout.index'
18
+ import { Route as CheckoutCompleteRouteImport } from './routes/checkout.complete'
19
+ import { Route as OrdersIndexRouteImport } from './routes/orders.index'
20
+ import { Route as OrdersOrderIdRouteImport } from './routes/orders.$orderId'
21
+ import { Route as ProductsIndexRouteImport } from './routes/products.index'
22
+ import { Route as ProductsProductIdRouteImport } from './routes/products.$productId'
23
+
24
+ const IndexRoute = IndexRouteImport.update({
25
+ id: '/',
26
+ path: '/',
27
+ getParentRoute: () => rootRouteImport,
28
+ } as any)
29
+ const AccountRoute = AccountRouteImport.update({
30
+ id: '/account',
31
+ path: '/account',
32
+ getParentRoute: () => rootRouteImport,
33
+ } as any)
34
+ const CartRoute = CartRouteImport.update({
35
+ id: '/cart',
36
+ path: '/cart',
37
+ getParentRoute: () => rootRouteImport,
38
+ } as any)
39
+ const LoginRoute = LoginRouteImport.update({
40
+ id: '/login',
41
+ path: '/login',
42
+ getParentRoute: () => rootRouteImport,
43
+ } as any)
44
+ const AuthCallbackRoute = AuthCallbackRouteImport.update({
45
+ id: '/auth/callback',
46
+ path: '/auth/callback',
47
+ getParentRoute: () => rootRouteImport,
48
+ } as any)
49
+ const CheckoutIndexRoute = CheckoutIndexRouteImport.update({
50
+ id: '/checkout/',
51
+ path: '/checkout/',
52
+ getParentRoute: () => rootRouteImport,
53
+ } as any)
54
+ const CheckoutCompleteRoute = CheckoutCompleteRouteImport.update({
55
+ id: '/checkout/complete',
56
+ path: '/checkout/complete',
57
+ getParentRoute: () => rootRouteImport,
58
+ } as any)
59
+ const OrdersIndexRoute = OrdersIndexRouteImport.update({
60
+ id: '/orders/',
61
+ path: '/orders/',
62
+ getParentRoute: () => rootRouteImport,
63
+ } as any)
64
+ const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
65
+ id: '/orders/$orderId',
66
+ path: '/orders/$orderId',
67
+ getParentRoute: () => rootRouteImport,
68
+ } as any)
69
+ const ProductsIndexRoute = ProductsIndexRouteImport.update({
70
+ id: '/products/',
71
+ path: '/products/',
72
+ getParentRoute: () => rootRouteImport,
73
+ } as any)
74
+ const ProductsProductIdRoute = ProductsProductIdRouteImport.update({
75
+ id: '/products/$productId',
76
+ path: '/products/$productId',
77
+ getParentRoute: () => rootRouteImport,
78
+ } as any)
79
+
80
+ export interface FileRoutesByFullPath {
81
+ '/': typeof IndexRoute
82
+ '/account': typeof AccountRoute
83
+ '/cart': typeof CartRoute
84
+ '/login': typeof LoginRoute
85
+ '/auth/callback': typeof AuthCallbackRoute
86
+ '/checkout/complete': typeof CheckoutCompleteRoute
87
+ '/orders/$orderId': typeof OrdersOrderIdRoute
88
+ '/products/$productId': typeof ProductsProductIdRoute
89
+ '/checkout/': typeof CheckoutIndexRoute
90
+ '/orders/': typeof OrdersIndexRoute
91
+ '/products/': typeof ProductsIndexRoute
92
+ }
93
+ export interface FileRoutesByTo {
94
+ '/': typeof IndexRoute
95
+ '/account': typeof AccountRoute
96
+ '/cart': typeof CartRoute
97
+ '/login': typeof LoginRoute
98
+ '/auth/callback': typeof AuthCallbackRoute
99
+ '/checkout/complete': typeof CheckoutCompleteRoute
100
+ '/orders/$orderId': typeof OrdersOrderIdRoute
101
+ '/products/$productId': typeof ProductsProductIdRoute
102
+ '/checkout': typeof CheckoutIndexRoute
103
+ '/orders': typeof OrdersIndexRoute
104
+ '/products': typeof ProductsIndexRoute
105
+ }
106
+ export interface FileRoutesById {
107
+ __root__: typeof rootRouteImport
108
+ '/': typeof IndexRoute
109
+ '/account': typeof AccountRoute
110
+ '/cart': typeof CartRoute
111
+ '/login': typeof LoginRoute
112
+ '/auth/callback': typeof AuthCallbackRoute
113
+ '/checkout/complete': typeof CheckoutCompleteRoute
114
+ '/orders/$orderId': typeof OrdersOrderIdRoute
115
+ '/products/$productId': typeof ProductsProductIdRoute
116
+ '/checkout/': typeof CheckoutIndexRoute
117
+ '/orders/': typeof OrdersIndexRoute
118
+ '/products/': typeof ProductsIndexRoute
119
+ }
120
+ export interface FileRouteTypes {
121
+ fileRoutesByFullPath: FileRoutesByFullPath
122
+ fullPaths:
123
+ | '/'
124
+ | '/account'
125
+ | '/cart'
126
+ | '/login'
127
+ | '/auth/callback'
128
+ | '/checkout/complete'
129
+ | '/orders/$orderId'
130
+ | '/products/$productId'
131
+ | '/checkout/'
132
+ | '/orders/'
133
+ | '/products/'
134
+ fileRoutesByTo: FileRoutesByTo
135
+ to:
136
+ | '/'
137
+ | '/account'
138
+ | '/cart'
139
+ | '/login'
140
+ | '/auth/callback'
141
+ | '/checkout/complete'
142
+ | '/orders/$orderId'
143
+ | '/products/$productId'
144
+ | '/checkout'
145
+ | '/orders'
146
+ | '/products'
147
+ id:
148
+ | '__root__'
149
+ | '/'
150
+ | '/account'
151
+ | '/cart'
152
+ | '/login'
153
+ | '/auth/callback'
154
+ | '/checkout/complete'
155
+ | '/orders/$orderId'
156
+ | '/products/$productId'
157
+ | '/checkout/'
158
+ | '/orders/'
159
+ | '/products/'
160
+ fileRoutesById: FileRoutesById
161
+ }
162
+ export interface RootRouteChildren {
163
+ IndexRoute: typeof IndexRoute
164
+ AccountRoute: typeof AccountRoute
165
+ CartRoute: typeof CartRoute
166
+ LoginRoute: typeof LoginRoute
167
+ AuthCallbackRoute: typeof AuthCallbackRoute
168
+ CheckoutCompleteRoute: typeof CheckoutCompleteRoute
169
+ OrdersOrderIdRoute: typeof OrdersOrderIdRoute
170
+ ProductsProductIdRoute: typeof ProductsProductIdRoute
171
+ CheckoutIndexRoute: typeof CheckoutIndexRoute
172
+ OrdersIndexRoute: typeof OrdersIndexRoute
173
+ ProductsIndexRoute: typeof ProductsIndexRoute
174
+ }
175
+
176
+ declare module '@tanstack/react-router' {
177
+ interface FileRoutesByPath {
178
+ '/': {
179
+ id: '/'
180
+ path: '/'
181
+ fullPath: '/'
182
+ preLoaderRoute: typeof IndexRouteImport
183
+ parentRoute: typeof rootRouteImport
184
+ }
185
+ '/account': {
186
+ id: '/account'
187
+ path: '/account'
188
+ fullPath: '/account'
189
+ preLoaderRoute: typeof AccountRouteImport
190
+ parentRoute: typeof rootRouteImport
191
+ }
192
+ '/cart': {
193
+ id: '/cart'
194
+ path: '/cart'
195
+ fullPath: '/cart'
196
+ preLoaderRoute: typeof CartRouteImport
197
+ parentRoute: typeof rootRouteImport
198
+ }
199
+ '/login': {
200
+ id: '/login'
201
+ path: '/login'
202
+ fullPath: '/login'
203
+ preLoaderRoute: typeof LoginRouteImport
204
+ parentRoute: typeof rootRouteImport
205
+ }
206
+ '/auth/callback': {
207
+ id: '/auth/callback'
208
+ path: '/auth/callback'
209
+ fullPath: '/auth/callback'
210
+ preLoaderRoute: typeof AuthCallbackRouteImport
211
+ parentRoute: typeof rootRouteImport
212
+ }
213
+ '/checkout/': {
214
+ id: '/checkout/'
215
+ path: '/checkout'
216
+ fullPath: '/checkout/'
217
+ preLoaderRoute: typeof CheckoutIndexRouteImport
218
+ parentRoute: typeof rootRouteImport
219
+ }
220
+ '/checkout/complete': {
221
+ id: '/checkout/complete'
222
+ path: '/checkout/complete'
223
+ fullPath: '/checkout/complete'
224
+ preLoaderRoute: typeof CheckoutCompleteRouteImport
225
+ parentRoute: typeof rootRouteImport
226
+ }
227
+ '/orders/': {
228
+ id: '/orders/'
229
+ path: '/orders'
230
+ fullPath: '/orders/'
231
+ preLoaderRoute: typeof OrdersIndexRouteImport
232
+ parentRoute: typeof rootRouteImport
233
+ }
234
+ '/orders/$orderId': {
235
+ id: '/orders/$orderId'
236
+ path: '/orders/$orderId'
237
+ fullPath: '/orders/$orderId'
238
+ preLoaderRoute: typeof OrdersOrderIdRouteImport
239
+ parentRoute: typeof rootRouteImport
240
+ }
241
+ '/products/': {
242
+ id: '/products/'
243
+ path: '/products'
244
+ fullPath: '/products/'
245
+ preLoaderRoute: typeof ProductsIndexRouteImport
246
+ parentRoute: typeof rootRouteImport
247
+ }
248
+ '/products/$productId': {
249
+ id: '/products/$productId'
250
+ path: '/products/$productId'
251
+ fullPath: '/products/$productId'
252
+ preLoaderRoute: typeof ProductsProductIdRouteImport
253
+ parentRoute: typeof rootRouteImport
254
+ }
255
+ }
256
+ }
257
+
258
+ const rootRouteChildren: RootRouteChildren = {
259
+ IndexRoute: IndexRoute,
260
+ AccountRoute: AccountRoute,
261
+ CartRoute: CartRoute,
262
+ LoginRoute: LoginRoute,
263
+ AuthCallbackRoute: AuthCallbackRoute,
264
+ CheckoutCompleteRoute: CheckoutCompleteRoute,
265
+ OrdersOrderIdRoute: OrdersOrderIdRoute,
266
+ ProductsProductIdRoute: ProductsProductIdRoute,
267
+ CheckoutIndexRoute: CheckoutIndexRoute,
268
+ OrdersIndexRoute: OrdersIndexRoute,
269
+ ProductsIndexRoute: ProductsIndexRoute,
270
+ }
271
+ export const routeTree = rootRouteImport
272
+ ._addFileChildren(rootRouteChildren)
273
+ ._addFileTypes<FileRouteTypes>()
274
+
275
+ import type { getRouter } from './router.tsx'
276
+ import type { startInstance } from './start.ts'
277
+ declare module '@tanstack/react-start' {
278
+ interface Register {
279
+ ssr: true
280
+ router: Awaited<ReturnType<typeof getRouter>>
281
+ config: Awaited<ReturnType<typeof startInstance.getOptions>>
282
+ }
283
+ }
@@ -0,0 +1,17 @@
1
+ import { createRouter } from "@tanstack/react-router";
2
+ import { routeTree } from "./routeTree.gen";
3
+
4
+ export function getRouter() {
5
+ return createRouter({
6
+ routeTree,
7
+ scrollRestoration: true,
8
+ // 주문서는 들어올 때마다 세션을 만든다. 마우스를 올린 것만으로 만들지 않도록 미리 불러오기를 끈다
9
+ defaultPreload: false,
10
+ });
11
+ }
12
+
13
+ declare module "@tanstack/react-router" {
14
+ interface Register {
15
+ router: ReturnType<typeof getRouter>;
16
+ }
17
+ }
@@ -0,0 +1,90 @@
1
+ import {
2
+ createRootRoute,
3
+ type ErrorComponentProps,
4
+ HeadContent,
5
+ Link,
6
+ Outlet,
7
+ Scripts,
8
+ } from "@tanstack/react-router";
9
+ import { createServerFn } from "@tanstack/react-start";
10
+ import { useEffect } from "react";
11
+ import { SiteHeader } from "../components/site-header";
12
+ import { type AnalyticsConfig, startAnalytics } from "../lib/analytics";
13
+ import { ANALYTICS_DEBUG, API_BASE_URL, resolveStoreCode } from "../lib/config.server";
14
+ import appCss from "../styles.css?url";
15
+
16
+ /** 방문 분석 설정 — 서버 값(`process.env`)이라 서버 함수로 내려준다 */
17
+ const getAnalyticsConfig = createServerFn({ method: "GET" }).handler(
18
+ (): AnalyticsConfig => ({
19
+ apiBaseUrl: API_BASE_URL,
20
+ storeCode: resolveStoreCode(),
21
+ debug: ANALYTICS_DEBUG,
22
+ }),
23
+ );
24
+
25
+ export const Route = createRootRoute({
26
+ head: () => ({
27
+ meta: [
28
+ { charSet: "utf-8" },
29
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
30
+ { title: "스토어" },
31
+ ],
32
+ links: [{ rel: "stylesheet", href: appCss }],
33
+ }),
34
+ loader: async () => ({ analytics: await getAnalyticsConfig() }),
35
+ // 설정은 바뀌지 않는다 — 화면을 옮길 때마다 다시 받지 않는다
36
+ staleTime: Number.POSITIVE_INFINITY,
37
+ shellComponent: RootDocument,
38
+ component: Root,
39
+ errorComponent: RootError,
40
+ notFoundComponent: NotFound,
41
+ });
42
+
43
+ function RootDocument({ children }: { children: React.ReactNode }) {
44
+ return (
45
+ <html lang="ko">
46
+ <head>
47
+ <HeadContent />
48
+ </head>
49
+ <body className="min-h-screen bg-white text-ink">
50
+ <SiteHeader />
51
+ <main className="mx-auto w-full max-w-5xl px-4 py-8">{children}</main>
52
+ <Scripts />
53
+ </body>
54
+ </html>
55
+ );
56
+ }
57
+
58
+ function Root() {
59
+ const { analytics } = Route.useLoaderData();
60
+ // 방문 분석은 브라우저에서 한 번 시작한다. 이후 페이지뷰는 SDK가 History API 이동으로 센다
61
+ useEffect(() => {
62
+ startAnalytics(analytics);
63
+ }, [analytics]);
64
+ return <Outlet />;
65
+ }
66
+
67
+ function RootError({ error }: ErrorComponentProps) {
68
+ return (
69
+ <ErrorView
70
+ title="문제가 생겼어요"
71
+ detail={error instanceof Error ? error.message : undefined}
72
+ />
73
+ );
74
+ }
75
+
76
+ function NotFound() {
77
+ return <ErrorView title="찾을 수 없어요" />;
78
+ }
79
+
80
+ function ErrorView({ title, detail }: { title: string; detail?: string }) {
81
+ return (
82
+ <div className="space-y-3 py-16 text-center">
83
+ <h1 className="font-bold text-2xl">{title}</h1>
84
+ {detail ? <p className="text-muted">{detail}</p> : null}
85
+ <Link className="inline-block text-point underline" to="/">
86
+ 홈으로
87
+ </Link>
88
+ </div>
89
+ );
90
+ }