@mohasinac/appkit 4.55.0 → 4.57.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 (86) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/actions.d.ts +53 -0
  3. package/dist/_internal/server/features/checkout/actions.js +156 -8
  4. package/dist/_internal/server/features/orders/actions.d.ts +30 -0
  5. package/dist/_internal/server/features/orders/actions.js +103 -0
  6. package/dist/_internal/server/features/orders/adapters.js +7 -0
  7. package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
  8. package/dist/_internal/server/features/shipping/reconcile.js +93 -0
  9. package/dist/_internal/shared/actions/action-registry.js +53 -0
  10. package/dist/_internal/shared/config/schema.d.ts +6 -0
  11. package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
  12. package/dist/_internal/shared/listing-types/route-gating.js +114 -0
  13. package/dist/_internal/shared/shipping/quote.d.ts +153 -0
  14. package/dist/_internal/shared/shipping/quote.js +123 -0
  15. package/dist/client.d.ts +22 -0
  16. package/dist/client.js +10 -0
  17. package/dist/features/about/components/PublicProfileView.js +2 -1
  18. package/dist/features/account/components/UserOrdersView.js +25 -3
  19. package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
  20. package/dist/features/admin/components/OwnerQuickActions.js +53 -6
  21. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  22. package/dist/features/auth/schemas/firestore.d.ts +15 -0
  23. package/dist/features/blog/components/BlogPostView.js +2 -1
  24. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  25. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  26. package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
  27. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
  28. package/dist/features/faq/components/FAQPageContent.js +17 -4
  29. package/dist/features/faq/constants/help-guides.d.ts +36 -0
  30. package/dist/features/faq/constants/help-guides.js +64 -0
  31. package/dist/features/homepage/components/HeroCarousel.js +7 -3
  32. package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
  33. package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
  34. package/dist/features/homepage/components/WelcomeSection.js +3 -3
  35. package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
  36. package/dist/features/homepage/lib/section-renderer.js +24 -6
  37. package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
  38. package/dist/features/homepage/lib/slide-gating.js +98 -0
  39. package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
  40. package/dist/features/media/MediaAvatar.js +1 -1
  41. package/dist/features/media/MediaImage.d.ts +18 -1
  42. package/dist/features/media/MediaImage.js +1 -1
  43. package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
  44. package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
  45. package/dist/features/orders/components/index.d.ts +1 -0
  46. package/dist/features/orders/components/index.js +1 -0
  47. package/dist/features/orders/schemas/firestore.d.ts +38 -0
  48. package/dist/features/orders/schemas/index.d.ts +2 -2
  49. package/dist/features/orders/types/index.d.ts +15 -0
  50. package/dist/features/points/repository/points.repository.d.ts +81 -0
  51. package/dist/features/points/repository/points.repository.js +155 -0
  52. package/dist/features/points/schemas/firestore.d.ts +118 -0
  53. package/dist/features/points/schemas/firestore.js +116 -0
  54. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  55. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
  56. package/dist/features/products/components/ProductDetailPageView.js +3 -1
  57. package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
  58. package/dist/features/seller/actions/seller-actions.d.ts +17 -0
  59. package/dist/features/seller/actions/seller-actions.js +33 -0
  60. package/dist/features/stores/components/StoreHeader.js +3 -1
  61. package/dist/index.d.ts +1 -0
  62. package/dist/index.js +8 -0
  63. package/dist/next/routing/route-map.d.ts +4 -0
  64. package/dist/next/routing/route-map.js +2 -0
  65. package/dist/schemas/registry.d.ts +2 -2
  66. package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
  67. package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
  68. package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
  69. package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
  70. package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
  71. package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
  72. package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
  73. package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
  74. package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
  75. package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
  76. package/dist/seed/_helpers/category-models.d.ts +9 -2
  77. package/dist/seed/_helpers/category-models.js +19 -1368
  78. package/dist/seed/carousel-slides-seed-data.js +48 -7
  79. package/dist/server.d.ts +3 -1
  80. package/dist/server.js +11 -0
  81. package/dist/styles.css +38 -6
  82. package/dist/tailwind-utilities.css +1 -1
  83. package/dist/ui/components/ImageMosaic.d.ts +17 -1
  84. package/dist/ui/components/ImageMosaic.js +28 -5
  85. package/dist/ui/components/ImageMosaic.style.css +37 -5
  86. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -405,6 +405,8 @@ export { couponDraftToPayload } from "./features/seller/schemas/coupon-form.js";
405
405
  export { UserAccountHubView, UserOrdersView, UserBidsView, OrderDetailView, UserReturnsView, useNotifications } from "./features/account/index.js";
406
406
  export { useOrders, useOrder, OrdersList } from "./features/orders/index.js";
407
407
  export { OrderStatusTimeline } from "./features/orders/components/OrderStatusTimeline.js";
408
+ // Buyer-facing EMI schedule with a pay-any-instalment action.
409
+ export { EmiInstallmentsPanel } from "./features/orders/components/EmiInstallmentsPanel.js";
408
410
  export { CountdownDisplay } from "./ui/components/CountdownDisplay.js";
409
411
  export { OrderAddonBadges } from "./features/orders/components/OrderAddonBadges.js";
410
412
  // Manual-payment (cash / UPI / EMI) shared constants — pure data + predicates,
@@ -546,6 +548,14 @@ export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS
546
548
  // Checkout lanes — the derived auction > offer > standard partition of the
547
549
  // cart, and the priority rule that decides which one may be checked out.
548
550
  export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, } from "./_internal/shared/checkout/lanes.js";
551
+ /*
552
+ * Shipping quote model. Public because the CONSUMER owns the route that asks
553
+ * the carrier for rates (`/api/shipping/quote`) and the checkout UI that
554
+ * renders them — the rounding rule has to be reachable from both, and it must
555
+ * be the same rule the server-side reconciliation uses, or the buyer is
556
+ * credited against a figure they were never charged.
557
+ */
558
+ export { roundShippingUp, toQuoteOptions, reconcileShipping, SHIPPING_ROUND_UP_TO, } from "./_internal/shared/shipping/quote.js";
549
559
  /*
550
560
  * The cart page needs these to tell a multi-member line from an ordinary one: a
551
561
  * group/bundle line's `productId` is a group or category id, not a product id,
@@ -16,6 +16,7 @@ import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js"
16
16
  import { normalizeListingType } from "../../products/utils/listing-type.js";
17
17
  import { isAdminUser, isSellerUser } from "../../auth/role-predicates.js";
18
18
  import { User, Star, ShoppingBag, Package, Trophy, Globe, MapPin, ExternalLink } from "lucide-react";
19
+ import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
19
20
  const __P = {
20
21
  p4: "p-[var(--appkit-space-4)]",
21
22
  p6: "p-[var(--appkit-space-6)]",
@@ -120,7 +121,7 @@ export async function PublicProfileView({ userId, tab, }) {
120
121
  const profileHeroCtx = { displayName, photoURL, memberSince, isSeller, storeSlug, flex, page };
121
122
  const statItems = buildProfileStatItems(t, { listingCount, reviewCount, itemsSold, auctionsWon, totalOrders, isSeller });
122
123
  const activeTab = tab === "catalogue" ? "catalogue" : "overview";
123
- return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [renderProfileHero(t, profileHeroCtx), _jsxs(Stack, { gap: "2xl", className: `${page.container.md}`, paddingY: "y-3xl", children: [_jsxs(Row, { gap: "md", padding: "t-xs", border: "bottom", children: [_jsx(TextLink, { href: `/profile/${userId}/overview`, variant: activeTab === "overview" ? "default" : "muted", weight: activeTab === "overview" ? "semibold" : "normal", children: "Overview" }), _jsx(TextLink, { href: `/profile/${userId}/catalogue`, variant: activeTab === "catalogue" ? "default" : "muted", weight: activeTab === "catalogue" ? "semibold" : "normal", children: "Catalogue" })] }), activeTab === "catalogue" ? (_jsx(PublicCatalogueView, { ownerSlug: userId })) : (_jsxs(_Fragment, { children: [renderProfileStatsRow(flex, statItems), renderProfileBioSection(pub), renderStoreDescriptionSection(isSeller, storeSlug ?? null, storeDescription ?? null, storeName, t), renderProfileListingsSection(t, products, storeSlug ?? null), renderAuthoredReviewsSection(t, reviewsAuthored, displayName), reviewsReceivedAsBuyer.length > 0 && renderBuyerReceivedReviewsSection(t, reviewsReceivedAsBuyer)] })), _jsx(Row, { justify: "center", padding: "t-xs", children: _jsxs(Link, { href: String(ROUTES.HOME), className: "text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]", children: ["\u2190 ", t("backHome")] }) })] })] }));
124
+ return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [renderProfileHero(t, profileHeroCtx), _jsxs(Stack, { gap: "2xl", className: `${page.container.md}`, paddingY: "y-3xl", children: [_jsx(Row, { justify: "end", children: _jsx(OwnerQuickActions, { resource: "USER", placement: "detail", recordLabel: displayName, editHref: String(ROUTES.ADMIN.USER_EDIT(userId)) }) }), _jsxs(Row, { gap: "md", padding: "t-xs", border: "bottom", children: [_jsx(TextLink, { href: `/profile/${userId}/overview`, variant: activeTab === "overview" ? "default" : "muted", weight: activeTab === "overview" ? "semibold" : "normal", children: "Overview" }), _jsx(TextLink, { href: `/profile/${userId}/catalogue`, variant: activeTab === "catalogue" ? "default" : "muted", weight: activeTab === "catalogue" ? "semibold" : "normal", children: "Catalogue" })] }), activeTab === "catalogue" ? (_jsx(PublicCatalogueView, { ownerSlug: userId })) : (_jsxs(_Fragment, { children: [renderProfileStatsRow(flex, statItems), renderProfileBioSection(pub), renderStoreDescriptionSection(isSeller, storeSlug ?? null, storeDescription ?? null, storeName, t), renderProfileListingsSection(t, products, storeSlug ?? null), renderAuthoredReviewsSection(t, reviewsAuthored, displayName), reviewsReceivedAsBuyer.length > 0 && renderBuyerReceivedReviewsSection(t, reviewsReceivedAsBuyer)] })), _jsx(Row, { justify: "center", padding: "t-xs", children: _jsxs(Link, { href: String(ROUTES.HOME), className: "text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]", children: ["\u2190 ", t("backHome")] }) })] })] }));
124
125
  }
125
126
  function buildProfileStatItems(t, ctx) {
126
127
  const { listingCount, reviewCount, itemsSold, auctionsWon, totalOrders, isSeller } = ctx;
@@ -51,6 +51,27 @@ const ORDER_LANE_TABS = [
51
51
  { id: "auction", label: "Auction wins" },
52
52
  { id: "offer", label: "Offer wins" },
53
53
  ];
54
+ /**
55
+ * Payment-method tabs — a THIRD axis, independent of the lane tabs above and
56
+ * the lifecycle scope beside them.
57
+ *
58
+ * 🛑 EMI is not a lane and not a status, which is why it could not join
59
+ * either existing control: `orderType` records how the order was WON
60
+ * (standard / auction / offer) and the scope records where it IS. How it is
61
+ * being PAID is orthogonal to both — an auction win can be on an EMI plan.
62
+ *
63
+ * Every id is exactly a stored `OrderDocument.paymentMethod` value, and
64
+ * `paymentMethod` is `canFilter: true` in the orders SIEVE_FIELDS — checked
65
+ * before wiring this, because an unallowlisted field does not raise: the
66
+ * clause is silently dropped and the chip returns the ENTIRE order list while
67
+ * looking like a filter (Root Cause #62).
68
+ */
69
+ const PAYMENT_METHOD_TABS = [
70
+ { id: "", label: "Any" },
71
+ { id: "emi", label: "EMI plans" },
72
+ { id: "cod", label: "Cash on delivery" },
73
+ { id: "online", label: "Paid online" },
74
+ ];
54
75
  const STATUS_OPTIONS = [
55
76
  { value: "pending", label: "Pending" },
56
77
  { value: "processing", label: "Processing" },
@@ -85,7 +106,7 @@ export function UserOrdersView({ onOrderClick }) {
85
106
  * (CLAUDE.md, Root Cause #35).
86
107
  */
87
108
  const searchParams = useSearchParams();
88
- const hasNarrowingFilter = ["q", "search", "status", "orderType", "filters"].some((k) => Boolean(searchParams.get(k)));
109
+ const hasNarrowingFilter = ["q", "search", "status", "orderType", "paymentMethod", "filters"].some((k) => Boolean(searchParams.get(k)));
89
110
  const emptyMessage = hasNarrowingFilter
90
111
  ? "No orders match your search or filters."
91
112
  : "You haven't placed any orders yet.";
@@ -104,7 +125,7 @@ export function UserOrdersView({ onOrderClick }) {
104
125
  fields: ["id", "productTitle"],
105
126
  },
106
127
  emptyLabel: emptyMessage,
107
- filterKeys: ["status", "orderType"],
128
+ filterKeys: ["status", "orderType", "paymentMethod"],
108
129
  defaultSort: sortBy("createdAt", "DESC"),
109
130
  queryKey: ["user", "orders", "listing"],
110
131
  endpoint: ACCOUNT_ENDPOINTS.ORDERS,
@@ -115,6 +136,7 @@ export function UserOrdersView({ onOrderClick }) {
115
136
  buildFilters: (state) => [
116
137
  state.status ? sieveFilter("status", SIEVE_OP.EQ, state.status) : null,
117
138
  state.orderType ? sieveFilter("orderType", SIEVE_OP.EQ, state.orderType) : null,
139
+ state.paymentMethod ? sieveFilter("paymentMethod", SIEVE_OP.EQ, state.paymentMethod) : null,
118
140
  ]
119
141
  .filter(Boolean)
120
142
  .join(",") || undefined,
@@ -123,7 +145,7 @@ export function UserOrdersView({ onOrderClick }) {
123
145
  // in flight" without those two questions fighting over one control.
124
146
  buildExtraParams: () => orderScope.extraParams,
125
147
  renderAboveContent: orderScope.renderAboveContent,
126
- renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
148
+ renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(FilterChipGroup, { label: "Payment", tabs: PAYMENT_METHOD_TABS, value: pendingFilters.paymentMethod ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, paymentMethod: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
127
149
  renderCards: (rows, _view, _selection, isLoading) => {
128
150
  if (isLoading) {
129
151
  return (_jsx(Stack, { gap: "md", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Div, { className: "h-24 animate-pulse border border-[var(--appkit-color-border)]", rounded: "xl" }, i))) }));
@@ -1,5 +1,5 @@
1
1
  /** Which registry bucket supplies the labels and the confirm copy. */
2
- export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW";
2
+ export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "FRANCHISE" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW" | "STORE" | "USER" | "CAROUSEL" | "SECTION";
3
3
  export interface OwnerQuickActionsProps {
4
4
  resource: OwnerQuickActionsResource;
5
5
  /** Where the pencil goes — that record's dashboard editor. */
@@ -54,6 +54,45 @@ import { IconButton } from "../../../ui/components/IconButton.js";
54
54
  import { Row, useToast } from "../../../ui/index.js";
55
55
  import { normalizeError } from "../../../errors/normalize.js";
56
56
  import { toUserMessage } from "../../../errors/error-display-map.js";
57
+ const RESOURCE_LEAVES = {
58
+ PRODUCT: { bucket: "PRODUCT", edit: "edit", remove: "delete", duplicate: "duplicate" },
59
+ CATEGORY: { bucket: "CATEGORY", edit: "edit", remove: "delete" },
60
+ BRAND: { bucket: "BRAND", edit: "edit", remove: "delete" },
61
+ BUNDLE: { bucket: "BUNDLE", edit: "edit", remove: "delete" },
62
+ GROUP: { bucket: "GROUP", edit: "edit", remove: "delete" },
63
+ SUBLISTING: { bucket: "SUBLISTING", edit: "edit", remove: "delete" },
64
+ EVENT: { bucket: "EVENT", edit: "edit", remove: "delete", duplicate: "duplicate" },
65
+ REVIEW: { bucket: "REVIEW", edit: "edit", remove: "delete" },
66
+ /** 🛑 `edit-post` / `delete-post`, not `edit` / `delete`. See the note above. */
67
+ BLOG: { bucket: "BLOG", edit: "edit-post", remove: "delete-post", duplicate: "duplicate-post" },
68
+ /**
69
+ * A franchise row IS a category (`categoryType: "franchise"`), so it reuses
70
+ * the category leaves rather than duplicating copy that would drift.
71
+ */
72
+ FRANCHISE: { bucket: "CATEGORY", edit: "edit", remove: "delete" },
73
+ /** Carousel slides have no bucket of their own; the leaves live under ADMIN. */
74
+ CAROUSEL: { bucket: "ADMIN", edit: "edit-carousel", remove: "delete-carousel" },
75
+ /*
76
+ * ── The three PENCIL-ONLY resources ─────────────────────────────────────
77
+ *
78
+ * `remove` is omitted on purpose for all three. Each is a delete whose blast
79
+ * radius is far larger than the page the bin would sit on, and a public
80
+ * surface is the worst possible place to offer it:
81
+ *
82
+ * STORE — deleting a store orphans its products, orders and payouts. The
83
+ * dashboard offers `suspend-store` / `verify-store`, which are the
84
+ * operations an admin actually wants from a store page.
85
+ * USER — deleting a user cascades across orders, reviews, bids and
86
+ * addresses. `ADMIN.ban-user` is the reversible tool, and it has
87
+ * its own flow with a reason.
88
+ * SECTION — homepage section `order` is POSITIONAL, so removing one silently
89
+ * renumbers its neighbours. There is also no per-section public
90
+ * page; the pencil goes to the list that owns them.
91
+ */
92
+ STORE: { bucket: "ADMIN", edit: "manage-store" },
93
+ USER: { bucket: "ADMIN", edit: "manage-user" },
94
+ SECTION: { bucket: "ADMIN", edit: "edit-section" },
95
+ };
57
96
  export function OwnerQuickActions({ resource, editHref, onDelete, onClone, ownerId, storeId, recordLabel, placement = "card", className = "", }) {
58
97
  const session = useOptionalSession();
59
98
  const isOwnerOrAdmin = useIsOwner(ownerId);
@@ -61,7 +100,8 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
61
100
  const { showToast } = useToast();
62
101
  const [confirmOpen, setConfirmOpen] = useState(false);
63
102
  const [deleting, setDeleting] = useState(false);
64
- const bucket = ACTIONS[resource];
103
+ const leaves = RESOURCE_LEAVES[resource];
104
+ const bucket = ACTIONS[leaves.bucket];
65
105
  const handleConfirmedDelete = useCallback(async () => {
66
106
  if (!onDelete)
67
107
  return;
@@ -113,10 +153,17 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
113
153
  */
114
154
  if (!isAdmin && !isOwnerOrAdmin && !ownsStore)
115
155
  return null;
116
- const editDef = bucket.edit;
117
- const deleteDef = bucket.delete;
156
+ const editDef = bucket[leaves.edit];
157
+ const deleteDef = leaves.remove ? bucket[leaves.remove] : undefined;
118
158
  const confirmation = deleteDef?.confirmation;
119
- const showClone = Boolean(onClone) && isAdmin;
159
+ const showClone = Boolean(onClone) && isAdmin && Boolean(leaves.duplicate);
160
+ /*
161
+ * 🛑 `leaves.remove` gates the bin, not just `onDelete`. A resource whose
162
+ * mapping omits a delete leaf (store, user, homepage section) must render no
163
+ * bin even if a caller passes `onDelete` — the safety decision belongs to the
164
+ * resource, not to each of its call sites, or the next call site re-opens it.
165
+ */
166
+ const showDelete = Boolean(onDelete) && Boolean(leaves.remove);
120
167
  return (_jsxs(_Fragment, { children: [_jsxs(Row, { gap: "xs", align: "center", className: className,
121
168
  /*
122
169
  * Stops the click reaching an ancestor `<Link>` or the card's own
@@ -135,8 +182,8 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
135
182
  */
136
183
  if (typeof window !== "undefined")
137
184
  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
185
+ } }), showClone && (_jsx(IconButton, { "aria-label": (leaves.duplicate ? bucket[leaves.duplicate]?.ariaLabel ?? bucket[leaves.duplicate]?.label : undefined) ||
186
+ "Duplicate", size: "touch", variant: placement === "card" ? "scrim" : "ghost", icon: _jsx(Icon, { name: "duplicate" }), onClick: () => void onClone?.() })), showDelete && (_jsx(IconButton, { "aria-label": deleteDef?.ariaLabel ?? deleteDef?.label ?? "Delete", size: "touch", variant: placement === "card" ? "scrim" : "danger", icon: _jsx(Icon, { name: "delete" }), onClick: () => setConfirmOpen(true) }))] }), showDelete && (_jsx(ConfirmDeleteModal, { isOpen: confirmOpen, onClose: () => setConfirmOpen(false), onConfirm: handleConfirmedDelete, isDeleting: deleting, title: confirmation?.title ?? "Delete this record?", message: recordLabel && confirmation
140
187
  ? `${confirmation.body}\n\nDeleting: ${recordLabel}`
141
188
  : confirmation?.body, confirmText: confirmation?.confirmLabel ?? deleteDef?.label ?? "Delete", variant: "danger" }))] }));
142
189
  }
@@ -43,6 +43,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
43
43
  import { SublistingCarouselSection } from "../../products/components/SublistingCarouselSection.js";
44
44
  import { isFinalSale } from "../../products/constants/final-sale.js";
45
45
  import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
46
+ import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
46
47
  function toDescriptionHtml(raw) {
47
48
  if (!raw)
48
49
  return "";
@@ -235,6 +236,6 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
235
236
  return (_jsx(CollapsibleBidHistory, { productId: String(product.id), initialData: bidsResult, currency: currency }));
236
237
  }, renderRelated: () => {
237
238
  const related = toRelatedAuctionCards(relatedDocs, product.id, new Date());
238
- return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related })) })), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
239
+ return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related })) })), _jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
239
240
  } }), renderAuctionStoreReviews(storeId), onPlaceBid && (_jsx(AuctionBottomActions, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow }))] })] }));
240
241
  }
@@ -84,6 +84,21 @@ export interface UserDocument extends BaseDocument {
84
84
  storeId?: string;
85
85
  storeSlug?: string;
86
86
  storeStatus?: "pending" | "approved" | "rejected";
87
+ /**
88
+ * Denormalised sum of this user's `pointsLedger` entries. 1 point = ₹1.
89
+ *
90
+ * 🛑 A MIRROR, and `pointsRepository` is the only thing allowed to write it —
91
+ * always in the same transaction as the ledger entry that moved it. Root
92
+ * Cause #42: a mirror maintained by more than one write path drifts, and this
93
+ * one guards money-shaped value, so drift either invents points or swallows
94
+ * earned ones.
95
+ *
96
+ * 🛑 Absence means "never computed", NOT zero. `getBalance` falls back to
97
+ * summing the ledger rather than answering 0, because every user document
98
+ * written before this field existed has no value here and answering zero
99
+ * would quietly deny people credits they hold.
100
+ */
101
+ pointsBalance?: number;
87
102
  isTester?: boolean;
88
103
  canTestAdmin?: boolean;
89
104
  publicProfile?: {
@@ -11,6 +11,7 @@ import { getMediaUrl } from "../../media/types/index.js";
11
11
  import { getDefaultLocale } from "../../../core/baseline-resolver.js";
12
12
  import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
13
13
  import { ROUTES } from "../../../next/routing/route-map.js";
14
+ import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
14
15
  const __P = {
15
16
  p8: "p-[var(--appkit-space-8)]",
16
17
  };
@@ -26,7 +27,7 @@ const CATEGORY_BADGE = {
26
27
  };
27
28
  const CLS_FEATURED_BADGE = "bg-warning-surface text-warning dark:bg-warning-surface dark:text-warning px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] rounded-full";
28
29
  function renderBlogPostHeader(post, date, labels) {
29
- return (_jsxs(Div, { className: "mb-8", children: [_jsxs(Row, { className: "mb-4", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-block capitalize ${CATEGORY_BADGE[post.category] ?? ""}`, rounded: "full", padding: "pill-xs", children: post.category }), post.isFeatured && _jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: labels?.featured ?? "Featured" })] }), _jsx(Heading, { level: 1, className: "mb-4", size: "3xl", weight: "bold", children: post.title }), post.excerpt && _jsx(Text, { className: "mb-6", color: "muted", size: "lg", children: post.excerpt }), _jsxs(Row, { color: "muted", textSize: "sm", wrap: true, gap: "md", children: [post.authorName && _jsxs(Span, { children: [labels?.author ?? "By", " ", _jsx(Span, { weight: "medium", color: "muted", children: post.authorName })] }), post.readTimeMinutes != null && _jsxs(Span, { children: [post.readTimeMinutes, " ", labels?.readTime ?? "min read"] }), date && _jsxs(Span, { children: [labels?.publishedOn ?? "Published", " ", date] }), post.views != null && _jsxs(Span, { children: [post.views, " ", labels?.viewsLabel ?? "views"] })] })] }));
30
+ return (_jsxs(Div, { className: "mb-8", children: [_jsxs(Row, { className: "mb-4", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-block capitalize ${CATEGORY_BADGE[post.category] ?? ""}`, rounded: "full", padding: "pill-xs", children: post.category }), post.isFeatured && _jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: labels?.featured ?? "Featured" })] }), _jsxs(Row, { className: "mb-4", gap: "sm", align: "center", justify: "between", wrap: true, children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", children: post.title }), _jsx(OwnerQuickActions, { resource: "BLOG", placement: "detail", editHref: String(ROUTES.ADMIN.BLOG_EDIT(post.id)), recordLabel: post.title })] }), post.excerpt && _jsx(Text, { className: "mb-6", color: "muted", size: "lg", children: post.excerpt }), _jsxs(Row, { color: "muted", textSize: "sm", wrap: true, gap: "md", children: [post.authorName && _jsxs(Span, { children: [labels?.author ?? "By", " ", _jsx(Span, { weight: "medium", color: "muted", children: post.authorName })] }), post.readTimeMinutes != null && _jsxs(Span, { children: [post.readTimeMinutes, " ", labels?.readTime ?? "min read"] }), date && _jsxs(Span, { children: [labels?.publishedOn ?? "Published", " ", date] }), post.views != null && _jsxs(Span, { children: [post.views, " ", labels?.viewsLabel ?? "views"] })] })] }));
30
31
  }
31
32
  function renderBlogPostRelated(related, title, renderRelatedCard) {
32
33
  if (related.length === 0)
@@ -24,6 +24,7 @@ export interface CartPriceBreakdownStore {
24
24
  giftWrapFee: number;
25
25
  shipmentProtectionFee: number;
26
26
  saleProtectionFee: number;
27
+ emiSurcharge: number;
27
28
  gstAmount: number;
28
29
  couponDiscount: number;
29
30
  total: number;
@@ -40,6 +41,7 @@ export interface CartPriceBreakdownData {
40
41
  giftWrapFee: number;
41
42
  shipmentProtectionFee: number;
42
43
  saleProtectionFee: number;
44
+ emiSurcharge: number;
43
45
  gstAmount: number;
44
46
  couponDiscount: number;
45
47
  platformFee: number;
@@ -21,5 +21,5 @@ export function CartPriceBreakdown({ preview, itemCount, currency = "INR", unava
21
21
  const subtotalLabel = itemCount !== undefined
22
22
  ? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""}${storeCount > 1 ? `, ${storeCount} stores` : ""})`
23
23
  : "Subtotal";
24
- return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.platformFee > 0 && _jsx(Line, { label: "Platform fee", amount: money(preview.platformFee) }), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee) })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: true }) }), preview.gstAmount > 0 && (_jsxs(Text, { size: "xs", color: "muted", children: ["Includes GST of ", money(preview.gstAmount)] })), errorNote ? (_jsx(Text, { size: "xs", color: "error", children: errorNote })) : (isLoading && (_jsx(Text, { size: "xs", color: "muted", children: "Updating\u2026" })))] }));
24
+ return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge) })), preview.platformFee > 0 && _jsx(Line, { label: "Platform fee", amount: money(preview.platformFee) }), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee) })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: true }) }), preview.gstAmount > 0 && (_jsxs(Text, { size: "xs", color: "muted", children: ["Includes GST of ", money(preview.gstAmount)] })), errorNote ? (_jsx(Text, { size: "xs", color: "error", children: errorNote })) : (isLoading && (_jsx(Text, { size: "xs", color: "muted", children: "Updating\u2026" })))] }));
25
25
  }
@@ -9,6 +9,8 @@ import { Heading, Text } from "../../../ui/components/Typography.js";
9
9
  import { MediaImage } from "../../media/MediaImage.js";
10
10
  import { ProductGrid } from "../../products/components/ProductGrid.js";
11
11
  import { Alert } from "../../../ui/components/Alert.js";
12
+ import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
13
+ import { ROUTES } from "../../../next/routing/route-map.js";
12
14
  const PAGE_SIZE = 24;
13
15
  export async function FranchiseDetailPageView({ slug, franchise, }) {
14
16
  const result = await safeRead(() => listPublicProducts({
@@ -37,7 +39,7 @@ export async function FranchiseDetailPageView({ slug, franchise, }) {
37
39
  const products = rows.map((r) => toProductItem(r));
38
40
  const total = reportedTotal ?? products.length;
39
41
  const cover = franchise.display?.coverImage;
40
- return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsxs(Row, { align: "center", gap: "comfortable", className: "mb-8", children: [cover ? (_jsx(MediaImage, { src: cover, alt: franchise.name ?? slug, size: "thumbnail", rounded: "lg" })) : null, _jsxs(Stack, { gap: "dense", children: [_jsx(Heading, { level: 1, color: "primary", size: "3xl", weight: "semibold", children: franchise.name }), franchise.description ? (_jsx(Text, { color: "muted", className: "max-w-2xl", children: franchise.description })) : null] })] }), franchise.isActive === false ? (_jsxs(Alert, { variant: "info", className: "mb-6", children: ["We are not stocking ", franchise.name, " yet. This page is here so the link works \u2014 check back, or browse what we do carry."] })) : null, readFailed ? (_jsxs(Alert, { variant: "warning", children: ["We could not load ", franchise.name, " listings just now. Please try again."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "muted", className: "mb-4", children: total === 0
42
+ return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsxs(Row, { align: "center", gap: "comfortable", className: "mb-8", children: [cover ? (_jsx(MediaImage, { src: cover, alt: franchise.name ?? slug, size: "thumbnail", rounded: "lg" })) : null, _jsxs(Stack, { gap: "dense", children: [_jsxs(Row, { gap: "sm", align: "center", justify: "between", wrap: true, children: [_jsx(Heading, { level: 1, color: "primary", size: "3xl", weight: "semibold", children: franchise.name }), franchise.id ? (_jsx(OwnerQuickActions, { resource: "FRANCHISE", placement: "detail", recordLabel: franchise.name ?? slug, editHref: String(ROUTES.ADMIN.CATEGORIES_EDIT(franchise.id)) })) : null] }), franchise.description ? (_jsx(Text, { color: "muted", className: "max-w-2xl", children: franchise.description })) : null] })] }), franchise.isActive === false ? (_jsxs(Alert, { variant: "info", className: "mb-6", children: ["We are not stocking ", franchise.name, " yet. This page is here so the link works \u2014 check back, or browse what we do carry."] })) : null, readFailed ? (_jsxs(Alert, { variant: "warning", children: ["We could not load ", franchise.name, " listings just now. Please try again."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "muted", className: "mb-4", children: total === 0
41
43
  ? "No listings yet."
42
44
  : `${total}${truncated ? "+" : ""} listing${total === 1 ? "" : "s"}` }), _jsx(ProductGrid, { products: products, emptyLabel: "No listings yet." })] }))] }) }) }));
43
45
  }
@@ -23,6 +23,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
23
23
  import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
24
24
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
25
25
  import { productImageUrls } from "../../products/schemas/firestore.js";
26
+ import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
26
27
  function toDescriptionHtml(raw) {
27
28
  if (!raw)
28
29
  return "";
@@ -84,5 +85,5 @@ export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPr
84
85
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
85
86
  storeName: storeName ?? undefined,
86
87
  inStock: codesLeft > 0,
87
- }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
88
+ }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
88
89
  }
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Alert, Div, EmptyState, Heading, Input, SlottedListingView, Text } from "../../../ui/index.js";
3
+ import { Alert, Div, EmptyState, Heading, Input, Row, SlottedListingView, Text, TextLink } from "../../../ui/index.js";
4
4
  import { sortBy } from "../../../constants/sort.js";
5
5
  import { FAQCategorySidebar } from "./FAQCategorySidebar.js";
6
6
  import { FAQSortDropdown } from "./FAQSortDropdown.js";
7
7
  import { FAQAccordion } from "./FAQAccordion.js";
8
8
  import { ContactCTA } from "./ContactCTA.js";
9
9
  import { useFaqList } from "../hooks/useFaqList.js";
10
+ import { helpGuideFor } from "../constants/help-guides.js";
10
11
  export function FAQPageContent({ initialCategory = "all", categories, routeHelpers, labels, contact, searchValue, onSearchChange, sortOption, onSortChange, page = 1, pageSize = 100, renderPagination, renderMobileCategoryTabs, renderAccordion, }) {
11
12
  const selectedCategory = initialCategory;
12
13
  const sorts = sortOption === "helpful"
@@ -49,9 +50,21 @@ export function FAQPageContent({ initialCategory = "all", categories, routeHelpe
49
50
  allFaqs: labels.allFaqs,
50
51
  stillHaveQuestions: labels.stillHaveQuestions,
51
52
  contactSupport: labels.contactSupport,
52
- } }) }), _jsxs(Div, { className: "col-span-1 lg:col-span-8 xl:col-span-9", children: [_jsx(SlottedListingView, { portal: "public", inlineToolbar: true, className: "space-y-6", renderHeader: () => (_jsxs(Text, { size: "sm", color: "muted", children: [labels.resultCount(total), selectedCategory !== "all"
53
- ? ` ${labels.inCategory(categories.find((c) => c.key === selectedCategory)?.label ?? selectedCategory)}`
54
- : ""] })), renderSearch: () => (_jsx(Input, { value: searchValue, onChange: (event) => onSearchChange(event.target.value), placeholder: labels.searchPlaceholder })), renderSort: () => (_jsx(FAQSortDropdown, { selectedSort: sortOption, onSortChange: onSortChange, labels: {
53
+ } }) }), _jsxs(Div, { className: "col-span-1 lg:col-span-8 xl:col-span-9", children: [_jsx(SlottedListingView, { portal: "public", inlineToolbar: true, className: "space-y-6", renderHeader: () => {
54
+ /*
55
+ * The fuller answer, next to the short ones.
56
+ *
57
+ * Only on a chosen category: on "all" there is no single guide
58
+ * to offer, and a link to the Help Centre index here would
59
+ * duplicate the footer's for no gain. `helpGuideFor` falls back
60
+ * to the index for the two categories that span the product, so
61
+ * a selected category always has somewhere honest to point.
62
+ */
63
+ const guide = selectedCategory !== "all" ? helpGuideFor(selectedCategory) : null;
64
+ return (_jsxs(Row, { align: "center", justify: "between", gap: "sm", className: "min-w-0", children: [_jsxs(Text, { size: "sm", color: "muted", className: "min-w-0", children: [labels.resultCount(total), selectedCategory !== "all"
65
+ ? ` ${labels.inCategory(categories.find((c) => c.key === selectedCategory)?.label ?? selectedCategory)}`
66
+ : ""] }), guide && (_jsxs(TextLink, { href: guide.path, size: "sm", variant: "muted", className: "flex-shrink-0", children: [guide.label, " \u2192"] }))] }));
67
+ }, renderSearch: () => (_jsx(Input, { value: searchValue, onChange: (event) => onSearchChange(event.target.value), placeholder: labels.searchPlaceholder })), renderSort: () => (_jsx(FAQSortDropdown, { selectedSort: sortOption, onSortChange: onSortChange, labels: {
55
68
  label: labels.sortLabel,
56
69
  helpful: labels.sortHelpful,
57
70
  newest: labels.sortNewest,
@@ -0,0 +1,36 @@
1
+ import type { FAQCategory } from "../types/index";
2
+ /**
3
+ * The Help Centre guide that answers a FAQ category at length.
4
+ *
5
+ * A FAQ is a short answer to one question; a help guide is the surrounding
6
+ * explanation. They were written as separate surfaces and never linked —
7
+ * measured 2026-10-20, the FAQ seed contained **zero** `/help/` links across
8
+ * 91 entries — so a reader who wanted the fuller version had to already know
9
+ * the Help Centre existed and find the right page in it.
10
+ *
11
+ * 🛑 `Record<FAQCategory, …>`, deliberately, so adding a tenth category is a
12
+ * COMPILE error rather than a category that silently offers no guide. This is
13
+ * the same decision as `ALL_LISTING_TYPES_MAP` and `EMAIL_ELIGIBLE_TYPES`, and
14
+ * the reason is Root Cause #61: every hand-written enumeration of a union in
15
+ * this codebase has eventually fallen behind it.
16
+ *
17
+ * `null` is a real answer and not a gap. `technical_support` and `general`
18
+ * span the whole product, so pointing them at one guide would be arbitrary —
19
+ * they get the Help Centre index instead, via `HELP_INDEX_PATH`. Writing a
20
+ * guide to point them at later is a content decision, not a missing entry.
21
+ *
22
+ * 🛑 Paths, not `ROUTES`. The Help Centre pages are consumer routes under
23
+ * `src/app/[locale]/help/`, and `ROUTES` is appkit's own map — adding them
24
+ * there would make appkit declare pages it does not own. They are stable,
25
+ * linked from the footer, and covered by `audit-nav-page-wiring`.
26
+ */
27
+ export declare const HELP_INDEX_PATH: "/help";
28
+ export declare const FAQ_CATEGORY_HELP_GUIDE: Record<FAQCategory, {
29
+ path: string;
30
+ label: string;
31
+ } | null>;
32
+ /** The guide for a category, or the Help Centre index when none is specific. */
33
+ export declare function helpGuideFor(category: FAQCategory): {
34
+ path: string;
35
+ label: string;
36
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The Help Centre guide that answers a FAQ category at length.
3
+ *
4
+ * A FAQ is a short answer to one question; a help guide is the surrounding
5
+ * explanation. They were written as separate surfaces and never linked —
6
+ * measured 2026-10-20, the FAQ seed contained **zero** `/help/` links across
7
+ * 91 entries — so a reader who wanted the fuller version had to already know
8
+ * the Help Centre existed and find the right page in it.
9
+ *
10
+ * 🛑 `Record<FAQCategory, …>`, deliberately, so adding a tenth category is a
11
+ * COMPILE error rather than a category that silently offers no guide. This is
12
+ * the same decision as `ALL_LISTING_TYPES_MAP` and `EMAIL_ELIGIBLE_TYPES`, and
13
+ * the reason is Root Cause #61: every hand-written enumeration of a union in
14
+ * this codebase has eventually fallen behind it.
15
+ *
16
+ * `null` is a real answer and not a gap. `technical_support` and `general`
17
+ * span the whole product, so pointing them at one guide would be arbitrary —
18
+ * they get the Help Centre index instead, via `HELP_INDEX_PATH`. Writing a
19
+ * guide to point them at later is a content decision, not a missing entry.
20
+ *
21
+ * 🛑 Paths, not `ROUTES`. The Help Centre pages are consumer routes under
22
+ * `src/app/[locale]/help/`, and `ROUTES` is appkit's own map — adding them
23
+ * there would make appkit declare pages it does not own. They are stable,
24
+ * linked from the footer, and covered by `audit-nav-page-wiring`.
25
+ */
26
+ export const HELP_INDEX_PATH = "/help";
27
+ /**
28
+ * The orders guide, named once because THREE categories share it.
29
+ *
30
+ * `orders_payment`, `shipping_delivery` and `returns_refunds` all resolve to
31
+ * the same page — see the per-entry notes below for why — so the path and the
32
+ * label were written out three times each. Extracted so a retitle is one edit
33
+ * rather than three, and so the three cannot drift into disagreeing labels for
34
+ * one destination.
35
+ */
36
+ const ORDERS_GUIDE = {
37
+ path: "/help/orders",
38
+ label: "Orders, Returns & Support guide",
39
+ };
40
+ export const FAQ_CATEGORY_HELP_GUIDE = {
41
+ orders_payment: ORDERS_GUIDE,
42
+ // Shipping questions are about an order in flight, which is what the orders
43
+ // guide covers end to end. A separate shipping guide does not exist, and a
44
+ // link to one that did not would be worse than this.
45
+ shipping_delivery: ORDERS_GUIDE,
46
+ returns_refunds: ORDERS_GUIDE,
47
+ // "Is my Beyblade original?" — the vocabulary behind most questions about
48
+ // what an item actually is.
49
+ product_information: { path: "/help/authenticity", label: "Is my Beyblade original?" },
50
+ account_security: { path: "/help/account", label: "Account & Safety guide" },
51
+ // Safety, not shopping: the account guide is where reporting and blocking
52
+ // live.
53
+ scam_awareness: { path: "/help/account", label: "Account & Safety guide" },
54
+ fees_and_taxes: { path: "/help/shopping", label: "Shopping & Checkout guide" },
55
+ technical_support: null,
56
+ general: null,
57
+ };
58
+ /** The guide for a category, or the Help Centre index when none is specific. */
59
+ export function helpGuideFor(category) {
60
+ return (FAQ_CATEGORY_HELP_GUIDE[category] ?? {
61
+ path: HELP_INDEX_PATH,
62
+ label: "Help Centre",
63
+ });
64
+ }
@@ -10,6 +10,8 @@ import { POSITION_FILL, FLEX_CENTER, HERO_PLACEHOLDER_BG } from "../constants/he
10
10
  /* The one carousel position/navigation control. The dot and arrow class
11
11
  strings this file used to import are deleted — see CarouselNav.style.css. */
12
12
  import { CarouselNav } from "../../../ui/components/CarouselNav.js";
13
+ import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
14
+ import { ROUTES } from "../../../next/routing/route-map.js";
13
15
  function makeButtonClickHandler(href, openInNewTab, push) {
14
16
  return () => {
15
17
  if (openInNewTab) {
@@ -88,7 +90,7 @@ function SlideBackground({ bg, legacy, mobileLegacy, isMobile, priority, }) {
88
90
  if (effectiveBg.type === "video") {
89
91
  return (_jsxs(_Fragment, { children: [_jsx(MediaVideo, { src: src, thumbnailUrl: effectiveBg.thumbnail ?? legacy?.thumbnail, alt: "", autoPlayMuted: true, loop: true, controls: false }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
90
92
  }
91
- return (_jsxs(_Fragment, { children: [_jsx(MediaImage, { src: src, alt: "", size: "hero", priority: priority, fetchPriority: priority ? "high" : "low" }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
93
+ return (_jsxs(_Fragment, { children: [_jsx(MediaImage, { src: src, alt: "", size: "hero", objectFit: "cover", priority: priority, fetchPriority: priority ? "high" : "low" }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
92
94
  }
93
95
  /** Renders the background of a card (image, video, color, gradient). */
94
96
  function CardBackground({ bg }) {
@@ -102,7 +104,9 @@ function CardBackground({ bg }) {
102
104
  return (_jsx(Div, { className: POSITION_FILL, style: { background: `linear-gradient(${angle}deg, ${from}, ${to})` } }));
103
105
  }
104
106
  if (bg.type === "image" && bg.url) {
105
- return _jsx(MediaImage, { src: bg.url, alt: "", size: "card" });
107
+ // `cover` for the same reason as the slide backdrop above — this sits
108
+ // behind the card's eyebrow/title/description, not beside them.
109
+ return _jsx(MediaImage, { src: bg.url, alt: "", size: "card", objectFit: "cover" });
106
110
  }
107
111
  return null;
108
112
  }
@@ -198,7 +202,7 @@ export function HeroCarousel({ initialSlides, push, height, defaultAutoplayDelay
198
202
  onMouseEnter: pauseOnHover ? () => setIsPaused(true) : undefined, onMouseLeave: pauseOnHover ? () => setIsPaused(false) : undefined, onFocus: () => setIsPaused(true), onBlur: () => setIsPaused(false), tabIndex: 0, children: [_jsx(Div, { "aria-live": "polite", "aria-atomic": "true", className: "sr-only", children: `Slide ${currentSlide + 1} of ${slides.length}` }), _jsx(Div, { className: POSITION_FILL, children: _jsx(Div, { layout: "flex", ref: slidesRef, onScroll: handleSlidesScroll, className: "h-full overflow-x-auto scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", style: { gap: 0 }, children: slides.map((slide, slideIndex) => {
199
203
  const slideHeightClass = getSlideHeightClass(slide.settings?.height);
200
204
  const hasCards = slide.cards && slide.cards.length > 0;
201
- return (_jsxs(Div, { className: `snap-start flex-none w-full relative self-stretch bg-neutral-900 ${slideHeightClass}`, children: [_jsx(Div, { className: POSITION_FILL, children: _jsx(SlideBackground, { bg: slide.background, legacy: slide.media, mobileLegacy: slide.mobileMedia, isMobile: isMobile, priority: slideIndex === 0 }) }), slide.overlay && (_jsxs(Div, { layout: "flex-col", align: "start", justify: "center", className: `${POSITION_FILL} text-left md:px-[4rem] lg:px-[8rem]`, padding: "x-lg", children: [slide.overlay.subtitle && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-1 opacity-80 mb-1 md:mb-2 tracking-widest", mdSize: "sm", size: "xs", transform: "uppercase", children: slide.overlay.subtitle })), slide.overlay.title && (
205
+ return (_jsxs(Div, { className: `snap-start flex-none w-full relative self-stretch bg-neutral-900 ${slideHeightClass}`, children: [_jsx(Div, { className: "absolute bottom-2 left-2 z-10", children: _jsx(OwnerQuickActions, { resource: "CAROUSEL", placement: "card", recordLabel: slide.title ?? `Slide ${slideIndex + 1}`, editHref: String(ROUTES.ADMIN.CAROUSEL_EDIT(slide.id)) }) }), _jsx(Div, { className: POSITION_FILL, children: _jsx(SlideBackground, { bg: slide.background, legacy: slide.media, mobileLegacy: slide.mobileMedia, isMobile: isMobile, priority: slideIndex === 0 }) }), slide.overlay && (_jsxs(Div, { layout: "flex-col", align: "start", justify: "center", className: `${POSITION_FILL} text-left md:px-[4rem] lg:px-[8rem]`, padding: "x-lg", children: [slide.overlay.subtitle && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-1 opacity-80 mb-1 md:mb-2 tracking-widest", mdSize: "sm", size: "xs", transform: "uppercase", children: slide.overlay.subtitle })), slide.overlay.title && (
202
206
  // level={2}, NOT 1. The welcome section above this
203
207
  // carousel owns the page's single <h1>; a carousel that
204
208
  // emits one <h1> per slide gives the document as many
@@ -17,6 +17,7 @@ import { getHomepageFaqsCached } from "../../faq/utils/getHomepageFaqsCached.js"
17
17
  import { safeRead } from "../../../errors/safe-read.js";
18
18
  import { fetchLiveStats } from "../lib/live-stats.js";
19
19
  import { renderSection, AnnouncementBar } from "../lib/section-renderer.js";
20
+ import { gateCarouselSlides } from "../lib/slide-gating.js";
20
21
  import { SECTION_COPY } from "../constants/section-copy.js";
21
22
  import { homepageSectionsRepository } from "../repository/homepage-sections.repository.js";
22
23
  import { isListingTypeEnabled, isCategoryTypeEnabled, } from "../../../_internal/shared/listing-types/feature-flags.js";
@@ -215,6 +216,20 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
215
216
  ? eventsResult.items
216
217
  : undefined,
217
218
  };
218
- const carouselSlides = toCarouselSlides(slides);
219
+ /*
220
+ * The same "do not advertise what we do not sell" rule the section filter
221
+ * above applies, one level deeper.
222
+ *
223
+ * `enabledSections` is keyed on `SectionType`, so it can drop the auctions
224
+ * RAIL but is structurally blind to a carousel — a carousel is one section
225
+ * of type `carousel` whatever its slides happen to be about. With
226
+ * `auction: false` live, the hero still shipped a "Live auctions" card and a
227
+ * full "Win it at your price" slide, both pointing at `/auctions`.
228
+ *
229
+ * Gated on the LINK rather than the copy: "Win it at your price" names no
230
+ * type, so a keyword scan would miss the worse of the two offenders while
231
+ * eating innocent ones. See `slide-gating.ts`.
232
+ */
233
+ const carouselSlides = gateCarouselSlides(toCarouselSlides(slides), siteSettings);
219
234
  return (_jsx(Main, { children: _jsxs(Div, { className: "relative", children: [showAnnouncement ? (_jsx(TopBannerPortal, { children: _jsx(AnnouncementBar, { message: announcementMessage, link: siteSettings?.announcementBar?.link, onDismiss: onBannerDismiss }) })) : null, orderedSections.map((section) => renderSection(section, adSlots, newsletterFormSlot ?? null, faqItems, carouselSlides, liveStats, sectionData, brand))] }) }));
220
235
  }
@@ -19,8 +19,13 @@ export interface WelcomeSectionProps {
19
19
  onSecondaryCtaClick?: () => void;
20
20
  trustChips?: WelcomeSectionChip[];
21
21
  /**
22
- * Decorative hero imagery, MOST IMPORTANT FIRST — the first takes the large
23
- * mosaic cell. Below `MOSAIC_MIN` (3) the brand-mark panel is used instead.
22
+ * Decorative hero imagery for the mosaic's SMALL cells — the large cell is
23
+ * always the brand panel now, so these no longer compete for it.
24
+ *
25
+ * 🛑 Pass REAL images only. A caller sourcing these from
26
+ * `display.coverImage` must drop `SEED_TILE_PREFIX` values first: those are
27
+ * six flat generated SVGs, and a hero built from them is four coloured
28
+ * rectangles claiming to be a product preview.
24
29
  *
25
30
  * Must be deterministic: `ImageMosaic` is explicit that `Math.random()` here
26
31
  * would break SSR/hydration agreement, so the caller passes a stable
@@ -29,6 +34,14 @@ export interface WelcomeSectionProps {
29
34
  heroImages?: readonly string[];
30
35
  isLoading?: boolean;
31
36
  brandLogoText?: string;
37
+ /**
38
+ * Wordmark caption under the hero logo, e.g. "Let It Rip India".
39
+ *
40
+ * Consumer-supplied, never defaulted to a literal here: appkit is a library
41
+ * and a brand string baked into it is the encapsulation break
42
+ * `audit-ssr-in-appkit` Rule 3 exists to prevent. Omitted = no caption.
43
+ */
44
+ brandCaption?: string;
32
45
  className?: string;
33
46
  }
34
- export declare function WelcomeSection({ title, subtitle, pillLabel, showCTA, ctaLabel, ctaHref, onCtaClick, secondaryCtaLabel, secondaryCtaHref, onSecondaryCtaClick, trustChips, heroImages, isLoading, brandLogoText, className, }: WelcomeSectionProps): React.JSX.Element;
47
+ export declare function WelcomeSection({ title, subtitle, pillLabel, showCTA, ctaLabel, ctaHref, onCtaClick, secondaryCtaLabel, secondaryCtaHref, onSecondaryCtaClick, trustChips, heroImages, isLoading, brandLogoText, brandCaption, className, }: WelcomeSectionProps): React.JSX.Element;