@mohasinac/appkit 4.56.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 (52) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.d.ts +31 -0
  3. package/dist/_internal/server/features/checkout/actions.js +117 -5
  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/shared/actions/action-registry.js +53 -0
  8. package/dist/_internal/shared/shipping/quote.js +5 -6
  9. package/dist/client.d.ts +21 -0
  10. package/dist/client.js +2 -0
  11. package/dist/features/about/components/PublicProfileView.js +2 -1
  12. package/dist/features/account/components/UserOrdersView.js +25 -3
  13. package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
  14. package/dist/features/admin/components/OwnerQuickActions.js +53 -6
  15. package/dist/features/blog/components/BlogPostView.js +2 -1
  16. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  17. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  18. package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
  19. package/dist/features/faq/components/FAQPageContent.js +17 -4
  20. package/dist/features/faq/constants/help-guides.d.ts +36 -0
  21. package/dist/features/faq/constants/help-guides.js +64 -0
  22. package/dist/features/homepage/components/HeroCarousel.js +3 -1
  23. package/dist/features/homepage/lib/section-renderer.js +2 -1
  24. package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
  25. package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
  26. package/dist/features/orders/components/index.d.ts +1 -0
  27. package/dist/features/orders/components/index.js +1 -0
  28. package/dist/features/orders/schemas/index.d.ts +2 -2
  29. package/dist/features/orders/types/index.d.ts +15 -0
  30. package/dist/features/points/repository/points.repository.js +5 -5
  31. package/dist/features/points/schemas/firestore.d.ts +16 -0
  32. package/dist/features/points/schemas/firestore.js +17 -0
  33. package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
  34. package/dist/features/stores/components/StoreHeader.js +3 -1
  35. package/dist/next/routing/route-map.d.ts +4 -0
  36. package/dist/next/routing/route-map.js +2 -0
  37. package/dist/schemas/registry.d.ts +2 -2
  38. package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
  39. package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
  40. package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
  41. package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
  42. package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
  43. package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
  44. package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
  45. package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
  46. package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
  47. package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
  48. package/dist/seed/_helpers/category-models.d.ts +9 -2
  49. package/dist/seed/_helpers/category-models.js +19 -1368
  50. package/dist/server.d.ts +3 -1
  51. package/dist/server.js +11 -0
  52. package/package.json +1 -1
@@ -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
  }
@@ -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
  }
@@ -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) {
@@ -200,7 +202,7 @@ export function HeroCarousel({ initialSlides, push, height, defaultAutoplayDelay
200
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) => {
201
203
  const slideHeightClass = getSlideHeightClass(slide.settings?.height);
202
204
  const hasCards = slide.cards && slide.cards.length > 0;
203
- 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 && (
204
206
  // level={2}, NOT 1. The welcome section above this
205
207
  // carousel owns the page's single <h1>; a carousel that
206
208
  // emits one <h1> per slide gives the document as many
@@ -48,6 +48,7 @@ import { CollectionCardsSection } from "../components/CollectionCardsSection.js"
48
48
  import { HowItWorksSection } from "../components/HowItWorksSection.js";
49
49
  import { TestimonialsCarousel } from "../components/TestimonialsCarousel.js";
50
50
  import { PromoGrid } from "../components/PromoGrid.js";
51
+ import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
51
52
  const AD_SLOT_MAP = {
52
53
  carousel: "afterHero",
53
54
  products: "afterFeaturedProducts",
@@ -433,6 +434,6 @@ export function renderSection(section, adSlots, newsletterFormSlot, faqItems, sl
433
434
  if (!sectionElement)
434
435
  return null;
435
436
  const adSlotKey = AD_SLOT_MAP[section.type];
436
- return (_jsxs(React.Fragment, { children: [_jsx(HomepageSectionBoundary, { sectionId: section.id, sectionType: section.type, children: sectionElement }), adSlots && adSlotKey !== undefined && adSlotKey in adSlots && adSlots[adSlotKey]] }, section.id));
437
+ return (_jsxs(React.Fragment, { children: [_jsx(OwnerQuickActions, { resource: "SECTION", placement: "detail", className: "justify-end", recordLabel: section.type, editHref: String(ROUTES.ADMIN.SECTIONS) }), _jsx(HomepageSectionBoundary, { sectionId: section.id, sectionType: section.type, children: sectionElement }), adSlots && adSlotKey !== undefined && adSlotKey in adSlots && adSlots[adSlotKey]] }, section.id));
437
438
  }
438
439
  export { AnnouncementBar, hashBannerMessage };
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import type { EmiInstallment } from "../schemas/firestore";
3
+ export interface EmiInstallmentsPanelProps {
4
+ orderId: string;
5
+ installments: readonly EmiInstallment[];
6
+ /** Sum of unpaid instalments, as recorded on the order. */
7
+ remainingBalance?: number;
8
+ currency?: string;
9
+ /**
10
+ * Submits one instalment. Supplied by the consumer so this component does
11
+ * no fetching of its own — the same slot shape the rest of the orders
12
+ * feature uses.
13
+ */
14
+ onSubmit: (input: {
15
+ installmentIndex: number;
16
+ transactionId: string;
17
+ }) => Promise<void>;
18
+ }
19
+ export declare function EmiInstallmentsPanel({ orderId, installments, remainingBalance, currency, onSubmit, }: EmiInstallmentsPanelProps): React.JSX.Element;
@@ -0,0 +1,57 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { Badge, Button, Div, Form, Row, Stack, Text, useToast } from "../../../ui/index.js";
5
+ import { FieldInput } from "../../../ui/forms/index.js";
6
+ import { formatCurrency } from "../../../utils/number.formatter.js";
7
+ import { normalizeError } from "../../../errors/normalize.js";
8
+ function stateOf(i) {
9
+ if (i.status === "paid")
10
+ return "paid";
11
+ // A reference the buyer submitted but nobody has confirmed. The status
12
+ // field cannot express this: an instalment is created "pending" and stays
13
+ // "pending" until it is paid, so the presence of a reference is the signal.
14
+ if (i.transactionId)
15
+ return "awaiting-review";
16
+ return "payable";
17
+ }
18
+ const STATE_LABEL = {
19
+ paid: "Paid",
20
+ "awaiting-review": "Awaiting confirmation",
21
+ payable: "Due",
22
+ };
23
+ export function EmiInstallmentsPanel({ orderId, installments, remainingBalance, currency = "INR", onSubmit, }) {
24
+ const { showToast } = useToast();
25
+ const [openIndex, setOpenIndex] = useState(null);
26
+ const [reference, setReference] = useState("");
27
+ const [isSubmitting, setIsSubmitting] = useState(false);
28
+ const money = (value) => formatCurrency(value, currency);
29
+ const ordered = [...installments].sort((a, b) => a.index - b.index);
30
+ const outstanding = ordered.filter((i) => i.status !== "paid").length;
31
+ const submit = async (index) => {
32
+ setIsSubmitting(true);
33
+ try {
34
+ await onSubmit({ installmentIndex: index, transactionId: reference.trim() });
35
+ showToast("Payment reference submitted — the seller will confirm it.", "success");
36
+ setOpenIndex(null);
37
+ setReference("");
38
+ }
39
+ catch (err) {
40
+ void normalizeError(err);
41
+ // The action's own message is surfaced by the caller's error mapping;
42
+ // this only has to stop the panel pretending the submission landed.
43
+ showToast("Couldn't submit that payment. Please try again.", "error");
44
+ }
45
+ finally {
46
+ setIsSubmitting(false);
47
+ }
48
+ };
49
+ return (_jsx(Div, { surface: "card", border: "default", rounded: "lg", padding: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", children: [_jsx(Text, { weight: "semibold", children: "Your EMI plan" }), remainingBalance !== undefined && remainingBalance > 0 && (_jsxs(Text, { size: "sm", color: "muted", children: [money(remainingBalance), " outstanding across ", outstanding, " instalment", outstanding === 1 ? "" : "s"] }))] }), _jsx(Text, { size: "xs", color: "muted", children: "You can pay any instalment whenever you like, including ahead of its due date \u2014 paying early settles the plan sooner and does not cost more." }), _jsx(Stack, { gap: "xs", children: ordered.map((inst) => {
50
+ const state = stateOf(inst);
51
+ const due = inst.dueDate ? new Date(inst.dueDate) : null;
52
+ return (_jsxs(Div, { border: "default", rounded: "md", padding: "sm", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsxs(Text, { size: "sm", weight: "medium", children: ["Instalment ", inst.index, " \u00B7 ", money(inst.amount)] }), _jsx(Text, { size: "xs", color: "muted", children: due ? `Due ${due.toLocaleDateString()}` : "No due date recorded" })] }), _jsxs(Row, { align: "center", gap: "xs", className: "flex-shrink-0", children: [_jsx(Badge, { variant: state === "paid" ? "success" : state === "awaiting-review" ? "warning" : "default", children: STATE_LABEL[state] }), state === "payable" && (_jsx(Button, { type: "button", size: "sm", variant: "secondary", onClick: () => {
53
+ setOpenIndex(openIndex === inst.index ? null : inst.index);
54
+ setReference("");
55
+ }, children: openIndex === inst.index ? "Cancel" : "Pay now" }))] })] }), openIndex === inst.index && (_jsx(Div, { className: "mt-3", children: _jsx(Form, { onSubmit: (e) => e.preventDefault(), children: () => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Transfer ", money(inst.amount), " to the seller, then enter the UTR or reference number so they can confirm it."] }), _jsx(FieldInput, { name: `emi-reference-${orderId}-${inst.index}`, label: "UTR / reference number", required: true, value: reference, onChange: setReference }), _jsx(Button, { type: "submit", size: "sm", isLoading: isSubmitting, disabled: reference.trim().length === 0 || isSubmitting, onClick: () => { void submit(inst.index); }, children: "Submit payment reference" })] })) }) }))] }, inst.index));
56
+ }) })] }) }));
57
+ }
@@ -14,3 +14,4 @@ export { RefundRequestView } from "./RefundRequestView";
14
14
  export type { RefundRequestViewProps } from "./RefundRequestView";
15
15
  export { YourPurchaseStrip } from "./YourPurchaseStrip";
16
16
  export type { YourPurchaseStripProps, PurchaseContext } from "./YourPurchaseStrip";
17
+ export { EmiInstallmentsPanel, type EmiInstallmentsPanelProps, } from "./EmiInstallmentsPanel";
@@ -7,3 +7,4 @@ export { OrderStatusTimeline } from "./OrderStatusTimeline.js";
7
7
  export { RefundHistoryTable } from "./RefundHistoryTable.js";
8
8
  export { RefundRequestView } from "./RefundRequestView.js";
9
9
  export { YourPurchaseStrip } from "./YourPurchaseStrip.js";
10
+ export { EmiInstallmentsPanel, } from "./EmiInstallmentsPanel.js";
@@ -614,6 +614,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
614
614
  } | undefined;
615
615
  offerId?: string | undefined;
616
616
  orderType?: string | undefined;
617
+ isNonRefundable?: boolean | undefined;
617
618
  imageUrls?: string[] | undefined;
618
619
  refundFeeDeducted?: number | undefined;
619
620
  refundNetAmount?: number | undefined;
@@ -650,7 +651,6 @@ export declare const orderFirestoreSchema: z.ZodObject<{
650
651
  }[] | undefined;
651
652
  emiRemainingBalance?: number | undefined;
652
653
  emiComplete?: boolean | undefined;
653
- isNonRefundable?: boolean | undefined;
654
654
  paymentBatchId?: string | undefined;
655
655
  refunds?: {
656
656
  type: "full" | "partial";
@@ -762,6 +762,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
762
762
  } | undefined;
763
763
  offerId?: string | undefined;
764
764
  orderType?: string | undefined;
765
+ isNonRefundable?: boolean | undefined;
765
766
  imageUrls?: string[] | undefined;
766
767
  refundFeeDeducted?: number | undefined;
767
768
  refundNetAmount?: number | undefined;
@@ -798,7 +799,6 @@ export declare const orderFirestoreSchema: z.ZodObject<{
798
799
  }[] | undefined;
799
800
  emiRemainingBalance?: number | undefined;
800
801
  emiComplete?: boolean | undefined;
801
- isNonRefundable?: boolean | undefined;
802
802
  paymentBatchId?: string | undefined;
803
803
  refunds?: {
804
804
  type: "full" | "partial";
@@ -1,3 +1,4 @@
1
+ import type { EmiInstallment } from "../schemas/firestore";
1
2
  import type { ListingType } from "../../products/types/index";
2
3
  import type { OrderType } from "../utils/order-splitter";
3
4
  import type { AppliedOrderDiscount, OrderSourceContext } from "../schemas/firestore";
@@ -183,6 +184,20 @@ export interface Order {
183
184
  * read it from site settings, because `gst` is in `PRIVATE_SITE_FIELDS`
184
185
  * after an earlier deny-list projection shipped it publicly (Root Cause #70).
185
186
  */
187
+ /**
188
+ * ── EMI, buyer-facing ─────────────────────────────────────────────────
189
+ *
190
+ * 🛑 Declared here because `EmiInstallmentsPanel` is useless without it,
191
+ * and the absence was Root Cause #57's exact shape: the whole EMI schedule
192
+ * sat on `OrderDocument` and `orderDocumentToOrder` mapped none of it, so
193
+ * every buyer surface would have rendered its "no EMI plan" fallback with
194
+ * a real plan on the record. That reads as "this order isn't on EMI"
195
+ * rather than as a bug, which is why it survives.
196
+ */
197
+ emiEnabled?: boolean;
198
+ emiInstallments?: EmiInstallment[];
199
+ /** Sum of unpaid instalments. 0 once the plan is settled. */
200
+ emiRemainingBalance?: number;
186
201
  supplierGstin?: string;
187
202
  supplierLegalName?: string;
188
203
  supplierAddress?: string;
@@ -1,5 +1,5 @@
1
1
  import { getAdminDb } from "../../../providers/db-firebase/index.js";
2
- import { POINTS_LEDGER_COLLECTION, isPointsEntryWellFormed, } from "../schemas/firestore.js";
2
+ import { POINTS_FIELDS, POINTS_LEDGER_COLLECTION, isPointsEntryWellFormed, } from "../schemas/firestore.js";
3
3
  import { USER_COLLECTION } from "../../auth/schemas/firestore.js";
4
4
  /**
5
5
  * The points ledger: append-only entries, and a balance that is their sum.
@@ -51,8 +51,8 @@ export class PointsRepository {
51
51
  const db = getAdminDb();
52
52
  const rows = await db
53
53
  .collection(POINTS_LEDGER_COLLECTION)
54
- .where("userId", "==", userId)
55
- .select("amount")
54
+ .where(POINTS_FIELDS.USER_ID, "==", userId)
55
+ .select(POINTS_FIELDS.AMOUNT)
56
56
  .get();
57
57
  const total = rows.docs.reduce((sum, d) => sum + (Number(d.data().amount) || 0), 0);
58
58
  return Math.round(total * 100) / 100;
@@ -145,8 +145,8 @@ export class PointsRepository {
145
145
  const db = getAdminDb();
146
146
  const rows = await db
147
147
  .collection(POINTS_LEDGER_COLLECTION)
148
- .where("userId", "==", userId)
149
- .orderBy("createdAt", "desc")
148
+ .where(POINTS_FIELDS.USER_ID, "==", userId)
149
+ .orderBy(POINTS_FIELDS.CREATED_AT, "desc")
150
150
  .limit(limit)
151
151
  .get();
152
152
  return rows.docs.map((d) => d.data());
@@ -81,6 +81,22 @@ export interface PointsLedgerEntry extends BaseDocument {
81
81
  */
82
82
  export declare function isPointsEntryWellFormed(entry: Pick<PointsLedgerEntry, "kind" | "amount" | "adminReason">): boolean;
83
83
  export declare const POINTS_LEDGER_COLLECTION = "pointsLedger";
84
+ /**
85
+ * Firestore field names for this collection.
86
+ *
87
+ * Declared rather than written as string literals at the query site, matching
88
+ * every other collection here: a typo in a raw `where("userID", ...)` is a
89
+ * query that returns nothing and raises nothing, which on a points balance
90
+ * reads as "you have no points" rather than as a bug.
91
+ */
92
+ export declare const POINTS_FIELDS: {
93
+ readonly ID: "id";
94
+ readonly USER_ID: "userId";
95
+ readonly KIND: "kind";
96
+ readonly AMOUNT: "amount";
97
+ readonly ORDER_ID: "orderId";
98
+ readonly CREATED_AT: "createdAt";
99
+ };
84
100
  /**
85
101
  * 🛑 Points cannot pay for the whole order, and this is the cap that enforces
86
102
  * it.
@@ -71,6 +71,22 @@ export function isPointsEntryWellFormed(entry) {
71
71
  }
72
72
  }
73
73
  export const POINTS_LEDGER_COLLECTION = "pointsLedger";
74
+ /**
75
+ * Firestore field names for this collection.
76
+ *
77
+ * Declared rather than written as string literals at the query site, matching
78
+ * every other collection here: a typo in a raw `where("userID", ...)` is a
79
+ * query that returns nothing and raises nothing, which on a points balance
80
+ * reads as "you have no points" rather than as a bug.
81
+ */
82
+ export const POINTS_FIELDS = {
83
+ ID: "id",
84
+ USER_ID: "userId",
85
+ KIND: "kind",
86
+ AMOUNT: "amount",
87
+ ORDER_ID: "orderId",
88
+ CREATED_AT: "createdAt",
89
+ };
74
90
  /**
75
91
  * 🛑 Points cannot pay for the whole order, and this is the cap that enforces
76
92
  * it.
@@ -94,6 +110,7 @@ export function maxRedeemablePoints(orderTotal, balance) {
94
110
  return 0;
95
111
  if (!Number.isFinite(balance) || balance <= 0)
96
112
  return 0;
113
+ // audit-money-units-ok: a percentage of an order total, not a paise/rupee conversion
97
114
  const cap = Math.floor((orderTotal * POINTS_MAX_REDEMPTION_PERCENT) / 100);
98
115
  return Math.max(0, Math.min(Math.floor(balance), cap));
99
116
  }
@@ -2,10 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import Link from "next/link.js";
3
3
  import { reviewRepository } from "../../../repositories/index.js";
4
4
  import { safeRead } from "../../../errors/safe-read.js";
5
- import { Container, Div, Grid, Heading, Main, Nav, Section, Span, Text } from "../../../ui/index.js";
5
+ import { Container, Div, Grid, Heading, Main, Nav, Row, Section, Span, Text } from "../../../ui/index.js";
6
6
  import { ROUTES } from "../../../next/routing/route-map.js";
7
7
  import { ReviewDetailShell } from "./ReviewDetailShell.js";
8
8
  import { ReviewCard } from "./ReviewsList.js";
9
+ import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
9
10
  export async function ReviewDetailPageView({ id }) {
10
11
  // The review IS this page's subject — a read failure must surface rather than
11
12
  // become the same `null` a removed review produces, which the "Review not
@@ -42,5 +43,5 @@ export async function ReviewDetailPageView({ id }) {
42
43
  ]);
43
44
  const sameProductReviews = sameProductDocs.filter((r) => r.id !== review.id).slice(0, 6);
44
45
  const sameStoreReviews = sameStoreDocs.filter((r) => r.id !== review.id).slice(0, 6);
45
- return (_jsxs(Main, { children: [_jsx(Div, { paddingY: "y-xs-tall", className: "border-b border-neutral-100 bg-neutral-50", padding: "x-md", children: _jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "xs", color: "faint", className: "mx-auto max-w-3xl", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary transition-colors", children: "Home" }), _jsx(Span, { children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.REVIEWS), className: "hover:text-primary transition-colors", children: "Reviews" }), _jsx(Span, { children: "/" }), _jsx(Span, { className: "text-neutral-700 truncate max-w-[200px]", children: review.title ?? review.id })] }) }), _jsx(ReviewDetailShell, { review: review, storeHref: storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null }), sameProductReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.productTitle ?? "this product"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameProductReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "listing" }, r.id))) })] }) })), sameStoreReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.storeName ?? "this store"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameStoreReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "store" }, r.id))) })] }) }))] }));
46
+ return (_jsxs(Main, { children: [_jsx(Div, { paddingY: "y-xs-tall", className: "border-b border-neutral-100 bg-neutral-50", padding: "x-md", children: _jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "xs", color: "faint", className: "mx-auto max-w-3xl", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary transition-colors", children: "Home" }), _jsx(Span, { children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.REVIEWS), className: "hover:text-primary transition-colors", children: "Reviews" }), _jsx(Span, { children: "/" }), _jsx(Span, { className: "text-neutral-700 truncate max-w-[200px]", children: review.title ?? review.id })] }) }), _jsx(Row, { justify: "end", className: "mb-2", children: _jsx(OwnerQuickActions, { resource: "REVIEW", placement: "detail", ownerId: review.userId, recordLabel: review.title ?? review.id, editHref: String(ROUTES.USER.REVIEW_EDIT(review.id)) }) }), _jsx(ReviewDetailShell, { review: review, storeHref: storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null }), sameProductReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.productTitle ?? "this product"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameProductReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "listing" }, r.id))) })] }) })), sameStoreReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.storeName ?? "this store"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameStoreReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "store" }, r.id))) })] }) }))] }));
46
47
  }