@sayren/mcp 0.4.0 → 0.5.1
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 +184 -27
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/src/lib/config.server.ts +1 -1
- package/template/src/routes/__root.tsx +2 -2
- package/template/src/routes/account.tsx +14 -14
- package/template/src/routes/cart.tsx +3 -3
- package/template/src/routes/checkout.complete.tsx +2 -2
- package/template/src/routes/checkout.index.tsx +24 -20
- package/template/src/routes/checkout.return.tsx +7 -7
- package/template/src/routes/index.tsx +1 -1
- package/template/src/routes/login.tsx +9 -9
- package/template/src/routes/orders.$orderId.tsx +9 -9
- package/template/src/routes/orders.index.tsx +1 -1
- package/template/src/routes/products.$productId.tsx +1 -1
- package/template/src/routes/products.index.tsx +2 -2
package/dist/index.mjs
CHANGED
|
@@ -19,13 +19,17 @@ const METHODS = [
|
|
|
19
19
|
"DELETE"
|
|
20
20
|
];
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
* 목록은
|
|
22
|
+
* 서버가 정책(`x-agent-policy`)을 싣지 않는 옛 api에만 쓰는 폴백 — 환불로 이어지는 쓰기를 막는다.
|
|
23
|
+
* 새 api는 문서의 정책이 원천이고 이 목록은 보지 않는다(승인 흐름으로 부를 수 있다).
|
|
24
24
|
*/
|
|
25
|
-
const
|
|
25
|
+
const LEGACY_BLOCKED_OPERATIONS = {
|
|
26
26
|
OrdersController_cancelBySeller: "셀러 직권취소는 PG 환불로 이어져 되돌릴 수 없다. 셀러 콘솔에서 처리한다",
|
|
27
27
|
ClaimsController_approve: "클레임 승인은 환불로 이어져 되돌릴 수 없다. 셀러 콘솔에서 처리한다"
|
|
28
28
|
};
|
|
29
|
+
/** 문서가 에이전트 정책을 싣는 api인가 — 하나라도 있으면 서버가 정책을 강제한다 */
|
|
30
|
+
function servesAgentPolicy(document) {
|
|
31
|
+
return Object.values(document.paths ?? {}).some((item) => Object.values(item).some((operation) => operation?.["x-agent-policy"] !== void 0));
|
|
32
|
+
}
|
|
29
33
|
/**
|
|
30
34
|
* 문서에서 에이전트가 쓸 수 있는 오퍼레이션만 고른다.
|
|
31
35
|
* - `/v1/oauth2/*`: 토큰 발급 표면이다. PAT로 부를 일이 없다
|
|
@@ -34,6 +38,7 @@ const BLOCKED_OPERATIONS = {
|
|
|
34
38
|
*/
|
|
35
39
|
function buildOperationIndex(document) {
|
|
36
40
|
const operations = [];
|
|
41
|
+
const serverPolicy = servesAgentPolicy(document);
|
|
37
42
|
for (const [path, item] of Object.entries(document.paths ?? {})) {
|
|
38
43
|
if (!path.startsWith("/v1/") || path.startsWith("/v1/oauth2/")) continue;
|
|
39
44
|
for (const method of METHODS) {
|
|
@@ -41,6 +46,7 @@ function buildOperationIndex(document) {
|
|
|
41
46
|
if (!raw?.operationId) continue;
|
|
42
47
|
const scopes = raw["x-required-scopes"] ?? [];
|
|
43
48
|
if (scopes.length === 0 || raw["x-console-only"]) continue;
|
|
49
|
+
const policy = raw["x-agent-policy"];
|
|
44
50
|
operations.push({
|
|
45
51
|
operationId: raw.operationId,
|
|
46
52
|
method,
|
|
@@ -49,7 +55,11 @@ function buildOperationIndex(document) {
|
|
|
49
55
|
summary: raw.summary ?? raw.operationId,
|
|
50
56
|
scopes,
|
|
51
57
|
deprecated: raw.deprecated === true,
|
|
52
|
-
blocked:
|
|
58
|
+
blocked: serverPolicy ? policy?.decision === "DENIED" ? policy.reason : void 0 : LEGACY_BLOCKED_OPERATIONS[raw.operationId],
|
|
59
|
+
approval: policy?.decision === "APPROVAL_REQUIRED" ? {
|
|
60
|
+
reason: policy.reason,
|
|
61
|
+
...policy.condition ? { condition: policy.condition } : {}
|
|
62
|
+
} : void 0,
|
|
53
63
|
raw
|
|
54
64
|
});
|
|
55
65
|
}
|
|
@@ -63,7 +73,8 @@ function formatOperationIndex(operations) {
|
|
|
63
73
|
const flags = [
|
|
64
74
|
op.scopes.join(","),
|
|
65
75
|
op.deprecated ? "deprecated" : "",
|
|
66
|
-
op.blocked ? "호출 불가" : ""
|
|
76
|
+
op.blocked ? "호출 불가" : "",
|
|
77
|
+
op.approval ? op.approval.condition ? "조건부 승인 필요" : "승인 필요" : ""
|
|
67
78
|
].filter(Boolean);
|
|
68
79
|
const lines = byTag[op.tag] ?? [];
|
|
69
80
|
lines.push(`${op.operationId} · ${op.method} ${op.path} · ${op.summary} [${flags.join(" · ")}]`);
|
|
@@ -102,6 +113,10 @@ function describeOperation(op, document) {
|
|
|
102
113
|
requiredScopes: op.scopes,
|
|
103
114
|
deprecated: op.deprecated || void 0,
|
|
104
115
|
blocked: op.blocked,
|
|
116
|
+
approval: op.approval ? {
|
|
117
|
+
...op.approval,
|
|
118
|
+
note: "부르면 서버가 실행하지 않고 승인 대기(202)로 둔다. 셀러가 콘솔에서 승인하면 서버가 그대로 실행한다. `dryRun: true`로 먼저 영향을 확인한다"
|
|
119
|
+
} : void 0,
|
|
105
120
|
tool: op.blocked ? void 0 : op.method === "GET" ? "call_api_read" : "call_api_write",
|
|
106
121
|
pathParams: parameters.filter((p) => p.in === "path").map((p) => p.name),
|
|
107
122
|
query: parameters.filter((p) => p.in === "query").map((p) => ({
|
|
@@ -127,14 +142,14 @@ function buildPath(template, params) {
|
|
|
127
142
|
const used = /* @__PURE__ */ new Set();
|
|
128
143
|
const path = template.replace(/\{([^}]+)\}/g, (_match, name) => {
|
|
129
144
|
const value = params[name];
|
|
130
|
-
if (value === void 0 || value === "") throw new Error(`경로 값이
|
|
145
|
+
if (value === void 0 || value === "") throw new Error(`경로 값이 없습니다: ${name}`);
|
|
131
146
|
const text$1 = String(value);
|
|
132
|
-
if (text$1 === "." || text$1 === ".." || !SAFE_SEGMENT.test(text$1)) throw new Error(`쓸 수 없는 경로
|
|
147
|
+
if (text$1 === "." || text$1 === ".." || !SAFE_SEGMENT.test(text$1)) throw new Error(`쓸 수 없는 경로 값입니다: ${name}`);
|
|
133
148
|
used.add(name);
|
|
134
149
|
return encodeURIComponent(text$1);
|
|
135
150
|
});
|
|
136
151
|
const extra = Object.keys(params).filter((name) => !used.has(name));
|
|
137
|
-
if (extra.length) throw new Error(`경로에 없는
|
|
152
|
+
if (extra.length) throw new Error(`경로에 없는 값입니다: ${extra.join(", ")}`);
|
|
138
153
|
return path;
|
|
139
154
|
}
|
|
140
155
|
/**
|
|
@@ -148,7 +163,7 @@ function truncateData(data, maxChars) {
|
|
|
148
163
|
while (kept > 0 && JSON.stringify(rebuild(items.slice(0, kept))).length > maxChars) kept = Math.floor(kept / 2);
|
|
149
164
|
return {
|
|
150
165
|
data: rebuild(items.slice(0, kept)),
|
|
151
|
-
truncated: `목록 ${items.length}개 중 ${kept}개만
|
|
166
|
+
truncated: `목록 ${items.length}개 중 ${kept}개만 실었습니다. size·page로 나눠 조회하십시오`
|
|
152
167
|
};
|
|
153
168
|
};
|
|
154
169
|
if (Array.isArray(data)) return shrink(data, (kept) => kept);
|
|
@@ -160,7 +175,110 @@ function truncateData(data, maxChars) {
|
|
|
160
175
|
const text$1 = JSON.stringify(data);
|
|
161
176
|
return {
|
|
162
177
|
data: `${text$1.slice(0, maxChars)}…`,
|
|
163
|
-
truncated: `응답 ${text$1.length}자 중 ${maxChars}자만
|
|
178
|
+
truncated: `응답 ${text$1.length}자 중 ${maxChars}자만 실었습니다`
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** 같은 멱등키의 승인이 끝났을 때 상태별 안내 — 에이전트가 다음에 할 일 */
|
|
182
|
+
const DECIDED_GUIDE = {
|
|
183
|
+
SUCCEEDED: {
|
|
184
|
+
message: "이 멱등키의 요청은 셀러가 승인해서 이미 실행됐습니다. 다시 실행하지 않았습니다",
|
|
185
|
+
nextSteps: ["실행 결과는 `result`다. 같은 요청을 다시 보내지 않는다"]
|
|
186
|
+
},
|
|
187
|
+
FAILED: {
|
|
188
|
+
message: "이 멱등키의 요청은 승인 뒤 실행했지만 실패했습니다",
|
|
189
|
+
nextSteps: ["`result.error`로 원인을 사용자에게 알린다", "고쳐서 다시 하려면 새 멱등키로 요청한다(같은 키는 이 결과만 돌려준다)"]
|
|
190
|
+
},
|
|
191
|
+
UNKNOWN: {
|
|
192
|
+
message: "이 멱등키의 요청은 승인 뒤 실행 결과를 확인하지 못했습니다. 이미 실행됐을 수 있습니다",
|
|
193
|
+
nextSteps: ["다시 보내지 않는다. 대상 리소스를 조회해 바뀌었는지 확인하고 사용자에게 알린다", "셀러에게 콘솔의 감사 로그에서 실행 여부를 확인해 달라고 전한다"]
|
|
194
|
+
},
|
|
195
|
+
REJECTED: {
|
|
196
|
+
message: "이 멱등키의 요청은 셀러가 거절했습니다. 아무것도 바뀌지 않았습니다",
|
|
197
|
+
nextSteps: ["거절 사유(`decisionNote`)를 전한다. 사용자가 원하면 새 멱등키로 다시 요청한다"]
|
|
198
|
+
},
|
|
199
|
+
EXPIRED: {
|
|
200
|
+
message: "이 멱등키의 요청은 승인 기한(24시간)이 지났습니다. 아무것도 바뀌지 않았습니다",
|
|
201
|
+
nextSteps: ["사용자가 원하면 새 멱등키로 다시 요청한다"]
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* 쓰기 결과를 에이전트가 읽을 형태로 바꾼다. 승인 대기(202 `APPROVAL_REQUIRED`)는 성공이 아니다 — 아직 아무것도
|
|
206
|
+
* 바뀌지 않았다는 것과 셀러에게 전할 링크, 상태를 확인할 도구를 함께 준다. 같은 멱등키의 승인이 이미 끝났으면
|
|
207
|
+
* (200 `APPROVAL_ALREADY_DECIDED`) `data.status`로 결과와 다음 할 일을 알린다. 에이전트 정책 거부(403)는 다시 시도하지
|
|
208
|
+
* 않게 알린다. 다른 202(비동기 접수)는 승인 대기가 아니다 — `meta.code`로만 가른다.
|
|
209
|
+
*/
|
|
210
|
+
function describeWriteResult(result) {
|
|
211
|
+
if (result.ok && result.code === "APPROVAL_ALREADY_DECIDED") {
|
|
212
|
+
const approval = result.data ?? {};
|
|
213
|
+
const guide = DECIDED_GUIDE[approval.status ?? ""] ?? DECIDED_GUIDE.EXPIRED;
|
|
214
|
+
return {
|
|
215
|
+
isError: approval.status !== "SUCCEEDED",
|
|
216
|
+
body: {
|
|
217
|
+
status: `APPROVAL_${approval.status ?? "DECIDED"}`,
|
|
218
|
+
applied: approval.status === "SUCCEEDED" ? true : approval.status === "UNKNOWN" ? "unknown" : false,
|
|
219
|
+
message: guide?.message,
|
|
220
|
+
approvalId: approval.approvalId,
|
|
221
|
+
result: approval.result,
|
|
222
|
+
decisionNote: approval.decisionNote ?? void 0,
|
|
223
|
+
nextSteps: guide?.nextSteps
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (result.ok && result.code === "APPROVAL_REQUIRED") {
|
|
228
|
+
const pending = result.data ?? {};
|
|
229
|
+
if (pending.status === "EXECUTING") return {
|
|
230
|
+
isError: false,
|
|
231
|
+
body: {
|
|
232
|
+
status: "APPROVAL_EXECUTING",
|
|
233
|
+
applied: "unknown",
|
|
234
|
+
message: "셀러가 승인해서 서버가 이 요청을 실행하고 있습니다",
|
|
235
|
+
approvalId: pending.approvalId,
|
|
236
|
+
nextSteps: ["같은 요청을 다시 보내지 않는다. 결과는 `get_approval_request`로 확인한다"]
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
isError: false,
|
|
241
|
+
body: {
|
|
242
|
+
status: "APPROVAL_PENDING",
|
|
243
|
+
applied: false,
|
|
244
|
+
message: "승인 대기 중입니다. 아직 아무것도 바뀌지 않았습니다. 셀러가 콘솔에서 승인하면 서버가 이 요청을 그대로 실행합니다",
|
|
245
|
+
approvalId: pending.approvalId,
|
|
246
|
+
reason: pending.reason,
|
|
247
|
+
expiresAt: pending.expiresAt,
|
|
248
|
+
approvalUrl: pending.approvalUrl ?? void 0,
|
|
249
|
+
preview: pending.preview,
|
|
250
|
+
nextSteps: [pending.approvalUrl ? `사용자에게 승인 화면 링크를 전한다: ${pending.approvalUrl}` : "사용자에게 셀러 콘솔 › 설정 › 승인 요청에서 승인해 달라고 전한다", "같은 요청을 다시 보내지 않는다. 결과는 `get_approval_request`로 확인한다"]
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (result.ok && result.code === "DRY_RUN") return {
|
|
255
|
+
isError: false,
|
|
256
|
+
body: {
|
|
257
|
+
status: "DRY_RUN",
|
|
258
|
+
applied: false,
|
|
259
|
+
message: "실행하지 않았습니다. 서버의 판정과 영향 미리보기입니다",
|
|
260
|
+
...result.data
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
if (!result.ok) {
|
|
264
|
+
if (result.error?.code === "AGENT_OPERATION_DENIED") return {
|
|
265
|
+
isError: true,
|
|
266
|
+
body: {
|
|
267
|
+
...result,
|
|
268
|
+
nextSteps: ["에이전트는 이 작업을 할 수 없다. 다시 시도하지 말고 셀러에게 콘솔에서 직접 하도록 안내한다"]
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
return {
|
|
272
|
+
isError: true,
|
|
273
|
+
body: { ...result }
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
isError: false,
|
|
278
|
+
body: {
|
|
279
|
+
status: result.status,
|
|
280
|
+
data: result.data
|
|
281
|
+
}
|
|
164
282
|
};
|
|
165
283
|
}
|
|
166
284
|
|
|
@@ -196,7 +314,7 @@ var AdminApi = class {
|
|
|
196
314
|
redirect: "error",
|
|
197
315
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
198
316
|
});
|
|
199
|
-
if (!response.ok) throw new Error(`API 문서를 읽지
|
|
317
|
+
if (!response.ok) throw new Error(`API 문서를 읽지 못했습니다 (HTTP ${response.status})`);
|
|
200
318
|
const document = await response.json();
|
|
201
319
|
this.spec = {
|
|
202
320
|
document,
|
|
@@ -208,7 +326,7 @@ var AdminApi = class {
|
|
|
208
326
|
async find(operationId) {
|
|
209
327
|
const { document, operations } = await this.load();
|
|
210
328
|
const op = operations.find((candidate) => candidate.operationId === operationId);
|
|
211
|
-
if (!op) throw new Error(`쓸 수 있는 오퍼레이션이
|
|
329
|
+
if (!op) throw new Error(`쓸 수 있는 오퍼레이션이 아닙니다: ${operationId}. list_operations의 operationId를 쓰십시오`);
|
|
212
330
|
return {
|
|
213
331
|
document,
|
|
214
332
|
op
|
|
@@ -223,6 +341,7 @@ var AdminApi = class {
|
|
|
223
341
|
};
|
|
224
342
|
if (input.body !== void 0) headers["content-type"] = "application/json";
|
|
225
343
|
if (input.idempotencyKey) headers["idempotency-key"] = input.idempotencyKey;
|
|
344
|
+
if (input.dryRun) headers["x-sayren-dry-run"] = "1";
|
|
226
345
|
const response = await this.fetchImpl(url, {
|
|
227
346
|
method: input.method,
|
|
228
347
|
headers,
|
|
@@ -238,6 +357,7 @@ var AdminApi = class {
|
|
|
238
357
|
return response.ok ? {
|
|
239
358
|
ok: true,
|
|
240
359
|
status: response.status,
|
|
360
|
+
...payload?.meta?.code ? { code: payload.meta.code } : {},
|
|
241
361
|
data: payload?.data ?? payload
|
|
242
362
|
} : {
|
|
243
363
|
ok: false,
|
|
@@ -252,7 +372,7 @@ var AdminApi = class {
|
|
|
252
372
|
const DEFAULT_API_ORIGIN = "https://api.sayren.app";
|
|
253
373
|
function loadConfig(env = process.env) {
|
|
254
374
|
const token = env.SAYREN_TOKEN?.trim();
|
|
255
|
-
if (!token) throw new Error("SAYREN_TOKEN이
|
|
375
|
+
if (!token) throw new Error("SAYREN_TOKEN이 없습니다. 셀러 콘솔 개발자 공간 › API 토큰에서 발급한 토큰을 MCP 설정의 env에 넣어주십시오");
|
|
256
376
|
const origin = (env.SAYREN_API_ORIGIN ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
|
|
257
377
|
return {
|
|
258
378
|
apiOrigin: origin,
|
|
@@ -270,7 +390,7 @@ async function callApi(url, init = {}) {
|
|
|
270
390
|
const body = await response.json().catch(() => null);
|
|
271
391
|
if (!response.ok) {
|
|
272
392
|
const code = body?.error?.code ?? String(response.status);
|
|
273
|
-
const message = body?.error?.message ?? "요청이
|
|
393
|
+
const message = body?.error?.message ?? "요청이 실패했습니다";
|
|
274
394
|
throw new Error(`${code}: ${message}`);
|
|
275
395
|
}
|
|
276
396
|
return body?.data ?? body;
|
|
@@ -292,7 +412,7 @@ async function fetchStoreContext(config$1) {
|
|
|
292
412
|
const payment = paymentSettings ? {
|
|
293
413
|
connectedProviders: connected.map((p) => p.provider),
|
|
294
414
|
sandboxOnly: connected.length === 0 || connected.every((p) => p.sandboxMode)
|
|
295
|
-
} : { unavailable: "결제 설정은 셀러 콘솔 OWNER만 볼 수 있어 이 토큰으로는 확인하지
|
|
415
|
+
} : { unavailable: "결제 설정은 셀러 콘솔 OWNER만 볼 수 있어 이 토큰으로는 확인하지 못했습니다" };
|
|
296
416
|
return {
|
|
297
417
|
storeId: store.storeId,
|
|
298
418
|
storeCode: store.storeCode,
|
|
@@ -399,12 +519,12 @@ function resolveSpec(name, spec, meta, options) {
|
|
|
399
519
|
if (options.link && name.startsWith("@sayren/")) return "workspace:*";
|
|
400
520
|
if (spec === "catalog:" || spec === "catalog:default") {
|
|
401
521
|
const version$1 = meta.catalog[name];
|
|
402
|
-
if (!version$1) throw new Error(`catalog에 ${name} 버전이
|
|
522
|
+
if (!version$1) throw new Error(`catalog에 ${name} 버전이 없습니다`);
|
|
403
523
|
return version$1;
|
|
404
524
|
}
|
|
405
525
|
if (spec.startsWith("workspace:")) {
|
|
406
526
|
const version$1 = meta.versions[name];
|
|
407
|
-
if (!version$1) throw new Error(`${name}의 배포 버전을
|
|
527
|
+
if (!version$1) throw new Error(`${name}의 배포 버전을 모르겠습니다`);
|
|
408
528
|
return `^${version$1}`;
|
|
409
529
|
}
|
|
410
530
|
return spec;
|
|
@@ -478,7 +598,7 @@ function resolveRoot() {
|
|
|
478
598
|
for (const candidate of CANDIDATES) try {
|
|
479
599
|
if (statSync(candidate).isDirectory()) return candidate;
|
|
480
600
|
} catch {}
|
|
481
|
-
throw new Error("스토어프론트 템플릿을 찾지
|
|
601
|
+
throw new Error("스토어프론트 템플릿을 찾지 못했습니다");
|
|
482
602
|
}
|
|
483
603
|
const TEMPLATE_ROOT = resolveRoot();
|
|
484
604
|
function walk(dir, acc) {
|
|
@@ -503,7 +623,7 @@ function listTemplateFiles() {
|
|
|
503
623
|
*/
|
|
504
624
|
function readTemplateFile(path, options = {}) {
|
|
505
625
|
const normalized = path.replace(/^\.?\//, "");
|
|
506
|
-
if (!listTemplateFiles().includes(normalized)) throw new Error(`템플릿에 없는
|
|
626
|
+
if (!listTemplateFiles().includes(normalized)) throw new Error(`템플릿에 없는 파일입니다: ${path}`);
|
|
507
627
|
let file = join(TEMPLATE_ROOT, normalized);
|
|
508
628
|
if (normalized.endsWith(".gitignore") && !existsSync(file)) file = file.replace(/\.gitignore$/, BUNDLED_GITIGNORE);
|
|
509
629
|
const raw = readFileSync(file, "utf8");
|
|
@@ -802,7 +922,7 @@ function buildReport(files) {
|
|
|
802
922
|
},
|
|
803
923
|
results,
|
|
804
924
|
findings: reported,
|
|
805
|
-
message: findings.length ? `규칙 ${RULES.length}개 중 ${violated}개를
|
|
925
|
+
message: findings.length ? `규칙 ${RULES.length}개 중 ${violated}개를 어겼습니다(위반 ${findings.length}건). 아래 findings의 fix대로 고친 뒤 다시 검사해주십시오` : `규칙 ${RULES.length}개를 모두 지켰습니다. 정적 검사가 잡지 못하는 것이 있으니 직접 띄워 결제까지 한 번 해보십시오`,
|
|
806
926
|
nextSteps: nextSteps(reported)
|
|
807
927
|
};
|
|
808
928
|
}
|
|
@@ -875,7 +995,7 @@ async function runCreate(argv) {
|
|
|
875
995
|
}
|
|
876
996
|
const target = resolve(args.dir);
|
|
877
997
|
if (existsSync(target) && readdirSync(target).length > 0 && !args.force) {
|
|
878
|
-
console.error(`폴더가 비어 있지
|
|
998
|
+
console.error(`폴더가 비어 있지 않습니다: ${target} (--force로 덮어쓸 수 있습니다)`);
|
|
879
999
|
return 1;
|
|
880
1000
|
}
|
|
881
1001
|
const name = args.name ?? basename(target);
|
|
@@ -895,7 +1015,7 @@ async function runCreate(argv) {
|
|
|
895
1015
|
const env = `SAYREN_API_URL=${store.storefrontApiBaseUrl}\nSAYREN_STORE_CODE=${store.storeCode}\n`;
|
|
896
1016
|
writeFileSync(join(target, ".env"), env);
|
|
897
1017
|
console.log(`.env 작성 — ${store.name} (${store.storeCode})`);
|
|
898
|
-
} else console.log("SAYREN_TOKEN이 없어 .env는 비워
|
|
1018
|
+
} else console.log("SAYREN_TOKEN이 없어 .env는 비워 뒀습니다. SAYREN_API_URL과 SAYREN_STORE_CODE를 넣어주십시오");
|
|
899
1019
|
const startedAt = Date.now();
|
|
900
1020
|
const report$1 = buildReport(await collectSources(target));
|
|
901
1021
|
const durationMs = Date.now() - startedAt;
|
|
@@ -906,7 +1026,7 @@ async function runCreate(argv) {
|
|
|
906
1026
|
}
|
|
907
1027
|
console.log(`\n다음:\n cd ${args.dir}\n pnpm install\n pnpm dev`);
|
|
908
1028
|
console.log(`
|
|
909
|
-
디자인을 고친 뒤에는 MCP 도구 \`verify_storefront\`로 다시
|
|
1029
|
+
디자인을 고친 뒤에는 MCP 도구 \`verify_storefront\`로 다시 검사해주십시오. 결제·구매자 인증·방문 분석 규칙 ${report$1.summary.rules}개를 보고, 어긴 항목마다 고칠 자리와 수정 방법·예시 코드를 알려 줍니다.\n결제까지 해 보려면 셀러 콘솔 설정 › 결제에서 결제 도메인에 이 쇼핑몰 주소를 등록해주십시오(테스트 결제는 localhost가 항상 허용됩니다).`);
|
|
910
1030
|
if (config$1) await new Telemetry(config$1).reportVerify("create", report$1, durationMs);
|
|
911
1031
|
return 0;
|
|
912
1032
|
}
|
|
@@ -1067,8 +1187,21 @@ async function invoke(expected, input) {
|
|
|
1067
1187
|
path: buildPath(op.path, input.pathParams ?? {}),
|
|
1068
1188
|
query: input.query,
|
|
1069
1189
|
body: input.body,
|
|
1070
|
-
idempotencyKey: input.idempotencyKey
|
|
1190
|
+
idempotencyKey: input.idempotencyKey,
|
|
1191
|
+
dryRun: expected === "write" && input.dryRun === true
|
|
1071
1192
|
});
|
|
1193
|
+
if (expected === "write") {
|
|
1194
|
+
const described = describeWriteResult(result);
|
|
1195
|
+
if (described.isError) return {
|
|
1196
|
+
...text(described.body),
|
|
1197
|
+
isError: true
|
|
1198
|
+
};
|
|
1199
|
+
const { data: data$1, truncated: truncated$1 } = truncateData(described.body, MAX_RESPONSE_CHARS);
|
|
1200
|
+
return text(truncated$1 ? {
|
|
1201
|
+
...data$1,
|
|
1202
|
+
truncated: truncated$1
|
|
1203
|
+
} : data$1);
|
|
1204
|
+
}
|
|
1072
1205
|
if (!result.ok) return {
|
|
1073
1206
|
...text(result),
|
|
1074
1207
|
isError: true
|
|
@@ -1095,13 +1228,14 @@ server.registerTool("call_api_read", {
|
|
|
1095
1228
|
}, async (input) => invoke("read", input));
|
|
1096
1229
|
server.registerTool("call_api_write", {
|
|
1097
1230
|
title: "관리 API 변경",
|
|
1098
|
-
description: `POST·PUT·PATCH·DELETE 오퍼레이션을 부른다. 스토어 데이터가 실제로 바뀐다. 사용자가 요청한 변경만 한다. \`describe_operation\`에서 \`idempotencyKey: true\`인 오퍼레이션은 키를 새로 만들어 넣고, 재시도할 때는 같은 키를 쓴다.
|
|
1231
|
+
description: `POST·PUT·PATCH·DELETE 오퍼레이션을 부른다. 스토어 데이터가 실제로 바뀐다. 사용자가 요청한 변경만 한다. \`describe_operation\`에서 \`idempotencyKey: true\`인 오퍼레이션은 키를 새로 만들어 넣고, 재시도할 때는 같은 키를 쓴다. 바꾸기 전에 \`dryRun: true\`로 먼저 불러 대상·변경 전후·금액 영향과 승인 필요 여부를 사용자에게 보여 준다(서버가 실행하지 않는다). 환불·가격·할인처럼 승인이 필요한 쓰기는 서버가 승인 대기(\`APPROVAL_PENDING\`)로 두고, 셀러가 콘솔에서 승인하면 서버가 실행한다 — 결과 링크를 사용자에게 전하고 \`get_approval_request\`로 상태를 확인한다. 결제 설정·토큰 발급처럼 에이전트에게 거부된 작업은 다시 시도하지 않는다. ${UNTRUSTED_NOTE}`,
|
|
1099
1232
|
inputSchema: {
|
|
1100
1233
|
operationId: z.string().describe("`list_operations`가 준 operationId (GET 제외)"),
|
|
1101
1234
|
pathParams: pathParamsInput,
|
|
1102
1235
|
query: queryInput,
|
|
1103
1236
|
body: z.unknown().optional().describe("요청 본문(JSON). `describe_operation`의 body 스키마를 따른다"),
|
|
1104
|
-
idempotencyKey: z.string().max(255).optional().describe("멱등키. 재시도에는 같은 값을 쓴다")
|
|
1237
|
+
idempotencyKey: z.string().max(255).optional().describe("멱등키. 재시도에는 같은 값을 쓴다"),
|
|
1238
|
+
dryRun: z.boolean().optional().describe("true면 실행하지 않고 서버의 판정(ALLOW·APPROVAL_REQUIRED·DENIED)과 영향 미리보기만 받는다")
|
|
1105
1239
|
},
|
|
1106
1240
|
annotations: {
|
|
1107
1241
|
readOnlyHint: false,
|
|
@@ -1110,6 +1244,29 @@ server.registerTool("call_api_write", {
|
|
|
1110
1244
|
openWorldHint: false
|
|
1111
1245
|
}
|
|
1112
1246
|
}, async (input) => invoke("write", input));
|
|
1247
|
+
server.registerTool("get_approval_request", {
|
|
1248
|
+
title: "승인 요청 상태",
|
|
1249
|
+
description: "`call_api_write`가 승인 대기(`APPROVAL_PENDING`)로 돌려준 요청의 상태를 본다. `PENDING`은 아직 대기, `EXECUTING`은 승인 뒤 실행 중, `SUCCEEDED`·`FAILED`는 서버가 실행한 결과(`result`), `UNKNOWN`은 실행 결과를 확인하지 못함(이미 실행됐을 수 있으니 다시 보내지 말고 대상을 조회해 확인한다), `REJECTED`는 거절, `EXPIRED`는 기한(24시간) 지남이다. 승인은 셀러가 콘솔에서 한다 — 이 도구로 승인할 수 없다.",
|
|
1250
|
+
inputSchema: { approvalId: z.string().describe("승인 대기 응답의 approvalId") },
|
|
1251
|
+
annotations: {
|
|
1252
|
+
readOnlyHint: true,
|
|
1253
|
+
openWorldHint: false
|
|
1254
|
+
}
|
|
1255
|
+
}, async ({ approvalId }) => {
|
|
1256
|
+
const result = await adminApi.call({
|
|
1257
|
+
method: "GET",
|
|
1258
|
+
path: buildPath("/v1/approval-requests/{approvalId}", { approvalId })
|
|
1259
|
+
});
|
|
1260
|
+
if (!result.ok) return {
|
|
1261
|
+
...text(result),
|
|
1262
|
+
isError: true
|
|
1263
|
+
};
|
|
1264
|
+
const { data, truncated } = truncateData(result.data, MAX_RESPONSE_CHARS);
|
|
1265
|
+
return text({
|
|
1266
|
+
data,
|
|
1267
|
+
...truncated ? { truncated } : {}
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1113
1270
|
server.registerTool("verify_storefront", {
|
|
1114
1271
|
title: "생성 결과 검증",
|
|
1115
1272
|
description: "만들어진 프로젝트를 규칙과 대조한다. 결제가 조용히 실패하는 실수(브라우저 번들의 process.env, 클릭 시점 팝업 선오픈 누락, 결제 복귀 화면의 결과 확인 누락, 결과 확인 중 상태 조회 누락, 확정 배송비 미반영, 브라우저에 노출된 구매자 토큰 등)를 잡는다. 위반마다 고칠 자리(`file`·`line`)와 원인(`cause`)·수정 방법(`fix`)·예시 코드(`example`)·문서(`docUrl`)를 함께 준다. 규칙별 통과·위반은 `results`에 있다. 화면을 만든 뒤와 디자인을 고친 뒤에 부른다.",
|
|
@@ -1119,7 +1276,7 @@ server.registerTool("verify_storefront", {
|
|
|
1119
1276
|
const files = await collectSources(projectDir);
|
|
1120
1277
|
if (!files.length) return text({
|
|
1121
1278
|
ok: false,
|
|
1122
|
-
message: `소스를 찾지
|
|
1279
|
+
message: `소스를 찾지 못했습니다: ${projectDir}`,
|
|
1123
1280
|
nextSteps: ["`projectDir`이 프로젝트 루트(package.json이 있는 폴더)의 절대 경로인지 확인한다.", "템플릿을 아직 받지 않았으면 `list_template_files`·`get_template_file`로 먼저 만든다."]
|
|
1124
1281
|
});
|
|
1125
1282
|
const report$1 = buildReport(files);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sayren-mcp": "./dist/index.mjs"
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
14
14
|
"zod": "^4.6.5",
|
|
15
|
-
"@sayren/store-sdk": "^0.
|
|
16
|
-
"@sayren/storefront-sdk": "^0.11.
|
|
15
|
+
"@sayren/store-sdk": "^0.14.1",
|
|
16
|
+
"@sayren/storefront-sdk": "^0.11.1"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
|
@@ -18,6 +18,6 @@ export function resolveStoreCode(): string {
|
|
|
18
18
|
const fromHost = storeCodeFromHost(getRequestHeader("host"));
|
|
19
19
|
if (fromHost) return fromHost;
|
|
20
20
|
throw new Error(
|
|
21
|
-
"스토어 코드를 알 수
|
|
21
|
+
"스토어 코드를 알 수 없습니다. SAYREN_STORE_CODE 환경변수를 설정하거나 서브도메인으로 서비스해주십시오",
|
|
22
22
|
);
|
|
23
23
|
}
|
|
@@ -67,14 +67,14 @@ function Root() {
|
|
|
67
67
|
function RootError({ error }: ErrorComponentProps) {
|
|
68
68
|
return (
|
|
69
69
|
<ErrorView
|
|
70
|
-
title="문제가
|
|
70
|
+
title="문제가 생겼습니다"
|
|
71
71
|
detail={error instanceof Error ? error.message : undefined}
|
|
72
72
|
/>
|
|
73
73
|
);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function NotFound() {
|
|
77
|
-
return <ErrorView title="찾을 수
|
|
77
|
+
return <ErrorView title="찾을 수 없습니다" />;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
function ErrorView({ title, detail }: { title: string; detail?: string }) {
|
|
@@ -53,7 +53,7 @@ const changeConsents = createServerFn({ method: "POST" })
|
|
|
53
53
|
.validator(consentInput)
|
|
54
54
|
.handler(async ({ data }) => {
|
|
55
55
|
const accessToken = readToken();
|
|
56
|
-
if (!accessToken) return { error: "다시 로그인해
|
|
56
|
+
if (!accessToken) return { error: "다시 로그인해 주십시오" };
|
|
57
57
|
try {
|
|
58
58
|
await apiFor({ accessToken }).member.updateConsents({
|
|
59
59
|
...(data.reconsent ? { terms: true as const, privacy: true as const } : {}),
|
|
@@ -73,7 +73,7 @@ const startLink = createServerFn({ method: "POST" })
|
|
|
73
73
|
.validator(providerInput)
|
|
74
74
|
.handler(async ({ data }) => {
|
|
75
75
|
const accessToken = readToken();
|
|
76
|
-
if (!accessToken) return { url: null, error: "다시 로그인해
|
|
76
|
+
if (!accessToken) return { url: null, error: "다시 로그인해 주십시오" };
|
|
77
77
|
const origin = new URL(getRequestUrl()).origin;
|
|
78
78
|
try {
|
|
79
79
|
const started = await authFor().linkIdp(accessToken, data.provider, {
|
|
@@ -95,7 +95,7 @@ const unlink = createServerFn({ method: "POST" })
|
|
|
95
95
|
.validator(providerInput)
|
|
96
96
|
.handler(async ({ data }) => {
|
|
97
97
|
const accessToken = readToken();
|
|
98
|
-
if (!accessToken) return { error: "다시 로그인해
|
|
98
|
+
if (!accessToken) return { error: "다시 로그인해 주십시오" };
|
|
99
99
|
try {
|
|
100
100
|
await authFor().unlinkIdp(accessToken, data.provider);
|
|
101
101
|
return { error: null };
|
|
@@ -105,18 +105,18 @@ const unlink = createServerFn({ method: "POST" })
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
const ERROR_MESSAGE: Record<string, string> = {
|
|
108
|
-
IDENTITY_IN_USE: "이 계정은 이미 다른 회원에 연결돼
|
|
108
|
+
IDENTITY_IN_USE: "이 계정은 이미 다른 회원에 연결돼 있습니다",
|
|
109
109
|
PROVIDER_ALREADY_LINKED:
|
|
110
|
-
"같은 서비스의 다른 계정이 이미 연결돼
|
|
111
|
-
LAST_LOGIN_METHOD: "마지막 로그인 수단은 해제할 수
|
|
112
|
-
access_denied: "연결을
|
|
113
|
-
state_mismatch: "연결 요청이
|
|
110
|
+
"같은 서비스의 다른 계정이 이미 연결돼 있습니다. 해제한 뒤 다시 연결해 주십시오",
|
|
111
|
+
LAST_LOGIN_METHOD: "마지막 로그인 수단은 해제할 수 없습니다. 다른 계정을 먼저 연결해 주십시오",
|
|
112
|
+
access_denied: "연결을 취소했습니다",
|
|
113
|
+
state_mismatch: "연결 요청이 만료됐습니다. 다시 시도해 주십시오",
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
function accountErrorMessage(error: unknown): string {
|
|
117
117
|
if (error instanceof ApiError)
|
|
118
|
-
return ERROR_MESSAGE[error.code] ?? "처리하지
|
|
119
|
-
return "처리하지
|
|
118
|
+
return ERROR_MESSAGE[error.code] ?? "처리하지 못했습니다. 잠시 후 다시 시도해 주십시오";
|
|
119
|
+
return "처리하지 못했습니다. 잠시 후 다시 시도해 주십시오";
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export const Route = createFileRoute("/account")({
|
|
@@ -135,7 +135,7 @@ function Account() {
|
|
|
135
135
|
const error =
|
|
136
136
|
actionError ??
|
|
137
137
|
(callbackError
|
|
138
|
-
? (ERROR_MESSAGE[callbackError] ?? "연결하지
|
|
138
|
+
? (ERROR_MESSAGE[callbackError] ?? "연결하지 못했습니다. 다시 시도해 주십시오")
|
|
139
139
|
: null);
|
|
140
140
|
|
|
141
141
|
const changeConsent = (data: { reconsent?: boolean; marketing?: boolean }) => {
|
|
@@ -187,7 +187,7 @@ function Account() {
|
|
|
187
187
|
<h2 className="font-bold text-lg">로그인 수단</h2>
|
|
188
188
|
{linked ? (
|
|
189
189
|
<p className="rounded-md bg-chip px-3 py-2 text-sm">
|
|
190
|
-
{PROVIDER_NAME[linked]} 계정을
|
|
190
|
+
{PROVIDER_NAME[linked]} 계정을 연결했습니다
|
|
191
191
|
</p>
|
|
192
192
|
) : null}
|
|
193
193
|
<form onSubmit={submit}>
|
|
@@ -240,7 +240,7 @@ function Account() {
|
|
|
240
240
|
</form>
|
|
241
241
|
{error ? <p className="text-point text-sm">{error}</p> : null}
|
|
242
242
|
<p className="text-muted text-xs">
|
|
243
|
-
연결한 계정으로도 같은 회원으로
|
|
243
|
+
연결한 계정으로도 같은 회원으로 로그인합니다. 마지막 로그인 수단은 해제할 수 없습니다.
|
|
244
244
|
</p>
|
|
245
245
|
</section>
|
|
246
246
|
|
|
@@ -252,7 +252,7 @@ function Account() {
|
|
|
252
252
|
<span className="font-medium">이용약관 · 개인정보 수집이용</span>
|
|
253
253
|
<span className="text-muted text-xs">
|
|
254
254
|
{consents.reconsentRequired
|
|
255
|
-
? "약관이
|
|
255
|
+
? "약관이 개정됐습니다. 다시 동의해 주십시오"
|
|
256
256
|
: `동의 완료 · v${consents.terms.agreedVersion ?? "-"}`}
|
|
257
257
|
</span>
|
|
258
258
|
{/* 스토어가 약관 주소를 정했으면 열어 볼 수 있게 한다 */}
|
|
@@ -73,7 +73,7 @@ function CartPage() {
|
|
|
73
73
|
if (!cart.items.length) {
|
|
74
74
|
return (
|
|
75
75
|
<div className="space-y-4 py-16 text-center">
|
|
76
|
-
<p className="text-muted">장바구니가
|
|
76
|
+
<p className="text-muted">장바구니가 비었습니다</p>
|
|
77
77
|
<Link to="/products" className="inline-block rounded-md bg-ink px-5 py-3 text-white">
|
|
78
78
|
상품 보러 가기
|
|
79
79
|
</Link>
|
|
@@ -107,7 +107,7 @@ function CartPage() {
|
|
|
107
107
|
) : null}
|
|
108
108
|
{!item.purchasable ? (
|
|
109
109
|
<span className="text-point text-xs">
|
|
110
|
-
{item.unpurchasableReason === "SOLD_OUT" ? "
|
|
110
|
+
{item.unpurchasableReason === "SOLD_OUT" ? "품절입니다" : "지금은 살 수 없습니다"}
|
|
111
111
|
</span>
|
|
112
112
|
) : null}
|
|
113
113
|
<div className="flex items-center gap-3 pt-1">
|
|
@@ -171,7 +171,7 @@ function CartPage() {
|
|
|
171
171
|
{loginRequired ? (
|
|
172
172
|
<div className="flex w-full flex-col gap-2 md:w-64">
|
|
173
173
|
<p className="text-center text-muted text-xs">
|
|
174
|
-
이 스토어는 회원만 주문할 수
|
|
174
|
+
이 스토어는 회원만 주문할 수 있습니다. 담은 상품은 로그인하면 그대로 이어집니다
|
|
175
175
|
</p>
|
|
176
176
|
<Link
|
|
177
177
|
to="/login"
|
|
@@ -34,11 +34,11 @@ function CheckoutComplete() {
|
|
|
34
34
|
return (
|
|
35
35
|
<div className="space-y-6 py-10 text-center">
|
|
36
36
|
<div className="space-y-2">
|
|
37
|
-
<h1 className="font-bold text-2xl">주문을
|
|
37
|
+
<h1 className="font-bold text-2xl">주문을 완료했습니다</h1>
|
|
38
38
|
{orderId ? (
|
|
39
39
|
<p className="text-muted text-sm">주문번호 {orderId}</p>
|
|
40
40
|
) : (
|
|
41
|
-
<p className="text-muted text-sm">주문 내역에서 주문을
|
|
41
|
+
<p className="text-muted text-sm">주문 내역에서 주문을 확인해주십시오</p>
|
|
42
42
|
)}
|
|
43
43
|
</div>
|
|
44
44
|
|
|
@@ -64,7 +64,10 @@ const quoteDelivery = createServerFn({ method: "POST" })
|
|
|
64
64
|
const quote = await api.checkout.quoteDelivery(data.checkoutId, { zipCode: data.zipCode });
|
|
65
65
|
return { quote, error: null };
|
|
66
66
|
} catch {
|
|
67
|
-
return {
|
|
67
|
+
return {
|
|
68
|
+
quote: null,
|
|
69
|
+
error: "배송비를 확인하지 못했습니다. 우편번호를 다시 확인해주십시오.",
|
|
70
|
+
};
|
|
68
71
|
}
|
|
69
72
|
});
|
|
70
73
|
|
|
@@ -125,20 +128,21 @@ const startPayment = createServerFn({ method: "POST" })
|
|
|
125
128
|
});
|
|
126
129
|
|
|
127
130
|
function startErrorMessage(error: unknown): string {
|
|
128
|
-
if (!(error instanceof ApiError))
|
|
131
|
+
if (!(error instanceof ApiError))
|
|
132
|
+
return "결제를 시작하지 못했습니다. 잠시 후 다시 시도해주십시오.";
|
|
129
133
|
switch (error.code) {
|
|
130
134
|
case "PAYMENT_OPTION_UNAVAILABLE":
|
|
131
|
-
return "지금 쓸 수 없는
|
|
135
|
+
return "지금 쓸 수 없는 결제수단입니다. 다른 결제수단을 골라주십시오.";
|
|
132
136
|
case "PAYMENT_PROVIDER_UNAVAILABLE":
|
|
133
|
-
return "결제창을 준비하지
|
|
137
|
+
return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해주십시오.";
|
|
134
138
|
case "PAYMENT_NOT_CONFIGURED":
|
|
135
|
-
return "결제 수단을 준비하고
|
|
139
|
+
return "결제 수단을 준비하고 있습니다. 잠시 후 다시 시도해주십시오.";
|
|
136
140
|
case "CASH_RECEIPT_NOT_AVAILABLE":
|
|
137
|
-
return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수
|
|
141
|
+
return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있습니다.";
|
|
138
142
|
case "RETURN_URL_NOT_ALLOWED":
|
|
139
|
-
return "이 쇼핑몰 주소에서는 결제할 수
|
|
143
|
+
return "이 쇼핑몰 주소에서는 결제할 수 없습니다. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해주십시오.";
|
|
140
144
|
default:
|
|
141
|
-
return "결제를 시작하지
|
|
145
|
+
return "결제를 시작하지 못했습니다. 입력한 내용을 확인하고 다시 시도해주십시오.";
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
|
|
@@ -210,10 +214,10 @@ function Checkout() {
|
|
|
210
214
|
}
|
|
211
215
|
setNotice(
|
|
212
216
|
result.status === "CANCELED"
|
|
213
|
-
? "결제를
|
|
217
|
+
? "결제를 취소했습니다. 다른 결제수단으로 다시 시도해주십시오."
|
|
214
218
|
: result.status === "FAILED"
|
|
215
219
|
? result.message
|
|
216
|
-
: "결제 결과를 확인하지
|
|
220
|
+
: "결제 결과를 확인하지 못했습니다. 주문 내역을 확인해주십시오.",
|
|
217
221
|
);
|
|
218
222
|
};
|
|
219
223
|
|
|
@@ -221,7 +225,7 @@ function Checkout() {
|
|
|
221
225
|
event.preventDefault();
|
|
222
226
|
const option = options.find((item) => paymentOptionKey(item) === selected);
|
|
223
227
|
if (!option) {
|
|
224
|
-
setNotice("결제수단을
|
|
228
|
+
setNotice("결제수단을 골라주십시오.");
|
|
225
229
|
return;
|
|
226
230
|
}
|
|
227
231
|
const form = new FormData(event.currentTarget);
|
|
@@ -233,7 +237,7 @@ function Checkout() {
|
|
|
233
237
|
identityNumber: field("cashReceiptNumber"),
|
|
234
238
|
});
|
|
235
239
|
if (!parsed.success) {
|
|
236
|
-
setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를
|
|
240
|
+
setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해주십시오.");
|
|
237
241
|
return;
|
|
238
242
|
}
|
|
239
243
|
cashReceipt = parsed.data;
|
|
@@ -275,7 +279,7 @@ function Checkout() {
|
|
|
275
279
|
})
|
|
276
280
|
.catch(() => {
|
|
277
281
|
paymentWindow.close();
|
|
278
|
-
setNotice("결제를 시작하지
|
|
282
|
+
setNotice("결제를 시작하지 못했습니다. 잠시 후 다시 시도해주십시오.");
|
|
279
283
|
})
|
|
280
284
|
.finally(() => setSubmitting(false));
|
|
281
285
|
};
|
|
@@ -293,7 +297,7 @@ function Checkout() {
|
|
|
293
297
|
placeholder="04524"
|
|
294
298
|
required
|
|
295
299
|
onBlur={handleZipBlur}
|
|
296
|
-
hint="입력하면 지역 추가 배송비까지 반영한 배송비를 보여
|
|
300
|
+
hint="입력하면 지역 추가 배송비까지 반영한 배송비를 보여 줍니다"
|
|
297
301
|
/>
|
|
298
302
|
<Field label="주소" name="address1" required />
|
|
299
303
|
<Field label="상세 주소" name="address2" />
|
|
@@ -310,7 +314,7 @@ function Checkout() {
|
|
|
310
314
|
name="orderPassword"
|
|
311
315
|
type="password"
|
|
312
316
|
required
|
|
313
|
-
hint="주문 내역을 찾을 때 쓸 6자 이상
|
|
317
|
+
hint="주문 내역을 찾을 때 쓸 6자 이상 비밀번호입니다"
|
|
314
318
|
/>
|
|
315
319
|
</section>
|
|
316
320
|
)}
|
|
@@ -318,7 +322,7 @@ function Checkout() {
|
|
|
318
322
|
<fieldset className="space-y-2">
|
|
319
323
|
<legend className="mb-3 font-bold text-lg">결제수단</legend>
|
|
320
324
|
{options.length === 0 ? (
|
|
321
|
-
<p className="text-muted text-sm">지금 결제할 수 있는 결제수단이
|
|
325
|
+
<p className="text-muted text-sm">지금 결제할 수 있는 결제수단이 없습니다.</p>
|
|
322
326
|
) : (
|
|
323
327
|
<div className="grid grid-cols-2 gap-2">
|
|
324
328
|
{options.map((option) => {
|
|
@@ -438,7 +442,7 @@ function Checkout() {
|
|
|
438
442
|
))}
|
|
439
443
|
{checkout.testPayment ? (
|
|
440
444
|
<p className="rounded-md bg-chip px-3 py-2 text-xs">
|
|
441
|
-
테스트
|
|
445
|
+
테스트 결제입니다. 실제로 돈이 오가지 않습니다.
|
|
442
446
|
</p>
|
|
443
447
|
) : null}
|
|
444
448
|
{notice ? <p className="rounded-md bg-chip px-3 py-2 text-sm">{notice}</p> : null}
|
|
@@ -451,10 +455,10 @@ function Checkout() {
|
|
|
451
455
|
function deliveryNotes(delivery: CheckoutDelivery): string[] {
|
|
452
456
|
const notes: string[] = [];
|
|
453
457
|
if (delivery.bundleCount >= 2) {
|
|
454
|
-
notes.push(`배송 묶음 ${delivery.bundleCount}건 — 묶음마다 배송비가
|
|
458
|
+
notes.push(`배송 묶음 ${delivery.bundleCount}건 — 묶음마다 배송비가 붙습니다.`);
|
|
455
459
|
}
|
|
456
|
-
if (delivery.freeByThreshold) notes.push("무료배송 기준 금액을 넘겨 기본 배송비가
|
|
457
|
-
if (delivery.zipCode === null) notes.push("배송지를 입력하면 배송비가
|
|
460
|
+
if (delivery.freeByThreshold) notes.push("무료배송 기준 금액을 넘겨 기본 배송비가 빠졌습니다.");
|
|
461
|
+
if (delivery.zipCode === null) notes.push("배송지를 입력하면 배송비가 확정됩니다.");
|
|
458
462
|
return notes;
|
|
459
463
|
}
|
|
460
464
|
|
|
@@ -107,17 +107,17 @@ function PaymentReturn() {
|
|
|
107
107
|
<div className="mx-auto max-w-md space-y-6 py-16 text-center">
|
|
108
108
|
{view.kind === "working" || view.kind === "processing" ? (
|
|
109
109
|
<>
|
|
110
|
-
<h1 className="font-bold text-2xl">결제를 확인하고
|
|
110
|
+
<h1 className="font-bold text-2xl">결제를 확인하고 있습니다</h1>
|
|
111
111
|
<p className="text-muted text-sm">
|
|
112
112
|
{view.kind === "processing"
|
|
113
|
-
? "결제사에서 결과를 확인하는
|
|
114
|
-
: "잠시만
|
|
113
|
+
? "결제사에서 결과를 확인하는 중입니다. 이 화면을 닫지 말고 잠시만 기다려주십시오."
|
|
114
|
+
: "잠시만 기다려주십시오."}
|
|
115
115
|
</p>
|
|
116
116
|
</>
|
|
117
117
|
) : view.kind === "failed" ? (
|
|
118
118
|
<>
|
|
119
119
|
<h1 className="font-bold text-2xl">
|
|
120
|
-
{view.canceled ? "결제를
|
|
120
|
+
{view.canceled ? "결제를 취소했습니다" : "결제하지 못했습니다"}
|
|
121
121
|
</h1>
|
|
122
122
|
{view.canceled ? null : <p className="text-muted text-sm">{view.message}</p>}
|
|
123
123
|
<div className="flex justify-center gap-2">
|
|
@@ -128,10 +128,10 @@ function PaymentReturn() {
|
|
|
128
128
|
</>
|
|
129
129
|
) : (
|
|
130
130
|
<>
|
|
131
|
-
<h1 className="font-bold text-2xl">결제 결과를 확인하지
|
|
131
|
+
<h1 className="font-bold text-2xl">결제 결과를 확인하지 못했습니다</h1>
|
|
132
132
|
<p className="text-muted text-sm">
|
|
133
|
-
결제를 마쳤다면 잠시 뒤 주문 내역에
|
|
134
|
-
|
|
133
|
+
결제를 마쳤다면 잠시 뒤 주문 내역에 나타납니다. 다시 결제하기 전에 주문 내역을 먼저
|
|
134
|
+
확인해주십시오.
|
|
135
135
|
</p>
|
|
136
136
|
<Link to="/orders" className="inline-block rounded-md border border-ink px-5 py-3">
|
|
137
137
|
주문 내역
|
|
@@ -48,7 +48,7 @@ function Home() {
|
|
|
48
48
|
) : null}
|
|
49
49
|
|
|
50
50
|
<ProductSection listId="home:recommend" title="추천 상품" products={recommended} />
|
|
51
|
-
<ProductSection listId="home:latest" title="새로
|
|
51
|
+
<ProductSection listId="home:latest" title="새로 들어온 상품" products={latest} />
|
|
52
52
|
</div>
|
|
53
53
|
);
|
|
54
54
|
}
|
|
@@ -74,18 +74,18 @@ const passwordLogin = createServerFn({ method: "POST" })
|
|
|
74
74
|
|
|
75
75
|
function loginErrorMessage(error: unknown): string {
|
|
76
76
|
if (error instanceof ApiError) {
|
|
77
|
-
if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지
|
|
78
|
-
if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인만
|
|
77
|
+
if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지 않습니다";
|
|
78
|
+
if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인만 받습니다";
|
|
79
79
|
if (error.code === "ACCOUNT_LOCKED")
|
|
80
|
-
return "로그인을 여러 번 실패해 잠시
|
|
81
|
-
if (error.status === 400) return "이메일과 비밀번호를 확인해
|
|
80
|
+
return "로그인을 여러 번 실패해 잠시 잠겼습니다. 10분 뒤에 다시 시도해 주십시오";
|
|
81
|
+
if (error.status === 400) return "이메일과 비밀번호를 확인해 주십시오";
|
|
82
82
|
}
|
|
83
|
-
return "로그인하지
|
|
83
|
+
return "로그인하지 못했습니다. 잠시 후 다시 시도해 주십시오";
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const CALLBACK_ERRORS: Record<string, string> = {
|
|
87
|
-
access_denied: "로그인을
|
|
88
|
-
state_mismatch: "로그인 요청이
|
|
87
|
+
access_denied: "로그인을 취소했습니다",
|
|
88
|
+
state_mismatch: "로그인 요청이 만료됐습니다. 다시 시도해 주십시오",
|
|
89
89
|
};
|
|
90
90
|
|
|
91
91
|
export const Route = createFileRoute("/login")({
|
|
@@ -105,7 +105,7 @@ function Login() {
|
|
|
105
105
|
const error =
|
|
106
106
|
actionError ??
|
|
107
107
|
(callbackError
|
|
108
|
-
? (CALLBACK_ERRORS[callbackError] ?? "로그인하지
|
|
108
|
+
? (CALLBACK_ERRORS[callbackError] ?? "로그인하지 못했습니다. 다시 시도해 주십시오")
|
|
109
109
|
: null);
|
|
110
110
|
|
|
111
111
|
const submitSocial = (event: React.FormEvent<HTMLFormElement>) => {
|
|
@@ -195,7 +195,7 @@ function Login() {
|
|
|
195
195
|
) : null}
|
|
196
196
|
{error ? <p className="text-point text-sm">{error}</p> : null}
|
|
197
197
|
{social.length ? (
|
|
198
|
-
<p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께
|
|
198
|
+
<p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 됩니다</p>
|
|
199
199
|
) : null}
|
|
200
200
|
</div>
|
|
201
201
|
);
|
|
@@ -62,18 +62,18 @@ const createClaim = createServerFn({ method: "POST" })
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
function claimErrorMessage(error: unknown): string {
|
|
65
|
-
if (!(error instanceof ApiError)) return "접수하지
|
|
65
|
+
if (!(error instanceof ApiError)) return "접수하지 못했습니다. 잠시 후 다시 시도해주십시오.";
|
|
66
66
|
switch (error.code) {
|
|
67
67
|
case "INVALID_QUANTITY":
|
|
68
|
-
return "남은 수량보다 많이
|
|
68
|
+
return "남은 수량보다 많이 신청했습니다. 수량을 확인해주십시오.";
|
|
69
69
|
case "CLAIM_IN_PROGRESS":
|
|
70
|
-
return "이미 진행 중인 신청이
|
|
70
|
+
return "이미 진행 중인 신청이 있습니다.";
|
|
71
71
|
case "INVALID_STATUS":
|
|
72
|
-
return "지금은 신청할 수 없는
|
|
72
|
+
return "지금은 신청할 수 없는 상태입니다. 주문 상태를 확인해주십시오.";
|
|
73
73
|
case "PERIOD_EXPIRED":
|
|
74
|
-
return "반품·교환 가능 기간이
|
|
74
|
+
return "반품·교환 가능 기간이 지났습니다.";
|
|
75
75
|
default:
|
|
76
|
-
return error.message || "접수하지
|
|
76
|
+
return error.message || "접수하지 못했습니다. 잠시 후 다시 시도해주십시오.";
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
@@ -147,7 +147,7 @@ function OrderDetail() {
|
|
|
147
147
|
|
|
148
148
|
{result ? (
|
|
149
149
|
<div className="space-y-1 rounded-md bg-chip p-4 text-sm">
|
|
150
|
-
<p className="font-semibold">신청이
|
|
150
|
+
<p className="font-semibold">신청이 접수됐습니다 (수량 {result.quantity}개)</p>
|
|
151
151
|
{/* 상품 금액 환불은 수량 비율이고, 반품 배송비는 이미 빠져 있다. 배송비 환불은 따로 온다 */}
|
|
152
152
|
{result.expectedRefundAmount === null ? null : (
|
|
153
153
|
<p className="text-muted">
|
|
@@ -271,9 +271,9 @@ function ClaimForm({
|
|
|
271
271
|
const quantity = Number(quantityText);
|
|
272
272
|
const quantityError =
|
|
273
273
|
quantityText.trim() === "" || !Number.isInteger(quantity)
|
|
274
|
-
? "수량을
|
|
274
|
+
? "수량을 입력해주십시오."
|
|
275
275
|
: quantity < 1 || quantity > item.activeQuantity
|
|
276
|
-
? `수량은 1개 이상 ${item.activeQuantity}개 이하로
|
|
276
|
+
? `수량은 1개 이상 ${item.activeQuantity}개 이하로 입력해주십시오.`
|
|
277
277
|
: null;
|
|
278
278
|
return (
|
|
279
279
|
<form
|
|
@@ -107,7 +107,7 @@ function ProductDetail() {
|
|
|
107
107
|
setError(null);
|
|
108
108
|
addToCart({ data: { productId: product.productId, optionId, quantity } })
|
|
109
109
|
.then(() => navigate({ to: "/cart" }))
|
|
110
|
-
.catch(() => setError("장바구니에 담지
|
|
110
|
+
.catch(() => setError("장바구니에 담지 못했습니다. 다시 시도해주십시오"))
|
|
111
111
|
.finally(() => setSubmitting(false));
|
|
112
112
|
}}
|
|
113
113
|
>
|
|
@@ -71,7 +71,7 @@ function Products() {
|
|
|
71
71
|
type="search"
|
|
72
72
|
name="keyword"
|
|
73
73
|
defaultValue={applied.keyword ?? ""}
|
|
74
|
-
placeholder="
|
|
74
|
+
placeholder="상품 검색"
|
|
75
75
|
className="h-11 flex-1 rounded-md border border-line px-3"
|
|
76
76
|
/>
|
|
77
77
|
{applied.categoryId ? (
|
|
@@ -104,7 +104,7 @@ function Products() {
|
|
|
104
104
|
</div>
|
|
105
105
|
|
|
106
106
|
{page.contents.length === 0 ? (
|
|
107
|
-
<p className="py-16 text-center text-muted">조건에 맞는 상품이
|
|
107
|
+
<p className="py-16 text-center text-muted">조건에 맞는 상품이 없습니다</p>
|
|
108
108
|
) : (
|
|
109
109
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
110
110
|
{page.contents.map((product) => (
|