@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.
- package/dist/index.mjs +22 -19
- package/package.json +1 -1
- 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 +1 -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-buttons.ts +6 -0
- package/template/src/lib/social-login.server.ts +46 -0
- package/template/src/routeTree.gen.ts +262 -0
- package/template/src/router.tsx +17 -0
- package/template/src/routes/__root.tsx +90 -0
- package/template/src/routes/auth.callback.ts +48 -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 -38
- package/template/app/root.tsx +0 -78
- package/template/app/routes/auth-callback.tsx +0 -44
- package/template/app/routes/login.tsx +0 -152
- 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/app.css → src/styles.css} +0 -0
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,38 +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
|
-
}
|
|
32
|
-
|
|
33
|
-
/** 공급자 표시 — 버튼 문구·색. 로고는 각 사 가이드의 공식 버튼 자산으로 바꿔 쓴다 */
|
|
34
|
-
export const SOCIAL_BUTTONS = {
|
|
35
|
-
kakao: { label: "카카오로 시작하기", className: "bg-[#FEE500] text-[#191600]" },
|
|
36
|
-
naver: { label: "네이버로 시작하기", className: "bg-[#03A94D] text-white" },
|
|
37
|
-
google: { label: "구글로 시작하기", className: "border border-line bg-white text-ink" },
|
|
38
|
-
} as const;
|
package/template/app/root.tsx
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
2
|
-
import {
|
|
3
|
-
isRouteErrorResponse,
|
|
4
|
-
Links,
|
|
5
|
-
Meta,
|
|
6
|
-
Outlet,
|
|
7
|
-
Scripts,
|
|
8
|
-
ScrollRestoration,
|
|
9
|
-
} from "react-router";
|
|
10
|
-
import type { Route } from "./+types/root";
|
|
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 { buyerSession } from "./lib/session.server";
|
|
15
|
-
import "./app.css";
|
|
16
|
-
|
|
17
|
-
export function Layout({ children }: { children: React.ReactNode }) {
|
|
18
|
-
return (
|
|
19
|
-
<html lang="ko">
|
|
20
|
-
<head>
|
|
21
|
-
<meta charSet="utf-8" />
|
|
22
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
23
|
-
<Meta />
|
|
24
|
-
<Links />
|
|
25
|
-
</head>
|
|
26
|
-
<body className="min-h-screen bg-white text-ink">
|
|
27
|
-
<SiteHeader />
|
|
28
|
-
<main className="mx-auto w-full max-w-5xl px-4 py-8">{children}</main>
|
|
29
|
-
<ScrollRestoration />
|
|
30
|
-
<Scripts />
|
|
31
|
-
</body>
|
|
32
|
-
</html>
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** 구매자 세션 — 모든 요청에서 액세스 토큰을 정하고 만료가 가까우면 갱신한다 */
|
|
37
|
-
export const middleware: Route.MiddlewareFunction[] = [buyerSession];
|
|
38
|
-
|
|
39
|
-
export function loader({ request }: Route.LoaderArgs) {
|
|
40
|
-
const analytics: AnalyticsConfig = {
|
|
41
|
-
apiBaseUrl: API_BASE_URL,
|
|
42
|
-
storeCode: resolveStoreCode(request),
|
|
43
|
-
debug: ANALYTICS_DEBUG,
|
|
44
|
-
};
|
|
45
|
-
return { analytics };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export default function App({ loaderData }: Route.ComponentProps) {
|
|
49
|
-
const { analytics } = loaderData;
|
|
50
|
-
// 방문 분석은 브라우저에서 한 번 시작한다. 이후 페이지뷰는 SDK가 History API 이동으로 센다
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
startAnalytics(analytics);
|
|
53
|
-
}, [analytics]);
|
|
54
|
-
return <Outlet />;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|
58
|
-
const title = isRouteErrorResponse(error)
|
|
59
|
-
? error.status === 404
|
|
60
|
-
? "찾을 수 없어요"
|
|
61
|
-
: "문제가 생겼어요"
|
|
62
|
-
: "문제가 생겼어요";
|
|
63
|
-
const detail = isRouteErrorResponse(error)
|
|
64
|
-
? error.statusText || String(error.data ?? "")
|
|
65
|
-
: error instanceof Error
|
|
66
|
-
? error.message
|
|
67
|
-
: "";
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
<div className="space-y-3 py-16 text-center">
|
|
71
|
-
<h1 className="font-bold text-2xl">{title}</h1>
|
|
72
|
-
{detail ? <p className="text-muted">{detail}</p> : null}
|
|
73
|
-
<a className="inline-block text-point underline" href="/">
|
|
74
|
-
홈으로
|
|
75
|
-
</a>
|
|
76
|
-
</div>
|
|
77
|
-
);
|
|
78
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { ApiError } from "@sayren/storefront-sdk";
|
|
2
|
-
import { IdpCallbackError } from "@sayren/storefront-sdk/auth";
|
|
3
|
-
import { redirect } from "react-router";
|
|
4
|
-
import { authFor } from "../lib/api.server";
|
|
5
|
-
import { cartCookie, readCartToken } from "../lib/cart-session.server";
|
|
6
|
-
import { signInCookie } from "../lib/session.server";
|
|
7
|
-
import { readSocialLogin, socialLoginCookie } from "../lib/social-login.server";
|
|
8
|
-
import type { Route } from "./+types/auth-callback";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* 소셜 로그인에서 돌아오는 곳 — 쿠키에 둔 state·verifier로 1회용 code를 구매자 토큰으로 바꾸고 세션 쿠키를 심는다.
|
|
12
|
-
* 비회원 장바구니가 있으면 합친다. 실패하면 로그인 화면으로 이유를 들고 돌아간다.
|
|
13
|
-
*/
|
|
14
|
-
export async function loader({ request }: Route.LoaderArgs) {
|
|
15
|
-
const url = new URL(request.url);
|
|
16
|
-
const stored = await readSocialLogin(request);
|
|
17
|
-
const clearSocial = await socialLoginCookie.serialize("", { maxAge: 0 });
|
|
18
|
-
const fail = (reason: string) =>
|
|
19
|
-
redirect(`/login?error=${encodeURIComponent(reason)}`, {
|
|
20
|
-
headers: { "Set-Cookie": clearSocial },
|
|
21
|
-
});
|
|
22
|
-
if (!stored) return fail("state_mismatch");
|
|
23
|
-
|
|
24
|
-
const cartToken = await readCartToken(request);
|
|
25
|
-
try {
|
|
26
|
-
const tokens = await authFor(request).idpCallback({
|
|
27
|
-
code: url.searchParams.get("code"),
|
|
28
|
-
state: url.searchParams.get("state"),
|
|
29
|
-
error: url.searchParams.get("error"),
|
|
30
|
-
expectedState: stored.state,
|
|
31
|
-
codeVerifier: stored.codeVerifier,
|
|
32
|
-
cartToken: cartToken ?? undefined,
|
|
33
|
-
});
|
|
34
|
-
const headers = new Headers();
|
|
35
|
-
headers.append("Set-Cookie", await signInCookie(tokens));
|
|
36
|
-
headers.append("Set-Cookie", clearSocial);
|
|
37
|
-
if (cartToken) headers.append("Set-Cookie", await cartCookie.serialize("", { maxAge: 0 }));
|
|
38
|
-
return redirect(stored.redirectTo, { headers });
|
|
39
|
-
} catch (error) {
|
|
40
|
-
if (error instanceof IdpCallbackError) return fail(error.reason);
|
|
41
|
-
if (error instanceof ApiError) return fail("provider_error");
|
|
42
|
-
throw error;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { ApiError, type SocialProvider } from "@sayren/storefront-sdk";
|
|
2
|
-
import { Form, redirect, useSearchParams } from "react-router";
|
|
3
|
-
import { apiFor, authFor } from "../lib/api.server";
|
|
4
|
-
import { cartCookie, readCartToken } from "../lib/cart-session.server";
|
|
5
|
-
import { safeRedirect } from "../lib/safe-redirect";
|
|
6
|
-
import { signInCookie } from "../lib/session.server";
|
|
7
|
-
import { SOCIAL_BUTTONS, socialLoginCookie } from "../lib/social-login.server";
|
|
8
|
-
import type { Route } from "./+types/login";
|
|
9
|
-
|
|
10
|
-
export function meta() {
|
|
11
|
-
return [{ title: "로그인" }];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** 켜진 로그인 방법 — 셀러가 콘솔 설정 › 로그인에서 켠 것만 온다 */
|
|
15
|
-
export async function loader({ request }: Route.LoaderArgs) {
|
|
16
|
-
const store = await apiFor(request).store.get();
|
|
17
|
-
return { loginMethods: store.loginMethods };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* 소셜 로그인 시작 — state·PKCE verifier를 쿠키에 두고 공급자 로그인 화면으로 보낸다. 돌아오는 곳은 `/auth/callback`이고,
|
|
22
|
-
* 이 앱의 주소가 콘솔의 스토어프런트 도메인에 등록돼 있어야 한다.
|
|
23
|
-
*/
|
|
24
|
-
async function startSocial(request: Request, provider: SocialProvider, redirectTo: string) {
|
|
25
|
-
const origin = new URL(request.url).origin;
|
|
26
|
-
const started = await authFor(request).idp(provider, { redirectUri: `${origin}/auth/callback` });
|
|
27
|
-
return redirect(started.url, {
|
|
28
|
-
headers: {
|
|
29
|
-
"Set-Cookie": await socialLoginCookie.serialize({
|
|
30
|
-
state: started.state,
|
|
31
|
-
codeVerifier: started.codeVerifier,
|
|
32
|
-
redirectTo,
|
|
33
|
-
}),
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* 로그인 — 화면은 이 앱이 그리고, 확인은 sayren이 한다. 비회원으로 담아 둔 장바구니 토큰을 함께 보내면
|
|
40
|
-
* 회원 장바구니로 합쳐지고 그 토큰은 닫힌다. 그래서 장바구니 쿠키도 지운다.
|
|
41
|
-
*/
|
|
42
|
-
export async function action({ request }: Route.ActionArgs) {
|
|
43
|
-
const form = await request.formData();
|
|
44
|
-
const redirectTo = safeRedirect(
|
|
45
|
-
form.get("redirectTo") ?? new URL(request.url).searchParams.get("redirectTo"),
|
|
46
|
-
);
|
|
47
|
-
const social = form.get("social");
|
|
48
|
-
if (social === "kakao" || social === "naver" || social === "google") {
|
|
49
|
-
try {
|
|
50
|
-
return await startSocial(request, social, redirectTo);
|
|
51
|
-
} catch (error) {
|
|
52
|
-
return { error: loginErrorMessage(error) };
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
const cartToken = await readCartToken(request);
|
|
56
|
-
try {
|
|
57
|
-
const tokens = await authFor(request).session({
|
|
58
|
-
email: String(form.get("email") ?? ""),
|
|
59
|
-
password: String(form.get("password") ?? ""),
|
|
60
|
-
cartToken: cartToken ?? undefined,
|
|
61
|
-
});
|
|
62
|
-
const headers = new Headers();
|
|
63
|
-
headers.append("Set-Cookie", await signInCookie(tokens));
|
|
64
|
-
if (cartToken) headers.append("Set-Cookie", await cartCookie.serialize("", { maxAge: 0 }));
|
|
65
|
-
return redirect(redirectTo, { headers });
|
|
66
|
-
} catch (error) {
|
|
67
|
-
return { error: loginErrorMessage(error) };
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function loginErrorMessage(error: unknown): string {
|
|
72
|
-
if (error instanceof ApiError) {
|
|
73
|
-
if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지 않아요";
|
|
74
|
-
if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인만 받아요";
|
|
75
|
-
if (error.code === "ACCOUNT_LOCKED")
|
|
76
|
-
return "로그인을 여러 번 실패해 잠시 잠겼어요. 10분 뒤에 다시 시도해 주세요";
|
|
77
|
-
if (error.status === 400) return "이메일과 비밀번호를 확인해 주세요";
|
|
78
|
-
}
|
|
79
|
-
return "로그인하지 못했어요. 잠시 후 다시 시도해 주세요";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const CALLBACK_ERRORS: Record<string, string> = {
|
|
83
|
-
access_denied: "로그인을 취소했어요",
|
|
84
|
-
state_mismatch: "로그인 요청이 만료됐어요. 다시 시도해 주세요",
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export default function Login({ loaderData, actionData }: Route.ComponentProps) {
|
|
88
|
-
const [params] = useSearchParams();
|
|
89
|
-
const callbackError = params.get("error");
|
|
90
|
-
const social = loaderData.loginMethods.filter(
|
|
91
|
-
(method): method is SocialProvider => method !== "password",
|
|
92
|
-
);
|
|
93
|
-
const password = loaderData.loginMethods.includes("password");
|
|
94
|
-
const error =
|
|
95
|
-
actionData?.error ??
|
|
96
|
-
(callbackError
|
|
97
|
-
? (CALLBACK_ERRORS[callbackError] ?? "로그인하지 못했어요. 다시 시도해 주세요")
|
|
98
|
-
: null);
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<div className="mx-auto max-w-sm space-y-5 py-10">
|
|
102
|
-
<h1 className="font-bold text-2xl">로그인</h1>
|
|
103
|
-
{social.length ? (
|
|
104
|
-
<Form method="post" className="space-y-2">
|
|
105
|
-
{social.map((provider) => (
|
|
106
|
-
<button
|
|
107
|
-
key={provider}
|
|
108
|
-
type="submit"
|
|
109
|
-
name="social"
|
|
110
|
-
value={provider}
|
|
111
|
-
className={`h-12 w-full rounded-md font-semibold ${SOCIAL_BUTTONS[provider].className}`}
|
|
112
|
-
>
|
|
113
|
-
{SOCIAL_BUTTONS[provider].label}
|
|
114
|
-
</button>
|
|
115
|
-
))}
|
|
116
|
-
</Form>
|
|
117
|
-
) : null}
|
|
118
|
-
{social.length && password ? (
|
|
119
|
-
<div className="flex items-center gap-3 text-muted text-xs">
|
|
120
|
-
<span className="h-px flex-1 bg-line" />
|
|
121
|
-
또는 이메일로
|
|
122
|
-
<span className="h-px flex-1 bg-line" />
|
|
123
|
-
</div>
|
|
124
|
-
) : null}
|
|
125
|
-
{password ? (
|
|
126
|
-
<Form method="post" className="space-y-3">
|
|
127
|
-
<input
|
|
128
|
-
type="email"
|
|
129
|
-
name="email"
|
|
130
|
-
placeholder="이메일"
|
|
131
|
-
required
|
|
132
|
-
className="h-11 w-full rounded-md border border-line px-3"
|
|
133
|
-
/>
|
|
134
|
-
<input
|
|
135
|
-
type="password"
|
|
136
|
-
name="password"
|
|
137
|
-
placeholder="비밀번호"
|
|
138
|
-
required
|
|
139
|
-
className="h-11 w-full rounded-md border border-line px-3"
|
|
140
|
-
/>
|
|
141
|
-
<button type="submit" className="h-12 w-full rounded-md bg-ink font-semibold text-white">
|
|
142
|
-
로그인
|
|
143
|
-
</button>
|
|
144
|
-
</Form>
|
|
145
|
-
) : null}
|
|
146
|
-
{error ? <p className="text-point text-sm">{error}</p> : null}
|
|
147
|
-
{social.length ? (
|
|
148
|
-
<p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 돼요</p>
|
|
149
|
-
) : null}
|
|
150
|
-
</div>
|
|
151
|
-
);
|
|
152
|
-
}
|
package/template/app/routes.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { index, type RouteConfig, route } from "@react-router/dev/routes";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 라우트 구성 — 구매 흐름 전체를 덮는다.
|
|
5
|
-
* 확장 지점: 기획전·브랜드관 같은 화면은 여기에 route()를 더하면 된다.
|
|
6
|
-
*/
|
|
7
|
-
export default [
|
|
8
|
-
index("routes/home.tsx"),
|
|
9
|
-
route("products", "routes/products.tsx"),
|
|
10
|
-
route("products/:productId", "routes/product-detail.tsx"),
|
|
11
|
-
route("cart", "routes/cart.tsx"),
|
|
12
|
-
route("checkout", "routes/checkout.tsx"),
|
|
13
|
-
route("checkout/complete", "routes/checkout-complete.tsx"),
|
|
14
|
-
route("login", "routes/login.tsx"),
|
|
15
|
-
route("auth/callback", "routes/auth-callback.tsx"),
|
|
16
|
-
route("orders", "routes/orders.tsx"),
|
|
17
|
-
route("orders/:orderId", "routes/order-detail.tsx"),
|
|
18
|
-
] satisfies RouteConfig;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|