@mohasinac/appkit 4.33.1 → 4.34.1

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 (104) hide show
  1. package/dist/_internal/server/features/checkout/actions.js +9 -1
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +9 -0
  3. package/dist/_internal/server/features/media/contextGuards.d.ts +3 -3
  4. package/dist/_internal/server/features/media/contextGuards.js +19 -5
  5. package/dist/_internal/server/features/orders/actions.d.ts +9 -0
  6. package/dist/_internal/server/features/orders/actions.js +47 -4
  7. package/dist/_internal/server/features/orders/adapters.js +10 -0
  8. package/dist/_internal/server/features/refunds/actions.d.ts +9 -1
  9. package/dist/_internal/server/features/refunds/actions.js +36 -3
  10. package/dist/_internal/server/features/seo/sitemap.js +2 -2
  11. package/dist/_internal/server/features/site-settings/adapters.js +1 -1
  12. package/dist/_internal/server/functions/scheduled.d.ts +1 -2
  13. package/dist/_internal/server/functions/scheduled.js +8 -9
  14. package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +15 -0
  15. package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +15 -6
  16. package/dist/_internal/server/jobs/handlers/index.d.ts +0 -1
  17. package/dist/_internal/server/jobs/handlers/index.js +0 -1
  18. package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
  19. package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
  20. package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
  21. package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
  22. package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
  23. package/dist/_internal/shared/features/orders/schema.js +20 -0
  24. package/dist/_internal/shared/features/products/schema.d.ts +12 -0
  25. package/dist/_internal/shared/features/products/schema.js +4 -1
  26. package/dist/_internal/shared/media/limits.d.ts +14 -0
  27. package/dist/_internal/shared/media/limits.js +29 -0
  28. package/dist/client.d.ts +9 -1
  29. package/dist/client.js +19 -1
  30. package/dist/features/admin/components/AdminProductEditorView.js +6 -1
  31. package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
  32. package/dist/features/admin/components/index.d.ts +0 -2
  33. package/dist/features/admin/components/index.js +0 -1
  34. package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
  35. package/dist/features/admin/schemas/firestore.d.ts +0 -2
  36. package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
  37. package/dist/features/auth/schemas/firestore.d.ts +1 -0
  38. package/dist/features/contact/components/ContactForm.js +46 -31
  39. package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
  40. package/dist/features/media/upload/MediaUploadList.js +58 -8
  41. package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
  42. package/dist/features/orders/components/RefundRequestView.js +108 -24
  43. package/dist/features/orders/schemas/firestore.d.ts +42 -0
  44. package/dist/features/orders/types/index.d.ts +24 -0
  45. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
  46. package/dist/features/products/components/ProductDetailPageView.js +14 -2
  47. package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
  48. package/dist/features/products/components/ProductFeatureBadges.js +17 -1
  49. package/dist/features/products/components/ProductForm.js +46 -10
  50. package/dist/features/products/components/ProductGrid.js +10 -1
  51. package/dist/features/products/constants/final-sale.d.ts +40 -0
  52. package/dist/features/products/constants/final-sale.js +40 -0
  53. package/dist/features/products/index.d.ts +1 -0
  54. package/dist/features/products/index.js +1 -0
  55. package/dist/features/products/schemas/firestore.d.ts +19 -2
  56. package/dist/features/products/schemas/firestore.js +4 -0
  57. package/dist/features/products/types/index.d.ts +2 -0
  58. package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
  59. package/dist/features/seller/components/SellerProductShell.js +82 -11
  60. package/dist/features/seller/index.d.ts +1 -0
  61. package/dist/features/seller/index.js +1 -0
  62. package/dist/features/seller/schemas/product-form.d.ts +304 -0
  63. package/dist/features/seller/schemas/product-form.js +132 -0
  64. package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
  65. package/dist/features/seller/utils/product-draft-mapping.js +147 -0
  66. package/dist/features/shell/SectionForm.js +62 -7
  67. package/dist/features/shell/form-sections.js +4 -0
  68. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +16 -1
  69. package/dist/features/tester/repository/tester-checklist-item.repository.js +32 -6
  70. package/dist/features/tester/seed-data/events-tester-seed-data.js +77 -5
  71. package/dist/features/tester/seed-data/offers-tester-seed-data.js +23 -9
  72. package/dist/features/tester/seed-data/products-tester-seed-data.js +65 -49
  73. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +182 -0
  74. package/dist/features/tester/seed-data/tester-window.d.ts +25 -0
  75. package/dist/features/tester/seed-data/tester-window.js +66 -0
  76. package/dist/index.d.ts +6 -3
  77. package/dist/index.js +12 -3
  78. package/dist/next/routing/route-map.d.ts +2 -0
  79. package/dist/next/routing/route-map.js +1 -0
  80. package/dist/schemas/registry.d.ts +271 -0
  81. package/dist/schemas/registry.js +9 -0
  82. package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
  83. package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
  84. package/dist/seed/products-art-seed-data.js +9 -1
  85. package/dist/seed/products-auctions-seed-data.js +2 -1
  86. package/dist/seed/products-classifieds-seed-data.js +2 -1
  87. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  88. package/dist/seed/products-live-items-seed-data.js +2 -1
  89. package/dist/seed/products-preorders-seed-data.d.ts +2 -138
  90. package/dist/seed/products-preorders-seed-data.js +2 -1
  91. package/dist/seed/products-prize-draws-seed-data.js +2 -1
  92. package/dist/seed/products-standard-seed-data.js +2 -1
  93. package/dist/seed/products-stickers-seed-data.js +9 -1
  94. package/dist/seed/users-seed-data.js +107 -4
  95. package/dist/server.d.ts +6 -2
  96. package/dist/server.js +15 -2
  97. package/dist/styles.css +1 -1
  98. package/dist/tailwind-utilities.css +1 -1
  99. package/dist/tokens/motion.d.ts +12 -0
  100. package/dist/tokens/motion.js +12 -0
  101. package/dist/ui/components/CollapsibleSection.d.ts +18 -1
  102. package/dist/ui/components/CollapsibleSection.js +20 -3
  103. package/dist/ui/components/Motion.js +17 -1
  104. package/package.json +1 -1
@@ -6,6 +6,7 @@ import type { OrderType } from "../utils/order-splitter";
6
6
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
7
7
  import type { ListingType } from "../../products/types";
8
8
  import type { StatusChangeEntry } from "../../../_internal/shared/history/index";
9
+ import type { ReturnReason } from "../../../_internal/shared/features/orders/return-reasons";
9
10
  export interface OrderSourceStandard {
10
11
  path: "standard";
11
12
  listPrice: number;
@@ -203,6 +204,20 @@ export interface OrderDocumentItem {
203
204
  /** P-8 GST — snapshotted from the product at order time so the invoice stays accurate even if the product's HSN/rate later changes. */
204
205
  hsnCode?: string;
205
206
  gstRate?: 0 | 5 | 12 | 18 | 28;
207
+ /**
208
+ * Whether THIS line was final sale at the moment of purchase.
209
+ *
210
+ * Snapshotted, exactly like `gstRate` and `hsnCode` above and for the same
211
+ * reason: it is a term the buyer agreed to at checkout. Re-reading
212
+ * `product.finalSale` at refund time would let a seller flip the switch
213
+ * after the sale and retroactively refuse a return the buyer was promised —
214
+ * or, in the other direction, lose a restriction the buyer accepted.
215
+ *
216
+ * ALWAYS an explicit boolean here, resolved through `isFinalSale()` by the
217
+ * order-row builder, so no downstream reader has to know that absence means
218
+ * true on the product. Absence HERE means the order predates the field.
219
+ */
220
+ finalSale?: boolean;
206
221
  }
207
222
  /**
208
223
  * One refund event on an order. Multiple partial refund events are allowed
@@ -217,7 +232,17 @@ export interface OrderRefundEvent {
217
232
  amount: number;
218
233
  /** ProductIds / itemIds that were refunded (for partial refunds). */
219
234
  itemIds?: string[];
235
+ /**
236
+ * Human-readable reason, rendered by `RefundHistoryTable`. Derived from
237
+ * `reasonCode` (plus any buyer note) rather than typed freehand, so the
238
+ * prose and the code can never disagree about why a refund happened.
239
+ */
220
240
  reason: string;
241
+ /**
242
+ * Coded reason. Optional only because refunds recorded before the enum
243
+ * existed have none — every new refund sets it.
244
+ */
245
+ reasonCode?: ReturnReason;
221
246
  refundedAt: Date;
222
247
  refundedBy: string;
223
248
  /** Set when Razorpay processed the refund. */
@@ -280,6 +305,23 @@ export interface OrderDocument extends BaseDocument {
280
305
  deliveryDate?: Date;
281
306
  cancellationDate?: Date;
282
307
  cancellationReason?: string;
308
+ /**
309
+ * Why the buyer asked to return this order. Closed enum — see
310
+ * `_internal/shared/features/orders/return-reasons`.
311
+ *
312
+ * `requestReturnAction` accepted a free-text reason and then discarded it
313
+ * before writing, so until now an order could reach `return_requested` with
314
+ * no recorded reason at all and the seller had nothing to act on.
315
+ */
316
+ returnReasonCode?: ReturnReason;
317
+ /** Optional buyer elaboration alongside the coded reason. */
318
+ returnReasonNote?: string;
319
+ returnRequestedAt?: Date;
320
+ /**
321
+ * Which lines the buyer wants to return. Absent means the whole order —
322
+ * the same convention `processRefundAction`'s `itemIds` already uses.
323
+ */
324
+ returnRequestedItemIds?: string[];
283
325
  refundAmount?: number;
284
326
  refundStatus?: RefundStatus;
285
327
  refundFeeDeducted?: number;
@@ -46,6 +46,15 @@ export interface OrderItem {
46
46
  bundleCategorySlug?: string;
47
47
  /** Snapshot of bundle.bundleProductIds at order-creation time. */
48
48
  bundleProductIds?: string[];
49
+ /**
50
+ * Whether this line was final sale when it was bought. Snapshotted on the
51
+ * order document; surfaced here so the buyer's return form can narrow the
52
+ * reason list without a second fetch.
53
+ *
54
+ * Absent means the order predates the field — NOT "returnable". The server
55
+ * gate reads the document, so this is a rendering hint only.
56
+ */
57
+ finalSale?: boolean;
49
58
  }
50
59
  /**
51
60
  * One entry on an order's timeline — the client-facing shape of
@@ -106,6 +115,21 @@ export interface Order {
106
115
  couponDiscount?: number;
107
116
  /** Every discount applied to this order — the full stack, not just the first. */
108
117
  appliedDiscounts?: AppliedOrderDiscount[];
118
+ /**
119
+ * Listing-TYPE level block (prize-draw entries). Distinct from per-line
120
+ * `finalSale`: this refuses every reason, that one refuses only
121
+ * change-of-mind reasons.
122
+ */
123
+ isNonRefundable?: boolean;
124
+ /**
125
+ * False once a refund has been posted — no further dispute or RMA.
126
+ * Was never mapped, so the buyer-facing refund form could not render its
127
+ * "already refunded" state at all.
128
+ */
129
+ contestable?: boolean;
130
+ /** Coded reason recorded when the buyer requested a return. */
131
+ returnReasonCode?: string;
132
+ returnReasonNote?: string;
109
133
  /**
110
134
  * Paid add-ons the buyer opted into for THIS order (= this store).
111
135
  *
@@ -25,6 +25,7 @@ import { CustomSectionTabContent } from "../../products/components/CustomSection
25
25
  import { PreOrderActionsClient } from "./PreOrderActionsClient";
26
26
  import { ProductGalleryClient } from "../../products/components/ProductGalleryClient";
27
27
  import { ProductFeatureBadges } from "../../products/components/ProductFeatureBadges";
28
+ import { isFinalSale } from "../../products/constants/final-sale";
28
29
  import { FeatureBadgeList } from "../../products/components/FeatureBadge";
29
30
  import { ShareButton } from "../../products/components/ShareButton";
30
31
  import { SublistingCarouselSection } from "../../products/components/SublistingCarouselSection";
@@ -45,8 +46,8 @@ const PRODUCTION_STATUS_LABELS = {
45
46
  in_production: "In Production",
46
47
  ready_to_ship: "Ready to Ship",
47
48
  };
48
- function PreOrderInfoSection({ title, productionStatus, maxPerUser, deliveryDate, featured, freeShipping, condition, isCancellable, category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref, }) {
49
- return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_PREORDER_BADGE, children: "Pre-Order" }), productionStatus && (_jsx(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: PRODUCTION_STATUS_LABELS[productionStatus] ?? productionStatus })), maxPerUser !== null && (_jsxs(Span, { size: "xs", weight: "medium", className: CLS_STATUS_BADGE, children: ["Limit: ", maxPerUser, " per customer"] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), deliveryDate && (_jsxs(Row, { color: "muted", textSize: "sm", align: "center", gap: "xs", children: [_jsx(Span, { children: "\uD83D\uDCC5" }), _jsx(Span, { children: "Estimated delivery:" }), _jsx(Span, { weight: "medium", children: deliveryDate.toLocaleDateString(undefined, { year: "numeric", month: "long" }) })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, returnable: isCancellable, labels: {
49
+ function PreOrderInfoSection({ title, productionStatus, maxPerUser, deliveryDate, featured, freeShipping, condition, isCancellable, finalSale, category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref, }) {
50
+ return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_PREORDER_BADGE, children: "Pre-Order" }), productionStatus && (_jsx(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: PRODUCTION_STATUS_LABELS[productionStatus] ?? productionStatus })), maxPerUser !== null && (_jsxs(Span, { size: "xs", weight: "medium", className: CLS_STATUS_BADGE, children: ["Limit: ", maxPerUser, " per customer"] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), deliveryDate && (_jsxs(Row, { color: "muted", textSize: "sm", align: "center", gap: "xs", children: [_jsx(Span, { children: "\uD83D\uDCC5" }), _jsx(Span, { children: "Estimated delivery:" }), _jsx(Span, { weight: "medium", children: deliveryDate.toLocaleDateString(undefined, { year: "numeric", month: "long" }) })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, returnable: isCancellable, finalSale: finalSale, labels: {
50
51
  featured: "Featured",
51
52
  fasterDelivery: "Faster Delivery",
52
53
  ratedSeller: "Rated Seller",
@@ -56,6 +57,7 @@ function PreOrderInfoSection({ title, productionStatus, maxPerUser, deliveryDate
56
57
  conditionBroken: "For Parts",
57
58
  conditionRefurbished: "Refurbished",
58
59
  returnable: "Cancellable",
60
+ finalSale: "Final Sale",
59
61
  freeShipping: "Free Shipping",
60
62
  codAvailable: "Cash on Delivery",
61
63
  emiAvailable: "EMI Available",
@@ -162,7 +164,7 @@ export async function PreOrderDetailPageView({ id, initialPreOrder, onReserveNow
162
164
  price: typeof p.price === "number" ? p.price : undefined,
163
165
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
164
166
  storeName: typeof p.storeName === "string" ? p.storeName : undefined,
165
- } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRE_ORDERS), className: CLS_BREADCRUMB_LINK, children: "Pre-Orders" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title })), renderInfo: () => (_jsx(PreOrderInfoSection, { title: title, productionStatus: productionStatus, maxPerUser: maxPerUser, deliveryDate: deliveryDate, featured: featured, freeShipping: freeShipping, condition: condition, isCancellable: isCancellable, category: category, categoryName: categoryName, brand: brand, brandSlug: brandSlug, productFeatures: productFeatures, features: features, descriptionHtml: descriptionHtml, safeSeller: safeSeller, storeHref: storeHref })), renderSublistingSection: sublistingCategoryId
167
+ } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRE_ORDERS), className: CLS_BREADCRUMB_LINK, children: "Pre-Orders" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title })), renderInfo: () => (_jsx(PreOrderInfoSection, { title: title, productionStatus: productionStatus, maxPerUser: maxPerUser, deliveryDate: deliveryDate, featured: featured, freeShipping: freeShipping, condition: condition, isCancellable: isCancellable, finalSale: isFinalSale(p), category: category, categoryName: categoryName, brand: brand, brandSlug: brandSlug, productFeatures: productFeatures, features: features, descriptionHtml: descriptionHtml, safeSeller: safeSeller, storeHref: storeHref })), renderSublistingSection: sublistingCategoryId
166
168
  ? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
167
169
  : undefined, renderGroupSection: groupId
168
170
  ? () => (_jsx(ShowGroupSection, { groupId: groupId, currentSlug: String(p.slug ?? product.id), isParent: isGroupParent, groupTitle: groupTitle }))
@@ -14,6 +14,8 @@ const CLS_STOCK_IN = "bg-success-surface text-success dark:bg-success-surface da
14
14
  const CLS_STOCK_OUT = "bg-error-surface text-error dark:bg-error-surface dark:text-error";
15
15
  const CLS_BUNDLE_PILL = "inline-flex items-center gap-[var(--appkit-space-1)] rounded-full border border-success bg-success-surface px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-semibold text-success transition-colors hover:border-success hover:bg-success-surface dark:border-success/60 dark:bg-success-surface dark:text-success dark:hover:border-success dark:hover:bg-success-surface";
16
16
  const CLS_FREE_SHIPPING_ICON = "mt-0.5 flex-shrink-0 text-success";
17
+ /** Leading icon in a Delivery & Returns row. */
18
+ const CLS_POLICY_ROW_ICON = "mt-0.5 flex-shrink-0";
17
19
  const CLS_BUNDLE_BOX = "rounded-xl border border-success dark:border-success/60 bg-success-surface dark:bg-success-surface p-[var(--appkit-space-5)]";
18
20
  const CLS_BUNDLE_ICON = "text-success dark:text-success";
19
21
  const CLS_BUNDLE_TITLE = "text-[length:var(--appkit-text-sm)] font-semibold text-success dark:text-success";
@@ -30,6 +32,7 @@ import { ProductDetailView } from "./ProductDetailView";
30
32
  import { ProductGalleryClient } from "./ProductGalleryClient";
31
33
  import { ProductTabsShell } from "./ProductTabsShell";
32
34
  import { ProductFeatureBadges } from "./ProductFeatureBadges";
35
+ import { isFinalSale } from "../constants/final-sale";
33
36
  import { FeatureBadgeList } from "./FeatureBadge";
34
37
  import { ShareButton } from "./ShareButton";
35
38
  import { CustomSectionTabContent } from "./CustomSectionTabContent";
@@ -163,6 +166,12 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
163
166
  : DEFAULT_MIN_OFFER_PERCENT;
164
167
  const shippingInfo = typeof p.shippingInfo === "string" ? p.shippingInfo : null;
165
168
  const returnPolicy = typeof p.returnPolicy === "string" ? p.returnPolicy : null;
169
+ /*
170
+ * Absent means final sale, so this must go through `isFinalSale` — reading
171
+ * `p.finalSale` directly would treat every listing created before the field
172
+ * existed as returnable.
173
+ */
174
+ const finalSale = isFinalSale(p);
166
175
  const shippingPaidBy = p.shippingPaidBy;
167
176
  const freeShipping = shippingPaidBy === "seller";
168
177
  const featured = p.featured === true;
@@ -207,7 +216,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
207
216
  ? ` — only ${effectiveStock} left`
208
217
  : ""] })] })) : (_jsxs(Span, { layout: "inline-flex", gap: "xs", className: `w-fit .5 ${inStock ? CLS_STOCK_IN : CLS_STOCK_OUT}`, rounded: "full", padding: "pill-md", size: "xs", weight: "medium", children: [inStock ? "✓ In Stock" : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
209
218
  ? ` — only ${effectiveStock} left`
210
- : ""] })), (categorySlugs.length > 0 || subcategory || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [categorySlugs.map((slug, i) => (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(slug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryNames[i] ?? slug }, slug))), subcategory && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", transform: "capitalize", children: subcategory })), brand && brandSlug && (_jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand })), brand && !brandSlug && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand }))] })), bundleMemberships.length > 0 && (_jsx(Row, { gap: "sm", wrap: true, children: bundleMemberships.map((b) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: CLS_BUNDLE_PILL, children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), "In bundle: ", b.title] }, b.id))) })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, codAvailable: codEnabled === true, codHref: String(ROUTES.PUBLIC.HOW_CHECKOUT_WORKS), emiAvailable: emiEnabled === true, emiHref: String(ROUTES.PUBLIC.HOW_EMI_WORKS), condition: condition ?? undefined, returnable: returnPolicy != null && returnPolicy.length > 0, labels: {
219
+ : ""] })), (categorySlugs.length > 0 || subcategory || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [categorySlugs.map((slug, i) => (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(slug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryNames[i] ?? slug }, slug))), subcategory && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", transform: "capitalize", children: subcategory })), brand && brandSlug && (_jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand })), brand && !brandSlug && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand }))] })), bundleMemberships.length > 0 && (_jsx(Row, { gap: "sm", wrap: true, children: bundleMemberships.map((b) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: CLS_BUNDLE_PILL, children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), "In bundle: ", b.title] }, b.id))) })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, codAvailable: codEnabled === true, codHref: String(ROUTES.PUBLIC.HOW_CHECKOUT_WORKS), emiAvailable: emiEnabled === true, emiHref: String(ROUTES.PUBLIC.HOW_EMI_WORKS), condition: condition ?? undefined, returnable: !finalSale, finalSale: finalSale, labels: {
211
220
  featured: "Featured",
212
221
  fasterDelivery: "Faster Delivery",
213
222
  ratedSeller: "Rated Seller",
@@ -217,6 +226,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
217
226
  conditionBroken: "For Parts",
218
227
  conditionRefurbished: "Refurbished",
219
228
  returnable: "Returnable",
229
+ finalSale: "Final Sale",
220
230
  freeShipping: "Free Shipping",
221
231
  codAvailable: "Cash on Delivery",
222
232
  emiAvailable: "EMI Available",
@@ -246,7 +256,9 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
246
256
  minOfferPercent,
247
257
  allowOffers,
248
258
  }),
249
- })] }), (shippingInfo || returnPolicy || freeShipping) && (_jsxs(Stack, { gap: "sm", border: "default", className: "border-t", padding: "t-md", children: [freeShipping && (_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(Span, { className: CLS_FREE_SHIPPING_ICON, children: "\uD83D\uDE9A" }), _jsxs(Div, { children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Free Delivery" }), shippingInfo && (_jsx(Text, { size: "xs", color: "muted", children: shippingInfo }))] })] })), !freeShipping && shippingInfo && (_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0", color: "faint", children: "\uD83D\uDCE6" }), _jsx(Text, { size: "xs", color: "muted", children: shippingInfo })] })), returnPolicy && (_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0", color: "faint", children: "\u21BA" }), _jsx(Text, { size: "xs", color: "muted", children: returnPolicy })] }))] })), tags.length > 0 && (_jsxs(Div, { border: "default", className: "border-t", padding: "t-md", children: [_jsx(Text, { className: "mb-2", color: "muted", size: "xs", weight: "medium", children: "Tags" }), _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", size: "xs", children: tag }, tag))) })] })), _jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "sm", justify: "center", className: "text-center", children: [
259
+ })] }), (shippingInfo || returnPolicy || freeShipping || finalSale) && (_jsxs(Stack, { gap: "sm", border: "default", className: "border-t", padding: "t-md", children: [freeShipping && (_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(Span, { className: CLS_FREE_SHIPPING_ICON, children: "\uD83D\uDE9A" }), _jsxs(Div, { children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Free Delivery" }), shippingInfo && (_jsx(Text, { size: "xs", color: "muted", children: shippingInfo }))] })] })), !freeShipping && shippingInfo && (_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(Span, { className: CLS_POLICY_ROW_ICON, color: "faint", children: "\uD83D\uDCE6" }), _jsx(Text, { size: "xs", color: "muted", children: shippingInfo })] })), _jsxs(Row, { align: "start", gap: "sm", children: [_jsx(Span, { className: CLS_POLICY_ROW_ICON, color: "faint", children: "\u21BA" }), _jsxs(Div, { children: [_jsx(Text, { size: "xs", weight: "medium", color: finalSale ? "warning" : "muted", children: finalSale ? "Final sale — no change-of-mind returns" : "Returns accepted" }), _jsx(Text, { size: "xs", color: "muted", children: finalSale
260
+ ? "You can still claim if it never arrives, arrives damaged, is the wrong item, is not as described, or is counterfeit."
261
+ : "Return this item within the platform return window for any reason." })] })] }), returnPolicy && (_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(Span, { className: CLS_POLICY_ROW_ICON, color: "faint", children: "\uD83D\uDCC4" }), _jsx(Text, { size: "xs", color: "muted", children: returnPolicy })] }))] })), tags.length > 0 && (_jsxs(Div, { border: "default", className: "border-t", padding: "t-md", children: [_jsx(Text, { className: "mb-2", color: "muted", size: "xs", weight: "medium", children: "Tags" }), _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", size: "xs", children: tag }, tag))) })] })), _jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "sm", justify: "center", className: "text-center", children: [
250
262
  { icon: "🔒", label: "Secure\nPayment" },
251
263
  { icon: "✓", label: "Verified\nSeller" },
252
264
  { icon: "⭐", label: "Quality\nGuarantee" },
@@ -8,6 +8,7 @@ interface ProductFeatureBadgeLabels {
8
8
  conditionBroken: string;
9
9
  conditionRefurbished: string;
10
10
  returnable: string;
11
+ finalSale: string;
11
12
  freeShipping: string;
12
13
  codAvailable: string;
13
14
  emiAvailable: string;
@@ -20,6 +21,13 @@ interface ProductFeatureBadgesProps {
20
21
  ratedSeller?: boolean;
21
22
  condition?: string;
22
23
  returnable?: boolean;
24
+ /**
25
+ * Absent means final sale, so callers must resolve this through
26
+ * `isFinalSale()` rather than passing `product.finalSale` straight in.
27
+ * Mutually exclusive with `returnable` in practice, but both are accepted
28
+ * independently so a caller cannot accidentally render neither.
29
+ */
30
+ finalSale?: boolean;
23
31
  freeShipping?: boolean;
24
32
  wishlistCount?: number;
25
33
  categoryProductCount?: number;
@@ -34,5 +42,5 @@ interface ProductFeatureBadgesProps {
34
42
  formatCount?: (value: number) => string;
35
43
  categoryBadgeClassName?: string;
36
44
  }
37
- export declare function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, condition, returnable, freeShipping, wishlistCount, categoryProductCount, categoryName, codAvailable, codHref, emiAvailable, emiHref, labels, formatCount, categoryBadgeClassName, }: ProductFeatureBadgesProps): import("react").JSX.Element | null;
45
+ export declare function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, condition, returnable, finalSale, freeShipping, wishlistCount, categoryProductCount, categoryName, codAvailable, codHref, emiAvailable, emiHref, labels, formatCount, categoryBadgeClassName, }: ProductFeatureBadgesProps): import("react").JSX.Element | null;
38
46
  export {};
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment } from "react";
3
3
  import { Row, Span, TextLink, Icon } from "../../../ui";
4
- export function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, condition, returnable, freeShipping, wishlistCount, categoryProductCount, categoryName, codAvailable, codHref, emiAvailable, emiHref, labels, formatCount, categoryBadgeClassName, }) {
4
+ export function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, condition, returnable, finalSale, freeShipping, wishlistCount, categoryProductCount, categoryName, codAvailable, codHref, emiAvailable, emiHref, labels, formatCount, categoryBadgeClassName, }) {
5
5
  const badges = [];
6
6
  if (featured) {
7
7
  badges.push({
@@ -48,6 +48,22 @@ export function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, co
48
48
  bgClass: "bg-primary/5 dark:bg-primary/10 border-primary/20 dark:border-primary/30",
49
49
  });
50
50
  }
51
+ /*
52
+ * Final sale is the platform DEFAULT, so unlike every other badge here this
53
+ * one is the common case rather than the exception — it is rendered on
54
+ * warning tokens, not error, because it is a term of the sale and not a
55
+ * fault. `-surface` + matching ink per Root Cause #67: never `text-white`,
56
+ * which is invisible against the light-theme tint.
57
+ */
58
+ if (finalSale) {
59
+ badges.push({
60
+ key: "finalSale",
61
+ icon: _jsx(Span, { size: "xs", children: "\u2298" }),
62
+ label: labels.finalSale,
63
+ colorClass: "text-warning dark:text-warning",
64
+ bgClass: "bg-warning-surface dark:bg-warning-surface border-warning dark:border-warning",
65
+ });
66
+ }
51
67
  if (returnable && condition === "new") {
52
68
  badges.push({
53
69
  key: "returnable",
@@ -3,7 +3,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import { Row } from "@mohasinac/appkit/ui";
4
4
  import { useRef } from "react";
5
5
  import { useTranslations } from "next-intl";
6
- import { Alert, Checkbox, Div, FormField, FormGroup, Heading, Label, RichTextEditor, Stack, TagInput, Text, } from "../../../ui";
6
+ import { Alert, Checkbox, Div, FormField, FormGroup, Heading, Label, RichTextEditor, Stack, TagInput, Text, Toggle, } from "../../../ui";
7
+ import { isFinalSale } from "../constants/final-sale";
8
+ import { PRODUCT_MAX_IMAGES, PRODUCT_MAX_VIDEOS, PRODUCT_IMAGE_INDEX_MAX, } from "../../../_internal/shared/media/limits";
7
9
  import { CustomSectionsEditor } from "./CustomSectionsEditor";
8
10
  import { SublistingCategorySelect } from "./SublistingCategorySelect";
9
11
  import { ProductFeaturesSelector } from "./ProductFeaturesSelector";
@@ -56,15 +58,42 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
56
58
  const update = (partial) => {
57
59
  onChange({ ...product, ...partial });
58
60
  };
59
- const galleryImages = (product.images ?? []).map((url) => ({
60
- url,
61
- type: "image",
62
- }));
61
+ /*
62
+ * One control for both media kinds — see the matching comment in
63
+ * SellerProductShell's StepMedia. The document keeps `images: string[]` plus
64
+ * a single `video`; this seeds from both and splits again on write.
65
+ */
66
+ const galleryImages = [
67
+ ...(product.images ?? []).map((url) => ({ url, type: "image" })),
68
+ ...(product.video?.url
69
+ ? [{
70
+ url: product.video.url,
71
+ type: "video",
72
+ ...(product.video.thumbnailUrl ? { thumbnailUrl: product.video.thumbnailUrl } : {}),
73
+ }]
74
+ : []),
75
+ ];
76
+ /** Split a mixed gallery back into the document's two fields. */
77
+ const applyGallery = (fields) => {
78
+ const video = fields.find((f) => f.type === "video");
79
+ update({
80
+ images: fields.filter((f) => f.type === "image").map((f) => f.url),
81
+ video: video
82
+ ? { url: video.url, thumbnailUrl: video.thumbnailUrl ?? product.video?.thumbnailUrl }
83
+ : undefined,
84
+ });
85
+ };
63
86
  const handleGalleryUpload = async (file) => {
87
+ /*
88
+ * The gallery occupies indices 2..N — index 1 belongs to the main image,
89
+ * and this ref started at 0 producing index 1, so the first gallery image
90
+ * generated the same filename as the main image and overwrote it in
91
+ * Storage (content-derived slugs, no timestamp).
92
+ */
64
93
  galleryIndexRef.current += 1;
65
94
  return upload(file, "products", true, {
66
- type: "product-image",
67
- index: galleryIndexRef.current,
95
+ type: file.type.startsWith("video/") ? "product-video" : "product-image",
96
+ index: Math.min(galleryIndexRef.current + 1, PRODUCT_IMAGE_INDEX_MAX),
68
97
  name: product.title || "product",
69
98
  category: (product.categorySlugs?.[0] ?? product.category) || "uncategorized",
70
99
  store: product.storeName || "store",
@@ -113,7 +142,7 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
113
142
  name: product.title || "product",
114
143
  category: (product.categorySlugs?.[0] ?? product.category) || "uncategorized",
115
144
  store: product.storeName || "store",
116
- }), onChange: (url) => update({ mainImage: url }), label: t("formMainImage"), helperText: "Recommended: 800x800px (1:1)" })), isReadonly && product.mainImage && (_jsx(FormField, { name: "mainImage", label: t("formMainImage"), type: "text", value: product.mainImage, onChange: () => { }, disabled: true })), !isReadonly && (_jsx(MediaUploadList, { label: t("formGalleryImages"), value: galleryImages, onChange: (fields) => update({ images: fields.map((f) => f.url) }), onUpload: handleGalleryUpload, accept: "image/*,video/*", maxItems: 5, maxSizeMB: 10, helperText: t("formGalleryImagesHelper"), onAbort: onMediaAbort })), !isReadonly && (_jsx(MediaUploadField, { label: product.listingType === "live" ? `${t("formVideo")} *` : t("formVideo"), value: product.video?.url || "", onChange: (url) => update({
145
+ }), onChange: (url) => update({ mainImage: url }), label: t("formMainImage"), helperText: "Recommended: 800x800px (1:1)" })), isReadonly && product.mainImage && (_jsx(FormField, { name: "mainImage", label: t("formMainImage"), type: "text", value: product.mainImage, onChange: () => { }, disabled: true })), !isReadonly && (_jsx(MediaUploadList, { label: t("formGalleryImages"), value: galleryImages, onChange: applyGallery, onUpload: handleGalleryUpload, accept: "image/*,video/*", maxItems: PRODUCT_MAX_IMAGES + PRODUCT_MAX_VIDEOS, maxImages: PRODUCT_MAX_IMAGES, maxVideos: PRODUCT_MAX_VIDEOS, maxSizeMB: 50, helperText: t("formGalleryImagesHelper"), onAbort: onMediaAbort })), !isReadonly && (_jsx(MediaUploadField, { label: product.listingType === "live" ? `${t("formVideo")} *` : t("formVideo"), value: product.video?.url || "", onChange: (url) => update({
117
146
  video: url
118
147
  ? {
119
148
  url,
@@ -138,7 +167,12 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
138
167
  thumbnailUrl: media.thumbnailUrl,
139
168
  },
140
169
  });
141
- }, onUpload: handleVideoUpload, accept: "video/*", maxSizeMB: 50, helperText: product.listingType === "live"
170
+ }, onUpload: handleVideoUpload,
171
+ // `auto` (image+video+pdf), not `video/*`: this control is also the
172
+ // only place a YouTube/external URL can be attached, and refusing
173
+ // images here served no purpose. The live-listing REQUIREMENT below
174
+ // is unchanged — that is a requiredness rule, not a count rule.
175
+ kind: "auto", maxSizeMB: 50, helperText: product.listingType === "live"
142
176
  ? "Required for live listings — buyers must see the actual animal/plant moving before purchase."
143
177
  : t("formVideoHelper"), onAbort: onMediaAbort })), _jsx(TagInput, { label: t("formTags"), value: product.tags || [], onChange: (tags) => update({ tags }), disabled: isReadonly, placeholder: t("formTagsPlaceholder") }), _jsxs(FormGroup, { columns: 2, children: [_jsx(Checkbox, { label: t("formFeatured"), checked: !!product.featured, onChange: (e) => update({ featured: e.target.checked }), disabled: isReadonly }), _jsx(Checkbox, { label: t("formIsPromoted"), checked: !!product.isPromoted, onChange: (e) => update({ isPromoted: e.target.checked }), disabled: isReadonly }), _jsx(Checkbox, { label: t("formAllowShipBeforeEmiComplete"), checked: !!product.allowShipBeforeEmiComplete, onChange: (e) => update({ allowShipBeforeEmiComplete: e.target.checked }), disabled: isReadonly }), _jsx(Checkbox, { label: t("formAllowOffers"), checked: !!product.allowOffers, onChange: (e) => update({
144
178
  allowOffers: e.target.checked,
@@ -220,7 +254,9 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
220
254
  value: product.groupId,
221
255
  onChange: (id) => update({ groupId: id || undefined }),
222
256
  disabled: isReadonly,
223
- }), !isAuctionListing(product) && renderGroupSettings?.(product), _jsx(ProductFeaturesSelector, { value: product.features ?? [], onChange: (features) => update({ features }), productType: LISTING_TYPE_TO_FEATURE_PRODUCT_TYPE[normalizeListingType(product)], storeId: product.storeId, disabled: isReadonly }), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Custom Sections" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "Add up to 3 custom tabs to your product page \u2014 e.g. \"Box Contents\", \"Compatibility\", \"Grading Details\"." }), _jsx(CustomSectionsEditor, { sections: product.customSections ?? [], onChange: (sections) => update({ customSections: sections }) })] }), _jsx(FormField, { name: "shippingInfo", label: t("formShipping"), type: "textarea", value: product.shippingInfo || "", onChange: (value) => update({ shippingInfo: value }), disabled: isReadonly, placeholder: "Shipping information..." }), _jsx(FormField, { name: "returnPolicy", label: t("formReturnPolicy"), type: "textarea", value: product.returnPolicy || "", onChange: (value) => update({ returnPolicy: value }), disabled: isReadonly, placeholder: "Return policy details..." }), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Physical Inventory" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "Assign a storage location for warehouse or shelf organisation. Used by the Print & Label Center." }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { name: "physicalLocation.zone", label: "Zone", type: "text", placeholder: "e.g. A", value: product.physicalLocation?.zone ?? "", onChange: (v) => update({
257
+ }), !isAuctionListing(product) && renderGroupSettings?.(product), _jsx(ProductFeaturesSelector, { value: product.features ?? [], onChange: (features) => update({ features }), productType: LISTING_TYPE_TO_FEATURE_PRODUCT_TYPE[normalizeListingType(product)], storeId: product.storeId, disabled: isReadonly }), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Custom Sections" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "Add up to 3 custom tabs to your product page \u2014 e.g. \"Box Contents\", \"Compatibility\", \"Grading Details\"." }), _jsx(CustomSectionsEditor, { sections: product.customSections ?? [], onChange: (sections) => update({ customSections: sections }) })] }), _jsx(FormField, { name: "shippingInfo", label: t("formShipping"), type: "textarea", value: product.shippingInfo || "", onChange: (value) => update({ shippingInfo: value }), disabled: isReadonly, placeholder: "Shipping information..." }), _jsxs(Div, { children: [_jsx(Toggle, { checked: !isFinalSale(product), onChange: (checked) => update({ finalSale: !checked }), disabled: isReadonly, label: "Accept change-of-mind returns" }), _jsx(Text, { className: "mt-1", size: "xs", color: "muted", children: isFinalSale(product)
258
+ ? "Final sale (default). Not-received, damaged, wrong-item, not-as-described and counterfeit claims are still accepted."
259
+ : "Buyers can return this listing within the platform return window for any reason." })] }), _jsx(FormField, { name: "returnPolicy", label: t("formReturnPolicy"), type: "textarea", value: product.returnPolicy || "", onChange: (value) => update({ returnPolicy: value }), disabled: isReadonly, placeholder: "Return policy details..." }), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Physical Inventory" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "Assign a storage location for warehouse or shelf organisation. Used by the Print & Label Center." }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { name: "physicalLocation.zone", label: "Zone", type: "text", placeholder: "e.g. A", value: product.physicalLocation?.zone ?? "", onChange: (v) => update({
224
260
  physicalLocation: {
225
261
  zone: v,
226
262
  shelf: product.physicalLocation?.shelf ?? "",
@@ -10,6 +10,7 @@ import { formatCurrency } from "../../../utils/number.formatter";
10
10
  import { getDefaultCurrency } from "../../../core/baseline-resolver";
11
11
  import { useLongPress } from "../../../react/hooks/useLongPress";
12
12
  import { CardStoreLine } from "./CardStoreLine";
13
+ import { isFinalSale } from "../constants/final-sale";
13
14
  import { FeatureBadgeList } from "./FeatureBadge";
14
15
  import { useProductFeatures } from "./ProductFeaturesContext";
15
16
  import { PRODUCT_FEATURE_CARD_MAX_VISIBLE } from "../constants/product-features.constants";
@@ -31,6 +32,14 @@ const CLS_BADGE_LIMITED = "rounded-full bg-info-solid px-[var(--appkit-space-2)]
31
32
  // inverts with the theme. `warning` because success/info/error are each already
32
33
  // taken by a sibling pill in this same stack.
33
34
  const CLS_BADGE_OFFERABLE = "rounded-full bg-warning-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[10px] font-bold text-warning-on-solid shadow-sm";
35
+ /*
36
+ * Final sale. A fixed dark scrim with white ink, following CLS_BADGE_TRENDING's
37
+ * precedent rather than a `-surface` token: this sits on top of the product
38
+ * PHOTO, where a theme-relative tint is a near-white fill that vanishes against
39
+ * a light image (Root Cause #67). Not `warning-solid`, which "Taking Offers"
40
+ * already owns — two amber pills stacked would read as one control.
41
+ */
42
+ const CLS_BADGE_FINAL_SALE = "rounded-full bg-zinc-900/90 px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[10px] font-bold text-white shadow-sm";
34
43
  const CLS_STAR = "text-[11px] text-warning";
35
44
  const CLS_DISCOUNT_TEXT = "mt-1 text-[11px] font-semibold text-error";
36
45
  const CLS_DISCOUNT_TEXT_BARE = "text-[10px] text-error";
@@ -70,7 +79,7 @@ export function ProductCard({ product, href, onClick, onAddToWishlist, isWishlis
70
79
  .filter(Boolean)
71
80
  .join(" "), children: [_jsxs(Div, { className: `relative ${__O.hidden} aspect-square`, surface: "subtle", children: [product.mainImage ? (_jsx(MediaImage, { src: product.mainImage, alt: product.title, size: "card", className: "transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Row, { surface: "muted", className: "h-full w-full", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-30", size: "4xl", children: "\uD83D\uDECD\uFE0F" }) })), onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(product.id); }, label: isSelected ? "Deselect" : "Select", position: "top-2 left-2", className: selectionMode || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsx(Stack, { className: `absolute top-2 ${onSelect ? "left-10" : "left-2"}`, gap: "xs", children: !selectionMode && !isSelected && (_jsxs(_Fragment, { children: [discount && (_jsxs(Span, { weight: "bold", className: CLS_BADGE_NEW, children: ["-", discount, "%"] })), typeBadge && (_jsx(Span, { weight: "bold", className: `text-[10px] ${typeBadge.cls}`, rounded: "full", padding: "pill-xs", shadow: "sm", children: typeBadge.label })), product.partOfBundleIds && product.partOfBundleIds.length > 0 && (_jsx(Span, { className: CLS_BADGE_SALE, title: product.partOfBundleTitles && product.partOfBundleTitles.length > 0
72
81
  ? `In bundle: ${product.partOfBundleTitles[0]}`
73
- : "In a bundle", children: "Bundled" })), product.groupId && (_jsx(Span, { className: CLS_BADGE_TRENDING, title: product.groupTitle ? `Part of set: ${product.groupTitle}` : "Part of a set", children: product.isGroupParent ? "Set Parent" : "In Set" })), product.allowOffers && (_jsx(Span, { className: CLS_BADGE_OFFERABLE, title: "The seller accepts offers on this listing", children: "Taking Offers" })), product.sublistingCategoryId && (_jsx(Span, { className: CLS_BADGE_LIMITED, title: "Has variants or sub-listings", children: "Has Variants" }))] })) }), _jsx(BaseListingCard.IconOverlay, { groupIcon: product.groupIcon, sublistingIcon: product.sublistingIcon, position: "bottom-2 right-2" }), onAddToWishlist && (_jsx(WishlistHeartButton, { size: "card", variant: "overlay", inWishlist: !!isWishlisted, addLabel: "Add to wishlist", removeLabel: "Remove from wishlist", className: "absolute right-2 top-2", onToggle: (e) => {
82
+ : "In a bundle", children: "Bundled" })), product.groupId && (_jsx(Span, { className: CLS_BADGE_TRENDING, title: product.groupTitle ? `Part of set: ${product.groupTitle}` : "Part of a set", children: product.isGroupParent ? "Set Parent" : "In Set" })), product.allowOffers && (_jsx(Span, { className: CLS_BADGE_OFFERABLE, title: "The seller accepts offers on this listing", children: "Taking Offers" })), isFinalSale(product) && (_jsx(Span, { className: CLS_BADGE_FINAL_SALE, title: "Final sale \u2014 no change-of-mind returns. Not-received, damaged, wrong-item, not-as-described and counterfeit claims are still accepted.", children: "Final Sale" })), product.sublistingCategoryId && (_jsx(Span, { className: CLS_BADGE_LIMITED, title: "Has variants or sub-listings", children: "Has Variants" }))] })) }), _jsx(BaseListingCard.IconOverlay, { groupIcon: product.groupIcon, sublistingIcon: product.sublistingIcon, position: "bottom-2 right-2" }), onAddToWishlist && (_jsx(WishlistHeartButton, { size: "card", variant: "overlay", inWishlist: !!isWishlisted, addLabel: "Add to wishlist", removeLabel: "Remove from wishlist", className: "absolute right-2 top-2", onToggle: (e) => {
74
83
  e.stopPropagation();
75
84
  e.preventDefault();
76
85
  onAddToWishlist(product.id);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Final sale — whether a listing accepts change-of-mind returns.
3
+ *
4
+ * 🛑 ABSENT MEANS FINAL SALE. `finalSale` is optional on `ProductDocument`
5
+ * and the platform default is TRUE, so `undefined` and `true` mean the same
6
+ * thing and only an explicit `false` opts a listing out.
7
+ *
8
+ * That inversion is why this helper exists and why it is the ONLY legal way
9
+ * to read the field. The obvious spellings are all wrong for `undefined`:
10
+ *
11
+ * !product.finalSale // true for undefined -> WRONG, opts out
12
+ * product.finalSale === true // false for undefined -> WRONG, opts out
13
+ * product.finalSale ?? true // correct, but re-derived at each call site
14
+ *
15
+ * Every one of those reads "not final sale" for the ~69 seeded products and
16
+ * every listing created before the field existed — i.e. it fails open, in the
17
+ * direction that silently grants returns the seller never agreed to.
18
+ *
19
+ * The default is deliberately NOT baked into `DEFAULT_PRODUCT_DATA`: writing
20
+ * `finalSale: true` there would make `undefined` unreachable in new documents
21
+ * and hide the rule from anyone reading the schema, while the millions of
22
+ * already-stored documents would still arrive without it. One helper that
23
+ * handles absence is honest; a default that only covers new rows is not.
24
+ *
25
+ * What final sale actually blocks is a REASON, not a refund — see
26
+ * `_internal/shared/features/orders/return-reasons.ts`. Seller- and
27
+ * carrier-fault claims (not received, not as described, damaged, wrong item,
28
+ * counterfeit) are always accepted regardless of this flag.
29
+ */
30
+ export declare function isFinalSale(listing: {
31
+ finalSale?: boolean;
32
+ } | null | undefined): boolean;
33
+ /**
34
+ * The inverse, for reading as a positive at a call site that renders a
35
+ * "Returnable" affordance. Exists so no one writes `!isFinalSale(...)` and
36
+ * then has to reason about the double negative at a glance.
37
+ */
38
+ export declare function acceptsChangeOfMindReturns(listing: {
39
+ finalSale?: boolean;
40
+ } | null | undefined): boolean;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Final sale — whether a listing accepts change-of-mind returns.
3
+ *
4
+ * 🛑 ABSENT MEANS FINAL SALE. `finalSale` is optional on `ProductDocument`
5
+ * and the platform default is TRUE, so `undefined` and `true` mean the same
6
+ * thing and only an explicit `false` opts a listing out.
7
+ *
8
+ * That inversion is why this helper exists and why it is the ONLY legal way
9
+ * to read the field. The obvious spellings are all wrong for `undefined`:
10
+ *
11
+ * !product.finalSale // true for undefined -> WRONG, opts out
12
+ * product.finalSale === true // false for undefined -> WRONG, opts out
13
+ * product.finalSale ?? true // correct, but re-derived at each call site
14
+ *
15
+ * Every one of those reads "not final sale" for the ~69 seeded products and
16
+ * every listing created before the field existed — i.e. it fails open, in the
17
+ * direction that silently grants returns the seller never agreed to.
18
+ *
19
+ * The default is deliberately NOT baked into `DEFAULT_PRODUCT_DATA`: writing
20
+ * `finalSale: true` there would make `undefined` unreachable in new documents
21
+ * and hide the rule from anyone reading the schema, while the millions of
22
+ * already-stored documents would still arrive without it. One helper that
23
+ * handles absence is honest; a default that only covers new rows is not.
24
+ *
25
+ * What final sale actually blocks is a REASON, not a refund — see
26
+ * `_internal/shared/features/orders/return-reasons.ts`. Seller- and
27
+ * carrier-fault claims (not received, not as described, damaged, wrong item,
28
+ * counterfeit) are always accepted regardless of this flag.
29
+ */
30
+ export function isFinalSale(listing) {
31
+ return listing?.finalSale !== false;
32
+ }
33
+ /**
34
+ * The inverse, for reading as a positive at a call site that renders a
35
+ * "Returnable" affordance. Exists so no one writes `!isFinalSale(...)` and
36
+ * then has to reason about the double negative at a glance.
37
+ */
38
+ export function acceptsChangeOfMindReturns(listing) {
39
+ return !isFinalSale(listing);
40
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./types";
2
2
  export * from "./constants/action-defs";
3
+ export * from "./constants/final-sale";
3
4
  export * from "./constants/listing-tabs";
4
5
  export * from "./hooks/useProducts";
5
6
  export * from "./hooks/useProductDetail";
@@ -1,5 +1,6 @@
1
1
  export * from "./types";
2
2
  export * from "./constants/action-defs";
3
+ export * from "./constants/final-sale";
3
4
  export * from "./constants/listing-tabs";
4
5
  export * from "./hooks/useProducts";
5
6
  export * from "./hooks/useProductDetail";
@@ -183,6 +183,23 @@ export interface ProductDocument extends BaseDocument {
183
183
  features?: string[];
184
184
  shippingInfo?: string;
185
185
  returnPolicy?: string;
186
+ /**
187
+ * 🛑 ABSENT MEANS FINAL SALE. The platform default is `true`, so `undefined`
188
+ * and `true` are the same thing and only an explicit `false` opts a listing
189
+ * into change-of-mind returns.
190
+ *
191
+ * Never read this field directly — `!p.finalSale` and `p.finalSale === true`
192
+ * both get `undefined` wrong, in the direction that silently grants returns.
193
+ * Use `isFinalSale(p)` from `features/products/constants/final-sale`.
194
+ *
195
+ * Deliberately NOT in `DEFAULT_PRODUCT_DATA` (a default there would make
196
+ * `undefined` unreachable in new rows while every existing row still lacks
197
+ * it) and NOT in `PRODUCT_INDEXED_FIELDS` (nothing queries it).
198
+ *
199
+ * This blocks a return REASON, not a refund: seller/carrier-fault claims are
200
+ * always accepted. See `_internal/shared/features/orders/return-reasons`.
201
+ */
202
+ finalSale?: boolean;
186
203
  condition?: "new" | "like_new" | "good" | "fair" | "poor" | "used" | "refurbished" | "broken" | "graded";
187
204
  insurance?: boolean;
188
205
  insuranceCost?: number;
@@ -405,8 +422,8 @@ export declare const ProductStatusValues: {
405
422
  export declare const PRODUCT_COLLECTION: "products";
406
423
  export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId"];
407
424
  export declare const DEFAULT_PRODUCT_DATA: Partial<ProductDocument>;
408
- export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
409
- export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
425
+ export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
426
+ export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
410
427
  export type ProductCreateInput = Omit<ProductDocument, "id" | "createdAt" | "updatedAt" | "availableQuantity" | "bidCount" | "currentBid" | "auctionOriginalEndDate">;
411
428
  export type ProductUpdateInput = Partial<Pick<ProductDocument, (typeof PRODUCT_UPDATABLE_FIELDS)[number]>>;
412
429
  export type ProductAdminUpdateInput = Partial<Omit<ProductDocument, "id" | "createdAt">>;
@@ -72,6 +72,9 @@ export const PRODUCT_PUBLIC_FIELDS = [
72
72
  "features",
73
73
  "shippingInfo",
74
74
  "returnPolicy",
75
+ // Buyer-facing: drives the "Final Sale" card pill and the detail-page
76
+ // Delivery & Returns line. Absent means final sale — read via isFinalSale().
77
+ "finalSale",
75
78
  "listingType",
76
79
  "auctionEndDate",
77
80
  "startingBid",
@@ -138,6 +141,7 @@ export const PRODUCT_UPDATABLE_FIELDS = [
138
141
  "features",
139
142
  "shippingInfo",
140
143
  "returnPolicy",
144
+ "finalSale",
141
145
  "pickupAddressId",
142
146
  "condition",
143
147
  "insurance",
@@ -108,6 +108,8 @@ export interface ProductItem {
108
108
  features?: string[];
109
109
  shippingInfo?: string;
110
110
  returnPolicy?: string;
111
+ /** Absent means final sale — read via `isFinalSale()`, never directly. */
112
+ finalSale?: boolean;
111
113
  ingredients?: string[];
112
114
  howToUse?: string[];
113
115
  allowOffers?: boolean;