@joy-loyalty/mcp 0.3.8 → 0.4.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/build/index.js +1032 -274
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -255,6 +255,9 @@ var TtlCache = class {
|
|
|
255
255
|
this.store.set(key, { expiresAt: now + ttlMs, promise });
|
|
256
256
|
return promise;
|
|
257
257
|
}
|
|
258
|
+
delete(key) {
|
|
259
|
+
this.store.delete(key);
|
|
260
|
+
}
|
|
258
261
|
};
|
|
259
262
|
|
|
260
263
|
// ../mcp-core/build/tools/members.js
|
|
@@ -273,55 +276,124 @@ var log = {
|
|
|
273
276
|
}
|
|
274
277
|
};
|
|
275
278
|
|
|
279
|
+
// ../mcp-core/build/store.js
|
|
280
|
+
function displayName(store) {
|
|
281
|
+
return store.name.replace(/[\u0000-\u001f\u007f[\]]+/g, " ").trim() || store.myshopifyDomain;
|
|
282
|
+
}
|
|
283
|
+
function storeLabel(store, maxNameLength = 60) {
|
|
284
|
+
const safeName = displayName(store);
|
|
285
|
+
const name = safeName.length > maxNameLength ? `${safeName.slice(0, Math.max(0, maxNameLength - 1))}\u2026` : safeName;
|
|
286
|
+
return `${name} (${store.myshopifyDomain})`;
|
|
287
|
+
}
|
|
288
|
+
function storeDescriptionPrefix(store) {
|
|
289
|
+
return `[Store: ${storeLabel(store)}] `;
|
|
290
|
+
}
|
|
291
|
+
function stampResult(text, store) {
|
|
292
|
+
return `[Store: ${storeLabel(store)}]
|
|
293
|
+
${text}`;
|
|
294
|
+
}
|
|
295
|
+
function normalizeStoreInput(raw) {
|
|
296
|
+
let normalized = raw.trim().toLowerCase();
|
|
297
|
+
normalized = normalized.replace(/^https?:\/\//, "");
|
|
298
|
+
normalized = normalized.replace(/^www\./, "");
|
|
299
|
+
const slashIdx = normalized.indexOf("/");
|
|
300
|
+
if (slashIdx !== -1) {
|
|
301
|
+
normalized = normalized.slice(0, slashIdx);
|
|
302
|
+
}
|
|
303
|
+
normalized = normalized.split(/[?#]/)[0];
|
|
304
|
+
normalized = normalized.replace(/[./]+$/, "");
|
|
305
|
+
return normalized;
|
|
306
|
+
}
|
|
307
|
+
function matchesStore(raw, store) {
|
|
308
|
+
if (!raw || !raw.trim()) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
const normalized = normalizeStoreInput(raw);
|
|
312
|
+
if (normalized === normalizeStoreInput(store.myshopifyDomain)) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
if (store.primaryDomain && normalized === normalizeStoreInput(store.primaryDomain)) {
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
if (raw.trim().toLowerCase() === store.name.trim().toLowerCase()) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
function storeMismatchMessage(raw, store) {
|
|
324
|
+
const target = raw.length > 100 ? `${raw.slice(0, 99)}\u2026` : raw;
|
|
325
|
+
return `Rejected \u2014 this connector is bound to ${storeLabel(store)} but the call targeted "${target}". Nothing was changed. Use the connector for that store. Only retry here if the user explicitly confirms they mean ${displayName(store)} \u2014 ask them, do not assume.`;
|
|
326
|
+
}
|
|
327
|
+
var STORE_ARG_DESCRIPTION = `The store the USER asked to change \u2014 its name or domain, taken from the user's own words or an earlier turn, never copied from this tool's description. If it is not clear which store the user means, ask them before calling. The server rejects the call when this does not match the store this connector is bound to. If the user has not named a store and this is the only Joy connector enabled, the store this connector is bound to is the one meant.`;
|
|
328
|
+
|
|
276
329
|
// ../mcp-core/build/toolkit.js
|
|
277
|
-
async function runHandler(name, run) {
|
|
330
|
+
async function runHandler(name, run, store) {
|
|
278
331
|
try {
|
|
279
332
|
const text = await run();
|
|
280
|
-
return { content: [{ type: "text", text }] };
|
|
333
|
+
return { content: [{ type: "text", text: store ? stampResult(text, store) : text }] };
|
|
281
334
|
} catch (error) {
|
|
282
335
|
if (error instanceof JoyApiError) {
|
|
283
|
-
|
|
336
|
+
const text2 = store ? stampResult(error.modelMessage, store) : error.modelMessage;
|
|
337
|
+
return { isError: true, content: [{ type: "text", text: text2 }] };
|
|
284
338
|
}
|
|
285
339
|
const message = error instanceof Error ? error.message : String(error);
|
|
286
340
|
log.error(`${name} failed unexpectedly: ${message}`);
|
|
341
|
+
const text = `Unexpected error in ${name}: ${message}. This is likely a bug in the Joy MCP server, not your input \u2014 report it to the merchant rather than retrying.`;
|
|
287
342
|
return {
|
|
288
343
|
isError: true,
|
|
289
|
-
content: [
|
|
290
|
-
{
|
|
291
|
-
type: "text",
|
|
292
|
-
text: `Unexpected error in ${name}: ${message}. This is likely a bug in the Joy MCP server, not your input \u2014 report it to the merchant rather than retrying.`
|
|
293
|
-
}
|
|
294
|
-
]
|
|
344
|
+
content: [{ type: "text", text: store ? stampResult(text, store) : text }]
|
|
295
345
|
};
|
|
296
346
|
}
|
|
297
347
|
}
|
|
298
|
-
function registerReadTool(server, spec) {
|
|
299
|
-
const callback = (async (args) => runHandler(spec.name, () => spec.handler(args ?? {})));
|
|
348
|
+
function registerReadTool(server, ctx, spec) {
|
|
349
|
+
const callback = (async (args) => runHandler(spec.name, () => spec.handler(args ?? {}), ctx.store));
|
|
300
350
|
server.registerTool(spec.name, {
|
|
301
351
|
title: spec.title,
|
|
302
|
-
description: spec.description,
|
|
352
|
+
description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
|
|
303
353
|
inputSchema: spec.inputSchema,
|
|
304
354
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
305
355
|
}, callback);
|
|
306
356
|
}
|
|
357
|
+
function withStoreArg(shape, store) {
|
|
358
|
+
if (!store)
|
|
359
|
+
return shape;
|
|
360
|
+
return { ...shape, store: z.string().describe(STORE_ARG_DESCRIPTION) };
|
|
361
|
+
}
|
|
362
|
+
function checkStoreMismatch(name, args, store) {
|
|
363
|
+
const raw = typeof args?.store === "string" ? args.store : "";
|
|
364
|
+
if (matchesStore(raw, store))
|
|
365
|
+
return void 0;
|
|
366
|
+
log.info(`${name} rejected: store mismatch (bound=${store.myshopifyDomain})`);
|
|
367
|
+
return {
|
|
368
|
+
isError: true,
|
|
369
|
+
content: [{ type: "text", text: stampResult(storeMismatchMessage(raw, store), store) }]
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function stripStoreArg(args) {
|
|
373
|
+
const { store: _store, ...rest } = args ?? {};
|
|
374
|
+
return rest;
|
|
375
|
+
}
|
|
307
376
|
function registerConfigWriteTool(server, ctx, spec) {
|
|
377
|
+
const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
|
|
308
378
|
const callback = (async (args) => {
|
|
379
|
+
if (strictStore) {
|
|
380
|
+
const mismatch = checkStoreMismatch(spec.name, args, strictStore);
|
|
381
|
+
if (mismatch)
|
|
382
|
+
return mismatch;
|
|
383
|
+
}
|
|
309
384
|
if (!ctx.config.configWritesEnabled) {
|
|
385
|
+
const text = `${spec.name} is disabled: this server cannot change shop configuration. The merchant must set BOTH JOY_MCP_ENABLE_WRITES="true" and JOY_MCP_ENABLE_CONFIG_WRITES="true" (quoted strings, with their secret key) in the MCP config. Do not retry until they have \u2014 and do not fall back to a member-level write tool to approximate a configuration change.`;
|
|
310
386
|
return {
|
|
311
387
|
isError: true,
|
|
312
|
-
content: [
|
|
313
|
-
{
|
|
314
|
-
type: "text",
|
|
315
|
-
text: `${spec.name} is disabled: this server cannot change shop configuration. The merchant must set BOTH JOY_MCP_ENABLE_WRITES="true" and JOY_MCP_ENABLE_CONFIG_WRITES="true" (quoted strings, with their secret key) in the MCP config. Do not retry until they have \u2014 and do not fall back to a member-level write tool to approximate a configuration change.`
|
|
316
|
-
}
|
|
317
|
-
]
|
|
388
|
+
content: [{ type: "text", text: ctx.store ? stampResult(text, ctx.store) : text }]
|
|
318
389
|
};
|
|
319
390
|
}
|
|
320
|
-
|
|
391
|
+
const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
|
|
392
|
+
return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
|
|
321
393
|
});
|
|
322
394
|
const registration = {
|
|
323
395
|
title: spec.title,
|
|
324
|
-
description: spec.description,
|
|
396
|
+
description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
|
|
325
397
|
annotations: {
|
|
326
398
|
readOnlyHint: false,
|
|
327
399
|
destructiveHint: spec.destructiveHint,
|
|
@@ -330,31 +402,34 @@ function registerConfigWriteTool(server, ctx, spec) {
|
|
|
330
402
|
}
|
|
331
403
|
};
|
|
332
404
|
if (spec.strictArguments) {
|
|
333
|
-
const inputSchema = z.object(spec.inputSchema).strict();
|
|
405
|
+
const inputSchema = z.object(withStoreArg(spec.inputSchema, strictStore)).strict();
|
|
334
406
|
server.registerTool(spec.name, { ...registration, inputSchema }, callback);
|
|
335
407
|
return;
|
|
336
408
|
}
|
|
337
|
-
server.registerTool(spec.name, { ...registration, inputSchema: spec.inputSchema }, callback);
|
|
409
|
+
server.registerTool(spec.name, { ...registration, inputSchema: withStoreArg(spec.inputSchema, strictStore) }, callback);
|
|
338
410
|
}
|
|
339
411
|
function registerWriteTool(server, ctx, spec) {
|
|
412
|
+
const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
|
|
340
413
|
const callback = (async (args) => {
|
|
414
|
+
if (strictStore) {
|
|
415
|
+
const mismatch = checkStoreMismatch(spec.name, args, strictStore);
|
|
416
|
+
if (mismatch)
|
|
417
|
+
return mismatch;
|
|
418
|
+
}
|
|
341
419
|
if (!ctx.config.writesEnabled) {
|
|
420
|
+
const text = `${spec.name} is disabled: this server is running read-only. The merchant must set JOY_MCP_ENABLE_WRITES=true (with their secret key) in the MCP config to allow write operations. Do not retry until they have.`;
|
|
342
421
|
return {
|
|
343
422
|
isError: true,
|
|
344
|
-
content: [
|
|
345
|
-
{
|
|
346
|
-
type: "text",
|
|
347
|
-
text: `${spec.name} is disabled: this server is running read-only. The merchant must set JOY_MCP_ENABLE_WRITES=true (with their secret key) in the MCP config to allow write operations. Do not retry until they have.`
|
|
348
|
-
}
|
|
349
|
-
]
|
|
423
|
+
content: [{ type: "text", text: ctx.store ? stampResult(text, ctx.store) : text }]
|
|
350
424
|
};
|
|
351
425
|
}
|
|
352
|
-
|
|
426
|
+
const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
|
|
427
|
+
return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
|
|
353
428
|
});
|
|
354
429
|
server.registerTool(spec.name, {
|
|
355
430
|
title: spec.title,
|
|
356
|
-
description: spec.description,
|
|
357
|
-
inputSchema: spec.inputSchema,
|
|
431
|
+
description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
|
|
432
|
+
inputSchema: withStoreArg(spec.inputSchema, strictStore),
|
|
358
433
|
annotations: {
|
|
359
434
|
readOnlyHint: false,
|
|
360
435
|
destructiveHint: spec.destructiveHint,
|
|
@@ -514,7 +589,7 @@ function reclassifyCursorError(error, after) {
|
|
|
514
589
|
throw error;
|
|
515
590
|
}
|
|
516
591
|
function registerMemberTools(server, ctx) {
|
|
517
|
-
registerReadTool(server, {
|
|
592
|
+
registerReadTool(server, ctx, {
|
|
518
593
|
name: "joy_find_member",
|
|
519
594
|
title: "Find a loyalty member",
|
|
520
595
|
description: 'Look up ONE loyalty member by email or Shopify customer ID and return their points balance, VIP tier, and lifetime stats. Use this first whenever the merchant refers to a specific customer. Email matching is exact (case-insensitive) \u2014 if it misses, confirm spelling before concluding the customer is not a member. Field notes: "point" is the CURRENT spendable balance; "totalEarnedPoints" is lifetime earned (not spendable); "pointsRemain" is points needed to reach the next tier. Shopify store credit (money) is NOT included here \u2014 use joy_get_member_store_credit for that.',
|
|
@@ -526,7 +601,7 @@ function registerMemberTools(server, ctx) {
|
|
|
526
601
|
return summaryWithJson(`Member ${memberDisplayName(member)}: ${formatPoints(member.point)} points${tier}.`, shaped);
|
|
527
602
|
}
|
|
528
603
|
});
|
|
529
|
-
registerReadTool(server, {
|
|
604
|
+
registerReadTool(server, ctx, {
|
|
530
605
|
name: "joy_get_member_store_credit",
|
|
531
606
|
title: "Get a member's store credit balance",
|
|
532
607
|
description: "Shopify STORE CREDIT balance for ONE member (by email or Shopify customer ID). Store credit is real MONEY on the customer's Shopify account (e.g. from referral rewards or refunds) \u2014 it is NOT loyalty points; for the points balance use joy_find_member. A customer holds at most one account per currency, so multiple accounts mean multiple currencies. No account = zero store credit.",
|
|
@@ -554,7 +629,7 @@ function registerMemberTools(server, ctx) {
|
|
|
554
629
|
});
|
|
555
630
|
}
|
|
556
631
|
});
|
|
557
|
-
registerReadTool(server, {
|
|
632
|
+
registerReadTool(server, ctx, {
|
|
558
633
|
name: "joy_list_members",
|
|
559
634
|
title: "List loyalty members",
|
|
560
635
|
description: 'List loyalty members a page at a time, with sorting and an optional VIP-tier filter. Default order is most RECENT ACTIVITY (updatedAt desc). For ranking questions use sort_by \u2014 "which customer has the most points?" / "top 10 members" is ONE call with sort_by "points" (the first page IS the leaderboard; never page through the whole list for this). "How many customers/members does the shop have?" is ONE call with include_total_count true and limit 1 \u2014 never sum tier counts or page the list for this. sort_by "created_at" answers newest/oldest signups; tier_id narrows any of these to one tier. To inspect ONE known customer, prefer joy_find_member (cheaper and more precise). Returns up to `limit` members plus `has_more` and `next_after`; pass `next_after` back as `after` for the next page. Do not page through the whole list to answer aggregate questions \u2014 that burns the shop\'s API rate limit.',
|
|
@@ -615,7 +690,7 @@ function registerMemberTools(server, ctx) {
|
|
|
615
690
|
});
|
|
616
691
|
}
|
|
617
692
|
});
|
|
618
|
-
registerReadTool(server, {
|
|
693
|
+
registerReadTool(server, ctx, {
|
|
619
694
|
name: "joy_get_member_activity",
|
|
620
695
|
title: "Get a member's point activity",
|
|
621
696
|
description: 'Point transactions (earned, spent, adjusted, refund deductions\u2026) for ONE member, most recent first. Use to answer "why is this customer\'s balance X?" or to audit changes. Each entry shows the point change, balance before/after (oldPoint \u2192 newPoint), the source system, and any notes. Returns `next_after` when more history exists \u2014 pass it back as `after` to keep auditing.',
|
|
@@ -652,7 +727,7 @@ function registerMemberTools(server, ctx) {
|
|
|
652
727
|
});
|
|
653
728
|
}
|
|
654
729
|
});
|
|
655
|
-
registerReadTool(server, {
|
|
730
|
+
registerReadTool(server, ctx, {
|
|
656
731
|
name: "joy_get_member_rewards",
|
|
657
732
|
title: "Get a member's reward/coupon history",
|
|
658
733
|
description: `Coupons and rewards ONE member has already redeemed \u2014 code, discount program, status (active/used/expired), expiry. This is the member's OWN history, NOT the shop's catalog of what CAN be redeemed (use joy_list_redemption_rules for that). Use to answer "what coupons does this customer have?" or "did they already use that discount code?".`,
|
|
@@ -708,23 +783,68 @@ var TIER_KEYS = [
|
|
|
708
783
|
"systemType"
|
|
709
784
|
];
|
|
710
785
|
var TIERS_TTL_MS = 15 * 60 * 1e3;
|
|
786
|
+
var TIER_PROGRAM_KEYS = [
|
|
787
|
+
"status",
|
|
788
|
+
"entryMethod",
|
|
789
|
+
"startDate",
|
|
790
|
+
"prefix",
|
|
791
|
+
"isUsePrefixDiscountCode",
|
|
792
|
+
"showTierPointSettings",
|
|
793
|
+
"allowGiveEntryRewardAgain",
|
|
794
|
+
"useDemotionTier",
|
|
795
|
+
"typeDemotion",
|
|
796
|
+
"useResetTier",
|
|
797
|
+
"resetFrequency"
|
|
798
|
+
];
|
|
799
|
+
var LEVEL_UP_RULE_LABELS = {
|
|
800
|
+
pointEarned: "points earned",
|
|
801
|
+
moneySpent: "amount spent",
|
|
802
|
+
numberOfOrders: "number of orders"
|
|
803
|
+
};
|
|
804
|
+
function calculationSummary(settings, tiers) {
|
|
805
|
+
const entryMethod = typeof settings.entryMethod === "string" && settings.entryMethod || tiers.find((tier) => typeof tier.entryMethod === "string")?.entryMethod;
|
|
806
|
+
const label = typeof entryMethod === "string" ? LEVEL_UP_RULE_LABELS[entryMethod] : void 0;
|
|
807
|
+
if (!label)
|
|
808
|
+
return "";
|
|
809
|
+
const start = typeof settings.startDate === "string" && settings.startDate ? `, since ${settings.startDate.slice(0, 10)}` : "";
|
|
810
|
+
return ` Level-up rule: ${label}${start}.`;
|
|
811
|
+
}
|
|
812
|
+
async function readTierProgramSettings(ctx) {
|
|
813
|
+
try {
|
|
814
|
+
const { data } = await ctx.api.getData("/tiers/settings");
|
|
815
|
+
return data && typeof data === "object" ? data : {};
|
|
816
|
+
} catch (error) {
|
|
817
|
+
if (error instanceof JoyApiError && error.kind === "not_found")
|
|
818
|
+
return {};
|
|
819
|
+
throw error;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
711
822
|
function getTiersCached(ctx) {
|
|
712
823
|
return ctx.cache.get("tiers", TIERS_TTL_MS, async () => {
|
|
713
|
-
const
|
|
714
|
-
|
|
824
|
+
const [tiersResult, settings] = await Promise.all([
|
|
825
|
+
ctx.api.getData("/tiers"),
|
|
826
|
+
readTierProgramSettings(ctx)
|
|
827
|
+
]);
|
|
828
|
+
return {
|
|
829
|
+
tiers: Array.isArray(tiersResult.data) ? tiersResult.data : [],
|
|
830
|
+
settings
|
|
831
|
+
};
|
|
715
832
|
});
|
|
716
833
|
}
|
|
717
834
|
function registerTierTools(server, ctx) {
|
|
718
|
-
registerReadTool(server, {
|
|
835
|
+
registerReadTool(server, ctx, {
|
|
719
836
|
name: "joy_list_tiers",
|
|
720
837
|
title: "List VIP tiers",
|
|
721
|
-
description: 'List the shop\'s VIP tiers
|
|
838
|
+
description: 'List the shop\'s VIP tiers plus the program "Tier calculation" settings (entryMethod, startDate, prefix). Thresholds (targetPoint) are in the unit of entryMethod \u2014 150 means 150 points, $150 spent, or 150 orders depending on that rule. Also includes earning multipliers and per-tier benefits (tierRewards). For "how many customers are in tier X?" set include_member_counts \u2014 it returns memberCount (live count of current members) per tier; the plain `members` field is a stale cached snapshot, NEVER quote it as the member count. Config-only calls are cached ~15 minutes \u2014 one call per conversation is enough. To change calculation / demotion / reset settings, use joy_configure_tier_settings.',
|
|
722
839
|
inputSchema: {
|
|
723
840
|
include_member_counts: z3.boolean().optional().describe("Set true ONLY when the merchant asks how many customers/members are in tiers \u2014 adds memberCount (live count) per tier. Leave unset for tier-config questions; counting costs extra database reads.")
|
|
724
841
|
},
|
|
725
842
|
handler: async (args) => {
|
|
726
843
|
if (args.include_member_counts === true) {
|
|
727
|
-
const { data } = await
|
|
844
|
+
const [{ data }, settings2] = await Promise.all([
|
|
845
|
+
ctx.api.getData("/tiers", { query: { hasCount: true } }),
|
|
846
|
+
readTierProgramSettings(ctx)
|
|
847
|
+
]);
|
|
728
848
|
const tiers2 = Array.isArray(data) ? data : [];
|
|
729
849
|
if (tiers2.length === 0) {
|
|
730
850
|
return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
|
|
@@ -732,13 +852,19 @@ function registerTierTools(server, ctx) {
|
|
|
732
852
|
const keys = [...TIER_KEYS.filter((key) => key !== "members"), "memberCount"];
|
|
733
853
|
const total = tiers2.reduce((sum, tier) => sum + (Number(tier.memberCount) || 0), 0);
|
|
734
854
|
const top = [...tiers2].sort((a, b) => (Number(b.memberCount) || 0) - (Number(a.memberCount) || 0))[0];
|
|
735
|
-
return summaryWithJson(`${tiers2.length} VIP tier(s), ${total} member(s) total \u2014 largest: "${String(top.name)}" (${Number(top.memberCount) || 0} members)
|
|
855
|
+
return summaryWithJson(`${tiers2.length} VIP tier(s), ${total} member(s) total \u2014 largest: "${String(top.name)}" (${Number(top.memberCount) || 0} members).${calculationSummary(settings2, tiers2)}`, {
|
|
856
|
+
program: compact(settings2, TIER_PROGRAM_KEYS),
|
|
857
|
+
tiers: tiers2.map((tier) => compact(tier, keys))
|
|
858
|
+
});
|
|
736
859
|
}
|
|
737
|
-
const tiers = await getTiersCached(ctx);
|
|
860
|
+
const { tiers, settings } = await getTiersCached(ctx);
|
|
738
861
|
if (tiers.length === 0) {
|
|
739
862
|
return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
|
|
740
863
|
}
|
|
741
|
-
return summaryWithJson(`${tiers.length} VIP tier(s) configured
|
|
864
|
+
return summaryWithJson(`${tiers.length} VIP tier(s) configured.${calculationSummary(settings, tiers)}`, {
|
|
865
|
+
program: compact(settings, TIER_PROGRAM_KEYS),
|
|
866
|
+
tiers: tiers.map((tier) => compact(tier, TIER_KEYS))
|
|
867
|
+
});
|
|
742
868
|
}
|
|
743
869
|
});
|
|
744
870
|
}
|
|
@@ -788,6 +914,9 @@ var EARNING_KEYS = [
|
|
|
788
914
|
"rateMoney",
|
|
789
915
|
"earnPoint",
|
|
790
916
|
"earnPointsTiers",
|
|
917
|
+
"isAppliedVipTier",
|
|
918
|
+
"appliedPlaceOrderTo",
|
|
919
|
+
"skipEarnPointGuest",
|
|
791
920
|
"conditions",
|
|
792
921
|
"typeMilestone",
|
|
793
922
|
"milestones",
|
|
@@ -795,7 +924,15 @@ var EARNING_KEYS = [
|
|
|
795
924
|
"endDate",
|
|
796
925
|
"priority",
|
|
797
926
|
"isDraft",
|
|
798
|
-
"expired"
|
|
927
|
+
"expired",
|
|
928
|
+
// The artwork on this program's card, wherever the loyalty page draws it.
|
|
929
|
+
// Read-only here, and included because their ABSENCE was unreadable: the page
|
|
930
|
+
// design tool offers iconColor/iconBackground/iconRadius, which tells an agent
|
|
931
|
+
// a glyph exists without telling it where — one cold-context run spent three
|
|
932
|
+
// refused calls guessing `cardStyle.icon` before concluding, correctly, that
|
|
933
|
+
// artwork is not part of the page design at all.
|
|
934
|
+
"loyaltyPageIcon",
|
|
935
|
+
"loyaltyPageCustomIcon"
|
|
799
936
|
];
|
|
800
937
|
var SPENDING_KEYS = [
|
|
801
938
|
"id",
|
|
@@ -821,13 +958,49 @@ var SPENDING_KEYS = [
|
|
|
821
958
|
"hasLimitShipping",
|
|
822
959
|
"limitShipping",
|
|
823
960
|
"enableMaxCapShipping",
|
|
824
|
-
"maxCapShipping"
|
|
961
|
+
"maxCapShipping",
|
|
962
|
+
// See EARNING_KEYS — same reason, same pair.
|
|
963
|
+
"loyaltyPageIcon",
|
|
964
|
+
"loyaltyPageCustomIcon"
|
|
825
965
|
];
|
|
966
|
+
var CATALOGUE_ARRAYS = ["specificProducts", "variantIds", "freeProductIds", "appliedCollectionIds"];
|
|
967
|
+
function compactReward(reward) {
|
|
968
|
+
if (!reward || typeof reward !== "object" || Array.isArray(reward))
|
|
969
|
+
return reward;
|
|
970
|
+
const out = {};
|
|
971
|
+
for (const [key, value] of Object.entries(reward)) {
|
|
972
|
+
if (CATALOGUE_ARRAYS.includes(key)) {
|
|
973
|
+
if (Array.isArray(value) && value.length)
|
|
974
|
+
out[`${key}Count`] = value.length;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
out[key] = value;
|
|
978
|
+
}
|
|
979
|
+
return out;
|
|
980
|
+
}
|
|
981
|
+
function compactMilestones(milestones) {
|
|
982
|
+
if (!Array.isArray(milestones))
|
|
983
|
+
return milestones;
|
|
984
|
+
return milestones.map((milestone) => {
|
|
985
|
+
if (!milestone || typeof milestone !== "object" || Array.isArray(milestone))
|
|
986
|
+
return milestone;
|
|
987
|
+
const entry = milestone;
|
|
988
|
+
if (!Array.isArray(entry.rewards))
|
|
989
|
+
return entry;
|
|
990
|
+
return { ...entry, rewards: entry.rewards.map(compactReward) };
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
function compactEarningProgram(program) {
|
|
994
|
+
const compacted = compact(program, EARNING_KEYS);
|
|
995
|
+
if (compacted.milestones === void 0)
|
|
996
|
+
return compacted;
|
|
997
|
+
return { ...compacted, milestones: compactMilestones(compacted.milestones) };
|
|
998
|
+
}
|
|
826
999
|
function registerProgramTools(server, ctx) {
|
|
827
|
-
registerReadTool(server, {
|
|
1000
|
+
registerReadTool(server, ctx, {
|
|
828
1001
|
name: "joy_list_earning_rules",
|
|
829
1002
|
title: "List point-earning rules",
|
|
830
|
-
description: 'List the ways customers EARN points (place order, sign up, birthday, social follows\u2026). Use to answer "how do customers get points?".
|
|
1003
|
+
description: 'List the ways customers EARN points (place order, sign up, birthday, social follows\u2026). Use to answer "how do customers get points?". For place_order, appliedPlaceOrderTo is the admin "Who to reward" radio (all = All loyalty members, vip-tier = VIP Tier only, member_and_guest = All customers) \u2014 quote that, not just earnPointsTiers. When a rule has earnPointsTiers AND appliedPlaceOrderTo is vip-tier, the per-VIP-tier rates override the flat earnPoint.',
|
|
831
1004
|
inputSchema: {},
|
|
832
1005
|
handler: async () => {
|
|
833
1006
|
const { data } = await ctx.api.getData("/programs/earning");
|
|
@@ -835,10 +1008,10 @@ function registerProgramTools(server, ctx) {
|
|
|
835
1008
|
if (programs.length === 0) {
|
|
836
1009
|
return "No earning rules configured \u2014 customers currently have no way to earn points.";
|
|
837
1010
|
}
|
|
838
|
-
return summaryWithJson(`${programs.length} earning rule(s) configured.`, programs.map(
|
|
1011
|
+
return summaryWithJson(`${programs.length} earning rule(s) configured.`, programs.map(compactEarningProgram));
|
|
839
1012
|
}
|
|
840
1013
|
});
|
|
841
|
-
registerReadTool(server, {
|
|
1014
|
+
registerReadTool(server, ctx, {
|
|
842
1015
|
name: "joy_list_redemption_rules",
|
|
843
1016
|
title: "List point-redemption rules",
|
|
844
1017
|
description: 'List the ways customers SPEND points (discount amount, percentage off, free shipping, free gift\u2026), including point costs (spendPoint) and min/max limits. Use to answer "what can customers redeem?" and to find the programId for a redemption. For USAGE questions \u2014 "which reward is redeemed most?", "how many times was program X redeemed?" \u2014 set include_redeem_counts to get redeemCount (all-time REDEMPTIONS, not unique customers) per rule in the same call. Redeeming itself is a separate write operation, not available through this tool.',
|
|
@@ -1113,7 +1286,7 @@ function formatReferralReward(amount, type) {
|
|
|
1113
1286
|
return `${String(amount)} (${String(type ?? "unknown type")})`;
|
|
1114
1287
|
}
|
|
1115
1288
|
function registerReferralTools(server, ctx) {
|
|
1116
|
-
registerReadTool(server, {
|
|
1289
|
+
registerReadTool(server, ctx, {
|
|
1117
1290
|
name: "joy_get_referral_program",
|
|
1118
1291
|
title: "Get referral program status & configuration",
|
|
1119
1292
|
description: 'Whether the referral program is ENABLED plus its reward configuration \u2014 use this FIRST for "is the referral program on or off?", "what do referrers/friends get?". `status` is the on/off toggle: true = customers can refer friends, false = the program is turned off. Field naming: customerReward* = what the REFERRER (the advocate) gets; friendReward* = what the referred FRIEND gets; reward type may be "percentage", "amount" (fixed discount), "point", or store-credit. When isAppliedVipTier is true, earnPointsTiers overrides the flat referrer reward per VIP tier. For usage numbers use joy_get_referral_performance; for one member\'s results use joy_get_member_referrals.',
|
|
@@ -1135,7 +1308,7 @@ function registerReferralTools(server, ctx) {
|
|
|
1135
1308
|
return summaryWithJson(`Referral program is ${enabled ? "ENABLED (ON)" : "DISABLED (OFF)"} \u2014 referrer gets ${referrerReward}, referred friend gets ${friendReward}.`, compact(program, REFERRAL_PROGRAM_KEYS));
|
|
1136
1309
|
}
|
|
1137
1310
|
});
|
|
1138
|
-
registerReadTool(server, {
|
|
1311
|
+
registerReadTool(server, ctx, {
|
|
1139
1312
|
name: "joy_get_referral_performance",
|
|
1140
1313
|
title: "Get referral program performance",
|
|
1141
1314
|
description: "Shop-level referral analytics. NOTE: the underlying API currently reports one metric only \u2014 how many referral coupon codes were used (i.e. completed referrals). It does NOT report clicks, signups, or revenue, and it does NOT tell you whether the program is enabled \u2014 use joy_get_referral_program for status/config. Optionally filter by date range.",
|
|
@@ -1152,7 +1325,7 @@ function registerReferralTools(server, ctx) {
|
|
|
1152
1325
|
return summaryWithJson(`${count} referral coupon(s) were used${range} (completed referrals).`, compact(data ?? {}));
|
|
1153
1326
|
}
|
|
1154
1327
|
});
|
|
1155
|
-
registerReadTool(server, {
|
|
1328
|
+
registerReadTool(server, ctx, {
|
|
1156
1329
|
name: "joy_get_member_referrals",
|
|
1157
1330
|
title: "Get one member's referral results",
|
|
1158
1331
|
description: "Referral results for ONE member (identified by email or Shopify customer ID). Returns (1) aggregate totals \u2014 coupons redeemed via their referrals and points they earned from referring \u2014 and (2) their recent referral activity records. To generate/fetch their personal invite LINK, use joy_create_referral_invite instead.",
|
|
@@ -1211,7 +1384,7 @@ function registerReferralWriteTools(server, ctx) {
|
|
|
1211
1384
|
// ../mcp-core/build/tools/overview.js
|
|
1212
1385
|
var OVERVIEW_TTL_MS = 15 * 60 * 1e3;
|
|
1213
1386
|
function registerOverviewTools(server, ctx) {
|
|
1214
|
-
registerReadTool(server, {
|
|
1387
|
+
registerReadTool(server, ctx, {
|
|
1215
1388
|
name: "joy_get_loyalty_overview",
|
|
1216
1389
|
title: "Get loyalty program overview",
|
|
1217
1390
|
description: 'Orient yourself: shop identity (name, domain, plan, currency, timezone) plus the loyalty program settings. Call this FIRST in a conversation before other Joy tools \u2014 it tells you which shop you are operating on and how its program is configured. Cached ~15 minutes; repeat calls within a conversation are free. This is configuration only \u2014 for performance numbers (revenue impact, points issued, member growth, program health) use the joy_get_* analytics report tools instead, and for "how many members does the shop have?" use joy_list_members with include_total_count.',
|
|
@@ -1379,7 +1552,7 @@ async function getScopedReport(ctx, path, options = {}) {
|
|
|
1379
1552
|
}
|
|
1380
1553
|
}
|
|
1381
1554
|
function registerAnalyticsTools(server, ctx) {
|
|
1382
|
-
registerReadTool(server, {
|
|
1555
|
+
registerReadTool(server, ctx, {
|
|
1383
1556
|
name: "joy_get_analytics_report",
|
|
1384
1557
|
title: "Loyalty analytics report (Analytics V2)",
|
|
1385
1558
|
description: 'THE primary tool for loyalty analytics questions \u2014 members, VIP tiers, points economy, rewards ROI, coupons, referrals, and loyalty revenue impact. Retrieves any Analytics V2 report using the SAME data and cache as the Analytics dashboard the merchant sees in the Joy admin, so your numbers always match theirs. Joy-internal data \u2014 no Shopify permission needed. Defaults to the last 30 days (max 366-day range, cached server-side ~1h). Returns a pre-digested text summary (time series are collapsed to count/total/latest; tables capped at 8 rows). A 409 error means the shop is still on Analytics V1 (no V2 data exists \u2014 follow the error guidance about migrating, never report zeros); a summary starting with "WARNING: \u2026 backfill" means the historical backfill is unfinished and numbers may be incomplete \u2014 relay that caveat. For Shopify-sales-attributed revenue (order-tag attribution) use joy_get_loyalty_revenue_report instead \u2014 a DIFFERENT data source whose numbers may legitimately differ from assisted_revenue.\n' + V2_REPORT_MENU,
|
|
@@ -1407,7 +1580,7 @@ function registerAnalyticsTools(server, ctx) {
|
|
|
1407
1580
|
${summary}`);
|
|
1408
1581
|
}
|
|
1409
1582
|
});
|
|
1410
|
-
registerReadTool(server, {
|
|
1583
|
+
registerReadTool(server, ctx, {
|
|
1411
1584
|
name: "joy_get_loyalty_revenue_report",
|
|
1412
1585
|
title: "Loyalty revenue impact (Shopify sales data)",
|
|
1413
1586
|
description: `Loyalty revenue impact measured from SHOPIFY'S OWN sales dataset (ShopifyQL), attributed by the shop's "Joy assisted" order tag. Use for: loyalty ROI, "how much revenue does the loyalty program drive?", "is the program making money?", loyalty AOV, year-over-year (YoY) comparison. Metrics: total_sales = revenue from orders tagged "Joy assisted"; aov = average order value of those orders; returning_rate = returning-customer rate among customers tagged as active redeemers; orders = count of Joy-assisted orders. compare_to_last_year applies to total_sales ONLY and is IGNORED when a custom range is given. Custom range = BOTH start_date and end_date (ISO YYYY-MM-DD), max 366 days, not in the future. ` + SCOPE_HELP + " joy_get_analytics_report (reportType assisted_revenue) measures assisted revenue from Joy's own event log \u2014 a DIFFERENT data source with different attribution; the two numbers may legitimately disagree.",
|
|
@@ -1427,7 +1600,7 @@ ${summary}`);
|
|
|
1427
1600
|
return summaryWithJson(summary, compact(data, LOYALTY_REVENUE_KEYS));
|
|
1428
1601
|
}
|
|
1429
1602
|
});
|
|
1430
|
-
registerReadTool(server, {
|
|
1603
|
+
registerReadTool(server, ctx, {
|
|
1431
1604
|
name: "joy_get_tier_performance_breakdown",
|
|
1432
1605
|
title: "Sales performance by VIP tier",
|
|
1433
1606
|
description: 'Shopify sales performance broken down by VIP tier (ShopifyQL, grouped by the Joy tier metafield). Use for: "which tier spends the most?", "revenue by tier", "AOV per tier", "orders per customer by tier". Default window is ALL-TIME; pass BOTH start_date and end_date (YYYY-MM-DD, max 366 days) to bound it. Rows are tier names sorted by value; "(unassigned)" = customers without a tier. A 422 METAFIELD_NOT_CONFIGURED error means the Joy tier metafield definition is DISABLED on this shop \u2014 that is a setup gate, NOT empty data; ask the merchant to enable it in Joy \u2192 Settings \u2192 General \u2192 Metafield section. For loyalty-side tier reports (member counts, upgrades/downgrades) use joy_get_analytics_report with tier_performance / tier_trend / tier_transitions. ' + SCOPE_HELP,
|
|
@@ -1454,7 +1627,7 @@ ${summary}`);
|
|
|
1454
1627
|
return summaryWithJson(summary, compact(data, TIER_PERFORMANCE_KEYS));
|
|
1455
1628
|
}
|
|
1456
1629
|
});
|
|
1457
|
-
registerReadTool(server, {
|
|
1630
|
+
registerReadTool(server, ctx, {
|
|
1458
1631
|
name: "joy_get_loyalty_health_check",
|
|
1459
1632
|
title: "Loyalty program health check",
|
|
1460
1633
|
description: `Loyalty program health diagnostic \u2014 use FIRST for open-ended questions: "how is my loyalty program doing?", "is anything wrong?", "why aren't customers earning points?". Compares the current window (default last 30 days) against the equal-length prior window using Joy-internal data only \u2014 aggregate numbers, no customer PII, and NO Shopify permission needed. healthScore = 100 minus a penalty per flag (low 5, medium 15, high 25; floor 0): >=80 Healthy, 60-79 Watch, <60 Needs attention. Flags check: sandbox/test earning mode (real orders are NOT earning points \u2014 the most urgent flag), redemption rate, points-earned trend, redeem-revenue trend, data availability, and tier concentration. Pass start_date+end_date (both, YYYY-MM-DD) to move the current window. For the underlying detail behind a flag, follow up with joy_get_analytics_report.`,
|
|
@@ -1695,7 +1868,7 @@ function registerPointWriteTools(server, ctx) {
|
|
|
1695
1868
|
inputSchema: {
|
|
1696
1869
|
...memberIdentifierSchema,
|
|
1697
1870
|
program_id: z7.string().describe("Redemption program ID (from joy_list_redemption_rules)."),
|
|
1698
|
-
redeem_points: z7.number().int().positive().describe("Points to spend
|
|
1871
|
+
redeem_points: z7.number().int().positive().describe("Points to spend for dynamic rewards only. Fixed rewards use the program's server-side cost.")
|
|
1699
1872
|
},
|
|
1700
1873
|
destructiveHint: true,
|
|
1701
1874
|
idempotentHint: false,
|
|
@@ -1717,11 +1890,14 @@ function registerPointWriteTools(server, ctx) {
|
|
|
1717
1890
|
}
|
|
1718
1891
|
const discount = result?.discount ?? {};
|
|
1719
1892
|
const customer = result?.customer ?? {};
|
|
1893
|
+
const chargedPoints = typeof result.redeemPoint === "number" && Number.isFinite(result.redeemPoint) ? result.redeemPoint : typeof member.point === "number" && typeof customer.point === "number" && Number.isFinite(member.point) && Number.isFinite(customer.point) && member.point >= customer.point ? member.point - customer.point : void 0;
|
|
1720
1894
|
if (!discount.code) {
|
|
1721
1895
|
throw new JoyApiError("server", "The redeem response did not include a coupon code \u2014 treat this redemption as NOT completed. Common causes: insufficient points, program unavailable, or gift out of stock. Check the member balance and program via joy_find_member / joy_list_redemption_rules, and verify no redemption was recorded via joy_get_member_activity before trying again.");
|
|
1722
1896
|
}
|
|
1723
|
-
|
|
1897
|
+
const redemptionSummary = Number.isFinite(chargedPoints) ? `Redeemed ${formatPoints(chargedPoints)} points from ${memberDisplayName(member)}` : `Redeemed a server-priced reward for ${memberDisplayName(member)}`;
|
|
1898
|
+
return summaryWithJson(`${redemptionSummary} for coupon "${String(discount.code)}" \u2014 balance now ${formatPoints(customer.point)}.`, {
|
|
1724
1899
|
coupon: compact(discount, ["code", "title", "expiredAt"]),
|
|
1900
|
+
charged_points: chargedPoints,
|
|
1725
1901
|
member_balance: customer.point
|
|
1726
1902
|
});
|
|
1727
1903
|
}
|
|
@@ -1798,7 +1974,7 @@ function toRetentionRiskQuery(args) {
|
|
|
1798
1974
|
return query;
|
|
1799
1975
|
}
|
|
1800
1976
|
function registerRetentionTools(server, ctx) {
|
|
1801
|
-
registerReadTool(server, {
|
|
1977
|
+
registerReadTool(server, ctx, {
|
|
1802
1978
|
name: "joy_get_retention_risk_members",
|
|
1803
1979
|
title: "Find members at risk of losing points or dropping a VIP tier",
|
|
1804
1980
|
description: 'READ-ONLY retention/outreach tool \u2014 "which members are about to lose points or drop a VIP tier, so I can proactively reach out?" Two independent signals via `signal`: "points_expiring" = members with points expiring within `window_days` (ONLY covers shops on earn-time/FIFO point expiration \u2014 shops using all-at-once expiration are not covered by this signal, so an empty result here does not mean no one is losing points); "tier_drop_risk" = members the REAL tier-demotion engine will demote soon \u2014 Joy demotes on INACTIVITY (no order since the tier program\'s configured demotion window), so a member is at risk when their last order is already past that cutoff (`daysUntilDemotion: 0` = demotable on the very next demotion run) or will pass it within `window_days` unless they order again; each row carries `demotion`: "down_one_tier" or "reset_to_lowest_tier" per the program\'s setting. A shop can also (or instead) run a SCHEDULED STORE-WIDE reset on a fixed calendar day \u2014 then the response carries `tier_scheduled_reset` (resetAt + daysUntilReset): EVERY member resets to the lowest tier on that date at once, so the correct answer is that DATE, not a per-member list. If the shop has tier demotion disabled, the response includes `tierDropRiskNote` saying the signal does not apply \u2014 report that note, never conclude "no one is at risk" from the empty list. Known limit: shops using assessment-based demotion (periodic spending re-evaluation) are not predicted by this signal. Optionally scope to one tier with `tier_id` (the lowest tier is never at risk \u2014 nothing to drop to). Default "both" returns both signals in one call. Results are HARD-CAPPED: window_days max 30, limit max 50 per signal (values above the cap are silently clamped, not rejected) \u2014 this is a shortlist for outreach, never the full at-risk population. Returns only the fields needed to act (name/email, expiring points/days, last-order date/days-until-demotion) plus has_more \u2014 never the member\'s full activity history.',
|
|
@@ -1876,7 +2052,7 @@ function shapeSupportingData(supportingData) {
|
|
|
1876
2052
|
return shaped;
|
|
1877
2053
|
}
|
|
1878
2054
|
function registerRiskTools(server, ctx) {
|
|
1879
|
-
registerReadTool(server, {
|
|
2055
|
+
registerReadTool(server, ctx, {
|
|
1880
2056
|
name: "joy_investigate_member_risk",
|
|
1881
2057
|
title: "Investigate a member for referral/redemption abuse risk",
|
|
1882
2058
|
description: `READ-ONLY fraud/abuse investigation for ONE member \u2014 "is this member doing anything suspicious?" Checks a FIXED, hard-coded set of flags (the same rules run every time, not a judgment call): referral_email_similarity (this member referred someone whose email closely matches their own \u2014 e.g. john+1@/john+2@ patterns); referral_device_match (this member's CURRENT last-known IP/device value matches their referrer's CURRENT last-known value); referee_had_prior_order (this member already had an order before being referred, so was not actually a new customer); high_daily_redemptions (redeemed more than a fixed threshold of rewards today). The response includes supporting data ONLY for flags that actually fired (e.g. the masked similar-email referrals, or today's redemption list) \u2014 if nothing is flagged, no supporting data is returned; this is NOT a full activity/history dump, so do not ask follow-up questions expecting more raw data from this same call. CRITICAL DATA LIMITS \u2014 do not claim more than this: there is NO IP/device HISTORY anywhere in Joy, only a single last-known value per customer that gets overwritten on every storefront visit \u2014 so referral_device_match is a live snapshot comparison, NOT a check of what the IP/device was at the actual moment the referral was claimed (that comparison happens live during the claim and is never stored). Do NOT say things like "this customer used N different IPs" or "here is their IP address" \u2014 that data does not exist and IPs are never returned, only a boolean match. Redemption velocity is checked at DAY granularity only \u2014 there is no minute/hour-level velocity check, so do not claim to detect rapid-fire redemption bursts within a single day. This tool never locks accounts, cancels rewards, or takes any action \u2014 it only surfaces flags for the merchant to review and decide next steps.`,
|
|
@@ -1917,7 +2093,7 @@ function describeRule(rule) {
|
|
|
1917
2093
|
return `${title}: did not match${reasons ? ` (${reasons})` : ""}`;
|
|
1918
2094
|
}
|
|
1919
2095
|
function registerOrderExplainTools(server, ctx) {
|
|
1920
|
-
registerReadTool(server, {
|
|
2096
|
+
registerReadTool(server, ctx, {
|
|
1921
2097
|
name: "joy_explain_order_points",
|
|
1922
2098
|
title: "Explain why an order did or didn't earn points",
|
|
1923
2099
|
description: "READ-ONLY explanation of the loyalty points earning rule engine for ONE order \u2014 \"why did/didn't this order earn points (or earn fewer than expected)?\" The response's `explainSource` field tells you which of two modes produced it: `recorded` means the answer comes from the decision-time trace the REAL earn pipeline stored on the order's activity when it was processed (historical ground truth \u2014 per-rule pass/fail with the exact condition fields that failed, recorded at `recordedAt`); `replay` means no stored trace exists (order processed before tracing shipped, or a legacy calculation path) and this is a RECONSTRUCTION run against the order's CURRENT Shopify snapshot, reusing the exact same gate and rule-matching functions the real earn pipeline uses \u2014 NOT a historical record. If the order was modified after points were originally calculated (e.g. a partial refund applied after the initial award), this tool reports against the order's current state, which may not match what was actually awarded at the time \u2014 do not present this as infallible ground truth for what happened historically; treat it as \"what the rules say NOW\". Evaluation order: (1) pre-rule-engine gates \u2014 no customer matched on the order, guest checkout not opted in, customer left the loyalty program, order not paid/fulfilled per the shop's setting, order fully refunded, or an update arriving after the order was already closed; if any gate blocks, `blockedByGate` explains which one and no rules were evaluated. (2) If gates pass, every active place-order earning rule is evaluated in priority order (`rules`), each with `matched` true/false/null and human-readable `reasons` \u2014 `matched: null` means the rule was NEVER evaluated because a higher-priority rule matched first with \"stop processing\" enabled (distinct from `false`, which means it WAS evaluated and did not match). (3) `maxTheoreticalPoints` is the total before the global per-customer/tier earning cap; `totalEarnedPoints` is after; `cappedBy` is set only when that cap actually reduced the total. Scope: covers the place_order (checkout) earning event only \u2014 birthday, sign-up, and milestone rewards are NOT covered by this tool.",
|
|
@@ -2004,7 +2180,7 @@ function toActivityAuditQuery(args) {
|
|
|
2004
2180
|
return query;
|
|
2005
2181
|
}
|
|
2006
2182
|
function registerActivityAuditTools(server, ctx) {
|
|
2007
|
-
registerReadTool(server, {
|
|
2183
|
+
registerReadTool(server, ctx, {
|
|
2008
2184
|
name: "joy_list_activities",
|
|
2009
2185
|
title: "List loyalty activities across members (audit by activity group)",
|
|
2010
2186
|
description: 'READ-ONLY shop-wide activity audit \u2014 "WHICH members did X in this period", the cross-member counterpart of joy_get_member_activity (one member\'s history) and of joy_get_analytics_report report_type=tier_transitions (aggregate COUNTS only, no names). Use this when the merchant asks WHO, use analytics when they ask HOW MANY. One semantic `group` per call, each spanning every raw activity type that feature area produces: "tier_downgraded" covers ALL THREE demotion mechanisms (reassessment demote_tier, inactivity downgrade_tier, relaunch reset_tier \u2014 never query one of these alone or you will miss mechanisms); "tier_upgraded" = tier upgrades; "points_earned" = program point earns (rows include orderName when the earn came from an order); "points_redeemed" = redemptions web + POS (each row carries programId/programTitle \u2014 combine with `program_id` for "who redeemed program A?"); "points_refunded" / "points_expired" / "points_adjusted" = the corresponding point movements; "referral" = referral outcomes (referred members, referred orders, referral rewards + revocations \u2014 rows carry referralCustomerEmail linking referrer and referee); "members_joined" = joins/rejoins. Time range: from_date/to_date (defaults to the last 30 days, range hard-capped at 366 days \u2014 an over-wide range is clamped to the most recent 366 days, not rejected). Results are a HARD-CAPPED shortlist: limit max 50 (default 20), newest first, plus has_more \u2014 never the full population; treat has_more:true as "narrow the range or filter, do not page exhaustively".',
|
|
@@ -2061,7 +2237,7 @@ function toTierUpgradeQuery(args) {
|
|
|
2061
2237
|
return query;
|
|
2062
2238
|
}
|
|
2063
2239
|
function registerTierUpgradeTools(server, ctx) {
|
|
2064
|
-
registerReadTool(server, {
|
|
2240
|
+
registerReadTool(server, ctx, {
|
|
2065
2241
|
name: "joy_get_tier_upgrade_candidates",
|
|
2066
2242
|
title: "Find members close to reaching the next VIP tier",
|
|
2067
2243
|
description: 'READ-ONLY growth/upsell outreach tool \u2014 "which members are ABOUT TO reach the next VIP tier, so I can nudge them over the line?" (e.g. "kh\xE1ch n\xE0o s\u1EAFp l\xEAn Gold?"). The mirror of joy_get_retention_risk_members\' tier_drop_risk signal: that one finds members about to FALL, this one finds members about to CLIMB. Upgrades in Joy apply immediately when tierPoint reaches the next tier\'s threshold, so distance-to-threshold is the exact signal \u2014 each row reports the member\'s current tier, tierPoint, the next tier and its threshold, and `pointsToNextTier` (how little they still need). Sorted closest-first. `margin_points` defines "close" (default: 10% of the next tier\'s own threshold, so it scales with the shop); note tierPoint is measured in the tier program\'s entry metric \u2014 points, money spent, or order count depending on the program setup \u2014 so quote distances in that unit, not always "points". Optionally scope to one CURRENT tier with `tier_id` (from joy_list_tiers); the highest tier is never a candidate (nothing to climb to), exclusive/VIP-deal tiers are excluded on both ends. Results are a HARD-CAPPED shortlist (limit max 50, default 20, split evenly across tiers when unscoped) plus has_more \u2014 never the full population. For ONE member\'s own distance to the next tier, prefer joy_find_member (field pointsRemain) instead.',
|
|
@@ -2087,8 +2263,152 @@ function registerTierUpgradeTools(server, ctx) {
|
|
|
2087
2263
|
});
|
|
2088
2264
|
}
|
|
2089
2265
|
|
|
2090
|
-
// ../mcp-core/build/tools/
|
|
2266
|
+
// ../mcp-core/build/tools/loyaltyPage.js
|
|
2091
2267
|
import { z as z13 } from "zod";
|
|
2268
|
+
function summarizeDesign(data) {
|
|
2269
|
+
const sections = data.design?.sections ?? [];
|
|
2270
|
+
const on = sections.filter((section) => section.enabled !== false).map((section) => section.id);
|
|
2271
|
+
const legacyWarning = data.renderPath === "legacy-blocks" ? `\u26A0 The storefront loyalty page renders through legacy blocks, so Loyalty Page V2 design changes will not appear there. The merchant must open ${data.v2BlockAddUrl || "the Loyalty Page V2 block link from joy_check_theme_setup"}, add the Loyalty Page V2 block, and save the theme. ` : "";
|
|
2272
|
+
let live = "The shop still serves the older Loyalty Page V1, so changes here are not visible to customers yet.";
|
|
2273
|
+
if (data.pageVersionActive && (data.renderPath === "v2-block" || data.renderPath === void 0)) {
|
|
2274
|
+
live = "This page IS the shop\u2019s live loyalty page \u2014 changes are visible to customers.";
|
|
2275
|
+
}
|
|
2276
|
+
if (data.pageVersionActive && data.renderPath === "legacy-blocks") {
|
|
2277
|
+
live = "Loyalty Page V2 is active for this shop.";
|
|
2278
|
+
}
|
|
2279
|
+
if (data.pageVersionActive && data.renderPath === "none") {
|
|
2280
|
+
live = "Loyalty Page V2 is active for this shop, but no loyalty page block was detected in the live theme, so changes are not visible to customers.";
|
|
2281
|
+
}
|
|
2282
|
+
const css = data.canEditCustomCss ? "" : " Custom CSS is not available on this shop\u2019s plan.";
|
|
2283
|
+
return `${legacyWarning}Loyalty Page V2 design. Sections on: ${on.join(", ") || "none"}. ${live}${css}`;
|
|
2284
|
+
}
|
|
2285
|
+
function summarizeAppliedChanges(applied2, published, warnings2 = []) {
|
|
2286
|
+
const headline = applied2.slice(0, 5).map((change) => `${change.field}: ${String(change.from ?? "\u2014")} \u2192 ${String(change.to)}`).join("; ");
|
|
2287
|
+
const more = applied2.length > 5 ? ` (+${applied2.length - 5} more)` : "";
|
|
2288
|
+
const where = published ? "Published: live on the storefront now." : "Saved to the draft \u2014 visible in the merchant\u2019s editor now, and to customers once they publish.";
|
|
2289
|
+
const caveat = warnings2.length ? `\u26A0 ${warnings2.join(" \u26A0 ")} ` : "";
|
|
2290
|
+
return `${caveat}${applied2.length} design field${applied2.length === 1 ? "" : "s"} changed \u2014 ${headline}${more}. ${where} Undo with joy_revert_loyalty_page_design.`;
|
|
2291
|
+
}
|
|
2292
|
+
function registerLoyaltyPageTools(server, ctx) {
|
|
2293
|
+
registerReadTool(server, ctx, {
|
|
2294
|
+
name: "joy_get_loyalty_page_design",
|
|
2295
|
+
title: "Get loyalty page design",
|
|
2296
|
+
description: "Read the shop's Loyalty Page V2 appearance \u2014 page theme (colours, fonts, radii, widths), which sections are switched on, and each section's layout and card styling. It also audits whether the live theme renders this design through the V2 block, legacy blocks, or no loyalty page block; when legacy blocks are present, the result leads with the server-provided theme-editor link the merchant must use to add and save the V2 block. This read-only tool cannot add or enable a theme block itself. Also returns `writableFields`: the exact field names joy_update_loyalty_page_design accepts, per section. CALL THIS BEFORE any design change \u2014 the field vocabulary is large and specific, and guessing a name costs a failed call. This is appearance only: page text (headings, FAQ questions, program descriptions) is NOT here and cannot be changed through these tools.",
|
|
2297
|
+
inputSchema: {},
|
|
2298
|
+
handler: async () => {
|
|
2299
|
+
const { data } = await ctx.api.getData("/loyalty-page/design");
|
|
2300
|
+
return summaryWithJson(summarizeDesign(data), data);
|
|
2301
|
+
}
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
function registerLoyaltyPageWriteTools(server, ctx) {
|
|
2305
|
+
registerWriteTool(server, ctx, {
|
|
2306
|
+
name: "joy_update_loyalty_page_design",
|
|
2307
|
+
title: "Update loyalty page design",
|
|
2308
|
+
// Not idempotent in the annotation sense: repeating the call is harmless for
|
|
2309
|
+
// the page, but each call overwrites the single undo snapshot, so a second
|
|
2310
|
+
// identical call is not a no-op for what revert would restore.
|
|
2311
|
+
destructiveHint: false,
|
|
2312
|
+
idempotentHint: false,
|
|
2313
|
+
description: "Change the appearance of the shop's Loyalty Page V2, including its media and program or tier artwork. Send ONLY the fields you are changing \u2014 anything you omit keeps its current value. WARNING: the `icons` branch has no draft \u2014 program and tier artwork is live data, so those changes reach shoppers immediately even when publish is false, and they also appear anywhere else that reads the program or tier. Tier artwork is dual-written to `imageBlock` for the loyalty page and `icon` for the tier-achieved email, membership card Widget V4, and account block: changing a tier icon changes those email and widget surfaces too, not just this page. For image artwork, first call joy_upload_image, then pass its returned URL to `hero.backgroundImage`, `hero.mobileBackgroundImage`, `inlineBanner.backgroundImage`, `inlineBanner.mobileBackgroundImage`, `earn.bannerImage`, `referral.image`, or `icons`; the other media fields are `hero.backgroundVideo` and `hero.mobileBackgroundVideo`. Image URLs must use http: or https: and contain no username or password; video URLs must use https: and end in .mp4. For a storefront-based starting point, send `applyScan` inside `design` in this tool \u2014 there is no separate apply-scan tool. Call joy_get_loyalty_page_design first and use the field names from its `writableFields`; a name outside that list is refused with the offending path rather than silently ignored. Saves to the DRAFT: the merchant sees it in their loyalty page editor immediately, and customers see it when they publish. Iterate freely here \u2014 refining a design costs nothing. Only pass publish=true when the merchant has asked for the change to go live, because publishing writes a Shopify metafield and re-syncs the FAQ projection every time. Undo with joy_revert_loyalty_page_design. Appearance only \u2014 this cannot change any text on the page.",
|
|
2314
|
+
inputSchema: {
|
|
2315
|
+
design: z13.record(z13.unknown()).describe('The fields to change, shaped like the `design` object from joy_get_loyalty_page_design. Branches: `theme` (page-wide tokens), `sections` (array of {id, enabled?, settings:{theme}}), `content` (per-section layout/style, keyed by section id), `advanced` ({customCss}), `icons` ({earning?:{...}, spending?:{...}, tiers?:{...}|[...]}, valued with an image URL or a built-in icon name), and `applyScan` ({url?: string, storefrontPassword?: string, colors?: boolean, images?: boolean}). Address earning/spending artwork by program id OR event; address tier artwork by tier id OR tier name (`name`/`title`), or pass an array in tier order. A tier artwork write updates both `imageBlock` (loyalty page) and `icon` (tier-achieved email, membership card Widget V4, and account block), so it changes email and widget surfaces too. Read current program artwork from joy_list_earning_rules / joy_list_redemption_rules. For `applyScan`, url defaults to this shop\'s storefront, storefrontPassword is only for a gated store, colors defaults to true, and images defaults to false so a scan does not unexpectedly replace artwork. If the scan returns 409 PASSWORD_REQUIRED, ask the merchant for the storefront password and pass it as `storefrontPassword`; never guess it. If it fails with 503, do not invent colours, fonts, or button styling; ask the merchant to retry later or configure the draft manually. Scan output is design input, not visual evidence of how the page looks after the change. NOTE on precedence: a value under `sections[].settings.theme` BEATS the same key in `theme`, so recolouring a page whose sections carry overrides means patching both. Example: {"theme":{"primary":"#0F4C3A"},"content":{"earn":{"columns":2}},"icons":{"earning":{"birthday":"https://cdn.../cake.png"}}}'),
|
|
2316
|
+
publish: z13.boolean().optional().describe("Default false \u2014 the change lands on the draft, which the merchant sees in the loyalty page editor and nobody else. Pass true only when they asked for it to go live; each publish costs a Shopify metafield write, so do not send it on every tweak.")
|
|
2317
|
+
},
|
|
2318
|
+
handler: async (args) => {
|
|
2319
|
+
const result = await ctx.api.mutateData("/loyalty-page/design", {
|
|
2320
|
+
method: "PATCH",
|
|
2321
|
+
timeoutMs: 2e4,
|
|
2322
|
+
body: { design: args.design, ...args.publish === void 0 ? {} : { publish: args.publish } }
|
|
2323
|
+
});
|
|
2324
|
+
const applied2 = result?.applied ?? [];
|
|
2325
|
+
if (!applied2.length) {
|
|
2326
|
+
return "No change \u2014 every field you sent already had that value.";
|
|
2327
|
+
}
|
|
2328
|
+
const published = result?.published ?? false;
|
|
2329
|
+
const warnings2 = result?.warnings ?? [];
|
|
2330
|
+
return summaryWithJson(summarizeAppliedChanges(applied2, published, warnings2), {
|
|
2331
|
+
applied: applied2,
|
|
2332
|
+
published,
|
|
2333
|
+
...warnings2.length ? { warnings: warnings2 } : {}
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
});
|
|
2337
|
+
registerWriteTool(server, ctx, {
|
|
2338
|
+
name: "joy_revert_loyalty_page_design",
|
|
2339
|
+
title: "Revert the last loyalty page design change",
|
|
2340
|
+
// It removes work the merchant may have wanted, and calling it twice
|
|
2341
|
+
// restores the same snapshot rather than stepping further back.
|
|
2342
|
+
destructiveHint: true,
|
|
2343
|
+
idempotentHint: true,
|
|
2344
|
+
description: "Undo the last design change made through joy_update_loyalty_page_design, restoring the page exactly as it was before that call \u2014 including any program or tier artwork it changed, which goes back LIVE immediately because artwork has no draft \u2014 and republish. ONE step only \u2014 it is not a history, and a second call restores the same snapshot again rather than going further back. WARNING: it restores a whole saved design, so any edit the merchant made in the admin editor AFTER that update is discarded too \u2014 if they may have been editing, ask before calling this. Use it promptly when a change is judged wrong; the next update overwrites the snapshot.",
|
|
2345
|
+
inputSchema: {
|
|
2346
|
+
publish: z13.boolean().optional().describe("Default false \u2014 the restored design lands on the draft. Pass true to put it back on the storefront immediately.")
|
|
2347
|
+
},
|
|
2348
|
+
handler: async (args) => {
|
|
2349
|
+
const result = await ctx.api.mutateData("/loyalty-page/design/revert", {
|
|
2350
|
+
method: "POST",
|
|
2351
|
+
timeoutMs: 2e4,
|
|
2352
|
+
body: args.publish === void 0 ? {} : { publish: args.publish }
|
|
2353
|
+
});
|
|
2354
|
+
const programIcons = result?.programIconsRestored ?? 0;
|
|
2355
|
+
const tierIcons = result?.tierIconsRestored ?? 0;
|
|
2356
|
+
const icons = programIcons || tierIcons ? ` ${[
|
|
2357
|
+
programIcons ? `${programIcons} program icon${programIcons === 1 ? "" : "s"}` : "",
|
|
2358
|
+
tierIcons ? `${tierIcons} tier icon${tierIcons === 1 ? "" : "s"}` : ""
|
|
2359
|
+
].filter(Boolean).join(" and ")} were put back too, and artwork has no draft \u2014 those are LIVE now.` : "";
|
|
2360
|
+
return summaryWithJson(`Reverted to the design from before the last update. ${result?.published ? "Live on the storefront again." : "The page is restored on the draft \u2014 publish to put it back for customers."}${icons}`, { design: result?.design, programIconsRestored: programIcons, tierIconsRestored: tierIcons });
|
|
2361
|
+
}
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// ../mcp-core/build/tools/loyaltyPageText.js
|
|
2366
|
+
import { z as z14 } from "zod";
|
|
2367
|
+
var localeSchema = z14.string().min(2).optional().describe("Active locale code. Omit to use the shop's primary language.");
|
|
2368
|
+
function registerLoyaltyPageTextTools(server, ctx) {
|
|
2369
|
+
registerReadTool(server, ctx, {
|
|
2370
|
+
name: "joy_get_loyalty_page_text",
|
|
2371
|
+
title: "Get loyalty page text",
|
|
2372
|
+
description: "Loyalty Page V2 text has no draft: text edits are LIVE on the storefront immediately. Read the current text for one active locale, the placeholders each value must preserve, and the stable ids/keys for steps and tier benefits. CALL THIS BEFORE joy_update_loyalty_page_text; never guess keys or item positions. Editing the primary language automatically retranslates only those changed keys into every other active locale. `points.singular` and `points.plural` are shown for context but are read-only through this tool surface.",
|
|
2373
|
+
inputSchema: { locale: localeSchema },
|
|
2374
|
+
handler: async (args) => {
|
|
2375
|
+
const { data } = await ctx.api.getData("/loyalty-page/text", {
|
|
2376
|
+
query: { locale: args.locale }
|
|
2377
|
+
});
|
|
2378
|
+
return summaryWithJson(`${Object.keys(data.values).length} live Loyalty Page V2 text values for ${data.locale}. Primary language: ${data.primaryLanguage}; active locales: ${data.activeLocales.join(", ")}.`, data);
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
function registerLoyaltyPageTextWriteTools(server, ctx) {
|
|
2383
|
+
registerWriteTool(server, ctx, {
|
|
2384
|
+
name: "joy_update_loyalty_page_text",
|
|
2385
|
+
title: "Update loyalty page text",
|
|
2386
|
+
destructiveHint: true,
|
|
2387
|
+
idempotentHint: false,
|
|
2388
|
+
description: "Loyalty Page V2 text has no draft: every accepted change is LIVE on the storefront immediately. Update only exact keys returned by joy_get_loyalty_page_text; unknown or duplicate keys are skipped. Preserve every returned placeholder unless the merchant explicitly authored the replacement, in which case pass manual: true. Editing the primary language automatically retranslates the changed keys into every other active locale in the same operation; editing a secondary locale changes only that locale. This can replace merchant-authored translations for those same keys. Undo the latest text or design update with joy_revert_loyalty_page_design.",
|
|
2389
|
+
inputSchema: {
|
|
2390
|
+
locale: localeSchema,
|
|
2391
|
+
changes: z14.array(z14.object({
|
|
2392
|
+
key: z14.string().min(1),
|
|
2393
|
+
value: z14.string(),
|
|
2394
|
+
manual: z14.boolean().optional().describe("Set true only when the merchant authored this exact replacement.")
|
|
2395
|
+
})).min(1).describe("Exact key/value edits from joy_get_loyalty_page_text.")
|
|
2396
|
+
},
|
|
2397
|
+
handler: async (args) => {
|
|
2398
|
+
const result = await ctx.api.mutateData("/loyalty-page/text", {
|
|
2399
|
+
method: "PATCH",
|
|
2400
|
+
timeoutMs: 12e4,
|
|
2401
|
+
body: { changes: args.changes, ...args.locale ? { locale: args.locale } : {} }
|
|
2402
|
+
});
|
|
2403
|
+
const propagation = result.translatedLocales.length ? ` Retranslated in ${result.translatedLocales.join(", ")}.` : "";
|
|
2404
|
+
const skipped = result.skipped.length ? ` ${result.skipped.length} change(s) skipped.` : "";
|
|
2405
|
+
return summaryWithJson(`\u26A0 Loyalty Page V2 text has no draft \u2014 ${result.applied.length} text change(s) are LIVE now.${propagation}${skipped} Undo with joy_revert_loyalty_page_design.`, result);
|
|
2406
|
+
}
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// ../mcp-core/build/tools/programsWrite.js
|
|
2411
|
+
import { z as z15 } from "zod";
|
|
2092
2412
|
var PROGRAM_TYPE_BY_KIND = {
|
|
2093
2413
|
earning: "earning",
|
|
2094
2414
|
redemption: "spending"
|
|
@@ -2167,7 +2487,8 @@ var EARNING_FIELDS = [
|
|
|
2167
2487
|
"extraPointsForVideos",
|
|
2168
2488
|
"isAppliedVipTier",
|
|
2169
2489
|
"earnPointsTiers",
|
|
2170
|
-
"appliedPlaceOrderTo"
|
|
2490
|
+
"appliedPlaceOrderTo",
|
|
2491
|
+
"skipEarnPointGuest"
|
|
2171
2492
|
];
|
|
2172
2493
|
var REDEMPTION_FIELDS = [
|
|
2173
2494
|
"event",
|
|
@@ -2484,45 +2805,45 @@ function isProgramChangeNoop(current, prepared) {
|
|
|
2484
2805
|
return diffFields(current, prepared.body).length === 0;
|
|
2485
2806
|
}
|
|
2486
2807
|
var programConfigInputSchema = {
|
|
2487
|
-
program_id:
|
|
2488
|
-
event:
|
|
2489
|
-
title:
|
|
2490
|
-
status:
|
|
2491
|
-
priority:
|
|
2492
|
-
start_date:
|
|
2493
|
-
end_date:
|
|
2494
|
-
earn_by:
|
|
2495
|
-
rate_money:
|
|
2496
|
-
rate_item:
|
|
2497
|
-
earn_point:
|
|
2498
|
-
date_format:
|
|
2499
|
-
type_reward_timing:
|
|
2500
|
-
limit_unit:
|
|
2501
|
-
limit_interval:
|
|
2502
|
-
url_account:
|
|
2503
|
-
review_app:
|
|
2504
|
-
description_review:
|
|
2505
|
-
extra_points_for_images:
|
|
2506
|
-
extra_points_for_videos:
|
|
2507
|
-
spend_point:
|
|
2508
|
-
earn_amount:
|
|
2509
|
-
shipping_discount_type:
|
|
2510
|
-
shipping_discount_amount:
|
|
2511
|
-
applied_to:
|
|
2512
|
-
specific_products:
|
|
2513
|
-
specific_collections:
|
|
2514
|
-
applied_collection_ids:
|
|
2515
|
-
collection_quantity:
|
|
2516
|
-
product_url:
|
|
2517
|
-
applied_place_order_to:
|
|
2518
|
-
is_applied_vip_tier:
|
|
2519
|
-
earn_points_tiers:
|
|
2520
|
-
earn_point:
|
|
2521
|
-
rate_money:
|
|
2522
|
-
type_earn:
|
|
2808
|
+
program_id: z15.string().min(1).optional().describe("Existing Joy program ID. Omit to create a new program."),
|
|
2809
|
+
event: z15.string().optional().describe("Program event. Required for create; optional for update."),
|
|
2810
|
+
title: z15.string().min(1).optional().describe("Customer-facing program title."),
|
|
2811
|
+
status: z15.boolean().optional().describe("Set false to disable this rule instead of deleting it."),
|
|
2812
|
+
priority: z15.number().int().optional().describe("Optional program priority."),
|
|
2813
|
+
start_date: z15.string().optional().describe("Optional ISO start date."),
|
|
2814
|
+
end_date: z15.string().optional().describe("Optional ISO end date."),
|
|
2815
|
+
earn_by: z15.enum(["price", "fixed_order", "quantity", "order"]).optional(),
|
|
2816
|
+
rate_money: z15.number().optional(),
|
|
2817
|
+
rate_item: z15.number().optional(),
|
|
2818
|
+
earn_point: z15.number().optional(),
|
|
2819
|
+
date_format: z15.enum(["MM/DD", "DD/MM", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"]).optional(),
|
|
2820
|
+
type_reward_timing: z15.enum(["exact_birthday", "begin_of_month", "around_birthday"]).optional(),
|
|
2821
|
+
limit_unit: z15.string().optional(),
|
|
2822
|
+
limit_interval: z15.number().int().positive().optional(),
|
|
2823
|
+
url_account: z15.string().regex(/^https?:\/\//, "url_account must start with http:// or https://").optional().describe("REQUIRED when creating a social rule (follow_instagram, subscribe_youtube, like_facebook, follow_tiktok, follow_twitter, follow_pinterest, join_discord, join_telegram, join_reddit). The full URL of the shop's profile on that network \u2014 customers are sent here to complete the action."),
|
|
2824
|
+
review_app: z15.enum(REVIEW_APPS).optional().describe("REQUIRED when creating a write_review rule: the review app the shop actually runs. Points cannot be awarded for reviews unless this matches the installed app."),
|
|
2825
|
+
description_review: z15.string().optional().describe("Required only when review_app is a Shopify Flow app (stampedApp, looxApp, okendoApp, ReviewsIoApp, growaveApp, typdalApp) \u2014 the merchant-facing note describing how the Flow awards points."),
|
|
2826
|
+
extra_points_for_images: z15.number().int().nonnegative().optional().describe("Bonus points for a review with a photo. Only reviewRocketApp and looxApp report photos; ignored for every other review app."),
|
|
2827
|
+
extra_points_for_videos: z15.number().int().nonnegative().optional().describe("Bonus points for a review with a video. Only reviewRocketApp and looxApp report videos; ignored for every other review app."),
|
|
2828
|
+
spend_point: z15.number().int().positive().optional(),
|
|
2829
|
+
earn_amount: z15.number().optional(),
|
|
2830
|
+
shipping_discount_type: z15.enum(["percentage", "fixedAmount"]).optional(),
|
|
2831
|
+
shipping_discount_amount: z15.number().optional(),
|
|
2832
|
+
applied_to: z15.enum(FREE_GIFT_PROGRAM_APPLIED_TO).optional().describe("free_gift only \u2014 sf_product, specific, or exclude_collection. This is a program-level enum and differs from tier rewards; it has no all value."),
|
|
2833
|
+
specific_products: z15.array(z15.unknown()).optional().describe("free_gift with applied_to sf_product only \u2014 the merchant-provided product records/IDs. Do not invent product IDs."),
|
|
2834
|
+
specific_collections: z15.array(z15.unknown()).optional(),
|
|
2835
|
+
applied_collection_ids: z15.array(z15.string()).optional().describe("free_gift with applied_to specific or exclude_collection only \u2014 merchant-provided collection IDs."),
|
|
2836
|
+
collection_quantity: z15.number().int().positive().optional().describe("Required for collection-based free_gift rules (applied_to specific or exclude_collection)."),
|
|
2837
|
+
product_url: z15.array(z15.string()).optional().describe("free_gift with applied_to sf_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
|
|
2838
|
+
applied_place_order_to: z15.enum(["all", "vip-tier", "member_and_guest"]).optional().describe('place_order "Who to reward" radio: all = All loyalty members, vip-tier = VIP Tier only, member_and_guest = All customers. The admin UI and the earning engine both key off this field, not is_applied_vip_tier. Enabling is_applied_vip_tier or earn_points_tiers FORCES vip-tier \u2014 sending all here is why per-tier rates can save while the radio stays on All loyalty members.'),
|
|
2839
|
+
is_applied_vip_tier: z15.boolean().optional().describe("Turn per-VIP-tier earning on for this rule. MUST be true for earn_points_tiers to have any effect \u2014 the backend reads the per-tier map only when this flag is set, and otherwise silently uses the flat earn_point."),
|
|
2840
|
+
earn_points_tiers: z15.record(z15.object({
|
|
2841
|
+
earn_point: z15.number().optional().describe("Points this tier earns, replacing the flat earn_point."),
|
|
2842
|
+
rate_money: z15.number().optional().describe("place_order only: money amount that earns earn_point for this tier."),
|
|
2843
|
+
type_earn: z15.enum(["point", "store_credit"]).optional()
|
|
2523
2844
|
})).optional().describe("Per-VIP-tier earning, keyed by TIER ID (from joy_list_tiers) \u2014 not tier name. For place_order a \xD71.25 multiplier on a 1-point-per-$1 base is earn_point 1.25, not 1.25 as a factor: the value REPLACES the base rate. Requires is_applied_vip_tier: true."),
|
|
2524
|
-
reason:
|
|
2525
|
-
apply:
|
|
2845
|
+
reason: z15.string().min(3).describe("Why the merchant wants this configuration change."),
|
|
2846
|
+
apply: z15.boolean().optional().describe("Set true only after reviewing this tool's dry-run preview.")
|
|
2526
2847
|
};
|
|
2527
2848
|
var PLACE_ORDER_EVENTS = /* @__PURE__ */ new Set(["place_order", "place_order_subscription"]);
|
|
2528
2849
|
function placeOrderVipTierPatch(event, args) {
|
|
@@ -2531,9 +2852,7 @@ function placeOrderVipTierPatch(event, args) {
|
|
|
2531
2852
|
return {};
|
|
2532
2853
|
if (!PLACE_ORDER_EVENTS.has(String(event)))
|
|
2533
2854
|
return {};
|
|
2534
|
-
|
|
2535
|
-
return {};
|
|
2536
|
-
return { appliedPlaceOrderTo: "vip-tier" };
|
|
2855
|
+
return { appliedPlaceOrderTo: "vip-tier", skipEarnPointGuest: true };
|
|
2537
2856
|
}
|
|
2538
2857
|
function toIntegerRatio(earnPoint, rateMoney) {
|
|
2539
2858
|
const isWhole = (value) => Math.abs(value - Math.round(value)) < 1e-9;
|
|
@@ -2626,11 +2945,14 @@ async function executeProgramChange(ctx, kind, args, toChange) {
|
|
|
2626
2945
|
if (stop2)
|
|
2627
2946
|
return stop2;
|
|
2628
2947
|
await ctx.api.mutateData(`/programs/${prepared.programId}`, { method: "PUT", body: prepared.body });
|
|
2629
|
-
if (resolvedArgs.earn_points_tiers) {
|
|
2948
|
+
if (resolvedArgs.earn_points_tiers || prepared.body.appliedPlaceOrderTo === "vip-tier") {
|
|
2630
2949
|
const { data: after } = await ctx.api.getData(`/programs/${prepared.programId}`);
|
|
2631
2950
|
if (earnPointsTiersDropped(resolvedArgs.earn_points_tiers, after?.earnPointsTiers)) {
|
|
2632
2951
|
return `Every other field saved, but earnPointsTiers did NOT: the backend stored an empty map. REST v2 declares earnPointsTiers as a shapeless Yup.object() and validates with stripUnknown, which strips every tier key. Per-VIP-tier earning cannot currently be set through this API by any caller \u2014 configure it in Joy admin, and report this so the schema gets a real shape. Do not retry; the result will be the same.`;
|
|
2633
2952
|
}
|
|
2953
|
+
if (prepared.body.appliedPlaceOrderTo === "vip-tier" && after?.appliedPlaceOrderTo !== "vip-tier") {
|
|
2954
|
+
return `Per-tier rates may have saved, but Who to reward did not: appliedPlaceOrderTo is still "${String(after?.appliedPlaceOrderTo ?? "unset")}" (All loyalty members), not vip-tier. The admin radio and the earning engine both read this field. Do not report VIP Tier only as applied; verify the place_order program in Joy admin.`;
|
|
2955
|
+
}
|
|
2634
2956
|
}
|
|
2635
2957
|
return `Updated ${kind} program ${prepared.programId}. This affects every customer immediately.`;
|
|
2636
2958
|
}
|
|
@@ -2649,7 +2971,7 @@ function registerProgramConfigTools(server, ctx) {
|
|
|
2649
2971
|
registerConfigWriteTool(server, ctx, {
|
|
2650
2972
|
name: "joy_configure_earning_program",
|
|
2651
2973
|
title: "Create or update an earning program",
|
|
2652
|
-
description: "WRITE \u2014 changes earning behaviour for every customer immediately. Creates place_order, sign_up, birthday, interact_website, write_review, and the nine social-follow rules (Instagram, YouTube, TikTok, Facebook, X/Twitter, Pinterest, Discord, Telegram, Reddit); social rules need url_account and write_review needs review_app. Use program_id to update ANY existing earning rule, including types this tool cannot create. Previews by default. Joy has no safe delete here: set status: false to reversibly disable a rule instead.",
|
|
2974
|
+
description: "WRITE \u2014 changes earning behaviour for every customer immediately. Creates place_order, sign_up, birthday, interact_website, write_review, and the nine social-follow rules (Instagram, YouTube, TikTok, Facebook, X/Twitter, Pinterest, Discord, Telegram, Reddit); social rules need url_account and write_review needs review_app. For place_order, is_applied_vip_tier / earn_points_tiers also sets Who to reward (appliedPlaceOrderTo) to vip-tier \u2014 the admin radio is that field, not the per-tier map. Use program_id to update ANY existing earning rule, including types this tool cannot create. Previews by default. Joy has no safe delete here: set status: false to reversibly disable a rule instead.",
|
|
2653
2975
|
inputSchema: programConfigInputSchema,
|
|
2654
2976
|
destructiveHint: true,
|
|
2655
2977
|
handler: (args) => executeProgramChange(ctx, "earning", args, toEarningProgramChange)
|
|
@@ -2665,8 +2987,10 @@ function registerProgramConfigTools(server, ctx) {
|
|
|
2665
2987
|
}
|
|
2666
2988
|
|
|
2667
2989
|
// ../mcp-core/build/tools/tiersWrite.js
|
|
2668
|
-
import { z as
|
|
2990
|
+
import { z as z16 } from "zod";
|
|
2669
2991
|
var TIER_THRESHOLD_WARNING = "Threshold changes are STAGED, not live. Members are not re-evaluated until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that.";
|
|
2992
|
+
var ENTRY_METHOD_WARNING = "Level-up rule (entryMethod) is saved immediately. NEW points, spend, and orders start counting in the new unit at once. Existing members keep their current tierPoint until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that. Review each tier threshold (target_point): 0/150/350 means points, currency, or orders depending on this rule.";
|
|
2993
|
+
var START_DATE_WARNING = "Program start date (startDate) is saved immediately and is the calculation window for NEW activity. Existing members keep their current tierPoint until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that. This is the VIP program start date, not reset_start_date.";
|
|
2670
2994
|
var RESET_SCHEDULE_FIELDS = [
|
|
2671
2995
|
"useResetTier",
|
|
2672
2996
|
"resetFrequency",
|
|
@@ -2728,6 +3052,12 @@ var TIER_REWARD_FIELDS = [
|
|
|
2728
3052
|
];
|
|
2729
3053
|
var TIER_PERK_FIELDS = ["contentPerk"];
|
|
2730
3054
|
var TIER_SETTINGS_FIELDS = [
|
|
3055
|
+
"entryMethod",
|
|
3056
|
+
"startDate",
|
|
3057
|
+
"prefix",
|
|
3058
|
+
"isUsePrefixDiscountCode",
|
|
3059
|
+
"showTierPointSettings",
|
|
3060
|
+
"allowGiveEntryRewardAgain",
|
|
2731
3061
|
"useDemotionTier",
|
|
2732
3062
|
"typeDemotion",
|
|
2733
3063
|
"downgradeAfter",
|
|
@@ -2746,7 +3076,7 @@ var TIER_SETTINGS_FIELDS = [
|
|
|
2746
3076
|
"notificationDowngradeBefore",
|
|
2747
3077
|
"notificationDowngradeBeforeUnit"
|
|
2748
3078
|
];
|
|
2749
|
-
var rewardEventSchema =
|
|
3079
|
+
var rewardEventSchema = z16.enum([
|
|
2750
3080
|
"amount_discount",
|
|
2751
3081
|
"percentage_discount",
|
|
2752
3082
|
"free_shipping",
|
|
@@ -2754,95 +3084,101 @@ var rewardEventSchema = z14.enum([
|
|
|
2754
3084
|
"bonus_point",
|
|
2755
3085
|
"buy_x_get_y"
|
|
2756
3086
|
]);
|
|
2757
|
-
var finiteNumber = () =>
|
|
2758
|
-
var tierRewardInputSchema =
|
|
3087
|
+
var finiteNumber = () => z16.number().finite();
|
|
3088
|
+
var tierRewardInputSchema = z16.object({
|
|
2759
3089
|
event: rewardEventSchema.describe("Required reward kind."),
|
|
2760
|
-
title:
|
|
2761
|
-
type:
|
|
2762
|
-
status:
|
|
3090
|
+
title: z16.string().min(1).optional(),
|
|
3091
|
+
type: z16.enum(["tier_spending", "tier_privileges", "pre_tier_reward"]).optional(),
|
|
3092
|
+
status: z16.boolean().optional(),
|
|
2763
3093
|
earn_amount: finiteNumber().optional(),
|
|
2764
3094
|
spend_point: finiteNumber().optional(),
|
|
2765
3095
|
bonus_points: finiteNumber().optional(),
|
|
2766
|
-
code_name:
|
|
2767
|
-
redeem_type:
|
|
2768
|
-
applied_to:
|
|
2769
|
-
order_req:
|
|
3096
|
+
code_name: z16.string().optional(),
|
|
3097
|
+
redeem_type: z16.string().optional(),
|
|
3098
|
+
applied_to: z16.enum(["all", "specific_product", "specific"]).optional().describe("Tier-reward scope only: all, specific_product, or specific. These literals differ from program-level reward tools."),
|
|
3099
|
+
order_req: z16.enum(["none", "min_amount", "min_quantity"]).optional(),
|
|
2770
3100
|
order_req_amount: finiteNumber().optional(),
|
|
2771
3101
|
min_spend_point: finiteNumber().optional(),
|
|
2772
3102
|
max_spend_point: finiteNumber().optional(),
|
|
2773
|
-
specific_products:
|
|
2774
|
-
specific_collections:
|
|
2775
|
-
applied_collection_ids:
|
|
2776
|
-
combined_with:
|
|
2777
|
-
has_limit_shipping:
|
|
3103
|
+
specific_products: z16.array(z16.unknown()).optional(),
|
|
3104
|
+
specific_collections: z16.array(z16.unknown()).optional(),
|
|
3105
|
+
applied_collection_ids: z16.array(z16.unknown()).optional(),
|
|
3106
|
+
combined_with: z16.array(z16.enum(["orderDiscounts", "shippingDiscounts", "productDiscounts"])).optional(),
|
|
3107
|
+
has_limit_shipping: z16.boolean().optional(),
|
|
2778
3108
|
limit_shipping: finiteNumber().optional(),
|
|
2779
3109
|
expired_after: finiteNumber().optional(),
|
|
2780
|
-
expired_unit:
|
|
2781
|
-
is_refund_coupon:
|
|
3110
|
+
expired_unit: z16.enum(["day", "month"]).optional(),
|
|
3111
|
+
is_refund_coupon: z16.boolean().optional(),
|
|
2782
3112
|
priority: finiteNumber().optional(),
|
|
2783
|
-
show_loyalty_page:
|
|
2784
|
-
perk_title_language:
|
|
2785
|
-
reward_product_selection_mode:
|
|
3113
|
+
show_loyalty_page: z16.boolean().optional(),
|
|
3114
|
+
perk_title_language: z16.record(z16.unknown()).optional(),
|
|
3115
|
+
reward_product_selection_mode: z16.string().optional(),
|
|
2786
3116
|
reward_product_quantity: finiteNumber().optional()
|
|
2787
3117
|
});
|
|
2788
3118
|
var tierConfigInputSchema = {
|
|
2789
|
-
tier_id:
|
|
2790
|
-
name:
|
|
3119
|
+
tier_id: z16.string().min(1).optional().describe("Existing tier ID. Omit to create a tier."),
|
|
3120
|
+
name: z16.string().min(1).optional(),
|
|
2791
3121
|
target_point: finiteNumber().nonnegative().optional().describe("Tier threshold. On an existing tier this is staged; it does not immediately re-evaluate members."),
|
|
2792
|
-
icon:
|
|
2793
|
-
icon_custom:
|
|
2794
|
-
bg_image:
|
|
2795
|
-
default_avatar:
|
|
2796
|
-
image_block:
|
|
2797
|
-
is_exclusive_tier:
|
|
2798
|
-
hide_default_tier:
|
|
2799
|
-
text_color:
|
|
2800
|
-
progress_bar_color:
|
|
2801
|
-
box_bg_color:
|
|
2802
|
-
box_text_color:
|
|
2803
|
-
bg_card_color:
|
|
3122
|
+
icon: z16.string().optional(),
|
|
3123
|
+
icon_custom: z16.string().optional(),
|
|
3124
|
+
bg_image: z16.string().optional().describe("Membership card background image URL for this ONE tier. Pass bg_type: 'image' alongside bg_image for it to show, or '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
|
|
3125
|
+
default_avatar: z16.string().optional().describe("Membership card avatar image URL for members in this ONE tier. Pass '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
|
|
3126
|
+
image_block: z16.string().optional(),
|
|
3127
|
+
is_exclusive_tier: z16.boolean().optional(),
|
|
3128
|
+
hide_default_tier: z16.boolean().optional(),
|
|
3129
|
+
text_color: z16.string().optional(),
|
|
3130
|
+
progress_bar_color: z16.string().optional(),
|
|
3131
|
+
box_bg_color: z16.string().optional(),
|
|
3132
|
+
box_text_color: z16.string().optional(),
|
|
3133
|
+
bg_card_color: z16.string().optional(),
|
|
2804
3134
|
card_opacity: finiteNumber().optional(),
|
|
2805
|
-
icon_color:
|
|
2806
|
-
bg_type:
|
|
2807
|
-
tier_names_language:
|
|
2808
|
-
rewards:
|
|
2809
|
-
reason:
|
|
2810
|
-
apply:
|
|
3135
|
+
icon_color: z16.string().optional(),
|
|
3136
|
+
bg_type: z16.string().optional(),
|
|
3137
|
+
tier_names_language: z16.record(z16.unknown()).optional(),
|
|
3138
|
+
rewards: z16.array(tierRewardInputSchema).optional().describe("Optional rewards to create with a NEW tier in this one configuration write."),
|
|
3139
|
+
reason: z16.string().min(3).describe("Why the merchant wants this tier configuration."),
|
|
3140
|
+
apply: z16.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2811
3141
|
};
|
|
2812
3142
|
var tierRewardToolInputSchema = {
|
|
2813
|
-
tier_id:
|
|
2814
|
-
reward_id:
|
|
3143
|
+
tier_id: z16.string().min(1).describe("Tier ID from joy_list_tiers."),
|
|
3144
|
+
reward_id: z16.string().min(1).optional().describe("Existing reward ID. Omit to create."),
|
|
2815
3145
|
...tierRewardInputSchema.shape,
|
|
2816
|
-
product_url:
|
|
2817
|
-
reason:
|
|
2818
|
-
apply:
|
|
3146
|
+
product_url: z16.array(z16.string()).optional().describe("free_gift with applied_to specific_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
|
|
3147
|
+
reason: z16.string().min(3).describe("Why the merchant wants this reward configuration."),
|
|
3148
|
+
apply: z16.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2819
3149
|
};
|
|
2820
3150
|
var tierSettingsInputSchema = {
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
3151
|
+
entry_method: z16.enum(["pointEarned", "moneySpent", "numberOfOrders"]).optional().describe("Tier calculation / level-up rule: how members climb the ladder. pointEarned = points earned, moneySpent = amount spent, numberOfOrders = number of orders. Saved immediately \u2014 NEW activity counts in the new unit at once. Existing members keep their current tierPoint until the merchant relaunches in Joy admin; this tool cannot trigger that. After changing, review each tier's target_point: the same 0/150/350 numbers mean points, currency, or orders depending on this rule."),
|
|
3152
|
+
start_date: z16.string().min(1).optional().describe('VIP program start date \u2014 the calculation window in the admin "Tier calculation" card (ISO date, YYYY-MM-DD). Distinct from reset_start_date, which is the custom reset-cycle anchor. Saved immediately; members keep their current tierPoint until the merchant relaunches in Joy admin.'),
|
|
3153
|
+
prefix: z16.string().optional().describe("Custom discount-code prefix for tier rewards. Supports {{tier_name}}. Pair with use_prefix_discount_code: true, or the custom prefix is stored but unused."),
|
|
3154
|
+
use_prefix_discount_code: z16.boolean().optional().describe("When true, tier reward codes use prefix instead of the shop default."),
|
|
3155
|
+
show_tier_point_settings: z16.boolean().optional().describe("Show tier-point editing on the customer admin page."),
|
|
3156
|
+
allow_give_entry_reward_again: z16.boolean().optional().describe("When true (Joy default), a member who is demoted and later re-enters a tier can receive that tier entry reward again. False grants it only once."),
|
|
3157
|
+
use_demotion_tier: z16.boolean().optional(),
|
|
3158
|
+
type_demotion: z16.enum(["downgrade_tier", "reset_tier"]).optional(),
|
|
3159
|
+
downgrade_after: z16.number().int().positive().optional(),
|
|
3160
|
+
downgrade_after_unit: z16.enum(["day", "month", "year"]).optional(),
|
|
3161
|
+
use_reset_tier: z16.boolean().optional(),
|
|
3162
|
+
reset_after: z16.number().int().positive().optional(),
|
|
3163
|
+
reset_after_unit: z16.enum(["day", "month", "year"]).optional(),
|
|
3164
|
+
reset_frequency: z16.enum(["monthly", "quarterly", "half_yearly", "yearly", "custom"]).optional(),
|
|
3165
|
+
reset_interval_value: z16.number().int().positive().optional(),
|
|
3166
|
+
reset_interval_unit: z16.enum(["day", "month", "year"]).optional(),
|
|
3167
|
+
reset_start_date: z16.string().min(1).optional(),
|
|
3168
|
+
month_reset_tier: z16.number().int().min(1).max(12).optional(),
|
|
3169
|
+
day_reset_tier: z16.number().int().min(1).max(31).optional(),
|
|
3170
|
+
is_sync_tags_customer_tier: z16.boolean().optional(),
|
|
3171
|
+
customer_tier_tag_prefix: z16.string().optional(),
|
|
3172
|
+
notification_downgrade_before: z16.number().int().positive().optional(),
|
|
3173
|
+
notification_downgrade_before_unit: z16.enum(["day", "month"]).optional(),
|
|
3174
|
+
reason: z16.string().min(3).describe("Why the merchant wants these tier settings."),
|
|
3175
|
+
apply: z16.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2840
3176
|
};
|
|
2841
3177
|
var tierPerksInputSchema = {
|
|
2842
|
-
tier_id:
|
|
2843
|
-
perks:
|
|
2844
|
-
reason:
|
|
2845
|
-
apply:
|
|
3178
|
+
tier_id: z16.string().min(1).describe("Tier ID from joy_list_tiers."),
|
|
3179
|
+
perks: z16.array(z16.string().min(1)).describe("The COMPLETE replacement list of display-only perks. Include every existing perk that should remain; omitted entries are removed, and [] clears the list. Each entry is perk text or an http(s) URL rendered as a tick/cross icon in the storefront benefits table."),
|
|
3180
|
+
reason: z16.string().min(3).describe("Why the merchant wants to replace this tier perk list."),
|
|
3181
|
+
apply: z16.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2846
3182
|
};
|
|
2847
3183
|
function tierFreeGiftSelectionError(args, requireAppliedTo = false) {
|
|
2848
3184
|
if (args.event !== "free_gift")
|
|
@@ -3138,6 +3474,12 @@ async function configureTierPerks(ctx, args) {
|
|
|
3138
3474
|
}
|
|
3139
3475
|
function toTierSettingsBody(args) {
|
|
3140
3476
|
return omitUndefined({
|
|
3477
|
+
entryMethod: args.entry_method,
|
|
3478
|
+
startDate: args.start_date,
|
|
3479
|
+
prefix: args.prefix,
|
|
3480
|
+
isUsePrefixDiscountCode: args.use_prefix_discount_code,
|
|
3481
|
+
showTierPointSettings: args.show_tier_point_settings,
|
|
3482
|
+
allowGiveEntryRewardAgain: args.allow_give_entry_reward_again,
|
|
3141
3483
|
useDemotionTier: args.use_demotion_tier,
|
|
3142
3484
|
typeDemotion: args.type_demotion,
|
|
3143
3485
|
downgradeAfter: args.downgrade_after === void 0 ? void 0 : String(args.downgrade_after),
|
|
@@ -3163,6 +3505,11 @@ function touchesResetSchedule(body) {
|
|
|
3163
3505
|
function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date()) {
|
|
3164
3506
|
const body = toTierSettingsBody(args);
|
|
3165
3507
|
const changes = diffFields(current, body);
|
|
3508
|
+
const calculationWarnings = [
|
|
3509
|
+
...changes.some((change) => change.field === "entryMethod") ? [ENTRY_METHOD_WARNING] : [],
|
|
3510
|
+
...changes.some((change) => change.field === "startDate") ? [START_DATE_WARNING] : []
|
|
3511
|
+
];
|
|
3512
|
+
const entryMethodWarning = calculationWarnings.length > 0 ? calculationWarnings : void 0;
|
|
3166
3513
|
if (!touchesResetSchedule(body)) {
|
|
3167
3514
|
return {
|
|
3168
3515
|
kind: "preview",
|
|
@@ -3172,7 +3519,8 @@ function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date(
|
|
|
3172
3519
|
target: "tier settings",
|
|
3173
3520
|
reason: args.reason,
|
|
3174
3521
|
changes,
|
|
3175
|
-
dropped: droppedFields(body, TIER_SETTINGS_FIELDS)
|
|
3522
|
+
dropped: droppedFields(body, TIER_SETTINGS_FIELDS),
|
|
3523
|
+
warnings: entryMethodWarning
|
|
3176
3524
|
})
|
|
3177
3525
|
};
|
|
3178
3526
|
}
|
|
@@ -3202,7 +3550,7 @@ function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date(
|
|
|
3202
3550
|
reason: args.reason,
|
|
3203
3551
|
changes,
|
|
3204
3552
|
dropped: droppedFields(body, TIER_SETTINGS_FIELDS),
|
|
3205
|
-
warnings: [scheduleWarning]
|
|
3553
|
+
warnings: entryMethodWarning ? [scheduleWarning, ...entryMethodWarning] : [scheduleWarning]
|
|
3206
3554
|
})
|
|
3207
3555
|
};
|
|
3208
3556
|
}
|
|
@@ -3220,8 +3568,19 @@ async function configureTierSettings(ctx, args) {
|
|
|
3220
3568
|
timeoutMs: 15e3,
|
|
3221
3569
|
body: decision.body
|
|
3222
3570
|
});
|
|
3571
|
+
const unconfirmed = unconfirmedSettingsFields(decision.body, updated ?? {});
|
|
3572
|
+
if (unconfirmed.length > 0) {
|
|
3573
|
+
throw new JoyApiError("server", `The tier-settings write was not confirmed for: ${unconfirmed.join(", ")}. Do not report those fields as changed; verify them in Joy admin.`);
|
|
3574
|
+
}
|
|
3223
3575
|
const schedule = touchesResetSchedule(decision.body) ? ` Projected scheduleResetTierAt: ${formatSchedule(decision.projectedScheduleResetTierAt)}.` : "";
|
|
3224
|
-
|
|
3576
|
+
const calculationChanged = decision.body.entryMethod !== void 0 && !sameSettingValue("entryMethod", current.entryMethod, decision.body.entryMethod) || decision.body.startDate !== void 0 && !sameSettingValue("startDate", current.startDate, decision.body.startDate);
|
|
3577
|
+
if (calculationChanged)
|
|
3578
|
+
ctx.cache?.delete("tiers");
|
|
3579
|
+
const calculationNote = [
|
|
3580
|
+
decision.body.entryMethod !== void 0 && !sameSettingValue("entryMethod", current.entryMethod, decision.body.entryMethod) ? ENTRY_METHOD_WARNING : "",
|
|
3581
|
+
decision.body.startDate !== void 0 && !sameSettingValue("startDate", current.startDate, decision.body.startDate) ? START_DATE_WARNING : ""
|
|
3582
|
+
].filter(Boolean).map((warning) => ` ${warning}`).join("");
|
|
3583
|
+
return summaryWithJson(`Updated tier settings.${schedule}${calculationNote}`, compact(updated, [...TIER_SETTINGS_FIELDS, "id"]));
|
|
3225
3584
|
} catch (error) {
|
|
3226
3585
|
rethrowTierSettingsNotLaunched(error);
|
|
3227
3586
|
}
|
|
@@ -3308,8 +3667,8 @@ function registerTierConfigTools(server, ctx) {
|
|
|
3308
3667
|
});
|
|
3309
3668
|
registerConfigWriteTool(server, ctx, {
|
|
3310
3669
|
name: "joy_configure_tier_settings",
|
|
3311
|
-
title: "Configure VIP tier demotion and reset settings",
|
|
3312
|
-
description:
|
|
3670
|
+
title: "Configure VIP tier calculation, demotion and reset settings",
|
|
3671
|
+
description: 'CONFIGURATION WRITE \u2014 the admin "Tier calculation" card plus demotion/reset. Covers entry_method (pointEarned / moneySpent / numberOfOrders), start_date (program calculation window \u2014 not reset_start_date), prefix / use_prefix_discount_code, show_tier_point_settings, allow_give_entry_reward_again, demotion, reset, customer-tag, and downgrade-notification settings. Changing entry_method or start_date saves immediately and NEW activity uses the new rule/window; existing members keep their current tierPoint until the merchant relaunches in Joy admin \u2014 this tool cannot trigger that. Any reset-schedule change previews the current and projected scheduleResetTierAt before it may apply, because that daily-cron anchor affects every tier member.',
|
|
3313
3672
|
inputSchema: tierSettingsInputSchema,
|
|
3314
3673
|
destructiveHint: true,
|
|
3315
3674
|
handler: (args) => configureTierSettings(ctx, args)
|
|
@@ -3324,6 +3683,15 @@ function isData(value) {
|
|
|
3324
3683
|
function sameValue(a, b) {
|
|
3325
3684
|
return a === b || String(a) === String(b);
|
|
3326
3685
|
}
|
|
3686
|
+
function sameSettingValue(field, a, b) {
|
|
3687
|
+
if (field === "startDate" || field === "resetStartDate") {
|
|
3688
|
+
return String(a ?? "").slice(0, 10) === String(b ?? "").slice(0, 10);
|
|
3689
|
+
}
|
|
3690
|
+
return sameValue(a, b);
|
|
3691
|
+
}
|
|
3692
|
+
function unconfirmedSettingsFields(body, updated) {
|
|
3693
|
+
return Object.keys(body).filter((field) => !sameSettingValue(field, body[field], updated[field]));
|
|
3694
|
+
}
|
|
3327
3695
|
function formatSchedule(value) {
|
|
3328
3696
|
if (value === void 0)
|
|
3329
3697
|
return "(not exposed by REST v2)";
|
|
@@ -3419,7 +3787,7 @@ function isAfterOrEqual(candidate, cursor, inclusive) {
|
|
|
3419
3787
|
}
|
|
3420
3788
|
|
|
3421
3789
|
// ../mcp-core/build/tools/shopConfig.js
|
|
3422
|
-
import { z as
|
|
3790
|
+
import { z as z17 } from "zod";
|
|
3423
3791
|
var SHOP_SETTINGS_FIELDS = [
|
|
3424
3792
|
"primaryLanguageInApp",
|
|
3425
3793
|
"dateFormat",
|
|
@@ -3562,20 +3930,20 @@ function registerShopConfigTools(server, ctx) {
|
|
|
3562
3930
|
title: "Configure safe shop-level loyalty settings",
|
|
3563
3931
|
description: "WRITE \u2014 updates a deliberately limited set of shop-level loyalty settings. The backend splits this request between the shops collection and a separate settings document without atomic rollback, so a partial failure is possible. SSO controls, tier-specific earning limits, and weekly-report BCC recipients are intentionally unavailable through this tool.",
|
|
3564
3932
|
inputSchema: {
|
|
3565
|
-
primary_language_in_app:
|
|
3566
|
-
date_format:
|
|
3567
|
-
date_format_for_activity:
|
|
3568
|
-
point_position:
|
|
3569
|
-
customer_apply_discount_behavior:
|
|
3570
|
-
customer_redeem_coupon_behavior:
|
|
3571
|
-
customer_free_gift_behavior:
|
|
3572
|
-
status_for_review:
|
|
3573
|
-
earn_by_order_status:
|
|
3574
|
-
enable_global_earning_limit:
|
|
3575
|
-
global_earning_limit_timeframe:
|
|
3576
|
-
global_earning_limit_rolling_days:
|
|
3577
|
-
reason:
|
|
3578
|
-
apply:
|
|
3933
|
+
primary_language_in_app: z17.enum(["en", "en-US", "fr", "es", "de", "it", "ja", "id", "zh-CN"]).optional(),
|
|
3934
|
+
date_format: z17.enum(["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD", "YYYY-MM-DD"]).optional(),
|
|
3935
|
+
date_format_for_activity: z17.enum(["HH:mm", "hh:mm A"]).optional(),
|
|
3936
|
+
point_position: z17.enum(["before", "after"]).optional(),
|
|
3937
|
+
customer_apply_discount_behavior: z17.enum(["stay_current", "go_to_cart", "open_cart_drawer"]).optional(),
|
|
3938
|
+
customer_redeem_coupon_behavior: z17.enum(["auto_apply", "save_for_later"]).optional(),
|
|
3939
|
+
customer_free_gift_behavior: z17.enum(["manual_add_to_cart", "auto_add_to_cart"]).optional(),
|
|
3940
|
+
status_for_review: z17.enum(["pending", "approveAuto"]).optional(),
|
|
3941
|
+
earn_by_order_status: z17.enum(["financial_status", "fulfillment_status"]).optional(),
|
|
3942
|
+
enable_global_earning_limit: z17.boolean().optional(),
|
|
3943
|
+
global_earning_limit_timeframe: z17.enum(["yearly", "monthly", "rolling"]).optional(),
|
|
3944
|
+
global_earning_limit_rolling_days: z17.number().int().min(1).max(365).optional(),
|
|
3945
|
+
reason: z17.string().min(3).describe("Why the merchant wants these shop-wide settings changed."),
|
|
3946
|
+
apply: z17.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
3579
3947
|
},
|
|
3580
3948
|
destructiveHint: false,
|
|
3581
3949
|
strictArguments: true,
|
|
@@ -3597,10 +3965,10 @@ function registerShopConfigTools(server, ctx) {
|
|
|
3597
3965
|
title: "Add a storefront translation language",
|
|
3598
3966
|
description: "WRITE \u2014 adds one storefront language by machine-translating the shop's entire existing primary-language content. This can be expensive and is hard to undo, so it always requires a dry-run preview and explicit approval before applying.",
|
|
3599
3967
|
inputSchema: {
|
|
3600
|
-
primary_language:
|
|
3601
|
-
additional_language:
|
|
3602
|
-
reason:
|
|
3603
|
-
apply:
|
|
3968
|
+
primary_language: z17.enum(TRANSLATION_LOCALES),
|
|
3969
|
+
additional_language: z17.enum(TRANSLATION_LOCALES),
|
|
3970
|
+
reason: z17.string().min(3).describe("Why the merchant wants this storefront language added."),
|
|
3971
|
+
apply: z17.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
3604
3972
|
},
|
|
3605
3973
|
destructiveHint: false,
|
|
3606
3974
|
strictArguments: true,
|
|
@@ -3619,7 +3987,7 @@ function registerShopConfigTools(server, ctx) {
|
|
|
3619
3987
|
}
|
|
3620
3988
|
|
|
3621
3989
|
// ../mcp-core/build/tools/brandingWrite.js
|
|
3622
|
-
import { z as
|
|
3990
|
+
import { z as z18 } from "zod";
|
|
3623
3991
|
var WIDGET_DRAFT_SAVED_MESSAGE = "Saved to the V4 draft. The storefront is unchanged until you publish it in Joy admin.";
|
|
3624
3992
|
var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
3625
3993
|
var COLOR_ROLE_PATHS = {
|
|
@@ -3662,26 +4030,26 @@ var BUTTON_STYLE_VALUES = ["store", "preset", "custom"];
|
|
|
3662
4030
|
var CONTENT_STYLE_VALUES = ["icons_and_text", "text_only"];
|
|
3663
4031
|
var DISPLAY_TYPE_VALUES = ["widget", "drawer", "floating-drawer"];
|
|
3664
4032
|
var widgetDraftInputSchema = {
|
|
3665
|
-
version:
|
|
3666
|
-
target:
|
|
3667
|
-
apply_scan:
|
|
3668
|
-
scan_url:
|
|
3669
|
-
storefront_password:
|
|
3670
|
-
apply_images:
|
|
3671
|
-
color_overrides:
|
|
3672
|
-
design_overrides:
|
|
3673
|
-
corner_radius:
|
|
3674
|
-
button_style:
|
|
3675
|
-
content_style:
|
|
3676
|
-
display_type:
|
|
4033
|
+
version: z18.enum(["v3", "v4"]).describe("Widget version to change. A live target is accepted only when this is the shop\u2019s currently live version."),
|
|
4034
|
+
target: z18.enum(["draft", "live"]).default("draft").describe("Draft by default. Set live only after the merchant approves a dry-run preview of the immediate storefront change."),
|
|
4035
|
+
apply_scan: z18.boolean().optional().describe("Apply a storefront scan through Joy\u2019s shared backend design merge. Omit scan_url to scan this shop\u2019s own storefront."),
|
|
4036
|
+
scan_url: z18.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL to scan when apply_scan is true."),
|
|
4037
|
+
storefront_password: z18.string().min(1).optional().describe("Optional storefront password used only when apply_scan is true. It is sent only in the PUT body and is never returned."),
|
|
4038
|
+
apply_images: z18.boolean().optional().describe("When apply_scan is true, also replace detected logo and card imagery. Defaults to false."),
|
|
4039
|
+
color_overrides: z18.record(z18.string()).optional().describe(`Optional hex overrides for supported V4 color roles: ${COLOR_ROLES.join(", ")}.`),
|
|
4040
|
+
design_overrides: z18.object({
|
|
4041
|
+
corner_radius: z18.enum(CORNER_RADIUS_VALUES).optional().describe("Corner style. radiusNone is square/not rounded; radiusExtra is the most rounded."),
|
|
4042
|
+
button_style: z18.enum(BUTTON_STYLE_VALUES).optional().describe("store matches the storefront, custom uses the widget\u2019s own button colours."),
|
|
4043
|
+
content_style: z18.enum(CONTENT_STYLE_VALUES).optional().describe("text_only removes the icons beside earn/redeem rows; icons_and_text shows them."),
|
|
4044
|
+
display_type: z18.enum(DISPLAY_TYPE_VALUES).optional().describe("widget is the inline panel; drawer slides in from the edge; floating-drawer is the floating panel with a launcher button.")
|
|
3677
4045
|
}).optional().describe("Optional non-colour design settings. Values are enums, not numbers or CSS lengths."),
|
|
3678
|
-
font_overrides:
|
|
3679
|
-
reason:
|
|
3680
|
-
apply:
|
|
4046
|
+
font_overrides: z18.object({ heading: z18.string().min(1).optional(), body: z18.string().min(1).optional() }).optional().describe("Optional heading and body font overrides."),
|
|
4047
|
+
reason: z18.string().min(3).describe("Why the merchant wants this widget draft."),
|
|
4048
|
+
apply: z18.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
|
|
3681
4049
|
};
|
|
3682
4050
|
var brandScanInputSchema = {
|
|
3683
|
-
url:
|
|
3684
|
-
storefront_password:
|
|
4051
|
+
url: z18.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL. Omit it to scan this shop\u2019s own storefront."),
|
|
4052
|
+
storefront_password: z18.string().min(1).optional().describe("Optional storefront password for a gated shop. It is sent only in the POST body and is never returned.")
|
|
3685
4053
|
};
|
|
3686
4054
|
function isHexColor(value) {
|
|
3687
4055
|
return typeof value === "string" && HEX_COLOR.test(value);
|
|
@@ -3934,7 +4302,7 @@ async function putWidgetScan(ctx, args) {
|
|
|
3934
4302
|
}
|
|
3935
4303
|
}
|
|
3936
4304
|
function registerBrandingReadTools(server, ctx) {
|
|
3937
|
-
registerReadTool(server, {
|
|
4305
|
+
registerReadTool(server, ctx, {
|
|
3938
4306
|
name: "joy_scan_brand_design",
|
|
3939
4307
|
title: "Scan a storefront\u2019s design",
|
|
3940
4308
|
description: "READ \u2014 scans an optional public HTTPS storefront URL, or this shop\u2019s storefront when omitted, and returns detected colors, heading/body fonts, button style, and whether the result was cached. For a gated storefront, ask the merchant for its password and pass storefront_password; never guess it. The result is a design input, not visual proof of how the widget will look.",
|
|
@@ -4000,13 +4368,13 @@ No write was sent because this would be a no-op.`, args.storefront_password);
|
|
|
4000
4368
|
throw new JoyApiError("server", `${failureGuidance} These fields were unchanged after the required read-back: ${missing.join(", ")}.`);
|
|
4001
4369
|
}
|
|
4002
4370
|
}
|
|
4003
|
-
const
|
|
4371
|
+
const confirmed2 = [
|
|
4004
4372
|
...scanApplied ? ["the backend storefront-design merge"] : [],
|
|
4005
4373
|
...explicit.changes.length > 0 ? [
|
|
4006
4374
|
`${explicit.changes.length} explicit field${explicit.changes.length === 1 ? "" : "s"}`
|
|
4007
4375
|
] : []
|
|
4008
4376
|
].join(" and ");
|
|
4009
|
-
const savedSummary = target === "live" ? `Updated the live ${args.version.toUpperCase()} widget configuration and confirmed ${
|
|
4377
|
+
const savedSummary = target === "live" ? `Updated the live ${args.version.toUpperCase()} widget configuration and confirmed ${confirmed2} by reading it back. The storefront changed as soon as it saved; there is no publish step and no undo.` : `Updated ${args.version.toUpperCase()} widget draft and confirmed ${confirmed2} by reading it back. The agent cannot see the rendered result; review it in Joy admin.`;
|
|
4010
4378
|
const result = `${summaryWithJson(savedSummary, {
|
|
4011
4379
|
...scanApplied ? { appliedScan: { colors: true, images: args.apply_images === true } } : {},
|
|
4012
4380
|
...explicit.changes.length > 0 ? { applied: explicit.changes.map((change) => ({ path: change.path, value: change.to })) } : {}
|
|
@@ -4022,18 +4390,36 @@ ${WIDGET_DRAFT_SAVED_MESSAGE}` : ""}`;
|
|
|
4022
4390
|
|
|
4023
4391
|
// ../mcp-core/build/tools/widgetFields.js
|
|
4024
4392
|
import { isDeepStrictEqual } from "node:util";
|
|
4025
|
-
import { z as
|
|
4393
|
+
import { z as z19 } from "zod";
|
|
4026
4394
|
var fieldsPath = "/shop/branding/widget/fields";
|
|
4027
|
-
var
|
|
4395
|
+
var HIDE_MASTER = "display.hideWidgetButton";
|
|
4396
|
+
var HIDE_ALL = "launcher.hideCompletely";
|
|
4397
|
+
var HIDE_MOBILE = "launcher.hideOnMobile";
|
|
4398
|
+
function expandLauncherHideFields(fields, current) {
|
|
4399
|
+
const byPath = new Map(fields.map(({ path, value }) => [path, value]));
|
|
4400
|
+
const currentOf = (path) => current.find((field) => field.path === path)?.current;
|
|
4401
|
+
const get = (path) => byPath.has(path) ? byPath.get(path) : currentOf(path);
|
|
4402
|
+
const hideMaster = get(HIDE_MASTER);
|
|
4403
|
+
const hideAll = get(HIDE_ALL);
|
|
4404
|
+
const hideMobile = get(HIDE_MOBILE);
|
|
4405
|
+
if ((hideAll === true || hideMobile === true) && hideMaster !== true) {
|
|
4406
|
+
byPath.set(HIDE_MASTER, true);
|
|
4407
|
+
}
|
|
4408
|
+
if (hideMaster === true && hideAll !== true && hideMobile !== true) {
|
|
4409
|
+
byPath.set(HIDE_ALL, true);
|
|
4410
|
+
}
|
|
4411
|
+
return [...byPath.entries()].map(([path, value]) => ({ path, value }));
|
|
4412
|
+
}
|
|
4413
|
+
var versionSchema = z19.literal("v4").default("v4").describe("Widget version. Field discovery currently supports V4.");
|
|
4028
4414
|
var widgetFieldsInputSchema = {
|
|
4029
4415
|
version: versionSchema,
|
|
4030
|
-
target:
|
|
4031
|
-
fields:
|
|
4032
|
-
path:
|
|
4033
|
-
value:
|
|
4416
|
+
target: z19.enum(["draft", "live"]).default("draft").describe("Draft by default. Use live only when the merchant explicitly requests and approves the immediate storefront change; there is no undo."),
|
|
4417
|
+
fields: z19.array(z19.object({
|
|
4418
|
+
path: z19.string().min(1),
|
|
4419
|
+
value: z19.unknown().refine((value) => value !== void 0, "Each field must include a value; use null to clear it.")
|
|
4034
4420
|
})).min(1).describe("Paths and values to set. Discover paths and allowed values with joy_get_widget_fields. The server validates every value."),
|
|
4035
|
-
reason:
|
|
4036
|
-
apply:
|
|
4421
|
+
reason: z19.string().min(3).describe("Why the merchant wants these widget changes."),
|
|
4422
|
+
apply: z19.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
|
|
4037
4423
|
};
|
|
4038
4424
|
var MAX_FIELD_RESPONSE_CHARS = 12e3;
|
|
4039
4425
|
function estimateSize(fields) {
|
|
@@ -4047,14 +4433,14 @@ async function getWidgetFields(ctx, version2) {
|
|
|
4047
4433
|
return data.fields;
|
|
4048
4434
|
}
|
|
4049
4435
|
function registerWidgetFieldReadTools(server, ctx) {
|
|
4050
|
-
registerReadTool(server, {
|
|
4436
|
+
registerReadTool(server, ctx, {
|
|
4051
4437
|
name: "joy_get_widget_fields",
|
|
4052
4438
|
title: "Get settable widget fields",
|
|
4053
4439
|
description: "READ \u2014 discovers settable V4 widget fields. With no arguments it returns the group index (names, counts, example paths); pass group or search to get those fields with merchant-facing labels, paths, types, current values and allowed enum values. Call before joy_set_widget_fields; never guess a field path or enum value.",
|
|
4054
4440
|
inputSchema: {
|
|
4055
4441
|
version: versionSchema,
|
|
4056
|
-
group:
|
|
4057
|
-
search:
|
|
4442
|
+
group: z19.string().optional().describe("Return only this group. Omit to get the group index first."),
|
|
4443
|
+
search: z19.string().optional().describe('Return only fields whose label or path contains this text, e.g. "corner" or "launcher".')
|
|
4058
4444
|
},
|
|
4059
4445
|
handler: async (args) => {
|
|
4060
4446
|
const fields = await getWidgetFields(ctx, args.version);
|
|
@@ -4089,13 +4475,14 @@ function registerWidgetFieldWriteTools(server, ctx) {
|
|
|
4089
4475
|
registerConfigWriteTool(server, ctx, {
|
|
4090
4476
|
name: "joy_set_widget_fields",
|
|
4091
4477
|
title: "Set widget fields",
|
|
4092
|
-
description: "CONFIG WRITE \u2014 set paths discovered by joy_get_widget_fields; the server validates fields and values. Previews each before \u2192 after by default, surfaces every refusal, and reads back after writing. Draft by default; never switch to live unless the merchant explicitly requests target: 'live'. A live save changes the storefront immediately with no publish step and no undo. Set apply: true only after preview approval; do not claim visual verification.",
|
|
4478
|
+
description: "CONFIG WRITE \u2014 set paths discovered by joy_get_widget_fields; the server validates fields and values. Previews each before \u2192 after by default, surfaces every refusal, and reads back after writing. Hiding the floating button needs display.hideWidgetButton AND launcher.hideCompletely (or hideOnMobile); setting one expands the other to match the admin Display panel. Account icon opening the widget is links.replaceAccountLink, not links.openWidgetAfterLogin. Draft by default; never switch to live unless the merchant explicitly requests target: 'live'. A live save changes the storefront immediately with no publish step and no undo. Set apply: true only after preview approval; do not claim visual verification.",
|
|
4093
4479
|
inputSchema: widgetFieldsInputSchema,
|
|
4094
4480
|
strictArguments: true,
|
|
4095
4481
|
destructiveHint: true,
|
|
4096
4482
|
handler: async (args) => {
|
|
4097
4483
|
const current = await getWidgetFields(ctx, args.version);
|
|
4098
|
-
const
|
|
4484
|
+
const fields = expandLauncherHideFields(args.fields, current);
|
|
4485
|
+
const changes = fields.map(({ path, value }) => ({
|
|
4099
4486
|
path,
|
|
4100
4487
|
from: current.find((field) => field.path === path)?.current,
|
|
4101
4488
|
to: value
|
|
@@ -4110,7 +4497,7 @@ The server will validate every requested path and value when applied; this previ
|
|
|
4110
4497
|
result = await ctx.api.mutateData(fieldsPath, {
|
|
4111
4498
|
method: "PUT",
|
|
4112
4499
|
query: { version: args.version, target: args.target },
|
|
4113
|
-
body: { fields
|
|
4500
|
+
body: { fields }
|
|
4114
4501
|
});
|
|
4115
4502
|
} catch (error) {
|
|
4116
4503
|
throw brandingErrorGuidance(error, "draft", args.target) ?? error;
|
|
@@ -4123,23 +4510,23 @@ The server will validate every requested path and value when applied; this previ
|
|
|
4123
4510
|
readBackFailure = error instanceof Error ? error.message : String(error);
|
|
4124
4511
|
}
|
|
4125
4512
|
const refusedPaths = new Set(result.refused.map((field) => field.path));
|
|
4126
|
-
const
|
|
4513
|
+
const applied2 = [];
|
|
4127
4514
|
const unconfirmed = [];
|
|
4128
|
-
for (const field of
|
|
4515
|
+
for (const field of fields) {
|
|
4129
4516
|
if (refusedPaths.has(field.path))
|
|
4130
4517
|
continue;
|
|
4131
4518
|
const accepted = result.applied.find((entry) => entry.path === field.path);
|
|
4132
4519
|
const observed = readBack.find((entry) => entry.path === field.path);
|
|
4133
4520
|
if (!readBackFailure && accepted && observed && isDeepStrictEqual(accepted.value, field.value) && isDeepStrictEqual(observed.current, field.value)) {
|
|
4134
|
-
|
|
4521
|
+
applied2.push({ path: field.path, value: field.value });
|
|
4135
4522
|
continue;
|
|
4136
4523
|
}
|
|
4137
4524
|
unconfirmed.push({ path: field.path, requested: field.value, current: observed?.current });
|
|
4138
4525
|
}
|
|
4139
|
-
const summary = `Widget field write: ${
|
|
4526
|
+
const summary = `Widget field write: ${applied2.length} confirmed by read-back, ${result.refused.length} refused, ${unconfirmed.length} unconfirmed.`;
|
|
4140
4527
|
const details = `${summary}
|
|
4141
|
-
${JSON.stringify({ refused: result.refused, unconfirmed, applied }, null, 1)}`;
|
|
4142
|
-
const consequence = args.target === "live" ? "WARNING \u2014 saving this changes the storefront immediately. There is NO publish step and NO undo." :
|
|
4528
|
+
${JSON.stringify({ refused: result.refused, unconfirmed, applied: applied2 }, null, 1)}`;
|
|
4529
|
+
const consequence = args.target === "live" ? "WARNING \u2014 saving this changes the storefront immediately. There is NO publish step and NO undo." : applied2.length > 0 ? WIDGET_DRAFT_SAVED_MESSAGE : "Review the V4 draft in Joy admin.";
|
|
4143
4530
|
const report = `${details}
|
|
4144
4531
|
${consequence}${readBackFailure ? `
|
|
4145
4532
|
Read-back failed: ${readBackFailure}` : ""}`;
|
|
@@ -4153,7 +4540,7 @@ Do not report refused or unconfirmed fields as saved. Inspect the current widget
|
|
|
4153
4540
|
}
|
|
4154
4541
|
|
|
4155
4542
|
// ../mcp-core/build/tools/referralBrandingWrite.js
|
|
4156
|
-
import { z as
|
|
4543
|
+
import { z as z20 } from "zod";
|
|
4157
4544
|
var POPUP_DISPLAY_TYPES = ["auto_show_popup", "show_bubble_teaser", "do_not_show"];
|
|
4158
4545
|
var REFERRAL_WIDGET_ARGUMENTS = {
|
|
4159
4546
|
brand_color: "brandColor",
|
|
@@ -4169,24 +4556,24 @@ var REFERRAL_WIDGET_ARGUMENTS = {
|
|
|
4169
4556
|
frequency_show_popup: "frequencyShowPopup",
|
|
4170
4557
|
is_show_subscribe_newsletter: "isShowSubscribeNewsletter"
|
|
4171
4558
|
};
|
|
4172
|
-
var hexColor = (description) =>
|
|
4559
|
+
var hexColor = (description) => z20.string().refine(isHexColor, { message: "Must be a 3- or 6-digit hex color such as #123 or #112233." }).optional().describe(description);
|
|
4173
4560
|
var referralWidgetInputShape = {
|
|
4174
4561
|
brand_color: hexColor("Primary accent color for the referral widget."),
|
|
4175
4562
|
text_color: hexColor("Main referral-widget text color."),
|
|
4176
4563
|
action_bg_color: hexColor("Referral-widget action button background color."),
|
|
4177
4564
|
action_text_color: hexColor("Referral-widget action button text color."),
|
|
4178
|
-
color_type:
|
|
4565
|
+
color_type: z20.enum(["template", "custom-solid", "custom-gradient"]).optional().describe("Color mode. This endpoint can select an existing template or gradient, but cannot define gradient stops."),
|
|
4179
4566
|
custom_solid_color: hexColor("Custom solid background color used when color_type is custom-solid."),
|
|
4180
|
-
theme_text_font:
|
|
4181
|
-
type_show_popup:
|
|
4182
|
-
is_auto_close_pop_up:
|
|
4183
|
-
closing_pop_up_time:
|
|
4184
|
-
frequency_show_popup:
|
|
4185
|
-
is_show_subscribe_newsletter:
|
|
4186
|
-
reason:
|
|
4187
|
-
apply:
|
|
4567
|
+
theme_text_font: z20.string().trim().min(1).optional().describe("Referral-widget font family, for example Open+Sans."),
|
|
4568
|
+
type_show_popup: z20.enum(POPUP_DISPLAY_TYPES).optional().describe("How the referral popup appears: automatically, as a bubble teaser, or not at all."),
|
|
4569
|
+
is_auto_close_pop_up: z20.boolean().optional().describe("Whether an automatically shown referral popup closes itself."),
|
|
4570
|
+
closing_pop_up_time: z20.number().int().min(1).optional().describe("Auto-close delay in seconds; must be a positive integer."),
|
|
4571
|
+
frequency_show_popup: z20.number().int().min(1).max(10).optional().describe("Number of automatic popup displays, from 1 through 10. The admin UI stores this as an integer."),
|
|
4572
|
+
is_show_subscribe_newsletter: z20.boolean().optional().describe("Show the newsletter subscription option in the referral popup."),
|
|
4573
|
+
reason: z20.string().min(3).describe("Why the merchant wants the live referral widget changed."),
|
|
4574
|
+
apply: z20.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
4188
4575
|
};
|
|
4189
|
-
var referralWidgetArgsSchema =
|
|
4576
|
+
var referralWidgetArgsSchema = z20.object(referralWidgetInputShape).strict();
|
|
4190
4577
|
function parseReferralWidgetArgs(input) {
|
|
4191
4578
|
const parsed = referralWidgetArgsSchema.safeParse(input);
|
|
4192
4579
|
if (parsed.success)
|
|
@@ -4252,6 +4639,369 @@ function registerReferralBrandingWriteTools(server, ctx) {
|
|
|
4252
4639
|
});
|
|
4253
4640
|
}
|
|
4254
4641
|
|
|
4642
|
+
// ../mcp-core/build/tools/redeemInCart.js
|
|
4643
|
+
import { z as z21 } from "zod";
|
|
4644
|
+
var PATH = "/shop/branding/redeem-inline";
|
|
4645
|
+
var styles = [
|
|
4646
|
+
"minimal",
|
|
4647
|
+
"editorial",
|
|
4648
|
+
"luxury",
|
|
4649
|
+
"bold",
|
|
4650
|
+
"brutalism",
|
|
4651
|
+
"playful",
|
|
4652
|
+
"tech",
|
|
4653
|
+
"botanic"
|
|
4654
|
+
];
|
|
4655
|
+
var fills = ["solid", "gradient-br", "gradient-r", "gradient-b", "radial"];
|
|
4656
|
+
var gradients = ["gradient-br", "gradient-r", "gradient-b", "radial"];
|
|
4657
|
+
var hex = z21.string().refine((value) => value === "" || isHexColor(value), "Use a hex color or an empty string to clear.");
|
|
4658
|
+
var https = z21.string().refine((value) => value === "" || (() => {
|
|
4659
|
+
try {
|
|
4660
|
+
return new URL(value).protocol === "https:";
|
|
4661
|
+
} catch {
|
|
4662
|
+
return false;
|
|
4663
|
+
}
|
|
4664
|
+
})(), "Use an HTTPS URL or an empty string to clear.");
|
|
4665
|
+
var fill = z21.object({
|
|
4666
|
+
fillType: z21.enum(fills).optional(),
|
|
4667
|
+
colors: z21.array(z21.string()).min(1).max(2).optional(),
|
|
4668
|
+
lastGradient: z21.enum(gradients).optional()
|
|
4669
|
+
}).strict();
|
|
4670
|
+
var colorFields = [
|
|
4671
|
+
"text",
|
|
4672
|
+
"dropdown",
|
|
4673
|
+
"primary",
|
|
4674
|
+
"buttonText",
|
|
4675
|
+
"buttonHover",
|
|
4676
|
+
"buttonHoverText",
|
|
4677
|
+
"savings",
|
|
4678
|
+
"sliderPrimary",
|
|
4679
|
+
"sliderTrack",
|
|
4680
|
+
"icon",
|
|
4681
|
+
"dropdownAccent"
|
|
4682
|
+
];
|
|
4683
|
+
var colorsSchema = z21.object({
|
|
4684
|
+
background: fill.optional(),
|
|
4685
|
+
button: fill.optional(),
|
|
4686
|
+
...Object.fromEntries(colorFields.map((key) => [key, hex.optional()])),
|
|
4687
|
+
buttonBorder: z21.string().optional(),
|
|
4688
|
+
buttonHoverBorder: z21.string().optional()
|
|
4689
|
+
}).strict();
|
|
4690
|
+
var cssShapeFields = [
|
|
4691
|
+
"buttonHoverOpacity",
|
|
4692
|
+
"buttonHoverTransform",
|
|
4693
|
+
"buttonHoverShadow",
|
|
4694
|
+
"buttonShadow",
|
|
4695
|
+
"buttonBorderWidth",
|
|
4696
|
+
"buttonBorderStyle",
|
|
4697
|
+
"buttonFontWeight",
|
|
4698
|
+
"buttonLetterSpacing",
|
|
4699
|
+
"buttonTextCase",
|
|
4700
|
+
"buttonTransition",
|
|
4701
|
+
"buttonHeight",
|
|
4702
|
+
"buttonFontSize",
|
|
4703
|
+
"buttonPaddingInline",
|
|
4704
|
+
"buttonSweepColor",
|
|
4705
|
+
"buttonSweepFrom",
|
|
4706
|
+
"buttonSweepTo",
|
|
4707
|
+
"buttonSweepDuration",
|
|
4708
|
+
"buttonSweepEasing"
|
|
4709
|
+
];
|
|
4710
|
+
var fonts = ["default", "inter", "space-grotesk", "playfair", "pt-serif", "nunito"];
|
|
4711
|
+
var shapeSchema = z21.object({
|
|
4712
|
+
...Object.fromEntries(cssShapeFields.map((key) => [key, z21.string().optional()])),
|
|
4713
|
+
borderColor: hex.optional(),
|
|
4714
|
+
patternColor: hex.optional(),
|
|
4715
|
+
dividerColor: hex.optional(),
|
|
4716
|
+
borderRadius: z21.enum(["none", "sharp", "soft", "rounded", "extra"]).optional(),
|
|
4717
|
+
backgroundPattern: z21.enum(["none", "diagonal", "crosshatch", "grid", "column"]).optional(),
|
|
4718
|
+
headingFont: z21.enum(fonts).optional(),
|
|
4719
|
+
bodyFont: z21.enum(["", ...fonts]).optional(),
|
|
4720
|
+
buttonFont: z21.enum(["", ...fonts]).optional(),
|
|
4721
|
+
applyLoadingStyle: z21.enum(["spinner", "spinner-label", "dots"]).optional(),
|
|
4722
|
+
buttonHoverEffect: z21.enum(["auto", "color", "fade", "lift", "none", "sweep"]).optional(),
|
|
4723
|
+
boxShadow: z21.enum(["none", "small", "medium", "large", "hard"]).optional(),
|
|
4724
|
+
iconName: z21.enum([
|
|
4725
|
+
"gift",
|
|
4726
|
+
"crown",
|
|
4727
|
+
"shopping-bag",
|
|
4728
|
+
"star",
|
|
4729
|
+
"heart",
|
|
4730
|
+
"trophy",
|
|
4731
|
+
"sparkle",
|
|
4732
|
+
"ticket",
|
|
4733
|
+
"none"
|
|
4734
|
+
]).optional(),
|
|
4735
|
+
iconImage: https.optional(),
|
|
4736
|
+
backgroundImage: https.optional(),
|
|
4737
|
+
backgroundImageOverlay: z21.number().min(0).max(100).optional(),
|
|
4738
|
+
dividerWidth: z21.enum(["thin", "medium", "thick"]).optional(),
|
|
4739
|
+
dividerStyle: z21.enum(["solid", "dashed", "dotted"]).optional()
|
|
4740
|
+
}).strict();
|
|
4741
|
+
var behaviorFields = {
|
|
4742
|
+
hide_for_guest: "hideForGuest",
|
|
4743
|
+
hide_not_enough_point: "hideNotEnoughPoint",
|
|
4744
|
+
detail_view_layout: "detailViewLayout",
|
|
4745
|
+
has_enable_cancel: "hasEnableCancel",
|
|
4746
|
+
enable_revoke_discount_apply_failed: "enableRevokeDiscountApplyFailed",
|
|
4747
|
+
show_points_input: "showPointsInput",
|
|
4748
|
+
prefer_higher_value_reward: "preferHigherValueReward",
|
|
4749
|
+
show_earn_back: "showEarnBack",
|
|
4750
|
+
has_reload_page: "hasReloadPage"
|
|
4751
|
+
};
|
|
4752
|
+
var behaviorInput = Object.fromEntries(Object.keys(behaviorFields).map((key) => [key, z21.boolean().optional()]));
|
|
4753
|
+
var common = { reason: z21.string().min(3), apply: z21.boolean().optional() };
|
|
4754
|
+
var behaviorArgs = z21.object({
|
|
4755
|
+
...behaviorInput,
|
|
4756
|
+
position_mode: z21.enum(["above_cart_summary", "custom"]).optional(),
|
|
4757
|
+
custom_type: z21.enum(["snippet", "selector"]).optional(),
|
|
4758
|
+
css_selector: z21.string().optional(),
|
|
4759
|
+
...common
|
|
4760
|
+
}).strict();
|
|
4761
|
+
var designArgs = z21.object({
|
|
4762
|
+
style: z21.enum(styles).optional(),
|
|
4763
|
+
set_active: z21.boolean().optional(),
|
|
4764
|
+
colors: colorsSchema.optional(),
|
|
4765
|
+
shape: shapeSchema.optional(),
|
|
4766
|
+
...common
|
|
4767
|
+
}).strict();
|
|
4768
|
+
function parse(schema, input) {
|
|
4769
|
+
const result = schema.safeParse(input);
|
|
4770
|
+
if (result.success)
|
|
4771
|
+
return result.data;
|
|
4772
|
+
const issue = result.error.issues[0];
|
|
4773
|
+
throw new JoyApiError("validation", `${issue.path.join(".")}: ${issue.message}`);
|
|
4774
|
+
}
|
|
4775
|
+
function valueAt(data, path) {
|
|
4776
|
+
return path.split(".").reduce((value, key) => value?.[key], data);
|
|
4777
|
+
}
|
|
4778
|
+
function changesFor(data, body) {
|
|
4779
|
+
const changes = [];
|
|
4780
|
+
function visit(value, prefix) {
|
|
4781
|
+
for (const [key, to] of Object.entries(value)) {
|
|
4782
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
4783
|
+
if (to && typeof to === "object" && !Array.isArray(to))
|
|
4784
|
+
visit(to, path);
|
|
4785
|
+
else
|
|
4786
|
+
changes.push(...diffFields({ [path]: valueAt(data, path) }, { [path]: to }));
|
|
4787
|
+
}
|
|
4788
|
+
}
|
|
4789
|
+
visit(body, "");
|
|
4790
|
+
return changes;
|
|
4791
|
+
}
|
|
4792
|
+
function warnings(data) {
|
|
4793
|
+
return [
|
|
4794
|
+
...data.redeemCartV4Ready === false ? [
|
|
4795
|
+
"V4 is disabled for this shop; settings are stored but do not show until enabled and published."
|
|
4796
|
+
] : [],
|
|
4797
|
+
"Storefront updates after background metafield publish and requires the app embed."
|
|
4798
|
+
];
|
|
4799
|
+
}
|
|
4800
|
+
function confirmed(data, body) {
|
|
4801
|
+
const missing = changesFor(data, body).map((change) => change.field);
|
|
4802
|
+
if (missing.length)
|
|
4803
|
+
throw new JoyApiError("server", `Redeem-in-cart write was not confirmed by read-back: ${missing.join(", ")}. Verify in Joy admin; do not retry blindly.`);
|
|
4804
|
+
}
|
|
4805
|
+
function applied(changes, data) {
|
|
4806
|
+
return summaryWithJson("Confirmed the Redeem-in-Cart settings in Firestore. Storefront updates after background metafield publish; the app embed is required. Prior values are included for manual restoration.", {
|
|
4807
|
+
changes: changes.map(({ field, from, to }) => ({ field, from: from ?? "(unset)", to })),
|
|
4808
|
+
current: Object.fromEntries(changes.map(({ field }) => [field, valueAt(data, field)]))
|
|
4809
|
+
});
|
|
4810
|
+
}
|
|
4811
|
+
async function getRedeemInCart(ctx, input) {
|
|
4812
|
+
const args = parse(z21.object({ style: z21.enum(styles).optional() }).strict(), input);
|
|
4813
|
+
const { data: raw } = await ctx.api.getData(PATH);
|
|
4814
|
+
const data = raw ?? {};
|
|
4815
|
+
const active = data.design?.activeStyle ?? "minimal";
|
|
4816
|
+
const legacy = data.design?.styles?.[active]?.earnBack?.show;
|
|
4817
|
+
const behavior = { ...data.behavior };
|
|
4818
|
+
for (const [field, fallback] of Object.entries({
|
|
4819
|
+
hideForGuest: false,
|
|
4820
|
+
hideNotEnoughPoint: false,
|
|
4821
|
+
detailViewLayout: false,
|
|
4822
|
+
hasEnableCancel: true,
|
|
4823
|
+
enableRevokeDiscountApplyFailed: true,
|
|
4824
|
+
showPointsInput: true,
|
|
4825
|
+
preferHigherValueReward: false
|
|
4826
|
+
}))
|
|
4827
|
+
behavior[field] ??= fallback;
|
|
4828
|
+
behavior.showEarnBackEffective = behavior.showEarnBack === void 0 ? legacy !== false : behavior.showEarnBack;
|
|
4829
|
+
if (behavior.showEarnBack === void 0) {
|
|
4830
|
+
behavior.showEarnBack = legacy === false ? "(unset \u2192 hidden by legacy preset)" : "(unset \u2192 shown)";
|
|
4831
|
+
}
|
|
4832
|
+
behavior.hasReloadPage = data.behavior?.hasReloadPage ?? "(unset)";
|
|
4833
|
+
behavior.hasReloadPageNote = "V3 theme setting, no effect on V4";
|
|
4834
|
+
const position = { ...data.position };
|
|
4835
|
+
for (const [field, fallback] of Object.entries({
|
|
4836
|
+
mode: "above_cart_summary",
|
|
4837
|
+
customType: "snippet",
|
|
4838
|
+
cssSelector: ""
|
|
4839
|
+
}))
|
|
4840
|
+
position[field] ??= fallback;
|
|
4841
|
+
const status = data.redeemCartV4Ready === void 0 ? "Redeem in cart V2 (internal V4, redeemCartV4Ready) status unknown (older backend)." : data.redeemCartV4Ready ? "Redeem in cart V2 (internal V4, redeemCartV4Ready) enabled for this shop (storefront follows after metafield publish; requires the app embed)." : "Redeem in cart V2 (internal V4, redeemCartV4Ready) disabled for this shop; stored settings do not currently apply.";
|
|
4842
|
+
const resolved = data.activeStyleResolved === void 0 ? "unknown (older backend)" : data.activeStyleResolved;
|
|
4843
|
+
return summaryWithJson(status, {
|
|
4844
|
+
behavior,
|
|
4845
|
+
position,
|
|
4846
|
+
activeStyle: active,
|
|
4847
|
+
activeStyleResolved: resolved,
|
|
4848
|
+
customisedStyles: Object.keys(data.design?.styles ?? {}),
|
|
4849
|
+
...args.style ? {
|
|
4850
|
+
style: args.style,
|
|
4851
|
+
styleResolved: data.stylesResolved?.[args.style] ?? (data.activeStyleResolved?.id === args.style ? data.activeStyleResolved : "unknown (older backend)"),
|
|
4852
|
+
storedOverrides: data.design?.styles?.[args.style] ?? {},
|
|
4853
|
+
overriddenKeys: {
|
|
4854
|
+
colors: Object.keys(data.design?.styles?.[args.style]?.colors ?? {}),
|
|
4855
|
+
shape: Object.keys(data.design?.styles?.[args.style]?.shape ?? {})
|
|
4856
|
+
},
|
|
4857
|
+
legacyEarnBackShow: data.design?.styles?.[args.style]?.earnBack?.show ?? "(unset)"
|
|
4858
|
+
} : {}
|
|
4859
|
+
});
|
|
4860
|
+
}
|
|
4861
|
+
async function configureRedeemInCart(ctx, input) {
|
|
4862
|
+
const args = parse(behaviorArgs, input);
|
|
4863
|
+
if (args.position_mode === "custom" && args.custom_type === "selector" && !args.css_selector?.trim()) {
|
|
4864
|
+
throw new JoyApiError("validation", "custom selector position requires a non-empty css_selector.");
|
|
4865
|
+
}
|
|
4866
|
+
const body = {};
|
|
4867
|
+
for (const [argument, field] of Object.entries(behaviorFields)) {
|
|
4868
|
+
const value = args[argument];
|
|
4869
|
+
if (value !== void 0)
|
|
4870
|
+
(body.behavior ??= {})[field] = value;
|
|
4871
|
+
}
|
|
4872
|
+
for (const [argument, field] of Object.entries({
|
|
4873
|
+
position_mode: "mode",
|
|
4874
|
+
custom_type: "customType",
|
|
4875
|
+
css_selector: "cssSelector"
|
|
4876
|
+
})) {
|
|
4877
|
+
const value = args[argument];
|
|
4878
|
+
if (value !== void 0)
|
|
4879
|
+
(body.position ??= {})[field] = value;
|
|
4880
|
+
}
|
|
4881
|
+
if (!Object.keys(body).length)
|
|
4882
|
+
throw new JoyApiError("validation", "At least one behavior or position field is required.");
|
|
4883
|
+
const { data: raw } = await ctx.api.getData(PATH);
|
|
4884
|
+
const current = raw ?? {};
|
|
4885
|
+
const position = { ...current.position, ...body.position };
|
|
4886
|
+
if (position.mode === "custom" && position.customType === "selector" && !position.cssSelector?.trim())
|
|
4887
|
+
throw new JoyApiError("validation", "custom selector position requires a non-empty css_selector.");
|
|
4888
|
+
const changes = changesFor(current, body);
|
|
4889
|
+
const preview = buildChangePreview({
|
|
4890
|
+
action: "UPDATE",
|
|
4891
|
+
target: "Redeem-in-Cart behavior and position",
|
|
4892
|
+
reason: args.reason,
|
|
4893
|
+
changes,
|
|
4894
|
+
warnings: [
|
|
4895
|
+
...warnings(current),
|
|
4896
|
+
...position.mode === "custom" && position.customType === "snippet" ? ["Custom snippet position requires the snippet in the theme."] : [],
|
|
4897
|
+
"A bad CSS selector silently falls back to the default position."
|
|
4898
|
+
]
|
|
4899
|
+
});
|
|
4900
|
+
if (!changes.length)
|
|
4901
|
+
return preview;
|
|
4902
|
+
const blocked = requireApply(args.apply, preview);
|
|
4903
|
+
if (blocked)
|
|
4904
|
+
return blocked;
|
|
4905
|
+
await ctx.api.mutateData(PATH, { method: "PUT", body });
|
|
4906
|
+
const { data } = await ctx.api.getData(PATH);
|
|
4907
|
+
confirmed(data ?? {}, body);
|
|
4908
|
+
return applied(changes, data ?? {});
|
|
4909
|
+
}
|
|
4910
|
+
async function configureRedeemInCartDesign(ctx, input) {
|
|
4911
|
+
const args = parse(designArgs, input);
|
|
4912
|
+
const { data: raw } = await ctx.api.getData(PATH);
|
|
4913
|
+
const current = raw ?? {};
|
|
4914
|
+
const style = args.style ?? current.design?.activeStyle ?? "minimal";
|
|
4915
|
+
const body = { design: {} };
|
|
4916
|
+
if (args.set_active === true)
|
|
4917
|
+
body.design.activeStyle = style;
|
|
4918
|
+
if (args.colors || args.shape) {
|
|
4919
|
+
const override = {};
|
|
4920
|
+
if (args.colors && Object.keys(args.colors).length) {
|
|
4921
|
+
override.colors = { ...args.colors };
|
|
4922
|
+
for (const key of ["background", "button"]) {
|
|
4923
|
+
const requested = override.colors[key];
|
|
4924
|
+
if (!requested)
|
|
4925
|
+
continue;
|
|
4926
|
+
const existing = current.design?.styles?.[style]?.colors?.[key] ?? current.stylesResolved?.[style]?.colors?.[key] ?? (current.activeStyleResolved?.id === style ? current.activeStyleResolved.colors?.[key] : void 0);
|
|
4927
|
+
if (existing) {
|
|
4928
|
+
if (requested.fillType === void 0 && existing.fillType)
|
|
4929
|
+
requested.fillType = existing.fillType;
|
|
4930
|
+
if (requested.colors?.length === 1 && existing.colors?.[1])
|
|
4931
|
+
requested.colors = [requested.colors[0], existing.colors[1]];
|
|
4932
|
+
if (requested.lastGradient === void 0 && existing.lastGradient)
|
|
4933
|
+
requested.lastGradient = existing.lastGradient;
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
if (args.shape && Object.keys(args.shape).length)
|
|
4938
|
+
override.shape = args.shape;
|
|
4939
|
+
if (Object.keys(override).length)
|
|
4940
|
+
body.design.styles = { [style]: override };
|
|
4941
|
+
}
|
|
4942
|
+
if (!Object.keys(body.design).length)
|
|
4943
|
+
throw new JoyApiError("validation", "Set set_active, colors, or shape to change a design.");
|
|
4944
|
+
const changes = changesFor(current, body);
|
|
4945
|
+
const previewChanges = changes.map((change) => {
|
|
4946
|
+
const prefix = `design.styles.${style}.`;
|
|
4947
|
+
if (!change.field.startsWith(prefix))
|
|
4948
|
+
return change;
|
|
4949
|
+
const resolved = current.stylesResolved?.[style] ?? (current.activeStyleResolved?.id === style ? current.activeStyleResolved : void 0);
|
|
4950
|
+
return {
|
|
4951
|
+
...change,
|
|
4952
|
+
from: valueAt(resolved, change.field.slice(prefix.length)) ?? change.from ?? "(preset default)"
|
|
4953
|
+
};
|
|
4954
|
+
});
|
|
4955
|
+
const preview = buildChangePreview({
|
|
4956
|
+
action: "UPDATE",
|
|
4957
|
+
target: `Redeem-in-Cart preset ${style}`,
|
|
4958
|
+
reason: args.reason,
|
|
4959
|
+
changes: previewChanges,
|
|
4960
|
+
warnings: [
|
|
4961
|
+
...warnings(current),
|
|
4962
|
+
"No visual preview exists; a wrong color can appear for every customer."
|
|
4963
|
+
]
|
|
4964
|
+
});
|
|
4965
|
+
if (!changes.length)
|
|
4966
|
+
return preview;
|
|
4967
|
+
const blocked = requireApply(args.apply, preview);
|
|
4968
|
+
if (blocked)
|
|
4969
|
+
return blocked;
|
|
4970
|
+
await ctx.api.mutateData(PATH, { method: "PUT", body });
|
|
4971
|
+
const { data } = await ctx.api.getData(PATH);
|
|
4972
|
+
confirmed(data ?? {}, body);
|
|
4973
|
+
return applied(previewChanges, data ?? {});
|
|
4974
|
+
}
|
|
4975
|
+
function registerRedeemInCartReadTools(server, ctx) {
|
|
4976
|
+
registerReadTool(server, ctx, {
|
|
4977
|
+
name: "joy_get_redeem_in_cart",
|
|
4978
|
+
title: "Read Redeem-in-Cart settings",
|
|
4979
|
+
description: 'Read all effective behavior flags, position, active preset, its resolved colors and shape, and stored per-preset overrides. Admin "Redeem in cart V2" uses the internal V4 engine (redeemCartV4Ready); availability requires metafield publish and the app embed. Pass style to inspect one preset, including read-only legacy earnBack.show.',
|
|
4980
|
+
inputSchema: { style: z21.enum(styles).optional() },
|
|
4981
|
+
handler: (args) => getRedeemInCart(ctx, args)
|
|
4982
|
+
});
|
|
4983
|
+
}
|
|
4984
|
+
function registerRedeemInCartWriteTools(server, ctx) {
|
|
4985
|
+
registerConfigWriteTool(server, ctx, {
|
|
4986
|
+
name: "joy_configure_redeem_in_cart",
|
|
4987
|
+
title: "Configure Redeem-in-Cart behavior and position",
|
|
4988
|
+
description: "CONFIGURATION WRITE. Preview first, then apply explicitly. Includes every behavior flag, including hidden show_earn_back and V3-only has_reload_page. A bad CSS selector silently falls back to default position. Confirms by read-back.",
|
|
4989
|
+
inputSchema: behaviorArgs.shape,
|
|
4990
|
+
destructiveHint: true,
|
|
4991
|
+
strictArguments: true,
|
|
4992
|
+
handler: (args) => configureRedeemInCart(ctx, args)
|
|
4993
|
+
});
|
|
4994
|
+
registerConfigWriteTool(server, ctx, {
|
|
4995
|
+
name: "joy_configure_redeem_in_cart_design",
|
|
4996
|
+
title: "Configure Redeem-in-Cart design",
|
|
4997
|
+
description: "CONFIGURATION WRITE. Set the active preset or update its colors and shape. Preview first, then apply explicitly. No visual preview exists; storefront updates after metafield publish. Confirms every requested field by read-back.",
|
|
4998
|
+
inputSchema: designArgs.shape,
|
|
4999
|
+
destructiveHint: true,
|
|
5000
|
+
strictArguments: true,
|
|
5001
|
+
handler: (args) => configureRedeemInCartDesign(ctx, args)
|
|
5002
|
+
});
|
|
5003
|
+
}
|
|
5004
|
+
|
|
4255
5005
|
// ../mcp-core/build/tools/themeSetup.js
|
|
4256
5006
|
function loyaltyWidget(embeds) {
|
|
4257
5007
|
return embeds.find((embed) => embed.name === "Joy Loyalty Widget" || embed.handle === "app-embed");
|
|
@@ -4279,6 +5029,8 @@ function formatThemeSetup(themeSetup) {
|
|
|
4279
5029
|
});
|
|
4280
5030
|
}
|
|
4281
5031
|
for (const block of blocks) {
|
|
5032
|
+
if (block.added === true)
|
|
5033
|
+
continue;
|
|
4282
5034
|
actions.push({
|
|
4283
5035
|
item: block.name,
|
|
4284
5036
|
instruction: "The merchant can add this missing block from the theme editor and save.",
|
|
@@ -4296,7 +5048,7 @@ function formatThemeSetup(themeSetup) {
|
|
|
4296
5048
|
return summaryWithJson(`Loyalty widget is ${widgetStatus}.${passwordWarning} This read-only tool cannot switch anything on; only the merchant can use the server-provided links and save the change.`, { next_steps: actions });
|
|
4297
5049
|
}
|
|
4298
5050
|
function registerThemeSetupTools(server, ctx) {
|
|
4299
|
-
registerReadTool(server, {
|
|
5051
|
+
registerReadTool(server, ctx, {
|
|
4300
5052
|
name: "joy_check_theme_setup",
|
|
4301
5053
|
title: "Check storefront theme setup",
|
|
4302
5054
|
description: "Read-only diagnosis for the Joy loyalty widget, related theme embeds, and missing blocks. It cannot switch anything on or add anything: only the merchant can open the server-provided Shopify editor link, make the change, and save. Use it to answer why the widget is not showing and to get the exact setup links; never claim this tool enabled anything.",
|
|
@@ -4309,14 +5061,14 @@ function registerThemeSetupTools(server, ctx) {
|
|
|
4309
5061
|
}
|
|
4310
5062
|
|
|
4311
5063
|
// ../mcp-core/build/tools/imageUpload.js
|
|
4312
|
-
import { z as
|
|
5064
|
+
import { z as z22 } from "zod";
|
|
4313
5065
|
var imageUploadInputSchema = {
|
|
4314
|
-
source_url:
|
|
4315
|
-
data_url:
|
|
4316
|
-
filename:
|
|
4317
|
-
alt:
|
|
4318
|
-
reason:
|
|
4319
|
-
apply:
|
|
5066
|
+
source_url: z22.string().url().optional().describe("Public URL of an already hosted image. Provide exactly one of source_url or data_url."),
|
|
5067
|
+
data_url: z22.string().optional().describe("Image bytes as a data URL, for example an image the agent generated. Provide exactly one of data_url or source_url."),
|
|
5068
|
+
filename: z22.string().min(1).max(200).optional().describe("Filename to use for the uploaded image."),
|
|
5069
|
+
alt: z22.string().max(512).optional().describe("Merchant-provided alternative text for the image."),
|
|
5070
|
+
reason: z22.string().min(3).describe("Why the merchant wants this image uploaded."),
|
|
5071
|
+
apply: z22.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
|
|
4320
5072
|
};
|
|
4321
5073
|
function imageUploadErrorGuidance(error) {
|
|
4322
5074
|
if (!(error instanceof JoyApiError))
|
|
@@ -4336,7 +5088,7 @@ function registerImageUploadTools(server, ctx) {
|
|
|
4336
5088
|
registerConfigWriteTool(server, ctx, {
|
|
4337
5089
|
name: "joy_upload_image",
|
|
4338
5090
|
title: "Upload image to Shopify Files",
|
|
4339
|
-
description: "CONFIG WRITE \u2014 uploads an image to the merchant's Shopify Files CDN and returns a permanent URL. Two-step flow: first call joy_upload_image, then pass its returned url to joy_set_widget_fields for any image field (sections.<ID>.bannerImage, membershipCard.*.bgImage, header.bannerImage, program.logo). Use data_url for image bytes the agent generated, or source_url for an image already hosted somewhere public; provide exactly one. The upload cap is 20 MB; the server validates the image type and source. This tool cannot see the image: do not describe, judge, or vouch for what was uploaded. The merchant reviews it in Joy admin. This writes into the merchant's store and is not undone by discarding a widget draft: the file stays in Shopify Files until the merchant deletes it. Dry run by default; set apply: true only after the merchant approves the preview.",
|
|
5091
|
+
description: "CONFIG WRITE \u2014 uploads an image to the merchant's Shopify Files CDN and returns a permanent URL. Two-step flow: first call joy_upload_image, then pass its returned url to joy_set_widget_fields for any image field (sections.<ID>.bannerImage, membershipCard.*.bgImage, header.bannerImage, program.logo), or to joy_update_loyalty_page_design for `hero.backgroundImage`, `hero.mobileBackgroundImage`, `inlineBanner.backgroundImage`, `inlineBanner.mobileBackgroundImage`, `earn.bannerImage`, `referral.image`, or `icons`; that loyalty-page tool also accepts separate video URLs at `hero.backgroundVideo` and `hero.mobileBackgroundVideo`. Use data_url for image bytes the agent generated, or source_url for an image already hosted somewhere public; provide exactly one. The upload cap is 20 MB; the server validates the image type and source. This tool cannot see the image: do not describe, judge, or vouch for what was uploaded. The merchant reviews it in Joy admin. This writes into the merchant's store and is not undone by discarding a widget draft: the file stays in Shopify Files until the merchant deletes it. Dry run by default; set apply: true only after the merchant approves the preview.",
|
|
4340
5092
|
inputSchema: imageUploadInputSchema,
|
|
4341
5093
|
strictArguments: true,
|
|
4342
5094
|
destructiveHint: false,
|
|
@@ -4361,14 +5113,14 @@ function registerImageUploadTools(server, ctx) {
|
|
|
4361
5113
|
}
|
|
4362
5114
|
|
|
4363
5115
|
// ../mcp-core/build/prompts.js
|
|
4364
|
-
import { z as
|
|
5116
|
+
import { z as z23 } from "zod";
|
|
4365
5117
|
function registerAllPrompts(server) {
|
|
4366
5118
|
server.registerPrompt("support_missing_points", {
|
|
4367
5119
|
title: "Investigate missing points for an order",
|
|
4368
5120
|
description: "Support workflow: verify whether a customer really is missing points for an order, explain why, and prepare a compensation proposal with a proper audit reason.",
|
|
4369
5121
|
argsSchema: {
|
|
4370
|
-
email:
|
|
4371
|
-
order_number:
|
|
5122
|
+
email: z23.string().describe("Customer's email address"),
|
|
5123
|
+
order_number: z23.string().optional().describe("Order name/number the customer says earned nothing, e.g. #1234")
|
|
4372
5124
|
}
|
|
4373
5125
|
}, ({ email, order_number }) => ({
|
|
4374
5126
|
messages: [
|
|
@@ -4395,7 +5147,7 @@ function registerAllPrompts(server) {
|
|
|
4395
5147
|
title: "Loyalty program review (health + revenue + tiers + referral)",
|
|
4396
5148
|
description: "Periodic business review: program health check, revenue impact, tier performance, and referral results \u2014 with data-source rules so numbers from different systems are never mixed.",
|
|
4397
5149
|
argsSchema: {
|
|
4398
|
-
period:
|
|
5150
|
+
period: z23.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
|
|
4399
5151
|
}
|
|
4400
5152
|
}, ({ period }) => ({
|
|
4401
5153
|
messages: [
|
|
@@ -4426,7 +5178,7 @@ function registerAllPrompts(server) {
|
|
|
4426
5178
|
title: "Investigate a member for abuse risk",
|
|
4427
5179
|
description: "Fraud/abuse triage for one member: risk flags, point activity, and referral history \u2014 with the mandatory caveat about signal limitations and no punitive action.",
|
|
4428
5180
|
argsSchema: {
|
|
4429
|
-
email:
|
|
5181
|
+
email: z23.string().describe("Email of the member to investigate")
|
|
4430
5182
|
}
|
|
4431
5183
|
}, ({ email }) => ({
|
|
4432
5184
|
messages: [
|
|
@@ -4453,8 +5205,8 @@ function registerAllPrompts(server) {
|
|
|
4453
5205
|
title: "Set up a loyalty program safely",
|
|
4454
5206
|
description: "Guided loyalty-program setup: inspect the current configuration, propose a minimal program, and make each approved configuration change one at a time.",
|
|
4455
5207
|
argsSchema: {
|
|
4456
|
-
business_type:
|
|
4457
|
-
goal:
|
|
5208
|
+
business_type: z23.string().optional().describe("Business type, e.g. 'fashion' or 'coffee shop'."),
|
|
5209
|
+
goal: z23.string().optional().describe("Primary loyalty goal, e.g. 'increase repeat purchases'.")
|
|
4458
5210
|
}
|
|
4459
5211
|
}, ({ business_type, goal }) => ({
|
|
4460
5212
|
messages: [
|
|
@@ -4479,7 +5231,7 @@ function registerAllPrompts(server) {
|
|
|
4479
5231
|
title: "Apply storefront branding to a widget draft",
|
|
4480
5232
|
description: "Guided widget-branding workflow: scan the storefront, explain the detected design, get approval, and save the approved changes to the V4 draft for the merchant to review and publish manually.",
|
|
4481
5233
|
argsSchema: {
|
|
4482
|
-
preferences:
|
|
5234
|
+
preferences: z23.string().optional().describe("Optional brand preferences or constraints, e.g. 'keep the current heading font' or 'use a softer button color'.")
|
|
4483
5235
|
}
|
|
4484
5236
|
}, ({ preferences }) => ({
|
|
4485
5237
|
messages: [
|
|
@@ -4506,7 +5258,7 @@ function registerAllPrompts(server) {
|
|
|
4506
5258
|
title: "Set up the storefront loyalty widget",
|
|
4507
5259
|
description: "Guided theme-setup workflow: audit the live theme, explain each missing Joy embed or block, and hand the merchant server-provided deep links to finish setup manually.",
|
|
4508
5260
|
argsSchema: {
|
|
4509
|
-
focus:
|
|
5261
|
+
focus: z23.string().optional().describe("Optional setup focus, e.g. 'loyalty widget', 'points calculator', or 'loyalty page'.")
|
|
4510
5262
|
}
|
|
4511
5263
|
}, ({ focus }) => ({
|
|
4512
5264
|
messages: [
|
|
@@ -4544,15 +5296,20 @@ function registerAllTools(server, ctx) {
|
|
|
4544
5296
|
registerOrderExplainTools(server, ctx);
|
|
4545
5297
|
registerActivityAuditTools(server, ctx);
|
|
4546
5298
|
registerTierUpgradeTools(server, ctx);
|
|
5299
|
+
registerLoyaltyPageTools(server, ctx);
|
|
5300
|
+
registerLoyaltyPageTextTools(server, ctx);
|
|
4547
5301
|
registerThemeSetupTools(server, ctx);
|
|
4548
5302
|
registerBrandingReadTools(server, ctx);
|
|
4549
5303
|
registerWidgetFieldReadTools(server, ctx);
|
|
5304
|
+
registerRedeemInCartReadTools(server, ctx);
|
|
4550
5305
|
if (ctx.config.writesEnabled) {
|
|
4551
5306
|
registerPointWriteTools(server, ctx);
|
|
4552
5307
|
registerTierWriteTools(server, ctx);
|
|
4553
5308
|
registerRewardWriteTools(server, ctx);
|
|
4554
5309
|
registerReferralWriteTools(server, ctx);
|
|
4555
5310
|
registerAnalyticsWriteTools(server, ctx);
|
|
5311
|
+
registerLoyaltyPageWriteTools(server, ctx);
|
|
5312
|
+
registerLoyaltyPageTextWriteTools(server, ctx);
|
|
4556
5313
|
log.info("write tools ENABLED (award/deduct/adjust/redeem/set-tier/refund-coupon/create-referral-invite/enable-analytics-v2). Requires the deployed REST v2 write-path (reason/source/Idempotency-Key support) \u2014 against an older backend, reason/source are silently dropped and idempotency is not enforced.");
|
|
4557
5314
|
}
|
|
4558
5315
|
if (ctx.config.configWritesEnabled) {
|
|
@@ -4563,6 +5320,7 @@ function registerAllTools(server, ctx) {
|
|
|
4563
5320
|
registerBrandingWriteTools(server, ctx);
|
|
4564
5321
|
registerWidgetFieldWriteTools(server, ctx);
|
|
4565
5322
|
registerReferralBrandingWriteTools(server, ctx);
|
|
5323
|
+
registerRedeemInCartWriteTools(server, ctx);
|
|
4566
5324
|
registerImageUploadTools(server, ctx);
|
|
4567
5325
|
log.info("CONFIG write tools ENABLED (configure earning/redemption/referral program, tier, tier reward, tier settings, shop settings, referral widget, add translation language). These change behaviour for every customer at once and every one of them previews by default \u2014 an apply requires a second call. No backend audit trail records who changed configuration or why, and the endpoints accept no Idempotency-Key, so a repeated create makes a second live record.");
|
|
4568
5326
|
}
|