hookwright 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +12 -1
  2. package/dist/cli.js +168 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -124,6 +124,17 @@ Each provider owns its gate, signature scheme, field map, payload shape and summ
124
124
  - **Razorpay Magic** is root-level, uses capitalised shipping keys (`Shipping_address.Address1`) and sends `line_items_total` as a **string in paise** — matching [Razorpay's abandoned cart webhook docs](https://razorpay.com/docs/payments/magic-checkout/abandoned-cart/). Its gate is "non-empty `abandoned_checkout_url`" rather than an exact value.
125
125
  - **Nitro** keys off `eventName` and requires `eventVal.customer.phone` on *every* event, parsed against root `country`. Choose the event with `--event`, or from the menu in the UI.
126
126
 
127
+ Nitro's eight events are about different things, so each asks for a different subject:
128
+
129
+ | event | you pick | cart |
130
+ |---|---|---|
131
+ | `view` | nothing | — |
132
+ | `category_view` | one collection (fetched from Shopify, custom **and** smart) | — |
133
+ | `product_view` | one product | — |
134
+ | `addtocart` · `removefromcart` · `checkout` · `orders/create` · `orders/updated` | products and quantities | ✅ |
135
+
136
+ A page view has no cart, so it is never asked for a discount; a category view emits the collection's real id, title and URL and carries no line items.
137
+
127
138
  Adding one is a single file in `src/providers/` exporting `buildPayload`, `sign`, `webhookUrl`, `summarize`, a `gate` and a `fieldMap` — see [docs/cashfree-occ.md](docs/cashfree-occ.md).
128
139
 
129
140
  ## Development
@@ -131,7 +142,7 @@ Adding one is a single file in `src/providers/` exporting `buildPayload`, `sign`
131
142
  ```bash
132
143
  npm install
133
144
  npm run dev # esbuild watch
134
- npm test # 179 tests, no network required
145
+ npm test # 185 tests, no network required
135
146
  npm start # build + run
136
147
  ```
137
148
 
package/dist/cli.js CHANGED
@@ -24,7 +24,7 @@ var init_package = __esm({
24
24
  "package.json"() {
25
25
  package_default = {
26
26
  name: "hookwright",
27
- version: "1.1.1",
27
+ version: "1.2.0",
28
28
  description: "Build real, signed e-commerce webhooks from a live Shopify catalogue \u2014 interactive terminal UI, no backend",
29
29
  keywords: [
30
30
  "webhook",
@@ -407,6 +407,16 @@ function cartPermalink(domain, items) {
407
407
  function money2(value) {
408
408
  return Math.round(Number(value) * 100) / 100;
409
409
  }
410
+ function normalizeCollection(collection) {
411
+ return {
412
+ id: String(collection.id),
413
+ title: collection.title,
414
+ handle: collection.handle,
415
+ kind: collection.kind,
416
+ image: collection.image?.src ?? "",
417
+ productsCount: collection.products_count ?? null
418
+ };
419
+ }
410
420
 
411
421
  // src/providers/cashfree-occ.mjs
412
422
  var GATE_PATH = "type";
@@ -757,15 +767,18 @@ var razorpay_magic_default = {
757
767
  // src/providers/nitro.mjs
758
768
  import crypto3 from "node:crypto";
759
769
  var EVENTS = [
760
- { eventName: "view", type: "nitro_view", label: "Page View" },
761
- { eventName: "category_view", type: "nitro_category_view", label: "Category View" },
762
- { eventName: "product_view", type: "nitro_product_view", label: "Product View" },
763
- { eventName: "addtocart", type: "nitro_addtocart", label: "Add To Cart" },
764
- { eventName: "removefromcart", type: "nitro_removefromcart", label: "Remove From Cart" },
765
- { eventName: "checkout", type: "nitro_checkout", label: "Checkout" },
766
- { eventName: "orders/create", type: "nitro_orders_create", label: "Order Created" },
767
- { eventName: "orders/updated", type: "nitro_orders_updated", label: "Order Updated" }
770
+ { eventName: "view", type: "nitro_view", label: "Page View", selection: "none", subject: "a page, no product" },
771
+ { eventName: "category_view", type: "nitro_category_view", label: "Category View", selection: "collection", subject: "one collection" },
772
+ { eventName: "product_view", type: "nitro_product_view", label: "Product View", selection: "product", subject: "one product" },
773
+ { eventName: "addtocart", type: "nitro_addtocart", label: "Add To Cart", selection: "cart", subject: "cart contents" },
774
+ { eventName: "removefromcart", type: "nitro_removefromcart", label: "Remove From Cart", selection: "cart", subject: "cart contents" },
775
+ { eventName: "checkout", type: "nitro_checkout", label: "Checkout", selection: "cart", subject: "cart contents" },
776
+ { eventName: "orders/create", type: "nitro_orders_create", label: "Order Created", selection: "cart", subject: "ordered items" },
777
+ { eventName: "orders/updated", type: "nitro_orders_updated", label: "Order Updated", selection: "cart", subject: "ordered items" }
768
778
  ];
779
+ function selectionFor(eventName) {
780
+ return EVENTS.find((e) => e.eventName === eventName)?.selection ?? "cart";
781
+ }
769
782
  var DEFAULT_EVENT = "addtocart";
770
783
  var COMMON_FIELDS = [
771
784
  { source: "eventName", target: "\xABgate\xBB", required: true, note: "must match the schema exactly" },
@@ -783,8 +796,9 @@ var FIELD_MAPS = {
783
796
  category_view: [
784
797
  ...COMMON_FIELDS,
785
798
  ...PAGE_FIELDS,
786
- { source: "eventVal.resource_id", target: "eventVal.resource_id", required: true },
799
+ { source: "eventVal.resource_id", target: "eventVal.resource_id", required: true, note: "the collection id" },
787
800
  { source: "eventVal.resource", target: "eventVal.resource" },
801
+ { source: "eventVal.title", target: "collection name", required: true },
788
802
  { source: "eventVal.domain", target: "eventVal.domain" }
789
803
  ],
790
804
  product_view: [
@@ -848,6 +862,17 @@ var EDITABLE = {
848
862
  { group: "Page", path: "eventVal.l", label: "Location URL" },
849
863
  { group: "Page", path: "eventVal._ss", label: "Session id" }
850
864
  ],
865
+ collection: [
866
+ { group: "Collection", path: "eventVal.resource_id", label: "Collection id" },
867
+ { group: "Collection", path: "eventVal.title", label: "Collection name" },
868
+ { group: "Collection", path: "eventVal.l", label: "Collection URL" }
869
+ ],
870
+ product: [
871
+ { group: "Product", path: "eventVal.resource_id", label: "Product id" },
872
+ { group: "Product", path: "eventVal.title", label: "Product title" },
873
+ { group: "Product", path: "eventVal.price", label: "Price", type: "number" },
874
+ { group: "Product", path: "eventVal.image", label: "Image URL" }
875
+ ],
851
876
  cart: [
852
877
  { group: "Cart", path: "eventVal.cart_value", label: "Cart value", type: "number" },
853
878
  { group: "Cart", path: "eventVal.init_cart", label: "Init cart" },
@@ -866,7 +891,8 @@ var EDITABLE = {
866
891
  function editableFieldsFor(eventName) {
867
892
  const e = EDITABLE;
868
893
  if (eventName === "view") return [...e.common, ...e.page];
869
- if (eventName === "category_view" || eventName === "product_view") return [...e.common, ...e.page];
894
+ if (eventName === "category_view") return [...e.common, ...e.page, ...e.collection];
895
+ if (eventName === "product_view") return [...e.common, ...e.page, ...e.product];
870
896
  if (eventName === "addtocart" || eventName === "removefromcart") return [...e.common, ...e.cart];
871
897
  if (eventName === "checkout") return [...e.common, ...e.cart];
872
898
  if (eventName.startsWith("orders/")) return [...e.common, ...e.order];
@@ -919,17 +945,22 @@ function buildPayload3(ctx) {
919
945
  let eventVal;
920
946
  switch (eventName) {
921
947
  case "view":
922
- eventVal = { ...page, page: "home", l: `https://${domain}`, customer };
948
+ eventVal = { page: "home", h: domain, l: `https://${domain}`, _ss: session, domain, customer };
923
949
  break;
924
- case "category_view":
950
+ case "category_view": {
951
+ const collection = ctx.collection;
925
952
  eventVal = {
926
953
  ...page,
954
+ page: "collection",
927
955
  resource: "collection",
928
- resource_id: String(first?.product?.id ?? ""),
929
- l: `https://${domain}/collections/all`,
956
+ resource_id: String(collection?.id ?? ""),
957
+ title: collection?.title,
958
+ image: collection?.image || void 0,
959
+ l: collection?.handle ? `https://${domain}/collections/${collection.handle}` : `https://${domain}/collections/all`,
930
960
  customer
931
961
  };
932
962
  break;
963
+ }
933
964
  case "product_view":
934
965
  eventVal = {
935
966
  ...page,
@@ -1029,6 +1060,7 @@ var nitro_default = {
1029
1060
  fieldMapFor: (eventName) => FIELD_MAPS[eventName] ?? FIELD_MAPS[DEFAULT_EVENT],
1030
1061
  editableFields: editableFields3,
1031
1062
  editableFieldsFor,
1063
+ selectionFor,
1032
1064
  buildPayload: buildPayload3,
1033
1065
  sign: sign3,
1034
1066
  webhookUrl: webhookUrl3,
@@ -1378,6 +1410,20 @@ var ShopifyClient = class {
1378
1410
  const { json } = await this.request("/products/count.json");
1379
1411
  return json?.count ?? 0;
1380
1412
  }
1413
+ /**
1414
+ * Collections, from both endpoints Shopify splits them across: custom
1415
+ * (manually curated) and smart (rule based). Either can back a category page.
1416
+ */
1417
+ async collections({ limit = 50, title } = {}) {
1418
+ const [custom, smart] = await Promise.all([
1419
+ this.request("/custom_collections.json", { query: { limit, title } }).catch(() => ({ json: null })),
1420
+ this.request("/smart_collections.json", { query: { limit, title } }).catch(() => ({ json: null }))
1421
+ ]);
1422
+ return [
1423
+ ...(custom.json?.custom_collections ?? []).map((c2) => ({ ...c2, kind: "custom" })),
1424
+ ...(smart.json?.smart_collections ?? []).map((c2) => ({ ...c2, kind: "smart" }))
1425
+ ];
1426
+ }
1381
1427
  };
1382
1428
  function normalizeDomain(domain) {
1383
1429
  return String(domain).trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
@@ -1403,6 +1449,11 @@ async function fetchCatalogue(cfg, { search, pages = 1, limit = 50 } = {}) {
1403
1449
  const raw = await client.products({ title: search, pages, limit });
1404
1450
  return raw.map(normalizeProduct).filter((p) => p.variants.length);
1405
1451
  }
1452
+ async function fetchCollections(cfg, { search, limit = 50 } = {}) {
1453
+ const client = new ShopifyClient(cfg.shopify);
1454
+ const raw = await client.collections({ title: search, limit });
1455
+ return raw.map(normalizeCollection);
1456
+ }
1406
1457
  function autoSelect(products, count = 1) {
1407
1458
  return products.slice(0, count).map((product) => ({
1408
1459
  product,
@@ -1808,7 +1859,7 @@ function loadPayload(file) {
1808
1859
  }
1809
1860
 
1810
1861
  // src/core/build.mjs
1811
- function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName }) {
1862
+ function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName, collection }) {
1812
1863
  const provider = getProvider(providerId);
1813
1864
  const event = eventName ?? provider.defaultEvent;
1814
1865
  const resolvedPhone = phone ?? cfg.customer.phone;
@@ -1818,6 +1869,7 @@ function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, p
1818
1869
  items,
1819
1870
  discount,
1820
1871
  eventName: event,
1872
+ collection,
1821
1873
  phone: parsed.ok ? parsed.value : String(resolvedPhone ?? "")
1822
1874
  });
1823
1875
  const editable = provider.editableFieldsFor ? provider.editableFieldsFor(event) : provider.editableFields;
@@ -1833,14 +1885,16 @@ function applyEdits(payload, fields, edits) {
1833
1885
  }
1834
1886
  return payload;
1835
1887
  }
1836
- async function buildPayload4({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName }) {
1837
- if (!items?.length) throw new ConfigError("no products selected");
1888
+ async function buildPayload4({ cfg, providerId = "cashfree-occ", items = [], discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName, collection }) {
1889
+ const provider0 = getProvider(providerId);
1890
+ const needsItems = !provider0.selectionFor || ["cart", "product"].includes(provider0.selectionFor(eventName ?? provider0.defaultEvent));
1891
+ if (needsItems && !items.length) throw new ConfigError("no products selected");
1838
1892
  const provider = getProvider(providerId);
1839
1893
  const resolvedPhone = phone ?? cfg.customer.phone;
1840
1894
  const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
1841
1895
  if (!parsed.ok) throw new ConfigError(`phone is unusable: ${parsed.reason}`);
1842
1896
  const event = eventName ?? provider.defaultEvent;
1843
- const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event });
1897
+ const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event, collection });
1844
1898
  const body = JSON.stringify(payload);
1845
1899
  const report = await runAll({ payload, body, provider, config: cfg, checkImageUrls, eventName: event });
1846
1900
  const summary = provider.summarize ? provider.summarize(payload) : {};
@@ -2040,6 +2094,7 @@ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
2040
2094
  var STEP = {
2041
2095
  EVENT: "event",
2042
2096
  LOADING: "loading",
2097
+ COLLECTION: "collection",
2043
2098
  PICK: "pick",
2044
2099
  VARIANT: "variant",
2045
2100
  QUANTITY: "quantity",
@@ -2060,6 +2115,8 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
2060
2115
  const [eventName, setEventName] = useState(provider.defaultEvent);
2061
2116
  const [step, setStep] = useState(multiEvent ? STEP.EVENT : STEP.LOADING);
2062
2117
  const [products, setProducts] = useState([]);
2118
+ const [collections, setCollections] = useState([]);
2119
+ const [collection, setCollection] = useState(null);
2063
2120
  const [chosen, setChosen] = useState([]);
2064
2121
  const [cursor, setCursor] = useState(0);
2065
2122
  const [items, setItems] = useState([]);
@@ -2079,6 +2136,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
2079
2136
  case STEP.EVENT:
2080
2137
  return onDone();
2081
2138
  case STEP.PICK:
2139
+ case STEP.COLLECTION:
2082
2140
  return multiEvent ? setStep(STEP.EVENT) : onDone();
2083
2141
  case STEP.VARIANT:
2084
2142
  if (cursor === 0) return setStep(STEP.PICK);
@@ -2090,6 +2148,8 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
2090
2148
  setItems(items.slice(0, -1));
2091
2149
  return stepInto(cursor - 1);
2092
2150
  case STEP.DISCOUNT: {
2151
+ if (selection === "none") return multiEvent ? setStep(STEP.EVENT) : onDone();
2152
+ if (selection === "collection") return setStep(STEP.COLLECTION);
2093
2153
  const last = chosen.length - 1;
2094
2154
  setItems(items.slice(0, -1));
2095
2155
  return stepInto(last);
@@ -2130,9 +2190,36 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
2130
2190
  if (input === "j" && built) copy(built.body).then((r) => setCopied({ ...r, what: "payload JSON" }));
2131
2191
  if ((input === "s" || key.return) && built?.report.ok) doSend();
2132
2192
  });
2193
+ const selection = provider.selectionFor ? provider.selectionFor(eventName) : "cart";
2133
2194
  useEffect(() => {
2134
2195
  if (step === STEP.EVENT) return;
2135
2196
  let alive = true;
2197
+ if (selection === "none") {
2198
+ startReview(0);
2199
+ return () => {
2200
+ alive = false;
2201
+ };
2202
+ }
2203
+ if (selection === "collection") {
2204
+ fetchCollections(config).then((list) => {
2205
+ if (!alive) return;
2206
+ if (!list.length) {
2207
+ setError({ message: "this store has no collections", hint: "create one in Shopify, or pick a different event" });
2208
+ setStep(STEP.ERROR);
2209
+ return;
2210
+ }
2211
+ setCollections(list);
2212
+ setStep(STEP.COLLECTION);
2213
+ }).catch((err) => {
2214
+ if (alive) {
2215
+ setError({ message: err.message, hint: err.hint });
2216
+ setStep(STEP.ERROR);
2217
+ }
2218
+ });
2219
+ return () => {
2220
+ alive = false;
2221
+ };
2222
+ }
2136
2223
  fetchCatalogue(config).then((list) => {
2137
2224
  if (!alive) return;
2138
2225
  if (!list.length) {
@@ -2150,11 +2237,12 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
2150
2237
  return () => {
2151
2238
  alive = false;
2152
2239
  };
2153
- }, [step === STEP.EVENT]);
2240
+ }, [step === STEP.EVENT, eventName]);
2154
2241
  const advance = (nextItems, nextCursor) => {
2155
2242
  if (nextCursor >= chosen.length) {
2156
2243
  setItems(nextItems);
2157
- setStep(STEP.DISCOUNT);
2244
+ if (selection === "cart") setStep(STEP.DISCOUNT);
2245
+ else startReview(0, collection, nextItems);
2158
2246
  return;
2159
2247
  }
2160
2248
  setItems(nextItems);
@@ -2167,6 +2255,24 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
2167
2255
  setStep(STEP.QUANTITY);
2168
2256
  }
2169
2257
  };
2258
+ const onSingleProductPicked = (id) => {
2259
+ const product = products.find((p) => p.id === id);
2260
+ if (!product) return;
2261
+ setChosen([product]);
2262
+ setCursor(0);
2263
+ setItems([]);
2264
+ if (product.variants.length > 1) setStep(STEP.VARIANT);
2265
+ else {
2266
+ setPendingVariant(product.variants[0]);
2267
+ setStep(STEP.QUANTITY);
2268
+ }
2269
+ };
2270
+ const onCollectionPicked = (id) => {
2271
+ const picked = collections.find((c2) => c2.id === id);
2272
+ if (!picked) return;
2273
+ setCollection(picked);
2274
+ startReview(0, picked);
2275
+ };
2170
2276
  const onProductsPicked = (ids) => {
2171
2277
  const picked = ids.map((id) => products.find((p) => p.id === id)).filter(Boolean);
2172
2278
  if (!picked.length) return;
@@ -2191,10 +2297,16 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
2191
2297
  const nextItems = [...items, { product, variant: pendingVariant, quantity: qty }];
2192
2298
  advance(nextItems, cursor + 1);
2193
2299
  };
2194
- const onDiscount = (raw) => {
2195
- const discount = Math.max(0, Number.parseFloat(raw) || 0);
2300
+ const startReview = (discount, chosenCollection = collection, chosenItems = items) => {
2196
2301
  try {
2197
- const next = draftPayload({ cfg: config, providerId, items, discount, eventName });
2302
+ const next = draftPayload({
2303
+ cfg: config,
2304
+ providerId,
2305
+ items: chosenItems,
2306
+ discount,
2307
+ eventName,
2308
+ collection: chosenCollection
2309
+ });
2198
2310
  setDraft({ ...next, discount });
2199
2311
  setStep(STEP.MODE);
2200
2312
  } catch (err) {
@@ -2202,9 +2314,10 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
2202
2314
  setStep(STEP.ERROR);
2203
2315
  }
2204
2316
  };
2317
+ const onDiscount = (raw) => startReview(Math.max(0, Number.parseFloat(raw) || 0));
2205
2318
  const finalise = (payload) => {
2206
2319
  setStep(STEP.BUILDING);
2207
- buildPayload4({ cfg: config, providerId, items, discount: draft.discount, payload, eventName }).then(async (result) => {
2320
+ buildPayload4({ cfg: config, providerId, items, discount: draft.discount, payload, eventName, collection }).then(async (result) => {
2208
2321
  setBuilt(result);
2209
2322
  let req = null;
2210
2323
  let command = "";
@@ -2278,9 +2391,37 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
2278
2391
  ) })
2279
2392
  ] }),
2280
2393
  step === STEP.LOADING && /* @__PURE__ */ jsx8(Spinner, { label: `fetching live products from ${store}\u2026` }),
2281
- step === STEP.PICK && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2394
+ step === STEP.COLLECTION && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2395
+ /* @__PURE__ */ jsx8(Text8, { children: "Which collection was viewed?" }),
2396
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
2397
+ Select4,
2398
+ {
2399
+ visibleOptionCount: 10,
2400
+ options: collections.map((c2) => ({
2401
+ label: `${truncate(c2.title, 40).padEnd(41)} ${c2.productsCount != null ? `${c2.productsCount} products` : c2.kind}`,
2402
+ value: c2.id
2403
+ })),
2404
+ onChange: onCollectionPicked
2405
+ }
2406
+ ) })
2407
+ ] }),
2408
+ step === STEP.PICK && selection === "product" && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2409
+ /* @__PURE__ */ jsx8(Text8, { children: "Which product was viewed?" }),
2410
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
2411
+ Select4,
2412
+ {
2413
+ visibleOptionCount: 10,
2414
+ options: products.map((p) => ({
2415
+ label: `${truncate(p.title, 44).padEnd(45)} ${money(p.variants[0]?.price, currency)}`,
2416
+ value: p.id
2417
+ })),
2418
+ onChange: onSingleProductPicked
2419
+ }
2420
+ ) })
2421
+ ] }),
2422
+ step === STEP.PICK && selection !== "product" && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2282
2423
  /* @__PURE__ */ jsxs8(Text8, { children: [
2283
- `Select the products in the abandoned cart `,
2424
+ `Select the products in the cart `,
2284
2425
  /* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: "(space toggles \xB7 enter confirms)" })
2285
2426
  ] }),
2286
2427
  /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookwright",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Build real, signed e-commerce webhooks from a live Shopify catalogue — interactive terminal UI, no backend",
5
5
  "keywords": [
6
6
  "webhook",