@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.
- package/dist/index.mjs +22 -19
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +29 -12
- package/template/_gitignore +4 -2
- package/template/package.json +8 -10
- package/template/{app → src}/components/product-card.tsx +6 -2
- package/template/{app → src}/components/product-description.tsx +1 -1
- package/template/{app → src}/components/site-header.tsx +4 -1
- package/template/src/components/submit-button.tsx +32 -0
- package/template/{app → src}/lib/analytics.ts +1 -1
- package/template/{app → src}/lib/api.server.ts +6 -5
- package/template/{app → src}/lib/api.ts +1 -1
- package/template/src/lib/cart-session.server.ts +28 -0
- package/template/src/lib/checkout-intent.test.ts +27 -0
- package/template/{app → src}/lib/checkout-intent.ts +20 -8
- package/template/{app → src}/lib/config.server.ts +5 -4
- package/template/{app → src}/lib/config.ts +1 -1
- package/template/src/lib/format.test.ts +14 -0
- package/template/src/lib/format.ts +30 -0
- package/template/{app → src}/lib/safe-redirect.ts +1 -1
- package/template/src/lib/session.server.ts +121 -0
- package/template/src/lib/social-login.server.ts +54 -0
- package/template/src/routeTree.gen.ts +283 -0
- package/template/src/router.tsx +17 -0
- package/template/src/routes/__root.tsx +90 -0
- package/template/src/routes/account.tsx +213 -0
- package/template/src/routes/auth.callback.ts +70 -0
- package/template/{app → src}/routes/cart.tsx +94 -78
- package/template/{app/routes/checkout-complete.tsx → src/routes/checkout.complete.tsx} +25 -18
- package/template/{app/routes/checkout.tsx → src/routes/checkout.index.tsx} +134 -94
- package/template/{app/routes/home.tsx → src/routes/index.tsx} +19 -13
- package/template/src/routes/login.tsx +198 -0
- package/template/{app/routes/order-detail.tsx → src/routes/orders.$orderId.tsx} +25 -13
- package/template/{app/routes/orders.tsx → src/routes/orders.index.tsx} +16 -14
- package/template/{app/routes/product-detail.tsx → src/routes/products.$productId.tsx} +77 -55
- package/template/{app/routes/products.tsx → src/routes/products.index.tsx} +62 -38
- package/template/src/start.ts +18 -0
- package/template/tsconfig.json +3 -4
- package/template/vite.config.ts +3 -2
- package/template/vitest.config.ts +1 -1
- package/template/app/lib/cart-session.server.ts +0 -18
- package/template/app/lib/format.ts +0 -10
- package/template/app/lib/session.server.ts +0 -99
- package/template/app/lib/social-login.server.ts +0 -31
- package/template/app/root.tsx +0 -78
- package/template/app/routes/auth-callback.tsx +0 -44
- package/template/app/routes/login.tsx +0 -153
- package/template/app/routes.ts +0 -18
- package/template/react-router.config.ts +0 -6
- /package/template/{app → src}/lib/config.test.ts +0 -0
- /package/template/{app → src}/lib/payment-popup.ts +0 -0
- /package/template/{app → src}/lib/safe-redirect.test.ts +0 -0
- /package/template/{app → src}/lib/social-buttons.ts +0 -0
- /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
|
-
|
|
8
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
13
|
+
export const Route = createFileRoute("/orders/")({
|
|
14
|
+
loader: () => getOrders(),
|
|
15
|
+
head: () => ({ meta: [{ title: "주문 내역" }] }),
|
|
16
|
+
component: Orders,
|
|
17
|
+
});
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
const
|
|
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=
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
const
|
|
58
|
+
function ProductDetail() {
|
|
59
|
+
const product = Route.useLoaderData();
|
|
50
60
|
useTrack({ name: "product_view", productId: product.productId });
|
|
51
|
-
const
|
|
52
|
-
const submitting =
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
131
|
-
</
|
|
150
|
+
</SubmitButton>
|
|
151
|
+
</form>
|
|
132
152
|
|
|
133
|
-
{/* 바로구매는
|
|
134
|
-
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
</
|
|
145
|
-
|
|
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 {
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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=
|
|
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}
|
|
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
|
-
|
|
124
|
+
search,
|
|
104
125
|
}: {
|
|
105
126
|
page: number;
|
|
106
127
|
totalPages: number;
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
+
}));
|
package/template/tsconfig.json
CHANGED
|
@@ -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",
|
|
13
|
+
"include": ["**/*.ts", "**/*.tsx"],
|
|
14
|
+
"exclude": ["node_modules", "dist", ".output", ".tanstack"]
|
|
16
15
|
}
|
package/template/vite.config.ts
CHANGED
|
@@ -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(),
|
|
9
|
+
return { plugins: [tailwindcss(), tanstackStart(), viteReact()] };
|
|
9
10
|
});
|
|
@@ -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
|
-
}
|