@sayren/mcp 0.1.7 → 0.1.9

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 (53) hide show
  1. package/dist/index.mjs +22 -19
  2. package/package.json +1 -1
  3. package/template/README.md +29 -12
  4. package/template/_gitignore +4 -2
  5. package/template/package.json +8 -10
  6. package/template/{app → src}/components/product-card.tsx +6 -2
  7. package/template/{app → src}/components/product-description.tsx +1 -1
  8. package/template/{app → src}/components/site-header.tsx +1 -1
  9. package/template/src/components/submit-button.tsx +32 -0
  10. package/template/{app → src}/lib/analytics.ts +1 -1
  11. package/template/{app → src}/lib/api.server.ts +6 -5
  12. package/template/{app → src}/lib/api.ts +1 -1
  13. package/template/src/lib/cart-session.server.ts +28 -0
  14. package/template/src/lib/checkout-intent.test.ts +27 -0
  15. package/template/{app → src}/lib/checkout-intent.ts +20 -8
  16. package/template/{app → src}/lib/config.server.ts +5 -4
  17. package/template/{app → src}/lib/config.ts +1 -1
  18. package/template/src/lib/format.test.ts +14 -0
  19. package/template/src/lib/format.ts +30 -0
  20. package/template/{app → src}/lib/safe-redirect.ts +1 -1
  21. package/template/src/lib/session.server.ts +121 -0
  22. package/template/src/lib/social-buttons.ts +6 -0
  23. package/template/src/lib/social-login.server.ts +46 -0
  24. package/template/src/routeTree.gen.ts +262 -0
  25. package/template/src/router.tsx +17 -0
  26. package/template/src/routes/__root.tsx +90 -0
  27. package/template/src/routes/auth.callback.ts +48 -0
  28. package/template/{app → src}/routes/cart.tsx +94 -78
  29. package/template/{app/routes/checkout-complete.tsx → src/routes/checkout.complete.tsx} +25 -18
  30. package/template/{app/routes/checkout.tsx → src/routes/checkout.index.tsx} +134 -94
  31. package/template/{app/routes/home.tsx → src/routes/index.tsx} +19 -13
  32. package/template/src/routes/login.tsx +198 -0
  33. package/template/{app/routes/order-detail.tsx → src/routes/orders.$orderId.tsx} +25 -13
  34. package/template/{app/routes/orders.tsx → src/routes/orders.index.tsx} +16 -14
  35. package/template/{app/routes/product-detail.tsx → src/routes/products.$productId.tsx} +77 -55
  36. package/template/{app/routes/products.tsx → src/routes/products.index.tsx} +62 -38
  37. package/template/src/start.ts +18 -0
  38. package/template/tsconfig.json +3 -4
  39. package/template/vite.config.ts +3 -2
  40. package/template/vitest.config.ts +1 -1
  41. package/template/app/lib/cart-session.server.ts +0 -18
  42. package/template/app/lib/format.ts +0 -10
  43. package/template/app/lib/session.server.ts +0 -99
  44. package/template/app/lib/social-login.server.ts +0 -38
  45. package/template/app/root.tsx +0 -78
  46. package/template/app/routes/auth-callback.tsx +0 -44
  47. package/template/app/routes/login.tsx +0 -152
  48. package/template/app/routes.ts +0 -18
  49. package/template/react-router.config.ts +0 -6
  50. /package/template/{app → src}/lib/config.test.ts +0 -0
  51. /package/template/{app → src}/lib/payment-popup.ts +0 -0
  52. /package/template/{app → src}/lib/safe-redirect.test.ts +0 -0
  53. /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,6 @@
1
+ /** 공급자 표시 — 버튼 문구·색. 로고는 각 사 가이드의 공식 버튼 자산으로 바꿔 쓴다 */
2
+ export const SOCIAL_BUTTONS = {
3
+ kakao: { label: "카카오로 시작하기", className: "bg-[#FEE500] text-[#191600]" },
4
+ naver: { label: "네이버로 시작하기", className: "bg-[#03A94D] text-white" },
5
+ google: { label: "구글로 시작하기", className: "border border-line bg-white text-ink" },
6
+ } as const;
@@ -0,0 +1,46 @@
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
+
15
+ export function writeSocialLogin(value: SocialLoginState) {
16
+ setCookie(SOCIAL_COOKIE, JSON.stringify(value), {
17
+ httpOnly: true,
18
+ sameSite: "lax",
19
+ path: "/",
20
+ secure: process.env.NODE_ENV === "production",
21
+ maxAge: 60 * 10,
22
+ });
23
+ }
24
+
25
+ export function readSocialLogin(): SocialLoginState | null {
26
+ const raw = getCookie(SOCIAL_COOKIE);
27
+ if (!raw) return null;
28
+ let value: unknown;
29
+ try {
30
+ value = JSON.parse(raw);
31
+ } catch {
32
+ return null;
33
+ }
34
+ if (!value || typeof value !== "object") return null;
35
+ const stored = value as Partial<SocialLoginState>;
36
+ if (typeof stored.state !== "string" || typeof stored.codeVerifier !== "string") return null;
37
+ return {
38
+ state: stored.state,
39
+ codeVerifier: stored.codeVerifier,
40
+ redirectTo: typeof stored.redirectTo === "string" ? stored.redirectTo : "/",
41
+ };
42
+ }
43
+
44
+ export function clearSocialLogin() {
45
+ deleteCookie(SOCIAL_COOKIE, { path: "/" });
46
+ }
@@ -0,0 +1,262 @@
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 CartRouteImport } from './routes/cart'
14
+ import { Route as LoginRouteImport } from './routes/login'
15
+ import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
16
+ import { Route as CheckoutIndexRouteImport } from './routes/checkout.index'
17
+ import { Route as CheckoutCompleteRouteImport } from './routes/checkout.complete'
18
+ import { Route as OrdersIndexRouteImport } from './routes/orders.index'
19
+ import { Route as OrdersOrderIdRouteImport } from './routes/orders.$orderId'
20
+ import { Route as ProductsIndexRouteImport } from './routes/products.index'
21
+ import { Route as ProductsProductIdRouteImport } from './routes/products.$productId'
22
+
23
+ const IndexRoute = IndexRouteImport.update({
24
+ id: '/',
25
+ path: '/',
26
+ getParentRoute: () => rootRouteImport,
27
+ } as any)
28
+ const CartRoute = CartRouteImport.update({
29
+ id: '/cart',
30
+ path: '/cart',
31
+ getParentRoute: () => rootRouteImport,
32
+ } as any)
33
+ const LoginRoute = LoginRouteImport.update({
34
+ id: '/login',
35
+ path: '/login',
36
+ getParentRoute: () => rootRouteImport,
37
+ } as any)
38
+ const AuthCallbackRoute = AuthCallbackRouteImport.update({
39
+ id: '/auth/callback',
40
+ path: '/auth/callback',
41
+ getParentRoute: () => rootRouteImport,
42
+ } as any)
43
+ const CheckoutIndexRoute = CheckoutIndexRouteImport.update({
44
+ id: '/checkout/',
45
+ path: '/checkout/',
46
+ getParentRoute: () => rootRouteImport,
47
+ } as any)
48
+ const CheckoutCompleteRoute = CheckoutCompleteRouteImport.update({
49
+ id: '/checkout/complete',
50
+ path: '/checkout/complete',
51
+ getParentRoute: () => rootRouteImport,
52
+ } as any)
53
+ const OrdersIndexRoute = OrdersIndexRouteImport.update({
54
+ id: '/orders/',
55
+ path: '/orders/',
56
+ getParentRoute: () => rootRouteImport,
57
+ } as any)
58
+ const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
59
+ id: '/orders/$orderId',
60
+ path: '/orders/$orderId',
61
+ getParentRoute: () => rootRouteImport,
62
+ } as any)
63
+ const ProductsIndexRoute = ProductsIndexRouteImport.update({
64
+ id: '/products/',
65
+ path: '/products/',
66
+ getParentRoute: () => rootRouteImport,
67
+ } as any)
68
+ const ProductsProductIdRoute = ProductsProductIdRouteImport.update({
69
+ id: '/products/$productId',
70
+ path: '/products/$productId',
71
+ getParentRoute: () => rootRouteImport,
72
+ } as any)
73
+
74
+ export interface FileRoutesByFullPath {
75
+ '/': typeof IndexRoute
76
+ '/cart': typeof CartRoute
77
+ '/login': typeof LoginRoute
78
+ '/auth/callback': typeof AuthCallbackRoute
79
+ '/checkout/complete': typeof CheckoutCompleteRoute
80
+ '/orders/$orderId': typeof OrdersOrderIdRoute
81
+ '/products/$productId': typeof ProductsProductIdRoute
82
+ '/checkout/': typeof CheckoutIndexRoute
83
+ '/orders/': typeof OrdersIndexRoute
84
+ '/products/': typeof ProductsIndexRoute
85
+ }
86
+ export interface FileRoutesByTo {
87
+ '/': typeof IndexRoute
88
+ '/cart': typeof CartRoute
89
+ '/login': typeof LoginRoute
90
+ '/auth/callback': typeof AuthCallbackRoute
91
+ '/checkout/complete': typeof CheckoutCompleteRoute
92
+ '/orders/$orderId': typeof OrdersOrderIdRoute
93
+ '/products/$productId': typeof ProductsProductIdRoute
94
+ '/checkout': typeof CheckoutIndexRoute
95
+ '/orders': typeof OrdersIndexRoute
96
+ '/products': typeof ProductsIndexRoute
97
+ }
98
+ export interface FileRoutesById {
99
+ __root__: typeof rootRouteImport
100
+ '/': typeof IndexRoute
101
+ '/cart': typeof CartRoute
102
+ '/login': typeof LoginRoute
103
+ '/auth/callback': typeof AuthCallbackRoute
104
+ '/checkout/complete': typeof CheckoutCompleteRoute
105
+ '/orders/$orderId': typeof OrdersOrderIdRoute
106
+ '/products/$productId': typeof ProductsProductIdRoute
107
+ '/checkout/': typeof CheckoutIndexRoute
108
+ '/orders/': typeof OrdersIndexRoute
109
+ '/products/': typeof ProductsIndexRoute
110
+ }
111
+ export interface FileRouteTypes {
112
+ fileRoutesByFullPath: FileRoutesByFullPath
113
+ fullPaths:
114
+ | '/'
115
+ | '/cart'
116
+ | '/login'
117
+ | '/auth/callback'
118
+ | '/checkout/complete'
119
+ | '/orders/$orderId'
120
+ | '/products/$productId'
121
+ | '/checkout/'
122
+ | '/orders/'
123
+ | '/products/'
124
+ fileRoutesByTo: FileRoutesByTo
125
+ to:
126
+ | '/'
127
+ | '/cart'
128
+ | '/login'
129
+ | '/auth/callback'
130
+ | '/checkout/complete'
131
+ | '/orders/$orderId'
132
+ | '/products/$productId'
133
+ | '/checkout'
134
+ | '/orders'
135
+ | '/products'
136
+ id:
137
+ | '__root__'
138
+ | '/'
139
+ | '/cart'
140
+ | '/login'
141
+ | '/auth/callback'
142
+ | '/checkout/complete'
143
+ | '/orders/$orderId'
144
+ | '/products/$productId'
145
+ | '/checkout/'
146
+ | '/orders/'
147
+ | '/products/'
148
+ fileRoutesById: FileRoutesById
149
+ }
150
+ export interface RootRouteChildren {
151
+ IndexRoute: typeof IndexRoute
152
+ CartRoute: typeof CartRoute
153
+ LoginRoute: typeof LoginRoute
154
+ AuthCallbackRoute: typeof AuthCallbackRoute
155
+ CheckoutCompleteRoute: typeof CheckoutCompleteRoute
156
+ OrdersOrderIdRoute: typeof OrdersOrderIdRoute
157
+ ProductsProductIdRoute: typeof ProductsProductIdRoute
158
+ CheckoutIndexRoute: typeof CheckoutIndexRoute
159
+ OrdersIndexRoute: typeof OrdersIndexRoute
160
+ ProductsIndexRoute: typeof ProductsIndexRoute
161
+ }
162
+
163
+ declare module '@tanstack/react-router' {
164
+ interface FileRoutesByPath {
165
+ '/': {
166
+ id: '/'
167
+ path: '/'
168
+ fullPath: '/'
169
+ preLoaderRoute: typeof IndexRouteImport
170
+ parentRoute: typeof rootRouteImport
171
+ }
172
+ '/cart': {
173
+ id: '/cart'
174
+ path: '/cart'
175
+ fullPath: '/cart'
176
+ preLoaderRoute: typeof CartRouteImport
177
+ parentRoute: typeof rootRouteImport
178
+ }
179
+ '/login': {
180
+ id: '/login'
181
+ path: '/login'
182
+ fullPath: '/login'
183
+ preLoaderRoute: typeof LoginRouteImport
184
+ parentRoute: typeof rootRouteImport
185
+ }
186
+ '/auth/callback': {
187
+ id: '/auth/callback'
188
+ path: '/auth/callback'
189
+ fullPath: '/auth/callback'
190
+ preLoaderRoute: typeof AuthCallbackRouteImport
191
+ parentRoute: typeof rootRouteImport
192
+ }
193
+ '/checkout/': {
194
+ id: '/checkout/'
195
+ path: '/checkout'
196
+ fullPath: '/checkout/'
197
+ preLoaderRoute: typeof CheckoutIndexRouteImport
198
+ parentRoute: typeof rootRouteImport
199
+ }
200
+ '/checkout/complete': {
201
+ id: '/checkout/complete'
202
+ path: '/checkout/complete'
203
+ fullPath: '/checkout/complete'
204
+ preLoaderRoute: typeof CheckoutCompleteRouteImport
205
+ parentRoute: typeof rootRouteImport
206
+ }
207
+ '/orders/': {
208
+ id: '/orders/'
209
+ path: '/orders'
210
+ fullPath: '/orders/'
211
+ preLoaderRoute: typeof OrdersIndexRouteImport
212
+ parentRoute: typeof rootRouteImport
213
+ }
214
+ '/orders/$orderId': {
215
+ id: '/orders/$orderId'
216
+ path: '/orders/$orderId'
217
+ fullPath: '/orders/$orderId'
218
+ preLoaderRoute: typeof OrdersOrderIdRouteImport
219
+ parentRoute: typeof rootRouteImport
220
+ }
221
+ '/products/': {
222
+ id: '/products/'
223
+ path: '/products'
224
+ fullPath: '/products/'
225
+ preLoaderRoute: typeof ProductsIndexRouteImport
226
+ parentRoute: typeof rootRouteImport
227
+ }
228
+ '/products/$productId': {
229
+ id: '/products/$productId'
230
+ path: '/products/$productId'
231
+ fullPath: '/products/$productId'
232
+ preLoaderRoute: typeof ProductsProductIdRouteImport
233
+ parentRoute: typeof rootRouteImport
234
+ }
235
+ }
236
+ }
237
+
238
+ const rootRouteChildren: RootRouteChildren = {
239
+ IndexRoute: IndexRoute,
240
+ CartRoute: CartRoute,
241
+ LoginRoute: LoginRoute,
242
+ AuthCallbackRoute: AuthCallbackRoute,
243
+ CheckoutCompleteRoute: CheckoutCompleteRoute,
244
+ OrdersOrderIdRoute: OrdersOrderIdRoute,
245
+ ProductsProductIdRoute: ProductsProductIdRoute,
246
+ CheckoutIndexRoute: CheckoutIndexRoute,
247
+ OrdersIndexRoute: OrdersIndexRoute,
248
+ ProductsIndexRoute: ProductsIndexRoute,
249
+ }
250
+ export const routeTree = rootRouteImport
251
+ ._addFileChildren(rootRouteChildren)
252
+ ._addFileTypes<FileRouteTypes>()
253
+
254
+ import type { getRouter } from './router.tsx'
255
+ import type { startInstance } from './start.ts'
256
+ declare module '@tanstack/react-start' {
257
+ interface Register {
258
+ ssr: true
259
+ router: Awaited<ReturnType<typeof getRouter>>
260
+ config: Awaited<ReturnType<typeof startInstance.getOptions>>
261
+ }
262
+ }
@@ -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
+ }
@@ -0,0 +1,48 @@
1
+ import { ApiError } from "@sayren/storefront-sdk";
2
+ import { IdpCallbackError } from "@sayren/storefront-sdk/auth";
3
+ import { createFileRoute } from "@tanstack/react-router";
4
+ import { authFor } from "../lib/api.server";
5
+ import { clearCartToken, readCartToken } from "../lib/cart-session.server";
6
+ import { signIn } from "../lib/session.server";
7
+ import { clearSocialLogin, readSocialLogin } from "../lib/social-login.server";
8
+
9
+ /**
10
+ * 소셜 로그인에서 돌아오는 곳 — 쿠키에 둔 state·verifier로 1회용 code를 구매자 토큰으로 바꾸고 세션 쿠키를 심는다.
11
+ * 비회원 장바구니가 있으면 합친다. 실패하면 로그인 화면으로 이유를 들고 돌아간다.
12
+ *
13
+ * 화면이 없는 서버 라우트다. 공급자가 문서째 보내 오므로 응답은 리다이렉트 하나다.
14
+ */
15
+ export const Route = createFileRoute("/auth/callback")({
16
+ server: {
17
+ handlers: {
18
+ GET: async ({ request }) => {
19
+ const url = new URL(request.url);
20
+ const stored = readSocialLogin();
21
+ clearSocialLogin();
22
+ const go = (location: string) =>
23
+ new Response(null, { status: 302, headers: { Location: location } });
24
+ const fail = (reason: string) => go(`/login?error=${encodeURIComponent(reason)}`);
25
+ if (!stored) return fail("state_mismatch");
26
+
27
+ const cartToken = readCartToken();
28
+ try {
29
+ const tokens = await authFor().idpCallback({
30
+ code: url.searchParams.get("code"),
31
+ state: url.searchParams.get("state"),
32
+ error: url.searchParams.get("error"),
33
+ expectedState: stored.state,
34
+ codeVerifier: stored.codeVerifier,
35
+ cartToken: cartToken ?? undefined,
36
+ });
37
+ signIn(tokens);
38
+ if (cartToken) clearCartToken();
39
+ return go(stored.redirectTo);
40
+ } catch (error) {
41
+ if (error instanceof IdpCallbackError) return fail(error.reason);
42
+ if (error instanceof ApiError) return fail("provider_error");
43
+ throw error;
44
+ }
45
+ },
46
+ },
47
+ },
48
+ });