@sayren/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/index.mjs +361 -0
  2. package/package.json +42 -0
  3. package/template/.env +2 -0
  4. package/template/README.md +42 -0
  5. package/template/app/app.css +22 -0
  6. package/template/app/components/product-card.tsx +45 -0
  7. package/template/app/components/product-description.tsx +28 -0
  8. package/template/app/components/site-header.tsx +25 -0
  9. package/template/app/lib/api.server.ts +34 -0
  10. package/template/app/lib/api.ts +7 -0
  11. package/template/app/lib/cart-session.server.ts +18 -0
  12. package/template/app/lib/checkout-intent.ts +30 -0
  13. package/template/app/lib/config.server.ts +19 -0
  14. package/template/app/lib/config.test.ts +16 -0
  15. package/template/app/lib/config.ts +19 -0
  16. package/template/app/lib/format.ts +10 -0
  17. package/template/app/lib/payment-popup.ts +98 -0
  18. package/template/app/lib/session.server.ts +24 -0
  19. package/template/app/root.tsx +57 -0
  20. package/template/app/routes/cart.tsx +161 -0
  21. package/template/app/routes/checkout-complete.tsx +66 -0
  22. package/template/app/routes/checkout.tsx +251 -0
  23. package/template/app/routes/home.tsx +67 -0
  24. package/template/app/routes/login.tsx +52 -0
  25. package/template/app/routes/order-detail.tsx +76 -0
  26. package/template/app/routes/orders.tsx +48 -0
  27. package/template/app/routes/product-detail.tsx +153 -0
  28. package/template/app/routes/products.tsx +117 -0
  29. package/template/app/routes.ts +17 -0
  30. package/template/package.json +37 -0
  31. package/template/react-router.config.ts +6 -0
  32. package/template/tsconfig.json +16 -0
  33. package/template/vite.config.ts +7 -0
  34. package/template/vitest.config.ts +5 -0
@@ -0,0 +1,19 @@
1
+ import { storeCodeFromHost } from "./config";
2
+
3
+ /**
4
+ * 서버 전용 설정 — `.server.ts`라 브라우저 번들에 들어가지 않는다. `process.env`를 여기에만 둔다.
5
+ * 브라우저에 필요한 값은 loader가 내려준다(`PublicConfig`).
6
+ */
7
+ export const API_BASE_URL = process.env.SAYREN_API_URL ?? "https://api.sayren.app/storefront/v1";
8
+
9
+ const FIXED_STORE_CODE = process.env.SAYREN_STORE_CODE ?? "";
10
+
11
+ /** 요청에서 테넌트를 정한다 — 고정 값이 먼저이고, 없으면 호스트 서브도메인에서 뽑는다 */
12
+ export function resolveStoreCode(request: Request): string {
13
+ if (FIXED_STORE_CODE) return FIXED_STORE_CODE;
14
+ const fromHost = storeCodeFromHost(request.headers.get("host"));
15
+ if (fromHost) return fromHost;
16
+ throw new Error(
17
+ "스토어 코드를 알 수 없어요. SAYREN_STORE_CODE 환경변수를 설정하거나 서브도메인으로 서비스해주세요",
18
+ );
19
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { storeCodeFromHost } from "./config";
3
+
4
+ describe("스토어 코드 해석", () => {
5
+ it("서브도메인이 있으면 첫 라벨을 쓴다", () => {
6
+ expect(storeCodeFromHost("mystore.example.com")).toBe("mystore");
7
+ expect(storeCodeFromHost("mystore.example.com:443")).toBe("mystore");
8
+ });
9
+
10
+ it("서브도메인이 없거나 로컬·IP면 알 수 없다", () => {
11
+ expect(storeCodeFromHost("example.com")).toBeNull();
12
+ expect(storeCodeFromHost("localhost:4010")).toBeNull();
13
+ expect(storeCodeFromHost("127.0.0.1:4010")).toBeNull();
14
+ expect(storeCodeFromHost(null)).toBeNull();
15
+ });
16
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 브라우저와 서버가 함께 쓰는 값만 둔다. `process.env`는 여기에 두지 않는다 —
3
+ * 브라우저 번들에 들어가면 `process is not defined`로 화면이 깨진다(서버 값은 `config.server.ts`).
4
+ */
5
+
6
+ /** 호스트에서 서브도메인을 뽑는다(`mystore.example.com` → `mystore`) */
7
+ export function storeCodeFromHost(host: string | null | undefined): string | null {
8
+ if (!host) return null;
9
+ const hostname = host.split(":")[0] ?? "";
10
+ if (hostname === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return null;
11
+ const labels = hostname.split(".");
12
+ return labels.length >= 3 ? (labels[0] ?? null) : null;
13
+ }
14
+
15
+ /** loader가 브라우저로 내려주는 값 */
16
+ export interface PublicConfig {
17
+ apiBaseUrl: string;
18
+ storeCode: string;
19
+ }
@@ -0,0 +1,10 @@
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
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Hosted Checkout 결제 팝업 — 이 파일은 그대로 두는 편이 좋다. 규칙을 하나라도 어기면
3
+ * 결제가 조용히 실패한다.
4
+ *
5
+ * 1. 팝업은 **클릭 콜스택에서 동기적으로** 연다. API 응답을 기다린 뒤에 열면 브라우저가 막는다.
6
+ * 그래서 빈 창을 먼저 열고 `navigate`로 결제 주소를 넣는다.
7
+ * 2. 팝업이 보내는 postMessage는 `event.origin`과 `event.source`를 **모두** 검증한다.
8
+ * 3. 결과 메시지 없이 창이 닫히면 결제가 끝났는지 알 수 없다. 이때는 실패로 단정하지 말고
9
+ * 서버에 결제 상태를 물어본다(주문서 화면이 폴링한다).
10
+ */
11
+ export const PAYMENT_POPUP_NAME = "sayren-checkout";
12
+ const PAYMENT_POPUP_FEATURES = "width=460,height=680";
13
+ const CLOSE_POLL_MS = 500;
14
+
15
+ export interface PaymentCompletedMessage {
16
+ type: "payment:completed";
17
+ paymentId: string;
18
+ /** 결제는 끝났지만 팝업이 주문번호를 모를 수 있다 — 이때는 주문 내역을 안내한다 */
19
+ orderId: string | null;
20
+ }
21
+
22
+ export interface PaymentFailedMessage {
23
+ type: "payment:failed";
24
+ paymentId?: string;
25
+ code?: string;
26
+ message?: string;
27
+ }
28
+
29
+ export interface PaymentPopupHandlers {
30
+ onCompleted: (message: PaymentCompletedMessage) => void;
31
+ onFailed: (message: PaymentFailedMessage) => void;
32
+ /** 결과 메시지 없이 닫혔다 — 실패가 아니라 "모른다"는 뜻이다 */
33
+ onClosed: () => void;
34
+ }
35
+
36
+ export interface PaymentPopup {
37
+ /** 클릭 즉시 부른다. 차단되면 null */
38
+ open: () => Window | null;
39
+ /** 결제 주소를 받은 뒤 부른다. 팝업이 없거나 닫혔으면 false */
40
+ navigate: (popupUrl: string) => boolean;
41
+ close: () => void;
42
+ }
43
+
44
+ export function createPaymentPopup(handlers: PaymentPopupHandlers): PaymentPopup {
45
+ let popup: Window | null = null;
46
+ let listener: ((event: MessageEvent) => void) | null = null;
47
+ let timer: ReturnType<typeof setInterval> | null = null;
48
+
49
+ const stop = () => {
50
+ if (listener) window.removeEventListener("message", listener);
51
+ listener = null;
52
+ if (timer) clearInterval(timer);
53
+ timer = null;
54
+ };
55
+
56
+ const watch = (origin: string) => {
57
+ stop();
58
+ listener = (event: MessageEvent) => {
59
+ if (event.origin !== origin) return;
60
+ if (event.source !== popup) return;
61
+ const data = event.data as PaymentCompletedMessage | PaymentFailedMessage | null;
62
+ if (!data || typeof data !== "object") return;
63
+ if (data.type === "payment:completed") {
64
+ stop();
65
+ handlers.onCompleted(data);
66
+ } else if (data.type === "payment:failed") {
67
+ stop();
68
+ handlers.onFailed(data);
69
+ }
70
+ };
71
+ window.addEventListener("message", listener);
72
+
73
+ timer = setInterval(() => {
74
+ if (popup?.closed) {
75
+ stop();
76
+ handlers.onClosed();
77
+ }
78
+ }, CLOSE_POLL_MS);
79
+ };
80
+
81
+ return {
82
+ open: () => {
83
+ popup = window.open("", PAYMENT_POPUP_NAME, PAYMENT_POPUP_FEATURES);
84
+ return popup;
85
+ },
86
+ navigate: (popupUrl) => {
87
+ if (!popup || popup.closed) return false;
88
+ popup.location.href = popupUrl;
89
+ watch(new URL(popupUrl).origin);
90
+ return true;
91
+ },
92
+ close: () => {
93
+ stop();
94
+ popup?.close();
95
+ popup = null;
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,24 @@
1
+ import { createCookie } from "react-router";
2
+
3
+ /**
4
+ * 구매자 세션 — 액세스 토큰을 HttpOnly 쿠키에 둔다. 브라우저 JS가 토큰을 읽지 못하고,
5
+ * SSR loader가 그대로 쓸 수 있다.
6
+ *
7
+ * 확장 지점: 리프레시 토큰 회전을 쓰려면 여기에 refresh 쿠키를 더한다.
8
+ */
9
+ export const sessionCookie = createCookie("sayren_member", {
10
+ httpOnly: true,
11
+ sameSite: "lax",
12
+ path: "/",
13
+ secure: process.env.NODE_ENV === "production",
14
+ maxAge: 60 * 60 * 24 * 7,
15
+ });
16
+
17
+ export async function readToken(request: Request): Promise<string | null> {
18
+ const value = await sessionCookie.parse(request.headers.get("cookie"));
19
+ return typeof value === "string" ? value : null;
20
+ }
21
+
22
+ export function tokenHeaders(accessToken: string | null): Promise<string> {
23
+ return sessionCookie.serialize(accessToken ?? "", accessToken ? undefined : { maxAge: 0 });
24
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ isRouteErrorResponse,
3
+ Links,
4
+ Meta,
5
+ Outlet,
6
+ Scripts,
7
+ ScrollRestoration,
8
+ } from "react-router";
9
+ import type { Route } from "./+types/root";
10
+ import { SiteHeader } from "./components/site-header";
11
+ import "./app.css";
12
+
13
+ export function Layout({ children }: { children: React.ReactNode }) {
14
+ return (
15
+ <html lang="ko">
16
+ <head>
17
+ <meta charSet="utf-8" />
18
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
19
+ <Meta />
20
+ <Links />
21
+ </head>
22
+ <body className="min-h-screen bg-white text-ink">
23
+ <SiteHeader />
24
+ <main className="mx-auto w-full max-w-5xl px-4 py-8">{children}</main>
25
+ <ScrollRestoration />
26
+ <Scripts />
27
+ </body>
28
+ </html>
29
+ );
30
+ }
31
+
32
+ export default function App() {
33
+ return <Outlet />;
34
+ }
35
+
36
+ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
37
+ const title = isRouteErrorResponse(error)
38
+ ? error.status === 404
39
+ ? "찾을 수 없어요"
40
+ : "문제가 생겼어요"
41
+ : "문제가 생겼어요";
42
+ const detail = isRouteErrorResponse(error)
43
+ ? error.statusText || String(error.data ?? "")
44
+ : error instanceof Error
45
+ ? error.message
46
+ : "";
47
+
48
+ return (
49
+ <div className="space-y-3 py-16 text-center">
50
+ <h1 className="font-bold text-2xl">{title}</h1>
51
+ {detail ? <p className="text-muted">{detail}</p> : null}
52
+ <a className="inline-block text-point underline" href="/">
53
+ 홈으로
54
+ </a>
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,161 @@
1
+ import { Form, Link, redirect } from "react-router";
2
+ import { apiFor } from "../lib/api.server";
3
+ import { cartCookie, readCartToken } from "../lib/cart-session.server";
4
+ import { formatPrice } from "../lib/format";
5
+ import { readToken } from "../lib/session.server";
6
+ import type { Route } from "./+types/cart";
7
+
8
+ export function meta() {
9
+ return [{ title: "장바구니" }];
10
+ }
11
+
12
+ export async function loader({ request }: Route.LoaderArgs) {
13
+ const accessToken = await readToken(request);
14
+ const cartToken = await readCartToken(request);
15
+ // 비회원이 아직 장바구니를 만든 적이 없으면 서버에 물어볼 것이 없다
16
+ if (!accessToken && !cartToken) {
17
+ return { cart: { items: [], summary: { productAmount: 0, deliveryFee: 0, totalAmount: 0 } } };
18
+ }
19
+ const api = apiFor(request, { accessToken, cartToken });
20
+ return { cart: await api.cart.get() };
21
+ }
22
+
23
+ export async function action({ request }: Route.ActionArgs) {
24
+ const form = await request.formData();
25
+ const intent = String(form.get("intent") ?? "");
26
+ const accessToken = await readToken(request);
27
+ const cartToken = await readCartToken(request);
28
+ let issued: string | null = null;
29
+ const api = apiFor(request, {
30
+ accessToken,
31
+ cartToken,
32
+ onCartToken: (token) => {
33
+ issued = token;
34
+ },
35
+ });
36
+
37
+ if (intent === "remove") {
38
+ await api.cart.removeItem(String(form.get("cartItemId")));
39
+ } else if (intent === "update") {
40
+ await api.cart.updateItem(String(form.get("cartItemId")), {
41
+ quantity: Number(form.get("quantity")),
42
+ });
43
+ }
44
+
45
+ const token = issued ?? cartToken;
46
+ return redirect("/cart", {
47
+ headers: token ? { "Set-Cookie": await cartCookie.serialize(token) } : undefined,
48
+ });
49
+ }
50
+
51
+ export default function CartPage({ loaderData }: Route.ComponentProps) {
52
+ const { cart } = loaderData;
53
+
54
+ if (!cart.items.length) {
55
+ return (
56
+ <div className="space-y-4 py-16 text-center">
57
+ <p className="text-muted">장바구니가 비었어요</p>
58
+ <Link to="/products" className="inline-block rounded-md bg-ink px-5 py-3 text-white">
59
+ 상품 보러 가기
60
+ </Link>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ const purchasable = cart.items.filter((item) => item.purchasable);
66
+
67
+ return (
68
+ <div className="space-y-6">
69
+ <h1 className="font-bold text-2xl">장바구니</h1>
70
+
71
+ <ul className="divide-y divide-line border-line border-y">
72
+ {cart.items.map((item) => (
73
+ <li key={item.cartItemId} className="flex gap-4 py-4">
74
+ <img
75
+ src={item.thumbnailUrl}
76
+ alt=""
77
+ className="size-20 rounded-md bg-chip object-cover"
78
+ />
79
+ <div className="flex flex-1 flex-col gap-1">
80
+ <Link to={`/products/${item.productId}`} className="text-sm">
81
+ {item.productName}
82
+ </Link>
83
+ {item.optionName ? (
84
+ <span className="text-muted text-xs">{item.optionName}</span>
85
+ ) : null}
86
+ {!item.purchasable ? (
87
+ <span className="text-point text-xs">
88
+ {item.unpurchasableReason === "SOLD_OUT" ? "품절이에요" : "지금은 살 수 없어요"}
89
+ </span>
90
+ ) : null}
91
+ <div className="flex items-center gap-3 pt-1">
92
+ <Form method="post" className="flex items-center gap-2">
93
+ <input type="hidden" name="cartItemId" value={item.cartItemId} />
94
+ <input
95
+ type="number"
96
+ name="quantity"
97
+ min={1}
98
+ max={999}
99
+ defaultValue={item.quantity}
100
+ className="h-9 w-20 rounded-md border border-line px-2 text-sm"
101
+ />
102
+ <button
103
+ type="submit"
104
+ name="intent"
105
+ value="update"
106
+ className="text-muted text-xs underline"
107
+ >
108
+ 수량 변경
109
+ </button>
110
+ </Form>
111
+ <Form method="post">
112
+ <input type="hidden" name="cartItemId" value={item.cartItemId} />
113
+ <button
114
+ type="submit"
115
+ name="intent"
116
+ value="remove"
117
+ className="text-muted text-xs underline"
118
+ >
119
+ 삭제
120
+ </button>
121
+ </Form>
122
+ </div>
123
+ </div>
124
+ <span className="font-semibold text-sm">{formatPrice(item.totalPrice)}</span>
125
+ </li>
126
+ ))}
127
+ </ul>
128
+
129
+ <div className="flex flex-col gap-3 md:items-end">
130
+ <dl className="w-full space-y-1 text-sm md:w-64">
131
+ <div className="flex justify-between">
132
+ <dt className="text-muted">상품 금액</dt>
133
+ <dd>{formatPrice(cart.summary.productAmount)}</dd>
134
+ </div>
135
+ <div className="flex justify-between">
136
+ <dt className="text-muted">배송비</dt>
137
+ <dd>{formatPrice(cart.summary.deliveryFee)}</dd>
138
+ </div>
139
+ <div className="flex justify-between border-line border-t pt-1 font-bold">
140
+ <dt>결제 금액</dt>
141
+ <dd>{formatPrice(cart.summary.totalAmount)}</dd>
142
+ </div>
143
+ </dl>
144
+
145
+ {/* 주문서는 GET으로 연다 — 진입 조건이 URL에 남아 새로고침·뒤로가기가 그대로 동작한다 */}
146
+ <Form method="get" action="/checkout" className="w-full md:w-64">
147
+ {purchasable.map((item) => (
148
+ <input key={item.cartItemId} type="hidden" name="cartItemId" value={item.cartItemId} />
149
+ ))}
150
+ <button
151
+ type="submit"
152
+ disabled={!purchasable.length}
153
+ className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
154
+ >
155
+ 주문하기
156
+ </button>
157
+ </Form>
158
+ </div>
159
+ </div>
160
+ );
161
+ }
@@ -0,0 +1,66 @@
1
+ import { Link } from "react-router";
2
+ import { apiFor } from "../lib/api.server";
3
+ import { formatPrice } from "../lib/format";
4
+ import { readToken } from "../lib/session.server";
5
+ import type { Route } from "./+types/checkout-complete";
6
+
7
+ export function meta() {
8
+ return [{ title: "주문 완료" }];
9
+ }
10
+
11
+ export async function loader({ request }: Route.LoaderArgs) {
12
+ const orderId = new URL(request.url).searchParams.get("orderId");
13
+ if (!orderId) return { orderId: null, order: null };
14
+
15
+ // 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 준다
16
+ const accessToken = await readToken(request);
17
+ if (!accessToken) return { orderId, order: null };
18
+
19
+ const api = apiFor(request, { accessToken });
20
+ const order = await api.myOrders.get(orderId).catch(() => null);
21
+ return { orderId, order };
22
+ }
23
+
24
+ export default function CheckoutComplete({ loaderData }: Route.ComponentProps) {
25
+ const { orderId, order } = loaderData;
26
+
27
+ return (
28
+ <div className="space-y-6 py-10 text-center">
29
+ <div className="space-y-2">
30
+ <h1 className="font-bold text-2xl">주문을 완료했어요</h1>
31
+ {orderId ? (
32
+ <p className="text-muted text-sm">주문번호 {orderId}</p>
33
+ ) : (
34
+ <p className="text-muted text-sm">주문 내역에서 주문을 확인해주세요</p>
35
+ )}
36
+ </div>
37
+
38
+ {order ? (
39
+ <div className="mx-auto max-w-md space-y-2 rounded-lg border border-line p-5 text-left text-sm">
40
+ {order.items.map((item) => (
41
+ <div key={item.orderItemId} className="flex justify-between gap-3">
42
+ <span>
43
+ {item.productName}
44
+ <span className="text-muted"> × {item.quantity}</span>
45
+ </span>
46
+ <span>{formatPrice(item.totalPrice)}</span>
47
+ </div>
48
+ ))}
49
+ <div className="flex justify-between border-line border-t pt-2 font-bold">
50
+ <span>결제 금액</span>
51
+ <span>{formatPrice(order.payment.totalAmount)}</span>
52
+ </div>
53
+ </div>
54
+ ) : null}
55
+
56
+ <div className="flex justify-center gap-2">
57
+ <Link to="/orders" className="rounded-md border border-ink px-5 py-3">
58
+ 주문 내역
59
+ </Link>
60
+ <Link to="/products" className="rounded-md bg-ink px-5 py-3 text-white">
61
+ 계속 쇼핑하기
62
+ </Link>
63
+ </div>
64
+ </div>
65
+ );
66
+ }