@mohasinac/appkit 4.26.0 → 4.27.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.
@@ -40,6 +40,25 @@ export declare const listBundleMembers: (bundle: Pick<CategoryDocument, "bundleP
40
40
  * displayed discount.
41
41
  */
42
42
  export declare function resolveBundleOriginalTotal(productIds: string[]): Promise<number | undefined>;
43
+ /**
44
+ * Reject a bundle whose members belong to more than one seller.
45
+ *
46
+ * `storeId` is a SCALAR on the cart line, and it is simultaneously the
47
+ * order-splitting key and the key that per-store add-ons, coupons, shipping and
48
+ * payout all hang off. A bundle spanning two sellers therefore produces one
49
+ * order belonging to one of them while containing the other's products — the
50
+ * second seller gets no notification, no shipping resolution and no payout.
51
+ *
52
+ * Grouped cart lines refuse this at both the picker and the add-to-cart action.
53
+ * Bundles had no equivalent guard, so the only reason nothing was broken was
54
+ * that every bundle anyone had built happened to be single-store. This closes
55
+ * that at the point a bundle is SAVED, which is the cheapest place to say no:
56
+ * bundles already in the database keep working, and checkout is untouched.
57
+ *
58
+ * Returns the offending store ids (empty when fine) rather than throwing, so
59
+ * each route can phrase its own 400.
60
+ */
61
+ export declare function findBundleMemberStores(productIds: string[]): Promise<string[]>;
43
62
  /**
44
63
  * Union of the member products' `categorySlugs` — the `bundleCategorySlugs`
45
64
  * mirror that lets a category page scope its Bundles tab.
@@ -68,6 +68,38 @@ export async function resolveBundleOriginalTotal(productIds) {
68
68
  const total = prices.reduce((sum, p) => sum + p, 0);
69
69
  return Math.round(total * 100) / 100;
70
70
  }
71
+ /**
72
+ * Reject a bundle whose members belong to more than one seller.
73
+ *
74
+ * `storeId` is a SCALAR on the cart line, and it is simultaneously the
75
+ * order-splitting key and the key that per-store add-ons, coupons, shipping and
76
+ * payout all hang off. A bundle spanning two sellers therefore produces one
77
+ * order belonging to one of them while containing the other's products — the
78
+ * second seller gets no notification, no shipping resolution and no payout.
79
+ *
80
+ * Grouped cart lines refuse this at both the picker and the add-to-cart action.
81
+ * Bundles had no equivalent guard, so the only reason nothing was broken was
82
+ * that every bundle anyone had built happened to be single-store. This closes
83
+ * that at the point a bundle is SAVED, which is the cheapest place to say no:
84
+ * bundles already in the database keep working, and checkout is untouched.
85
+ *
86
+ * Returns the offending store ids (empty when fine) rather than throwing, so
87
+ * each route can phrase its own 400.
88
+ */
89
+ export async function findBundleMemberStores(productIds) {
90
+ if (productIds.length === 0)
91
+ return [];
92
+ const results = await Promise.all(productIds.map((id) => productRepository.findById(id).catch(() => null)));
93
+ const stores = new Set();
94
+ for (const p of results) {
95
+ // An unresolvable member says nothing about seller spread — the price
96
+ // helper above already refuses to guess in that case, and inventing a
97
+ // store here would produce a false positive that blocks a valid save.
98
+ if (p?.storeId)
99
+ stores.add(p.storeId);
100
+ }
101
+ return [...stores];
102
+ }
71
103
  /**
72
104
  * Union of the member products' `categorySlugs` — the `bundleCategorySlugs`
73
105
  * mirror that lets a category page scope its Bundles tab.
@@ -1,4 +1,4 @@
1
- export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, resolveBundleCategorySlugs, type BundleDataOptions, } from "./data";
1
+ export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, findBundleMemberStores, resolveBundleCategorySlugs, type BundleDataOptions, } from "./data";
2
2
  export { buildBundleMetadata, type BundleMetadataOptions } from "./metadata";
3
3
  export { renderBundleOg, renderBundleOgImage, type BundleOgData, } from "./og";
4
4
  export { addBundleToCartAction } from "./actions";
@@ -1,4 +1,4 @@
1
- export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, resolveBundleCategorySlugs, } from "./data";
1
+ export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, findBundleMemberStores, resolveBundleCategorySlugs, } from "./data";
2
2
  export { buildBundleMetadata } from "./metadata";
3
3
  // S-SBUNI-4 2026-05-13 — bundle OG renderer.
4
4
  export { renderBundleOg, renderBundleOgImage, } from "./og";
@@ -308,6 +308,111 @@ export const productsTesterSeedData = [
308
308
  createdAt: new Date(),
309
309
  updatedAt: new Date(),
310
310
  }),
311
+ // ── Grouped-cart-line fixtures ───────────────────────────────────────────
312
+ // Each of the three below exists to make ONE picker branch testable by hand.
313
+ // Without them the branch can only be reached by editing data live, which a
314
+ // reseed silently wipes out.
315
+ withTokens({
316
+ id: "product-tester-group-soldout",
317
+ slug: "product-tester-group-soldout",
318
+ title: "Test Part — Sold Out (Group Member)",
319
+ description: "A member of the tester product group that is deliberately out of stock. Exists so the group picker's blocked-member branch has real data: it must render a reason chip instead of a +/- stepper, and must not be selectable. Auto-expires in 7 days.",
320
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
321
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
322
+ brandSlug: "brand-tester-sandbox",
323
+ brand: "TestBrand",
324
+ price: 129,
325
+ currency: "INR",
326
+ stockQuantity: 0,
327
+ availableQuantity: 0,
328
+ isSold: true,
329
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-group-soldout-20260101/900/900"),
330
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-group-soldout-20260101/900/900")],
331
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
332
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
333
+ listingType: "standard",
334
+ customFields: [],
335
+ customSections: [],
336
+ isPromoted: false,
337
+ isOnSale: false,
338
+ gstRate: 18,
339
+ hsnCode: "95030090",
340
+ groupId: "group-tester-sandbox-bundle",
341
+ groupParentSlug: "group-tester-sandbox-bundle",
342
+ groupTitle: TESTER_GROUP_TITLE,
343
+ createdAt: new Date(),
344
+ updatedAt: new Date(),
345
+ }),
346
+ // A group whose members span TWO stores. `storeId` is the order-splitting key
347
+ // and the key per-store add-ons/coupons/shipping/payout hang off, so such a
348
+ // group can never become one cart line — the picker must stand down to a
349
+ // read-only table rather than offer a button that always fails.
350
+ // `withTokens` spreads `...p` last, so the storeId below overrides the
351
+ // sandbox default on purpose.
352
+ withTokens({
353
+ id: "product-tester-crossstore-a",
354
+ slug: "product-tester-crossstore-a",
355
+ title: "Test Cross-Store Set — Seller A",
356
+ description: "Parent of a deliberately CROSS-STORE tester group. Its sibling belongs to a different seller, so the group picker must render read-only with no quantity column and no \"Add selected\" button. Auto-expires in 7 days.",
357
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
358
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
359
+ brandSlug: "brand-tester-sandbox",
360
+ brand: "TestBrand",
361
+ price: 259,
362
+ currency: "INR",
363
+ stockQuantity: 6,
364
+ availableQuantity: 6,
365
+ isSold: false,
366
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-a-20260101/900/900"),
367
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-a-20260101/900/900")],
368
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
369
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
370
+ listingType: "standard",
371
+ customFields: [],
372
+ customSections: [],
373
+ isPromoted: false,
374
+ isOnSale: false,
375
+ gstRate: 18,
376
+ isGroupParent: true,
377
+ groupId: "group-tester-cross-store",
378
+ groupTitle: "Tester Cross-Store Set",
379
+ groupChildSlugs: ["product-tester-crossstore-b"],
380
+ createdAt: new Date(),
381
+ updatedAt: new Date(),
382
+ }),
383
+ withTokens({
384
+ id: "product-tester-crossstore-b",
385
+ slug: "product-tester-crossstore-b",
386
+ title: "Test Cross-Store Set — Seller B",
387
+ description: "Sibling of product-tester-crossstore-a, deliberately owned by a DIFFERENT store so the group spans two sellers. Still isTestData, so filterTestDataForViewer keeps it off that store's public listings for non-testers. Auto-expires in 7 days.",
388
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
389
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
390
+ brandSlug: "brand-tester-sandbox",
391
+ brand: "TestBrand",
392
+ price: 189,
393
+ currency: "INR",
394
+ stockQuantity: 6,
395
+ availableQuantity: 6,
396
+ isSold: false,
397
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-b-20260101/900/900"),
398
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-b-20260101/900/900")],
399
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
400
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
401
+ listingType: "standard",
402
+ customFields: [],
403
+ customSections: [],
404
+ isPromoted: false,
405
+ isOnSale: false,
406
+ gstRate: 12,
407
+ // The whole point of this fixture — a different seller from its sibling.
408
+ storeId: "store-beyblade-arena",
409
+ storeName: "Beyblade Arena",
410
+ groupId: "group-tester-cross-store",
411
+ groupParentSlug: "product-tester-crossstore-a",
412
+ groupTitle: "Tester Cross-Store Set",
413
+ createdAt: new Date(),
414
+ updatedAt: new Date(),
415
+ }),
311
416
  withTokens({
312
417
  id: "product-tester-standard-sold",
313
418
  slug: "product-tester-standard-sold",
@@ -687,6 +792,7 @@ export const productsTesterSeedData = [
687
792
  "product-tester-standard-1",
688
793
  "product-tester-standard-2",
689
794
  "product-tester-standard-3",
795
+ "product-tester-group-soldout",
690
796
  ],
691
797
  createdAt: new Date(),
692
798
  updatedAt: new Date(),
@@ -729,39 +729,124 @@ const rawTesterChecklistItems = [
729
729
  href: "/cart",
730
730
  cases: [
731
731
  { key: "update-qty", label: "Updating item quantity in cart recalculates the total" },
732
+ // ── Grouped & bundle cart lines ──────────────────────────────────
733
+ // Fixtures backing these: product-tester-standard-1/2/3 (three members
734
+ // of one group, deliberately spanning two GST rates),
735
+ // product-tester-group-soldout (blocked member),
736
+ // product-tester-crossstore-a/b (a group spanning two sellers),
737
+ // bundle-tester-sandbox (a priced, all-or-nothing bundle).
738
+ {
739
+ key: "group-picker-opens",
740
+ label: "On a product that belongs to a group, the \"Part of: …\" strip has a \"Pick items →\" control that opens a picker with a Qty column and a +/- stepper on each member",
741
+ description: "Before this existed the panel was navigation only — the buyer had to open each member separately and add it as its own cart line.",
742
+ href: "/products/product-tester-standard-1",
743
+ },
732
744
  {
733
- key: "group-picker-add-selected",
734
- label: "On a product that is part of a group, \"Pick items →\" opens a picker with +/- per member, and \"Add selected to cart\" creates ONE cart line (not one per item)",
735
- description: "Use the Test Gadget page. Set two different members to different quantities, check the running total in the picker footer, then add. The cart should show a single line, not three.",
745
+ key: "group-picker-running-total",
746
+ label: "Changing any member's quantity updates the picker's running total (\"N items · ₹X\") immediately, and \"Add selected to cart\" stays disabled while nothing is selected",
736
747
  href: "/products/product-tester-standard-1",
737
748
  },
738
749
  {
739
- key: "group-line-editable-in-cart",
740
- label: "A grouped cart line expands to show its members, each with its own +/- and remove; removing the last member removes the whole line",
741
- description: "There should be NO line-level quantity stepper on a grouped line — the per-member steppers carry the whole selection. Changing one member should update both the line total and the seller subtotal.",
742
- href: "/cart",
750
+ key: "group-picker-stock-cap",
751
+ label: "A member's + button stops at that member's available stock — it cannot be pushed past it",
752
+ href: "/products/product-tester-standard-1",
753
+ },
754
+ {
755
+ key: "group-picker-blocked-member",
756
+ label: "A sold-out member shows a reason chip (e.g. \"Sold\" / \"Out of stock\") in place of its stepper and cannot be selected",
757
+ description: "Test Part — Sold Out is seeded into the tester group specifically for this. The rest of the group must stay selectable around it.",
758
+ href: "/products/product-tester-group-soldout",
759
+ },
760
+ {
761
+ key: "group-picker-one-line",
762
+ label: "\"Add selected to cart\" with two or more members creates ONE cart line, not one line per product",
763
+ description: "Pick 2 of one member and 1 of another, then open the cart: a single line reading \"N items\", not three separate rows.",
764
+ href: "/products/product-tester-standard-1",
765
+ },
766
+ {
767
+ key: "group-picker-single-member-plain-line",
768
+ label: "Selecting exactly ONE member produces an ordinary product line (with a normal quantity stepper), not a grouped line",
769
+ description: "1 item × qty N is already what a normal cart line means, so it deliberately does not become a group line.",
770
+ href: "/products/product-tester-standard-1",
743
771
  },
744
772
  {
745
773
  key: "group-picker-cross-store",
746
- label: "A group whose members belong to different sellers shows the read-only table with no quantity column and an explanation, rather than an \"Add selected\" button that always fails",
774
+ label: "A group whose members belong to DIFFERENT sellers renders read-only — no Qty column, no \"Add selected\" button — with an explanation, rather than a button that always fails",
775
+ description: "Use the Test Cross-Store Set. Its two members sit in different stores on purpose.",
776
+ href: "/products/product-tester-crossstore-a",
777
+ },
778
+ {
779
+ key: "group-picker-guest",
780
+ label: "A signed-out visitor using the picker is shown the login prompt rather than silently losing the selection",
747
781
  href: "/products/product-tester-standard-1",
748
782
  },
783
+ {
784
+ key: "group-line-expands-in-cart",
785
+ label: "A grouped cart line in the cart expands to list its members with a thumbnail, unit price and its own +/- stepper each",
786
+ href: "/cart",
787
+ },
788
+ {
789
+ key: "group-line-no-line-level-stepper",
790
+ label: "A grouped line has NO line-level quantity stepper — only the per-member ones",
791
+ description: "The member quantities carry the whole selection; a second multiplier on top would double every price, tax and stock decrement.",
792
+ href: "/cart",
793
+ },
794
+ {
795
+ key: "group-line-member-edit-recalculates",
796
+ label: "Changing one member's quantity in the cart updates that line's total AND the seller-group subtotal above it",
797
+ href: "/cart",
798
+ },
799
+ {
800
+ key: "group-line-remove-member",
801
+ label: "Removing one member from a grouped line leaves the rest of the line intact; removing the LAST member removes the whole line",
802
+ href: "/cart",
803
+ },
804
+ {
805
+ key: "group-line-link-target",
806
+ label: "Clicking a grouped or bundle line's title in the cart lands on a real page — never a 404",
807
+ description: "A bundle line goes to /bundles/{slug}, a grouped-listing line to /groups/{slug}, a product-group line to its parent product. It used to point at /products/{categoryId}, which never existed.",
808
+ href: "/cart",
809
+ },
749
810
  {
750
811
  key: "bundle-copies-stepper",
751
812
  label: "A bundle page has a \"Copies\" +/- stepper and BOTH \"Buy now\" and \"Add to cart\" — Add to cart stays on the page, Buy now goes to checkout",
752
- description: "A bundle is all-or-nothing: there must be no per-member quantity controls on the bundle page, only the copies stepper.",
813
+ description: "A bundle is all-or-nothing: there must be no per-member quantity controls anywhere on the bundle page, only the copies stepper.",
753
814
  href: "/bundles/bundle-tester-sandbox",
754
815
  },
755
816
  {
756
- key: "bundle-line-editable-in-cart",
757
- label: "A bundle in the cart shows its member list read-only and a copies stepper; changing copies recalculates the line at the bundle's discounted price, not the sum of member prices",
817
+ key: "bundle-line-in-cart",
818
+ label: "A bundle in the cart lists its members READ-ONLY and offers a copies stepper; raising copies multiplies the bundle's discounted price, never the sum of the members' individual prices",
819
+ description: "Test Bundle is ₹199 while its members list at ₹199 + ₹149. Two copies must read ₹398, not ₹696.",
758
820
  href: "/cart",
759
821
  },
822
+ {
823
+ key: "bundle-cross-store-rejected",
824
+ label: "Saving a bundle whose items come from two different sellers is refused with a clear message naming how many sellers they span",
825
+ description: "Build a bundle mixing Test Cross-Store Set — Seller A with any Tester Sandbox product and save. Applies to both creating and editing, on the admin and the seller bundle editors. A bundle's storeId is a single value and is what decides which seller gets the order, the shipment and the payout — so a two-seller bundle would pay only one of them.",
826
+ href: "/store/bundles/new",
827
+ },
760
828
  {
761
829
  key: "grouped-listing-page-picker",
762
- label: "A grouped listing has its own page with the same picker, reachable from the \"Pick items from this group →\" button on a group carousel",
830
+ label: "A grouped listing has its own public page with the same picker, reachable from the \"Pick items from this group →\" button on a group carousel",
763
831
  href: "/groups/group-beyblade-original-lineage",
764
832
  },
833
+ {
834
+ key: "group-lane-gate",
835
+ label: "While an unpaid won auction or accepted offer is sitting in the cart, adding a group selection is refused with the same lane message as any other add",
836
+ description: "This path used to bypass the gate entirely, so a buyer could keep shopping around an obligation they had already committed to.",
837
+ href: "/products/product-tester-standard-1",
838
+ },
839
+ {
840
+ key: "group-checkout-order-rows",
841
+ label: "After checking out a grouped or bundle line, the order shows one row PER MEMBER under a single group header, and the line totals add up to the order total",
842
+ description: "Rows are expanded so each carries its own HSN code and GST rate — a collapsed row can only carry one of each, which is why bundles previously showed none.",
843
+ href: "/user/orders",
844
+ },
845
+ {
846
+ key: "group-checkout-stock",
847
+ label: "Checking out a grouped line decrements each member by (its per-copy quantity × the number of copies), not by one each",
848
+ href: "/user/orders",
849
+ },
765
850
  { key: "apply-coupon", label: "Applying a coupon code at checkout works" },
766
851
  { key: "remove-coupon", label: "Removing an already-applied coupon from the cart recalculates the total back down" },
767
852
  { key: "remove-item", label: "Removing an item from the cart works" },
package/dist/server.d.ts CHANGED
@@ -501,7 +501,7 @@ export { GoogleReviewsSection } from "./features/homepage/components/GoogleRevie
501
501
  export type { GoogleReviewsSectionProps } from "./features/homepage/components/GoogleReviewsSection";
502
502
  export { getProductForDetail, listSitemapProducts, computeRelatedItems, toProductItem, type SitemapProduct, type RelatedItemsResult, } from "./_internal/server/features/products/index";
503
503
  export { listPublicProducts, parsePublicProductParams, defaultAvailabilityForListingTypes, PUBLIC_PRODUCT_MAX_PAGE_SIZE, ANY_STATUS, type PublicProductListInput, type PublicProductListResult, type PublicProductListOptions, type PublicProductExecutor, type PublicProductQuery, } from "./_internal/server/features/products/index";
504
- export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, resolveBundleCategorySlugs, buildBundleMetadata, renderBundleOg, renderBundleOgImage, addBundleToCartAction, type BundleDataOptions, type BundleMetadataOptions, type BundleOgData, } from "./_internal/server/features/bundles/index";
504
+ export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, findBundleMemberStores, resolveBundleCategorySlugs, buildBundleMetadata, renderBundleOg, renderBundleOgImage, addBundleToCartAction, type BundleDataOptions, type BundleMetadataOptions, type BundleOgData, } from "./_internal/server/features/bundles/index";
505
505
  export { getGroupsForProduct, getGroupsWithItemsForProduct, getGroupsForCategory, getGroupsForBrand } from "./_internal/server/features/grouped/index";
506
506
  export { getGroupedListingForDetail, getGroupedListingWithItems, buildGroupedListingMetadata, renderGroupedListingOg, type GroupedListingWithItems, type GroupedListingMetadataOptions, } from "./_internal/server/features/grouped/index";
507
507
  export { toPublicGroupMember, toPublicGroupMembers, type PublicGroupMember, } from "./_internal/server/features/products/group-members";
package/dist/server.js CHANGED
@@ -1356,7 +1356,7 @@ export { getProductForDetail, listSitemapProducts, computeRelatedItems, toProduc
1356
1356
  // share it so their filter semantics cannot drift (Root Cause #30).
1357
1357
  export { listPublicProducts, parsePublicProductParams, defaultAvailabilityForListingTypes, PUBLIC_PRODUCT_MAX_PAGE_SIZE, ANY_STATUS, } from "./_internal/server/features/products/index";
1358
1358
  // S-SBUNI-3/4 2026-05-13 — bundle data/metadata/og layer.
1359
- export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, resolveBundleCategorySlugs, buildBundleMetadata, renderBundleOg, renderBundleOgImage, addBundleToCartAction, } from "./_internal/server/features/bundles/index";
1359
+ export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, findBundleMemberStores, resolveBundleCategorySlugs, buildBundleMetadata, renderBundleOg, renderBundleOgImage, addBundleToCartAction, } from "./_internal/server/features/bundles/index";
1360
1360
  export { getGroupsForProduct, getGroupsWithItemsForProduct, getGroupsForCategory, getGroupsForBrand } from "./_internal/server/features/grouped/index";
1361
1361
  // Public /groups/{slug} page — data, metadata and OG.
1362
1362
  export { getGroupedListingForDetail, getGroupedListingWithItems, buildGroupedListingMetadata, renderGroupedListingOg, } from "./_internal/server/features/grouped/index";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.26.0",
3
+ "version": "4.27.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"