@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
@@ -1,22 +1,23 @@
1
- import { Link, redirect } from "react-router";
1
+ import { createFileRoute, Link, redirect } from "@tanstack/react-router";
2
+ import { createServerFn } from "@tanstack/react-start";
2
3
  import { apiFor } from "../lib/api.server";
3
4
  import { formatDateTime, formatPrice } from "../lib/format";
4
5
  import { readToken } from "../lib/session.server";
5
- import type { Route } from "./+types/orders";
6
6
 
7
- export function meta() {
8
- return [{ title: "주문 내역" }];
9
- }
7
+ const getOrders = createServerFn({ method: "GET" }).handler(async () => {
8
+ const accessToken = readToken();
9
+ if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/orders" } });
10
+ return apiFor({ accessToken }).myOrders.list({ size: 20 });
11
+ });
10
12
 
11
- export async function loader({ request, context }: Route.LoaderArgs) {
12
- const accessToken = readToken(context);
13
- if (!accessToken) throw redirect("/login?redirectTo=/orders");
14
- const api = apiFor(request, { accessToken });
15
- return { orders: await api.myOrders.list({ size: 20 }) };
16
- }
13
+ export const Route = createFileRoute("/orders/")({
14
+ loader: () => getOrders(),
15
+ head: () => ({ meta: [{ title: "주문 내역" }] }),
16
+ component: Orders,
17
+ });
17
18
 
18
- export default function Orders({ loaderData }: Route.ComponentProps) {
19
- const { orders } = loaderData;
19
+ function Orders() {
20
+ const orders = Route.useLoaderData();
20
21
 
21
22
  if (!orders.contents.length) {
22
23
  return <p className="py-16 text-center text-muted">아직 주문이 없어요</p>;
@@ -29,7 +30,8 @@ export default function Orders({ loaderData }: Route.ComponentProps) {
29
30
  {orders.contents.map((order) => (
30
31
  <li key={order.orderId}>
31
32
  <Link
32
- to={`/orders/${order.orderId}`}
33
+ to="/orders/$orderId"
34
+ params={{ orderId: order.orderId }}
33
35
  className="flex items-center justify-between gap-4 rounded-lg border border-line p-4 hover:border-ink"
34
36
  >
35
37
  <span className="flex flex-col gap-1">
@@ -1,55 +1,66 @@
1
+ import { ApiError } from "@sayren/storefront-sdk";
2
+ import { createFileRoute, Link, notFound, useNavigate } from "@tanstack/react-router";
3
+ import { createServerFn } from "@tanstack/react-start";
1
4
  import { useState } from "react";
2
- import { Form, redirect, useNavigation } from "react-router";
5
+ import { z } from "zod";
3
6
  import { ProductDescription } from "../components/product-description";
7
+ import { SubmitButton } from "../components/submit-button";
4
8
  import { useTrack } from "../lib/analytics";
5
9
  import { apiFor } from "../lib/api.server";
6
- import { cartCookie, readCartToken } from "../lib/cart-session.server";
10
+ import { readCartToken, writeCartToken } from "../lib/cart-session.server";
7
11
  import { formatPrice } from "../lib/format";
8
12
  import { readToken } from "../lib/session.server";
9
- import type { Route } from "./+types/product-detail";
10
13
 
11
- export function meta({ loaderData }: Route.MetaArgs) {
12
- return [{ title: loaderData ? loaderData.product.name : "상품" }];
13
- }
14
-
15
- export async function loader({ request, params }: Route.LoaderArgs) {
16
- const api = apiFor(request);
17
- const product = await api.catalog.getProduct(params.productId);
18
- return { product };
19
- }
14
+ const getProduct = createServerFn({ method: "GET" })
15
+ .validator(z.object({ productId: z.string() }))
16
+ .handler(async ({ data }) => {
17
+ try {
18
+ return await apiFor().catalog.getProduct(data.productId);
19
+ } catch (error) {
20
+ if (error instanceof ApiError && error.status === 404) throw notFound();
21
+ throw error;
22
+ }
23
+ });
20
24
 
21
25
  /**
22
- * 장바구니 담기·바로구매를 서버에서 처리한다. 비회원 장바구니 토큰은 서버가 새로 발급할 수 있어
26
+ * 장바구니 담기 — 서버에서 처리한다. 비회원 장바구니 토큰은 서버가 새로 발급할 수 있어
23
27
  * 응답 쿠키에 다시 심는다.
24
28
  */
25
- export async function action({ request, params, context }: Route.ActionArgs) {
26
- const form = await request.formData();
27
- const optionId = String(form.get("optionId") ?? "") || undefined;
28
- const quantity = Number(form.get("quantity") ?? 1);
29
-
30
- const accessToken = readToken(context);
31
- let cartToken = await readCartToken(request);
32
- let issued: string | null = null;
33
- const api = apiFor(request, {
34
- accessToken,
35
- cartToken,
36
- onCartToken: (token) => {
37
- issued = token;
38
- },
29
+ const addToCart = createServerFn({ method: "POST" })
30
+ .validator(
31
+ z.object({
32
+ productId: z.string(),
33
+ optionId: z.string().optional(),
34
+ quantity: z.number().int().min(1).max(999),
35
+ }),
36
+ )
37
+ .handler(async ({ data }) => {
38
+ const cartToken = readCartToken();
39
+ let issued: string | null = null;
40
+ const api = apiFor({
41
+ accessToken: readToken(),
42
+ cartToken,
43
+ onCartToken: (token) => {
44
+ issued = token;
45
+ },
46
+ });
47
+ await api.cart.addItem(data);
48
+ const token = issued ?? cartToken;
49
+ if (token) writeCartToken(token);
39
50
  });
40
51
 
41
- await api.cart.addItem({ productId: params.productId, optionId, quantity });
42
- cartToken = issued ?? cartToken;
43
- return redirect("/cart", {
44
- headers: cartToken ? { "Set-Cookie": await cartCookie.serialize(cartToken) } : undefined,
45
- });
46
- }
52
+ export const Route = createFileRoute("/products/$productId")({
53
+ loader: ({ params }) => getProduct({ data: { productId: params.productId } }),
54
+ head: ({ loaderData }) => ({ meta: [{ title: loaderData ? loaderData.name : "상품" }] }),
55
+ component: ProductDetail,
56
+ });
47
57
 
48
- export default function ProductDetail({ loaderData }: Route.ComponentProps) {
49
- const { product } = loaderData;
58
+ function ProductDetail() {
59
+ const product = Route.useLoaderData();
50
60
  useTrack({ name: "product_view", productId: product.productId });
51
- const navigation = useNavigation();
52
- const submitting = navigation.state !== "idle";
61
+ const navigate = useNavigate();
62
+ const [submitting, setSubmitting] = useState(false);
63
+ const [error, setError] = useState<string | null>(null);
53
64
  const hasOptions = product.optionGroups.length > 0;
54
65
  const [quantity, setQuantity] = useState(1);
55
66
  const [variantId, setVariantId] = useState(
@@ -58,6 +69,7 @@ export default function ProductDetail({ loaderData }: Route.ComponentProps) {
58
69
  const variant = product.variants.find((v) => v.variantId === variantId);
59
70
  const price =
60
71
  (product.discountedPrice ?? product.salePrice) + (variant ? variant.additionalPrice : 0);
72
+ const optionId = hasOptions ? variantId : undefined;
61
73
 
62
74
  return (
63
75
  <div className="grid gap-8 md:grid-cols-2">
@@ -86,7 +98,18 @@ export default function ProductDetail({ loaderData }: Route.ComponentProps) {
86
98
  </p>
87
99
  </div>
88
100
 
89
- <Form method="post" className="space-y-4">
101
+ <form
102
+ className="space-y-4"
103
+ onSubmit={(event) => {
104
+ event.preventDefault();
105
+ setSubmitting(true);
106
+ setError(null);
107
+ addToCart({ data: { productId: product.productId, optionId, quantity } })
108
+ .then(() => navigate({ to: "/cart" }))
109
+ .catch(() => setError("장바구니에 담지 못했어요. 다시 시도해주세요"))
110
+ .finally(() => setSubmitting(false));
111
+ }}
112
+ >
90
113
  {hasOptions ? (
91
114
  <label className="block space-y-1">
92
115
  <span className="text-sm">옵션</span>
@@ -119,30 +142,29 @@ export default function ProductDetail({ loaderData }: Route.ComponentProps) {
119
142
  />
120
143
  </label>
121
144
 
122
- <button
123
- type="submit"
124
- name="intent"
125
- value="cart"
145
+ <SubmitButton
126
146
  disabled={submitting || product.soldOut}
127
147
  className="h-12 w-full rounded-md border border-ink font-semibold disabled:opacity-50"
128
148
  >
129
149
  장바구니
130
- </button>
131
- </Form>
150
+ </SubmitButton>
151
+ </form>
132
152
 
133
- {/* 바로구매는 주문서를 GET으로 연다 — 장바구니를 거치지 않는다 */}
134
- <Form method="get" action="/checkout" className="space-y-0">
135
- <input type="hidden" name="productId" value={product.productId} />
136
- {hasOptions ? <input type="hidden" name="optionId" value={variantId} /> : null}
137
- <input type="hidden" name="quantity" value={quantity} />
138
- <button
139
- type="submit"
140
- disabled={submitting || product.soldOut}
141
- className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
153
+ {/* 바로구매는 주문서로 바로 간다 — 장바구니를 거치지 않는다 */}
154
+ {product.soldOut ? (
155
+ <span className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white opacity-50">
156
+ 바로구매
157
+ </span>
158
+ ) : (
159
+ <Link
160
+ to="/checkout"
161
+ search={{ productId: product.productId, optionId, quantity }}
162
+ className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white"
142
163
  >
143
164
  바로구매
144
- </button>
145
- </Form>
165
+ </Link>
166
+ )}
167
+ {error ? <p className="text-point text-sm">{error}</p> : null}
146
168
 
147
169
  <section className="space-y-2 border-line border-t pt-5">
148
170
  <h2 className="font-semibold">상품 설명</h2>
@@ -1,34 +1,42 @@
1
- import { Form, Link, useSearchParams } from "react-router";
1
+ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
2
+ import { createServerFn } from "@tanstack/react-start";
3
+ import { z } from "zod";
2
4
  import { ProductCard } from "../components/product-card";
3
5
  import { useTrack } from "../lib/analytics";
4
6
  import { apiFor } from "../lib/api.server";
5
- import type { Route } from "./+types/products";
6
7
 
7
- export function meta() {
8
- return [{ title: "전체 상품" }];
9
- }
8
+ /** 목록·검색 조건 — URL에 둔다. 공유·뒤로가기가 그대로 동작한다 */
9
+ const productsSearch = z.object({
10
+ keyword: z.string().optional().catch(undefined),
11
+ categoryId: z.string().optional().catch(undefined),
12
+ sort: z.enum(["recommend", "latest"]).optional().catch(undefined),
13
+ page: z.coerce.number().int().min(1).optional().catch(undefined),
14
+ });
15
+ type ProductsSearch = z.infer<typeof productsSearch>;
10
16
 
11
- /** 목록·검색 — 필터 상태는 URL에 둔다. 공유·뒤로가기가 그대로 동작한다 */
12
- export async function loader({ request }: Route.LoaderArgs) {
13
- const url = new URL(request.url);
14
- const params = {
15
- keyword: url.searchParams.get("keyword") ?? undefined,
16
- categoryId: url.searchParams.get("categoryId") ?? undefined,
17
- sort: (url.searchParams.get("sort") ?? undefined) as "recommend" | "latest" | undefined,
18
- page: Number(url.searchParams.get("page") ?? 1),
19
- size: 24,
20
- };
21
- const api = apiFor(request);
22
- const [categories, page] = await Promise.all([
23
- api.catalog.listCategories(),
24
- api.catalog.searchProducts(params),
25
- ]);
26
- return { categories, page, applied: params };
27
- }
17
+ const searchProducts = createServerFn({ method: "GET" })
18
+ .validator(productsSearch)
19
+ .handler(async ({ data }) => {
20
+ const api = apiFor();
21
+ const [categories, page] = await Promise.all([
22
+ api.catalog.listCategories(),
23
+ api.catalog.searchProducts({ ...data, page: data.page ?? 1, size: 24 }),
24
+ ]);
25
+ return { categories, page };
26
+ });
28
27
 
29
- export default function Products({ loaderData }: Route.ComponentProps) {
30
- const { categories, page, applied } = loaderData;
31
- const [searchParams] = useSearchParams();
28
+ export const Route = createFileRoute("/products/")({
29
+ validateSearch: productsSearch,
30
+ loaderDeps: ({ search }) => search,
31
+ loader: ({ deps }) => searchProducts({ data: deps }),
32
+ head: () => ({ meta: [{ title: "전체 상품" }] }),
33
+ component: Products,
34
+ });
35
+
36
+ function Products() {
37
+ const { categories, page } = Route.useLoaderData();
38
+ const applied = Route.useSearch();
39
+ const navigate = useNavigate();
32
40
  useTrack(
33
41
  applied.keyword
34
42
  ? { name: "search", query: applied.keyword, resultCount: page.totalElements }
@@ -46,7 +54,19 @@ export default function Products({ loaderData }: Route.ComponentProps) {
46
54
 
47
55
  return (
48
56
  <div className="space-y-6">
49
- <Form method="get" className="flex gap-2">
57
+ {/* 하이드레이션 전에는 GET 폼으로 그대로 제출되고, 뒤에는 화면을 새로 그리지 않고 이동한다 */}
58
+ <form
59
+ action="/products"
60
+ className="flex gap-2"
61
+ onSubmit={(event) => {
62
+ event.preventDefault();
63
+ const keyword = String(new FormData(event.currentTarget).get("keyword") ?? "").trim();
64
+ void navigate({
65
+ to: "/products",
66
+ search: { keyword: keyword || undefined, categoryId: applied.categoryId },
67
+ });
68
+ }}
69
+ >
50
70
  <input
51
71
  type="search"
52
72
  name="keyword"
@@ -60,7 +80,7 @@ export default function Products({ loaderData }: Route.ComponentProps) {
60
80
  <button type="submit" className="h-11 rounded-md bg-ink px-5 font-semibold text-white">
61
81
  검색
62
82
  </button>
63
- </Form>
83
+ </form>
64
84
 
65
85
  <div className="flex flex-wrap gap-2">
66
86
  <Link
@@ -72,7 +92,8 @@ export default function Products({ loaderData }: Route.ComponentProps) {
72
92
  {categories.map((category) => (
73
93
  <Link
74
94
  key={category.categoryId}
75
- to={`/products?categoryId=${category.categoryId}`}
95
+ to="/products"
96
+ search={{ categoryId: category.categoryId }}
76
97
  className={`rounded-full px-3 py-1.5 text-sm ${
77
98
  applied.categoryId === category.categoryId ? "bg-ink text-white" : "bg-chip"
78
99
  }`}
@@ -92,7 +113,7 @@ export default function Products({ loaderData }: Route.ComponentProps) {
92
113
  </div>
93
114
  )}
94
115
 
95
- <Pagination page={page.page} totalPages={page.totalPages} searchParams={searchParams} />
116
+ <Pagination page={page.page} totalPages={page.totalPages} search={applied} />
96
117
  </div>
97
118
  );
98
119
  }
@@ -100,22 +121,21 @@ export default function Products({ loaderData }: Route.ComponentProps) {
100
121
  function Pagination({
101
122
  page,
102
123
  totalPages,
103
- searchParams,
124
+ search,
104
125
  }: {
105
126
  page: number;
106
127
  totalPages: number;
107
- searchParams: URLSearchParams;
128
+ search: ProductsSearch;
108
129
  }) {
109
130
  if (totalPages <= 1) return null;
110
- const linkTo = (next: number) => {
111
- const params = new URLSearchParams(searchParams);
112
- params.set("page", String(next));
113
- return `/products?${params.toString()}`;
114
- };
115
131
  return (
116
132
  <nav className="flex items-center justify-center gap-3 text-sm">
117
133
  {page > 1 ? (
118
- <Link to={linkTo(page - 1)} className="rounded-md border border-line px-3 py-1.5">
134
+ <Link
135
+ to="/products"
136
+ search={{ ...search, page: page - 1 }}
137
+ className="rounded-md border border-line px-3 py-1.5"
138
+ >
119
139
  이전
120
140
  </Link>
121
141
  ) : null}
@@ -123,7 +143,11 @@ function Pagination({
123
143
  {page} / {totalPages}
124
144
  </span>
125
145
  {page < totalPages ? (
126
- <Link to={linkTo(page + 1)} className="rounded-md border border-line px-3 py-1.5">
146
+ <Link
147
+ to="/products"
148
+ search={{ ...search, page: page + 1 }}
149
+ className="rounded-md border border-line px-3 py-1.5"
150
+ >
127
151
  다음
128
152
  </Link>
129
153
  ) : null}
@@ -0,0 +1,18 @@
1
+ import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
2
+ import { refreshBuyerSession } from "./lib/session.server";
3
+
4
+ /**
5
+ * 서버 함수 호출은 같은 사이트에서 온 것만 받는다. 구매자 세션이 쿠키라 다른 사이트가 결제·장바구니
6
+ * 서버 함수를 대신 부르지 못하게 한다.
7
+ */
8
+ const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });
9
+
10
+ /** 구매자 세션 — 모든 요청에서 액세스 토큰을 정하고 만료가 가까우면 갱신한다(`readToken`으로 읽는다) */
11
+ const buyerSession = createMiddleware().server(async ({ next }) => {
12
+ const accessToken = await refreshBuyerSession();
13
+ return next({ context: { accessToken } });
14
+ });
15
+
16
+ export const startInstance = createStart(() => ({
17
+ requestMiddleware: [csrf, buyerSession],
18
+ }));
@@ -8,9 +8,8 @@
8
8
  "strict": true,
9
9
  "noEmit": true,
10
10
  "skipLibCheck": true,
11
- "types": ["node", "vite/client"],
12
- "rootDirs": [".", "./.react-router/types"],
13
- "paths": { "~/*": ["./app/*"] }
11
+ "types": ["node", "vite/client"]
14
12
  },
15
- "include": ["**/*.ts", "**/*.tsx", ".react-router/types/**/*"]
13
+ "include": ["**/*.ts", "**/*.tsx"],
14
+ "exclude": ["node_modules", "dist", ".output", ".tanstack"]
16
15
  }
@@ -1,9 +1,10 @@
1
- import { reactRouter } from "@react-router/dev/vite";
2
1
  import tailwindcss from "@tailwindcss/vite";
2
+ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
3
+ import viteReact from "@vitejs/plugin-react";
3
4
  import { defineConfig, loadEnv } from "vite";
4
5
 
5
6
  export default defineConfig(({ mode }) => {
6
7
  // .env의 SAYREN_*를 서버 코드가 읽는 process.env에 싣는다. 셸에서 준 값이 우선한다
7
8
  Object.assign(process.env, loadEnv(mode, process.cwd(), "SAYREN_"), { ...process.env });
8
- return { plugins: [tailwindcss(), reactRouter()] };
9
+ return { plugins: [tailwindcss(), tanstackStart(), viteReact()] };
9
10
  });
@@ -1,5 +1,5 @@
1
1
  import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
- test: { environment: "node", include: ["app/**/*.test.ts"] },
4
+ test: { environment: "node", include: ["src/**/*.test.ts"] },
5
5
  });
@@ -1,18 +0,0 @@
1
- import { createCookie } from "react-router";
2
-
3
- /**
4
- * 비회원 장바구니 토큰 — 서버가 `X-Cart-Token` 헤더로 발급한다. 회원은 액세스 토큰으로
5
- * 장바구니를 찾지만 비회원은 이 토큰이 유일한 열쇠라, 잃어버리면 담은 상품이 사라진다.
6
- */
7
- export const cartCookie = createCookie("sayren_cart", {
8
- httpOnly: true,
9
- sameSite: "lax",
10
- path: "/",
11
- secure: process.env.NODE_ENV === "production",
12
- maxAge: 60 * 60 * 24 * 30,
13
- });
14
-
15
- export async function readCartToken(request: Request): Promise<string | null> {
16
- const value = await cartCookie.parse(request.headers.get("cookie"));
17
- return typeof value === "string" && value ? value : null;
18
- }
@@ -1,10 +0,0 @@
1
- const KRW = new Intl.NumberFormat("ko-KR");
2
-
3
- export function formatPrice(amount: number): string {
4
- return `${KRW.format(amount)}원`;
5
- }
6
-
7
- export function formatDateTime(value: string | null | undefined): string {
8
- if (!value) return "-";
9
- return new Date(value).toLocaleString("ko-KR", { dateStyle: "medium", timeStyle: "short" });
10
- }
@@ -1,99 +0,0 @@
1
- import { ApiError, type TokenPair } from "@sayren/storefront-sdk/auth";
2
- import {
3
- createContext,
4
- createCookie,
5
- type MiddlewareFunction,
6
- type RouterContextProvider,
7
- } from "react-router";
8
- import { authFor } from "./api.server";
9
-
10
- /**
11
- * 구매자 세션 — 로그인 토큰 쌍을 HttpOnly 쿠키 하나에 둔다. 브라우저 JS는 토큰을 읽지 못하고,
12
- * 서버(loader·action)만 쓴다.
13
- *
14
- * 액세스 토큰은 30분이라 `buyerSession` 미들웨어가 요청마다 만료를 보고, 곧 끝나면 리프레시 토큰으로
15
- * 새 쌍을 받아 쿠키를 갈아 끼운다. 리프레시 토큰이 거부되면(401: 14일 만료·폐기) 쿠키를 지워 로그아웃 상태가 된다.
16
- * 네트워크 오류·5xx 같은 일시 장애에는 쿠키를 지우지 않는다 — 다음 요청에서 다시 갱신한다.
17
- */
18
- export const sessionCookie = createCookie("sayren_member", {
19
- httpOnly: true,
20
- sameSite: "lax",
21
- path: "/",
22
- secure: process.env.NODE_ENV === "production",
23
- maxAge: 60 * 60 * 24 * 14,
24
- });
25
-
26
- interface StoredSession {
27
- accessToken: string;
28
- refreshToken: string;
29
- /** 액세스 토큰 만료 시각(epoch ms) */
30
- expiresAt: number;
31
- }
32
-
33
- /** 만료 이만큼 전에 미리 갱신한다 — 요청 도중 만료로 401이 나지 않게 */
34
- const REFRESH_LEEWAY_MS = 60_000;
35
-
36
- const accessTokenContext = createContext<string | null>(null);
37
-
38
- function toStored(tokens: TokenPair): StoredSession {
39
- return {
40
- accessToken: tokens.accessToken,
41
- refreshToken: tokens.refreshToken,
42
- expiresAt: Date.now() + tokens.expiresIn * 1000,
43
- };
44
- }
45
-
46
- async function parseSession(request: Request): Promise<StoredSession | null> {
47
- const value: unknown = await sessionCookie.parse(request.headers.get("cookie"));
48
- if (!value || typeof value !== "object") return null;
49
- const session = value as Partial<StoredSession>;
50
- if (typeof session.accessToken !== "string" || typeof session.refreshToken !== "string")
51
- return null;
52
- return {
53
- accessToken: session.accessToken,
54
- refreshToken: session.refreshToken,
55
- expiresAt: Number(session.expiresAt),
56
- };
57
- }
58
-
59
- /** 루트 미들웨어 — 이 요청의 액세스 토큰을 정하고, 갱신했으면 응답에 새 쿠키를 싣는다 */
60
- export const buyerSession: MiddlewareFunction<Response> = async ({ request, context }, next) => {
61
- const stored = await parseSession(request);
62
- let setCookie: string | null = null;
63
- let accessToken = stored?.accessToken ?? null;
64
-
65
- // 만료 시각을 모르면(형식이 다른 쿠키) 만료로 본다
66
- if (stored && !(stored.expiresAt - REFRESH_LEEWAY_MS > Date.now())) {
67
- try {
68
- const refreshed = toStored(await authFor(request).refresh(stored.refreshToken));
69
- accessToken = refreshed.accessToken;
70
- setCookie = await sessionCookie.serialize(refreshed);
71
- } catch (error) {
72
- // 일시 장애면 이번 요청은 기존 토큰으로 진행한다(만료 전 여유 1분 안이면 아직 유효하다)
73
- if (error instanceof ApiError && error.status === 401) {
74
- accessToken = null;
75
- setCookie = await signOutCookie();
76
- }
77
- }
78
- }
79
-
80
- context.set(accessTokenContext, accessToken);
81
- const response = await next();
82
- if (setCookie) response.headers.append("Set-Cookie", setCookie);
83
- return response;
84
- };
85
-
86
- /** 이 요청의 구매자 액세스 토큰 — 로그인하지 않았으면 null */
87
- export function readToken(context: Readonly<RouterContextProvider>): string | null {
88
- return context.get(accessTokenContext);
89
- }
90
-
91
- /** 로그인·가입 직후 응답에 실을 쿠키 */
92
- export function signInCookie(tokens: TokenPair): Promise<string> {
93
- return sessionCookie.serialize(toStored(tokens));
94
- }
95
-
96
- /** 로그아웃 — 쿠키를 지운다 */
97
- export function signOutCookie(): Promise<string> {
98
- return sessionCookie.serialize("", { maxAge: 0 });
99
- }
@@ -1,31 +0,0 @@
1
- import { createCookie } from "react-router";
2
-
3
- /**
4
- * 소셜 로그인 왕복 상태 — 공급자로 보내기 전에 만든 state·PKCE verifier·돌아갈 곳을 HttpOnly 쿠키에 잠깐 둔다.
5
- * 돌아오면 state를 비교하고 verifier로 1회용 code를 토큰으로 바꾼 뒤 지운다. 10분이 지나면 사라진다.
6
- */
7
- export const socialLoginCookie = createCookie("sayren_social", {
8
- httpOnly: true,
9
- sameSite: "lax",
10
- path: "/",
11
- secure: process.env.NODE_ENV === "production",
12
- maxAge: 60 * 10,
13
- });
14
-
15
- export interface SocialLoginState {
16
- state: string;
17
- codeVerifier: string;
18
- redirectTo: string;
19
- }
20
-
21
- export async function readSocialLogin(request: Request): Promise<SocialLoginState | null> {
22
- const value: unknown = await socialLoginCookie.parse(request.headers.get("cookie"));
23
- if (!value || typeof value !== "object") return null;
24
- const stored = value as Partial<SocialLoginState>;
25
- if (typeof stored.state !== "string" || typeof stored.codeVerifier !== "string") return null;
26
- return {
27
- state: stored.state,
28
- codeVerifier: stored.codeVerifier,
29
- redirectTo: typeof stored.redirectTo === "string" ? stored.redirectTo : "/",
30
- };
31
- }