@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.
Files changed (2) hide show
  1. package/build/index.js +1032 -274
  2. 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
- return { isError: true, content: [{ type: "text", text: error.modelMessage }] };
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
- return runHandler(spec.name, () => spec.handler(args ?? {}));
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
- return runHandler(spec.name, () => spec.handler(args ?? {}));
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 { data } = await ctx.api.getData("/tiers");
714
- return Array.isArray(data) ? data : [];
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 with thresholds (targetPoint/targetAmount), earning multipliers, and per-tier benefits (tierRewards). Use to understand the tier ladder before answering tier questions. 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.',
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 ctx.api.getData("/tiers", { query: { hasCount: true } });
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).`, tiers2.map((tier) => compact(tier, keys)));
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.`, tiers.map((tier) => compact(tier, TIER_KEYS)));
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?". IMPORTANT: when a rule has earnPointsTiers, the per-VIP-tier multipliers there override the flat earnPoint \u2014 quote the tiered values, not just the base rate.',
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((program) => compact(program, EARNING_KEYS)));
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 on this redemption.")
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
- return summaryWithJson(`Redeemed ${formatPoints(args.redeem_points)} points from ${memberDisplayName(member)} for coupon "${String(discount.code)}" \u2014 balance now ${formatPoints(customer.point)}.`, {
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/programsWrite.js
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: z13.string().min(1).optional().describe("Existing Joy program ID. Omit to create a new program."),
2488
- event: z13.string().optional().describe("Program event. Required for create; optional for update."),
2489
- title: z13.string().min(1).optional().describe("Customer-facing program title."),
2490
- status: z13.boolean().optional().describe("Set false to disable this rule instead of deleting it."),
2491
- priority: z13.number().int().optional().describe("Optional program priority."),
2492
- start_date: z13.string().optional().describe("Optional ISO start date."),
2493
- end_date: z13.string().optional().describe("Optional ISO end date."),
2494
- earn_by: z13.enum(["price", "fixed_order", "quantity", "order"]).optional(),
2495
- rate_money: z13.number().optional(),
2496
- rate_item: z13.number().optional(),
2497
- earn_point: z13.number().optional(),
2498
- date_format: z13.enum(["MM/DD", "DD/MM", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"]).optional(),
2499
- type_reward_timing: z13.enum(["exact_birthday", "begin_of_month", "around_birthday"]).optional(),
2500
- limit_unit: z13.string().optional(),
2501
- limit_interval: z13.number().int().positive().optional(),
2502
- url_account: z13.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."),
2503
- review_app: z13.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."),
2504
- description_review: z13.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."),
2505
- extra_points_for_images: z13.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."),
2506
- extra_points_for_videos: z13.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."),
2507
- spend_point: z13.number().int().positive().optional(),
2508
- earn_amount: z13.number().optional(),
2509
- shipping_discount_type: z13.enum(["percentage", "fixedAmount"]).optional(),
2510
- shipping_discount_amount: z13.number().optional(),
2511
- applied_to: z13.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."),
2512
- specific_products: z13.array(z13.unknown()).optional().describe("free_gift with applied_to sf_product only \u2014 the merchant-provided product records/IDs. Do not invent product IDs."),
2513
- specific_collections: z13.array(z13.unknown()).optional(),
2514
- applied_collection_ids: z13.array(z13.string()).optional().describe("free_gift with applied_to specific or exclude_collection only \u2014 merchant-provided collection IDs."),
2515
- collection_quantity: z13.number().int().positive().optional().describe("Required for collection-based free_gift rules (applied_to specific or exclude_collection)."),
2516
- product_url: z13.array(z13.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."),
2517
- applied_place_order_to: z13.enum(["all", "vip-tier", "member_and_guest"]).optional().describe(`place_order only \u2014 the admin's "Who to reward" setting. Leave unset: enabling per-tier earning sets 'vip-tier' automatically, which legacy place_order programs REQUIRE (they predate isEventPlaceOrderV2 and ignore is_applied_vip_tier).`),
2518
- is_applied_vip_tier: z13.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."),
2519
- earn_points_tiers: z13.record(z13.object({
2520
- earn_point: z13.number().optional().describe("Points this tier earns, replacing the flat earn_point."),
2521
- rate_money: z13.number().optional().describe("place_order only: money amount that earns earn_point for this tier."),
2522
- type_earn: z13.enum(["point", "store_credit"]).optional()
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: z13.string().min(3).describe("Why the merchant wants this configuration change."),
2525
- apply: z13.boolean().optional().describe("Set true only after reviewing this tool's dry-run preview.")
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
- if (args.applied_place_order_to)
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 z14 } from "zod";
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 = z14.enum([
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 = () => z14.number().finite();
2758
- var tierRewardInputSchema = z14.object({
3087
+ var finiteNumber = () => z16.number().finite();
3088
+ var tierRewardInputSchema = z16.object({
2759
3089
  event: rewardEventSchema.describe("Required reward kind."),
2760
- title: z14.string().min(1).optional(),
2761
- type: z14.enum(["tier_spending", "tier_privileges", "pre_tier_reward"]).optional(),
2762
- status: z14.boolean().optional(),
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: z14.string().optional(),
2767
- redeem_type: z14.string().optional(),
2768
- applied_to: z14.enum(["all", "specific_product", "specific"]).optional().describe("Tier-reward scope only: all, specific_product, or specific. These literals differ from program-level reward tools."),
2769
- order_req: z14.enum(["none", "min_amount", "min_quantity"]).optional(),
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: z14.array(z14.unknown()).optional(),
2774
- specific_collections: z14.array(z14.unknown()).optional(),
2775
- applied_collection_ids: z14.array(z14.unknown()).optional(),
2776
- combined_with: z14.array(z14.enum(["orderDiscounts", "shippingDiscounts", "productDiscounts"])).optional(),
2777
- has_limit_shipping: z14.boolean().optional(),
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: z14.enum(["day", "month"]).optional(),
2781
- is_refund_coupon: z14.boolean().optional(),
3110
+ expired_unit: z16.enum(["day", "month"]).optional(),
3111
+ is_refund_coupon: z16.boolean().optional(),
2782
3112
  priority: finiteNumber().optional(),
2783
- show_loyalty_page: z14.boolean().optional(),
2784
- perk_title_language: z14.record(z14.unknown()).optional(),
2785
- reward_product_selection_mode: z14.string().optional(),
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: z14.string().min(1).optional().describe("Existing tier ID. Omit to create a tier."),
2790
- name: z14.string().min(1).optional(),
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: z14.string().optional(),
2793
- icon_custom: z14.string().optional(),
2794
- bg_image: z14.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."),
2795
- default_avatar: z14.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."),
2796
- image_block: z14.string().optional(),
2797
- is_exclusive_tier: z14.boolean().optional(),
2798
- hide_default_tier: z14.boolean().optional(),
2799
- text_color: z14.string().optional(),
2800
- progress_bar_color: z14.string().optional(),
2801
- box_bg_color: z14.string().optional(),
2802
- box_text_color: z14.string().optional(),
2803
- bg_card_color: z14.string().optional(),
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: z14.string().optional(),
2806
- bg_type: z14.string().optional(),
2807
- tier_names_language: z14.record(z14.unknown()).optional(),
2808
- rewards: z14.array(tierRewardInputSchema).optional().describe("Optional rewards to create with a NEW tier in this one configuration write."),
2809
- reason: z14.string().min(3).describe("Why the merchant wants this tier configuration."),
2810
- apply: z14.boolean().optional().describe("Set true only after the dry-run preview is approved.")
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: z14.string().min(1).describe("Tier ID from joy_list_tiers."),
2814
- reward_id: z14.string().min(1).optional().describe("Existing reward ID. Omit to create."),
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: z14.array(z14.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."),
2817
- reason: z14.string().min(3).describe("Why the merchant wants this reward configuration."),
2818
- apply: z14.boolean().optional().describe("Set true only after the dry-run preview is approved.")
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
- use_demotion_tier: z14.boolean().optional(),
2822
- type_demotion: z14.enum(["downgrade_tier", "reset_tier"]).optional(),
2823
- downgrade_after: z14.number().int().positive().optional(),
2824
- downgrade_after_unit: z14.enum(["day", "month", "year"]).optional(),
2825
- use_reset_tier: z14.boolean().optional(),
2826
- reset_after: z14.number().int().positive().optional(),
2827
- reset_after_unit: z14.enum(["day", "month", "year"]).optional(),
2828
- reset_frequency: z14.enum(["monthly", "quarterly", "half_yearly", "yearly", "custom"]).optional(),
2829
- reset_interval_value: z14.number().int().positive().optional(),
2830
- reset_interval_unit: z14.enum(["day", "month", "year"]).optional(),
2831
- reset_start_date: z14.string().min(1).optional(),
2832
- month_reset_tier: z14.number().int().min(1).max(12).optional(),
2833
- day_reset_tier: z14.number().int().min(1).max(31).optional(),
2834
- is_sync_tags_customer_tier: z14.boolean().optional(),
2835
- customer_tier_tag_prefix: z14.string().optional(),
2836
- notification_downgrade_before: z14.number().int().positive().optional(),
2837
- notification_downgrade_before_unit: z14.enum(["day", "month"]).optional(),
2838
- reason: z14.string().min(3).describe("Why the merchant wants these tier settings."),
2839
- apply: z14.boolean().optional().describe("Set true only after the dry-run preview is approved.")
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: z14.string().min(1).describe("Tier ID from joy_list_tiers."),
2843
- perks: z14.array(z14.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."),
2844
- reason: z14.string().min(3).describe("Why the merchant wants to replace this tier perk list."),
2845
- apply: z14.boolean().optional().describe("Set true only after the dry-run preview is approved.")
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
- return summaryWithJson(`Updated tier settings.${schedule}`, compact(updated, [...TIER_SETTINGS_FIELDS, "id"]));
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: "CONFIGURATION WRITE \u2014 changes tier demotion, reset, customer-tag, and downgrade-notification settings. Any reset-schedule change previews the current and projected scheduleResetTierAt before it may apply, because that daily-cron anchor affects every tier member.",
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 z15 } from "zod";
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: z15.enum(["en", "en-US", "fr", "es", "de", "it", "ja", "id", "zh-CN"]).optional(),
3566
- date_format: z15.enum(["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD", "YYYY-MM-DD"]).optional(),
3567
- date_format_for_activity: z15.enum(["HH:mm", "hh:mm A"]).optional(),
3568
- point_position: z15.enum(["before", "after"]).optional(),
3569
- customer_apply_discount_behavior: z15.enum(["stay_current", "go_to_cart", "open_cart_drawer"]).optional(),
3570
- customer_redeem_coupon_behavior: z15.enum(["auto_apply", "save_for_later"]).optional(),
3571
- customer_free_gift_behavior: z15.enum(["manual_add_to_cart", "auto_add_to_cart"]).optional(),
3572
- status_for_review: z15.enum(["pending", "approveAuto"]).optional(),
3573
- earn_by_order_status: z15.enum(["financial_status", "fulfillment_status"]).optional(),
3574
- enable_global_earning_limit: z15.boolean().optional(),
3575
- global_earning_limit_timeframe: z15.enum(["yearly", "monthly", "rolling"]).optional(),
3576
- global_earning_limit_rolling_days: z15.number().int().min(1).max(365).optional(),
3577
- reason: z15.string().min(3).describe("Why the merchant wants these shop-wide settings changed."),
3578
- apply: z15.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
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: z15.enum(TRANSLATION_LOCALES),
3601
- additional_language: z15.enum(TRANSLATION_LOCALES),
3602
- reason: z15.string().min(3).describe("Why the merchant wants this storefront language added."),
3603
- apply: z15.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
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 z16 } from "zod";
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: z16.enum(["v3", "v4"]).describe("Widget version to change. A live target is accepted only when this is the shop\u2019s currently live version."),
3666
- target: z16.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."),
3667
- apply_scan: z16.boolean().optional().describe("Apply a storefront scan through Joy\u2019s shared backend design merge. Omit scan_url to scan this shop\u2019s own storefront."),
3668
- scan_url: z16.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL to scan when apply_scan is true."),
3669
- storefront_password: z16.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."),
3670
- apply_images: z16.boolean().optional().describe("When apply_scan is true, also replace detected logo and card imagery. Defaults to false."),
3671
- color_overrides: z16.record(z16.string()).optional().describe(`Optional hex overrides for supported V4 color roles: ${COLOR_ROLES.join(", ")}.`),
3672
- design_overrides: z16.object({
3673
- corner_radius: z16.enum(CORNER_RADIUS_VALUES).optional().describe("Corner style. radiusNone is square/not rounded; radiusExtra is the most rounded."),
3674
- button_style: z16.enum(BUTTON_STYLE_VALUES).optional().describe("store matches the storefront, custom uses the widget\u2019s own button colours."),
3675
- content_style: z16.enum(CONTENT_STYLE_VALUES).optional().describe("text_only removes the icons beside earn/redeem rows; icons_and_text shows them."),
3676
- display_type: z16.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.")
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: z16.object({ heading: z16.string().min(1).optional(), body: z16.string().min(1).optional() }).optional().describe("Optional heading and body font overrides."),
3679
- reason: z16.string().min(3).describe("Why the merchant wants this widget draft."),
3680
- apply: z16.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
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: z16.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL. Omit it to scan this shop\u2019s own storefront."),
3684
- storefront_password: z16.string().min(1).optional().describe("Optional storefront password for a gated shop. It is sent only in the POST body and is never returned.")
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 confirmed = [
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 ${confirmed} 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 ${confirmed} by reading it back. The agent cannot see the rendered result; review it in Joy admin.`;
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 z17 } from "zod";
4393
+ import { z as z19 } from "zod";
4026
4394
  var fieldsPath = "/shop/branding/widget/fields";
4027
- var versionSchema = z17.literal("v4").default("v4").describe("Widget version. Field discovery currently supports V4.");
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: z17.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."),
4031
- fields: z17.array(z17.object({
4032
- path: z17.string().min(1),
4033
- value: z17.unknown().refine((value) => value !== void 0, "Each field must include a value; use null to clear it.")
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: z17.string().min(3).describe("Why the merchant wants these widget changes."),
4036
- apply: z17.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
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: z17.string().optional().describe("Return only this group. Omit to get the group index first."),
4057
- search: z17.string().optional().describe('Return only fields whose label or path contains this text, e.g. "corner" or "launcher".')
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 changes = args.fields.map(({ path, value }) => ({
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: args.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 applied = [];
4513
+ const applied2 = [];
4127
4514
  const unconfirmed = [];
4128
- for (const field of args.fields) {
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
- applied.push({ path: field.path, value: field.value });
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: ${applied.length} confirmed by read-back, ${result.refused.length} refused, ${unconfirmed.length} unconfirmed.`;
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." : applied.length > 0 ? WIDGET_DRAFT_SAVED_MESSAGE : "Review the V4 draft in Joy admin.";
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 z18 } from "zod";
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) => z18.string().refine(isHexColor, { message: "Must be a 3- or 6-digit hex color such as #123 or #112233." }).optional().describe(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: z18.enum(["template", "custom-solid", "custom-gradient"]).optional().describe("Color mode. This endpoint can select an existing template or gradient, but cannot define gradient stops."),
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: z18.string().trim().min(1).optional().describe("Referral-widget font family, for example Open+Sans."),
4181
- type_show_popup: z18.enum(POPUP_DISPLAY_TYPES).optional().describe("How the referral popup appears: automatically, as a bubble teaser, or not at all."),
4182
- is_auto_close_pop_up: z18.boolean().optional().describe("Whether an automatically shown referral popup closes itself."),
4183
- closing_pop_up_time: z18.number().int().min(1).optional().describe("Auto-close delay in seconds; must be a positive integer."),
4184
- frequency_show_popup: z18.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."),
4185
- is_show_subscribe_newsletter: z18.boolean().optional().describe("Show the newsletter subscription option in the referral popup."),
4186
- reason: z18.string().min(3).describe("Why the merchant wants the live referral widget changed."),
4187
- apply: z18.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
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 = z18.object(referralWidgetInputShape).strict();
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 z19 } from "zod";
5064
+ import { z as z22 } from "zod";
4313
5065
  var imageUploadInputSchema = {
4314
- source_url: z19.string().url().optional().describe("Public URL of an already hosted image. Provide exactly one of source_url or data_url."),
4315
- data_url: z19.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."),
4316
- filename: z19.string().min(1).max(200).optional().describe("Filename to use for the uploaded image."),
4317
- alt: z19.string().max(512).optional().describe("Merchant-provided alternative text for the image."),
4318
- reason: z19.string().min(3).describe("Why the merchant wants this image uploaded."),
4319
- apply: z19.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
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 z20 } from "zod";
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: z20.string().describe("Customer's email address"),
4371
- order_number: z20.string().optional().describe("Order name/number the customer says earned nothing, e.g. #1234")
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: z20.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
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: z20.string().describe("Email of the member to investigate")
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: z20.string().optional().describe("Business type, e.g. 'fashion' or 'coffee shop'."),
4457
- goal: z20.string().optional().describe("Primary loyalty goal, e.g. 'increase repeat purchases'.")
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: z20.string().optional().describe("Optional brand preferences or constraints, e.g. 'keep the current heading font' or 'use a softer button color'.")
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: z20.string().optional().describe("Optional setup focus, e.g. 'loyalty widget', 'points calculator', or 'loyalty page'.")
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
  }