@sellosh/commerce 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.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # @sellosh/commerce — Sello 커머스 SDK
2
+
3
+ 모든 스토어프론트(손코딩 스토어 · `frontend-01` · sello.sh 에서 **AI 가 생성**하는 스토어)가
4
+ 공유하는 **타입드 클라이언트 + React 훅**. 디자인·레이아웃은 자유롭게 만들되,
5
+ **결제·재고·인증의 안전 경계는 이 SDK 가 한 곳에서** 책임진다.
6
+
7
+ ## 왜 SDK 먼저인가
8
+
9
+ 이게 "계약(contract)"이다. 누가 어떤 레이아웃으로 스토어를 만들든 —
10
+ - 상품/장바구니/주문/결제/인증은 **이 SDK 를 통해서만** 백엔드(`/api/frontend/*`)에 닿는다.
11
+ - SDK 는 **시크릿을 절대 갖지 않는다.** 결제는 항상 서버 승인(`confirmTossPayment` → 서버가
12
+ `buildOrder` 로 가격·재고 재검증 + `total === amount` 확인 + 가게 시크릿키로 토스 승인)을 호출.
13
+ - 그래서 스토어 코드가 아무리 자유로워도(또는 AI 가 생성해도) 돈·데이터 무결성은 못 깨뜨린다.
14
+
15
+ ## 구성
16
+
17
+ - `@sellosh/commerce` — 프레임워크 무관 코어: `types`, `createSelloClient`, `resolveBusinessSlug`,
18
+ 순수 장바구니 로직(`addToCart`/`cartTotal`/`cartToOrderItems`…), checkout draft.
19
+ - `@sellosh/commerce/react` — `SelloProvider` + 훅(`useAuth`/`useCart`/`useProducts`/`useProduct`/
20
+ `useOrders`/`useOrder`/`usePaymentConfig`) + `<TossPaymentWidget>`.
21
+
22
+ ## 빠른 사용
23
+
24
+ ```tsx
25
+ // 앱 루트
26
+ import { SelloProvider } from "@sellosh/commerce/react";
27
+
28
+ <SelloProvider apiBase="https://www.sello.sh" /* Next 면 businessSlug="myshop" 권장 */>
29
+ <App />
30
+ </SelloProvider>;
31
+ ```
32
+
33
+ ```tsx
34
+ // 상품 목록 (레이아웃은 자유)
35
+ import { useProducts, useCart } from "@sellosh/commerce/react";
36
+
37
+ function Shop() {
38
+ const { products, loading } = useProducts();
39
+ const cart = useCart();
40
+ if (loading) return <Spinner />;
41
+ return products.map((p) => (
42
+ <MyProductCard
43
+ key={p.id}
44
+ product={p}
45
+ onAdd={() =>
46
+ cart.add({
47
+ productId: p.id, name: p.name, image_url: p.image_url,
48
+ options: {}, unitPrice: p.price, currency: p.currency,
49
+ quantity: 1, maxStock: p.stock ?? null,
50
+ })
51
+ }
52
+ />
53
+ ));
54
+ }
55
+ ```
56
+
57
+ ```tsx
58
+ // 결제 (안전 경계) — 위젯 렌더 → 토스 결제 → 성공 페이지에서 서버 승인
59
+ import { usePaymentConfig, useCart, TossPaymentWidget, useSello } from "@sellosh/commerce/react";
60
+ import { cartToOrderItems, saveDraft } from "@sellosh/commerce";
61
+
62
+ // 체크아웃
63
+ const { config } = usePaymentConfig();
64
+ const cart = useCart();
65
+ // ...config.tossClientKey 로 <TossPaymentWidget> 렌더, onReady(pay) 받아 결제 요청.
66
+ // successUrl 로 돌아오면:
67
+ const { client } = useSello();
68
+ await client.confirmTossPayment({
69
+ paymentKey, orderId, amount,
70
+ items: cartToOrderItems(cart.items),
71
+ customer_name, phone, address,
72
+ businessSlug, // 비회원이면 필수
73
+ });
74
+ // → 서버가 재검증 후 주문 생성. 클라가 보낸 가격/금액은 신뢰하지 않음.
75
+ ```
76
+
77
+ ## 멀티테넌트 / 환경
78
+
79
+ - 가게 식별: 서브도메인(`myshop.sello.sh`) → `?business=` → `fallbackSlug`. Next 에선 `businessSlug` prop 권장.
80
+ - `apiBase` 는 prop 으로 주입(빌드툴 env 에 비의존) — Vite/Next/AI생성 스토어 동일하게 동작.
81
+
82
+ ## 로드맵
83
+
84
+ - [ ] `frontend-01` 을 이 SDK 로 마이그레이션(중복 제거, 검증)
85
+ - [ ] npm 퍼블리시(`@sellosh/commerce`) — 별도 레포(chaae 등)에서 설치
86
+ - [ ] `<CheckoutForm>` / 주소검색 등 옵션 UI 프리미티브
87
+ - [ ] AI 스토어 생성기가 이 SDK 표면을 타겟으로 코드 생성
@@ -0,0 +1,7 @@
1
+ export interface ResolveSlugOptions {
2
+ /** 서브도메인/쿼리로 못 찾을 때 쓸 기본 slug (env 값 등을 호출부에서 주입) */
3
+ fallback?: string | null;
4
+ /** 테넌트 서브도메인이 아닌 예약어 */
5
+ reserved?: string[];
6
+ }
7
+ export declare function resolveBusinessSlug(opts?: ResolveSlugOptions): string | null;
@@ -0,0 +1,28 @@
1
+ // 현재 화면이 "어느 가게(business)"인지 판별.
2
+ // 우선순위: 서브도메인 → ?business= 쿼리 → fallback.
3
+ // production 은 서브도메인: {slug}.sello.sh → slug. (*.vercel.app, bare localhost 는 제외)
4
+ //
5
+ // 빌드툴(import.meta / process.env)에 의존하지 않도록 fallback 을 인자로 받는다 —
6
+ // Vite·Next·AI생성 스토어 어디서나 동일하게 쓰인다.
7
+ const DEFAULT_RESERVED = ["www", "app", "admin", "api", "localhost"];
8
+ export function resolveBusinessSlug(opts = {}) {
9
+ if (typeof window === "undefined")
10
+ return opts.fallback ?? null;
11
+ const host = window.location.hostname;
12
+ const parts = host.split(".");
13
+ let slug = null;
14
+ if (host.endsWith(".localhost") && parts.length >= 2) {
15
+ slug = parts[0] ?? null; // shopdemo.localhost
16
+ }
17
+ else if (parts.length > 2 && !host.endsWith(".vercel.app")) {
18
+ slug = parts[0] ?? null; // shopdemo.sello.sh
19
+ }
20
+ const reserved = new Set(opts.reserved ?? DEFAULT_RESERVED);
21
+ if (slug && reserved.has(slug))
22
+ slug = null;
23
+ if (!slug) {
24
+ const fromQuery = new URLSearchParams(window.location.search).get("business");
25
+ slug = fromQuery || opts.fallback || null;
26
+ }
27
+ return slug;
28
+ }
package/dist/cart.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { CartItem, CartItemInput } from "./types";
2
+ /** (상품 + 옵션조합) 단위의 고유 키 — 같은 상품 다른 옵션은 별도 라인. */
3
+ export declare function itemKey(productId: number, options: Record<string, string>): string;
4
+ /** 동일 (상품+옵션)이면 수량 합산(재고 상한 적용), 아니면 새 라인 추가. */
5
+ export declare function addToCart(items: CartItem[], item: CartItem): CartItem[];
6
+ export declare function setCartQty(items: CartItem[], key: string, qty: number): CartItem[];
7
+ export declare function removeFromCart(items: CartItem[], key: string): CartItem[];
8
+ export declare function cartCount(items: CartItem[]): number;
9
+ export declare function cartTotal(items: CartItem[]): number;
10
+ /** 장바구니 → 서버 주문 아이템(가격·재고는 서버가 재검증). */
11
+ export declare function cartToOrderItems(items: CartItem[]): CartItemInput[];
package/dist/cart.js ADDED
@@ -0,0 +1,51 @@
1
+ /** (상품 + 옵션조합) 단위의 고유 키 — 같은 상품 다른 옵션은 별도 라인. */
2
+ export function itemKey(productId, options) {
3
+ return (productId +
4
+ "::" +
5
+ Object.keys(options)
6
+ .sort()
7
+ .map((k) => `${k}=${options[k]}`)
8
+ .join("&"));
9
+ }
10
+ /** 동일 (상품+옵션)이면 수량 합산(재고 상한 적용), 아니면 새 라인 추가. */
11
+ export function addToCart(items, item) {
12
+ const k = itemKey(item.productId, item.options);
13
+ const idx = items.findIndex((x) => itemKey(x.productId, x.options) === k);
14
+ if (idx >= 0) {
15
+ const next = [...items];
16
+ const cur = next[idx];
17
+ let qty = cur.quantity + item.quantity;
18
+ if (cur.maxStock != null)
19
+ qty = Math.min(qty, cur.maxStock);
20
+ next[idx] = { ...cur, quantity: qty };
21
+ return next;
22
+ }
23
+ return [...items, item];
24
+ }
25
+ export function setCartQty(items, key, qty) {
26
+ return items.map((x) => {
27
+ if (itemKey(x.productId, x.options) !== key)
28
+ return x;
29
+ let q = Math.max(1, qty);
30
+ if (x.maxStock != null)
31
+ q = Math.min(q, x.maxStock);
32
+ return { ...x, quantity: q };
33
+ });
34
+ }
35
+ export function removeFromCart(items, key) {
36
+ return items.filter((x) => itemKey(x.productId, x.options) !== key);
37
+ }
38
+ export function cartCount(items) {
39
+ return items.reduce((s, x) => s + x.quantity, 0);
40
+ }
41
+ export function cartTotal(items) {
42
+ return items.reduce((s, x) => s + x.unitPrice * x.quantity, 0);
43
+ }
44
+ /** 장바구니 → 서버 주문 아이템(가격·재고는 서버가 재검증). */
45
+ export function cartToOrderItems(items) {
46
+ return items.map((x) => ({
47
+ product_id: x.productId,
48
+ options: x.options,
49
+ quantity: x.quantity,
50
+ }));
51
+ }
@@ -0,0 +1,69 @@
1
+ import type { AuthProviders, AuthResult, AuthUser, CartItemInput, ConfirmTossInput, ConfirmTossResult, MetaConfig, Order, OrderWithItems, PaymentConfig, ProductDetailResult, ProductListResult, ProfileInput, ReviewListParams, ReviewListResult } from "./types";
2
+ export interface SelloClientConfig {
3
+ /** API 베이스 URL. "" = 동일 출처(프록시/리라이트). 예: "https://www.sello.sh" */
4
+ apiBase?: string;
5
+ /** 기본 가게 slug — 메서드에 slug 를 안 넘기면 이걸 사용 */
6
+ businessSlug?: string | null;
7
+ /** 인증이 필요한 호출에 붙일 Bearer 토큰 공급자 */
8
+ getToken?: () => string | null | undefined;
9
+ }
10
+ export interface CreateOrderInput {
11
+ items: CartItemInput[];
12
+ customer_name: string;
13
+ phone: string;
14
+ address: string;
15
+ }
16
+ export interface SelloClient {
17
+ readonly businessSlug: string | null;
18
+ listProducts(slug?: string): Promise<ProductListResult>;
19
+ getProduct(id: number, slug?: string): Promise<ProductDetailResult>;
20
+ getPaymentConfig(slug?: string): Promise<PaymentConfig>;
21
+ confirmTossPayment(input: ConfirmTossInput): Promise<ConfirmTossResult>;
22
+ listOrders(): Promise<{
23
+ orders: Order[];
24
+ }>;
25
+ getOrder(id: number, opts?: {
26
+ guestToken?: string;
27
+ }): Promise<OrderWithItems>;
28
+ lookupOrder(input: {
29
+ businessSlug: string;
30
+ orderId: number | string;
31
+ phone: string;
32
+ }): Promise<OrderWithItems>;
33
+ claimOrder(id: number, guestToken: string): Promise<{
34
+ ok: true;
35
+ order: {
36
+ id: number;
37
+ };
38
+ }>;
39
+ createOrder(input: CreateOrderInput): Promise<{
40
+ order: {
41
+ id: number;
42
+ total: number;
43
+ status: string;
44
+ };
45
+ }>;
46
+ register(input: {
47
+ slug: string;
48
+ email: string;
49
+ password: string;
50
+ name?: string;
51
+ }): Promise<AuthResult>;
52
+ login(input: {
53
+ slug: string;
54
+ email: string;
55
+ password: string;
56
+ }): Promise<AuthResult>;
57
+ me(): Promise<{
58
+ user: AuthUser;
59
+ }>;
60
+ getProviders(slug?: string): Promise<AuthProviders>;
61
+ updateProfile(input: ProfileInput): Promise<{
62
+ user: AuthUser;
63
+ }>;
64
+ /** 소셜 로그인 시작 URL (브라우저를 이 URL 로 리다이렉트) */
65
+ oauthStartUrl(provider: "google" | "kakao", returnUrl: string, slug?: string): string;
66
+ listReviews(params: ReviewListParams): Promise<ReviewListResult>;
67
+ getMetaConfig(slug?: string): Promise<MetaConfig>;
68
+ }
69
+ export declare function createSelloClient(config?: SelloClientConfig): SelloClient;
package/dist/client.js ADDED
@@ -0,0 +1,74 @@
1
+ export function createSelloClient(config = {}) {
2
+ const apiBase = config.apiBase ?? "";
3
+ const q = (s) => encodeURIComponent(s);
4
+ function requireSlug(slug) {
5
+ const s = slug ?? config.businessSlug ?? null;
6
+ if (!s) {
7
+ throw new Error("business slug 가 필요합니다 (SelloProvider 의 businessSlug 또는 인자로 전달).");
8
+ }
9
+ return s;
10
+ }
11
+ async function req(path, options = {}, withAuth = false) {
12
+ const headers = new Headers(options.headers);
13
+ if (options.body)
14
+ headers.set("Content-Type", "application/json");
15
+ if (withAuth) {
16
+ const token = config.getToken?.();
17
+ if (token)
18
+ headers.set("Authorization", `Bearer ${token}`);
19
+ }
20
+ const res = await fetch(`${apiBase}${path}`, { ...options, headers });
21
+ const data = await res.json().catch(() => ({}));
22
+ if (!res.ok) {
23
+ const msg = typeof data === "object" && data !== null && "error" in data
24
+ ? String(data.error)
25
+ : "요청에 실패했습니다.";
26
+ throw new Error(msg);
27
+ }
28
+ return data;
29
+ }
30
+ return {
31
+ get businessSlug() {
32
+ return config.businessSlug ?? null;
33
+ },
34
+ listProducts: (slug) => req(`/api/frontend/products?business=${q(requireSlug(slug))}`),
35
+ getProduct: (id, slug) => req(`/api/frontend/products/${id}?business=${q(requireSlug(slug))}`),
36
+ getPaymentConfig: (slug) => req(`/api/frontend/payments/config?business=${q(requireSlug(slug))}`),
37
+ confirmTossPayment: (input) => req(`/api/frontend/payments/toss/confirm`, { method: "POST", body: JSON.stringify(input) }, true),
38
+ listOrders: () => req(`/api/frontend/orders`, {}, true),
39
+ getOrder: (id, opts) => req(`/api/frontend/orders/${id}${opts?.guestToken ? `?guestToken=${q(opts.guestToken)}` : ""}`, {}, true),
40
+ lookupOrder: (input) => req(`/api/frontend/orders/lookup`, {
41
+ method: "POST",
42
+ body: JSON.stringify(input),
43
+ }),
44
+ claimOrder: (id, guestToken) => req(`/api/frontend/orders/${id}/claim`, { method: "POST", body: JSON.stringify({ guestToken }) }, true),
45
+ createOrder: (input) => req(`/api/frontend/orders`, { method: "POST", body: JSON.stringify(input) }, true),
46
+ register: (input) => req(`/api/frontend/auth/register`, {
47
+ method: "POST",
48
+ body: JSON.stringify(input),
49
+ }),
50
+ login: (input) => req(`/api/frontend/auth/login`, {
51
+ method: "POST",
52
+ body: JSON.stringify(input),
53
+ }),
54
+ me: () => req(`/api/frontend/auth/me`, {}, true),
55
+ getProviders: (slug) => req(`/api/frontend/auth/providers?business=${q(requireSlug(slug))}`),
56
+ updateProfile: (input) => req(`/api/frontend/profile`, { method: "PUT", body: JSON.stringify(input) }, true),
57
+ oauthStartUrl: (provider, returnUrl, slug) => `${apiBase}/api/frontend/auth/${provider}/start?slug=${q(requireSlug(slug))}&return=${q(returnUrl)}`,
58
+ listReviews: (params) => {
59
+ const sp = new URLSearchParams();
60
+ sp.set("productId", String(params.productId));
61
+ sp.set("businessSlug", params.businessSlug);
62
+ if (params.rating != null)
63
+ sp.set("rating", String(params.rating));
64
+ if (params.status)
65
+ sp.set("status", params.status);
66
+ if (params.page != null)
67
+ sp.set("page", String(params.page));
68
+ if (params.limit != null)
69
+ sp.set("limit", String(params.limit));
70
+ return req(`/api/frontend/reviews?${sp.toString()}`);
71
+ },
72
+ getMetaConfig: (slug) => req(`/api/frontend/meta/config?business=${q(requireSlug(slug))}`),
73
+ };
74
+ }
@@ -0,0 +1,17 @@
1
+ import type { CartItemInput } from "./types";
2
+ export interface CheckoutDraft {
3
+ items: CartItemInput[];
4
+ customer_name: string;
5
+ phone: string;
6
+ address: string;
7
+ email?: string | null;
8
+ amount: number;
9
+ businessSlug?: string;
10
+ /** 바로구매(단품) 주문 — true 면 결제 성공 후 장바구니를 비우지 않는다(카트 보존) */
11
+ instant?: boolean;
12
+ }
13
+ export declare function saveDraft(orderId: string, draft: CheckoutDraft): void;
14
+ export declare function readDraft(orderId: string): CheckoutDraft | null;
15
+ export declare function clearDraft(orderId: string): void;
16
+ export declare function saveGuestToken(orderId: number, guestToken: string): void;
17
+ export declare function readGuestToken(orderId: number): string | null;
package/dist/draft.js ADDED
@@ -0,0 +1,45 @@
1
+ const PREFIX = "sello_toss_draft_";
2
+ export function saveDraft(orderId, draft) {
3
+ try {
4
+ localStorage.setItem(PREFIX + orderId, JSON.stringify(draft));
5
+ }
6
+ catch {
7
+ // localStorage 불가해도 결제는 진행 (성공 페이지에서 안내)
8
+ }
9
+ }
10
+ export function readDraft(orderId) {
11
+ try {
12
+ const raw = localStorage.getItem(PREFIX + orderId);
13
+ return raw ? JSON.parse(raw) : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function clearDraft(orderId) {
20
+ try {
21
+ localStorage.removeItem(PREFIX + orderId);
22
+ }
23
+ catch {
24
+ // ignore
25
+ }
26
+ }
27
+ // 비회원 주문 열람 토큰 — 결제 승인 응답의 guest_token 을 주문번호별로 보관.
28
+ // (회원 주문은 로그인 토큰으로 열람하므로 저장하지 않는다)
29
+ const GUEST_PREFIX = "sello_guest_order_";
30
+ export function saveGuestToken(orderId, guestToken) {
31
+ try {
32
+ localStorage.setItem(GUEST_PREFIX + orderId, guestToken);
33
+ }
34
+ catch {
35
+ // ignore
36
+ }
37
+ }
38
+ export function readGuestToken(orderId) {
39
+ try {
40
+ return localStorage.getItem(GUEST_PREFIX + orderId);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./types";
2
+ export * from "./business";
3
+ export * from "./cart";
4
+ export * from "./draft";
5
+ export * from "./client";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // @repo/commerce — 프레임워크 무관 코어 (타입 + 클라이언트 + 장바구니/주소 로직).
2
+ // React 훅/컴포넌트는 "@repo/commerce/react" 에서 import.
3
+ export * from "./types";
4
+ export * from "./business";
5
+ export * from "./cart";
6
+ export * from "./draft";
7
+ export * from "./client";
@@ -0,0 +1,42 @@
1
+ type TossWidgets = {
2
+ setAmount: (a: {
3
+ currency: string;
4
+ value: number;
5
+ }) => Promise<void>;
6
+ renderPaymentMethods: (o: {
7
+ selector: string;
8
+ variantKey: string;
9
+ }) => Promise<void>;
10
+ requestPayment: (o: Record<string, unknown>) => Promise<void>;
11
+ };
12
+ type TossPaymentsFn = (clientKey: string) => {
13
+ widgets: (o: {
14
+ customerKey: string;
15
+ }) => TossWidgets;
16
+ };
17
+ declare global {
18
+ interface Window {
19
+ TossPayments?: TossPaymentsFn;
20
+ }
21
+ }
22
+ export interface PayRequest {
23
+ orderId: string;
24
+ orderName: string;
25
+ successUrl: string;
26
+ failUrl: string;
27
+ customerName?: string;
28
+ customerEmail?: string;
29
+ customerMobilePhone?: string;
30
+ }
31
+ export interface TossPaymentWidgetProps {
32
+ clientKey: string;
33
+ amount: number;
34
+ customerKey: string;
35
+ /** 결제수단 위젯을 그릴 컨테이너 id (기본 toss-payment-method) */
36
+ selector?: string;
37
+ onReady?: (pay: (req: PayRequest) => Promise<void>) => void;
38
+ onError?: (msg: string) => void;
39
+ className?: string;
40
+ }
41
+ export declare function TossPaymentWidget({ clientKey, amount, customerKey, selector, onReady, onError, className, }: TossPaymentWidgetProps): import("react").JSX.Element;
42
+ export {};
@@ -0,0 +1,82 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ const SDK_SRC = "https://js.tosspayments.com/v2/standard";
5
+ export function TossPaymentWidget({ clientKey, amount, customerKey, selector = "toss-payment-method", onReady, onError, className, }) {
6
+ const widgetsRef = useRef(null);
7
+ const [status, setStatus] = useState(clientKey ? "loading" : "error");
8
+ // 콜백/금액을 ref 로 보관 → init effect 는 clientKey/customerKey 에만 의존.
9
+ const amountRef = useRef(amount);
10
+ const onReadyRef = useRef(onReady);
11
+ const onErrorRef = useRef(onError);
12
+ useEffect(() => {
13
+ amountRef.current = amount;
14
+ onReadyRef.current = onReady;
15
+ onErrorRef.current = onError;
16
+ });
17
+ useEffect(() => {
18
+ if (!clientKey)
19
+ return;
20
+ let cancelled = false;
21
+ function start() {
22
+ if (cancelled || !window.TossPayments)
23
+ return;
24
+ const widgets = window.TossPayments(clientKey).widgets({ customerKey });
25
+ widgetsRef.current = widgets;
26
+ void (async () => {
27
+ await widgets.setAmount({ currency: "KRW", value: amountRef.current });
28
+ await widgets.renderPaymentMethods({
29
+ selector: `#${selector}`,
30
+ variantKey: "DEFAULT",
31
+ });
32
+ if (cancelled)
33
+ return;
34
+ setStatus("ready");
35
+ const pay = async (req) => {
36
+ await widgets.setAmount({ currency: "KRW", value: amountRef.current });
37
+ await widgets.requestPayment({
38
+ orderId: req.orderId,
39
+ orderName: req.orderName,
40
+ successUrl: req.successUrl,
41
+ failUrl: req.failUrl,
42
+ customerName: req.customerName,
43
+ customerEmail: req.customerEmail,
44
+ customerMobilePhone: req.customerMobilePhone,
45
+ });
46
+ };
47
+ onReadyRef.current?.(pay);
48
+ })().catch(() => {
49
+ if (cancelled)
50
+ return;
51
+ setStatus("error");
52
+ onErrorRef.current?.("결제 위젯을 불러오지 못했습니다.");
53
+ });
54
+ }
55
+ if (window.TossPayments) {
56
+ start();
57
+ }
58
+ else {
59
+ const existing = document.querySelector(`script[src="${SDK_SRC}"]`);
60
+ if (existing) {
61
+ existing.addEventListener("load", start);
62
+ }
63
+ else {
64
+ const s = document.createElement("script");
65
+ s.src = SDK_SRC;
66
+ s.async = true;
67
+ s.onload = start;
68
+ document.head.appendChild(s);
69
+ }
70
+ }
71
+ return () => {
72
+ cancelled = true;
73
+ };
74
+ }, [clientKey, customerKey, selector]);
75
+ // 금액 변경 시 위젯에 반영
76
+ useEffect(() => {
77
+ if (status === "ready" && widgetsRef.current) {
78
+ void widgetsRef.current.setAmount({ currency: "KRW", value: amount });
79
+ }
80
+ }, [amount, status]);
81
+ return (_jsxs("div", { className: className, children: [status === "loading" && _jsx("p", { children: "\uACB0\uC81C \uC218\uB2E8 \uBD88\uB7EC\uC624\uB294 \uC911\u2026" }), status === "error" && (_jsx("p", { children: "\uACB0\uC81C \uC124\uC815\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. (\uAD00\uB9AC\uC790\uC5D0\uC11C Toss \uD0A4 \uD655\uC778)" })), _jsx("div", { id: selector })] }));
82
+ }
@@ -0,0 +1,83 @@
1
+ import type { ReactNode } from "react";
2
+ import type { SelloClient } from "../client";
3
+ import type { AuthUser, CartItem, Order, PaymentConfig } from "../types";
4
+ export interface CartApi {
5
+ items: CartItem[];
6
+ count: number;
7
+ total: number;
8
+ add: (item: CartItem) => void;
9
+ setQty: (key: string, qty: number) => void;
10
+ remove: (key: string) => void;
11
+ clear: () => void;
12
+ }
13
+ export interface SelloContextValue {
14
+ client: SelloClient;
15
+ businessSlug: string | null;
16
+ apiBase: string;
17
+ token: string | null;
18
+ user: AuthUser | null;
19
+ authReady: boolean;
20
+ oauthError: string | null;
21
+ login: (email: string, password: string) => Promise<void>;
22
+ register: (email: string, password: string, name?: string) => Promise<void>;
23
+ logout: () => void;
24
+ cart: CartApi;
25
+ }
26
+ export interface SelloProviderProps {
27
+ children: ReactNode;
28
+ /** API 베이스. "" = 동일 출처. 예: "https://www.sello.sh" */
29
+ apiBase?: string;
30
+ /** 가게 slug 를 명시 (SSR/Next 권장). 없으면 서브도메인/쿼리로 자동 해석 */
31
+ businessSlug?: string | null;
32
+ /** 자동 해석 실패 시 폴백 slug */
33
+ fallbackSlug?: string | null;
34
+ tokenStorageKey?: string;
35
+ }
36
+ export declare function SelloProvider({ children, apiBase, businessSlug, fallbackSlug, tokenStorageKey, }: SelloProviderProps): import("react").JSX.Element;
37
+ export declare function useSello(): SelloContextValue;
38
+ export declare function useAuth(): {
39
+ user: AuthUser | null;
40
+ token: string | null;
41
+ authReady: boolean;
42
+ oauthError: string | null;
43
+ login: (email: string, password: string) => Promise<void>;
44
+ register: (email: string, password: string, name?: string) => Promise<void>;
45
+ logout: () => void;
46
+ isLoggedIn: boolean;
47
+ };
48
+ export declare function useCart(): CartApi;
49
+ export declare function useProducts(slug?: string): {
50
+ products: import("..").Product[];
51
+ business: {
52
+ id: number;
53
+ name: string;
54
+ slug: string;
55
+ } | null;
56
+ loading: boolean;
57
+ error: string | null;
58
+ };
59
+ export declare function useProduct(id: number | null, slug?: string): {
60
+ product: import("..").Product | null;
61
+ variants: import("..").Variant[];
62
+ loading: boolean;
63
+ error: string | null;
64
+ };
65
+ export declare function usePaymentConfig(slug?: string): {
66
+ config: PaymentConfig | null;
67
+ error: string | null;
68
+ };
69
+ export declare function useOrders(): {
70
+ orders: Order[];
71
+ loading: boolean;
72
+ error: string | null;
73
+ };
74
+ export declare function useOrder(id: number | null, opts?: {
75
+ guestToken?: string;
76
+ }): {
77
+ order: Order | null;
78
+ items: import("..").OrderItem[];
79
+ loading: boolean;
80
+ error: string | null;
81
+ };
82
+ export { TossPaymentWidget } from "./TossPaymentWidget";
83
+ export type { PayRequest, TossPaymentWidgetProps } from "./TossPaymentWidget";
@@ -0,0 +1,369 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // @repo/commerce/react — SelloProvider + 훅.
4
+ // 가게 식별(slug), 인증 토큰, 장바구니 상태/영속을 한곳에서 관리.
5
+ // Vite·Next(App Router)·AI생성 스토어 어디서나 동작 (SSR 안전).
6
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
7
+ import { createSelloClient } from "../client";
8
+ import { resolveBusinessSlug } from "../business";
9
+ import { addToCart, cartCount, cartTotal, removeFromCart, setCartQty, } from "../cart";
10
+ const Ctx = createContext(null);
11
+ export function SelloProvider({ children, apiBase = "", businessSlug, fallbackSlug, tokenStorageKey = "sello_token", }) {
12
+ // ── slug: prop 우선, 없으면 클라에서 해석 ──
13
+ const [slug, setSlug] = useState(businessSlug ?? null);
14
+ useEffect(() => {
15
+ if (businessSlug != null) {
16
+ setSlug(businessSlug);
17
+ return;
18
+ }
19
+ setSlug(resolveBusinessSlug({ fallback: fallbackSlug }));
20
+ }, [businessSlug, fallbackSlug]);
21
+ // ── token ──
22
+ const [token, setToken] = useState(null);
23
+ const tokenRef = useRef(null);
24
+ useEffect(() => {
25
+ tokenRef.current = token;
26
+ }, [token]);
27
+ const persistToken = useCallback((t) => {
28
+ setToken(t);
29
+ tokenRef.current = t;
30
+ try {
31
+ if (t)
32
+ localStorage.setItem(tokenStorageKey, t);
33
+ else
34
+ localStorage.removeItem(tokenStorageKey);
35
+ }
36
+ catch {
37
+ /* ignore */
38
+ }
39
+ }, [tokenStorageKey]);
40
+ // client (apiBase/slug 변할 때만 재생성; 토큰은 ref 로 최신값 주입)
41
+ const client = useMemo(() => createSelloClient({
42
+ apiBase,
43
+ businessSlug: slug,
44
+ getToken: () => tokenRef.current,
45
+ }), [apiBase, slug]);
46
+ // ── 초기 토큰 로드 + OAuth #token= 캡처 ──
47
+ const [user, setUser] = useState(null);
48
+ const [authReady, setAuthReady] = useState(false);
49
+ const [oauthError, setOauthError] = useState(null);
50
+ useEffect(() => {
51
+ let initial = null;
52
+ try {
53
+ initial = localStorage.getItem(tokenStorageKey);
54
+ }
55
+ catch {
56
+ /* ignore */
57
+ }
58
+ if (typeof window !== "undefined" &&
59
+ (window.location.hash.includes("token=") ||
60
+ window.location.hash.includes("error="))) {
61
+ const h = new URLSearchParams(window.location.hash.slice(1));
62
+ const t = h.get("token");
63
+ const err = h.get("error");
64
+ if (t) {
65
+ initial = t;
66
+ try {
67
+ localStorage.setItem(tokenStorageKey, t);
68
+ }
69
+ catch {
70
+ /* ignore */
71
+ }
72
+ }
73
+ if (err)
74
+ setOauthError(err);
75
+ history.replaceState(null, "", window.location.pathname + window.location.search);
76
+ }
77
+ if (initial) {
78
+ setToken(initial);
79
+ tokenRef.current = initial;
80
+ }
81
+ else {
82
+ setAuthReady(true);
83
+ }
84
+ }, [tokenStorageKey]);
85
+ // 토큰 있으면 유저 조회
86
+ useEffect(() => {
87
+ let cancelled = false;
88
+ if (!token) {
89
+ setUser(null);
90
+ return;
91
+ }
92
+ client
93
+ .me()
94
+ .then((r) => {
95
+ if (!cancelled)
96
+ setUser(r.user);
97
+ })
98
+ .catch(() => {
99
+ if (!cancelled) {
100
+ persistToken(null);
101
+ setUser(null);
102
+ }
103
+ })
104
+ .finally(() => {
105
+ if (!cancelled)
106
+ setAuthReady(true);
107
+ });
108
+ return () => {
109
+ cancelled = true;
110
+ };
111
+ }, [token, client, persistToken]);
112
+ // ── 장바구니 (slug 별 localStorage 영속, SSR 안전) ──
113
+ const [items, setItems] = useState([]);
114
+ const hydratedRef = useRef(false);
115
+ const cartKey = slug ? `sello_cart_${slug}` : null;
116
+ useEffect(() => {
117
+ if (!cartKey)
118
+ return;
119
+ try {
120
+ const raw = localStorage.getItem(cartKey);
121
+ setItems(raw ? JSON.parse(raw) : []);
122
+ }
123
+ catch {
124
+ setItems([]);
125
+ }
126
+ hydratedRef.current = true;
127
+ }, [cartKey]);
128
+ useEffect(() => {
129
+ if (!cartKey || !hydratedRef.current)
130
+ return;
131
+ try {
132
+ localStorage.setItem(cartKey, JSON.stringify(items));
133
+ }
134
+ catch {
135
+ /* ignore */
136
+ }
137
+ }, [items, cartKey]);
138
+ const cart = useMemo(() => ({
139
+ items,
140
+ count: cartCount(items),
141
+ total: cartTotal(items),
142
+ add: (item) => setItems((prev) => addToCart(prev, item)),
143
+ setQty: (key, qty) => setItems((prev) => setCartQty(prev, key, qty)),
144
+ remove: (key) => setItems((prev) => removeFromCart(prev, key)),
145
+ clear: () => setItems([]),
146
+ }), [items]);
147
+ const login = useCallback(async (email, password) => {
148
+ if (!slug)
149
+ throw new Error("가게가 식별되지 않았습니다.");
150
+ const r = await client.login({ slug, email, password });
151
+ persistToken(r.token);
152
+ setUser(r.user);
153
+ }, [client, slug, persistToken]);
154
+ const register = useCallback(async (email, password, name) => {
155
+ if (!slug)
156
+ throw new Error("가게가 식별되지 않았습니다.");
157
+ const r = await client.register({ slug, email, password, name });
158
+ persistToken(r.token);
159
+ setUser(r.user);
160
+ }, [client, slug, persistToken]);
161
+ const logout = useCallback(() => {
162
+ persistToken(null);
163
+ setUser(null);
164
+ }, [persistToken]);
165
+ const value = {
166
+ client,
167
+ businessSlug: slug,
168
+ apiBase,
169
+ token,
170
+ user,
171
+ authReady,
172
+ oauthError,
173
+ login,
174
+ register,
175
+ logout,
176
+ cart,
177
+ };
178
+ return _jsx(Ctx.Provider, { value: value, children: children });
179
+ }
180
+ export function useSello() {
181
+ const ctx = useContext(Ctx);
182
+ if (!ctx)
183
+ throw new Error("useSello 는 <SelloProvider> 안에서만 사용하세요.");
184
+ return ctx;
185
+ }
186
+ export function useAuth() {
187
+ const { user, token, authReady, oauthError, login, register, logout } = useSello();
188
+ return {
189
+ user,
190
+ token,
191
+ authReady,
192
+ oauthError,
193
+ login,
194
+ register,
195
+ logout,
196
+ isLoggedIn: !!user,
197
+ };
198
+ }
199
+ export function useCart() {
200
+ return useSello().cart;
201
+ }
202
+ export function useProducts(slug) {
203
+ const { client } = useSello();
204
+ const [data, setData] = useState(null);
205
+ const [loading, setLoading] = useState(true);
206
+ const [error, setError] = useState(null);
207
+ const effSlug = slug ?? client.businessSlug;
208
+ useEffect(() => {
209
+ let cancelled = false;
210
+ if (!effSlug)
211
+ return;
212
+ setLoading(true);
213
+ setError(null);
214
+ client
215
+ .listProducts(effSlug)
216
+ .then((r) => {
217
+ if (!cancelled)
218
+ setData(r);
219
+ })
220
+ .catch((e) => {
221
+ if (!cancelled)
222
+ setError(e instanceof Error ? e.message : "오류");
223
+ })
224
+ .finally(() => {
225
+ if (!cancelled)
226
+ setLoading(false);
227
+ });
228
+ return () => {
229
+ cancelled = true;
230
+ };
231
+ }, [client, effSlug]);
232
+ return {
233
+ products: data?.products ?? [],
234
+ business: data?.business ?? null,
235
+ loading,
236
+ error,
237
+ };
238
+ }
239
+ export function useProduct(id, slug) {
240
+ const { client } = useSello();
241
+ const [data, setData] = useState(null);
242
+ const [loading, setLoading] = useState(true);
243
+ const [error, setError] = useState(null);
244
+ const effSlug = slug ?? client.businessSlug;
245
+ useEffect(() => {
246
+ let cancelled = false;
247
+ if (id == null || !effSlug)
248
+ return;
249
+ setLoading(true);
250
+ setError(null);
251
+ client
252
+ .getProduct(id, effSlug)
253
+ .then((r) => {
254
+ if (!cancelled)
255
+ setData(r);
256
+ })
257
+ .catch((e) => {
258
+ if (!cancelled)
259
+ setError(e instanceof Error ? e.message : "오류");
260
+ })
261
+ .finally(() => {
262
+ if (!cancelled)
263
+ setLoading(false);
264
+ });
265
+ return () => {
266
+ cancelled = true;
267
+ };
268
+ }, [client, id, effSlug]);
269
+ return {
270
+ product: data?.product ?? null,
271
+ variants: data?.variants ?? [],
272
+ loading,
273
+ error,
274
+ };
275
+ }
276
+ export function usePaymentConfig(slug) {
277
+ const { client } = useSello();
278
+ const [config, setConfig] = useState(null);
279
+ const [error, setError] = useState(null);
280
+ const effSlug = slug ?? client.businessSlug;
281
+ useEffect(() => {
282
+ let cancelled = false;
283
+ if (!effSlug)
284
+ return;
285
+ client
286
+ .getPaymentConfig(effSlug)
287
+ .then((c) => {
288
+ if (!cancelled)
289
+ setConfig(c);
290
+ })
291
+ .catch((e) => {
292
+ if (!cancelled)
293
+ setError(e instanceof Error ? e.message : "오류");
294
+ });
295
+ return () => {
296
+ cancelled = true;
297
+ };
298
+ }, [client, effSlug]);
299
+ return { config, error };
300
+ }
301
+ export function useOrders() {
302
+ const { client, token } = useSello();
303
+ const [orders, setOrders] = useState([]);
304
+ const [loading, setLoading] = useState(true);
305
+ const [error, setError] = useState(null);
306
+ useEffect(() => {
307
+ let cancelled = false;
308
+ if (!token) {
309
+ setLoading(false);
310
+ return;
311
+ }
312
+ setLoading(true);
313
+ client
314
+ .listOrders()
315
+ .then((r) => {
316
+ if (!cancelled)
317
+ setOrders(r.orders);
318
+ })
319
+ .catch((e) => {
320
+ if (!cancelled)
321
+ setError(e instanceof Error ? e.message : "오류");
322
+ })
323
+ .finally(() => {
324
+ if (!cancelled)
325
+ setLoading(false);
326
+ });
327
+ return () => {
328
+ cancelled = true;
329
+ };
330
+ }, [client, token]);
331
+ return { orders, loading, error };
332
+ }
333
+ export function useOrder(id, opts) {
334
+ const { client } = useSello();
335
+ const [data, setData] = useState(null);
336
+ const [loading, setLoading] = useState(true);
337
+ const [error, setError] = useState(null);
338
+ const guestToken = opts?.guestToken;
339
+ useEffect(() => {
340
+ let cancelled = false;
341
+ if (id == null)
342
+ return;
343
+ setLoading(true);
344
+ client
345
+ .getOrder(id, guestToken ? { guestToken } : undefined)
346
+ .then((r) => {
347
+ if (!cancelled)
348
+ setData(r);
349
+ })
350
+ .catch((e) => {
351
+ if (!cancelled)
352
+ setError(e instanceof Error ? e.message : "오류");
353
+ })
354
+ .finally(() => {
355
+ if (!cancelled)
356
+ setLoading(false);
357
+ });
358
+ return () => {
359
+ cancelled = true;
360
+ };
361
+ }, [client, id, guestToken]);
362
+ return {
363
+ order: data?.order ?? null,
364
+ items: data?.items ?? [],
365
+ loading,
366
+ error,
367
+ };
368
+ }
369
+ export { TossPaymentWidget } from "./TossPaymentWidget";
@@ -0,0 +1,173 @@
1
+ export interface ProductOption {
2
+ name: string;
3
+ values: string[];
4
+ }
5
+ export interface Product {
6
+ id: number;
7
+ name: string;
8
+ description: string | null;
9
+ price: number;
10
+ currency: string;
11
+ image_url: string | null;
12
+ options?: ProductOption[];
13
+ stock?: number | null;
14
+ /** true 면 스토어가 상품 상세를 자체 코드로 렌더 (chaae 의 custom_detail) */
15
+ custom_detail?: boolean;
16
+ }
17
+ export interface Variant {
18
+ options: Record<string, string>;
19
+ stock: number;
20
+ price: number | null;
21
+ }
22
+ export interface ProductListResult {
23
+ business: {
24
+ id: number;
25
+ name: string;
26
+ slug: string;
27
+ };
28
+ products: Product[];
29
+ }
30
+ export interface ProductDetailResult {
31
+ product: Product;
32
+ variants: Variant[];
33
+ }
34
+ export interface CartItem {
35
+ productId: number;
36
+ name: string;
37
+ image_url: string | null;
38
+ options: Record<string, string>;
39
+ unitPrice: number;
40
+ currency: string;
41
+ quantity: number;
42
+ maxStock: number | null;
43
+ }
44
+ /** 서버로 보내는 주문 아이템 — 가격·재고는 서버가 재검증한다(클라 신뢰 X). */
45
+ export interface CartItemInput {
46
+ product_id: number;
47
+ options?: Record<string, string>;
48
+ quantity: number;
49
+ }
50
+ export type OrderStatus = "pending" | "paid" | "preparing" | "shipped" | "delivered" | "cancelled" | "refunded";
51
+ export interface OrderItem {
52
+ id?: number;
53
+ order_id?: number;
54
+ product_id?: number;
55
+ name: string;
56
+ variant_options: Record<string, string>;
57
+ unit_price: number;
58
+ quantity: number;
59
+ }
60
+ export interface Order {
61
+ id: number;
62
+ status: OrderStatus | string;
63
+ total: number;
64
+ customer_name?: string;
65
+ phone?: string;
66
+ address?: string;
67
+ email?: string | null;
68
+ created_at?: string;
69
+ carrier?: string | null;
70
+ carrier_name?: string | null;
71
+ tracking_number?: string | null;
72
+ tracking_url?: string | null;
73
+ }
74
+ export interface OrderWithItems {
75
+ order: Order;
76
+ items: OrderItem[];
77
+ }
78
+ export interface AuthUser {
79
+ id: number;
80
+ email: string;
81
+ name: string;
82
+ user_type: string;
83
+ business_id: number | null;
84
+ phone?: string | null;
85
+ postal_code?: string | null;
86
+ address?: string | null;
87
+ address_detail?: string | null;
88
+ }
89
+ export interface AuthResult {
90
+ token: string;
91
+ user: AuthUser;
92
+ }
93
+ export interface AuthProviders {
94
+ google: boolean;
95
+ kakao: boolean;
96
+ }
97
+ export interface ProfileInput {
98
+ name?: string;
99
+ phone?: string;
100
+ postal_code?: string;
101
+ address?: string;
102
+ address_detail?: string;
103
+ }
104
+ export interface PaymentConfig {
105
+ provider: string;
106
+ tossClientKey: string;
107
+ }
108
+ /** 토스 결제 승인 입력 — 서버가 buildOrder 로 가격·재고 재검증 후 total === amount 확인. */
109
+ export interface ConfirmTossInput {
110
+ paymentKey: string;
111
+ orderId: string;
112
+ amount: number;
113
+ items: CartItemInput[];
114
+ customer_name: string;
115
+ phone: string;
116
+ address: string;
117
+ email?: string;
118
+ businessSlug?: string;
119
+ }
120
+ export interface ConfirmTossResult {
121
+ order: {
122
+ id: number;
123
+ total: number;
124
+ status: string;
125
+ };
126
+ guest_token: string | null;
127
+ }
128
+ export interface Review {
129
+ id: number;
130
+ order_id: number;
131
+ product_id: number;
132
+ user_id: number;
133
+ rating: number;
134
+ title: string | null;
135
+ content: string | null;
136
+ images: string[] | null;
137
+ status: "pending" | "approved" | "rejected" | "hidden";
138
+ is_verified_purchase: boolean;
139
+ admin_reply: string | null;
140
+ admin_reply_date: string | null;
141
+ created_at: string;
142
+ updated_at: string;
143
+ user_name: string | null;
144
+ user_email: string | null;
145
+ product_name: string | null;
146
+ }
147
+ export interface ReviewListResult {
148
+ reviews: Review[];
149
+ page: number;
150
+ limit: number;
151
+ total: number;
152
+ hasMore: boolean;
153
+ }
154
+ export interface ReviewListParams {
155
+ productId: number;
156
+ businessSlug: string;
157
+ rating?: number;
158
+ status?: Review["status"];
159
+ page?: number;
160
+ limit?: number;
161
+ }
162
+ export interface MetaConfig {
163
+ enabled: boolean;
164
+ pixelId: string | null;
165
+ }
166
+ export interface CheckoutValues {
167
+ customer_name: string;
168
+ phone: string;
169
+ address: string;
170
+ email?: string;
171
+ postal_code?: string;
172
+ address_detail?: string;
173
+ }
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // Sello 커머스 공유 타입 — 모든 스토어프론트가 이 타입 위에서 동작한다.
2
+ // (두 스토어가 각자 types.ts 를 중복 정의하던 것을 여기로 통합)
3
+ export {};
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@sellosh/commerce",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "Sello 커머스 SDK — 모든 스토어프론트(손코딩/AI생성)가 공유하는 타입드 클라이언트 + React 훅. 결제·재고·인증의 안전 경계를 한 곳에 둔다.",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./react": {
18
+ "types": "./dist/react/index.d.ts",
19
+ "import": "./dist/react/index.js",
20
+ "default": "./dist/react/index.js"
21
+ }
22
+ },
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.build.json",
28
+ "check-types": "tsc --noEmit",
29
+ "prepublishOnly": "pnpm build"
30
+ },
31
+ "peerDependencies": {
32
+ "react": "^18 || ^19"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "react": {
36
+ "optional": true
37
+ }
38
+ },
39
+ "devDependencies": {
40
+ "@types/react": "^19.2.7",
41
+ "typescript": "^5"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }