@joy-loyalty/mcp 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -53,9 +53,19 @@ function loadConfig(env = process.env) {
53
53
  }
54
54
  const mode = privateBotKey ? "internal-bot" : "merchant";
55
55
  const writesEnabled = mode === "merchant" && env.JOY_MCP_ENABLE_WRITES === "true";
56
+ const configWritesEnabled = writesEnabled && env.JOY_MCP_ENABLE_CONFIG_WRITES === "true";
56
57
  const rawThreshold = Number(env.JOY_MCP_DEDUCT_DRYRUN_THRESHOLD ?? "1000");
57
58
  const deductDryRunThreshold = Number.isFinite(rawThreshold) && rawThreshold >= 0 ? rawThreshold : 1e3;
58
- return { apiBaseUrl, appKey, secretKey, privateBotKey, mode, writesEnabled, deductDryRunThreshold };
59
+ return {
60
+ apiBaseUrl,
61
+ appKey,
62
+ secretKey,
63
+ privateBotKey,
64
+ mode,
65
+ writesEnabled,
66
+ configWritesEnabled,
67
+ deductDryRunThreshold
68
+ };
59
69
  }
60
70
 
61
71
  // ../mcp-core/build/client/joyApi.js
@@ -71,7 +81,7 @@ var JoyApiError = class extends Error {
71
81
  this.name = "JoyApiError";
72
82
  }
73
83
  };
74
- var DEFAULT_TIMEOUT_MS = 1e4;
84
+ var DEFAULT_TIMEOUT_MS = 2e4;
75
85
  function extractServerMessage(body) {
76
86
  if (!body || typeof body !== "object")
77
87
  return void 0;
@@ -112,11 +122,24 @@ function classifyError(status, serverMessage, retryAfter, credentialHint) {
112
122
  }
113
123
  return new JoyApiError("forbidden", `This operation is not permitted with the current credential (read-only key or insufficient scope).${detail} Do NOT retry with the same credential.`, status);
114
124
  }
125
+ if (status === 402) {
126
+ return new JoyApiError("plan_gate", `The shop's plan does not include REST API or MCP access.${detail} Do NOT retry \u2014 tell the merchant to upgrade their Joy plan; the response's upgrade_url links straight to their subscription page.`, status);
127
+ }
115
128
  if (status === 429) {
116
129
  const wait = retryAfter ? `${retryAfter} seconds` : "~10 seconds";
117
- return new JoyApiError("rate_limit", `Rate limited: the Joy API allows 120 requests per 10 seconds per shop. Wait ${wait} before the next call, and prefer fewer, more specific queries (e.g. filter by email instead of listing pages). Do NOT retry immediately.`, status);
130
+ return new JoyApiError(
131
+ "rate_limit",
132
+ // No hard-coded ceiling here: the limit is per-plan (and there are two of them, a burst
133
+ // window and a daily quota), so any number written into this string is wrong for most
134
+ // shops. The server states the real one in `detail`, and Retry-After carries the wait.
135
+ `Rate limited by the Joy API.${detail} Wait ${wait} before the next call, and prefer fewer, more specific queries (e.g. filter by email instead of listing pages). Do NOT retry immediately.`,
136
+ status
137
+ );
118
138
  }
119
139
  if (status === 404) {
140
+ if (!serverMessage) {
141
+ return new JoyApiError("not_found", "This endpoint does not exist on the Joy backend this shop is connected to. Nothing is wrong with the arguments and there is no identifier to re-check \u2014 the shop is on a deployment older than this tool. Do NOT retry, do NOT try a lookup tool, and do NOT substitute data from another tool. Tell the merchant this feature is unavailable on their current Joy version.", status);
142
+ }
120
143
  return new JoyApiError("not_found", `Not found.${detail} Verify the identifier and try a lookup tool first (e.g. joy_find_member) instead of guessing IDs.`, status);
121
144
  }
122
145
  if (status === 400 || status === 422) {
@@ -184,7 +207,7 @@ var JoyApi = class {
184
207
  });
185
208
  } catch (error) {
186
209
  if (error instanceof Error && error.name === "TimeoutError") {
187
- throw new JoyApiError("timeout", `The Joy API did not respond within ${Math.round(timeoutMs / 1e3)}s. Safe to retry ONCE; if it times out again, report the outage instead of retrying further.`);
210
+ throw new JoyApiError("timeout", `The Joy API did not respond within ${Math.round(timeoutMs / 1e3)}s. The first call after an idle period pays a Cloud Functions cold start, so a lone timeout is usually transient rather than an outage. Safe to retry ONCE; if it times out again, report the outage instead of retrying further.`);
188
211
  }
189
212
  throw new JoyApiError("network", `Could not reach the Joy API at ${this.config.apiBaseUrl}. Check network connectivity and JOY_API_BASE_URL. Safe to retry ONCE.`);
190
213
  }
@@ -232,9 +255,15 @@ var TtlCache = class {
232
255
  this.store.set(key, { expiresAt: now + ttlMs, promise });
233
256
  return promise;
234
257
  }
258
+ delete(key) {
259
+ this.store.delete(key);
260
+ }
235
261
  };
236
262
 
237
263
  // ../mcp-core/build/tools/members.js
264
+ import { z as z2 } from "zod";
265
+
266
+ // ../mcp-core/build/toolkit.js
238
267
  import { z } from "zod";
239
268
 
240
269
  // ../mcp-core/build/logger.js
@@ -247,56 +276,160 @@ var log = {
247
276
  }
248
277
  };
249
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
+
250
329
  // ../mcp-core/build/toolkit.js
251
- async function runHandler(name, run) {
330
+ async function runHandler(name, run, store) {
252
331
  try {
253
332
  const text = await run();
254
- return { content: [{ type: "text", text }] };
333
+ return { content: [{ type: "text", text: store ? stampResult(text, store) : text }] };
255
334
  } catch (error) {
256
335
  if (error instanceof JoyApiError) {
257
- 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 }] };
258
338
  }
259
339
  const message = error instanceof Error ? error.message : String(error);
260
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.`;
261
342
  return {
262
343
  isError: true,
263
- content: [
264
- {
265
- type: "text",
266
- 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.`
267
- }
268
- ]
344
+ content: [{ type: "text", text: store ? stampResult(text, store) : text }]
269
345
  };
270
346
  }
271
347
  }
272
- function registerReadTool(server, spec) {
273
- 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));
274
350
  server.registerTool(spec.name, {
275
351
  title: spec.title,
276
- description: spec.description,
352
+ description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
277
353
  inputSchema: spec.inputSchema,
278
354
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
279
355
  }, callback);
280
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
+ }
376
+ function registerConfigWriteTool(server, ctx, spec) {
377
+ const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
378
+ const callback = (async (args) => {
379
+ if (strictStore) {
380
+ const mismatch = checkStoreMismatch(spec.name, args, strictStore);
381
+ if (mismatch)
382
+ return mismatch;
383
+ }
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.`;
386
+ return {
387
+ isError: true,
388
+ content: [{ type: "text", text: ctx.store ? stampResult(text, ctx.store) : text }]
389
+ };
390
+ }
391
+ const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
392
+ return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
393
+ });
394
+ const registration = {
395
+ title: spec.title,
396
+ description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
397
+ annotations: {
398
+ readOnlyHint: false,
399
+ destructiveHint: spec.destructiveHint,
400
+ idempotentHint: false,
401
+ openWorldHint: false
402
+ }
403
+ };
404
+ if (spec.strictArguments) {
405
+ const inputSchema = z.object(withStoreArg(spec.inputSchema, strictStore)).strict();
406
+ server.registerTool(spec.name, { ...registration, inputSchema }, callback);
407
+ return;
408
+ }
409
+ server.registerTool(spec.name, { ...registration, inputSchema: withStoreArg(spec.inputSchema, strictStore) }, callback);
410
+ }
281
411
  function registerWriteTool(server, ctx, spec) {
412
+ const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
282
413
  const callback = (async (args) => {
414
+ if (strictStore) {
415
+ const mismatch = checkStoreMismatch(spec.name, args, strictStore);
416
+ if (mismatch)
417
+ return mismatch;
418
+ }
283
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.`;
284
421
  return {
285
422
  isError: true,
286
- content: [
287
- {
288
- type: "text",
289
- 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.`
290
- }
291
- ]
423
+ content: [{ type: "text", text: ctx.store ? stampResult(text, ctx.store) : text }]
292
424
  };
293
425
  }
294
- return runHandler(spec.name, () => spec.handler(args ?? {}));
426
+ const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
427
+ return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
295
428
  });
296
429
  server.registerTool(spec.name, {
297
430
  title: spec.title,
298
- description: spec.description,
299
- inputSchema: spec.inputSchema,
431
+ description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
432
+ inputSchema: withStoreArg(spec.inputSchema, strictStore),
300
433
  annotations: {
301
434
  readOnlyHint: false,
302
435
  destructiveHint: spec.destructiveHint,
@@ -415,8 +548,8 @@ var REWARD_KEYS = [
415
548
  "createdAt"
416
549
  ];
417
550
  var memberIdentifierSchema = {
418
- email: z.string().optional().describe("Member email. Matched exactly after lowercasing/trimming \u2014 typos return no result."),
419
- shopify_customer_id: z.string().optional().describe('Shopify customer ID (numeric string), e.g. "7348912345".')
551
+ email: z2.string().optional().describe("Member email. Matched exactly after lowercasing/trimming \u2014 typos return no result."),
552
+ shopify_customer_id: z2.string().optional().describe('Shopify customer ID (numeric string), e.g. "7348912345".')
420
553
  };
421
554
  function normalizeEmail(email) {
422
555
  return email.trim().toLowerCase();
@@ -456,7 +589,7 @@ function reclassifyCursorError(error, after) {
456
589
  throw error;
457
590
  }
458
591
  function registerMemberTools(server, ctx) {
459
- registerReadTool(server, {
592
+ registerReadTool(server, ctx, {
460
593
  name: "joy_find_member",
461
594
  title: "Find a loyalty member",
462
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.',
@@ -468,7 +601,7 @@ function registerMemberTools(server, ctx) {
468
601
  return summaryWithJson(`Member ${memberDisplayName(member)}: ${formatPoints(member.point)} points${tier}.`, shaped);
469
602
  }
470
603
  });
471
- registerReadTool(server, {
604
+ registerReadTool(server, ctx, {
472
605
  name: "joy_get_member_store_credit",
473
606
  title: "Get a member's store credit balance",
474
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.",
@@ -496,17 +629,17 @@ function registerMemberTools(server, ctx) {
496
629
  });
497
630
  }
498
631
  });
499
- registerReadTool(server, {
632
+ registerReadTool(server, ctx, {
500
633
  name: "joy_list_members",
501
634
  title: "List loyalty members",
502
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.',
503
636
  inputSchema: {
504
- limit: z.number().int().min(1).max(100).optional().describe("Members per page (default 20, max 100)."),
505
- sort_by: z.enum(["points", "tier_points", "created_at", "recent_activity"]).optional().describe('Sort field (default recent_activity). "points" = current spendable balance (use for top/bottom members by points); "tier_points" = VIP tier progress points; "created_at" = signup date; "recent_activity" = last update.'),
506
- direction: z.enum(["desc", "asc"]).optional().describe('"desc" (default) = highest/newest first; "asc" = lowest/oldest first.'),
507
- tier_id: z.string().optional().describe("Only members currently in this VIP tier. Must be a tier `id` from joy_list_tiers, NOT the tier name."),
508
- include_total_count: z.boolean().optional().describe("Set true when the merchant asks HOW MANY members/customers there are \u2014 returns total_members (exact count of the whole list, respecting tier_id). Combine with limit 1 when only the number is needed. Costs extra database reads; leave unset otherwise."),
509
- after: z.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one, and only reuse it with the SAME sort_by/direction/tier_id that produced it.")
637
+ limit: z2.number().int().min(1).max(100).optional().describe("Members per page (default 20, max 100)."),
638
+ sort_by: z2.enum(["points", "tier_points", "created_at", "recent_activity"]).optional().describe('Sort field (default recent_activity). "points" = current spendable balance (use for top/bottom members by points); "tier_points" = VIP tier progress points; "created_at" = signup date; "recent_activity" = last update.'),
639
+ direction: z2.enum(["desc", "asc"]).optional().describe('"desc" (default) = highest/newest first; "asc" = lowest/oldest first.'),
640
+ tier_id: z2.string().optional().describe("Only members currently in this VIP tier. Must be a tier `id` from joy_list_tiers, NOT the tier name."),
641
+ include_total_count: z2.boolean().optional().describe("Set true when the merchant asks HOW MANY members/customers there are \u2014 returns total_members (exact count of the whole list, respecting tier_id). Combine with limit 1 when only the number is needed. Costs extra database reads; leave unset otherwise."),
642
+ after: z2.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one, and only reuse it with the SAME sort_by/direction/tier_id that produced it.")
510
643
  },
511
644
  handler: async (args) => {
512
645
  const limit = args.limit ?? 20;
@@ -557,14 +690,14 @@ function registerMemberTools(server, ctx) {
557
690
  });
558
691
  }
559
692
  });
560
- registerReadTool(server, {
693
+ registerReadTool(server, ctx, {
561
694
  name: "joy_get_member_activity",
562
695
  title: "Get a member's point activity",
563
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.',
564
697
  inputSchema: {
565
698
  ...memberIdentifierSchema,
566
- limit: z.number().int().min(1).max(50).optional().describe("Transactions per page (default 10, max 50)."),
567
- after: z.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one.")
699
+ limit: z2.number().int().min(1).max(50).optional().describe("Transactions per page (default 10, max 50)."),
700
+ after: z2.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one.")
568
701
  },
569
702
  handler: async (args) => {
570
703
  const limit = args.limit ?? 10;
@@ -594,14 +727,14 @@ function registerMemberTools(server, ctx) {
594
727
  });
595
728
  }
596
729
  });
597
- registerReadTool(server, {
730
+ registerReadTool(server, ctx, {
598
731
  name: "joy_get_member_rewards",
599
732
  title: "Get a member's reward/coupon history",
600
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?".`,
601
734
  inputSchema: {
602
735
  ...memberIdentifierSchema,
603
- limit: z.number().int().min(1).max(50).optional().describe("Rewards per page (default 10, max 50)."),
604
- after: z.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one.")
736
+ limit: z2.number().int().min(1).max(50).optional().describe("Rewards per page (default 10, max 50)."),
737
+ after: z2.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one.")
605
738
  },
606
739
  handler: async (args) => {
607
740
  const limit = args.limit ?? 10;
@@ -634,7 +767,7 @@ function registerMemberTools(server, ctx) {
634
767
  }
635
768
 
636
769
  // ../mcp-core/build/tools/tiers.js
637
- import { z as z2 } from "zod";
770
+ import { z as z3 } from "zod";
638
771
  var TIER_KEYS = [
639
772
  "id",
640
773
  "name",
@@ -650,23 +783,68 @@ var TIER_KEYS = [
650
783
  "systemType"
651
784
  ];
652
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
+ }
653
822
  function getTiersCached(ctx) {
654
823
  return ctx.cache.get("tiers", TIERS_TTL_MS, async () => {
655
- const { data } = await ctx.api.getData("/tiers");
656
- 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
+ };
657
832
  });
658
833
  }
659
834
  function registerTierTools(server, ctx) {
660
- registerReadTool(server, {
835
+ registerReadTool(server, ctx, {
661
836
  name: "joy_list_tiers",
662
837
  title: "List VIP tiers",
663
- 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.',
664
839
  inputSchema: {
665
- include_member_counts: z2.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.")
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.")
666
841
  },
667
842
  handler: async (args) => {
668
843
  if (args.include_member_counts === true) {
669
- 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
+ ]);
670
848
  const tiers2 = Array.isArray(data) ? data : [];
671
849
  if (tiers2.length === 0) {
672
850
  return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
@@ -674,13 +852,19 @@ function registerTierTools(server, ctx) {
674
852
  const keys = [...TIER_KEYS.filter((key) => key !== "members"), "memberCount"];
675
853
  const total = tiers2.reduce((sum, tier) => sum + (Number(tier.memberCount) || 0), 0);
676
854
  const top = [...tiers2].sort((a, b) => (Number(b.memberCount) || 0) - (Number(a.memberCount) || 0))[0];
677
- 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
+ });
678
859
  }
679
- const tiers = await getTiersCached(ctx);
860
+ const { tiers, settings } = await getTiersCached(ctx);
680
861
  if (tiers.length === 0) {
681
862
  return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
682
863
  }
683
- 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
+ });
684
868
  }
685
869
  });
686
870
  }
@@ -691,9 +875,9 @@ function registerTierWriteTools(server, ctx) {
691
875
  description: "WRITE \u2014 manually moves ONE member to a VIP tier (may trigger tier rewards for the member). Find valid tier IDs via joy_list_tiers first. Setting the tier the member already has is a safe no-op. admin_note is recorded on the tier-change activity.",
692
876
  inputSchema: {
693
877
  ...memberIdentifierSchema,
694
- tier_id: z2.string().describe("Target tier ID (from joy_list_tiers)."),
695
- reason: z2.string().min(3).describe("Why the tier is being changed \u2014 REQUIRED, recorded on the tier-change activity."),
696
- admin_note: z2.string().optional().describe("Optional extra note for the merchant team.")
878
+ tier_id: z3.string().describe("Target tier ID (from joy_list_tiers)."),
879
+ reason: z3.string().min(3).describe("Why the tier is being changed \u2014 REQUIRED, recorded on the tier-change activity."),
880
+ admin_note: z3.string().optional().describe("Optional extra note for the merchant team.")
697
881
  },
698
882
  destructiveHint: true,
699
883
  idempotentHint: true,
@@ -719,7 +903,7 @@ function registerTierWriteTools(server, ctx) {
719
903
  }
720
904
 
721
905
  // ../mcp-core/build/tools/programs.js
722
- import { z as z3 } from "zod";
906
+ import { z as z4 } from "zod";
723
907
  var EARNING_KEYS = [
724
908
  "id",
725
909
  "event",
@@ -730,6 +914,9 @@ var EARNING_KEYS = [
730
914
  "rateMoney",
731
915
  "earnPoint",
732
916
  "earnPointsTiers",
917
+ "isAppliedVipTier",
918
+ "appliedPlaceOrderTo",
919
+ "skipEarnPointGuest",
733
920
  "conditions",
734
921
  "typeMilestone",
735
922
  "milestones",
@@ -737,7 +924,15 @@ var EARNING_KEYS = [
737
924
  "endDate",
738
925
  "priority",
739
926
  "isDraft",
740
- "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"
741
936
  ];
742
937
  var SPENDING_KEYS = [
743
938
  "id",
@@ -763,13 +958,49 @@ var SPENDING_KEYS = [
763
958
  "hasLimitShipping",
764
959
  "limitShipping",
765
960
  "enableMaxCapShipping",
766
- "maxCapShipping"
961
+ "maxCapShipping",
962
+ // See EARNING_KEYS — same reason, same pair.
963
+ "loyaltyPageIcon",
964
+ "loyaltyPageCustomIcon"
767
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
+ }
768
999
  function registerProgramTools(server, ctx) {
769
- registerReadTool(server, {
1000
+ registerReadTool(server, ctx, {
770
1001
  name: "joy_list_earning_rules",
771
1002
  title: "List point-earning rules",
772
- 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.',
773
1004
  inputSchema: {},
774
1005
  handler: async () => {
775
1006
  const { data } = await ctx.api.getData("/programs/earning");
@@ -777,15 +1008,15 @@ function registerProgramTools(server, ctx) {
777
1008
  if (programs.length === 0) {
778
1009
  return "No earning rules configured \u2014 customers currently have no way to earn points.";
779
1010
  }
780
- 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));
781
1012
  }
782
1013
  });
783
- registerReadTool(server, {
1014
+ registerReadTool(server, ctx, {
784
1015
  name: "joy_list_redemption_rules",
785
1016
  title: "List point-redemption rules",
786
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.',
787
1018
  inputSchema: {
788
- include_redeem_counts: z3.boolean().optional().describe("Set true ONLY when the merchant asks about usage/popularity \u2014 adds redeemCount (all-time number of redemptions) to each rule. Leave unset for pure configuration questions; counting costs extra database reads.")
1019
+ include_redeem_counts: z4.boolean().optional().describe("Set true ONLY when the merchant asks about usage/popularity \u2014 adds redeemCount (all-time number of redemptions) to each rule. Leave unset for pure configuration questions; counting costs extra database reads.")
789
1020
  },
790
1021
  handler: async (args) => {
791
1022
  const withCounts = args.include_redeem_counts === true;
@@ -809,7 +1040,102 @@ function registerProgramTools(server, ctx) {
809
1040
  }
810
1041
 
811
1042
  // ../mcp-core/build/tools/referrals.js
812
- import { z as z4 } from "zod";
1043
+ import { z as z5 } from "zod";
1044
+
1045
+ // ../mcp-core/build/tools/configSupport.js
1046
+ function parseShopifyProductUrl(productUrl) {
1047
+ try {
1048
+ const url = new URL(productUrl);
1049
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
1050
+ return { ok: false, error: "Product URLs must start with http:// or https://." };
1051
+ }
1052
+ const path = url.pathname.split("/").filter(Boolean);
1053
+ const directHandle = path.length === 2 && path[0] === "products" ? path[1] : void 0;
1054
+ const collectionHandle = path.length === 4 && path[0] === "collections" && path[2] === "products" ? path[3] : void 0;
1055
+ const handle = directHandle ?? collectionHandle;
1056
+ if (!handle) {
1057
+ return {
1058
+ ok: false,
1059
+ error: "Use a Shopify product URL in the form https://shop.myshopify.com/products/<handle> or https://shop.myshopify.com/collections/<collection>/products/<handle>."
1060
+ };
1061
+ }
1062
+ return { ok: true, handle: decodeURIComponent(handle) };
1063
+ } catch {
1064
+ return {
1065
+ ok: false,
1066
+ error: "Use a Shopify product URL in the form https://shop.myshopify.com/products/<handle> or https://shop.myshopify.com/collections/<collection>/products/<handle>."
1067
+ };
1068
+ }
1069
+ }
1070
+ function isSameValue(a, b) {
1071
+ if (a === b)
1072
+ return true;
1073
+ if (typeof a === "number" && typeof b === "string")
1074
+ return String(a) === b;
1075
+ if (typeof a === "string" && typeof b === "number")
1076
+ return a === String(b);
1077
+ if (Array.isArray(a) && Array.isArray(b))
1078
+ return JSON.stringify(a) === JSON.stringify(b);
1079
+ return false;
1080
+ }
1081
+ function diffFields(current, incoming) {
1082
+ const changes = [];
1083
+ for (const [field, to] of Object.entries(incoming)) {
1084
+ if (to === void 0)
1085
+ continue;
1086
+ const from = current[field];
1087
+ if (isSameValue(from, to))
1088
+ continue;
1089
+ changes.push({ field, from, to });
1090
+ }
1091
+ return changes;
1092
+ }
1093
+ function droppedFields(incoming, allowedKeys) {
1094
+ const allowed = new Set(allowedKeys);
1095
+ return Object.entries(incoming).filter(([field, value]) => value !== void 0 && !allowed.has(field)).map(([field]) => field);
1096
+ }
1097
+ function findDuplicate(existing, candidate) {
1098
+ const normalise = (value) => String(value ?? "").trim().toLowerCase();
1099
+ const event = normalise(candidate.event);
1100
+ const title = normalise(candidate.title);
1101
+ return existing.find((item) => {
1102
+ if (event && normalise(item.event) !== event)
1103
+ return false;
1104
+ if (title)
1105
+ return normalise(item.title) === title;
1106
+ return Boolean(event);
1107
+ });
1108
+ }
1109
+ var formatValue = (value) => value === void 0 || value === null || value === "" ? "(unset)" : JSON.stringify(value);
1110
+ function buildChangePreview(input) {
1111
+ const lines = [];
1112
+ lines.push(`DRY RUN \u2014 nothing has been changed yet.`);
1113
+ lines.push(`Would ${input.action} ${input.target}.`);
1114
+ lines.push(`Reason given: ${input.reason}`);
1115
+ if (input.duplicate) {
1116
+ lines.push(`\u26A0\uFE0F A matching rule already exists (id ${String(input.duplicate.id)}, "${String(input.duplicate.title)}"). Creating this would leave TWO live rules for the same event \u2014 configuration writes are not idempotent, so this is not a replay. Update the existing one by passing its id instead, unless the merchant explicitly wants a second rule.`);
1117
+ }
1118
+ if (input.changes.length === 0) {
1119
+ lines.push("No field would change \u2014 this call is a no-op. Do not report it as a change.");
1120
+ } else {
1121
+ lines.push("", "Changes:");
1122
+ for (const change of input.changes) {
1123
+ lines.push(` ${change.field}: ${formatValue(change.from)} \u2192 ${formatValue(change.to)}`);
1124
+ }
1125
+ }
1126
+ if (input.dropped && input.dropped.length > 0) {
1127
+ lines.push("", `\u26A0\uFE0F These fields would be SILENTLY DISCARDED by the backend (not valid for this record type): ${input.dropped.join(", ")}. The call would still return success. Remove them or use the right tool \u2014 do not report them as applied.`);
1128
+ }
1129
+ for (const warning of input.warnings ?? [])
1130
+ lines.push("", `\u26A0\uFE0F ${warning}`);
1131
+ lines.push("", "This affects every customer, not one member, and takes effect as soon as it saves. Show this to the merchant and get an explicit yes; then call again with apply: true and the same arguments. Joy does not record who changed configuration or why, so this preview is the only audit trail.");
1132
+ return lines.join("\n");
1133
+ }
1134
+ function requireApply(apply, preview) {
1135
+ return apply === true ? void 0 : preview;
1136
+ }
1137
+
1138
+ // ../mcp-core/build/tools/referrals.js
813
1139
  var REFERRAL_ACTIVITY_KEYS = [
814
1140
  "id",
815
1141
  "email",
@@ -841,6 +1167,113 @@ var REFERRAL_PROGRAM_KEYS = [
841
1167
  "referralSharingMode",
842
1168
  "autoRevokeReferrerReward"
843
1169
  ];
1170
+ var CUSTOMER_REWARD_DISCOUNT_TYPES = [
1171
+ "percentage",
1172
+ "amount",
1173
+ "point",
1174
+ "point_order_value",
1175
+ "store_credit_fixed",
1176
+ "store_credit_order_value"
1177
+ ];
1178
+ var FRIEND_REWARD_DISCOUNT_TYPES = ["percentage", "amount", "store_credit_fixed"];
1179
+ var REFERRAL_EXPIRATION_PATTERN = /^(?:permanent|specific|\d+(?:min|D|W|M|Y))$/;
1180
+ var ORDER_REQUIREMENTS = ["none", "min_amount", "min_quantity"];
1181
+ var REFERRAL_PROGRAM_ARGUMENTS = {
1182
+ status: "status",
1183
+ title: "title",
1184
+ customer_reward_earn_amount: "customerRewardEarnAmount",
1185
+ customer_reward_discount_type: "customerRewardDiscountType",
1186
+ customer_reward_expired_after: "customerRewardExpiredAfter",
1187
+ customer_reward_order_req: "customerRewardOrderReq",
1188
+ customer_reward_order_req_amount: "customerRewardOrderReqAmount",
1189
+ friend_reward_earn_amount: "friendRewardEarnAmount",
1190
+ friend_reward_discount_type: "friendRewardDiscountType",
1191
+ friend_reward_expired_after: "friendRewardExpiredAfter",
1192
+ friend_reward_order_req: "friendRewardOrderReq",
1193
+ friend_reward_order_req_amount: "friendRewardOrderReqAmount",
1194
+ max_referrals_per_customer: "maxReferralsPerCustomer"
1195
+ };
1196
+ var referralConfigInputShape = {
1197
+ status: z5.boolean().optional().describe("Enable (true) or disable (false) the referral program."),
1198
+ title: z5.string().trim().min(1).optional().describe("Merchant-facing referral program title."),
1199
+ customer_reward_earn_amount: z5.number().min(0).optional().describe("Reward amount for the REFERRER (the existing customer/advocate)."),
1200
+ customer_reward_discount_type: z5.enum(CUSTOMER_REWARD_DISCOUNT_TYPES).optional().describe("REFERRER reward type. Supports six methods: percentage, amount, point, point_order_value, store_credit_fixed, or store_credit_order_value. This is deliberately broader than friend_reward_discount_type."),
1201
+ customer_reward_expired_after: z5.string().regex(REFERRAL_EXPIRATION_PATTERN).optional().describe("REFERRER reward expiration: permanent, specific, or a duration such as 30D, 2W, 3M, 1Y, or 90min."),
1202
+ customer_reward_order_req: z5.enum(ORDER_REQUIREMENTS).optional().describe("Order requirement for the REFERRER reward: none, min_amount, or min_quantity."),
1203
+ customer_reward_order_req_amount: z5.number().min(0).optional().describe("Minimum amount or quantity for the REFERRER reward order requirement."),
1204
+ friend_reward_earn_amount: z5.number().min(0).optional().describe("Reward amount for the referred FRIEND. Must be 100 or less when friend_reward_discount_type is percentage."),
1205
+ friend_reward_discount_type: z5.enum(FRIEND_REWARD_DISCOUNT_TYPES).optional().describe("Referred FRIEND reward type. ONLY percentage, amount, or store_credit_fixed are supported. Unlike the referrer reward, point and order-value reward types are invalid here."),
1206
+ friend_reward_expired_after: z5.string().regex(REFERRAL_EXPIRATION_PATTERN).optional().describe("FRIEND reward expiration: permanent, specific, or a duration such as 30D, 2W, 3M, 1Y, or 90min."),
1207
+ friend_reward_order_req: z5.enum(ORDER_REQUIREMENTS).optional().describe("Order requirement for the FRIEND reward: none, min_amount, or min_quantity."),
1208
+ friend_reward_order_req_amount: z5.number().min(0).optional().describe("Minimum amount or quantity for the FRIEND reward order requirement."),
1209
+ max_referrals_per_customer: z5.number().int().min(0).optional().describe("Maximum successful referrals allowed per customer. Use 0 only when the merchant intends that configured value."),
1210
+ reason: z5.string().min(3).describe("Why the merchant wants this shop-wide referral configuration changed."),
1211
+ apply: z5.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
1212
+ };
1213
+ var referralConfigArgsSchema = z5.object(referralConfigInputShape).strict().superRefine((args, ctx) => {
1214
+ if (args.friend_reward_discount_type === "percentage" && (args.friend_reward_earn_amount ?? 0) > 100) {
1215
+ ctx.addIssue({
1216
+ code: z5.ZodIssueCode.custom,
1217
+ path: ["friend_reward_earn_amount"],
1218
+ message: "A percentage friend reward cannot exceed 100."
1219
+ });
1220
+ }
1221
+ });
1222
+ function toReferralProgramBody(args) {
1223
+ const body = {};
1224
+ for (const [argument, field] of Object.entries(REFERRAL_PROGRAM_ARGUMENTS)) {
1225
+ const value = args[argument];
1226
+ if (value !== void 0)
1227
+ body[field] = value;
1228
+ }
1229
+ return body;
1230
+ }
1231
+ function parseReferralConfigArgs(args) {
1232
+ const parsed = referralConfigArgsSchema.safeParse(args);
1233
+ if (parsed.success)
1234
+ return parsed.data;
1235
+ const issue = parsed.error.issues[0];
1236
+ const field = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
1237
+ throw new JoyApiError("validation", `Invalid referral configuration \u2014 ${field}${issue.message} Fix the named parameter and call again.`);
1238
+ }
1239
+ function validateEffectiveFriendPercentage(current, body) {
1240
+ const type = body.friendRewardDiscountType ?? current.friendRewardDiscountType;
1241
+ const amount = body.friendRewardEarnAmount ?? current.friendRewardEarnAmount;
1242
+ if (type === "percentage" && typeof amount === "number" && amount > 100) {
1243
+ throw new JoyApiError("validation", "Invalid referral configuration \u2014 friend_reward_earn_amount cannot exceed 100 when the effective friend reward type is percentage. Lower the amount or choose amount/store_credit_fixed, then call again.");
1244
+ }
1245
+ }
1246
+ async function configureReferralProgram(ctx, input) {
1247
+ const args = parseReferralConfigArgs(input);
1248
+ const body = toReferralProgramBody(args);
1249
+ if (Object.keys(body).length === 0) {
1250
+ throw new JoyApiError("validation", `At least one referral configuration field is required. Valid fields: ${Object.keys(REFERRAL_PROGRAM_ARGUMENTS).join(", ")}.`);
1251
+ }
1252
+ const { data } = await ctx.api.getData("/referrals/program");
1253
+ const current = data ?? {};
1254
+ validateEffectiveFriendPercentage(current, body);
1255
+ const changes = diffFields(current, body);
1256
+ const preview = buildChangePreview({
1257
+ action: "UPDATE",
1258
+ target: `referral program "${String(body.title ?? current.title ?? "current")}"`,
1259
+ reason: args.reason,
1260
+ changes
1261
+ });
1262
+ if (changes.length === 0)
1263
+ return preview;
1264
+ const blocked = requireApply(args.apply, preview);
1265
+ if (blocked)
1266
+ return blocked;
1267
+ await ctx.api.mutateData("/referrals/program", { method: "PUT", body });
1268
+ const { data: refreshedData } = await ctx.api.getData("/referrals/program");
1269
+ const refreshed = refreshedData ?? {};
1270
+ const unconfirmed = diffFields(refreshed, body).map((change) => change.field);
1271
+ if (unconfirmed.length > 0) {
1272
+ throw new JoyApiError("server", `The referral program write was not confirmed: these requested fields did not match after the required read-back: ${unconfirmed.join(", ")}. Do not report the referral program as configured; ask the merchant to verify it in Joy admin and report this backend failure. Do not retry blindly.`);
1273
+ }
1274
+ const appliedFields = Object.keys(body);
1275
+ return summaryWithJson(`Updated the referral program and confirmed ${appliedFields.length} field${appliedFields.length === 1 ? "" : "s"} by reading it back. This affects every customer immediately.`, compact(refreshed, appliedFields));
1276
+ }
844
1277
  function formatReferralReward(amount, type) {
845
1278
  if (amount === void 0 || amount === null)
846
1279
  return "not configured";
@@ -853,7 +1286,7 @@ function formatReferralReward(amount, type) {
853
1286
  return `${String(amount)} (${String(type ?? "unknown type")})`;
854
1287
  }
855
1288
  function registerReferralTools(server, ctx) {
856
- registerReadTool(server, {
1289
+ registerReadTool(server, ctx, {
857
1290
  name: "joy_get_referral_program",
858
1291
  title: "Get referral program status & configuration",
859
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.',
@@ -875,13 +1308,13 @@ function registerReferralTools(server, ctx) {
875
1308
  return summaryWithJson(`Referral program is ${enabled ? "ENABLED (ON)" : "DISABLED (OFF)"} \u2014 referrer gets ${referrerReward}, referred friend gets ${friendReward}.`, compact(program, REFERRAL_PROGRAM_KEYS));
876
1309
  }
877
1310
  });
878
- registerReadTool(server, {
1311
+ registerReadTool(server, ctx, {
879
1312
  name: "joy_get_referral_performance",
880
1313
  title: "Get referral program performance",
881
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.",
882
1315
  inputSchema: {
883
- start_date: z4.string().optional().describe("ISO date (YYYY-MM-DD), inclusive start of the range."),
884
- end_date: z4.string().optional().describe("ISO date (YYYY-MM-DD), inclusive end of the range.")
1316
+ start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD), inclusive start of the range."),
1317
+ end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD), inclusive end of the range.")
885
1318
  },
886
1319
  handler: async (args) => {
887
1320
  const { data } = await ctx.api.getData("/referrals/analytics", {
@@ -892,7 +1325,7 @@ function registerReferralTools(server, ctx) {
892
1325
  return summaryWithJson(`${count} referral coupon(s) were used${range} (completed referrals).`, compact(data ?? {}));
893
1326
  }
894
1327
  });
895
- registerReadTool(server, {
1328
+ registerReadTool(server, ctx, {
896
1329
  name: "joy_get_member_referrals",
897
1330
  title: "Get one member's referral results",
898
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.",
@@ -914,14 +1347,25 @@ function registerReferralTools(server, ctx) {
914
1347
  }
915
1348
  });
916
1349
  }
1350
+ function registerReferralConfigTools(server, ctx) {
1351
+ registerConfigWriteTool(server, ctx, {
1352
+ name: "joy_configure_referral_program",
1353
+ title: "Configure the referral program",
1354
+ description: "CONFIGURATION WRITE \u2014 updates the shop's single existing referral program; there is no create branch and no program ID. customer_reward_* fields configure the REFERRER/advocate reward, while friend_reward_* fields configure the referred FRIEND reward. Their discount-type enums deliberately differ: the referrer supports six methods, but the friend supports ONLY percentage, amount, or store_credit_fixed (never point or order-value types). Previews by default, requires explicit approval to apply, and reads every requested field back before reporting success. Anti-cheat and eligibility settings are intentionally unavailable through this tool.",
1355
+ inputSchema: referralConfigInputShape,
1356
+ destructiveHint: true,
1357
+ strictArguments: true,
1358
+ handler: (args) => configureReferralProgram(ctx, args)
1359
+ });
1360
+ }
917
1361
  function registerReferralWriteTools(server, ctx) {
918
1362
  registerWriteTool(server, ctx, {
919
1363
  name: "joy_create_referral_invite",
920
1364
  title: "Generate a referral invite link",
921
1365
  description: "WRITE \u2014 generates a personal referral invite link for an email address. For an EXISTING member this just returns their own link (safe, cheap). For an email that is NOT yet a member, this CREATES A REAL SHOPIFY CUSTOMER RECORD \u2014 irreversible from this tool \u2014 and may trigger sign-up point rewards for them. Double-check the email spelling with the merchant before calling for a non-member; a typo creates a junk customer, not an error. Identify by email only (this can create a new record, so it never accepts shopify_customer_id).",
922
1366
  inputSchema: {
923
- email: z4.string().email().describe("Email address to generate the invite link for. Must be a real, verified address \u2014 a typo creates a real customer record, not an error."),
924
- use_marketing: z4.boolean().optional().describe("Marketing consent for a NEW customer record. Defaults to false (no consent). Only pass true if the person actually opted in to marketing \u2014 this can set real email-marketing-consent on their Shopify customer record.")
1367
+ email: z5.string().email().describe("Email address to generate the invite link for. Must be a real, verified address \u2014 a typo creates a real customer record, not an error."),
1368
+ use_marketing: z5.boolean().optional().describe("Marketing consent for a NEW customer record. Defaults to false (no consent). Only pass true if the person actually opted in to marketing \u2014 this can set real email-marketing-consent on their Shopify customer record.")
925
1369
  },
926
1370
  destructiveHint: false,
927
1371
  idempotentHint: false,
@@ -940,7 +1384,7 @@ function registerReferralWriteTools(server, ctx) {
940
1384
  // ../mcp-core/build/tools/overview.js
941
1385
  var OVERVIEW_TTL_MS = 15 * 60 * 1e3;
942
1386
  function registerOverviewTools(server, ctx) {
943
- registerReadTool(server, {
1387
+ registerReadTool(server, ctx, {
944
1388
  name: "joy_get_loyalty_overview",
945
1389
  title: "Get loyalty program overview",
946
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.',
@@ -967,7 +1411,7 @@ function registerOverviewTools(server, ctx) {
967
1411
  }
968
1412
 
969
1413
  // ../mcp-core/build/tools/analytics.js
970
- import { z as z5 } from "zod";
1414
+ import { z as z6 } from "zod";
971
1415
  function toDateRangeQuery(args) {
972
1416
  const query = {};
973
1417
  if (args.start_date !== void 0)
@@ -1064,8 +1508,8 @@ var LOYALTY_REVENUE_KEYS = [
1064
1508
  var TIER_PERFORMANCE_KEYS = ["metric", "period", "rows", "topGroup"];
1065
1509
  var HEALTH_CHECK_KEYS = ["healthScore", "flagCount", "flags", "snapshot"];
1066
1510
  var reportDateRangeSchema = {
1067
- start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD), inclusive start of the report window. Defaults to 30 days ago. Effective range max 366 days."),
1068
- end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD), inclusive end of the report window. Defaults to today.")
1511
+ start_date: z6.string().optional().describe("ISO date (YYYY-MM-DD), inclusive start of the report window. Defaults to 30 days ago. Effective range max 366 days."),
1512
+ end_date: z6.string().optional().describe("ISO date (YYYY-MM-DD), inclusive end of the report window. Defaults to today.")
1069
1513
  };
1070
1514
  var SCOPE_HELP = "A 403 mentioning read_reports means the MERCHANT has not granted Shopify reports access to Joy \u2014 not a credential problem, do not retry; ask them to grant it from Joy settings.";
1071
1515
  function withScopeGuidance(error) {
@@ -1108,14 +1552,14 @@ async function getScopedReport(ctx, path, options = {}) {
1108
1552
  }
1109
1553
  }
1110
1554
  function registerAnalyticsTools(server, ctx) {
1111
- registerReadTool(server, {
1555
+ registerReadTool(server, ctx, {
1112
1556
  name: "joy_get_analytics_report",
1113
1557
  title: "Loyalty analytics report (Analytics V2)",
1114
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,
1115
1559
  inputSchema: {
1116
- report_type: z5.enum(V2_REPORT_TYPES).describe("Which Analytics V2 loyalty report to retrieve (see the menu in the tool description)."),
1560
+ report_type: z6.enum(V2_REPORT_TYPES).describe("Which Analytics V2 loyalty report to retrieve (see the menu in the tool description)."),
1117
1561
  ...reportDateRangeSchema,
1118
- unit: z5.enum(["day", "month"]).optional().describe("Time grain for trend reports. Defaults by range size (\u2264100 days \u2192 day, else month).")
1562
+ unit: z6.enum(["day", "month"]).optional().describe("Time grain for trend reports. Defaults by range size (\u2264100 days \u2192 day, else month).")
1119
1563
  },
1120
1564
  handler: async (args) => {
1121
1565
  let report;
@@ -1136,16 +1580,16 @@ function registerAnalyticsTools(server, ctx) {
1136
1580
  ${summary}`);
1137
1581
  }
1138
1582
  });
1139
- registerReadTool(server, {
1583
+ registerReadTool(server, ctx, {
1140
1584
  name: "joy_get_loyalty_revenue_report",
1141
1585
  title: "Loyalty revenue impact (Shopify sales data)",
1142
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.",
1143
1587
  inputSchema: {
1144
- metric: z5.enum(["total_sales", "aov", "returning_rate", "orders"]).describe("Which revenue metric to compute (see description for definitions)."),
1145
- period: z5.enum(["last_7d", "last_30d", "last_90d", "last_1y", "this_month"]).optional().describe("Relative reporting period. Default last_30d. Ignored when start_date/end_date are given."),
1146
- compare_to_last_year: z5.boolean().optional().describe("Add previous-year comparison (previousValue + deltaPercent). total_sales only; ignored with a custom date range."),
1147
- start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range start. Requires end_date too; max 366 days."),
1148
- end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range end. Requires start_date too; not in the future.")
1588
+ metric: z6.enum(["total_sales", "aov", "returning_rate", "orders"]).describe("Which revenue metric to compute (see description for definitions)."),
1589
+ period: z6.enum(["last_7d", "last_30d", "last_90d", "last_1y", "this_month"]).optional().describe("Relative reporting period. Default last_30d. Ignored when start_date/end_date are given."),
1590
+ compare_to_last_year: z6.boolean().optional().describe("Add previous-year comparison (previousValue + deltaPercent). total_sales only; ignored with a custom date range."),
1591
+ start_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range start. Requires end_date too; max 366 days."),
1592
+ end_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range end. Requires start_date too; not in the future.")
1149
1593
  },
1150
1594
  handler: async (args) => {
1151
1595
  const data = await getScopedReport(ctx, "/analytics/loyalty-revenue", {
@@ -1156,14 +1600,14 @@ ${summary}`);
1156
1600
  return summaryWithJson(summary, compact(data, LOYALTY_REVENUE_KEYS));
1157
1601
  }
1158
1602
  });
1159
- registerReadTool(server, {
1603
+ registerReadTool(server, ctx, {
1160
1604
  name: "joy_get_tier_performance_breakdown",
1161
1605
  title: "Sales performance by VIP tier",
1162
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,
1163
1607
  inputSchema: {
1164
- metric: z5.enum(["total_sales", "aov", "orders_per_customer", "spend_per_customer"]).describe("Which per-tier metric to compute."),
1165
- start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range start. Requires end_date too; max 366 days. Omit both for all-time."),
1166
- end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range end. Requires start_date too.")
1608
+ metric: z6.enum(["total_sales", "aov", "orders_per_customer", "spend_per_customer"]).describe("Which per-tier metric to compute."),
1609
+ start_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range start. Requires end_date too; max 366 days. Omit both for all-time."),
1610
+ end_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range end. Requires start_date too.")
1167
1611
  },
1168
1612
  handler: async (args) => {
1169
1613
  let data;
@@ -1183,13 +1627,13 @@ ${summary}`);
1183
1627
  return summaryWithJson(summary, compact(data, TIER_PERFORMANCE_KEYS));
1184
1628
  }
1185
1629
  });
1186
- registerReadTool(server, {
1630
+ registerReadTool(server, ctx, {
1187
1631
  name: "joy_get_loyalty_health_check",
1188
1632
  title: "Loyalty program health check",
1189
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.`,
1190
1634
  inputSchema: {
1191
- start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom current-window start. Requires end_date too."),
1192
- end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom current-window end. Requires start_date too.")
1635
+ start_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom current-window start. Requires end_date too."),
1636
+ end_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom current-window end. Requires start_date too.")
1193
1637
  },
1194
1638
  handler: async (args) => {
1195
1639
  const { data } = await ctx.api.getData("/analytics/health-check", {
@@ -1208,7 +1652,7 @@ function registerAnalyticsWriteTools(server, ctx) {
1208
1652
  title: "Switch the shop to Analytics V2 (starts data backfill)",
1209
1653
  description: 'WRITE \u2014 enables the new Analytics V2 for this shop and starts the historical data backfill in the background. Call this when joy_get_analytics_report returned the "shop is still on Analytics V1" error AND the merchant has EXPLICITLY agreed to switch \u2014 never call it unprompted: it changes the default Analytics experience in their Joy admin and runs a data migration that can take from a few minutes to a few tens of minutes. IDEMPOTENT and doubles as a STATUS CHECK: calling it again never restarts a running backfill \u2014 it returns progress (running/failed/already enabled) instead, so use it to answer "is the migration done yet?".',
1210
1654
  inputSchema: {
1211
- merchant_confirmed: z5.literal(true).describe("Must be true \u2014 pass it ONLY after the merchant explicitly agreed to switch to the new Analytics. If they have not been asked yet, ask first instead of calling this tool.")
1655
+ merchant_confirmed: z6.literal(true).describe("Must be true \u2014 pass it ONLY after the merchant explicitly agreed to switch to the new Analytics. If they have not been asked yet, ask first instead of calling this tool.")
1212
1656
  },
1213
1657
  destructiveHint: false,
1214
1658
  idempotentHint: true,
@@ -1224,7 +1668,7 @@ function registerAnalyticsWriteTools(server, ctx) {
1224
1668
  }
1225
1669
 
1226
1670
  // ../mcp-core/build/tools/points.js
1227
- import { z as z6 } from "zod";
1671
+ import { z as z7 } from "zod";
1228
1672
 
1229
1673
  // ../mcp-core/build/tools/writeSupport.js
1230
1674
  import { randomUUID } from "node:crypto";
@@ -1290,9 +1734,9 @@ var MUTATION_RESULT_KEYS = [
1290
1734
  ];
1291
1735
  var writeArgsSchema = {
1292
1736
  ...memberIdentifierSchema,
1293
- reason: z6.string().min(3).describe("Why this change is being made \u2014 REQUIRED, recorded permanently in the audit trail."),
1294
- admin_note: z6.string().optional().describe("Optional internal note for the merchant team."),
1295
- idempotency_key: z6.string().max(255).optional().describe("Only pass this when RETRYING an uncertain outcome: reuse the exact key from the previous error message so the backend replays instead of applying twice. Omit for a new operation.")
1737
+ reason: z7.string().min(3).describe("Why this change is being made \u2014 REQUIRED, recorded permanently in the audit trail."),
1738
+ admin_note: z7.string().optional().describe("Optional internal note for the merchant team."),
1739
+ idempotency_key: z7.string().max(255).optional().describe("Only pass this when RETRYING an uncertain outcome: reuse the exact key from the previous error message so the backend replays instead of applying twice. Omit for a new operation.")
1296
1740
  };
1297
1741
  async function executePointMutation(params) {
1298
1742
  const { ctx, path, member, point, reason, adminNote, idempotencyKey } = params;
@@ -1320,7 +1764,7 @@ function registerPointWriteTools(server, ctx) {
1320
1764
  description: "WRITE \u2014 gives points to ONE loyalty member (points are merchant money; the host will ask the user to approve). Identify the member by email or Shopify customer ID. reason is required and goes to the permanent audit trail. The operation is protected by an idempotency key, so a confirmed retry with the returned key can never double-award.",
1321
1765
  inputSchema: {
1322
1766
  ...writeArgsSchema,
1323
- points: z6.number().int().positive().describe("Points to add (positive integer).")
1767
+ points: z7.number().int().positive().describe("Points to add (positive integer).")
1324
1768
  },
1325
1769
  destructiveHint: false,
1326
1770
  idempotentHint: false,
@@ -1345,8 +1789,8 @@ function registerPointWriteTools(server, ctx) {
1345
1789
  description: `WRITE, destructive \u2014 removes points from ONE member (points are merchant money; cannot be undone except by a compensating award). reason is required and audited. Deductions larger than ${ctx.config.deductDryRunThreshold} points return a DRY-RUN preview first \u2014 call again with apply: true to execute. Idempotency-protected.`,
1346
1790
  inputSchema: {
1347
1791
  ...writeArgsSchema,
1348
- points: z6.number().int().positive().describe("Points to remove (positive integer)."),
1349
- apply: z6.boolean().optional().describe(`Required (true) to EXECUTE a deduction above ${ctx.config.deductDryRunThreshold} points after reviewing the dry-run preview.`)
1792
+ points: z7.number().int().positive().describe("Points to remove (positive integer)."),
1793
+ apply: z7.boolean().optional().describe(`Required (true) to EXECUTE a deduction above ${ctx.config.deductDryRunThreshold} points after reviewing the dry-run preview.`)
1350
1794
  },
1351
1795
  destructiveHint: true,
1352
1796
  idempotentHint: false,
@@ -1387,8 +1831,8 @@ function registerPointWriteTools(server, ctx) {
1387
1831
  description: "WRITE, destructive \u2014 corrects a member's balance by a signed delta (points are merchant money). ALWAYS dry-runs first: the initial call returns a preview of current \u2192 resulting balance WITHOUT changing anything; call again with apply: true to execute. reason is required and audited. Idempotency-protected.",
1388
1832
  inputSchema: {
1389
1833
  ...writeArgsSchema,
1390
- points_delta: z6.number().int().refine((value) => value !== 0, { message: "points_delta must not be 0" }).describe("Signed change: positive adds, negative removes."),
1391
- apply: z6.boolean().optional().describe("Must be true to EXECUTE. Omit (or false) to get the dry-run preview.")
1834
+ points_delta: z7.number().int().refine((value) => value !== 0, { message: "points_delta must not be 0" }).describe("Signed change: positive adds, negative removes."),
1835
+ apply: z7.boolean().optional().describe("Must be true to EXECUTE. Omit (or false) to get the dry-run preview.")
1392
1836
  },
1393
1837
  destructiveHint: true,
1394
1838
  idempotentHint: false,
@@ -1423,8 +1867,8 @@ function registerPointWriteTools(server, ctx) {
1423
1867
  description: "WRITE, destructive \u2014 spends a member's points on a redemption program and returns the issued coupon code. Find the program_id via joy_list_redemption_rules first. Can take up to ~30s (a Shopify discount is minted inline). NO server-side retry protection yet: if the outcome is uncertain (timeout), verify via joy_get_member_activity BEFORE any retry \u2014 a blind retry can double-spend.",
1424
1868
  inputSchema: {
1425
1869
  ...memberIdentifierSchema,
1426
- program_id: z6.string().describe("Redemption program ID (from joy_list_redemption_rules)."),
1427
- redeem_points: z6.number().int().positive().describe("Points to spend on this redemption.")
1870
+ program_id: z7.string().describe("Redemption program ID (from joy_list_redemption_rules)."),
1871
+ redeem_points: z7.number().int().positive().describe("Points to spend for dynamic rewards only. Fixed rewards use the program's server-side cost.")
1428
1872
  },
1429
1873
  destructiveHint: true,
1430
1874
  idempotentHint: false,
@@ -1446,11 +1890,14 @@ function registerPointWriteTools(server, ctx) {
1446
1890
  }
1447
1891
  const discount = result?.discount ?? {};
1448
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;
1449
1894
  if (!discount.code) {
1450
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.");
1451
1896
  }
1452
- 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)}.`, {
1453
1899
  coupon: compact(discount, ["code", "title", "expiredAt"]),
1900
+ charged_points: chargedPoints,
1454
1901
  member_balance: customer.point
1455
1902
  });
1456
1903
  }
@@ -1458,7 +1905,7 @@ function registerPointWriteTools(server, ctx) {
1458
1905
  }
1459
1906
 
1460
1907
  // ../mcp-core/build/tools/rewards.js
1461
- import { z as z7 } from "zod";
1908
+ import { z as z8 } from "zod";
1462
1909
  function registerRewardWriteTools(server, ctx) {
1463
1910
  registerWriteTool(server, ctx, {
1464
1911
  name: "joy_refund_coupon",
@@ -1466,8 +1913,8 @@ function registerRewardWriteTools(server, ctx) {
1466
1913
  description: 'WRITE \u2014 refunds, revokes, cancels, voids, or undoes a coupon a member redeemed with points (use this for ANY request to "revoke", "disable", "cancel", "void", or "undo" a point-redeemed coupon/discount code). IMPORTANT: this AUTOMATICALLY credits the spent points back to the member as part of the refund (a refund_point_redeem activity is recorded with your reason) \u2014 do NOT also call joy_award_points afterward, that would double-credit them. Only coupons that came from a POINT REDEMPTION can be refunded this way (not coupons from other sources, e.g. manual discounts). Identify the member by email or Shopify customer ID \u2014 do not ask the caller for a raw customer ID.',
1467
1914
  inputSchema: {
1468
1915
  ...memberIdentifierSchema,
1469
- coupon_code: z7.string().describe("The discount code to refund (from joy_get_member_rewards or joy_get_member_activity)."),
1470
- reason: z7.string().min(3).describe("Why this coupon is being refunded \u2014 REQUIRED, recorded in the audit trail.")
1916
+ coupon_code: z8.string().describe("The discount code to refund (from joy_get_member_rewards or joy_get_member_activity)."),
1917
+ reason: z8.string().min(3).describe("Why this coupon is being refunded \u2014 REQUIRED, recorded in the audit trail.")
1471
1918
  },
1472
1919
  destructiveHint: true,
1473
1920
  idempotentHint: false,
@@ -1493,7 +1940,7 @@ function registerRewardWriteTools(server, ctx) {
1493
1940
  }
1494
1941
 
1495
1942
  // ../mcp-core/build/tools/retention.js
1496
- import { z as z8 } from "zod";
1943
+ import { z as z9 } from "zod";
1497
1944
  var POINTS_EXPIRING_KEYS = [
1498
1945
  "customerId",
1499
1946
  "name",
@@ -1527,15 +1974,15 @@ function toRetentionRiskQuery(args) {
1527
1974
  return query;
1528
1975
  }
1529
1976
  function registerRetentionTools(server, ctx) {
1530
- registerReadTool(server, {
1977
+ registerReadTool(server, ctx, {
1531
1978
  name: "joy_get_retention_risk_members",
1532
1979
  title: "Find members at risk of losing points or dropping a VIP tier",
1533
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.',
1534
1981
  inputSchema: {
1535
- signal: z8.enum(["points_expiring", "tier_drop_risk", "both"]).optional().describe('Which retention-risk signal to check. "points_expiring" for expiring-points questions, "tier_drop_risk" for tier-downgrade-risk questions, "both" (default) when the merchant asks broadly (e.g. "who is at risk of leaving?").'),
1536
- window_days: z8.number().int().min(1).max(30).optional().describe("Lookahead for BOTH signals (default 7, hard-capped at 30 \u2014 larger values are clamped, not rejected): points_expiring = points expiring within this many days; tier_drop_risk = members who are already demotable or become demotable within this many days."),
1537
- tier_id: z8.string().optional().describe("For tier_drop_risk: scope to one VIP tier ID (from joy_list_tiers). Omit to check every non-exclusive tier above the lowest \u2014 the result limit is split evenly across tiers."),
1538
- limit: z8.number().int().min(1).max(50).optional().describe("Max members per signal (default 20, hard-capped at 50).")
1982
+ signal: z9.enum(["points_expiring", "tier_drop_risk", "both"]).optional().describe('Which retention-risk signal to check. "points_expiring" for expiring-points questions, "tier_drop_risk" for tier-downgrade-risk questions, "both" (default) when the merchant asks broadly (e.g. "who is at risk of leaving?").'),
1983
+ window_days: z9.number().int().min(1).max(30).optional().describe("Lookahead for BOTH signals (default 7, hard-capped at 30 \u2014 larger values are clamped, not rejected): points_expiring = points expiring within this many days; tier_drop_risk = members who are already demotable or become demotable within this many days."),
1984
+ tier_id: z9.string().optional().describe("For tier_drop_risk: scope to one VIP tier ID (from joy_list_tiers). Omit to check every non-exclusive tier above the lowest \u2014 the result limit is split evenly across tiers."),
1985
+ limit: z9.number().int().min(1).max(50).optional().describe("Max members per signal (default 20, hard-capped at 50).")
1539
1986
  },
1540
1987
  handler: async (args) => {
1541
1988
  const { data } = await ctx.api.getData("/customers/retention-risk", {
@@ -1605,7 +2052,7 @@ function shapeSupportingData(supportingData) {
1605
2052
  return shaped;
1606
2053
  }
1607
2054
  function registerRiskTools(server, ctx) {
1608
- registerReadTool(server, {
2055
+ registerReadTool(server, ctx, {
1609
2056
  name: "joy_investigate_member_risk",
1610
2057
  title: "Investigate a member for referral/redemption abuse risk",
1611
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.`,
@@ -1634,7 +2081,7 @@ function registerRiskTools(server, ctx) {
1634
2081
  }
1635
2082
 
1636
2083
  // ../mcp-core/build/tools/orderExplain.js
1637
- import { z as z9 } from "zod";
2084
+ import { z as z10 } from "zod";
1638
2085
  var RULE_KEYS = ["programId", "title", "priority", "matched", "earnedPoints", "reasons"];
1639
2086
  function describeRule(rule) {
1640
2087
  const title = typeof rule.title === "string" ? rule.title : String(rule.programId ?? "rule");
@@ -1646,12 +2093,12 @@ function describeRule(rule) {
1646
2093
  return `${title}: did not match${reasons ? ` (${reasons})` : ""}`;
1647
2094
  }
1648
2095
  function registerOrderExplainTools(server, ctx) {
1649
- registerReadTool(server, {
2096
+ registerReadTool(server, ctx, {
1650
2097
  name: "joy_explain_order_points",
1651
2098
  title: "Explain why an order did or didn't earn points",
1652
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.",
1653
2100
  inputSchema: {
1654
- order_id: z9.string().describe('The order to explain \u2014 either the internal Shopify order ID (numeric) or the order name as shown to the merchant, e.g. "#1234".')
2101
+ order_id: z10.string().describe('The order to explain \u2014 either the internal Shopify order ID (numeric) or the order name as shown to the merchant, e.g. "#1234".')
1655
2102
  },
1656
2103
  handler: async (args) => {
1657
2104
  const { data } = await ctx.api.getData(`/orders/${encodeURIComponent(args.order_id)}/points-explanation`);
@@ -1691,7 +2138,7 @@ function registerOrderExplainTools(server, ctx) {
1691
2138
  }
1692
2139
 
1693
2140
  // ../mcp-core/build/tools/activityAudit.js
1694
- import { z as z10 } from "zod";
2141
+ import { z as z11 } from "zod";
1695
2142
  var AUDIT_GROUPS = [
1696
2143
  "tier_downgraded",
1697
2144
  "tier_upgraded",
@@ -1733,16 +2180,16 @@ function toActivityAuditQuery(args) {
1733
2180
  return query;
1734
2181
  }
1735
2182
  function registerActivityAuditTools(server, ctx) {
1736
- registerReadTool(server, {
2183
+ registerReadTool(server, ctx, {
1737
2184
  name: "joy_list_activities",
1738
2185
  title: "List loyalty activities across members (audit by activity group)",
1739
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".',
1740
2187
  inputSchema: {
1741
- group: z10.enum(AUDIT_GROUPS).describe('Which activity group to audit. Pick by merchant intent: "who dropped a tier" \u2192 tier_downgraded; "who redeemed (program A)" \u2192 points_redeemed (+ program_id); "who earned points from orders" \u2192 points_earned; "who referred people" \u2192 referral.'),
1742
- program_id: z10.string().optional().describe("Scope to one program (from joy_list_earning_rules / joy_list_redemption_rules), e.g. redemptions of one specific reward. Applied as a post-filter \u2014 a sparse program in a busy shop may fill fewer rows than `limit`."),
1743
- from_date: z10.string().optional().describe("Start of the period, ISO date (e.g. 2026-07-01). Default: 30 days before to_date."),
1744
- to_date: z10.string().optional().describe("End of the period, ISO date. Default: now."),
1745
- limit: z10.number().int().min(1).max(50).optional().describe("Max rows (default 20, hard-capped at 50).")
2188
+ group: z11.enum(AUDIT_GROUPS).describe('Which activity group to audit. Pick by merchant intent: "who dropped a tier" \u2192 tier_downgraded; "who redeemed (program A)" \u2192 points_redeemed (+ program_id); "who earned points from orders" \u2192 points_earned; "who referred people" \u2192 referral.'),
2189
+ program_id: z11.string().optional().describe("Scope to one program (from joy_list_earning_rules / joy_list_redemption_rules), e.g. redemptions of one specific reward. Applied as a post-filter \u2014 a sparse program in a busy shop may fill fewer rows than `limit`."),
2190
+ from_date: z11.string().optional().describe("Start of the period, ISO date (e.g. 2026-07-01). Default: 30 days before to_date."),
2191
+ to_date: z11.string().optional().describe("End of the period, ISO date. Default: now."),
2192
+ limit: z11.number().int().min(1).max(50).optional().describe("Max rows (default 20, hard-capped at 50).")
1746
2193
  },
1747
2194
  handler: async (args) => {
1748
2195
  const { data } = await ctx.api.getData("/activities/audit", {
@@ -1765,7 +2212,7 @@ function registerActivityAuditTools(server, ctx) {
1765
2212
  }
1766
2213
 
1767
2214
  // ../mcp-core/build/tools/tierUpgrade.js
1768
- import { z as z11 } from "zod";
2215
+ import { z as z12 } from "zod";
1769
2216
  var UPGRADE_ITEM_KEYS = [
1770
2217
  "customerId",
1771
2218
  "name",
@@ -1790,14 +2237,14 @@ function toTierUpgradeQuery(args) {
1790
2237
  return query;
1791
2238
  }
1792
2239
  function registerTierUpgradeTools(server, ctx) {
1793
- registerReadTool(server, {
2240
+ registerReadTool(server, ctx, {
1794
2241
  name: "joy_get_tier_upgrade_candidates",
1795
2242
  title: "Find members close to reaching the next VIP tier",
1796
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.',
1797
2244
  inputSchema: {
1798
- tier_id: z11.string().optional().describe("Scope to members currently in this VIP tier ID (from joy_list_tiers). Omit to check every climbable tier \u2014 the result limit is split evenly across tiers."),
1799
- margin_points: z11.number().min(1).optional().describe(`How close (in the tier program's entry metric) counts as "near the next tier". Default: 10% of the next tier's threshold. Use an explicit value when the merchant names one ("within 100 points of Gold").`),
1800
- limit: z11.number().int().min(1).max(50).optional().describe("Max members returned (default 20, hard-capped at 50).")
2245
+ tier_id: z12.string().optional().describe("Scope to members currently in this VIP tier ID (from joy_list_tiers). Omit to check every climbable tier \u2014 the result limit is split evenly across tiers."),
2246
+ margin_points: z12.number().min(1).optional().describe(`How close (in the tier program's entry metric) counts as "near the next tier". Default: 10% of the next tier's threshold. Use an explicit value when the merchant names one ("within 100 points of Gold").`),
2247
+ limit: z12.number().int().min(1).max(50).optional().describe("Max members returned (default 20, hard-capped at 50).")
1801
2248
  },
1802
2249
  handler: async (args) => {
1803
2250
  const { data } = await ctx.api.getData("/customers/tier-upgrade-candidates", {
@@ -1816,103 +2263,3032 @@ function registerTierUpgradeTools(server, ctx) {
1816
2263
  });
1817
2264
  }
1818
2265
 
1819
- // ../mcp-core/build/prompts.js
1820
- import { z as z12 } from "zod";
1821
- function registerAllPrompts(server) {
1822
- server.registerPrompt("support_missing_points", {
1823
- title: "Investigate missing points for an order",
1824
- 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.",
1825
- argsSchema: {
1826
- email: z12.string().describe("Customer's email address"),
1827
- order_number: z12.string().optional().describe("Order name/number the customer says earned nothing, e.g. #1234")
2266
+ // ../mcp-core/build/tools/loyaltyPage.js
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);
1828
2301
  }
1829
- }, ({ email, order_number }) => ({
1830
- messages: [
1831
- {
1832
- role: "user",
1833
- content: {
1834
- type: "text",
1835
- text: [
1836
- `A customer (${email}) reports they did not receive loyalty points${order_number ? ` for order ${order_number}` : ""}. Investigate before changing anything:`,
1837
- "",
1838
- "1. Look up the member with joy_find_member \u2014 flag anything unusual (disabled state, guest, recently created).",
1839
- `2. Check joy_get_member_activity for an earn entry matching the order${order_number ? "" : " they describe (ask me for the order number if you cannot identify it)"}.`,
1840
- order_number ? `3. Run joy_explain_order_points for ${order_number} \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.` : "3. Once the order is identified, run joy_explain_order_points on it \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.",
1841
- "4. Conclude with exactly one of: (a) points were already awarded (show the activity entry), (b) points were correctly not awarded (show the blocking gate/rule \u2014 cite its configured threshold or condition, do not paraphrase from memory), or (c) points are genuinely missing.",
1842
- "5. Only for case (c): state the exact number of points owed and propose a joy_award_points call with a reason that names the order and the root cause. Wait for my explicit confirmation before executing \u2014 never award in the same turn as the diagnosis.",
1843
- "",
1844
- "Caveat to include when you rely on joy_explain_order_points: it recomputes against the order as it exists now \u2014 if the order changed after the original webhook (e.g. partial refund), the explanation reflects the current state, not the historical moment."
1845
- ].join("\n")
1846
- }
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.";
1847
2327
  }
1848
- ]
1849
- }));
1850
- server.registerPrompt("program_review", {
1851
- title: "Loyalty program review (health + revenue + tiers + referral)",
1852
- 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.",
1853
- argsSchema: {
1854
- period: z12.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
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
+ });
1855
2335
  }
1856
- }, ({ period }) => ({
1857
- messages: [
1858
- {
1859
- role: "user",
1860
- content: {
1861
- type: "text",
1862
- text: [
1863
- `Review my loyalty program for ${period ?? "the last 30 days"}. Run, in parallel where possible:`,
1864
- "",
1865
- "1. joy_get_loyalty_health_check \u2014 overall score and flags.",
1866
- "2. joy_get_loyalty_revenue_report \u2014 revenue impact (Shopify sales data).",
1867
- "3. joy_get_tier_performance_breakdown \u2014 which VIP tier earns its keep.",
1868
- "4. joy_get_analytics_report with report_type referral_summary, and points_movement for the same period.",
1869
- "",
1870
- "Data-source rules (important):",
1871
- "- joy_get_loyalty_revenue_report and joy_get_tier_performance_breakdown read Shopify sales data (ShopifyQL); joy_get_analytics_report reads the Joy event log (BigQuery). These measure differently \u2014 label the source of every figure and never combine numbers across the two sources into one derived metric.",
1872
- "- If a ShopifyQL tool returns a 403 about the read_reports scope, relay its grant instructions instead of substituting numbers from the other source.",
1873
- "- If any response carries a backfill WARNING, repeat that caveat next to the affected numbers \u2014 do not present them as final.",
1874
- "",
1875
- "Format: a 3-sentence executive summary first, then anomalies worth my attention (with the flag or figure that triggered each), then exactly one concrete recommended action. Skip metrics that are unremarkable."
1876
- ].join("\n")
1877
- }
1878
- }
1879
- ]
1880
- }));
1881
- server.registerPrompt("investigate_member", {
1882
- title: "Investigate a member for abuse risk",
1883
- 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.",
1884
- argsSchema: {
1885
- email: z12.string().describe("Email of the member to investigate")
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 });
1886
2361
  }
1887
- }, ({ email }) => ({
1888
- messages: [
1889
- {
1890
- role: "user",
1891
- content: {
1892
- type: "text",
1893
- text: [
1894
- `I suspect member ${email} may be abusing the loyalty program (referral self-dealing, unusual redemptions, or similar). Investigate:`,
1895
- "",
1896
- "1. joy_find_member \u2014 profile basics and state.",
1897
- "2. joy_investigate_member_risk \u2014 the pre-defined abuse flags. For every flag that fires, show the concrete supporting data the tool returns; do not add suspicions of your own beyond the flags.",
1898
- "3. joy_get_member_activity and joy_get_member_referrals \u2014 recent point movements and referral results, to give the flags context.",
1899
- "",
1900
- "Mandatory caveat in your answer: device/IP matching compares the MOST RECENT values only \u2014 there is no full history over time \u2014 so these flags are advisory signals to support my judgement, not proof of abuse.",
1901
- "",
1902
- "End with: which flags fired (if any), what specific records I should open in the Joy admin to verify each one, and your read on severity. Do NOT deduct points, refund coupons, or take any corrective action \u2014 this session is investigation only; I will decide separately."
1903
- ].join("\n")
1904
- }
1905
- }
1906
- ]
1907
- }));
2362
+ });
1908
2363
  }
1909
2364
 
1910
- // ../mcp-core/build/registry.js
1911
- function registerAllTools(server, ctx) {
1912
- registerOverviewTools(server, ctx);
1913
- registerAnalyticsTools(server, ctx);
1914
- registerMemberTools(server, ctx);
1915
- registerTierTools(server, ctx);
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";
2412
+ var PROGRAM_TYPE_BY_KIND = {
2413
+ earning: "earning",
2414
+ redemption: "spending"
2415
+ };
2416
+ var SIMPLE_SOCIAL_EVENTS = /* @__PURE__ */ new Set([
2417
+ "like_facebook",
2418
+ "follow_instagram",
2419
+ "follow_twitter",
2420
+ "follow_pinterest",
2421
+ "follow_tiktok",
2422
+ "subscribe_youtube",
2423
+ "join_discord",
2424
+ "join_telegram",
2425
+ "join_reddit"
2426
+ ]);
2427
+ var REVIEW_APPS = [
2428
+ "airReviewApp",
2429
+ "judgeMeApp",
2430
+ "yotpoApp",
2431
+ "feraApp",
2432
+ "reviewRocketApp",
2433
+ "klaviyoApp",
2434
+ "stampedApp",
2435
+ "looxApp",
2436
+ "okendoApp",
2437
+ "ReviewsIoApp",
2438
+ "growaveApp",
2439
+ "typdalApp"
2440
+ ];
2441
+ var REVIEW_FLOW_APPS = /* @__PURE__ */ new Set([
2442
+ "stampedApp",
2443
+ "looxApp",
2444
+ "okendoApp",
2445
+ "ReviewsIoApp",
2446
+ "growaveApp",
2447
+ "typdalApp"
2448
+ ]);
2449
+ var REVIEW_PHOTO_VIDEO_APPS = /* @__PURE__ */ new Set(["reviewRocketApp", "looxApp"]);
2450
+ var EARNING_CREATE_EVENTS = /* @__PURE__ */ new Set([
2451
+ "place_order",
2452
+ "sign_up",
2453
+ "birthday",
2454
+ "interact_website",
2455
+ "write_review",
2456
+ ...SIMPLE_SOCIAL_EVENTS
2457
+ ]);
2458
+ var REDEMPTION_CREATE_EVENTS = /* @__PURE__ */ new Set([
2459
+ "amount_discount",
2460
+ "percentage_discount",
2461
+ "free_shipping",
2462
+ "free_gift"
2463
+ ]);
2464
+ var FREE_GIFT_PROGRAM_APPLIED_TO = ["sf_product", "specific", "exclude_collection"];
2465
+ var EARNING_FIELDS = [
2466
+ "event",
2467
+ "title",
2468
+ "status",
2469
+ "priority",
2470
+ "startDate",
2471
+ "endDate",
2472
+ "earnBy",
2473
+ "rateMoney",
2474
+ "rateItem",
2475
+ "earnPoint",
2476
+ "typeEarn",
2477
+ "isUseLayoutMultiRewards",
2478
+ "dateFormat",
2479
+ "typeRewardTiming",
2480
+ "limitUnit",
2481
+ "limitInterval",
2482
+ "urlAccount",
2483
+ "reviewApp",
2484
+ "descriptionReview",
2485
+ "extraPoints",
2486
+ "extraPointsForImages",
2487
+ "extraPointsForVideos",
2488
+ "isAppliedVipTier",
2489
+ "earnPointsTiers",
2490
+ "appliedPlaceOrderTo",
2491
+ "skipEarnPointGuest"
2492
+ ];
2493
+ var REDEMPTION_FIELDS = [
2494
+ "event",
2495
+ "title",
2496
+ "status",
2497
+ "priority",
2498
+ "startDate",
2499
+ "endDate",
2500
+ "spendPoint",
2501
+ "earnAmount",
2502
+ "redeemType",
2503
+ "shippingDiscountType",
2504
+ "shippingDiscountAmount",
2505
+ "appliedTo",
2506
+ "specificProducts",
2507
+ "specificCollections",
2508
+ "appliedCollectionIds",
2509
+ "collectionQuantity"
2510
+ ];
2511
+ function withoutUnsetFields(body) {
2512
+ return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== void 0));
2513
+ }
2514
+ function missingFields(event, fields) {
2515
+ return { ok: false, error: `Creating ${event} requires: ${fields.join(", ")}.` };
2516
+ }
2517
+ function unsupportedCreate(event) {
2518
+ return {
2519
+ ok: false,
2520
+ error: `Cannot create the ${event} program through this tool. Create it once in Joy admin, then call this tool with its program_id to tune points/status/priority/dates.`
2521
+ };
2522
+ }
2523
+ function vipTierFlagError(args, current = {}) {
2524
+ if (!args.earn_points_tiers)
2525
+ return void 0;
2526
+ if (Object.keys(args.earn_points_tiers).length === 0) {
2527
+ return "earn_points_tiers is empty. Provide at least one tier, or omit it.";
2528
+ }
2529
+ const willBeApplied = args.is_applied_vip_tier ?? current.isAppliedVipTier === true;
2530
+ if (!willBeApplied) {
2531
+ return "earn_points_tiers was given but per-tier earning is off for this rule, so the backend would ignore the whole map and keep using the flat earn_point. Pass is_applied_vip_tier: true in the same call to actually apply it.";
2532
+ }
2533
+ return void 0;
2534
+ }
2535
+ function unknownTierIds(earnPointsTiers, knownTierIds) {
2536
+ if (!earnPointsTiers)
2537
+ return [];
2538
+ const known = new Set(knownTierIds);
2539
+ return Object.keys(earnPointsTiers).filter((tierId) => !known.has(tierId));
2540
+ }
2541
+ function baseEarningBody(args) {
2542
+ return withoutUnsetFields({
2543
+ event: args.event,
2544
+ title: args.title,
2545
+ status: args.status,
2546
+ priority: args.priority,
2547
+ startDate: args.start_date,
2548
+ endDate: args.end_date,
2549
+ earnBy: args.earn_by,
2550
+ rateMoney: args.rate_money,
2551
+ rateItem: args.rate_item,
2552
+ earnPoint: args.earn_point,
2553
+ dateFormat: args.date_format,
2554
+ typeRewardTiming: args.type_reward_timing,
2555
+ limitUnit: args.limit_unit,
2556
+ limitInterval: args.limit_interval,
2557
+ urlAccount: args.url_account,
2558
+ reviewApp: args.review_app,
2559
+ descriptionReview: args.description_review,
2560
+ extraPointsForImages: args.extra_points_for_images,
2561
+ extraPointsForVideos: args.extra_points_for_videos,
2562
+ isAppliedVipTier: args.is_applied_vip_tier,
2563
+ appliedPlaceOrderTo: args.applied_place_order_to,
2564
+ earnPointsTiers: args.earn_points_tiers ? Object.fromEntries(Object.entries(args.earn_points_tiers).map(([tierId, rate]) => [
2565
+ tierId,
2566
+ withoutUnsetFields({
2567
+ earnPoint: rate.earn_point,
2568
+ rateMoney: rate.rate_money,
2569
+ typeEarn: rate.type_earn
2570
+ })
2571
+ ])) : void 0
2572
+ });
2573
+ }
2574
+ function baseRedemptionBody(args) {
2575
+ return withoutUnsetFields({
2576
+ event: args.event,
2577
+ title: args.title,
2578
+ status: args.status,
2579
+ priority: args.priority,
2580
+ startDate: args.start_date,
2581
+ endDate: args.end_date,
2582
+ spendPoint: args.spend_point,
2583
+ earnAmount: args.earn_amount,
2584
+ shippingDiscountType: args.shipping_discount_type,
2585
+ shippingDiscountAmount: args.shipping_discount_amount,
2586
+ appliedTo: args.applied_to,
2587
+ specificProducts: args.specific_products,
2588
+ specificCollections: args.specific_collections,
2589
+ appliedCollectionIds: args.applied_collection_ids,
2590
+ collectionQuantity: args.collection_quantity
2591
+ });
2592
+ }
2593
+ function freeGiftProgramSelectionError(args) {
2594
+ if (args.event !== "free_gift")
2595
+ return void 0;
2596
+ if (!args.applied_to) {
2597
+ return "Creating free_gift requires applied_to. Ask the merchant which product or collection is the gift; product and collection IDs must come from the merchant and cannot be invented.";
2598
+ }
2599
+ if (!FREE_GIFT_PROGRAM_APPLIED_TO.includes(args.applied_to)) {
2600
+ return `free_gift applied_to must be one of: ${FREE_GIFT_PROGRAM_APPLIED_TO.join(", ")}. Tier-reward values such as "all" and "specific_product" do not apply here.`;
2601
+ }
2602
+ if (args.applied_to === "sf_product" && !args.specific_products?.length) {
2603
+ return 'Creating free_gift with applied_to "sf_product" requires specific_products. Ask the merchant for the product IDs; product IDs cannot be invented.';
2604
+ }
2605
+ if ((args.applied_to === "specific" || args.applied_to === "exclude_collection") && !args.applied_collection_ids?.length) {
2606
+ return `Creating free_gift with applied_to "${args.applied_to}" requires applied_collection_ids. Ask the merchant for the collection IDs; product or collection IDs cannot be invented.`;
2607
+ }
2608
+ if ((args.applied_to === "specific" || args.applied_to === "exclude_collection") && (!Number.isInteger(args.collection_quantity) || (args.collection_quantity ?? 0) < 1)) {
2609
+ return `Creating free_gift with applied_to "${args.applied_to}" requires collection_quantity as a positive integer.`;
2610
+ }
2611
+ return void 0;
2612
+ }
2613
+ function toEarningProgramChange(args) {
2614
+ const fractionalError = fractionalTierRateError(args.earn_points_tiers);
2615
+ if (fractionalError)
2616
+ return { ok: false, error: fractionalError };
2617
+ const body = baseEarningBody(args);
2618
+ if (args.program_id) {
2619
+ return { ok: true, action: "UPDATE", programId: args.program_id, body, dropped: droppedFields(body, EARNING_FIELDS) };
2620
+ }
2621
+ const vipFlagError = vipTierFlagError(args);
2622
+ if (vipFlagError)
2623
+ return { ok: false, error: vipFlagError };
2624
+ if (!args.event)
2625
+ return { ok: false, error: "event is required when creating an earning program." };
2626
+ if (!EARNING_CREATE_EVENTS.has(args.event))
2627
+ return unsupportedCreate(args.event);
2628
+ if (!args.title)
2629
+ return missingFields(args.event, ["title"]);
2630
+ if (args.event === "place_order") {
2631
+ const earnBy = args.earn_by ?? "price";
2632
+ if (!["price", "fixed_order", "quantity", "order"].includes(earnBy)) {
2633
+ return { ok: false, error: "place_order earn_by must be price, fixed_order, quantity, or order." };
2634
+ }
2635
+ if (args.earn_point === void 0)
2636
+ return missingFields(args.event, ["earn_point"]);
2637
+ if (["price", "fixed_order"].includes(earnBy) && args.rate_money === void 0) {
2638
+ return missingFields(args.event, ["rate_money"]);
2639
+ }
2640
+ if (earnBy === "quantity" && args.rate_item === void 0) {
2641
+ return missingFields(args.event, ["rate_item"]);
2642
+ }
2643
+ const createBody2 = {
2644
+ ...body,
2645
+ earnBy,
2646
+ typeEarn: "point",
2647
+ ...placeOrderVipTierPatch(args.event, args)
2648
+ };
2649
+ return {
2650
+ ok: true,
2651
+ action: "CREATE",
2652
+ body: createBody2,
2653
+ dropped: droppedFields(createBody2, EARNING_FIELDS)
2654
+ };
2655
+ }
2656
+ if (args.event === "sign_up") {
2657
+ if (args.earn_point === void 0)
2658
+ return missingFields(args.event, ["earn_point"]);
2659
+ const createBody2 = { ...body, typeEarn: "point" };
2660
+ return { ok: true, action: "CREATE", body: createBody2, dropped: droppedFields(createBody2, EARNING_FIELDS) };
2661
+ }
2662
+ if (args.event === "birthday") {
2663
+ if (args.earn_point === void 0)
2664
+ return missingFields(args.event, ["earn_point"]);
2665
+ if (!args.date_format)
2666
+ return missingFields(args.event, ["date_format"]);
2667
+ if (!["MM/DD", "DD/MM", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"].includes(args.date_format)) {
2668
+ return { ok: false, error: "birthday date_format is invalid." };
2669
+ }
2670
+ if (!args.type_reward_timing)
2671
+ return missingFields(args.event, ["type_reward_timing"]);
2672
+ if (!["exact_birthday", "begin_of_month", "around_birthday"].includes(args.type_reward_timing)) {
2673
+ return { ok: false, error: "birthday type_reward_timing is invalid." };
2674
+ }
2675
+ const createBody2 = { ...body, typeEarn: "point", isUseLayoutMultiRewards: false };
2676
+ return { ok: true, action: "CREATE", body: createBody2, dropped: droppedFields(createBody2, EARNING_FIELDS) };
2677
+ }
2678
+ if (SIMPLE_SOCIAL_EVENTS.has(args.event)) {
2679
+ if (args.earn_point === void 0)
2680
+ return missingFields(args.event, ["earn_point"]);
2681
+ if (!args.url_account) {
2682
+ return {
2683
+ ok: false,
2684
+ error: `Creating ${args.event} requires url_account \u2014 the full URL of the shop's profile on that network (e.g. https://instagram.com/yourbrand). Ask the merchant for it; do not invent one.`
2685
+ };
2686
+ }
2687
+ const createBody2 = { ...body, typeEarn: "point" };
2688
+ return { ok: true, action: "CREATE", body: createBody2, dropped: droppedFields(createBody2, EARNING_FIELDS) };
2689
+ }
2690
+ if (args.event === "write_review") {
2691
+ if (args.earn_point === void 0)
2692
+ return missingFields(args.event, ["earn_point"]);
2693
+ if (!args.review_app) {
2694
+ return {
2695
+ ok: false,
2696
+ error: `Creating write_review requires review_app \u2014 which review app the shop runs. One of: ${REVIEW_APPS.join(", ")}. Ask the merchant which one is installed; the wrong value means reviews never award points.`
2697
+ };
2698
+ }
2699
+ if (REVIEW_FLOW_APPS.has(args.review_app) && !args.description_review) {
2700
+ return {
2701
+ ok: false,
2702
+ error: `review_app "${args.review_app}" awards points through Shopify Flow, so description_review is required.`
2703
+ };
2704
+ }
2705
+ const wantsMediaBonus = args.extra_points_for_images !== void 0 || args.extra_points_for_videos !== void 0;
2706
+ if (wantsMediaBonus && !REVIEW_PHOTO_VIDEO_APPS.has(args.review_app)) {
2707
+ return {
2708
+ ok: false,
2709
+ error: `Photo/video review bonuses only work with reviewRocketApp or looxApp \u2014 "${args.review_app}" does not report them, so those points would never be awarded. Remove extra_points_for_images/extra_points_for_videos, or confirm the shop's review app.`
2710
+ };
2711
+ }
2712
+ const createBody2 = { ...body, typeEarn: "point" };
2713
+ return { ok: true, action: "CREATE", body: createBody2, dropped: droppedFields(createBody2, EARNING_FIELDS) };
2714
+ }
2715
+ if (args.earn_point === void 0)
2716
+ return missingFields(args.event, ["earn_point"]);
2717
+ const createBody = {
2718
+ ...body,
2719
+ limitUnit: args.limit_unit ?? "day",
2720
+ limitInterval: args.limit_interval ?? 1
2721
+ };
2722
+ return { ok: true, action: "CREATE", body: createBody, dropped: droppedFields(createBody, EARNING_FIELDS) };
2723
+ }
2724
+ function toRedemptionProgramChange(args) {
2725
+ const body = baseRedemptionBody(args);
2726
+ if (args.program_id) {
2727
+ return { ok: true, action: "UPDATE", programId: args.program_id, body, dropped: droppedFields(body, REDEMPTION_FIELDS) };
2728
+ }
2729
+ if (!args.event)
2730
+ return { ok: false, error: "event is required when creating a redemption program." };
2731
+ if (!REDEMPTION_CREATE_EVENTS.has(args.event))
2732
+ return unsupportedCreate(args.event);
2733
+ if (!args.title)
2734
+ return missingFields(args.event, ["title"]);
2735
+ if (!Number.isInteger(args.spend_point) || (args.spend_point ?? 0) <= 0) {
2736
+ return missingFields(args.event, ["spend_point (a positive integer)"]);
2737
+ }
2738
+ if (args.event === "free_gift") {
2739
+ const selectionError = freeGiftProgramSelectionError(args);
2740
+ if (selectionError)
2741
+ return { ok: false, error: selectionError };
2742
+ return { ok: true, action: "CREATE", body, dropped: droppedFields(body, REDEMPTION_FIELDS) };
2743
+ }
2744
+ if (args.event === "amount_discount") {
2745
+ if (args.earn_amount === void 0)
2746
+ return missingFields(args.event, ["earn_amount"]);
2747
+ const createBody = { ...body, redeemType: "fixed" };
2748
+ return { ok: true, action: "CREATE", body: createBody, dropped: droppedFields(createBody, REDEMPTION_FIELDS) };
2749
+ }
2750
+ if (args.event === "percentage_discount") {
2751
+ if (args.earn_amount === void 0)
2752
+ return missingFields(args.event, ["earn_amount"]);
2753
+ if (args.earn_amount >= 100) {
2754
+ return { ok: false, error: "percentage_discount earn_amount must be less than 100." };
2755
+ }
2756
+ return { ok: true, action: "CREATE", body, dropped: droppedFields(body, REDEMPTION_FIELDS) };
2757
+ }
2758
+ if (!args.shipping_discount_type)
2759
+ return missingFields(args.event, ["shipping_discount_type"]);
2760
+ if (!["percentage", "fixedAmount"].includes(args.shipping_discount_type)) {
2761
+ return { ok: false, error: "free_shipping shipping_discount_type must be percentage or fixedAmount." };
2762
+ }
2763
+ if (args.shipping_discount_amount === void 0)
2764
+ return missingFields(args.event, ["shipping_discount_amount"]);
2765
+ if (args.shipping_discount_type === "percentage" && args.shipping_discount_amount > 100) {
2766
+ return { ok: false, error: "free_shipping shipping_discount_amount must be 100 or less for percentage." };
2767
+ }
2768
+ return { ok: true, action: "CREATE", body, dropped: droppedFields(body, REDEMPTION_FIELDS) };
2769
+ }
2770
+ function toProgramList(value) {
2771
+ if (Array.isArray(value))
2772
+ return value.filter(isProgramRecord);
2773
+ if (!isProgramRecord(value))
2774
+ return [];
2775
+ if (Array.isArray(value.items))
2776
+ return value.items.filter(isProgramRecord);
2777
+ if (Array.isArray(value.programs))
2778
+ return value.programs.filter(isProgramRecord);
2779
+ return [];
2780
+ }
2781
+ function isProgramRecord(value) {
2782
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2783
+ }
2784
+ function unsupportedUpdateProgramType(kind, current) {
2785
+ const actualType = typeof current.type === "string" && current.type ? current.type : "unknown";
2786
+ const expectedType = PROGRAM_TYPE_BY_KIND[kind];
2787
+ if (actualType === expectedType)
2788
+ return void 0;
2789
+ return `Refusing to update this ${kind} program: its actual type is "${actualType}", not "${expectedType}". Configure ${actualType === "unknown" ? "this program" : `the ${actualType} program`} ${actualType === "referral" ? "with joy_configure_referral_program" : "in Joy admin"} instead; this tool only supports ${kind} programs and must not partially update another program type.`;
2790
+ }
2791
+ function buildProgramChangePreview(params) {
2792
+ const { kind, args, prepared, current = {}, existing = [] } = params;
2793
+ const title = String(prepared.body.title ?? current.title ?? prepared.body.event ?? "unnamed");
2794
+ const duplicate = prepared.action === "CREATE" ? findDuplicate(existing, { event: String(prepared.body.event ?? ""), title: args.title }) : void 0;
2795
+ return buildChangePreview({
2796
+ action: prepared.action,
2797
+ target: `${kind} program "${title}"`,
2798
+ reason: args.reason,
2799
+ changes: diffFields(current, prepared.body),
2800
+ dropped: prepared.dropped,
2801
+ duplicate
2802
+ });
2803
+ }
2804
+ function isProgramChangeNoop(current, prepared) {
2805
+ return diffFields(current, prepared.body).length === 0;
2806
+ }
2807
+ var programConfigInputSchema = {
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()
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."),
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.")
2847
+ };
2848
+ var PLACE_ORDER_EVENTS = /* @__PURE__ */ new Set(["place_order", "place_order_subscription"]);
2849
+ function placeOrderVipTierPatch(event, args) {
2850
+ const enablingVipTier = args.is_applied_vip_tier === true || args.earn_points_tiers !== void 0;
2851
+ if (!enablingVipTier)
2852
+ return {};
2853
+ if (!PLACE_ORDER_EVENTS.has(String(event)))
2854
+ return {};
2855
+ return { appliedPlaceOrderTo: "vip-tier", skipEarnPointGuest: true };
2856
+ }
2857
+ function toIntegerRatio(earnPoint, rateMoney) {
2858
+ const isWhole = (value) => Math.abs(value - Math.round(value)) < 1e-9;
2859
+ for (let factor = 2; factor <= 1e3; factor++) {
2860
+ const points = earnPoint * factor;
2861
+ const money = rateMoney * factor;
2862
+ if (isWhole(points) && isWhole(money)) {
2863
+ return { earnPoint: Math.round(points), rateMoney: Math.round(money) };
2864
+ }
2865
+ }
2866
+ return void 0;
2867
+ }
2868
+ function fractionalTierRateError(earnPointsTiers) {
2869
+ if (!earnPointsTiers)
2870
+ return void 0;
2871
+ const offenders = [];
2872
+ for (const [tierId, rate] of Object.entries(earnPointsTiers)) {
2873
+ const points = rate.earn_point;
2874
+ if (points === void 0 || Number.isInteger(points))
2875
+ continue;
2876
+ const ratio = toIntegerRatio(points, rate.rate_money ?? 1);
2877
+ offenders.push(ratio ? `${tierId}: earn_point ${points} \u2192 use earn_point ${ratio.earnPoint} with rate_money ${ratio.rateMoney}` : `${tierId}: earn_point ${points} (no clean integer ratio found)`);
2878
+ }
2879
+ if (offenders.length === 0)
2880
+ return void 0;
2881
+ return `Fractional earn_point does not create a multiplier. Points are (amount / rate_money) * earn_point, then rounded, and the admin's point field is integer-stepped \u2014 a fractional value silently rounds, so a tier earns the wrong amount. Express the multiplier as an integer RATIO instead: ${offenders.join("; ")}. The backend stores the ratio as sent and derives ratePercent from it (verified against live staging: earn_point 5 / rate_money 4 reads back as ratePercent 125), so no extra flag is needed to stop it collapsing.`;
2882
+ }
2883
+ function earnPointsTiersDropped(sent, saved) {
2884
+ if (!sent || Object.keys(sent).length === 0)
2885
+ return false;
2886
+ if (!saved || typeof saved !== "object")
2887
+ return true;
2888
+ return Object.keys(saved).length === 0;
2889
+ }
2890
+ async function vipTierIdError(ctx, args) {
2891
+ if (!args.earn_points_tiers)
2892
+ return void 0;
2893
+ const { data } = await ctx.api.getData("/tiers");
2894
+ const tiers = Array.isArray(data) ? data : [];
2895
+ const knownIds = tiers.map((tier) => String(tier.id));
2896
+ const unknown = unknownTierIds(args.earn_points_tiers, knownIds);
2897
+ if (unknown.length === 0)
2898
+ return void 0;
2899
+ const known = tiers.map((tier) => `${String(tier.id)} (${String(tier.name)})`).join(", ");
2900
+ return `earn_points_tiers is keyed by tier ID, and these keys are not tiers on this shop: ${unknown.join(", ")}. Points for those keys would never be awarded and the write would still succeed. Valid tiers: ${known}.`;
2901
+ }
2902
+ async function resolveProgramProductUrls(ctx, args) {
2903
+ if (!args.product_url?.length)
2904
+ return args;
2905
+ if (args.event !== "free_gift" || args.applied_to !== "sf_product") {
2906
+ throw new JoyApiError("validation", 'product_url is only supported for free_gift programs with applied_to "sf_product". Use merchant-provided product IDs for another selection mode.');
2907
+ }
2908
+ const resolvedProducts = await Promise.all(args.product_url.map(async (productUrl) => {
2909
+ const parsed = parseShopifyProductUrl(productUrl);
2910
+ if (!parsed.ok)
2911
+ throw new JoyApiError("validation", `${parsed.error} Received: ${productUrl}`);
2912
+ const { data } = await ctx.api.getData(`/products/resolve?handle=${encodeURIComponent(parsed.handle)}`);
2913
+ if (!data || !Number.isSafeInteger(data.id) || data.id <= 0) {
2914
+ throw new JoyApiError("not_found", `No Shopify product was found for handle "${parsed.handle}". Ask the merchant for a valid product link; never fabricate an ID.`);
2915
+ }
2916
+ return { id: data.id, title: data.title, handle: data.handle };
2917
+ }));
2918
+ return { ...args, specific_products: [...args.specific_products ?? [], ...resolvedProducts] };
2919
+ }
2920
+ async function executeProgramChange(ctx, kind, args, toChange) {
2921
+ const resolvedArgs = await resolveProgramProductUrls(ctx, args);
2922
+ const prepared = toChange(resolvedArgs);
2923
+ if (!prepared.ok)
2924
+ return prepared.error;
2925
+ if (prepared.action === "UPDATE") {
2926
+ const { data: data2 } = await ctx.api.getData(`/programs/${prepared.programId}`);
2927
+ const current = data2 ?? {};
2928
+ const flagError = vipTierFlagError(resolvedArgs, current);
2929
+ if (flagError)
2930
+ return flagError;
2931
+ const fractionError = fractionalTierRateError(resolvedArgs.earn_points_tiers);
2932
+ if (fractionError)
2933
+ return fractionError;
2934
+ Object.assign(prepared.body, placeOrderVipTierPatch(current.event, resolvedArgs));
2935
+ const tierError = await vipTierIdError(ctx, resolvedArgs);
2936
+ if (tierError)
2937
+ return tierError;
2938
+ const unsupportedType = unsupportedUpdateProgramType(kind, current);
2939
+ if (unsupportedType)
2940
+ return unsupportedType;
2941
+ const preview2 = buildProgramChangePreview({ kind, args: resolvedArgs, prepared, current });
2942
+ if (isProgramChangeNoop(current, prepared))
2943
+ return preview2;
2944
+ const stop2 = requireApply(resolvedArgs.apply, preview2);
2945
+ if (stop2)
2946
+ return stop2;
2947
+ await ctx.api.mutateData(`/programs/${prepared.programId}`, { method: "PUT", body: prepared.body });
2948
+ if (resolvedArgs.earn_points_tiers || prepared.body.appliedPlaceOrderTo === "vip-tier") {
2949
+ const { data: after } = await ctx.api.getData(`/programs/${prepared.programId}`);
2950
+ if (earnPointsTiersDropped(resolvedArgs.earn_points_tiers, after?.earnPointsTiers)) {
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.`;
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
+ }
2956
+ }
2957
+ return `Updated ${kind} program ${prepared.programId}. This affects every customer immediately.`;
2958
+ }
2959
+ const createTierError = await vipTierIdError(ctx, resolvedArgs);
2960
+ if (createTierError)
2961
+ return createTierError;
2962
+ const { data } = await ctx.api.getData(`/programs/${kind}`);
2963
+ const preview = buildProgramChangePreview({ kind, args: resolvedArgs, prepared, existing: toProgramList(data) });
2964
+ const stop = requireApply(resolvedArgs.apply, preview);
2965
+ if (stop)
2966
+ return stop;
2967
+ await ctx.api.mutateData(`/programs/${kind}`, { method: "POST", body: prepared.body });
2968
+ return `Created ${kind} program. This affects every customer immediately.`;
2969
+ }
2970
+ function registerProgramConfigTools(server, ctx) {
2971
+ registerConfigWriteTool(server, ctx, {
2972
+ name: "joy_configure_earning_program",
2973
+ title: "Create or update an earning program",
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.",
2975
+ inputSchema: programConfigInputSchema,
2976
+ destructiveHint: true,
2977
+ handler: (args) => executeProgramChange(ctx, "earning", args, toEarningProgramChange)
2978
+ });
2979
+ registerConfigWriteTool(server, ctx, {
2980
+ name: "joy_configure_redemption_program",
2981
+ title: "Create or update a redemption program",
2982
+ description: "WRITE \u2014 changes redemption behaviour for every customer immediately. Creates amount_discount, percentage_discount, free_shipping, and free_gift rules. A free_gift accepts merchant-provided product_url values (Shopify product links) or product/collection IDs: program-level applied_to is sf_product, specific, or exclude_collection (never all). Joy resolves product-link handles and never invents IDs. Use program_id to update any existing redemption rule. Previews by default. Joy has no safe delete here: set status: false to reversibly disable a rule instead.",
2983
+ inputSchema: programConfigInputSchema,
2984
+ destructiveHint: true,
2985
+ handler: (args) => executeProgramChange(ctx, "redemption", args, toRedemptionProgramChange)
2986
+ });
2987
+ }
2988
+
2989
+ // ../mcp-core/build/tools/tiersWrite.js
2990
+ import { z as z16 } from "zod";
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.";
2994
+ var RESET_SCHEDULE_FIELDS = [
2995
+ "useResetTier",
2996
+ "resetFrequency",
2997
+ "resetIntervalValue",
2998
+ "resetIntervalUnit",
2999
+ "resetStartDate",
3000
+ "monthResetTier",
3001
+ "dayResetTier"
3002
+ ];
3003
+ var TIER_UPDATE_FIELDS = [
3004
+ "name",
3005
+ "icon",
3006
+ "iconCustom",
3007
+ "bgImage",
3008
+ "defaultAvatar",
3009
+ "targetPointUpdate",
3010
+ "imageBlock",
3011
+ "isExclusiveTier",
3012
+ "hideDefaultTier",
3013
+ "textColor",
3014
+ "progressBarColor",
3015
+ "boxBgColor",
3016
+ "boxTextColor",
3017
+ "bgCardColor",
3018
+ "cardOpacity",
3019
+ "iconColor",
3020
+ "bgType",
3021
+ "tierNamesLanguage"
3022
+ ];
3023
+ var TIER_REWARD_FIELDS = [
3024
+ "title",
3025
+ "status",
3026
+ "event",
3027
+ "type",
3028
+ "earnAmount",
3029
+ "spendPoint",
3030
+ "bonusPoints",
3031
+ "codeName",
3032
+ "appliedTo",
3033
+ "orderReq",
3034
+ "orderReqAmount",
3035
+ "specificProducts",
3036
+ "specificCollections",
3037
+ "appliedCollectionIds",
3038
+ "combinedWith",
3039
+ "hasLimitShipping",
3040
+ "limitShipping",
3041
+ "expiredAfter",
3042
+ "expiredUnit",
3043
+ "isRefundCoupon",
3044
+ "priority",
3045
+ "showLoyaltyPage",
3046
+ "perkTitleLanguage",
3047
+ "redeemType",
3048
+ "minSpendPoint",
3049
+ "maxSpendPoint",
3050
+ "rewardProductSelectionMode",
3051
+ "rewardProductQuantity"
3052
+ ];
3053
+ var TIER_PERK_FIELDS = ["contentPerk"];
3054
+ var TIER_SETTINGS_FIELDS = [
3055
+ "entryMethod",
3056
+ "startDate",
3057
+ "prefix",
3058
+ "isUsePrefixDiscountCode",
3059
+ "showTierPointSettings",
3060
+ "allowGiveEntryRewardAgain",
3061
+ "useDemotionTier",
3062
+ "typeDemotion",
3063
+ "downgradeAfter",
3064
+ "downgradeAfterUnit",
3065
+ "useResetTier",
3066
+ "resetAfter",
3067
+ "resetAfterUnit",
3068
+ "resetFrequency",
3069
+ "resetIntervalValue",
3070
+ "resetIntervalUnit",
3071
+ "resetStartDate",
3072
+ "monthResetTier",
3073
+ "dayResetTier",
3074
+ "isSyncTagsCustomerTier",
3075
+ "customerTierTagPrefix",
3076
+ "notificationDowngradeBefore",
3077
+ "notificationDowngradeBeforeUnit"
3078
+ ];
3079
+ var rewardEventSchema = z16.enum([
3080
+ "amount_discount",
3081
+ "percentage_discount",
3082
+ "free_shipping",
3083
+ "free_gift",
3084
+ "bonus_point",
3085
+ "buy_x_get_y"
3086
+ ]);
3087
+ var finiteNumber = () => z16.number().finite();
3088
+ var tierRewardInputSchema = z16.object({
3089
+ event: rewardEventSchema.describe("Required reward kind."),
3090
+ title: z16.string().min(1).optional(),
3091
+ type: z16.enum(["tier_spending", "tier_privileges", "pre_tier_reward"]).optional(),
3092
+ status: z16.boolean().optional(),
3093
+ earn_amount: finiteNumber().optional(),
3094
+ spend_point: finiteNumber().optional(),
3095
+ bonus_points: finiteNumber().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(),
3100
+ order_req_amount: finiteNumber().optional(),
3101
+ min_spend_point: finiteNumber().optional(),
3102
+ max_spend_point: finiteNumber().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(),
3108
+ limit_shipping: finiteNumber().optional(),
3109
+ expired_after: finiteNumber().optional(),
3110
+ expired_unit: z16.enum(["day", "month"]).optional(),
3111
+ is_refund_coupon: z16.boolean().optional(),
3112
+ priority: finiteNumber().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(),
3116
+ reward_product_quantity: finiteNumber().optional()
3117
+ });
3118
+ var tierConfigInputSchema = {
3119
+ tier_id: z16.string().min(1).optional().describe("Existing tier ID. Omit to create a tier."),
3120
+ name: z16.string().min(1).optional(),
3121
+ target_point: finiteNumber().nonnegative().optional().describe("Tier threshold. On an existing tier this is staged; it does not immediately re-evaluate members."),
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(),
3134
+ card_opacity: finiteNumber().optional(),
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.")
3141
+ };
3142
+ var tierRewardToolInputSchema = {
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."),
3145
+ ...tierRewardInputSchema.shape,
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.")
3149
+ };
3150
+ var tierSettingsInputSchema = {
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.")
3176
+ };
3177
+ var tierPerksInputSchema = {
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.")
3182
+ };
3183
+ function tierFreeGiftSelectionError(args, requireAppliedTo = false) {
3184
+ if (args.event !== "free_gift")
3185
+ return void 0;
3186
+ if (!args.applied_to) {
3187
+ if (!requireAppliedTo)
3188
+ return void 0;
3189
+ return "Creating free_gift requires applied_to. Ask the merchant which product or collection is the gift; product and collection IDs must come from the merchant and cannot be invented.";
3190
+ }
3191
+ if (args.applied_to === "specific_product" && !args.specific_products?.length) {
3192
+ return 'Creating free_gift with applied_to "specific_product" requires specific_products. Ask the merchant for the product IDs; product IDs cannot be invented.';
3193
+ }
3194
+ if (args.applied_to === "specific" && !args.applied_collection_ids?.length) {
3195
+ return 'Creating free_gift with applied_to "specific" requires applied_collection_ids. Ask the merchant for the collection IDs; product or collection IDs cannot be invented.';
3196
+ }
3197
+ return void 0;
3198
+ }
3199
+ function toTierConfigBody(args, action) {
3200
+ const body = {
3201
+ name: args.name,
3202
+ icon: args.icon,
3203
+ iconCustom: args.icon_custom,
3204
+ bgImage: args.bg_image,
3205
+ defaultAvatar: args.default_avatar,
3206
+ imageBlock: args.image_block,
3207
+ isExclusiveTier: args.is_exclusive_tier,
3208
+ hideDefaultTier: args.hide_default_tier,
3209
+ textColor: args.text_color,
3210
+ progressBarColor: args.progress_bar_color,
3211
+ boxBgColor: args.box_bg_color,
3212
+ boxTextColor: args.box_text_color,
3213
+ bgCardColor: args.bg_card_color,
3214
+ cardOpacity: args.card_opacity,
3215
+ iconColor: args.icon_color,
3216
+ bgType: args.bg_type,
3217
+ tierNamesLanguage: args.tier_names_language
3218
+ };
3219
+ if (action === "CREATE") {
3220
+ body.targetPoint = args.target_point;
3221
+ body.rewards = args.rewards?.map(toTierRewardFields);
3222
+ return omitUndefined(body);
3223
+ }
3224
+ body.targetPointUpdate = args.target_point;
3225
+ return omitUndefined(body);
3226
+ }
3227
+ function toTierUpdateBody(args) {
3228
+ return toTierConfigBody(args, "UPDATE");
3229
+ }
3230
+ function assertValidTierConfigArgs(args) {
3231
+ if (!args.tier_id && !args.name) {
3232
+ throw new JoyApiError("validation", "Creating a tier requires name.");
3233
+ }
3234
+ if (!args.tier_id && args.target_point === void 0) {
3235
+ throw new JoyApiError("validation", "Creating a tier requires target_point.");
3236
+ }
3237
+ if (!args.tier_id && args.target_point === 0) {
3238
+ throw new JoyApiError("validation", "target_point 0 is reserved for the system Bronze tier. Choose a positive threshold for a new tier.");
3239
+ }
3240
+ if (!args.tier_id) {
3241
+ for (const reward of args.rewards ?? []) {
3242
+ const selectionError = tierFreeGiftSelectionError(reward, true);
3243
+ if (selectionError)
3244
+ throw new JoyApiError("validation", selectionError);
3245
+ }
3246
+ }
3247
+ if (args.tier_id && Object.keys(toTierUpdateBody(args)).length === 0) {
3248
+ throw new JoyApiError("validation", "Choose at least one tier field to update.");
3249
+ }
3250
+ }
3251
+ function findTierDuplicate(tiers, args) {
3252
+ const byName = findDuplicate(tiers.map((tier) => ({ ...tier, title: tier.name })), { title: args.name });
3253
+ if (byName)
3254
+ return byName;
3255
+ if (args.target_point === void 0)
3256
+ return void 0;
3257
+ return findDuplicate(tiers.map((tier) => ({ ...tier, title: String(tier.targetPoint) })), { title: String(args.target_point) });
3258
+ }
3259
+ function buildTierConfigPreview(params) {
3260
+ const action = params.args.tier_id ? "UPDATE" : "CREATE";
3261
+ const body = toTierConfigBody(params.args, action);
3262
+ const current = action === "UPDATE" ? {
3263
+ ...params.current,
3264
+ targetPointUpdate: params.current?.targetPointUpdate ?? params.current?.targetPoint
3265
+ } : {};
3266
+ const dropped = action === "UPDATE" ? droppedFields({ rewards: params.args.rewards }, TIER_UPDATE_FIELDS) : droppedFields(body, [...TIER_UPDATE_FIELDS, "targetPoint", "rewards"]);
3267
+ const target = params.current?.name ?? params.args.name ?? params.args.tier_id ?? "new tier";
3268
+ return buildChangePreview({
3269
+ action,
3270
+ target: `tier "${String(target)}"`,
3271
+ reason: params.args.reason,
3272
+ changes: diffFields(current, body),
3273
+ dropped,
3274
+ duplicate: params.duplicate,
3275
+ warnings: action === "UPDATE" && params.args.target_point !== void 0 ? [TIER_THRESHOLD_WARNING] : void 0
3276
+ });
3277
+ }
3278
+ function rethrowTierCreateConflict(error) {
3279
+ if (error instanceof JoyApiError && error.status === 409) {
3280
+ throw new JoyApiError("conflict", "Cannot create this tier because another tier already has this name or target_point. Call joy_list_tiers, then update the existing tier with tier_id instead of retrying the create.", error.status);
3281
+ }
3282
+ throw error;
3283
+ }
3284
+ async function configureTier(ctx, args) {
3285
+ assertValidTierConfigArgs(args);
3286
+ const action = args.tier_id ? "UPDATE" : "CREATE";
3287
+ if (action === "UPDATE") {
3288
+ const current = await readTier(ctx, args.tier_id);
3289
+ const preview2 = buildTierConfigPreview({ args, current });
3290
+ const blocked2 = requireApply(args.apply, preview2);
3291
+ if (blocked2)
3292
+ return blocked2;
3293
+ const updated = await ctx.api.mutateData(`/tiers/${encodeURIComponent(args.tier_id)}`, {
3294
+ method: "PUT",
3295
+ timeoutMs: 15e3,
3296
+ body: toTierUpdateBody(args)
3297
+ });
3298
+ return tierConfigSummary("UPDATE", updated, args.target_point !== void 0);
3299
+ }
3300
+ const { data } = await ctx.api.getData("/tiers");
3301
+ const duplicate = findTierDuplicate(Array.isArray(data) ? data : [], args);
3302
+ const preview = buildTierConfigPreview({ args, duplicate });
3303
+ const blocked = requireApply(args.apply, preview);
3304
+ if (blocked)
3305
+ return blocked;
3306
+ try {
3307
+ const created = await ctx.api.mutateData("/tiers", {
3308
+ method: "POST",
3309
+ timeoutMs: 15e3,
3310
+ body: toTierConfigBody(args, "CREATE")
3311
+ });
3312
+ return tierConfigSummary("CREATE", created, false);
3313
+ } catch (error) {
3314
+ rethrowTierCreateConflict(error);
3315
+ }
3316
+ }
3317
+ async function readTier(ctx, tierId) {
3318
+ const { data } = await ctx.api.getData(`/tiers/${encodeURIComponent(tierId)}`);
3319
+ if (data && typeof data === "object")
3320
+ return data;
3321
+ throw new JoyApiError("not_found", `Tier "${tierId}" was not found. Call joy_list_tiers before changing it.`);
3322
+ }
3323
+ function tierConfigSummary(action, tier, changedThreshold) {
3324
+ const name = String(tier.name ?? tier.id ?? "tier");
3325
+ const prefix = action === "CREATE" ? `Created tier "${name}".` : `Updated tier "${name}".`;
3326
+ const warning = changedThreshold ? ` ${TIER_THRESHOLD_WARNING}` : "";
3327
+ return summaryWithJson(`${prefix}${warning}`, compact(tier, ["id", "name", "targetPoint", "targetPointUpdate", "tierRewards"]));
3328
+ }
3329
+ function toTierRewardBody(args) {
3330
+ return toTierRewardFields(args);
3331
+ }
3332
+ function toTierRewardFields(args) {
3333
+ return omitUndefined({
3334
+ title: args.title,
3335
+ status: args.status,
3336
+ event: args.event,
3337
+ type: args.type,
3338
+ earnAmount: args.earn_amount,
3339
+ spendPoint: args.spend_point,
3340
+ bonusPoints: args.bonus_points,
3341
+ codeName: args.code_name,
3342
+ redeemType: args.redeem_type,
3343
+ appliedTo: args.applied_to,
3344
+ orderReq: args.order_req,
3345
+ orderReqAmount: args.order_req_amount,
3346
+ minSpendPoint: args.min_spend_point,
3347
+ maxSpendPoint: args.max_spend_point,
3348
+ specificProducts: args.specific_products,
3349
+ specificCollections: args.specific_collections,
3350
+ appliedCollectionIds: args.applied_collection_ids,
3351
+ combinedWith: args.combined_with,
3352
+ hasLimitShipping: args.has_limit_shipping,
3353
+ limitShipping: args.limit_shipping,
3354
+ expiredAfter: args.expired_after,
3355
+ expiredUnit: args.expired_unit,
3356
+ isRefundCoupon: args.is_refund_coupon,
3357
+ priority: args.priority,
3358
+ showLoyaltyPage: args.show_loyalty_page,
3359
+ perkTitleLanguage: args.perk_title_language,
3360
+ rewardProductSelectionMode: args.reward_product_selection_mode,
3361
+ rewardProductQuantity: args.reward_product_quantity
3362
+ });
3363
+ }
3364
+ function assertValidTierRewardArgs(args) {
3365
+ if (!args.reward_id && !args.title) {
3366
+ throw new JoyApiError("validation", "Creating a tier reward requires title.");
3367
+ }
3368
+ const selectionError = tierFreeGiftSelectionError(args, !args.reward_id);
3369
+ if (selectionError)
3370
+ throw new JoyApiError("validation", selectionError);
3371
+ if (args.reward_id && Object.keys(toTierRewardBody(args)).length === 1) {
3372
+ throw new JoyApiError("validation", "Choose at least one tier-reward field to update in addition to event.");
3373
+ }
3374
+ }
3375
+ async function resolveTierRewardProductUrls(ctx, args) {
3376
+ if (!args.product_url?.length)
3377
+ return args;
3378
+ if (args.event !== "free_gift" || args.applied_to !== "specific_product") {
3379
+ throw new JoyApiError("validation", 'product_url is only supported for free_gift rewards with applied_to "specific_product". Use merchant-provided product IDs for another selection mode.');
3380
+ }
3381
+ const resolvedProducts = await Promise.all(args.product_url.map(async (productUrl) => {
3382
+ const parsed = parseShopifyProductUrl(productUrl);
3383
+ if (!parsed.ok)
3384
+ throw new JoyApiError("validation", `${parsed.error} Received: ${productUrl}`);
3385
+ const { data } = await ctx.api.getData(`/products/resolve?handle=${encodeURIComponent(parsed.handle)}`);
3386
+ if (!data || !Number.isSafeInteger(data.id) || data.id <= 0) {
3387
+ throw new JoyApiError("not_found", `No Shopify product was found for handle "${parsed.handle}". Ask the merchant for a valid product link; never fabricate an ID.`);
3388
+ }
3389
+ return { id: data.id, title: data.title, handle: data.handle };
3390
+ }));
3391
+ return { ...args, specific_products: [...args.specific_products ?? [], ...resolvedProducts] };
3392
+ }
3393
+ function findTierReward(tier, rewardId) {
3394
+ const rewards = Array.isArray(tier.tierRewards) ? tier.tierRewards : [];
3395
+ return rewards.find((reward) => isData(reward) && String(reward.id) === rewardId);
3396
+ }
3397
+ function buildTierRewardPreview(params) {
3398
+ const action = params.args.reward_id ? "UPDATE" : "CREATE";
3399
+ const body = toTierRewardBody(params.args);
3400
+ return buildChangePreview({
3401
+ action,
3402
+ target: `tier reward "${String(params.current?.title ?? params.args.title ?? params.args.event)}"`,
3403
+ reason: params.args.reason,
3404
+ changes: diffFields(params.current ?? {}, body),
3405
+ dropped: droppedFields(body, TIER_REWARD_FIELDS),
3406
+ duplicate: params.duplicate
3407
+ });
3408
+ }
3409
+ async function configureTierReward(ctx, args) {
3410
+ const resolvedArgs = await resolveTierRewardProductUrls(ctx, args);
3411
+ assertValidTierRewardArgs(resolvedArgs);
3412
+ const tier = await readTier(ctx, resolvedArgs.tier_id);
3413
+ const action = resolvedArgs.reward_id ? "UPDATE" : "CREATE";
3414
+ const current = resolvedArgs.reward_id ? findTierReward(tier, resolvedArgs.reward_id) : void 0;
3415
+ if (resolvedArgs.reward_id && !current) {
3416
+ throw new JoyApiError("not_found", `Reward "${resolvedArgs.reward_id}" is not attached to tier "${resolvedArgs.tier_id}". Nothing was changed; verify the tier and reward IDs before retrying.`);
3417
+ }
3418
+ const duplicate = action === "CREATE" ? findDuplicate(Array.isArray(tier.tierRewards) ? tier.tierRewards : [], {
3419
+ event: resolvedArgs.event,
3420
+ title: resolvedArgs.title
3421
+ }) : void 0;
3422
+ const preview = buildTierRewardPreview({ args: resolvedArgs, current, duplicate });
3423
+ const blocked = requireApply(resolvedArgs.apply, preview);
3424
+ if (blocked)
3425
+ return blocked;
3426
+ const result = await ctx.api.mutateData(action === "CREATE" ? `/tiers/${encodeURIComponent(resolvedArgs.tier_id)}/rewards` : `/tiers/${encodeURIComponent(resolvedArgs.tier_id)}/rewards/${encodeURIComponent(resolvedArgs.reward_id)}`, { method: action === "CREATE" ? "POST" : "PUT", timeoutMs: 15e3, body: toTierRewardBody(resolvedArgs) });
3427
+ const rewardId = action === "CREATE" ? String(result.id ?? "") : resolvedArgs.reward_id;
3428
+ if (!rewardId) {
3429
+ throw new JoyApiError("server", "The tier-reward create response did not include an id. Do not assume the reward was configured; re-read the tier in Joy admin before trying again.");
3430
+ }
3431
+ const refreshed = await readTier(ctx, resolvedArgs.tier_id);
3432
+ const reward = findTierReward(refreshed, rewardId);
3433
+ if (!reward) {
3434
+ throw new JoyApiError("server", `The write response named reward "${rewardId}", but it is not attached to tier "${resolvedArgs.tier_id}" after re-reading. Do not report this change as applied; verify it in Joy admin.`);
3435
+ }
3436
+ return tierRewardSummary(action, reward, resolvedArgs.tier_id);
3437
+ }
3438
+ function tierRewardSummary(action, reward, tierId) {
3439
+ const verb = action === "CREATE" ? "Created" : "Updated";
3440
+ return summaryWithJson(`${verb} tier reward "${String(reward.title ?? reward.id)}" on tier "${tierId}".`, compact(reward, ["id", "title", "event", "status", "earnAmount", "spendPoint", "bonusPoints", "appliedTo"]));
3441
+ }
3442
+ function toTierPerksBody(args) {
3443
+ return { contentPerk: args.perks };
3444
+ }
3445
+ function buildTierPerksPreview(current, args) {
3446
+ const body = toTierPerksBody(args);
3447
+ return buildChangePreview({
3448
+ action: "UPDATE",
3449
+ target: `display-only perks on tier "${String(current.name ?? args.tier_id)}"`,
3450
+ reason: args.reason,
3451
+ changes: diffFields({ contentPerk: Array.isArray(current.contentPerk) ? current.contentPerk : [] }, body),
3452
+ dropped: droppedFields(body, TIER_PERK_FIELDS),
3453
+ warnings: [
3454
+ "This write REPLACES the entire perk list; it does not append. Any existing perk omitted from perks will be removed."
3455
+ ]
3456
+ });
3457
+ }
3458
+ async function configureTierPerks(ctx, args) {
3459
+ const current = await readTier(ctx, args.tier_id);
3460
+ const preview = buildTierPerksPreview(current, args);
3461
+ const blocked = requireApply(args.apply, preview);
3462
+ if (blocked)
3463
+ return blocked;
3464
+ await ctx.api.mutateData(`/tiers/${encodeURIComponent(args.tier_id)}`, {
3465
+ method: "PUT",
3466
+ timeoutMs: 15e3,
3467
+ body: toTierPerksBody(args)
3468
+ });
3469
+ const refreshed = await readTier(ctx, args.tier_id);
3470
+ if (!Array.isArray(refreshed.contentPerk) || JSON.stringify(refreshed.contentPerk) !== JSON.stringify(args.perks)) {
3471
+ throw new JoyApiError("server", `The tier perk write was not confirmed: contentPerk did not match the complete replacement list after re-reading tier "${args.tier_id}". Do not report these perks as applied; verify the tier in Joy admin and report this backend failure.`);
3472
+ }
3473
+ return summaryWithJson(`Replaced the complete display-only perk list on tier "${String(refreshed.name ?? args.tier_id)}" and confirmed it by reading the tier back.`, compact(refreshed, ["id", "name", "contentPerk"]));
3474
+ }
3475
+ function toTierSettingsBody(args) {
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,
3483
+ useDemotionTier: args.use_demotion_tier,
3484
+ typeDemotion: args.type_demotion,
3485
+ downgradeAfter: args.downgrade_after === void 0 ? void 0 : String(args.downgrade_after),
3486
+ downgradeAfterUnit: args.downgrade_after_unit,
3487
+ useResetTier: args.use_reset_tier,
3488
+ resetAfter: args.reset_after === void 0 ? void 0 : String(args.reset_after),
3489
+ resetAfterUnit: args.reset_after_unit,
3490
+ resetFrequency: args.reset_frequency,
3491
+ resetIntervalValue: args.reset_interval_value,
3492
+ resetIntervalUnit: args.reset_interval_unit,
3493
+ resetStartDate: args.reset_start_date,
3494
+ monthResetTier: args.month_reset_tier === void 0 ? void 0 : String(args.month_reset_tier),
3495
+ dayResetTier: args.day_reset_tier === void 0 ? void 0 : String(args.day_reset_tier),
3496
+ isSyncTagsCustomerTier: args.is_sync_tags_customer_tier,
3497
+ customerTierTagPrefix: args.customer_tier_tag_prefix,
3498
+ notificationDowngradeBefore: args.notification_downgrade_before === void 0 ? void 0 : String(args.notification_downgrade_before),
3499
+ notificationDowngradeBeforeUnit: args.notification_downgrade_before_unit
3500
+ });
3501
+ }
3502
+ function touchesResetSchedule(body) {
3503
+ return RESET_SCHEDULE_FIELDS.some((field) => Object.hasOwn(body, field));
3504
+ }
3505
+ function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date()) {
3506
+ const body = toTierSettingsBody(args);
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;
3513
+ if (!touchesResetSchedule(body)) {
3514
+ return {
3515
+ kind: "preview",
3516
+ body,
3517
+ preview: buildChangePreview({
3518
+ action: "UPDATE",
3519
+ target: "tier settings",
3520
+ reason: args.reason,
3521
+ changes,
3522
+ dropped: droppedFields(body, TIER_SETTINGS_FIELDS),
3523
+ warnings: entryMethodWarning
3524
+ })
3525
+ };
3526
+ }
3527
+ const projectedSettings = prepareProjectedResetSettings(current, body, now);
3528
+ const projected = projectScheduleResetTierAt(projectedSettings, now);
3529
+ if (projected === void 0) {
3530
+ return {
3531
+ kind: "refused",
3532
+ body,
3533
+ guidance: "Refusing this tier reset change: Joy cannot calculate the projected scheduleResetTierAt from the merged settings. Supply a complete valid reset frequency, interval, and anchor/start date, then preview again. Applying blind could reset or demote every member on the wrong day."
3534
+ };
3535
+ }
3536
+ const calculatedCurrent = projectScheduleResetTierAt(prepareProjectedResetSettings(current, {}, now), now);
3537
+ const currentSchedule = current.scheduleResetTierAt ?? calculatedCurrent;
3538
+ const scheduleWarning = [
3539
+ `Current scheduleResetTierAt: ${formatSchedule(currentSchedule)}.`,
3540
+ `Projected scheduleResetTierAt: ${formatSchedule(projected)}.`,
3541
+ "This cron anchor controls when Joy demotes or resets every member."
3542
+ ].join(" ");
3543
+ return {
3544
+ kind: "preview",
3545
+ body,
3546
+ projectedScheduleResetTierAt: projected,
3547
+ preview: buildChangePreview({
3548
+ action: "UPDATE",
3549
+ target: "tier settings",
3550
+ reason: args.reason,
3551
+ changes,
3552
+ dropped: droppedFields(body, TIER_SETTINGS_FIELDS),
3553
+ warnings: entryMethodWarning ? [scheduleWarning, ...entryMethodWarning] : [scheduleWarning]
3554
+ })
3555
+ };
3556
+ }
3557
+ async function configureTierSettings(ctx, args) {
3558
+ const current = await readTierSettings(ctx);
3559
+ const decision = buildTierSettingsPreview(current, args);
3560
+ if (decision.kind === "refused")
3561
+ return decision.guidance;
3562
+ const blocked = requireApply(args.apply, decision.preview);
3563
+ if (blocked)
3564
+ return blocked;
3565
+ try {
3566
+ const updated = await ctx.api.mutateData("/tiers/settings", {
3567
+ method: "PUT",
3568
+ timeoutMs: 15e3,
3569
+ body: decision.body
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
+ }
3575
+ const schedule = touchesResetSchedule(decision.body) ? ` Projected scheduleResetTierAt: ${formatSchedule(decision.projectedScheduleResetTierAt)}.` : "";
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"]));
3584
+ } catch (error) {
3585
+ rethrowTierSettingsNotLaunched(error);
3586
+ }
3587
+ }
3588
+ async function readTierSettings(ctx) {
3589
+ try {
3590
+ const { data } = await ctx.api.getData("/tiers/settings");
3591
+ if (data && typeof data === "object")
3592
+ return data;
3593
+ throw new JoyApiError("not_found", "Tier settings are not available. Launch tiers first in Joy admin.");
3594
+ } catch (error) {
3595
+ rethrowTierSettingsNotLaunched(error);
3596
+ }
3597
+ }
3598
+ function rethrowTierSettingsNotLaunched(error) {
3599
+ if (error instanceof JoyApiError && error.kind === "not_found") {
3600
+ throw new JoyApiError("not_found", "This shop has never launched tiers, so tier settings do not exist yet. Launch the tier program in Joy admin first; this MCP tool cannot launch it.", error.status);
3601
+ }
3602
+ throw error;
3603
+ }
3604
+ function prepareProjectedResetSettings(current, body, now = /* @__PURE__ */ new Date()) {
3605
+ const merged = {
3606
+ useResetTier: false,
3607
+ resetFrequency: "yearly",
3608
+ monthResetTier: "12",
3609
+ dayResetTier: "31",
3610
+ ...current,
3611
+ ...body
3612
+ };
3613
+ if (merged.resetFrequency !== "custom" || merged.resetIntervalUnit !== "year")
3614
+ return merged;
3615
+ const timingKeys = ["resetFrequency", "resetIntervalValue", "resetIntervalUnit", "monthResetTier", "dayResetTier"];
3616
+ const timingChanged = timingKeys.some((key) => !sameValue(current[key], merged[key]));
3617
+ if (!timingChanged && validDate(current.resetStartDate))
3618
+ return merged;
3619
+ const first = nextAnchorOccurrence(merged.monthResetTier, merged.dayResetTier, 12, now, true, 2e3);
3620
+ if (!first)
3621
+ return merged;
3622
+ return { ...merged, resetStartDate: formatIsoDate(first) };
3623
+ }
3624
+ function projectScheduleResetTierAt(settings, now = /* @__PURE__ */ new Date()) {
3625
+ if (settings.useResetTier !== true)
3626
+ return null;
3627
+ const frequency = settings.resetFrequency;
3628
+ if (frequency === "monthly") {
3629
+ const next2 = nextMonthlyOccurrence(settings.dayResetTier, now, true);
3630
+ return next2 ? formatIsoDate(next2) : void 0;
3631
+ }
3632
+ if (frequency === "custom" && settings.resetIntervalUnit === "day") {
3633
+ const next2 = nextCustomDayOccurrence(settings.resetStartDate, settings.resetIntervalValue, now, true);
3634
+ return next2 ? formatIsoDate(next2) : void 0;
3635
+ }
3636
+ const stepMonths = resetStepMonths(frequency, settings.resetIntervalValue, settings.resetIntervalUnit);
3637
+ if (!stepMonths)
3638
+ return void 0;
3639
+ const baseYear = frequency === "custom" && settings.resetIntervalUnit === "year" && validDate(settings.resetStartDate) ? utcDate(new Date(settings.resetStartDate)).getUTCFullYear() : 2e3;
3640
+ const next = nextAnchorOccurrence(settings.monthResetTier, settings.dayResetTier, stepMonths, now, true, baseYear);
3641
+ return next ? formatIsoDate(next) : void 0;
3642
+ }
3643
+ function registerTierConfigTools(server, ctx) {
3644
+ registerConfigWriteTool(server, ctx, {
3645
+ name: "joy_configure_tier",
3646
+ title: "Create or update a VIP tier",
3647
+ description: "CONFIGURATION WRITE \u2014 creates a VIP tier, including optional inline rewards, or updates an existing tier. New tiers require name and target_point; target_point 0 is the system Bronze tier and cannot be created. Updating target_point is STAGED rather than live: Joy only promotes it and re-evaluates members when tiers relaunch in Joy admin.",
3648
+ inputSchema: tierConfigInputSchema,
3649
+ destructiveHint: false,
3650
+ handler: (args) => configureTier(ctx, args)
3651
+ });
3652
+ registerConfigWriteTool(server, ctx, {
3653
+ name: "joy_configure_tier_reward",
3654
+ title: "Create or update a VIP tier reward",
3655
+ description: "CONFIGURATION WRITE \u2014 attaches a reward to a tier or changes an attached reward. event is required. Tier reward applied_to values are all, specific_product, and specific; this is a DIFFERENT literal set from program-level reward tools. Numeric fields are strictly numeric because the REST backend otherwise accepts invalid strings.",
3656
+ inputSchema: tierRewardToolInputSchema,
3657
+ destructiveHint: false,
3658
+ handler: (args) => configureTierReward(ctx, args)
3659
+ });
3660
+ registerConfigWriteTool(server, ctx, {
3661
+ name: "joy_configure_tier_perks",
3662
+ title: "Replace display-only VIP tier perks",
3663
+ description: "CONFIGURATION WRITE \u2014 replaces the ENTIRE legacy contentPerk list on one VIP tier; it never appends. Pass every display-only perk that should remain, or [] to clear the list. These are storefront benefit-table labels or http(s) icon URLs and are distinct from the redeemable tierRewards managed by joy_configure_tier_reward.",
3664
+ inputSchema: tierPerksInputSchema,
3665
+ destructiveHint: true,
3666
+ handler: (args) => configureTierPerks(ctx, args)
3667
+ });
3668
+ registerConfigWriteTool(server, ctx, {
3669
+ name: "joy_configure_tier_settings",
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.',
3672
+ inputSchema: tierSettingsInputSchema,
3673
+ destructiveHint: true,
3674
+ handler: (args) => configureTierSettings(ctx, args)
3675
+ });
3676
+ }
3677
+ function omitUndefined(record) {
3678
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
3679
+ }
3680
+ function isData(value) {
3681
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3682
+ }
3683
+ function sameValue(a, b) {
3684
+ return a === b || String(a) === String(b);
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
+ }
3695
+ function formatSchedule(value) {
3696
+ if (value === void 0)
3697
+ return "(not exposed by REST v2)";
3698
+ if (value === null)
3699
+ return "(none)";
3700
+ return JSON.stringify(value);
3701
+ }
3702
+ function utcDate(value) {
3703
+ return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
3704
+ }
3705
+ function formatIsoDate(value) {
3706
+ return value.toISOString().slice(0, 10);
3707
+ }
3708
+ function validDate(value) {
3709
+ return typeof value === "string" && !Number.isNaN(new Date(value).getTime());
3710
+ }
3711
+ function integer(value) {
3712
+ const numeric = typeof value === "number" ? value : Number(value);
3713
+ return Number.isInteger(numeric) ? numeric : void 0;
3714
+ }
3715
+ function resetStepMonths(frequency, value, unit) {
3716
+ if (frequency === "quarterly")
3717
+ return 3;
3718
+ if (frequency === "half_yearly")
3719
+ return 6;
3720
+ if (frequency === "yearly")
3721
+ return 12;
3722
+ if (frequency !== "custom")
3723
+ return void 0;
3724
+ const interval = integer(value);
3725
+ if (!interval || interval < 1)
3726
+ return void 0;
3727
+ if (unit === "month")
3728
+ return [2, 3, 4, 6, 12].includes(interval) ? interval : void 0;
3729
+ if (unit === "year")
3730
+ return interval <= 5 ? interval * 12 : void 0;
3731
+ return void 0;
3732
+ }
3733
+ function nextMonthlyOccurrence(dayValue, after, inclusive) {
3734
+ const day = integer(dayValue);
3735
+ if (!day || day < 1 || day > 28)
3736
+ return void 0;
3737
+ const cursor = utcDate(after);
3738
+ const candidate = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), day));
3739
+ if (isAfterOrEqual(candidate, cursor, inclusive))
3740
+ return candidate;
3741
+ return new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, day));
3742
+ }
3743
+ function nextCustomDayOccurrence(startValue, intervalValue, after, inclusive) {
3744
+ const interval = integer(intervalValue);
3745
+ if (!validDate(startValue) || !interval || interval < 30)
3746
+ return void 0;
3747
+ const start = utcDate(new Date(startValue));
3748
+ const cursor = utcDate(after);
3749
+ const days = Math.round((cursor.getTime() - start.getTime()) / 864e5);
3750
+ let multiple = Math.max(0, Math.floor(days / interval));
3751
+ let candidate = new Date(start.getTime() + multiple * interval * 864e5);
3752
+ while (!isAfterOrEqual(candidate, cursor, inclusive)) {
3753
+ multiple += 1;
3754
+ candidate = new Date(start.getTime() + multiple * interval * 864e5);
3755
+ }
3756
+ return candidate;
3757
+ }
3758
+ function nextAnchorOccurrence(monthValue, dayValue, stepMonths, after, inclusive, baseYear) {
3759
+ const month = integer(monthValue);
3760
+ const day = integer(dayValue);
3761
+ if (!month || month < 1 || month > 12 || !day || day < 1 || day > 31)
3762
+ return void 0;
3763
+ if (day > daysInMonth(2e3, month - 1))
3764
+ return void 0;
3765
+ const cursor = utcDate(after);
3766
+ const anchorMonth = baseYear * 12 + month - 1;
3767
+ const cursorMonth = cursor.getUTCFullYear() * 12 + cursor.getUTCMonth();
3768
+ let step = Math.floor((cursorMonth - anchorMonth) / stepMonths);
3769
+ let candidate = anchorOccurrence(anchorMonth, day, step, stepMonths);
3770
+ while (!isAfterOrEqual(candidate, cursor, inclusive)) {
3771
+ step += 1;
3772
+ candidate = anchorOccurrence(anchorMonth, day, step, stepMonths);
3773
+ }
3774
+ return candidate;
3775
+ }
3776
+ function anchorOccurrence(anchorMonth, day, step, stepMonths) {
3777
+ const absoluteMonth = anchorMonth + step * stepMonths;
3778
+ const year = Math.floor(absoluteMonth / 12);
3779
+ const month = (absoluteMonth % 12 + 12) % 12;
3780
+ return new Date(Date.UTC(year, month, Math.min(day, daysInMonth(year, month))));
3781
+ }
3782
+ function daysInMonth(year, month) {
3783
+ return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
3784
+ }
3785
+ function isAfterOrEqual(candidate, cursor, inclusive) {
3786
+ return inclusive ? candidate.getTime() >= cursor.getTime() : candidate.getTime() > cursor.getTime();
3787
+ }
3788
+
3789
+ // ../mcp-core/build/tools/shopConfig.js
3790
+ import { z as z17 } from "zod";
3791
+ var SHOP_SETTINGS_FIELDS = [
3792
+ "primaryLanguageInApp",
3793
+ "dateFormat",
3794
+ "dateFormatForActivity",
3795
+ "pointPosition",
3796
+ "customerApplyDiscountBehavior",
3797
+ "customerRedeemCouponBehavior",
3798
+ "customerFreeGiftBehavior",
3799
+ "statusForReview",
3800
+ "earnByOrderStatus",
3801
+ "enableGlobalEarningLimit",
3802
+ "globalEarningLimitTimeframe",
3803
+ "globalEarningLimitRollingDays"
3804
+ ];
3805
+ var SHOP_SETTINGS_ARGUMENTS = {
3806
+ primary_language_in_app: "primaryLanguageInApp",
3807
+ date_format: "dateFormat",
3808
+ date_format_for_activity: "dateFormatForActivity",
3809
+ point_position: "pointPosition",
3810
+ customer_apply_discount_behavior: "customerApplyDiscountBehavior",
3811
+ customer_redeem_coupon_behavior: "customerRedeemCouponBehavior",
3812
+ customer_free_gift_behavior: "customerFreeGiftBehavior",
3813
+ status_for_review: "statusForReview",
3814
+ earn_by_order_status: "earnByOrderStatus",
3815
+ enable_global_earning_limit: "enableGlobalEarningLimit",
3816
+ global_earning_limit_timeframe: "globalEarningLimitTimeframe",
3817
+ global_earning_limit_rolling_days: "globalEarningLimitRollingDays"
3818
+ };
3819
+ var SHOP_SETTINGS_VALID_ARGUMENTS = [...Object.keys(SHOP_SETTINGS_ARGUMENTS), "reason", "apply"];
3820
+ var TRANSLATION_LANGUAGE_VALID_ARGUMENTS = ["primary_language", "additional_language", "reason", "apply"];
3821
+ var SHOP_COLLECTION_FIELDS = ["statusForReview", "earnByOrderStatus"];
3822
+ var SETTINGS_DOCUMENT_FIELDS = SHOP_SETTINGS_FIELDS.filter((field) => !SHOP_COLLECTION_FIELDS.includes(field));
3823
+ var TRANSLATION_LOCALES = [
3824
+ "ar",
3825
+ "ckb",
3826
+ "cs",
3827
+ "da",
3828
+ "de",
3829
+ "el",
3830
+ "en",
3831
+ "es",
3832
+ "et",
3833
+ "fi",
3834
+ "fr",
3835
+ "ga",
3836
+ "he-IL",
3837
+ "hi",
3838
+ "hu",
3839
+ "id",
3840
+ "it",
3841
+ "ja",
3842
+ "ko",
3843
+ "lb",
3844
+ "lt",
3845
+ "lv",
3846
+ "ms",
3847
+ "nl",
3848
+ "nb",
3849
+ "pl",
3850
+ "pt",
3851
+ "ro",
3852
+ "ru",
3853
+ "sk",
3854
+ "sl",
3855
+ "sr",
3856
+ "sv",
3857
+ "th",
3858
+ "tr",
3859
+ "ur",
3860
+ "vi",
3861
+ "zh-CN",
3862
+ "zh-TW",
3863
+ "uk",
3864
+ "hr"
3865
+ ];
3866
+ function getUnknownArguments(args, validArguments) {
3867
+ const valid = new Set(validArguments);
3868
+ return Object.keys(args).filter((argument) => !valid.has(argument));
3869
+ }
3870
+ function assertKnownArguments(args, validArguments) {
3871
+ const unknown = getUnknownArguments(args, validArguments);
3872
+ if (unknown.length > 0) {
3873
+ throw new Error(`Unknown argument${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. Valid arguments: ${validArguments.join(", ")}.`);
3874
+ }
3875
+ }
3876
+ function toShopSettingsBody(args) {
3877
+ assertKnownArguments(args, SHOP_SETTINGS_VALID_ARGUMENTS);
3878
+ const body = {};
3879
+ for (const [argument, field] of Object.entries(SHOP_SETTINGS_ARGUMENTS)) {
3880
+ if (args[argument] !== void 0)
3881
+ body[field] = args[argument];
3882
+ }
3883
+ return body;
3884
+ }
3885
+ function getDroppedShopSettingsFields(args) {
3886
+ return getUnknownArguments(args, SHOP_SETTINGS_VALID_ARGUMENTS);
3887
+ }
3888
+ function selectedFields(body, fields) {
3889
+ const selected = fields.filter((field) => body[field] !== void 0);
3890
+ return selected.length > 0 ? selected.join(", ") : "none selected";
3891
+ }
3892
+ function buildShopSettingsPreview(current, args) {
3893
+ const body = toShopSettingsBody(args);
3894
+ return buildChangePreview({
3895
+ action: "UPDATE",
3896
+ target: "shop settings",
3897
+ reason: args.reason,
3898
+ changes: diffFields(current, body),
3899
+ dropped: getDroppedShopSettingsFields(args),
3900
+ warnings: [
3901
+ `This endpoint performs two independent writes: fields for the shops collection (${selectedFields(body, SHOP_COLLECTION_FIELDS)}) and fields for the separate settings document (${selectedFields(body, SETTINGS_DOCUMENT_FIELDS)}). There is no atomic rollback, so a partial failure is possible.`
3902
+ ]
3903
+ });
3904
+ }
3905
+ function validateTranslationLanguages(args) {
3906
+ const invalid = [args.primary_language, args.additional_language].filter((locale) => !TRANSLATION_LOCALES.includes(locale));
3907
+ if (invalid.length > 0) {
3908
+ throw new Error(`Invalid locale: ${invalid.join(", ")}. Valid locales: ${TRANSLATION_LOCALES.join(", ")}.`);
3909
+ }
3910
+ }
3911
+ function buildTranslationLanguagePreview(args) {
3912
+ assertKnownArguments(args, TRANSLATION_LANGUAGE_VALID_ARGUMENTS);
3913
+ validateTranslationLanguages(args);
3914
+ return buildChangePreview({
3915
+ action: "CREATE",
3916
+ target: `storefront language "${args.additional_language}" from primary language "${args.primary_language}"`,
3917
+ reason: args.reason,
3918
+ changes: diffFields({}, {
3919
+ primaryLanguage: args.primary_language,
3920
+ additionalLanguage: args.additional_language
3921
+ }),
3922
+ warnings: [
3923
+ `Applying this machine-translates the shop's entire existing ${args.primary_language} primary-language content into ${args.additional_language}. This is expensive and hard to undo.`
3924
+ ]
3925
+ });
3926
+ }
3927
+ function registerShopConfigTools(server, ctx) {
3928
+ registerConfigWriteTool(server, ctx, {
3929
+ name: "joy_configure_shop_settings",
3930
+ title: "Configure safe shop-level loyalty settings",
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.",
3932
+ inputSchema: {
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.")
3947
+ },
3948
+ destructiveHint: false,
3949
+ strictArguments: true,
3950
+ handler: async (args) => {
3951
+ const current = (await ctx.api.getData("/shop/settings")).data ?? {};
3952
+ const preview = buildShopSettingsPreview(current, args);
3953
+ const stop = requireApply(args.apply, preview);
3954
+ if (stop)
3955
+ return stop;
3956
+ const body = toShopSettingsBody(args);
3957
+ if (Object.keys(body).length === 0)
3958
+ return preview;
3959
+ await ctx.api.mutateData("/shop/settings", { method: "PUT", body });
3960
+ return `Updated shop settings: ${Object.keys(body).join(", ")}. The backend writes the shops collection and settings document separately; verify both values if the request reported an error.`;
3961
+ }
3962
+ });
3963
+ registerConfigWriteTool(server, ctx, {
3964
+ name: "joy_add_translation_language",
3965
+ title: "Add a storefront translation language",
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.",
3967
+ inputSchema: {
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.")
3972
+ },
3973
+ destructiveHint: false,
3974
+ strictArguments: true,
3975
+ handler: async (args) => {
3976
+ const preview = buildTranslationLanguagePreview(args);
3977
+ const stop = requireApply(args.apply, preview);
3978
+ if (stop)
3979
+ return stop;
3980
+ await ctx.api.mutateData("/translations/languages", {
3981
+ method: "POST",
3982
+ body: { primaryLanguage: args.primary_language, additionalLanguage: args.additional_language }
3983
+ });
3984
+ return `Added storefront language ${args.additional_language}; Joy has started machine-translating the existing ${args.primary_language} primary-language content.`;
3985
+ }
3986
+ });
3987
+ }
3988
+
3989
+ // ../mcp-core/build/tools/brandingWrite.js
3990
+ import { z as z18 } from "zod";
3991
+ var WIDGET_DRAFT_SAVED_MESSAGE = "Saved to the V4 draft. The storefront is unchanged until you publish it in Joy admin.";
3992
+ var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
3993
+ var COLOR_ROLE_PATHS = {
3994
+ primary: [
3995
+ "theme.primaryColor",
3996
+ "theme.primaryButtonColor",
3997
+ "theme.headerBackgroundColor",
3998
+ "theme.footerActiveTextColor",
3999
+ "launcher.bgColor",
4000
+ "launcher.pointBalance.labelBgColor",
4001
+ "membershipCard.beforeSignIn.buttonBgColor",
4002
+ "membershipCard.afterSignIn.buttonBgColor"
4003
+ ],
4004
+ secondary: ["theme.secondaryColor"],
4005
+ text: ["theme.textColor", "theme.blockTextColor"],
4006
+ heading: ["theme.headingTextColor", "theme.cardHeaderColor", "header.textColor"],
4007
+ background: [
4008
+ "theme.widgetBackgroundColor",
4009
+ "theme.blockBgColor",
4010
+ "theme.footerBgColor",
4011
+ "theme.secondaryButtonColor",
4012
+ "membershipCard.beforeSignIn.bgColor",
4013
+ "membershipCard.afterSignIn.bgColor",
4014
+ "membershipCard.afterSignIn.boxBgColor"
4015
+ ],
4016
+ button: ["theme.buttonStyleMode", "theme.buttonBgColor"],
4017
+ buttonText: [
4018
+ "theme.buttonTextColor",
4019
+ "theme.primaryTextColor",
4020
+ "launcher.textColor",
4021
+ "launcher.pointBalance.labelTextColor",
4022
+ "membershipCard.beforeSignIn.buttonTextColor",
4023
+ "membershipCard.afterSignIn.buttonTextColor"
4024
+ ],
4025
+ border: ["theme.cardBorderColor"]
4026
+ };
4027
+ var COLOR_ROLES = Object.keys(COLOR_ROLE_PATHS);
4028
+ var CORNER_RADIUS_VALUES = ["radiusNone", "radiusSharp", "radiusSoft", "radiusRounded", "radiusExtra"];
4029
+ var BUTTON_STYLE_VALUES = ["store", "preset", "custom"];
4030
+ var CONTENT_STYLE_VALUES = ["icons_and_text", "text_only"];
4031
+ var DISPLAY_TYPE_VALUES = ["widget", "drawer", "floating-drawer"];
4032
+ var widgetDraftInputSchema = {
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.")
4045
+ }).optional().describe("Optional non-colour design settings. Values are enums, not numbers or CSS lengths."),
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.")
4049
+ };
4050
+ var brandScanInputSchema = {
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.")
4053
+ };
4054
+ function isHexColor(value) {
4055
+ return typeof value === "string" && HEX_COLOR.test(value);
4056
+ }
4057
+ function validateColorOverrides(overrides) {
4058
+ if (!overrides)
4059
+ return void 0;
4060
+ for (const [role, color] of Object.entries(overrides)) {
4061
+ if (!Object.hasOwn(COLOR_ROLE_PATHS, role)) {
4062
+ return `Unknown color role "${role}". Supported roles are: ${COLOR_ROLES.join(", ")}.`;
4063
+ }
4064
+ if (!isHexColor(color)) {
4065
+ return `Color override for "${role}" must be a hex color such as #112233.`;
4066
+ }
4067
+ }
4068
+ return void 0;
4069
+ }
4070
+ function brandingErrorGuidance(error, operation, target = "draft") {
4071
+ if (!(error instanceof JoyApiError))
4072
+ return void 0;
4073
+ const errorCode = error.code;
4074
+ const passwordIncorrect = errorCode === "PASSWORD_INCORRECT" || /PASSWORD_INCORRECT|storefront password (?:did not unlock|was not accepted)|incorrect storefront password/i.test(error.modelMessage);
4075
+ const passwordRequired = errorCode === "PASSWORD_REQUIRED" || error.status === 409 || /PASSWORD_REQUIRED|storefront is password-protected|storefront password is required/i.test(error.modelMessage);
4076
+ if (operation === "scan" && passwordIncorrect) {
4077
+ return new JoyApiError("validation", "The storefront password was not accepted. Ask the merchant to verify the storefront password, then pass the corrected value as storefront_password. Do not guess it.", error.status);
4078
+ }
4079
+ if (operation === "scan" && passwordRequired) {
4080
+ return new JoyApiError("conflict", "The storefront is password-protected. Ask the merchant for the storefront password, then pass it as storefront_password to joy_scan_brand_design. Do not guess the password, and do not invent colors or fonts.", error.status);
4081
+ }
4082
+ if (operation === "scan" && error.status === 503) {
4083
+ return new JoyApiError("server", "Joy could not complete the storefront design scan. No design has been returned, so do not invent colors, fonts, or button styling. Ask the merchant to retry later or configure the draft manually.", error.status);
4084
+ }
4085
+ if (operation === "draft" && target === "live" && error.status === 409) {
4086
+ return new JoyApiError("conflict", "The live widget document exists only for the version the shop is currently using. This target: 'live' request used a non-live version and was refused. Check which widget version is currently live before retrying; do not blindly retry the other version.", error.status);
4087
+ }
4088
+ if (operation === "draft" && error.status === 409) {
4089
+ return new JoyApiError("conflict", "The requested widget version is currently live. This tool only writes the non-live draft so it cannot alter the storefront. Choose the other version or edit the live widget manually in Joy admin.", error.status);
4090
+ }
4091
+ return void 0;
4092
+ }
4093
+ function asRecord(value) {
4094
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
4095
+ }
4096
+ function getPath(source, path) {
4097
+ let current = source;
4098
+ for (const part of path.split(".")) {
4099
+ if (!current || typeof current !== "object" || Array.isArray(current))
4100
+ return void 0;
4101
+ current = current[part];
4102
+ }
4103
+ return current;
4104
+ }
4105
+ function sameValue2(first, second) {
4106
+ return stableJson(first) === stableJson(second);
4107
+ }
4108
+ function stableJson(value) {
4109
+ return JSON.stringify(value, (_key, raw) => {
4110
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
4111
+ return raw;
4112
+ const record = raw;
4113
+ return Object.fromEntries(Object.keys(record).sort().map((key) => [key, record[key]]));
4114
+ });
4115
+ }
4116
+ var DOCUMENT_METADATA_KEYS = /* @__PURE__ */ new Set(["id", "shopId", "createdAt", "updatedAt", "type"]);
4117
+ function unconfirmedScanFields(readBack, expected) {
4118
+ return Object.keys(expected).filter((key) => !DOCUMENT_METADATA_KEYS.has(key)).filter((key) => !sameValue2(readBack[key], expected[key]));
4119
+ }
4120
+ function setPath(source, path, value) {
4121
+ const [part, ...rest] = path.split(".");
4122
+ if (!part)
4123
+ return source;
4124
+ if (rest.length === 0)
4125
+ return { ...source, [part]: value };
4126
+ const child = asRecord(source[part]);
4127
+ return { ...source, [part]: setPath(child, rest.join("."), value) };
4128
+ }
4129
+ function applyPath(draft, changes, path, value) {
4130
+ const from = getPath(draft, path);
4131
+ if (sameValue2(from, value))
4132
+ return draft;
4133
+ changes.push({ path, from, to: value });
4134
+ return setPath(draft, path, value);
4135
+ }
4136
+ function applyColorRole(draft, changes, role, color) {
4137
+ const paths = COLOR_ROLE_PATHS[role] ?? [];
4138
+ return paths.reduce((next, path) => {
4139
+ const value = path === "theme.buttonStyleMode" ? "custom" : color;
4140
+ return applyPath(next, changes, path, value);
4141
+ }, draft);
4142
+ }
4143
+ function redactCredential(text, credential) {
4144
+ if (!credential)
4145
+ return text;
4146
+ return text.split(credential).join("[REDACTED]");
4147
+ }
4148
+ function errorWithoutCredential(error, credential) {
4149
+ if (!credential)
4150
+ return error;
4151
+ if (error instanceof JoyApiError) {
4152
+ return new JoyApiError(error.kind, redactCredential(error.modelMessage, credential), error.status);
4153
+ }
4154
+ if (error instanceof Error)
4155
+ return new Error(redactCredential(error.message, credential));
4156
+ return new Error(redactCredential(String(error), credential));
4157
+ }
4158
+ function prepareWidgetDraft(current, args) {
4159
+ const changes = [];
4160
+ let draft = current;
4161
+ for (const [role, color] of Object.entries(args.color_overrides ?? {})) {
4162
+ draft = applyColorRole(draft, changes, role, color);
4163
+ }
4164
+ if (args.design_overrides?.corner_radius) {
4165
+ draft = applyPath(draft, changes, "theme.cornerRadius", args.design_overrides.corner_radius);
4166
+ }
4167
+ if (args.design_overrides?.button_style) {
4168
+ draft = applyPath(draft, changes, "theme.buttonStyleMode", args.design_overrides.button_style);
4169
+ }
4170
+ if (args.design_overrides?.content_style) {
4171
+ draft = applyPath(draft, changes, "theme.hideProgramIcons", args.design_overrides.content_style === "text_only");
4172
+ }
4173
+ if (args.design_overrides?.display_type) {
4174
+ draft = applyPath(draft, changes, "display.type", args.design_overrides.display_type);
4175
+ }
4176
+ if (args.font_overrides?.heading) {
4177
+ draft = applyPath(draft, changes, "fonts.heading", args.font_overrides.heading);
4178
+ draft = applyPath(draft, changes, "fonts.headline", args.font_overrides.heading);
4179
+ }
4180
+ if (args.font_overrides?.body) {
4181
+ draft = applyPath(draft, changes, "fonts.body", args.font_overrides.body);
4182
+ draft = applyPath(draft, changes, "fonts.action", args.font_overrides.body);
4183
+ }
4184
+ return { draft, changes };
4185
+ }
4186
+ function unappliedDraftPaths(readBack, changes) {
4187
+ return changes.filter((change) => !sameValue2(getPath(readBack, change.path), change.to)).map((change) => change.path);
4188
+ }
4189
+ function buildWidgetDraftPreview(args, changes) {
4190
+ const target = args.target ?? "draft";
4191
+ const lines = [
4192
+ "DRY RUN \u2014 nothing has been changed yet.",
4193
+ target === "live" ? `Would update the live ${args.version.toUpperCase()} widget configuration.` : `Would update the ${args.version.toUpperCase()} widget draft.`,
4194
+ `Reason given: ${args.reason}`
4195
+ ];
4196
+ if (args.apply_scan === true) {
4197
+ lines.push("", "Storefront scan changes:", " Joy\u2019s backend will merge the detected colors, fonts, button style, and corner radius into the widget.");
4198
+ if (args.apply_images === true) {
4199
+ lines.push(" Images will change where detected: the program logo, membership-card hero, REFERRAL section banner, and every VIP tier background.");
4200
+ } else {
4201
+ lines.push(" Images will not change (apply_images defaults to false).");
4202
+ }
4203
+ }
4204
+ if (changes.length === 0 && args.apply_scan !== true) {
4205
+ lines.push("No supported field would change \u2014 do not report this as a saved design.");
4206
+ } else if (changes.length > 0) {
4207
+ lines.push("", "Changes:");
4208
+ for (const change of changes) {
4209
+ lines.push(` ${change.path}: ${JSON.stringify(change.from)} \u2192 ${JSON.stringify(change.to)}`);
4210
+ }
4211
+ }
4212
+ if (target === "live") {
4213
+ lines.push("", "WARNING \u2014 saving this changes the storefront immediately. There is NO publish step and NO undo.");
4214
+ } else {
4215
+ lines.push("", "This writes only a non-live draft. The agent cannot see the resulting widget, so the merchant must review it in Joy admin and publish it manually if they approve.");
4216
+ }
4217
+ return lines.join("\n");
4218
+ }
4219
+ async function getBrandScan(ctx, args = {}) {
4220
+ try {
4221
+ if (args.storefront_password !== void 0) {
4222
+ return await ctx.api.mutateData("/shop/branding/scan", {
4223
+ method: "POST",
4224
+ // A storefront scan launches a headless browser and loads a real
4225
+ // commercial page; measured at 33s against arrae.com. The 20s client
4226
+ // default would abort a scan that is working, so this one call gets a
4227
+ // budget sized to the work rather than to a normal API round trip.
4228
+ timeoutMs: 9e4,
4229
+ body: {
4230
+ ...args.url === void 0 ? {} : { url: args.url },
4231
+ storefrontPassword: args.storefront_password
4232
+ }
4233
+ });
4234
+ }
4235
+ const { data } = await ctx.api.getData("/shop/branding/scan", {
4236
+ timeoutMs: 9e4,
4237
+ ...args.url === void 0 ? {} : { query: { url: args.url } }
4238
+ });
4239
+ return data;
4240
+ } catch (error) {
4241
+ const guided = brandingErrorGuidance(error, "scan") ?? error;
4242
+ throw errorWithoutCredential(guided, args.storefront_password);
4243
+ }
4244
+ }
4245
+ function widgetWriteQuery(version2, target) {
4246
+ return target === "live" ? { version: version2, target } : { version: version2 };
4247
+ }
4248
+ function isPasswordFailure(error) {
4249
+ if (!(error instanceof JoyApiError))
4250
+ return false;
4251
+ const errorCode = error.code;
4252
+ return errorCode === "PASSWORD_REQUIRED" || errorCode === "PASSWORD_INCORRECT" || /PASSWORD_REQUIRED|PASSWORD_INCORRECT|STOREFRONT_PASSWORD_ENABLED|storefront password/i.test(error.modelMessage);
4253
+ }
4254
+ function applyScanBody(args) {
4255
+ return {
4256
+ applyScan: {
4257
+ ...args.scan_url === void 0 ? {} : { url: args.scan_url },
4258
+ ...args.storefront_password === void 0 ? {} : { storefrontPassword: args.storefront_password },
4259
+ colors: true,
4260
+ ...args.apply_images === true ? { images: true } : {}
4261
+ }
4262
+ };
4263
+ }
4264
+ async function getWidgetDraft(ctx, version2) {
4265
+ try {
4266
+ const { data } = await ctx.api.getData("/shop/branding/widget/draft", { query: { version: version2 } });
4267
+ return asRecord(data);
4268
+ } catch (error) {
4269
+ throw brandingErrorGuidance(error, "draft") ?? error;
4270
+ }
4271
+ }
4272
+ async function putWidgetDraft(ctx, version2, target, draft) {
4273
+ try {
4274
+ const result = await ctx.api.mutateData("/shop/branding/widget/draft", {
4275
+ method: "PUT",
4276
+ query: widgetWriteQuery(version2, target),
4277
+ body: draft
4278
+ });
4279
+ return asRecord(result);
4280
+ } catch (error) {
4281
+ throw brandingErrorGuidance(error, "draft", target) ?? error;
4282
+ }
4283
+ }
4284
+ async function putWidgetScan(ctx, args) {
4285
+ try {
4286
+ const result = await ctx.api.mutateData("/shop/branding/widget/draft", {
4287
+ method: "PUT",
4288
+ query: widgetWriteQuery(args.version, args.target),
4289
+ // The SERVER runs the storefront scan inside this request — a headless
4290
+ // browser over a real commercial page, measured at 33-90s. The 20s
4291
+ // default aborts a write that is working, and an aborted write leaves the
4292
+ // caller unable to tell whether it landed. putWidgetDraft, which sends an
4293
+ // explicit document, is an ordinary write and keeps the default.
4294
+ timeoutMs: 12e4,
4295
+ body: applyScanBody(args)
4296
+ });
4297
+ return asRecord(result);
4298
+ } catch (error) {
4299
+ if (isPasswordFailure(error))
4300
+ throw brandingErrorGuidance(error, "scan") ?? error;
4301
+ throw brandingErrorGuidance(error, "draft", args.target) ?? error;
4302
+ }
4303
+ }
4304
+ function registerBrandingReadTools(server, ctx) {
4305
+ registerReadTool(server, ctx, {
4306
+ name: "joy_scan_brand_design",
4307
+ title: "Scan a storefront\u2019s design",
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.",
4309
+ inputSchema: brandScanInputSchema,
4310
+ handler: async (args) => {
4311
+ const scan = await getBrandScan(ctx, args);
4312
+ const source = asRecord(scan.source);
4313
+ const cached = source.cached === true;
4314
+ const domain = typeof source.domain === "string" ? source.domain : "the storefront";
4315
+ return redactCredential(summaryWithJson(`Brand scan completed for ${domain}. This is a ${cached ? "cached" : "fresh"} scan result; present it as detected storefront data, not a visual review of the widget.`, {
4316
+ colors: scan.colors,
4317
+ fonts: scan.fonts ?? {},
4318
+ buttons: scan.buttons ?? {},
4319
+ ...scan.images === void 0 ? {} : { images: scan.images },
4320
+ source: compact(source, ["domain", "scannedAt", "cached"])
4321
+ }), args.storefront_password);
4322
+ }
4323
+ });
4324
+ }
4325
+ function registerBrandingWriteTools(server, ctx) {
4326
+ registerConfigWriteTool(server, ctx, {
4327
+ name: "joy_configure_widget_draft",
4328
+ title: "Configure widget brand settings",
4329
+ description: "WRITE \u2014 draft by default; live only on explicit target: 'live'. Applies this shop\u2019s or a reference storefront\u2019s scan through Joy\u2019s shared backend design merge, supports limited explicit color, corner-radius, button-style and font overrides, previews by default, and reads the document back after saving. Images change only with apply_images: true. A live save changes the storefront immediately with no publish step and no undo. The agent cannot see the result, so do not claim it looks good.",
4330
+ inputSchema: widgetDraftInputSchema,
4331
+ destructiveHint: true,
4332
+ handler: async (args) => {
4333
+ try {
4334
+ const target = args.target ?? "draft";
4335
+ const current = await getWidgetDraft(ctx, args.version);
4336
+ const validationError = validateColorOverrides(args.color_overrides);
4337
+ if (validationError)
4338
+ throw new JoyApiError("validation", validationError);
4339
+ const prepared = prepareWidgetDraft(current, args);
4340
+ const preview = buildWidgetDraftPreview(args, prepared.changes);
4341
+ if (args.apply !== true)
4342
+ return redactCredential(preview, args.storefront_password);
4343
+ if (args.apply_scan !== true && prepared.changes.length === 0) {
4344
+ return redactCredential(`${preview}
4345
+
4346
+ No write was sent because this would be a no-op.`, args.storefront_password);
4347
+ }
4348
+ let explicitBase = current;
4349
+ let scanApplied = false;
4350
+ if (args.apply_scan === true) {
4351
+ const expected = await putWidgetScan(ctx, args);
4352
+ const readBack = await getWidgetDraft(ctx, args.version);
4353
+ const unconfirmed = unconfirmedScanFields(readBack, expected);
4354
+ if (unconfirmed.length > 0) {
4355
+ const scanFailureGuidance = target === "live" ? "The live scan-based widget write was not confirmed. Do not report the design as saved; the storefront may already have changed, so ask the merchant to inspect the live widget and report this backend failure." : "The scan-based draft write was not confirmed. Do not report the design as saved; ask the merchant to review the draft in Joy admin and report this backend failure.";
4356
+ throw new JoyApiError("server", `${scanFailureGuidance} These fields did not survive the required read-back: ${unconfirmed.join(", ")}.`);
4357
+ }
4358
+ explicitBase = readBack;
4359
+ scanApplied = true;
4360
+ }
4361
+ const explicit = prepareWidgetDraft(explicitBase, args);
4362
+ if (explicit.changes.length > 0) {
4363
+ await putWidgetDraft(ctx, args.version, target, explicit.draft);
4364
+ const readBack = await getWidgetDraft(ctx, args.version);
4365
+ const missing = unappliedDraftPaths(readBack, explicit.changes);
4366
+ if (missing.length > 0) {
4367
+ const failureGuidance = target === "live" ? "The live widget write was not confirmed. Do not report the design as saved; the storefront may already have changed, so ask the merchant to inspect the live widget and report this backend failure." : "The draft write was not confirmed. Do not report the design as saved; ask the merchant to review the draft in Joy admin and report this backend failure.";
4368
+ throw new JoyApiError("server", `${failureGuidance} These fields were unchanged after the required read-back: ${missing.join(", ")}.`);
4369
+ }
4370
+ }
4371
+ const confirmed2 = [
4372
+ ...scanApplied ? ["the backend storefront-design merge"] : [],
4373
+ ...explicit.changes.length > 0 ? [
4374
+ `${explicit.changes.length} explicit field${explicit.changes.length === 1 ? "" : "s"}`
4375
+ ] : []
4376
+ ].join(" and ");
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.`;
4378
+ const result = `${summaryWithJson(savedSummary, {
4379
+ ...scanApplied ? { appliedScan: { colors: true, images: args.apply_images === true } } : {},
4380
+ ...explicit.changes.length > 0 ? { applied: explicit.changes.map((change) => ({ path: change.path, value: change.to })) } : {}
4381
+ })}${target === "draft" ? `
4382
+ ${WIDGET_DRAFT_SAVED_MESSAGE}` : ""}`;
4383
+ return redactCredential(result, args.storefront_password);
4384
+ } catch (error) {
4385
+ throw errorWithoutCredential(error, args.storefront_password);
4386
+ }
4387
+ }
4388
+ });
4389
+ }
4390
+
4391
+ // ../mcp-core/build/tools/widgetFields.js
4392
+ import { isDeepStrictEqual } from "node:util";
4393
+ import { z as z19 } from "zod";
4394
+ var fieldsPath = "/shop/branding/widget/fields";
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.");
4414
+ var widgetFieldsInputSchema = {
4415
+ version: versionSchema,
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.")
4420
+ })).min(1).describe("Paths and values to set. Discover paths and allowed values with joy_get_widget_fields. The server validates every value."),
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.")
4423
+ };
4424
+ var MAX_FIELD_RESPONSE_CHARS = 12e3;
4425
+ function estimateSize(fields) {
4426
+ return fields.reduce((total, field) => total + field.path.length + field.label.length + 60 + (field.values?.join("").length ?? 0), 0);
4427
+ }
4428
+ async function getWidgetFields(ctx, version2) {
4429
+ const { data } = await ctx.api.getData(fieldsPath, { query: { version: version2 } });
4430
+ if (!Array.isArray(data?.fields)) {
4431
+ throw new JoyApiError("server", "Joy did not return widget field metadata. Field values cannot be confirmed.");
4432
+ }
4433
+ return data.fields;
4434
+ }
4435
+ function registerWidgetFieldReadTools(server, ctx) {
4436
+ registerReadTool(server, ctx, {
4437
+ name: "joy_get_widget_fields",
4438
+ title: "Get settable widget fields",
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.",
4440
+ inputSchema: {
4441
+ version: versionSchema,
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".')
4444
+ },
4445
+ handler: async (args) => {
4446
+ const fields = await getWidgetFields(ctx, args.version);
4447
+ const term = args.search?.trim().toLocaleLowerCase();
4448
+ const matched = fields.filter((field) => (args.group === void 0 || field.group === args.group) && (term === void 0 || term === "" || field.label.toLocaleLowerCase().includes(term) || field.path.toLocaleLowerCase().includes(term)));
4449
+ const wouldNotFit = estimateSize(matched) > MAX_FIELD_RESPONSE_CHARS;
4450
+ if (args.group === void 0 && !term && wouldNotFit) {
4451
+ const index = {};
4452
+ for (const field of fields) {
4453
+ const entry = index[field.group] ?? { fields: 0, examples: [] };
4454
+ entry.fields += 1;
4455
+ if (entry.examples.length < 2)
4456
+ entry.examples.push(field.path);
4457
+ index[field.group] = entry;
4458
+ }
4459
+ return summaryWithJson(`${fields.length} settable ${args.version.toUpperCase()} widget fields across ${Object.keys(index).length} groups. Call again with group: "<name>" or search: "<text>" to see fields and their allowed values.`, { groups: index });
4460
+ }
4461
+ if (matched.length === 0) {
4462
+ return `No ${args.version.toUpperCase()} widget field matches ${args.group === void 0 ? `search "${args.search}"` : `group "${args.group}"`}. Call joy_get_widget_fields with no arguments to list the groups.`;
4463
+ }
4464
+ const groups = /* @__PURE__ */ new Map();
4465
+ for (const { group, path, label, type, current, values } of matched) {
4466
+ const entries = groups.get(group) ?? [];
4467
+ entries.push({ label, path, type, current, ...values === void 0 ? {} : { values } });
4468
+ groups.set(group, entries);
4469
+ }
4470
+ return summaryWithJson(`${matched.length} of ${fields.length} settable ${args.version.toUpperCase()} widget fields. Use the server-provided paths and values when preparing changes.`, { groups: Object.fromEntries(groups) });
4471
+ }
4472
+ });
4473
+ }
4474
+ function registerWidgetFieldWriteTools(server, ctx) {
4475
+ registerConfigWriteTool(server, ctx, {
4476
+ name: "joy_set_widget_fields",
4477
+ title: "Set widget fields",
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.",
4479
+ inputSchema: widgetFieldsInputSchema,
4480
+ strictArguments: true,
4481
+ destructiveHint: true,
4482
+ handler: async (args) => {
4483
+ const current = await getWidgetFields(ctx, args.version);
4484
+ const fields = expandLauncherHideFields(args.fields, current);
4485
+ const changes = fields.map(({ path, value }) => ({
4486
+ path,
4487
+ from: current.find((field) => field.path === path)?.current,
4488
+ to: value
4489
+ }));
4490
+ if (args.apply !== true) {
4491
+ return `${buildWidgetDraftPreview(args, changes)}
4492
+
4493
+ The server will validate every requested path and value when applied; this preview does not confirm acceptance. Show this preview to the merchant, then call again with apply: true and the same arguments only after approval.`;
4494
+ }
4495
+ let result;
4496
+ try {
4497
+ result = await ctx.api.mutateData(fieldsPath, {
4498
+ method: "PUT",
4499
+ query: { version: args.version, target: args.target },
4500
+ body: { fields }
4501
+ });
4502
+ } catch (error) {
4503
+ throw brandingErrorGuidance(error, "draft", args.target) ?? error;
4504
+ }
4505
+ let readBack = [];
4506
+ let readBackFailure;
4507
+ try {
4508
+ readBack = await getWidgetFields(ctx, args.version);
4509
+ } catch (error) {
4510
+ readBackFailure = error instanceof Error ? error.message : String(error);
4511
+ }
4512
+ const refusedPaths = new Set(result.refused.map((field) => field.path));
4513
+ const applied2 = [];
4514
+ const unconfirmed = [];
4515
+ for (const field of fields) {
4516
+ if (refusedPaths.has(field.path))
4517
+ continue;
4518
+ const accepted = result.applied.find((entry) => entry.path === field.path);
4519
+ const observed = readBack.find((entry) => entry.path === field.path);
4520
+ if (!readBackFailure && accepted && observed && isDeepStrictEqual(accepted.value, field.value) && isDeepStrictEqual(observed.current, field.value)) {
4521
+ applied2.push({ path: field.path, value: field.value });
4522
+ continue;
4523
+ }
4524
+ unconfirmed.push({ path: field.path, requested: field.value, current: observed?.current });
4525
+ }
4526
+ const summary = `Widget field write: ${applied2.length} confirmed by read-back, ${result.refused.length} refused, ${unconfirmed.length} unconfirmed.`;
4527
+ const details = `${summary}
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.";
4530
+ const report = `${details}
4531
+ ${consequence}${readBackFailure ? `
4532
+ Read-back failed: ${readBackFailure}` : ""}`;
4533
+ if (result.refused.length > 0 || unconfirmed.length > 0 || readBackFailure) {
4534
+ throw new JoyApiError(unconfirmed.length > 0 || readBackFailure ? "server" : "validation", `${report}
4535
+ Do not report refused or unconfirmed fields as saved. Inspect the current widget in Joy admin before retrying; some fields may already have changed.`);
4536
+ }
4537
+ return report;
4538
+ }
4539
+ });
4540
+ }
4541
+
4542
+ // ../mcp-core/build/tools/referralBrandingWrite.js
4543
+ import { z as z20 } from "zod";
4544
+ var POPUP_DISPLAY_TYPES = ["auto_show_popup", "show_bubble_teaser", "do_not_show"];
4545
+ var REFERRAL_WIDGET_ARGUMENTS = {
4546
+ brand_color: "brandColor",
4547
+ text_color: "textColor",
4548
+ action_bg_color: "actionBgColor",
4549
+ action_text_color: "actionTextColor",
4550
+ color_type: "colorType",
4551
+ custom_solid_color: "customSolidColor",
4552
+ theme_text_font: "themeTextFont",
4553
+ type_show_popup: "typeShowPopup",
4554
+ is_auto_close_pop_up: "isAutoClosePopUp",
4555
+ closing_pop_up_time: "closingPopUpTime",
4556
+ frequency_show_popup: "frequencyShowPopup",
4557
+ is_show_subscribe_newsletter: "isShowSubscribeNewsletter"
4558
+ };
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);
4560
+ var referralWidgetInputShape = {
4561
+ brand_color: hexColor("Primary accent color for the referral widget."),
4562
+ text_color: hexColor("Main referral-widget text color."),
4563
+ action_bg_color: hexColor("Referral-widget action button background color."),
4564
+ action_text_color: hexColor("Referral-widget action button text color."),
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."),
4566
+ custom_solid_color: hexColor("Custom solid background color used when color_type is custom-solid."),
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.")
4575
+ };
4576
+ var referralWidgetArgsSchema = z20.object(referralWidgetInputShape).strict();
4577
+ function parseReferralWidgetArgs(input) {
4578
+ const parsed = referralWidgetArgsSchema.safeParse(input);
4579
+ if (parsed.success)
4580
+ return parsed.data;
4581
+ const issue = parsed.error.issues[0];
4582
+ const field = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
4583
+ throw new JoyApiError("validation", `Invalid referral widget configuration \u2014 ${field}${issue.message} Fix the named parameter and call again.`);
4584
+ }
4585
+ function toReferralWidgetBody(args) {
4586
+ const body = {};
4587
+ for (const [argument, field] of Object.entries(REFERRAL_WIDGET_ARGUMENTS)) {
4588
+ const value = args[argument];
4589
+ if (value !== void 0)
4590
+ body[field] = value;
4591
+ }
4592
+ return body;
4593
+ }
4594
+ function buildReferralWidgetPreview(current, args, body = toReferralWidgetBody(args)) {
4595
+ return buildChangePreview({
4596
+ action: "UPDATE",
4597
+ target: "the LIVE referral V3 widget",
4598
+ reason: args.reason,
4599
+ changes: diffFields(current, body),
4600
+ warnings: [
4601
+ "This changes the LIVE referral widget as soon as it saves. There is no draft and no publish step."
4602
+ ]
4603
+ });
4604
+ }
4605
+ async function configureReferralWidget(ctx, input) {
4606
+ const args = parseReferralWidgetArgs(input);
4607
+ const body = toReferralWidgetBody(args);
4608
+ if (Object.keys(body).length === 0) {
4609
+ throw new JoyApiError("validation", `At least one referral widget field is required. Valid fields: ${Object.keys(REFERRAL_WIDGET_ARGUMENTS).join(", ")}.`);
4610
+ }
4611
+ const { data } = await ctx.api.getData("/shop/branding/referral");
4612
+ const current = data ?? {};
4613
+ const changes = diffFields(current, body);
4614
+ const preview = buildReferralWidgetPreview(current, args, body);
4615
+ if (changes.length === 0)
4616
+ return preview;
4617
+ const blocked = requireApply(args.apply, preview);
4618
+ if (blocked)
4619
+ return blocked;
4620
+ await ctx.api.mutateData("/shop/branding/referral", { method: "PUT", body });
4621
+ const { data: refreshedData } = await ctx.api.getData("/shop/branding/referral");
4622
+ const refreshed = refreshedData ?? {};
4623
+ const unconfirmed = diffFields(refreshed, body).map((change) => change.field);
4624
+ if (unconfirmed.length > 0) {
4625
+ throw new JoyApiError("server", `The live referral widget write was not confirmed: these requested fields did not match after the required read-back: ${unconfirmed.join(", ")}. Do not report the referral widget as configured; ask the merchant to verify it in Joy admin and report this backend failure. Do not retry blindly.`);
4626
+ }
4627
+ const appliedFields = changes.map((change) => change.field);
4628
+ return summaryWithJson(`Updated the LIVE referral widget and confirmed ${appliedFields.length} field${appliedFields.length === 1 ? "" : "s"} by reading it back. There is no draft and no publish step.`, compact(refreshed, appliedFields));
4629
+ }
4630
+ function registerReferralBrandingWriteTools(server, ctx) {
4631
+ registerConfigWriteTool(server, ctx, {
4632
+ name: "joy_configure_referral_widget",
4633
+ title: "Configure the live referral widget",
4634
+ description: "CONFIGURATION WRITE \u2014 updates the LIVE referral V3 storefront popup immediately. This is not Widget V4: there is no draft and no publish step. It previews by default, requires explicit approval to apply, validates every color as hex before any HTTP call, and reads every requested field back before reporting success.",
4635
+ inputSchema: referralWidgetInputShape,
4636
+ destructiveHint: true,
4637
+ strictArguments: true,
4638
+ handler: (args) => configureReferralWidget(ctx, args)
4639
+ });
4640
+ }
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
+
5005
+ // ../mcp-core/build/tools/themeSetup.js
5006
+ function loyaltyWidget(embeds) {
5007
+ return embeds.find((embed) => embed.name === "Joy Loyalty Widget" || embed.handle === "app-embed");
5008
+ }
5009
+ function formatThemeSetup(themeSetup) {
5010
+ const embeds = Array.isArray(themeSetup.embeds) ? themeSetup.embeds : [];
5011
+ const blocks = Array.isArray(themeSetup.blocks) ? themeSetup.blocks : [];
5012
+ const widget = loyaltyWidget(embeds);
5013
+ const inactiveEmbeds = embeds.filter((embed) => !embed.active);
5014
+ const actions = [];
5015
+ if (widget && !widget.active) {
5016
+ actions.push({
5017
+ item: widget.name,
5018
+ instruction: "The merchant must open this link, switch it on, and save in the theme editor.",
5019
+ url: widget.activateUrl
5020
+ });
5021
+ }
5022
+ for (const embed of inactiveEmbeds) {
5023
+ if (embed === widget)
5024
+ continue;
5025
+ actions.push({
5026
+ item: embed.name,
5027
+ instruction: "The merchant must open this link, switch it on, and save in the theme editor.",
5028
+ url: embed.activateUrl
5029
+ });
5030
+ }
5031
+ for (const block of blocks) {
5032
+ if (block.added === true)
5033
+ continue;
5034
+ actions.push({
5035
+ item: block.name,
5036
+ instruction: "The merchant can add this missing block from the theme editor and save.",
5037
+ url: block.addUrl
5038
+ });
5039
+ }
5040
+ const passwordWarning = themeSetup.passwordEnabled === true ? " Storefront password protection is enabled, so the merchant cannot visually verify the widget until it is removed." : "";
5041
+ if (widget?.active && actions.length === 0 && !themeSetup.passwordEnabled) {
5042
+ return "Loyalty widget is active. Theme setup is complete \u2014 nothing to do.";
5043
+ }
5044
+ if (!widget) {
5045
+ return summaryWithJson(`The loyalty widget was not included in the theme audit.${passwordWarning} This read-only tool cannot change the theme.`, { next_steps: actions });
5046
+ }
5047
+ const widgetStatus = widget.active ? "active" : "OFF";
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 });
5049
+ }
5050
+ function registerThemeSetupTools(server, ctx) {
5051
+ registerReadTool(server, ctx, {
5052
+ name: "joy_check_theme_setup",
5053
+ title: "Check storefront theme setup",
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.",
5055
+ inputSchema: {},
5056
+ handler: async () => {
5057
+ const { data } = await ctx.api.getData("/shop/theme/extensions");
5058
+ return formatThemeSetup(data ?? {});
5059
+ }
5060
+ });
5061
+ }
5062
+
5063
+ // ../mcp-core/build/tools/imageUpload.js
5064
+ import { z as z22 } from "zod";
5065
+ var imageUploadInputSchema = {
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.")
5072
+ };
5073
+ function imageUploadErrorGuidance(error) {
5074
+ if (!(error instanceof JoyApiError))
5075
+ return void 0;
5076
+ if (error.status === 400) {
5077
+ return new JoyApiError("validation", "The image source was rejected (not an image, unsupported type, or a blocked URL). Do not retry the same input.", error.status);
5078
+ }
5079
+ if (error.status === 413) {
5080
+ return new JoyApiError("validation", "The image is too large; the upload cap is 20 MB. Resize or compress it before trying again.", error.status);
5081
+ }
5082
+ if (error.status === 502) {
5083
+ return new JoyApiError("server", "Shopify rejected the upload or the file never went live. Safe to retry ONCE; if it fails again, stop and report the failure to the merchant.", error.status);
5084
+ }
5085
+ return void 0;
5086
+ }
5087
+ function registerImageUploadTools(server, ctx) {
5088
+ registerConfigWriteTool(server, ctx, {
5089
+ name: "joy_upload_image",
5090
+ title: "Upload image to Shopify Files",
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.",
5092
+ inputSchema: imageUploadInputSchema,
5093
+ strictArguments: true,
5094
+ destructiveHint: false,
5095
+ handler: async (args) => {
5096
+ if (args.source_url !== void 0 === (args.data_url !== void 0)) {
5097
+ throw new JoyApiError("validation", "Provide exactly one of source_url or data_url, never both. No image was uploaded.");
5098
+ }
5099
+ const { source_url, data_url, filename, alt } = args;
5100
+ const body = { source_url, data_url, filename, alt };
5101
+ if (args.apply !== true) {
5102
+ return summaryWithJson("DRY RUN \u2014 no image was uploaded. Applying writes a permanent file into the merchant's Shopify Files; discarding a widget draft does not remove it. The file stays until the merchant deletes it. This tool cannot see the image; the merchant reviews it in Joy admin. Show this preview to the merchant, then call again with apply: true and the same arguments only after approval.", { ...body, data_url: data_url === void 0 ? void 0 : "[provided image bytes; not inspected]", reason: args.reason });
5103
+ }
5104
+ let result;
5105
+ try {
5106
+ result = await ctx.api.mutateData("/files/images", { method: "POST", body });
5107
+ } catch (error) {
5108
+ throw imageUploadErrorGuidance(error) ?? error;
5109
+ }
5110
+ return summaryWithJson("Image uploaded to the merchant's Shopify Files CDN. Pass the returned url to joy_set_widget_fields for the desired image field. This tool cannot see or vouch for the image; the merchant reviews it in Joy admin. The file remains in Shopify Files until the merchant deletes it, even if the widget draft is discarded.", result);
5111
+ }
5112
+ });
5113
+ }
5114
+
5115
+ // ../mcp-core/build/prompts.js
5116
+ import { z as z23 } from "zod";
5117
+ function registerAllPrompts(server) {
5118
+ server.registerPrompt("support_missing_points", {
5119
+ title: "Investigate missing points for an order",
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.",
5121
+ argsSchema: {
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")
5124
+ }
5125
+ }, ({ email, order_number }) => ({
5126
+ messages: [
5127
+ {
5128
+ role: "user",
5129
+ content: {
5130
+ type: "text",
5131
+ text: [
5132
+ `A customer (${email}) reports they did not receive loyalty points${order_number ? ` for order ${order_number}` : ""}. Investigate before changing anything:`,
5133
+ "",
5134
+ "1. Look up the member with joy_find_member \u2014 flag anything unusual (disabled state, guest, recently created).",
5135
+ `2. Check joy_get_member_activity for an earn entry matching the order${order_number ? "" : " they describe (ask me for the order number if you cannot identify it)"}.`,
5136
+ order_number ? `3. Run joy_explain_order_points for ${order_number} \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.` : "3. Once the order is identified, run joy_explain_order_points on it \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.",
5137
+ "4. Conclude with exactly one of: (a) points were already awarded (show the activity entry), (b) points were correctly not awarded (show the blocking gate/rule \u2014 cite its configured threshold or condition, do not paraphrase from memory), or (c) points are genuinely missing.",
5138
+ "5. Only for case (c): state the exact number of points owed and propose a joy_award_points call with a reason that names the order and the root cause. Wait for my explicit confirmation before executing \u2014 never award in the same turn as the diagnosis.",
5139
+ "",
5140
+ "Caveat to include when you rely on joy_explain_order_points: it recomputes against the order as it exists now \u2014 if the order changed after the original webhook (e.g. partial refund), the explanation reflects the current state, not the historical moment."
5141
+ ].join("\n")
5142
+ }
5143
+ }
5144
+ ]
5145
+ }));
5146
+ server.registerPrompt("program_review", {
5147
+ title: "Loyalty program review (health + revenue + tiers + referral)",
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.",
5149
+ argsSchema: {
5150
+ period: z23.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
5151
+ }
5152
+ }, ({ period }) => ({
5153
+ messages: [
5154
+ {
5155
+ role: "user",
5156
+ content: {
5157
+ type: "text",
5158
+ text: [
5159
+ `Review my loyalty program for ${period ?? "the last 30 days"}. Run, in parallel where possible:`,
5160
+ "",
5161
+ "1. joy_get_loyalty_health_check \u2014 overall score and flags.",
5162
+ "2. joy_get_loyalty_revenue_report \u2014 revenue impact (Shopify sales data).",
5163
+ "3. joy_get_tier_performance_breakdown \u2014 which VIP tier earns its keep.",
5164
+ "4. joy_get_analytics_report with report_type referral_summary, and points_movement for the same period.",
5165
+ "",
5166
+ "Data-source rules (important):",
5167
+ "- joy_get_loyalty_revenue_report and joy_get_tier_performance_breakdown read Shopify sales data (ShopifyQL); joy_get_analytics_report reads the Joy event log (BigQuery). These measure differently \u2014 label the source of every figure and never combine numbers across the two sources into one derived metric.",
5168
+ "- If a ShopifyQL tool returns a 403 about the read_reports scope, relay its grant instructions instead of substituting numbers from the other source.",
5169
+ "- If any response carries a backfill WARNING, repeat that caveat next to the affected numbers \u2014 do not present them as final.",
5170
+ "",
5171
+ "Format: a 3-sentence executive summary first, then anomalies worth my attention (with the flag or figure that triggered each), then exactly one concrete recommended action. Skip metrics that are unremarkable."
5172
+ ].join("\n")
5173
+ }
5174
+ }
5175
+ ]
5176
+ }));
5177
+ server.registerPrompt("investigate_member", {
5178
+ title: "Investigate a member for abuse risk",
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.",
5180
+ argsSchema: {
5181
+ email: z23.string().describe("Email of the member to investigate")
5182
+ }
5183
+ }, ({ email }) => ({
5184
+ messages: [
5185
+ {
5186
+ role: "user",
5187
+ content: {
5188
+ type: "text",
5189
+ text: [
5190
+ `I suspect member ${email} may be abusing the loyalty program (referral self-dealing, unusual redemptions, or similar). Investigate:`,
5191
+ "",
5192
+ "1. joy_find_member \u2014 profile basics and state.",
5193
+ "2. joy_investigate_member_risk \u2014 the pre-defined abuse flags. For every flag that fires, show the concrete supporting data the tool returns; do not add suspicions of your own beyond the flags.",
5194
+ "3. joy_get_member_activity and joy_get_member_referrals \u2014 recent point movements and referral results, to give the flags context.",
5195
+ "",
5196
+ "Mandatory caveat in your answer: device/IP matching compares the MOST RECENT values only \u2014 there is no full history over time \u2014 so these flags are advisory signals to support my judgement, not proof of abuse.",
5197
+ "",
5198
+ "End with: which flags fired (if any), what specific records I should open in the Joy admin to verify each one, and your read on severity. Do NOT deduct points, refund coupons, or take any corrective action \u2014 this session is investigation only; I will decide separately."
5199
+ ].join("\n")
5200
+ }
5201
+ }
5202
+ ]
5203
+ }));
5204
+ server.registerPrompt("setup_loyalty_program", {
5205
+ title: "Set up a loyalty program safely",
5206
+ description: "Guided loyalty-program setup: inspect the current configuration, propose a minimal program, and make each approved configuration change one at a time.",
5207
+ argsSchema: {
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'.")
5210
+ }
5211
+ }, ({ business_type, goal }) => ({
5212
+ messages: [
5213
+ {
5214
+ role: "user",
5215
+ content: {
5216
+ type: "text",
5217
+ text: [
5218
+ `Help me set up a loyalty program${business_type ? ` for a ${business_type} business` : ""}${goal ? ` with the goal of ${goal}` : ""}. Follow this workflow exactly:`,
5219
+ "",
5220
+ "1. Read the current state first: use joy_get_loyalty_overview and the earning, redemption, and tier read tools. Never create or change anything before knowing what already exists.",
5221
+ "2. Propose the smallest useful program set, then get my confirmation item by item before preparing each change.",
5222
+ "3. For each approved configuration change, call the relevant joy_configure_* tool in DRY-RUN mode first, show me the diff, and wait for my approval.",
5223
+ "4. Only after that approval, call the same tool with apply: true \u2014 one tool at a time. Never batch applies in one turn.",
5224
+ "5. Close by listing exactly what changed and what MCP cannot do: widget branding, launching or relaunching tiers, and deleting anything."
5225
+ ].join("\n")
5226
+ }
5227
+ }
5228
+ ]
5229
+ }));
5230
+ server.registerPrompt("apply_brand_to_widget", {
5231
+ title: "Apply storefront branding to a widget draft",
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.",
5233
+ argsSchema: {
5234
+ preferences: z23.string().optional().describe("Optional brand preferences or constraints, e.g. 'keep the current heading font' or 'use a softer button color'.")
5235
+ }
5236
+ }, ({ preferences }) => ({
5237
+ messages: [
5238
+ {
5239
+ role: "user",
5240
+ content: {
5241
+ type: "text",
5242
+ text: [
5243
+ `Help me apply my storefront brand to the loyalty widget${preferences ? ` while following these preferences: ${preferences}` : ""}. Follow this workflow exactly:`,
5244
+ "",
5245
+ "1. Run joy_scan_brand_design. If it reports that the storefront is password-protected, stop and ask me to temporarily disable the password before trying again. If the scan fails, do not invent colors or fonts.",
5246
+ "2. Describe the detected colors, fonts, and button treatment in plain language. Make clear that they are estimates derived from a storefront screenshot and that you cannot visually judge the finished widget.",
5247
+ "3. Propose the smallest useful set of branding changes and wait for my confirmation before preparing them.",
5248
+ "4. Run joy_configure_widget_draft in DRY-RUN mode first, show me the diff, and wait for my approval. Only after that approval, call it again with apply: true.",
5249
+ "5. State the boundary plainly: joy_configure_widget_draft writes only the V4 draft, never the live widget version. The storefront remains unchanged, and publishing is a manual human step that I must complete in the Joy admin after reviewing the draft.",
5250
+ "",
5251
+ "Do not claim that you published, can publish, or visually verified the widget."
5252
+ ].join("\n")
5253
+ }
5254
+ }
5255
+ ]
5256
+ }));
5257
+ server.registerPrompt("set_up_storefront_widget", {
5258
+ title: "Set up the storefront loyalty widget",
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.",
5260
+ argsSchema: {
5261
+ focus: z23.string().optional().describe("Optional setup focus, e.g. 'loyalty widget', 'points calculator', or 'loyalty page'.")
5262
+ }
5263
+ }, ({ focus }) => ({
5264
+ messages: [
5265
+ {
5266
+ role: "user",
5267
+ content: {
5268
+ type: "text",
5269
+ text: [
5270
+ `Help me set up Joy on my storefront${focus ? `, focusing on ${focus}` : ""}. Follow this workflow exactly:`,
5271
+ "",
5272
+ "1. Run joy_check_theme_setup against the live theme. If storefront password protection is enabled, warn me that the result cannot be visually verified while the password is active.",
5273
+ "2. For every inactive or missing embed or block, explain what it does for the storefront before giving me its deep link. Copy each link verbatim from joy_check_theme_setup; never construct one yourself.",
5274
+ "3. You cannot install, enable, or save a theme app embed or block for me. Ask me to open each deep link, enable or add the item, click Save in the Shopify theme editor, and then return here.",
5275
+ "4. After I confirm that I saved the theme, run joy_check_theme_setup again and report only what the second audit verifies. Do not claim success from my confirmation alone.",
5276
+ "5. Keep widget publishing separate from theme setup: joy_configure_widget_draft, if used in a separate branding workflow, writes only the V4 draft and never the live widget version. Publishing remains a manual human step in the Joy admin.",
5277
+ "",
5278
+ "Do not claim that you installed a theme embed or published a widget version."
5279
+ ].join("\n")
5280
+ }
5281
+ }
5282
+ ]
5283
+ }));
5284
+ }
5285
+
5286
+ // ../mcp-core/build/registry.js
5287
+ function registerAllTools(server, ctx) {
5288
+ registerOverviewTools(server, ctx);
5289
+ registerAnalyticsTools(server, ctx);
5290
+ registerMemberTools(server, ctx);
5291
+ registerTierTools(server, ctx);
1916
5292
  registerProgramTools(server, ctx);
1917
5293
  registerReferralTools(server, ctx);
1918
5294
  registerRetentionTools(server, ctx);
@@ -1920,14 +5296,34 @@ function registerAllTools(server, ctx) {
1920
5296
  registerOrderExplainTools(server, ctx);
1921
5297
  registerActivityAuditTools(server, ctx);
1922
5298
  registerTierUpgradeTools(server, ctx);
5299
+ registerLoyaltyPageTools(server, ctx);
5300
+ registerLoyaltyPageTextTools(server, ctx);
5301
+ registerThemeSetupTools(server, ctx);
5302
+ registerBrandingReadTools(server, ctx);
5303
+ registerWidgetFieldReadTools(server, ctx);
5304
+ registerRedeemInCartReadTools(server, ctx);
1923
5305
  if (ctx.config.writesEnabled) {
1924
5306
  registerPointWriteTools(server, ctx);
1925
5307
  registerTierWriteTools(server, ctx);
1926
5308
  registerRewardWriteTools(server, ctx);
1927
5309
  registerReferralWriteTools(server, ctx);
1928
5310
  registerAnalyticsWriteTools(server, ctx);
5311
+ registerLoyaltyPageWriteTools(server, ctx);
5312
+ registerLoyaltyPageTextWriteTools(server, ctx);
1929
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.");
1930
5314
  }
5315
+ if (ctx.config.configWritesEnabled) {
5316
+ registerProgramConfigTools(server, ctx);
5317
+ registerTierConfigTools(server, ctx);
5318
+ registerShopConfigTools(server, ctx);
5319
+ registerReferralConfigTools(server, ctx);
5320
+ registerBrandingWriteTools(server, ctx);
5321
+ registerWidgetFieldWriteTools(server, ctx);
5322
+ registerReferralBrandingWriteTools(server, ctx);
5323
+ registerRedeemInCartWriteTools(server, ctx);
5324
+ registerImageUploadTools(server, ctx);
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.");
5326
+ }
1931
5327
  }
1932
5328
 
1933
5329
  // src/index.ts