@mohasinac/appkit 4.53.2 → 4.54.0-next.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 (179) hide show
  1. package/dist/.build-stamp.json +5 -5
  2. package/dist/_internal/server/features/auth/capabilities.js +7 -5
  3. package/dist/_internal/server/features/checkout/supplier-identity.js +28 -6
  4. package/dist/_internal/server/features/products/listing-tab-counts.js +81 -25
  5. package/dist/_internal/server/features/search/index-build/sources.js +43 -2
  6. package/dist/_internal/server/jobs/core/jobRunners.js +0 -10
  7. package/dist/_internal/server/jobs/core/onOrderCreate.js +10 -104
  8. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  9. package/dist/_internal/shared/actions/action-registry.js +128 -0
  10. package/dist/_internal/shared/features/categories/bundle-copy.d.ts +1 -1
  11. package/dist/_internal/shared/features/site-settings/supplier-of-record.d.ts +56 -0
  12. package/dist/_internal/shared/features/site-settings/supplier-of-record.js +57 -0
  13. package/dist/_internal/shared/listing-types/tiers.d.ts +94 -0
  14. package/dist/_internal/shared/listing-types/tiers.js +150 -0
  15. package/dist/cli/index.js +0 -2
  16. package/dist/client.d.ts +21 -2
  17. package/dist/client.js +23 -4
  18. package/dist/constants/api-endpoints.d.ts +21 -0
  19. package/dist/constants/api-endpoints.js +7 -0
  20. package/dist/features/account/components/UserOrdersView.js +26 -7
  21. package/dist/features/addresses/utils/postal-autofill.d.ts +39 -0
  22. package/dist/features/addresses/utils/postal-autofill.js +54 -0
  23. package/dist/features/admin/components/AdminStoreEditorView.js +73 -34
  24. package/dist/features/admin/components/OwnerQuickActions.d.ts +29 -0
  25. package/dist/features/admin/components/OwnerQuickActions.js +142 -0
  26. package/dist/features/admin/schemas/admin-editor-forms.d.ts +1 -1
  27. package/dist/features/admin/schemas/admin-editor-forms.js +4 -1
  28. package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +11 -1
  29. package/dist/features/auctions/components/MarketplaceAuctionCard.js +9 -2
  30. package/dist/features/auth/permissions/constants.d.ts +29 -2
  31. package/dist/features/auth/permissions/constants.js +68 -1
  32. package/dist/features/blog/components/BlogFeaturedCard.d.ts +11 -1
  33. package/dist/features/blog/components/BlogFeaturedCard.js +9 -2
  34. package/dist/features/cart/components/CartDrawer.js +3 -2
  35. package/dist/features/cart/components/CartGroupLineRow.js +2 -1
  36. package/dist/features/cart/hooks/useAddToCart.js +10 -5
  37. package/dist/features/cart/utils/pending-ops.d.ts +11 -1
  38. package/dist/features/cart/utils/pending-ops.js +14 -2
  39. package/dist/features/categories/components/BrandDetailPageView.js +2 -1
  40. package/dist/features/categories/components/CategoryDetailPageView.js +2 -1
  41. package/dist/features/categories/components/CategoryGrid.d.ts +11 -1
  42. package/dist/features/categories/components/CategoryGrid.js +9 -2
  43. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +10 -5
  44. package/dist/features/checkout/actions/checkout-value-otp-actions.js +2 -88
  45. package/dist/features/events/components/EventCard.d.ts +11 -1
  46. package/dist/features/events/components/EventCard.js +9 -2
  47. package/dist/features/layout/AppLayoutShell.js +2 -1
  48. package/dist/features/layout/BottomActions.d.ts +1 -0
  49. package/dist/features/layout/BottomActions.js +330 -0
  50. package/dist/features/layout/BottomChrome.d.ts +45 -0
  51. package/dist/features/layout/BottomChrome.js +5 -17
  52. package/dist/features/layout/SideActionDock.js +36 -13
  53. package/dist/features/layout/index.d.ts +2 -7
  54. package/dist/features/layout/index.js +7 -7
  55. package/dist/features/layout/useIsDesktop.d.ts +36 -0
  56. package/dist/features/layout/useIsDesktop.js +48 -0
  57. package/dist/features/media/messages/en.json +67 -67
  58. package/dist/features/orders/components/YourPurchaseStrip.d.ts +24 -0
  59. package/dist/features/orders/components/YourPurchaseStrip.js +77 -0
  60. package/dist/features/orders/components/index.d.ts +2 -1
  61. package/dist/features/orders/components/index.js +1 -1
  62. package/dist/features/orders/utils/order-announcement-message.d.ts +18 -0
  63. package/dist/features/orders/utils/order-announcement-message.js +16 -0
  64. package/dist/features/orders/utils/payment-proof-message.d.ts +20 -0
  65. package/dist/features/orders/utils/payment-proof-message.js +17 -0
  66. package/dist/features/parts/schemas/firestore.d.ts +227 -0
  67. package/dist/features/parts/schemas/firestore.js +6 -6
  68. package/dist/features/pre-orders/components/MarketplacePreorderCard.d.ts +11 -1
  69. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +10 -3
  70. package/dist/features/products/api/[id]/route.js +8 -1
  71. package/dist/features/products/api/route.js +16 -4
  72. package/dist/features/products/components/AvailabilityScopeHint.js +1 -1
  73. package/dist/features/products/components/InteractiveProductCard.d.ts +7 -1
  74. package/dist/features/products/components/InteractiveProductCard.js +16 -4
  75. package/dist/features/products/components/MarketplaceBundleCard.d.ts +11 -1
  76. package/dist/features/products/components/MarketplaceBundleCard.js +9 -2
  77. package/dist/features/products/components/MarketplacePrizeDrawCard.d.ts +11 -1
  78. package/dist/features/products/components/MarketplacePrizeDrawCard.js +9 -2
  79. package/dist/features/products/components/ProductDetailPageView.js +27 -2
  80. package/dist/features/products/components/ProductGrid.d.ts +14 -1
  81. package/dist/features/products/components/ProductGrid.js +22 -5
  82. package/dist/features/products/components/ProductsIndexListing.js +2 -2
  83. package/dist/features/reviews/api/[id]/route.js +83 -23
  84. package/dist/features/reviews/components/PurchaseReviewAffordance.d.ts +6 -0
  85. package/dist/features/reviews/components/PurchaseReviewAffordance.js +42 -0
  86. package/dist/features/reviews/components/ReviewFormDrawer.d.ts +34 -0
  87. package/dist/features/reviews/components/ReviewFormDrawer.js +127 -0
  88. package/dist/features/reviews/components/index.d.ts +4 -0
  89. package/dist/features/reviews/components/index.js +2 -0
  90. package/dist/features/seller/actions/seller-actions.d.ts +1 -1
  91. package/dist/features/seller/actions/seller-actions.js +49 -3
  92. package/dist/features/seller/components/SellerPayoutSettingsView.js +30 -2
  93. package/dist/features/seller/components/SellerProductShell.d.ts +17 -1
  94. package/dist/features/seller/hooks/useStoreListingEntitlements.d.ts +8 -0
  95. package/dist/features/seller/hooks/useStoreListingEntitlements.js +70 -0
  96. package/dist/features/stores/components/InteractiveStoreCard.d.ts +11 -1
  97. package/dist/features/stores/components/InteractiveStoreCard.js +9 -2
  98. package/dist/features/stores/components/index.d.ts +0 -2
  99. package/dist/features/stores/components/index.js +0 -1
  100. package/dist/features/stores/schemas/index.d.ts +1 -1
  101. package/dist/features/stores/schemas/index.js +10 -1
  102. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +403 -0
  103. package/dist/index.d.ts +8 -27
  104. package/dist/index.js +14 -35
  105. package/dist/next/routing/route-map.d.ts +2 -0
  106. package/dist/next/routing/route-map.js +9 -0
  107. package/dist/providers/shipping-shiprocket/index.d.ts +98 -0
  108. package/dist/providers/shipping-shiprocket/index.js +353 -0
  109. package/dist/schemas/registry.d.ts +2 -2
  110. package/dist/security/csp.d.ts +16 -1
  111. package/dist/security/csp.js +5 -3
  112. package/dist/seed/_helpers/category-forest.js +27 -0
  113. package/dist/seed/_helpers/category-parts-glossary-map.js +1816 -86
  114. package/dist/seed/_helpers/category-parts.d.ts +1 -1
  115. package/dist/seed/_helpers/category-parts.js +13412 -3020
  116. package/dist/seed/categories-seed-data.js +48 -6
  117. package/dist/seed/homepage-sections-seed-data.js +41 -28
  118. package/dist/seed/model-composition-seed-data.js +6 -8
  119. package/dist/seed/part-glossary/chunk0.d.ts +9 -0
  120. package/dist/seed/part-glossary/chunk0.js +35044 -0
  121. package/dist/seed/part-glossary/chunk1.d.ts +9 -0
  122. package/dist/seed/part-glossary/chunk1.js +39955 -0
  123. package/dist/seed/part-glossary/chunk2.d.ts +9 -0
  124. package/dist/seed/part-glossary/chunk2.js +50335 -0
  125. package/dist/seed/part-glossary/chunk3.d.ts +9 -0
  126. package/dist/seed/part-glossary/chunk3.js +52386 -0
  127. package/dist/seed/part-glossary/chunk4.d.ts +9 -0
  128. package/dist/seed/part-glossary/chunk4.js +50549 -0
  129. package/dist/seed/part-glossary-seed-data.d.ts +1 -1
  130. package/dist/seed/part-glossary-seed-data.js +23 -47483
  131. package/dist/server.d.ts +4 -8
  132. package/dist/server.js +8 -20
  133. package/dist/styles.css +516 -358
  134. package/dist/tailwind-utilities.css +1 -1
  135. package/dist/tokens/themes/index.d.ts +3 -1
  136. package/dist/tokens/themes/index.js +11 -1
  137. package/dist/tokens/themes/technical-dark.d.ts +30 -0
  138. package/dist/tokens/themes/technical-dark.js +103 -0
  139. package/dist/tokens/themes/technical-light.d.ts +22 -0
  140. package/dist/tokens/themes/technical-light.js +89 -0
  141. package/dist/tokens/tokens.css +155 -0
  142. package/dist/ui/components/GatedPrice.js +30 -3
  143. package/dist/ui/components/GatedPrice.style.css +22 -19
  144. package/dist/ui/components/QuickCreateModal.style.css +78 -78
  145. package/dist/ui/forms/FormShell.style.css +260 -260
  146. package/package.json +1 -1
  147. package/dist/_internal/server/jobs/core/whatsappCatalogImport.d.ts +0 -27
  148. package/dist/_internal/server/jobs/core/whatsappCatalogImport.js +0 -172
  149. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +0 -35
  150. package/dist/_internal/server/jobs/core/whatsappNotify.js +0 -140
  151. package/dist/features/admin/components/AdminWhatsAppGuideView.d.ts +0 -2
  152. package/dist/features/admin/components/AdminWhatsAppGuideView.js +0 -35
  153. package/dist/features/orders/components/MarketplaceOrderCard.d.ts +0 -36
  154. package/dist/features/orders/components/MarketplaceOrderCard.js +0 -57
  155. package/dist/features/stores/components/StoreWhatsAppGuideView.d.ts +0 -3
  156. package/dist/features/stores/components/StoreWhatsAppGuideView.js +0 -12
  157. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +0 -12
  158. package/dist/features/whatsapp-bot/api/send-status/route.js +0 -38
  159. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +0 -12
  160. package/dist/features/whatsapp-bot/api/webhook/route.js +0 -43
  161. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +0 -6
  162. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -270
  163. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +0 -9
  164. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +0 -7
  165. package/dist/features/whatsapp-bot/components/index.d.ts +0 -3
  166. package/dist/features/whatsapp-bot/components/index.js +0 -2
  167. package/dist/features/whatsapp-bot/helpers/payment-proof-message.d.ts +0 -14
  168. package/dist/features/whatsapp-bot/helpers/payment-proof-message.js +0 -17
  169. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +0 -52
  170. package/dist/features/whatsapp-bot/helpers/whatsapp.js +0 -283
  171. package/dist/features/whatsapp-bot/index.d.ts +0 -3
  172. package/dist/features/whatsapp-bot/index.js +0 -3
  173. package/dist/features/whatsapp-bot/manifest.d.ts +0 -2
  174. package/dist/features/whatsapp-bot/manifest.js +0 -13
  175. package/dist/features/whatsapp-bot/messages/en.json +0 -7
  176. package/dist/features/whatsapp-bot/server.d.ts +0 -7
  177. package/dist/features/whatsapp-bot/server.js +0 -7
  178. package/dist/features/whatsapp-bot/types/index.d.ts +0 -126
  179. package/dist/features/whatsapp-bot/types/index.js +0 -1
@@ -0,0 +1,39 @@
1
+ interface CityState {
2
+ city: string;
3
+ state: string;
4
+ }
5
+ /**
6
+ * What the last autofill wrote, so a later one can tell its own output apart
7
+ * from a human's typing. A field is absent when the helper declined to write
8
+ * it — which makes it the human's, and untouchable.
9
+ */
10
+ export type PostalAutofillMemo = Partial<CityState>;
11
+ /**
12
+ * Merge `resolved` into `prev`, returning the new values and an updated memo.
13
+ *
14
+ * ── 🛑 THE BUG THIS EXISTS TO FIX ─────────────────────────────────────────
15
+ *
16
+ * Both address forms used `prev.city.trim() ? prev.city : city` — fill only
17
+ * an EMPTY field. That intent is right and is preserved here: correcting an
18
+ * address someone deliberately typed is a bug they cannot report, because
19
+ * they watch their own input vanish with no idea what did it.
20
+ *
21
+ * But "empty" cannot tell WHO filled the field. After the first lookup the
22
+ * city is non-empty — written by the autofill itself — so every subsequent
23
+ * lookup is refused. Enter 413001, get Solapur/Maharashtra; correct the PIN
24
+ * to 500005 (Hyderabad) and the form still shows Solapur. The PIN and the
25
+ * city now disagree, the form validates, and the parcel ships to the wrong
26
+ * city — worse than never autofilling at all.
27
+ *
28
+ * ── The rule ──────────────────────────────────────────────────────────────
29
+ *
30
+ * A field is ours to overwrite when it is EMPTY, or when it still holds
31
+ * exactly what we last wrote into it. The moment a human edits it, it stops
32
+ * matching the memo and becomes untouchable again — the original guarantee,
33
+ * now actually enforceable rather than approximated by "is it blank".
34
+ */
35
+ export declare function applyPostalAutofill<T extends CityState>(prev: T, resolved: Partial<CityState>, memo: PostalAutofillMemo): {
36
+ next: T;
37
+ memo: PostalAutofillMemo;
38
+ };
39
+ export {};
@@ -0,0 +1,54 @@
1
+ /*
2
+ * WHY: A postal lookup must fill the city/state it knows WITHOUT overwriting
3
+ * what the person typed — and the obvious test for that ("is the field
4
+ * empty?") silently breaks every lookup after the first.
5
+ * WHAT: `applyPostalAutofill` — merge a resolved city/state into form values,
6
+ * overwriting only values this helper itself wrote earlier.
7
+ *
8
+ * EXPORTS: applyPostalAutofill, type PostalAutofillMemo
9
+ *
10
+ * @tag domain:addresses
11
+ * @tag layer:utils
12
+ * @tag pattern:pure
13
+ * @tag sideEffects:none
14
+ */
15
+ /**
16
+ * Merge `resolved` into `prev`, returning the new values and an updated memo.
17
+ *
18
+ * ── 🛑 THE BUG THIS EXISTS TO FIX ─────────────────────────────────────────
19
+ *
20
+ * Both address forms used `prev.city.trim() ? prev.city : city` — fill only
21
+ * an EMPTY field. That intent is right and is preserved here: correcting an
22
+ * address someone deliberately typed is a bug they cannot report, because
23
+ * they watch their own input vanish with no idea what did it.
24
+ *
25
+ * But "empty" cannot tell WHO filled the field. After the first lookup the
26
+ * city is non-empty — written by the autofill itself — so every subsequent
27
+ * lookup is refused. Enter 413001, get Solapur/Maharashtra; correct the PIN
28
+ * to 500005 (Hyderabad) and the form still shows Solapur. The PIN and the
29
+ * city now disagree, the form validates, and the parcel ships to the wrong
30
+ * city — worse than never autofilling at all.
31
+ *
32
+ * ── The rule ──────────────────────────────────────────────────────────────
33
+ *
34
+ * A field is ours to overwrite when it is EMPTY, or when it still holds
35
+ * exactly what we last wrote into it. The moment a human edits it, it stops
36
+ * matching the memo and becomes untouchable again — the original guarantee,
37
+ * now actually enforceable rather than approximated by "is it blank".
38
+ */
39
+ export function applyPostalAutofill(prev, resolved, memo) {
40
+ const nextMemo = {};
41
+ const merge = (field) => {
42
+ const current = prev[field];
43
+ const incoming = resolved[field];
44
+ const ours = current.trim() === "" || current === memo[field];
45
+ if (!ours || !incoming)
46
+ return current; // human's value, or nothing to say
47
+ nextMemo[field] = incoming;
48
+ return incoming;
49
+ };
50
+ return {
51
+ next: { ...prev, city: merge("city"), state: merge("state") },
52
+ memo: nextMemo,
53
+ };
54
+ }
@@ -6,11 +6,20 @@ import { useQueryClient } from "@tanstack/react-query";
6
6
  import { Checkbox, Details, Div, SideDrawer, Span, Stack, Summary, useToast } from "../../../ui/index.js";
7
7
  import { apiClient } from "../../../http/index.js";
8
8
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
9
+ import { ALL_STORE_CAPABILITIES, DEFAULT_STORE_CAPABILITIES, } from "../../auth/permissions/constants.js";
9
10
  import { adminStoreUpdateSchema } from "../schemas/admin-editor-forms.js";
10
11
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary.js";
11
12
  import { FormShellContext, useFormShellState } from "../../../ui/forms/FormShell.js";
12
13
  import { SectionForm, useSectionFormNav, buildSectionsFromSchema } from "../../shell/index.js";
13
- const DEFAULT_CAPABILITIES = ["suggest_brands", "create_coupons"];
14
+ /*
15
+ * 🛑 Imported, not restated. This value is the `??` fallback on the load path
16
+ * below, so whatever it holds is what gets SAVED BACK for a store whose
17
+ * `capabilities` field is absent. A local copy that drifted from
18
+ * `DEFAULT_STORE_CAPABILITIES` would silently rewrite entitlements on the next
19
+ * unrelated edit — Root Cause #38's re-save shape, with two owners of one
20
+ * default as the cause.
21
+ */
22
+ const DEFAULT_CAPABILITIES = [...DEFAULT_STORE_CAPABILITIES];
14
23
  /** Add or remove one capability, returning the new list. */
15
24
  function toggleCapability(current, key) {
16
25
  const next = new Set(current);
@@ -20,41 +29,71 @@ function toggleCapability(current, key) {
20
29
  next.add(key);
21
30
  return Array.from(next);
22
31
  }
23
- const CAPABILITY_GROUPS = [
24
- {
25
- label: "Listings",
26
- caps: [
27
- { key: "host_auctions", label: "Host Auctions" },
28
- { key: "host_preorders", label: "Host Pre-orders" },
29
- { key: "create_categories", label: "Request New Categories" },
30
- { key: "suggest_brands", label: "Suggest Brands" },
31
- { key: "create_coupons", label: "Create Coupons" },
32
- { key: "bulk_listing_import", label: "Bulk Listing Import" },
33
- { key: "extended_return_window", label: "Extended Return Window" },
34
- ],
35
- },
36
- {
37
- label: "Trust & Visibility",
38
- caps: [
39
- { key: "verified_seller", label: "Verified Seller Badge" },
40
- { key: "featured_placement", label: "Featured Placement" },
41
- { key: "promotional_banner", label: "Promotional Banner" },
42
- { key: "priority_support", label: "Priority Support" },
43
- ],
44
- },
45
- {
46
- label: "Platform",
47
- caps: [
48
- { key: "multiple_stores", label: "Multiple Stores" },
49
- { key: "custom_store_slug", label: "Custom Store Slug" },
50
- { key: "api_access", label: "API Access" },
51
- { key: "lower_commission_rate", label: "Lower Commission Rate" },
52
- { key: "early_access_features", label: "Early Access Features" },
53
- { key: "advanced_analytics", label: "Advanced Analytics" },
54
- { key: "whatsapp_catalog_sync", label: "WhatsApp Catalog Sync" },
55
- ],
32
+ /**
33
+ * The three capability groups, named once.
34
+ *
35
+ * A literal repeated per capability is how a group silently splits in two the
36
+ * day someone types "Trust and Visibility" in one of nineteen entries — the
37
+ * `CAPABILITY_GROUPS` derivation below filters on equality, so the typo'd entry
38
+ * would land in no rendered group at all and that capability would vanish from
39
+ * the admin UI without any error.
40
+ */
41
+ const CAP_GROUP = {
42
+ LISTINGS: "Listings",
43
+ TRUST: "Trust & Visibility",
44
+ PLATFORM: "Platform",
45
+ };
46
+ /**
47
+ * Every capability's group and label.
48
+ *
49
+ * 🛑 `Record<StoreCapability, …>`, so a capability added to the union MUST be
50
+ * given a home here or this file does not compile. The groups used to be three
51
+ * hand-written arrays, which meant a new capability could be granted in code,
52
+ * checked by a server gate, and remain **unreachable from the admin UI** — the
53
+ * only place it can be turned on. That is the whole drift class in one
54
+ * sentence, and it had already happened: `advanced_listing_types` is the
55
+ * nineteenth member and the arrays listed eighteen.
56
+ */
57
+ const CAPABILITY_META = {
58
+ advanced_listing_types: {
59
+ group: CAP_GROUP.LISTINGS,
60
+ label: "Advanced Listing Types (pre-orders, prize draws, classifieds, digital codes, live, art, stickers)",
56
61
  },
62
+ // Legacy: no longer checked anywhere — `auction` is a basic type now.
63
+ host_auctions: { group: CAP_GROUP.LISTINGS, label: "Host Auctions (legacy — no longer required)" },
64
+ // Legacy per-type grant, still honoured so existing stores keep access.
65
+ host_preorders: { group: CAP_GROUP.LISTINGS, label: "Host Pre-orders (legacy grant)" },
66
+ create_categories: { group: CAP_GROUP.LISTINGS, label: "Request New Categories" },
67
+ suggest_brands: { group: CAP_GROUP.LISTINGS, label: "Suggest Brands" },
68
+ create_coupons: { group: CAP_GROUP.LISTINGS, label: "Create Coupons" },
69
+ bulk_listing_import: { group: CAP_GROUP.LISTINGS, label: "Bulk Listing Import" },
70
+ extended_return_window: { group: CAP_GROUP.LISTINGS, label: "Extended Return Window" },
71
+ verified_seller: { group: CAP_GROUP.TRUST, label: "Verified Seller Badge" },
72
+ featured_placement: { group: CAP_GROUP.TRUST, label: "Featured Placement" },
73
+ promotional_banner: { group: CAP_GROUP.TRUST, label: "Promotional Banner" },
74
+ priority_support: { group: CAP_GROUP.TRUST, label: "Priority Support" },
75
+ multiple_stores: { group: CAP_GROUP.PLATFORM, label: "Multiple Stores" },
76
+ custom_store_slug: { group: CAP_GROUP.PLATFORM, label: "Custom Store Slug" },
77
+ api_access: { group: CAP_GROUP.PLATFORM, label: "API Access" },
78
+ lower_commission_rate: { group: CAP_GROUP.PLATFORM, label: "Lower Commission Rate" },
79
+ early_access_features: { group: CAP_GROUP.PLATFORM, label: "Early Access Features" },
80
+ advanced_analytics: { group: CAP_GROUP.PLATFORM, label: "Advanced Analytics" },
81
+ whatsapp_catalog_sync: { group: CAP_GROUP.PLATFORM, label: "WhatsApp Catalog Sync" },
82
+ };
83
+ const CAPABILITY_GROUP_ORDER = [
84
+ CAP_GROUP.LISTINGS,
85
+ CAP_GROUP.TRUST,
86
+ CAP_GROUP.PLATFORM,
57
87
  ];
88
+ /**
89
+ * Derived, so every capability appears in exactly one group by construction.
90
+ * Keeps the `{ label, caps: [{ key, label }] }` shape the renderer below
91
+ * already expects.
92
+ */
93
+ const CAPABILITY_GROUPS = CAPABILITY_GROUP_ORDER.map((group) => ({
94
+ label: group,
95
+ caps: ALL_STORE_CAPABILITIES.filter((key) => CAPABILITY_META[key].group === group).map((key) => ({ key, label: CAPABILITY_META[key].label })),
96
+ }));
58
97
  const STATUS_OPTIONS = [
59
98
  { label: "Active", value: "active" },
60
99
  { label: "Pending", value: "pending" },
@@ -0,0 +1,29 @@
1
+ /** Which registry bucket supplies the labels and the confirm copy. */
2
+ export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW";
3
+ export interface OwnerQuickActionsProps {
4
+ resource: OwnerQuickActionsResource;
5
+ /** Where the pencil goes — that record's dashboard editor. */
6
+ editHref: string;
7
+ /** Called after the confirm is accepted. Must do the DELETE itself. */
8
+ onDelete?: () => void | Promise<void>;
9
+ /**
10
+ * Admin-only clone. Omit entirely for resources with no duplicate endpoint —
11
+ * categories, bundles, carousel slides and homepage sections all have
12
+ * derived or positional state that a copy would carry wrongly, so the
13
+ * absence is deliberate rather than unfinished.
14
+ */
15
+ onClone?: () => void | Promise<void>;
16
+ /** Auth UID of the owner, when the record has one. Admins pass regardless. */
17
+ ownerId?: string;
18
+ /** Store SLUG for store-owned records — `SessionUser.storeId` is the slug. */
19
+ storeId?: string;
20
+ /** Names the thing in the confirm dialog: "Delete Dranzer S?" */
21
+ recordLabel?: string;
22
+ /**
23
+ * `card` sits in a hero corner and uses a scrim so it reads on photography;
24
+ * `detail` sits in a header row beside the title.
25
+ */
26
+ placement?: "card" | "detail";
27
+ className?: string;
28
+ }
29
+ export declare function OwnerQuickActions({ resource, editHref, onDelete, onClone, ownerId, storeId, recordLabel, placement, className, }: OwnerQuickActionsProps): import("react").JSX.Element | null;
@@ -0,0 +1,142 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * Pencil / clone / bin, on a PUBLIC surface, for whoever owns the record.
5
+ *
6
+ * ── What this is for ──────────────────────────────────────────────────────
7
+ *
8
+ * An admin or seller spots a problem on the page a visitor sees — a wrong
9
+ * price, a typo, a bad photo — and today has to navigate to the dashboard,
10
+ * find the list, search for the row and open it. This is the two-click path to
11
+ * the editor from where the problem was noticed.
12
+ *
13
+ * ── Why it is a new component and not a slot on each card ─────────────────
14
+ *
15
+ * 🛑 Measured before building: ZERO public detail views render any owner-only
16
+ * control, and the only precedent anywhere is `CouponCard`, which has had
17
+ * `onEdit` / `onToggleActive` / `onDelete` props the whole time and whose two
18
+ * public consumers pass NONE of them. So there was no pattern to follow and no
19
+ * second implementation to reconcile — one component, used by every surface,
20
+ * is what keeps the visibility rule and the confirm copy in one place.
21
+ *
22
+ * ── The visibility rule, and why it has three states ──────────────────────
23
+ *
24
+ * 🛑 Public pages are static or ISR (`products/[slug]` is `revalidate = 3600`
25
+ * and reads no session), so `SessionProvider` mounts `initialUser={null}` and
26
+ * `loading` starts TRUE on every hard load — for signed-in users too. A
27
+ * two-state `!user → hide` gate therefore renders nothing, then pops the
28
+ * toolbar in a few hundred milliseconds later, on every single page load.
29
+ *
30
+ * So: render NOTHING while `isAuthResolving`. Appearing late is invisible;
31
+ * appearing with a flash is a bug people report. Same reasoning as
32
+ * `useCanSeePrices`, which exists for the mirror-image problem.
33
+ *
34
+ * ── Who sees what ─────────────────────────────────────────────────────────
35
+ *
36
+ * pencil + bin the record's owner, or any admin
37
+ * clone admins ONLY
38
+ *
39
+ * 🛑 Clone is gated on `useIsAdmin()`, NOT on the same check as the other two.
40
+ * `useIsOwner` returns `true` for admins AND for the owner, so reusing it
41
+ * would hand every seller a button that is meant to be a staff tool.
42
+ *
43
+ * @tag domain:admin
44
+ * @tag layer:components
45
+ * @tag access:client
46
+ */
47
+ import { useCallback, useState } from "react";
48
+ import { useIsAdmin, useIsOwner } from "../../auth/hooks/useRBAC.js";
49
+ import { useOptionalSession } from "../../../react/contexts/SessionContext.js";
50
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
51
+ import { ConfirmDeleteModal } from "../../../ui/components/ConfirmDeleteModal.js";
52
+ import { Icon } from "../../../ui/components/Icon.js";
53
+ import { IconButton } from "../../../ui/components/IconButton.js";
54
+ import { Row, useToast } from "../../../ui/index.js";
55
+ import { normalizeError } from "../../../errors/normalize.js";
56
+ import { toUserMessage } from "../../../errors/error-display-map.js";
57
+ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, ownerId, storeId, recordLabel, placement = "card", className = "", }) {
58
+ const session = useOptionalSession();
59
+ const isOwnerOrAdmin = useIsOwner(ownerId);
60
+ const isAdmin = useIsAdmin();
61
+ const { showToast } = useToast();
62
+ const [confirmOpen, setConfirmOpen] = useState(false);
63
+ const [deleting, setDeleting] = useState(false);
64
+ const bucket = ACTIONS[resource];
65
+ const handleConfirmedDelete = useCallback(async () => {
66
+ if (!onDelete)
67
+ return;
68
+ setDeleting(true);
69
+ try {
70
+ await onDelete();
71
+ setConfirmOpen(false);
72
+ }
73
+ catch (err) {
74
+ const normalized = normalizeError(err);
75
+ /*
76
+ * 🛑 A failed delete MUST say so. Without this the dialog simply closed
77
+ * its spinner and the record was still there — and because the caller
78
+ * navigates away on success, "nothing happened" is indistinguishable
79
+ * from "it worked but the page did not move". This is a destructive
80
+ * action on a public page; silence is the worst available answer.
81
+ */
82
+ showToast(toUserMessage(normalized.code ?? "UNKNOWN"), "error");
83
+ }
84
+ finally {
85
+ // Always clears, so a failed delete leaves a usable dialog rather than a
86
+ // permanently-spinning button the user cannot dismiss or retry.
87
+ setDeleting(false);
88
+ }
89
+ }, [onDelete, showToast]);
90
+ /*
91
+ * 🛑 Nothing at all until auth resolves — see the header. `loading` starts
92
+ * true on every hard load of a static page, so this is the common path on
93
+ * first paint, not an edge case.
94
+ */
95
+ if (!session || session.loading)
96
+ return null;
97
+ /*
98
+ * A store-owned record is also "mine" when I own that store.
99
+ * `SessionUser.storeId` holds the store SLUG and `StoreDocument.storeId` is
100
+ * the slug too, while `ownerId` is the Auth UID — two different id spaces,
101
+ * so both have to be checked rather than one standing in for the other.
102
+ */
103
+ const ownsStore = Boolean(storeId && session.user?.storeId === storeId);
104
+ /*
105
+ * 🛑 `isAdmin` is its OWN term, not folded into `useIsOwner`.
106
+ *
107
+ * `useIsOwner` early-returns `false` when `resourceOwnerId` is falsy — BEFORE
108
+ * it checks admin — so for a record with no owner at all it denies admins
109
+ * too. Categories, brands, franchises, carousel slides and homepage sections
110
+ * have no `ownerId`, and those are precisely the collections an admin needs
111
+ * this toolbar on. Relying on `useIsOwner` alone would have hidden it from
112
+ * the only person who can edit them.
113
+ */
114
+ if (!isAdmin && !isOwnerOrAdmin && !ownsStore)
115
+ return null;
116
+ const editDef = bucket.edit;
117
+ const deleteDef = bucket.delete;
118
+ const confirmation = deleteDef?.confirmation;
119
+ const showClone = Boolean(onClone) && isAdmin;
120
+ return (_jsxs(_Fragment, { children: [_jsxs(Row, { gap: "xs", align: "center", className: className,
121
+ /*
122
+ * Stops the click reaching an ancestor `<Link>` or the card's own
123
+ * `onClick`. Without it, pressing Edit on a card both navigates to the
124
+ * editor AND opens the public detail page behind it.
125
+ */
126
+ onClick: (e) => {
127
+ e.stopPropagation();
128
+ }, children: [_jsx(IconButton, { "aria-label": editDef?.ariaLabel ?? editDef?.label ?? "Edit", size: "touch", variant: placement === "card" ? "scrim" : "ghost", icon: _jsx(Icon, { name: "edit" }), onClick: () => {
129
+ /*
130
+ * `window.location`, not `next/link`. This component is rendered
131
+ * inside cards whose whole body is already an anchor, and nesting
132
+ * an `<a>` in an `<a>` is invalid HTML that browsers resolve by
133
+ * closing the outer one early — which breaks the card's own
134
+ * navigation, not just this button's.
135
+ */
136
+ if (typeof window !== "undefined")
137
+ window.location.assign(editHref);
138
+ } }), showClone && (_jsx(IconButton, { "aria-label": (bucket.duplicate?.ariaLabel ?? bucket.duplicate?.label) ||
139
+ "Duplicate", size: "touch", variant: placement === "card" ? "scrim" : "ghost", icon: _jsx(Icon, { name: "duplicate" }), onClick: () => void onClone?.() })), onDelete && (_jsx(IconButton, { "aria-label": deleteDef?.ariaLabel ?? deleteDef?.label ?? "Delete", size: "touch", variant: placement === "card" ? "scrim" : "danger", icon: _jsx(Icon, { name: "delete" }), onClick: () => setConfirmOpen(true) }))] }), onDelete && (_jsx(ConfirmDeleteModal, { isOpen: confirmOpen, onClose: () => setConfirmOpen(false), onConfirm: handleConfirmedDelete, isDeleting: deleting, title: confirmation?.title ?? "Delete this record?", message: recordLabel && confirmation
140
+ ? `${confirmation.body}\n\nDeleting: ${recordLabel}`
141
+ : confirmation?.body, confirmText: confirmation?.confirmLabel ?? deleteDef?.label ?? "Delete", variant: "danger" }))] }));
142
+ }
@@ -12,7 +12,7 @@ export declare const adminStoreUpdateSchema: z.ZodEffects<z.ZodObject<{
12
12
  isVerified: z.ZodOptional<z.ZodBoolean>;
13
13
  isFeatured: z.ZodOptional<z.ZodBoolean>;
14
14
  suspensionReason: z.ZodOptional<z.ZodString>;
15
- capabilities: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
15
+ capabilities: z.ZodOptional<z.ZodArray<z.ZodEnum<[string, ...string[]]>, "many">>;
16
16
  adminNotes: z.ZodOptional<z.ZodString>;
17
17
  }, "strip", z.ZodTypeAny, {
18
18
  storeStatus: StoreStatus;
@@ -29,6 +29,7 @@ import { z } from "zod";
29
29
  import { annotate } from "../../shell/field-ui-meta.js";
30
30
  import { mediaUrlSchema } from "../../../validation/schemas.js";
31
31
  import { StoreStatusValues } from "../../stores/schemas/firestore.js";
32
+ import { ALL_STORE_CAPABILITIES } from "../../auth/permissions/constants.js";
32
33
  const PHONE_RE = /^(\+?91[-\s]?)?[6-9]\d{9}$/;
33
34
  /*
34
35
  * `adminAddressFormSchema` is DELETED (W5 / D19).
@@ -84,7 +85,9 @@ export const adminStoreUpdateSchema = z
84
85
  section: "standing", order: 4, row: "full", kind: "textarea",
85
86
  when: (v) => v.storeStatus === "suspended",
86
87
  }),
87
- capabilities: annotate(z.array(z.string().min(1)).max(50).optional(), {
88
+ // 🛑 z.enum, not z.string().min(1) — see storeFirestoreSchema. A typo'd
89
+ // capability used to save cleanly and grant nothing.
90
+ capabilities: annotate(z.array(z.enum(ALL_STORE_CAPABILITIES)).max(50).optional(), {
88
91
  section: "capabilities", sectionLabel: "Capabilities", order: 1, row: "full", kind: "list",
89
92
  }),
90
93
  adminNotes: annotate(z.string().trim().max(2000).optional(), {
@@ -74,5 +74,15 @@ export interface MarketplaceAuctionCardProps {
74
74
  onNavigate?: (href: string) => void;
75
75
  wishlistActions?: WishlistToggleActions;
76
76
  labels?: MarketplaceAuctionCardLabels;
77
+ /**
78
+ * Owner-only pencil / clone / bin. Pass `<OwnerQuickActions>`; it renders
79
+ * nothing for a visitor who does not own the record.
80
+ *
81
+ * 🛑 Rendered OUTSIDE this card's own anchor, as a sibling. A `<button>`
82
+ * inside an `<a>` is invalid HTML that browsers resolve by closing the
83
+ * anchor early, which breaks the CARD's navigation rather than just the
84
+ * button's — `stopPropagation` stops a click but cannot fix a parse.
85
+ */
86
+ renderOwnerActions?: () => React.ReactNode;
77
87
  }
78
- export declare function MarketplaceAuctionCard({ product, buyoutPrice, className, variant, selectable, isSelected, onSelect, inWishlist: initialInWishlist, href, hrefBuilder, onNavigate, wishlistActions, labels, }: MarketplaceAuctionCardProps): import("react").JSX.Element;
88
+ export declare function MarketplaceAuctionCard({ product, buyoutPrice, className, variant, selectable, isSelected, onSelect, inWishlist: initialInWishlist, href, hrefBuilder, onNavigate, wishlistActions, labels, renderOwnerActions, }: MarketplaceAuctionCardProps): import("react").JSX.Element;
@@ -94,7 +94,7 @@ function formatCountdownLabel(remaining, endedLabel) {
94
94
  return `${hours}h ${minutes}m ${seconds}s`;
95
95
  return `${minutes}m ${seconds}s`;
96
96
  }
97
- export function MarketplaceAuctionCard({ product, buyoutPrice, className = "", variant = "grid", selectable = false, isSelected = false, onSelect, inWishlist: initialInWishlist = false, href, hrefBuilder, onNavigate, wishlistActions, labels, }) {
97
+ export function MarketplaceAuctionCard({ product, buyoutPrice, className = "", variant = "grid", selectable = false, isSelected = false, onSelect, inWishlist: initialInWishlist = false, href, hrefBuilder, onNavigate, wishlistActions, labels, renderOwnerActions, }) {
98
98
  const router = useRouter();
99
99
  const mergedLabels = { ...DEFAULT_LABELS, ...labels };
100
100
  const baseVariant = variant === "list" ? "list" : "grid";
@@ -180,5 +180,12 @@ export function MarketplaceAuctionCard({ product, buyoutPrice, className = "", v
180
180
  remaining, resolvedBuyoutPrice, inWishlist, wishlistLoading, wishlistActions,
181
181
  countdownClass, mergedLabels, handleWishlist, handleNavigate,
182
182
  };
183
- return (_jsxs(BaseListingCard, { isSelected: isSelected, isDisabled: isEnded, variant: baseVariant, className: className, onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: !isSelected ? longPress.onTouchCancel : undefined, children: [renderAuctionCardHero({ product, auctionHref, currentSrc, hovered, images, imageIndex, hasVideo, isEnded, isEndingSoon, isSelected, selectable, mergedLabels, onSelect, setHovered, handleSelect }), _jsx(BaseListingCard.Info, { variant: baseVariant, children: baseVariant === "list" ? renderAuctionCardInfoList(cardInfoProps) : renderAuctionCardInfoGrid(cardInfoProps) })] }));
183
+ const card = (_jsxs(BaseListingCard, { isSelected: isSelected, isDisabled: isEnded, variant: baseVariant, className: className, onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: !isSelected ? longPress.onTouchCancel : undefined, children: [renderAuctionCardHero({ product, auctionHref, currentSrc, hovered, images, imageIndex, hasVideo, isEnded, isEndingSoon, isSelected, selectable, mergedLabels, onSelect, setHovered, handleSelect }), _jsx(BaseListingCard.Info, { variant: baseVariant, children: baseVariant === "list" ? renderAuctionCardInfoList(cardInfoProps) : renderAuctionCardInfoGrid(cardInfoProps) })] }));
184
+ if (!renderOwnerActions)
185
+ return card;
186
+ /*
187
+ * Sibling of the card, never a child — see the prop's docstring. `z-20`
188
+ * clears this card's own overlays, which all sit at `z-10`.
189
+ */
190
+ return (_jsxs(Div, { className: "relative h-full", children: [card, _jsx(Div, { className: "absolute right-2 bottom-2 z-20", children: renderOwnerActions() })] }));
184
191
  }
@@ -67,8 +67,18 @@ export declare const ROUTE_PERMISSION_MAP: Record<string, Permission>;
67
67
  *
68
68
  * Defaults on store creation: ["suggest_brands", "create_coupons"]
69
69
  */
70
- export type StoreCapability = "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics" | "whatsapp_catalog_sync";
71
- /** Default capabilities granted to every new store on creation. */
70
+ export type StoreCapability = "advanced_listing_types" | "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics" | "whatsapp_catalog_sync";
71
+ export declare const ALL_STORE_CAPABILITIES: StoreCapability[];
72
+ /** Narrow an untrusted string to a real capability. */
73
+ export declare function isStoreCapability(value: unknown): value is StoreCapability;
74
+ /**
75
+ * Default capabilities granted to every new store on creation.
76
+ *
77
+ * 🛑 THE one definition. `AdminStoreEditorView` hardcoded its own copy, which
78
+ * is also the `??` fallback on its load path — so the two could disagree about
79
+ * what a store with no `capabilities` field is entitled to, and the admin
80
+ * editor's copy is the one that gets SAVED BACK. Import this; never restate it.
81
+ */
72
82
  export declare const DEFAULT_STORE_CAPABILITIES: StoreCapability[];
73
83
  /**
74
84
  * Check whether a permission array includes a required permission.
@@ -87,6 +97,23 @@ export declare function hasAllPermissions(permissions: Permission[], required: P
87
97
  * Check whether a store's capabilities include a specific capability.
88
98
  */
89
99
  export declare function hasCapability(capabilities: StoreCapability[], required: StoreCapability): boolean;
100
+ /**
101
+ * Resolve a store's stored `capabilities` array to its effective set.
102
+ *
103
+ * 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
104
+ * already holds the store document does not pay a second Firestore read to ask
105
+ * what it is entitled to (Rule #6). Three WhatsApp routes were reading
106
+ * `store.capabilities ?? []` inline for exactly that reason, which made them a
107
+ * second, divergent reader of the field — they skipped the default merge, so
108
+ * they and `getStoreCapabilities` could disagree about the same store.
109
+ *
110
+ * Note the empty-array case falls back to the defaults, matching
111
+ * `getStoreCapabilities`' long-standing `raw.length > 0` test. That is
112
+ * arguably the wrong call — an admin who deliberately unticks everything gets
113
+ * the defaults handed back — but it is the established behaviour and changing
114
+ * it would silently re-grant capabilities on stores that had them removed.
115
+ */
116
+ export declare function resolveStoreCapabilities(raw: readonly string[] | null | undefined): StoreCapability[];
90
117
  /**
91
118
  * Groups permission prefixes into human-labeled domains for display —
92
119
  * used by the employee-permission editor and the read-only permissions
@@ -319,7 +319,53 @@ export const ROUTE_PERMISSION_MAP = {
319
319
  "/admin/maintenance/payment-rollbacks": "admin:maintenance:view-payment-rollbacks",
320
320
  "/admin/maintenance/analysis": "admin:maintenance:run-analysis",
321
321
  };
322
- /** Default capabilities granted to every new store on creation. */
322
+ /**
323
+ * Every capability, as a runtime value.
324
+ *
325
+ * 🛑 `Record<StoreCapability, true>` rather than a plain array, for the same
326
+ * reason `ALL_LISTING_TYPES_MAP` is: adding a member to the union without
327
+ * adding it here is then a COMPILE error, not a silent omission. This is what
328
+ * lets `capabilities` be validated as a `z.enum` instead of
329
+ * `z.array(z.string())` — which accepted any string, so a typo'd capability
330
+ * saved successfully and the gate it was meant to open never opened, silently.
331
+ *
332
+ * It also backs the admin editor's grouping, so a new capability cannot be
333
+ * granted in code and remain unreachable from the admin UI.
334
+ */
335
+ const STORE_CAPABILITY_MAP = {
336
+ advanced_listing_types: true,
337
+ host_auctions: true,
338
+ host_preorders: true,
339
+ create_categories: true,
340
+ suggest_brands: true,
341
+ create_coupons: true,
342
+ bulk_listing_import: true,
343
+ extended_return_window: true,
344
+ verified_seller: true,
345
+ featured_placement: true,
346
+ promotional_banner: true,
347
+ priority_support: true,
348
+ multiple_stores: true,
349
+ custom_store_slug: true,
350
+ api_access: true,
351
+ lower_commission_rate: true,
352
+ early_access_features: true,
353
+ advanced_analytics: true,
354
+ whatsapp_catalog_sync: true,
355
+ };
356
+ export const ALL_STORE_CAPABILITIES = Object.keys(STORE_CAPABILITY_MAP);
357
+ /** Narrow an untrusted string to a real capability. */
358
+ export function isStoreCapability(value) {
359
+ return typeof value === "string" && value in STORE_CAPABILITY_MAP;
360
+ }
361
+ /**
362
+ * Default capabilities granted to every new store on creation.
363
+ *
364
+ * 🛑 THE one definition. `AdminStoreEditorView` hardcoded its own copy, which
365
+ * is also the `??` fallback on its load path — so the two could disagree about
366
+ * what a store with no `capabilities` field is entitled to, and the admin
367
+ * editor's copy is the one that gets SAVED BACK. Import this; never restate it.
368
+ */
323
369
  export const DEFAULT_STORE_CAPABILITIES = [
324
370
  "suggest_brands",
325
371
  "create_coupons",
@@ -352,6 +398,27 @@ export function hasAllPermissions(permissions, required) {
352
398
  export function hasCapability(capabilities, required) {
353
399
  return capabilities.includes(required);
354
400
  }
401
+ /**
402
+ * Resolve a store's stored `capabilities` array to its effective set.
403
+ *
404
+ * 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
405
+ * already holds the store document does not pay a second Firestore read to ask
406
+ * what it is entitled to (Rule #6). Three WhatsApp routes were reading
407
+ * `store.capabilities ?? []` inline for exactly that reason, which made them a
408
+ * second, divergent reader of the field — they skipped the default merge, so
409
+ * they and `getStoreCapabilities` could disagree about the same store.
410
+ *
411
+ * Note the empty-array case falls back to the defaults, matching
412
+ * `getStoreCapabilities`' long-standing `raw.length > 0` test. That is
413
+ * arguably the wrong call — an admin who deliberately unticks everything gets
414
+ * the defaults handed back — but it is the established behaviour and changing
415
+ * it would silently re-grant capabilities on stores that had them removed.
416
+ */
417
+ export function resolveStoreCapabilities(raw) {
418
+ if (Array.isArray(raw) && raw.length > 0)
419
+ return raw;
420
+ return [...DEFAULT_STORE_CAPABILITIES];
421
+ }
355
422
  // ============================================================================
356
423
  // PERMISSION DOMAINS — display grouping (S-ADMIN-7, 2026-08-19)
357
424
  // ============================================================================
@@ -17,5 +17,15 @@ export interface BlogFeaturedCardProps {
17
17
  showAuthor?: boolean;
18
18
  /** Show the cover image. Defaults to `true`. */
19
19
  showThumbnail?: boolean;
20
+ /**
21
+ * Owner-only pencil / clone / bin, for an admin or whoever owns this record.
22
+ * Pass `<OwnerQuickActions>`; it renders nothing for anyone else.
23
+ *
24
+ * 🛑 Rendered OUTSIDE this card's own `<Link>`/`<TextLink>`, as a sibling.
25
+ * A `<button>` inside an `<a>` is invalid HTML that browsers resolve by
26
+ * closing the anchor early, which breaks the CARD's navigation rather than
27
+ * just the button's — `stopPropagation` stops a click but cannot fix a parse.
28
+ */
29
+ renderOwnerActions?: () => React.ReactNode;
20
30
  }
21
- export declare function BlogFeaturedCard({ post, href, labels, className, selectable, isSelected, onSelect, showReadTime, showAuthor, showThumbnail, }: BlogFeaturedCardProps): React.JSX.Element;
31
+ export declare function BlogFeaturedCard({ post, href, labels, className, selectable, isSelected, onSelect, showReadTime, showAuthor, showThumbnail, renderOwnerActions, }: BlogFeaturedCardProps): React.JSX.Element;