@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.
- package/dist/_internal/server/features/checkout/actions.js +9 -1
- package/dist/_internal/server/features/checkout/expand-order-items.js +9 -0
- package/dist/_internal/server/features/media/contextGuards.d.ts +3 -3
- package/dist/_internal/server/features/media/contextGuards.js +19 -5
- package/dist/_internal/server/features/orders/actions.d.ts +9 -0
- package/dist/_internal/server/features/orders/actions.js +47 -4
- package/dist/_internal/server/features/orders/adapters.js +10 -0
- package/dist/_internal/server/features/refunds/actions.d.ts +9 -1
- package/dist/_internal/server/features/refunds/actions.js +36 -3
- package/dist/_internal/server/features/seo/sitemap.js +2 -2
- package/dist/_internal/server/features/site-settings/adapters.js +1 -1
- package/dist/_internal/server/functions/scheduled.d.ts +1 -2
- package/dist/_internal/server/functions/scheduled.js +8 -9
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +15 -0
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +15 -6
- package/dist/_internal/server/jobs/handlers/index.d.ts +0 -1
- package/dist/_internal/server/jobs/handlers/index.js +0 -1
- package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
- package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
- package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
- package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
- package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
- package/dist/_internal/shared/features/orders/schema.js +20 -0
- package/dist/_internal/shared/features/products/schema.d.ts +12 -0
- package/dist/_internal/shared/features/products/schema.js +4 -1
- package/dist/_internal/shared/media/limits.d.ts +14 -0
- package/dist/_internal/shared/media/limits.js +29 -0
- package/dist/client.d.ts +9 -1
- package/dist/client.js +19 -1
- package/dist/features/admin/components/AdminProductEditorView.js +6 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
- package/dist/features/admin/components/index.d.ts +0 -2
- package/dist/features/admin/components/index.js +0 -1
- package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
- package/dist/features/admin/schemas/firestore.d.ts +0 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
- package/dist/features/auth/schemas/firestore.d.ts +1 -0
- package/dist/features/contact/components/ContactForm.js +46 -31
- package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
- package/dist/features/media/upload/MediaUploadList.js +58 -8
- package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
- package/dist/features/orders/components/RefundRequestView.js +108 -24
- package/dist/features/orders/schemas/firestore.d.ts +42 -0
- package/dist/features/orders/types/index.d.ts +24 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
- package/dist/features/products/components/ProductDetailPageView.js +14 -2
- package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
- package/dist/features/products/components/ProductFeatureBadges.js +17 -1
- package/dist/features/products/components/ProductForm.js +46 -10
- package/dist/features/products/components/ProductGrid.js +10 -1
- package/dist/features/products/constants/final-sale.d.ts +40 -0
- package/dist/features/products/constants/final-sale.js +40 -0
- package/dist/features/products/index.d.ts +1 -0
- package/dist/features/products/index.js +1 -0
- package/dist/features/products/schemas/firestore.d.ts +19 -2
- package/dist/features/products/schemas/firestore.js +4 -0
- package/dist/features/products/types/index.d.ts +2 -0
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
- package/dist/features/seller/components/SellerProductShell.js +82 -11
- package/dist/features/seller/index.d.ts +1 -0
- package/dist/features/seller/index.js +1 -0
- package/dist/features/seller/schemas/product-form.d.ts +304 -0
- package/dist/features/seller/schemas/product-form.js +132 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
- package/dist/features/seller/utils/product-draft-mapping.js +147 -0
- package/dist/features/shell/SectionForm.js +62 -7
- package/dist/features/shell/form-sections.js +4 -0
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +16 -1
- package/dist/features/tester/repository/tester-checklist-item.repository.js +32 -6
- package/dist/features/tester/seed-data/events-tester-seed-data.js +77 -5
- package/dist/features/tester/seed-data/offers-tester-seed-data.js +23 -9
- package/dist/features/tester/seed-data/products-tester-seed-data.js +65 -49
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +182 -0
- package/dist/features/tester/seed-data/tester-window.d.ts +25 -0
- package/dist/features/tester/seed-data/tester-window.js +66 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +12 -3
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/schemas/registry.d.ts +271 -0
- package/dist/schemas/registry.js +9 -0
- package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
- package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
- package/dist/seed/products-art-seed-data.js +9 -1
- package/dist/seed/products-auctions-seed-data.js +2 -1
- package/dist/seed/products-classifieds-seed-data.js +2 -1
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.d.ts +2 -138
- package/dist/seed/products-preorders-seed-data.js +2 -1
- package/dist/seed/products-prize-draws-seed-data.js +2 -1
- package/dist/seed/products-standard-seed-data.js +2 -1
- package/dist/seed/products-stickers-seed-data.js +9 -1
- package/dist/seed/users-seed-data.js +107 -4
- package/dist/server.d.ts +6 -2
- package/dist/server.js +15 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/motion.d.ts +12 -0
- package/dist/tokens/motion.js +12 -0
- package/dist/ui/components/CollapsibleSection.d.ts +18 -1
- package/dist/ui/components/CollapsibleSection.js +20 -3
- package/dist/ui/components/Motion.js +17 -1
- 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:
|
|
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:
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
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,
|
|
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..." }),
|
|
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
|
+
}
|
|
@@ -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;
|