vonpay-medusa 1.0.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.medusa/server/src/admin/index.js +482 -5
  2. package/.medusa/server/src/admin/index.mjs +484 -7
  3. package/.medusa/server/src/api/admin/vonpay/offers/[id]/route.js +20 -0
  4. package/.medusa/server/src/api/admin/vonpay/offers/import/route.js +12 -0
  5. package/.medusa/server/src/api/admin/vonpay/offers/reorder/route.js +11 -0
  6. package/.medusa/server/src/api/admin/vonpay/offers/route.js +18 -0
  7. package/.medusa/server/src/api/middlewares.js +4 -0
  8. package/.medusa/server/src/lib/errors.js +16 -1
  9. package/.medusa/server/src/lib/money.js +8 -0
  10. package/.medusa/server/src/modules/vonpay-upsells/index.js +11 -0
  11. package/.medusa/server/src/modules/vonpay-upsells/key.js +4 -0
  12. package/.medusa/server/src/modules/vonpay-upsells/migrations/Migration20261023131258.js +20 -0
  13. package/.medusa/server/src/modules/vonpay-upsells/migrations/Migration20261023142238.js +15 -0
  14. package/.medusa/server/src/modules/vonpay-upsells/models/event.js +25 -0
  15. package/.medusa/server/src/modules/vonpay-upsells/models/offer.js +23 -0
  16. package/.medusa/server/src/modules/vonpay-upsells/service.js +8 -0
  17. package/.medusa/server/src/providers/vonpay/options.js +11 -7
  18. package/.medusa/server/src/providers/vonpay/service.js +29 -2
  19. package/.medusa/server/src/providers/vonpay/session-data.js +11 -2
  20. package/.medusa/server/src/providers/vonpay/setup-check.js +69 -0
  21. package/.medusa/server/src/providers/vonpay/ship-hold.js +12 -6
  22. package/.medusa/server/src/providers/vonpay/upsell-accept.js +151 -24
  23. package/.medusa/server/src/providers/vonpay/upsell-copy.js +48 -0
  24. package/.medusa/server/src/providers/vonpay/upsell-offers-admin.js +275 -0
  25. package/.medusa/server/src/providers/vonpay/upsell-offers.js +136 -0
  26. package/.medusa/server/src/providers/vonpay/upsell-routes.js +5 -0
  27. package/.medusa/server/src/providers/vonpay/upsell-rules.js +106 -0
  28. package/README.md +66 -7
  29. package/package.json +1 -1
  30. package/storefront/src/index.ts +1 -1
  31. package/storefront/src/offer-pass.ts +15 -3
  32. package/storefront/src/server.ts +3 -6
  33. package/storefront/src/upsell.ts +45 -1
  34. package/storefront/src/vonpay-upsell-offer.tsx +60 -38
  35. package/storefront/src/vonpay-upsell-return.tsx +30 -28
@@ -558,11 +558,11 @@ const HeldPaymentsBanner = () => {
558
558
  ] }) });
559
559
  };
560
560
  adminSdk.defineWidgetConfig({ zone: "order.list.before" });
561
- const KEY = "vonpay-ship-hold";
561
+ const KEY$1 = "vonpay-ship-hold";
562
562
  const SHIP_HOLD_REFRESH_MS = 3e4;
563
563
  function useShipHold(orderId) {
564
564
  return reactQuery.useQuery({
565
- queryKey: [KEY, orderId],
565
+ queryKey: [KEY$1, orderId],
566
566
  queryFn: () => sdk.client.fetch(`/admin/vonpay/orders/${encodeURIComponent(orderId)}/offer-hold`),
567
567
  enabled: !!orderId,
568
568
  retry: false,
@@ -577,7 +577,7 @@ function useShipOverride(orderId) {
577
577
  return reactQuery.useMutation({
578
578
  mutationFn: () => sdk.client.fetch(`/admin/vonpay/orders/${encodeURIComponent(orderId)}/ship-override`, { method: "POST", body: {} }),
579
579
  retry: false,
580
- onSettled: () => qc.invalidateQueries({ queryKey: [KEY, orderId] })
580
+ onSettled: () => qc.invalidateQueries({ queryKey: [KEY$1, orderId] })
581
581
  });
582
582
  }
583
583
  function clockTime(iso) {
@@ -1050,11 +1050,475 @@ const HeldPaymentsPage = () => {
1050
1050
  /* @__PURE__ */ jsxRuntime.jsx(SetupCheckPanel, {})
1051
1051
  ] });
1052
1052
  };
1053
- const config$1 = adminSdk.defineRouteConfig({
1053
+ const config$2 = adminSdk.defineRouteConfig({
1054
1054
  label: "Held payments",
1055
1055
  icon: icons.ExclamationCircle,
1056
1056
  nested: "/orders"
1057
1057
  });
1058
+ const HEADLINE_MAX = 80;
1059
+ const PITCH_MAX = 300;
1060
+ const OFFERS_COPY = {
1061
+ title: "One-click offers",
1062
+ intro: "After paying, shoppers see these offers one at a time (up to three per order), in this order. Only switched-on offers are shown.",
1063
+ off: "One-click offers are off. Turn them on with the upsells option in medusa-config; the offers below are kept.",
1064
+ fromConfig: "Your offers come from medusa-config right now (upsells.variantIds). Import them to manage them here. Once an offer exists here, this list is used instead.",
1065
+ configEmpty: "No offers yet. Add the first one below.",
1066
+ adminEmpty: "No offers are shown to shoppers. Add one below.",
1067
+ importButton: "Import from medusa-config",
1068
+ add: "Add offer",
1069
+ name: "Name (only you see it)",
1070
+ variant: "Product variant id (variant_…)",
1071
+ shown: "Shown",
1072
+ up: "Move up",
1073
+ down: "Move down",
1074
+ remove: "Remove",
1075
+ removeConfirm: "Remove this offer? A shopper already paying for it is not affected.",
1076
+ missingVariant: "This product variant no longer exists — it is not offered.",
1077
+ loadFailed: "The offers could not be loaded. Reload the page to try again.",
1078
+ lag: "A change shows on your store at once; with more than one server it can take up to 30 seconds everywhere.",
1079
+ rules: "Who sees it",
1080
+ downsell: "If they say No thanks",
1081
+ downsellNone: "Nothing",
1082
+ downsellIntro: 'Show one other product after a shopper says No thanks to this offer — usually a smaller or cheaper option. It counts toward the three offers per order, and the same "Who sees it" applies.',
1083
+ downsellVariant: "Downsell product variant id (variant_…)",
1084
+ downsellRemove: "Remove downsell",
1085
+ downsellMissing: "This product variant no longer exists — it is not offered.",
1086
+ wording: "Wording",
1087
+ wordingDefault: "Standard",
1088
+ wordingPitchOnly: "Pitch only",
1089
+ wordingUnreadable: "The saved wording can't be read, so shoppers see the standard wording. Save it again.",
1090
+ wordingIntro: "What shoppers read on this offer. Leave a field empty to use the standard wording. The payment line, the buttons and every result message stay the same.",
1091
+ headline: "Headline",
1092
+ headlineHint: 'Replaces the heading. Standard: "Add this to your order?"',
1093
+ pitch: "Pitch",
1094
+ pitchHint: "Replaces the product's description under its name. A phone shows about two lines.",
1095
+ wordingDownsellNote: `A downsell keeps the standard wording: "How about this instead?" and its product's description.`,
1096
+ wordingReset: "Use the standard wording",
1097
+ rulesEveryone: "Everyone",
1098
+ rulesUnreadable: "Its rules can't be read, so it is not shown. Set them again.",
1099
+ rulesIntro: "Show this offer only when ALL the conditions you set match. Leave a condition empty to ignore it.",
1100
+ customers: "Customers",
1101
+ customersAll: "Everyone",
1102
+ customersGuests: "Guests only",
1103
+ customersSignedIn: "Signed-in customers only",
1104
+ products: "The shopper bought one of these products at checkout",
1105
+ collections: "The shopper bought a product from one of these collections at checkout",
1106
+ groups: "The signed-in customer is in one of these groups",
1107
+ paid: "The amount paid at checkout (tax and shipping included)",
1108
+ paidNote: "An order in a currency not listed here does not see this offer.",
1109
+ paidAtLeast: "At least",
1110
+ paidAtMost: "At most",
1111
+ search: "Search by name",
1112
+ addCurrency: "Add currency",
1113
+ removeCurrency: "Remove currency",
1114
+ rulesUnreadableNotice: "The saved rules for this offer can't be read, so the offer is not shown. Saving replaces them with what you set here.",
1115
+ replaceRules: "Replace the rules",
1116
+ save: "Save",
1117
+ cancel: "Cancel",
1118
+ saveFailed: "That change was not saved."
1119
+ };
1120
+ function errorSentence(err, fallback = OFFERS_COPY.saveFailed) {
1121
+ const m = err?.message;
1122
+ if (typeof m !== "string") return fallback;
1123
+ const i = m.indexOf(": ");
1124
+ return m.startsWith("vonpay_") && i > 0 ? m.slice(i + 2) : fallback;
1125
+ }
1126
+ function productLabel(o) {
1127
+ if (o.product_title === null) return o.variant_id;
1128
+ return [o.product_title, o.variant_title].filter((x) => x && x !== "Default variant").join(" — ");
1129
+ }
1130
+ function wordingSummary(copy) {
1131
+ if (copy === "unreadable") return OFFERS_COPY.wordingUnreadable;
1132
+ if (!copy) return OFFERS_COPY.wordingDefault;
1133
+ return copy.headline ?? OFFERS_COPY.wordingPitchOnly;
1134
+ }
1135
+ function rulesSummary(rules) {
1136
+ if (rules === "unreadable") return OFFERS_COPY.rulesUnreadable;
1137
+ if (!rules) return OFFERS_COPY.rulesEveryone;
1138
+ const parts = [];
1139
+ if (rules.customers === "guests") parts.push("Guests");
1140
+ if (rules.customers === "signed_in") parts.push("Signed-in");
1141
+ if (rules.groups) parts.push(`${rules.groups.length} group${rules.groups.length === 1 ? "" : "s"}`);
1142
+ if (rules.products) parts.push(`${rules.products.length} product${rules.products.length === 1 ? "" : "s"}`);
1143
+ if (rules.collections) parts.push(`${rules.collections.length} collection${rules.collections.length === 1 ? "" : "s"}`);
1144
+ for (const [c, n] of Object.entries(rules.min_paid ?? {})) parts.push(`paid ≥ ${n} ${c.toUpperCase()}`);
1145
+ for (const [c, n] of Object.entries(rules.max_paid ?? {})) parts.push(`paid ≤ ${n} ${c.toUpperCase()}`);
1146
+ return parts.length ? parts.join(" · ") : OFFERS_COPY.rulesEveryone;
1147
+ }
1148
+ function OfferDownsellDrawer({ offer, open, onOpenChange, onSave, busy }) {
1149
+ const [variant, setVariant] = react.useState(offer.downsell_variant_id ?? "");
1150
+ const [problem, setProblem] = react.useState(null);
1151
+ const save = async (value) => {
1152
+ if (value !== null && !/^variant_[A-Za-z0-9]{1,64}$/.test(value)) return setProblem("Enter a product variant id, like variant_01H….");
1153
+ setProblem(null);
1154
+ const refused = await onSave(value);
1155
+ if (refused === null) onOpenChange(false);
1156
+ else setProblem(refused);
1157
+ };
1158
+ return /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer, { open, onOpenChange, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Content, { "aria-describedby": void 0, children: [
1159
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Header, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Title, { children: [
1160
+ OFFERS_COPY.downsell,
1161
+ ": ",
1162
+ offer.name
1163
+ ] }) }),
1164
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Body, { className: "flex flex-col gap-y-4", children: [
1165
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: OFFERS_COPY.downsellIntro }),
1166
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1167
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-downsell-variant", children: OFFERS_COPY.downsellVariant }),
1168
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-downsell-variant", value: variant, maxLength: 72, onChange: (e) => setVariant(e.target.value) })
1169
+ ] }),
1170
+ problem && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-ui-fg-error", "data-testid": "vonpay-downsell-problem", children: problem })
1171
+ ] }),
1172
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Footer, { children: [
1173
+ offer.downsell_variant_id && /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { variant: "danger", disabled: busy, onClick: () => void save(null), children: OFFERS_COPY.downsellRemove }),
1174
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { variant: "secondary", onClick: () => onOpenChange(false), children: OFFERS_COPY.cancel }),
1175
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { disabled: busy, onClick: () => void save(variant.trim()), children: OFFERS_COPY.save })
1176
+ ] })
1177
+ ] }) });
1178
+ }
1179
+ const SOURCES = {
1180
+ products: { path: "/admin/products", key: "products", label: "title" },
1181
+ collections: { path: "/admin/collections", key: "collections", label: "title" },
1182
+ groups: { path: "/admin/customer-groups", key: "customer_groups", label: "name" }
1183
+ };
1184
+ async function fetchNamed(kind, query) {
1185
+ const s = SOURCES[kind];
1186
+ const body = await sdk.client.fetch(s.path, { query: { ...query, fields: `id,${s.label}` } });
1187
+ const plain = (t) => t.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, "").trim();
1188
+ return (body[s.key] ?? []).flatMap((r) => typeof r.id === "string" ? [{ id: r.id, label: typeof r[s.label] === "string" && plain(r[s.label]) || r.id }] : []);
1189
+ }
1190
+ function Picker({ kind, label, ids, onChange }) {
1191
+ const [q, setQ] = react.useState("");
1192
+ const names = reactQuery.useQuery({ queryKey: ["vonpay-offer-rule-names", kind, ids], queryFn: () => fetchNamed(kind, { id: ids, limit: ids.length }), enabled: ids.length > 0 });
1193
+ const found = reactQuery.useQuery({ queryKey: ["vonpay-offer-rule-search", kind, q], queryFn: () => fetchNamed(kind, { q, limit: 8 }), enabled: q.trim().length >= 2, placeholderData: reactQuery.keepPreviousData });
1194
+ const nameOf = (id) => names.data?.find((n) => n.id === id)?.label ?? id;
1195
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2", "data-testid": `vonpay-rule-${kind}`, children: [
1196
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { children: label }),
1197
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-2", children: ids.map((id) => /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "bg-ui-bg-subtle flex items-center gap-x-1 rounded-md px-2 py-1", children: [
1198
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", children: nameOf(id) }),
1199
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", "aria-label": `Remove ${nameOf(id)}`, onClick: () => onChange(ids.filter((x) => x !== id)), children: /* @__PURE__ */ jsxRuntime.jsx(icons.XMarkMini, {}) })
1200
+ ] }, id)) }),
1201
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { placeholder: OFFERS_COPY.search, value: q, maxLength: 100, "aria-label": `${label}: ${OFFERS_COPY.search}`, onChange: (e) => setQ(e.target.value) }),
1202
+ (q.trim().length >= 2 ? found.data ?? [] : []).filter((n) => !ids.includes(n.id)).map((n) => /* @__PURE__ */ jsxRuntime.jsxs(ui.Button, { size: "small", variant: "transparent", type: "button", onClick: () => onChange([...ids, n.id]), children: [
1203
+ "+ ",
1204
+ n.label
1205
+ ] }, n.id))
1206
+ ] });
1207
+ }
1208
+ function rowsOf(rules) {
1209
+ const cs = [.../* @__PURE__ */ new Set([...Object.keys(rules?.min_paid ?? {}), ...Object.keys(rules?.max_paid ?? {})])];
1210
+ return cs.map((c) => ({ currency: c, min: rules?.min_paid?.[c] ?? "", max: rules?.max_paid?.[c] ?? "" }));
1211
+ }
1212
+ function amountText(text) {
1213
+ const t = text.trim().replace(",", ".");
1214
+ if (t === "") return void 0;
1215
+ return /^\d{1,10}(\.\d{1,6})?$/.test(t) ? t : null;
1216
+ }
1217
+ function rulesFromForm(f) {
1218
+ const out = {};
1219
+ if (f.customers !== "all") out.customers = f.customers;
1220
+ if (f.products.length) out.products = f.products;
1221
+ if (f.collections.length) out.collections = f.collections;
1222
+ if (f.groups.length) out.groups = f.groups;
1223
+ for (const r of f.paid) {
1224
+ const c = r.currency.trim().toLowerCase();
1225
+ if (!c && !r.min.trim() && !r.max.trim()) continue;
1226
+ if (!/^[a-z]{3}$/.test(c)) return "Use a 3-letter currency code, like EUR.";
1227
+ const min = amountText(r.min);
1228
+ const max = amountText(r.max);
1229
+ if (min === null || max === null) return `Enter a plain amount for ${c.toUpperCase()}, like 49.95.`;
1230
+ if (min === void 0 && max === void 0) return `Enter the least or the most for ${c.toUpperCase()}, or remove its row.`;
1231
+ if (out.min_paid?.[c] !== void 0 || out.max_paid?.[c] !== void 0) return `${c.toUpperCase()} is listed twice.`;
1232
+ if (min !== void 0) out.min_paid = { ...out.min_paid, [c]: min };
1233
+ if (max !== void 0) out.max_paid = { ...out.max_paid, [c]: max };
1234
+ }
1235
+ if (out.customers === "guests" && out.groups) return "Groups apply to signed-in customers only.";
1236
+ return Object.keys(out).length ? out : null;
1237
+ }
1238
+ function OfferRulesDrawer({ offer, open, onOpenChange, onSave, busy }) {
1239
+ const unreadable = offer.rules === "unreadable";
1240
+ const start = offer.rules === "unreadable" ? null : offer.rules;
1241
+ const [customers, setCustomers] = react.useState(start?.customers ?? "all");
1242
+ const [products, setProducts] = react.useState(start?.products ?? []);
1243
+ const [collections, setCollections] = react.useState(start?.collections ?? []);
1244
+ const [groups, setGroups] = react.useState(start?.groups ?? []);
1245
+ const [paid, setPaid] = react.useState(rowsOf(start));
1246
+ const [problem, setProblem] = react.useState(null);
1247
+ const save = async () => {
1248
+ const r = rulesFromForm({ customers, products, collections, groups, paid });
1249
+ if (typeof r === "string") return setProblem(r);
1250
+ setProblem(null);
1251
+ const refused = await onSave(r);
1252
+ if (refused === null) onOpenChange(false);
1253
+ else setProblem(refused);
1254
+ };
1255
+ return /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer, { open, onOpenChange, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Content, { "aria-describedby": void 0, children: [
1256
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Header, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Title, { children: [
1257
+ OFFERS_COPY.rules,
1258
+ ": ",
1259
+ offer.name
1260
+ ] }) }),
1261
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Body, { className: "flex flex-col gap-y-6 overflow-y-auto", children: [
1262
+ unreadable && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-ui-fg-error", "data-testid": "vonpay-rules-unreadable", children: OFFERS_COPY.rulesUnreadableNotice }),
1263
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: OFFERS_COPY.rulesIntro }),
1264
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2", children: [
1265
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { children: OFFERS_COPY.customers }),
1266
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Select, { value: customers, onValueChange: (v) => setCustomers(v), children: [
1267
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Select.Trigger, { "aria-label": OFFERS_COPY.customers, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Select.Value, {}) }),
1268
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Select.Content, { children: [
1269
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Select.Item, { value: "all", children: OFFERS_COPY.customersAll }),
1270
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Select.Item, { value: "guests", children: OFFERS_COPY.customersGuests }),
1271
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Select.Item, { value: "signed_in", children: OFFERS_COPY.customersSignedIn })
1272
+ ] })
1273
+ ] })
1274
+ ] }),
1275
+ /* @__PURE__ */ jsxRuntime.jsx(Picker, { kind: "products", label: OFFERS_COPY.products, ids: products, onChange: setProducts }),
1276
+ /* @__PURE__ */ jsxRuntime.jsx(Picker, { kind: "collections", label: OFFERS_COPY.collections, ids: collections, onChange: setCollections }),
1277
+ /* @__PURE__ */ jsxRuntime.jsx(Picker, { kind: "groups", label: OFFERS_COPY.groups, ids: groups, onChange: setGroups }),
1278
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2", "data-testid": "vonpay-rule-paid", children: [
1279
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { children: OFFERS_COPY.paid }),
1280
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: OFFERS_COPY.paidNote }),
1281
+ paid.map((r, i) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-[1fr_1fr_1fr_auto] items-center gap-2", children: [
1282
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { "aria-label": "Currency", placeholder: "EUR", value: r.currency, maxLength: 3, onChange: (e) => setPaid(paid.map((x, j) => j === i ? { ...x, currency: e.target.value } : x)) }),
1283
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { "aria-label": `${OFFERS_COPY.paidAtLeast} (${r.currency || "currency"})`, placeholder: OFFERS_COPY.paidAtLeast, value: r.min, maxLength: 17, inputMode: "decimal", onChange: (e) => setPaid(paid.map((x, j) => j === i ? { ...x, min: e.target.value } : x)) }),
1284
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { "aria-label": `${OFFERS_COPY.paidAtMost} (${r.currency || "currency"})`, placeholder: OFFERS_COPY.paidAtMost, value: r.max, maxLength: 17, inputMode: "decimal", onChange: (e) => setPaid(paid.map((x, j) => j === i ? { ...x, max: e.target.value } : x)) }),
1285
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", "aria-label": `${OFFERS_COPY.removeCurrency} ${r.currency.toUpperCase()}`.trim(), onClick: () => setPaid(paid.filter((_, j) => j !== i)), children: /* @__PURE__ */ jsxRuntime.jsx(icons.XMarkMini, {}) })
1286
+ ] }, i)),
1287
+ /* @__PURE__ */ jsxRuntime.jsx("div", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "secondary", type: "button", onClick: () => setPaid([...paid, { currency: "", min: "", max: "" }]), children: OFFERS_COPY.addCurrency }) })
1288
+ ] }),
1289
+ problem && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-ui-fg-error", "data-testid": "vonpay-rules-problem", children: problem })
1290
+ ] }),
1291
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Footer, { children: [
1292
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { variant: "secondary", onClick: () => onOpenChange(false), children: OFFERS_COPY.cancel }),
1293
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { disabled: busy, onClick: () => void save(), children: unreadable ? OFFERS_COPY.replaceRules : OFFERS_COPY.save })
1294
+ ] })
1295
+ ] }) });
1296
+ }
1297
+ function OfferWordingDrawer({ offer, open, onOpenChange, onSave, busy }) {
1298
+ const start = offer.copy === "unreadable" ? null : offer.copy;
1299
+ const [headline, setHeadline] = react.useState(start?.headline ?? "");
1300
+ const [pitch, setPitch] = react.useState(start?.pitch ?? "");
1301
+ const [problem, setProblem] = react.useState(null);
1302
+ const save = async (copy) => {
1303
+ setProblem(null);
1304
+ const refused = await onSave(copy);
1305
+ if (refused === null) onOpenChange(false);
1306
+ else setProblem(refused);
1307
+ };
1308
+ const typed = () => {
1309
+ const h = headline.trim();
1310
+ const p = pitch.trim();
1311
+ return h || p ? { ...h ? { headline: h } : {}, ...p ? { pitch: p } : {} } : null;
1312
+ };
1313
+ return /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer, { open, onOpenChange, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Content, { "aria-describedby": void 0, children: [
1314
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Header, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Title, { children: [
1315
+ OFFERS_COPY.wording,
1316
+ ": ",
1317
+ offer.name
1318
+ ] }) }),
1319
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Body, { className: "flex flex-col gap-y-4", children: [
1320
+ offer.copy === "unreadable" && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-ui-fg-error", "data-testid": "vonpay-wording-unreadable", children: OFFERS_COPY.wordingUnreadable }),
1321
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: OFFERS_COPY.wordingIntro }),
1322
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1323
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-wording-headline", children: OFFERS_COPY.headline }),
1324
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-wording-headline", value: headline, maxLength: HEADLINE_MAX, onChange: (e) => setHeadline(e.target.value) }),
1325
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: OFFERS_COPY.headlineHint })
1326
+ ] }),
1327
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1328
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-wording-pitch", children: OFFERS_COPY.pitch }),
1329
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Textarea, { id: "vonpay-wording-pitch", value: pitch, maxLength: PITCH_MAX, rows: 4, onChange: (e) => setPitch(e.target.value) }),
1330
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: [
1331
+ OFFERS_COPY.pitchHint,
1332
+ " (",
1333
+ pitch.length,
1334
+ "/",
1335
+ PITCH_MAX,
1336
+ ")"
1337
+ ] })
1338
+ ] }),
1339
+ offer.downsell_variant_id && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: OFFERS_COPY.wordingDownsellNote }),
1340
+ problem && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-ui-fg-error", "data-testid": "vonpay-wording-problem", children: problem })
1341
+ ] }),
1342
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Footer, { children: [
1343
+ offer.copy !== null && /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { variant: "secondary", disabled: busy, onClick: () => void save(null), children: OFFERS_COPY.wordingReset }),
1344
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { variant: "secondary", onClick: () => onOpenChange(false), children: OFFERS_COPY.cancel }),
1345
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { disabled: busy, onClick: () => void save(typed()), children: OFFERS_COPY.save })
1346
+ ] })
1347
+ ] }) });
1348
+ }
1349
+ const KEY = "vonpay-offers";
1350
+ function useOffers() {
1351
+ return reactQuery.useQuery({ queryKey: [KEY], queryFn: () => sdk.client.fetch("/admin/vonpay/offers") });
1352
+ }
1353
+ function useWrite(send) {
1354
+ const qc = reactQuery.useQueryClient();
1355
+ return reactQuery.useMutation({ mutationFn: send, retry: false, onSettled: () => qc.invalidateQueries({ queryKey: [KEY] }) });
1356
+ }
1357
+ const useCreateOffer = () => useWrite((v) => sdk.client.fetch("/admin/vonpay/offers", { method: "POST", body: v }));
1358
+ const useUpdateOffer = () => useWrite((v) => {
1359
+ const { id, ...body } = v;
1360
+ return sdk.client.fetch(`/admin/vonpay/offers/${encodeURIComponent(id)}`, { method: "POST", body });
1361
+ });
1362
+ const useDeleteOffer = () => useWrite((v) => sdk.client.fetch(`/admin/vonpay/offers/${encodeURIComponent(v.id)}`, { method: "DELETE" }));
1363
+ const useReorderOffers = () => useWrite((v) => sdk.client.fetch("/admin/vonpay/offers/reorder", { method: "POST", body: v }));
1364
+ const useImportOffers = () => useWrite(() => sdk.client.fetch("/admin/vonpay/offers/import", { method: "POST", body: {} }));
1365
+ const VonpayOffersPage = () => {
1366
+ const { data, isLoading, isError, error: loadError } = useOffers();
1367
+ const create = useCreateOffer();
1368
+ const update = useUpdateOffer();
1369
+ const remove = useDeleteOffer();
1370
+ const reorder = useReorderOffers();
1371
+ const importConfig = useImportOffers();
1372
+ const prompt = ui.usePrompt();
1373
+ const [name, setName] = react.useState("");
1374
+ const [variantId, setVariantId] = react.useState("");
1375
+ const [error, setError] = react.useState(null);
1376
+ const [editing, setEditing] = react.useState(null);
1377
+ const [downsellFor, setDownsellFor] = react.useState(null);
1378
+ const [wordingFor, setWordingFor] = react.useState(null);
1379
+ const busy = create.isPending || update.isPending || remove.isPending || reorder.isPending || importConfig.isPending;
1380
+ const run = async (work) => {
1381
+ setError(null);
1382
+ try {
1383
+ await work();
1384
+ return true;
1385
+ } catch (err) {
1386
+ setError(errorSentence(err));
1387
+ return false;
1388
+ }
1389
+ };
1390
+ const onAdd = async (e) => {
1391
+ e.preventDefault();
1392
+ if (await run(() => create.mutateAsync({ name: name.trim(), variant_id: variantId.trim() }))) {
1393
+ setName("");
1394
+ setVariantId("");
1395
+ }
1396
+ };
1397
+ const move = (offers, i, by) => {
1398
+ const ids = offers.map((o) => o.id);
1399
+ [ids[i], ids[i + by]] = [ids[i + by], ids[i]];
1400
+ void run(() => reorder.mutateAsync({ ids }));
1401
+ };
1402
+ const onRemove = async (o) => {
1403
+ if (await prompt({ title: OFFERS_COPY.remove, description: OFFERS_COPY.removeConfirm, confirmText: OFFERS_COPY.remove, cancelText: "Cancel" })) void run(() => remove.mutateAsync({ id: o.id }));
1404
+ };
1405
+ return /* @__PURE__ */ jsxRuntime.jsxs(ui.Container, { className: "divide-y p-0", children: [
1406
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-6 py-4", children: [
1407
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { level: "h1", children: OFFERS_COPY.title }),
1408
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: [
1409
+ OFFERS_COPY.intro,
1410
+ " ",
1411
+ OFFERS_COPY.lag
1412
+ ] })
1413
+ ] }),
1414
+ isLoading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Skeleton, { className: "h-8 w-full" }) }) : isError || !data ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "error", "data-testid": "vonpay-offers-load-error", children: errorSentence(loadError, OFFERS_COPY.loadFailed) }) }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1415
+ !data.upsells_on && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "warning", children: OFFERS_COPY.off }) }),
1416
+ data.source === "config" && data.config.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2 px-6 py-4", "data-testid": "vonpay-offers-from-config", children: [
1417
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "info", children: OFFERS_COPY.fromConfig }),
1418
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "list-disc pl-6", children: data.config.map((c) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", children: productLabel(c) }) }, c.variant_id)) }),
1419
+ /* @__PURE__ */ jsxRuntime.jsx("div", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "secondary", disabled: busy, onClick: () => void run(() => importConfig.mutateAsync(void 0)), children: OFFERS_COPY.importButton }) })
1420
+ ] }),
1421
+ error && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "error", "data-testid": "vonpay-offers-error", children: error }) }),
1422
+ data.offers.length === 0 ? (data.source === "admin" || data.config.length === 0) && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { children: data.source === "admin" ? OFFERS_COPY.adminEmpty : OFFERS_COPY.configEmpty }) }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table, { children: [
1423
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Header, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { children: [
1424
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Offer" }),
1425
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Product" }),
1426
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.rules }),
1427
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.downsell }),
1428
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.wording }),
1429
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.shown }),
1430
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, {})
1431
+ ] }) }),
1432
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Body, { children: data.offers.map((o, i) => /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { "data-testid": "vonpay-offer-row", children: [
1433
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: o.name }),
1434
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: o.product_title === null ? /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-error", children: OFFERS_COPY.missingVariant }) : productLabel(o) }),
1435
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.rules}: ${o.name}`, disabled: busy, onClick: () => setEditing(o), children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: o.rules === "unreadable" ? "text-ui-fg-error" : void 0, children: rulesSummary(o.rules) }) }) }),
1436
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.downsell}: ${o.name}`, disabled: busy, onClick: () => setDownsellFor(o), children: o.downsell_variant_id === null ? OFFERS_COPY.downsellNone : o.downsell_product_title === null ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-ui-fg-error", children: OFFERS_COPY.downsellMissing }) : productLabel({ product_title: o.downsell_product_title, variant_title: o.downsell_variant_title, variant_id: o.downsell_variant_id }) }) }),
1437
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.wording}: ${o.name}`, disabled: busy, onClick: () => setWordingFor(o), children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: `max-w-[16rem] truncate ${o.copy === "unreadable" ? "text-ui-fg-error" : ""}`, children: wordingSummary(o.copy) }) }) }),
1438
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Switch, { "aria-label": `${OFFERS_COPY.shown}: ${o.name}`, checked: o.enabled, disabled: busy, onCheckedChange: (on) => void run(() => update.mutateAsync({ id: o.id, enabled: on })) }) }),
1439
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-end gap-x-1", children: [
1440
+ /* @__PURE__ */ jsxRuntime.jsx(ui.IconButton, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.up}: ${o.name}`, disabled: busy || i === 0, onClick: () => move(data.offers, i, -1), children: /* @__PURE__ */ jsxRuntime.jsx(icons.ArrowUpMini, {}) }),
1441
+ /* @__PURE__ */ jsxRuntime.jsx(ui.IconButton, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.down}: ${o.name}`, disabled: busy || i === data.offers.length - 1, onClick: () => move(data.offers, i, 1), children: /* @__PURE__ */ jsxRuntime.jsx(icons.ArrowDownMini, {}) }),
1442
+ /* @__PURE__ */ jsxRuntime.jsx(ui.IconButton, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.remove}: ${o.name}`, disabled: busy, onClick: () => void onRemove(o), children: /* @__PURE__ */ jsxRuntime.jsx(icons.Trash, {}) })
1443
+ ] }) })
1444
+ ] }, o.id)) })
1445
+ ] }) }),
1446
+ downsellFor && /* @__PURE__ */ jsxRuntime.jsx(
1447
+ OfferDownsellDrawer,
1448
+ {
1449
+ offer: downsellFor,
1450
+ open: downsellFor !== null,
1451
+ busy,
1452
+ onOpenChange: (o) => !o && setDownsellFor(null),
1453
+ onSave: async (downsell) => {
1454
+ try {
1455
+ await update.mutateAsync({ id: downsellFor.id, downsell_variant_id: downsell });
1456
+ return null;
1457
+ } catch (err) {
1458
+ return errorSentence(err);
1459
+ }
1460
+ }
1461
+ },
1462
+ downsellFor.id
1463
+ ),
1464
+ wordingFor && /* @__PURE__ */ jsxRuntime.jsx(
1465
+ OfferWordingDrawer,
1466
+ {
1467
+ offer: wordingFor,
1468
+ open: wordingFor !== null,
1469
+ busy,
1470
+ onOpenChange: (o) => !o && setWordingFor(null),
1471
+ onSave: async (copy) => {
1472
+ try {
1473
+ await update.mutateAsync({ id: wordingFor.id, copy });
1474
+ return null;
1475
+ } catch (err) {
1476
+ return errorSentence(err);
1477
+ }
1478
+ }
1479
+ },
1480
+ wordingFor.id
1481
+ ),
1482
+ editing && /* @__PURE__ */ jsxRuntime.jsx(
1483
+ OfferRulesDrawer,
1484
+ {
1485
+ offer: editing,
1486
+ open: editing !== null,
1487
+ busy,
1488
+ onOpenChange: (o) => !o && setEditing(null),
1489
+ onSave: async (rules) => {
1490
+ try {
1491
+ await update.mutateAsync({ id: editing.id, rules });
1492
+ return null;
1493
+ } catch (err) {
1494
+ return errorSentence(err);
1495
+ }
1496
+ }
1497
+ },
1498
+ editing.id
1499
+ ),
1500
+ /* @__PURE__ */ jsxRuntime.jsxs("form", { className: "flex flex-col gap-y-3 px-6 py-4", onSubmit: (e) => void onAdd(e), children: [
1501
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { level: "h2", children: OFFERS_COPY.add }),
1502
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [
1503
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1504
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-offer-name", children: OFFERS_COPY.name }),
1505
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-offer-name", value: name, maxLength: 80, onChange: (e) => setName(e.target.value) })
1506
+ ] }),
1507
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1508
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-offer-variant", children: OFFERS_COPY.variant }),
1509
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-offer-variant", value: variantId, onChange: (e) => setVariantId(e.target.value) })
1510
+ ] })
1511
+ ] }),
1512
+ /* @__PURE__ */ jsxRuntime.jsx("div", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", type: "submit", disabled: busy || !name.trim() || !variantId.trim(), children: OFFERS_COPY.add }) })
1513
+ ] })
1514
+ ] })
1515
+ ] });
1516
+ };
1517
+ const config$1 = adminSdk.defineRouteConfig({
1518
+ label: "One-click offers",
1519
+ icon: icons.Sparkles,
1520
+ nested: "/orders"
1521
+ });
1058
1522
  const EVENT_LABEL = {
1059
1523
  "subscription.created": "Signed up",
1060
1524
  "subscription.renewed": "Renewal paid",
@@ -1156,6 +1620,11 @@ const routeModule = {
1156
1620
  {
1157
1621
  Component: HeldPaymentsPage,
1158
1622
  path: "/vonpay-holds",
1623
+ handle: { label: config$2.label, translationNs: config$2.translationNs }
1624
+ },
1625
+ {
1626
+ Component: VonpayOffersPage,
1627
+ path: "/vonpay-offers",
1159
1628
  handle: { label: config$1.label, translationNs: config$1.translationNs }
1160
1629
  },
1161
1630
  {
@@ -1167,10 +1636,18 @@ const routeModule = {
1167
1636
  };
1168
1637
  const menuItemModule = {
1169
1638
  menuItems: [
1639
+ {
1640
+ label: config$2.label,
1641
+ icon: config$2.icon,
1642
+ path: "/vonpay-holds",
1643
+ nested: "/orders",
1644
+ rank: void 0,
1645
+ translationNs: void 0
1646
+ },
1170
1647
  {
1171
1648
  label: config$1.label,
1172
1649
  icon: config$1.icon,
1173
- path: "/vonpay-holds",
1650
+ path: "/vonpay-offers",
1174
1651
  nested: "/orders",
1175
1652
  rank: void 0,
1176
1653
  translationNs: void 0