@mohasinac/appkit 4.33.0 → 4.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) 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/site-settings/adapters.js +1 -1
  11. package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
  12. package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
  13. package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
  14. package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
  15. package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
  16. package/dist/_internal/shared/features/orders/schema.js +20 -0
  17. package/dist/_internal/shared/features/products/schema.d.ts +12 -0
  18. package/dist/_internal/shared/features/products/schema.js +4 -1
  19. package/dist/_internal/shared/media/limits.d.ts +14 -0
  20. package/dist/_internal/shared/media/limits.js +29 -0
  21. package/dist/client.d.ts +9 -1
  22. package/dist/client.js +19 -1
  23. package/dist/features/about/components/PolicyPageView.js +1 -1
  24. package/dist/features/admin/components/AdminProductEditorView.js +6 -1
  25. package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
  26. package/dist/features/admin/components/index.d.ts +0 -2
  27. package/dist/features/admin/components/index.js +0 -1
  28. package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
  29. package/dist/features/admin/schemas/firestore.d.ts +0 -2
  30. package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
  31. package/dist/features/contact/components/ContactForm.js +46 -31
  32. package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
  33. package/dist/features/media/upload/MediaUploadList.js +58 -8
  34. package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
  35. package/dist/features/orders/components/RefundRequestView.js +108 -24
  36. package/dist/features/orders/schemas/firestore.d.ts +42 -0
  37. package/dist/features/orders/types/index.d.ts +24 -0
  38. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
  39. package/dist/features/products/components/ProductDetailPageView.js +14 -2
  40. package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
  41. package/dist/features/products/components/ProductFeatureBadges.js +17 -1
  42. package/dist/features/products/components/ProductForm.js +46 -10
  43. package/dist/features/products/components/ProductGrid.js +10 -1
  44. package/dist/features/products/constants/final-sale.d.ts +40 -0
  45. package/dist/features/products/constants/final-sale.js +40 -0
  46. package/dist/features/products/index.d.ts +1 -0
  47. package/dist/features/products/index.js +1 -0
  48. package/dist/features/products/schemas/firestore.d.ts +19 -2
  49. package/dist/features/products/schemas/firestore.js +4 -0
  50. package/dist/features/products/types/index.d.ts +2 -0
  51. package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
  52. package/dist/features/seller/components/SellerProductShell.js +82 -11
  53. package/dist/features/seller/index.d.ts +1 -0
  54. package/dist/features/seller/index.js +1 -0
  55. package/dist/features/seller/schemas/product-form.d.ts +304 -0
  56. package/dist/features/seller/schemas/product-form.js +132 -0
  57. package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
  58. package/dist/features/seller/utils/product-draft-mapping.js +147 -0
  59. package/dist/features/shell/SectionForm.js +62 -7
  60. package/dist/features/shell/form-sections.js +4 -0
  61. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +200 -0
  62. package/dist/index.d.ts +6 -3
  63. package/dist/index.js +12 -3
  64. package/dist/next/routing/route-map.d.ts +2 -0
  65. package/dist/next/routing/route-map.js +1 -0
  66. package/dist/schemas/registry.d.ts +271 -0
  67. package/dist/schemas/registry.js +9 -0
  68. package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
  69. package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
  70. package/dist/seed/products-art-seed-data.js +9 -1
  71. package/dist/seed/products-auctions-seed-data.js +2 -1
  72. package/dist/seed/products-classifieds-seed-data.js +2 -1
  73. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  74. package/dist/seed/products-live-items-seed-data.js +2 -1
  75. package/dist/seed/products-preorders-seed-data.d.ts +2 -138
  76. package/dist/seed/products-preorders-seed-data.js +2 -1
  77. package/dist/seed/products-prize-draws-seed-data.js +2 -1
  78. package/dist/seed/products-standard-seed-data.js +2 -1
  79. package/dist/seed/products-stickers-seed-data.js +9 -1
  80. package/dist/server.d.ts +6 -2
  81. package/dist/server.js +15 -2
  82. package/dist/styles.css +1 -1
  83. package/dist/tailwind-utilities.css +1 -1
  84. package/dist/tokens/motion.d.ts +12 -0
  85. package/dist/tokens/motion.js +12 -0
  86. package/dist/ui/components/CollapsibleSection.d.ts +18 -1
  87. package/dist/ui/components/CollapsibleSection.js +20 -3
  88. package/dist/ui/components/Motion.js +17 -1
  89. package/package.json +1 -1
@@ -7,6 +7,21 @@ export interface MediaUploadListProps {
7
7
  accept?: string;
8
8
  maxSizeMB?: number;
9
9
  maxItems?: number;
10
+ /**
11
+ * Per-TYPE ceilings, counted separately.
12
+ *
13
+ * `maxItems` alone cannot express "10 images and 1 video" — it is one
14
+ * number over a mixed list, so a buyer-facing rule of that shape had to be
15
+ * enforced by splitting the control in two, which is why product forms
16
+ * carried a gallery AND a separate single-video field that wrote a
17
+ * different part of the document.
18
+ *
19
+ * When either is set the matching type is counted on its own and `maxItems`
20
+ * becomes the overall backstop. Existing callers that pass neither are
21
+ * completely unaffected.
22
+ */
23
+ maxImages?: number;
24
+ maxVideos?: number;
10
25
  disabled?: boolean;
11
26
  helperText?: string;
12
27
  /**
@@ -23,4 +38,4 @@ export interface MediaUploadListProps {
23
38
  */
24
39
  isPersisted?: boolean;
25
40
  }
26
- export declare function MediaUploadList({ label, value, onChange, onUpload, accept, maxSizeMB, maxItems, disabled, helperText, onAbort, onStagedUrlsChange, isPersisted, }: MediaUploadListProps): import("react").JSX.Element;
41
+ export declare function MediaUploadList({ label, value, onChange, onUpload, accept, maxSizeMB, maxItems, maxImages, maxVideos, disabled, helperText, onAbort, onStagedUrlsChange, isPersisted, }: MediaUploadListProps): import("react").JSX.Element;
@@ -6,6 +6,7 @@ import { MediaImage } from "../MediaImage";
6
6
  import { MediaVideo } from "../MediaVideo";
7
7
  import { inferMediaTypeFromMime } from "../types/index";
8
8
  import { normalizeError } from "../../../errors/normalize";
9
+ import { toUserMessage } from "../../../errors/error-display-map";
9
10
  const __O = {
10
11
  hidden: "overflow-hidden",
11
12
  };
@@ -20,7 +21,7 @@ function fileNameFromUrl(url) {
20
21
  return url;
21
22
  }
22
23
  }
23
- export function MediaUploadList({ label, value, onChange, onUpload, accept = "image/*,video/*,application/pdf", maxSizeMB = 50, maxItems = 12, disabled = false, helperText, onAbort, onStagedUrlsChange, isPersisted = false, }) {
24
+ export function MediaUploadList({ label, value, onChange, onUpload, accept = "image/*,video/*,application/pdf", maxSizeMB = 50, maxItems = 12, maxImages, maxVideos, disabled = false, helperText, onAbort, onStagedUrlsChange, isPersisted = false, }) {
24
25
  const inputRef = useRef(null);
25
26
  const [isLoading, setIsLoading] = useState(false);
26
27
  const [error, setError] = useState(null);
@@ -69,15 +70,51 @@ export function MediaUploadList({ label, value, onChange, onUpload, accept = "im
69
70
  e.currentTarget.value = "";
70
71
  return;
71
72
  }
73
+ /*
74
+ * Per-type ceilings. Counted from the INCOMING batch as well as what is
75
+ * already there, so selecting five videos at once is refused up front
76
+ * rather than after four of them have uploaded.
77
+ *
78
+ * `inferMediaTypeFromMime` is the same classifier used to tag the item
79
+ * below, so the count and the stored `type` can never disagree.
80
+ */
81
+ if (maxImages != null || maxVideos != null) {
82
+ const incoming = files.map((f) => inferMediaTypeFromMime(f.type, f.name));
83
+ const countOf = (t) => value.filter((v) => v.type === t).length + incoming.filter((v) => v === t).length;
84
+ if (maxImages != null && countOf("image") > maxImages) {
85
+ setError(`You can upload up to ${maxImages} image${maxImages === 1 ? "" : "s"}.`);
86
+ e.currentTarget.value = "";
87
+ return;
88
+ }
89
+ if (maxVideos != null && countOf("video") > maxVideos) {
90
+ setError(maxVideos === 1
91
+ ? "Only one video is allowed. Remove the current one to replace it."
92
+ : `You can upload up to ${maxVideos} videos.`);
93
+ e.currentTarget.value = "";
94
+ return;
95
+ }
96
+ }
72
97
  setError(null);
98
+ /*
99
+ * Size is checked BEFORE the upload loop, and reported directly rather
100
+ * than thrown.
101
+ *
102
+ * It used to throw into the shared catch, which then rendered
103
+ * `err.message` — so one branch of that catch was authored copy worth
104
+ * showing while the other was whatever the network layer happened to
105
+ * throw. Splitting them lets the useful message survive and the arbitrary
106
+ * one resolve through `toUserMessage` (Rule #9.6 / Root Cause #86).
107
+ */
108
+ const tooBig = files.find((f) => f.size / 1024 / 1024 > maxSizeMB);
109
+ if (tooBig) {
110
+ setError(`${tooBig.name} exceeds ${maxSizeMB}MB`);
111
+ e.currentTarget.value = "";
112
+ return;
113
+ }
73
114
  setIsLoading(true);
74
115
  try {
75
116
  const uploaded = [];
76
117
  for (const file of files) {
77
- const sizeMb = file.size / 1024 / 1024;
78
- if (sizeMb > maxSizeMB) {
79
- throw new Error(`${file.name} exceeds ${maxSizeMB}MB`);
80
- }
81
118
  const url = await onUpload(file);
82
119
  stageUrl(url);
83
120
  uploaded.push({
@@ -88,8 +125,12 @@ export function MediaUploadList({ label, value, onChange, onUpload, accept = "im
88
125
  onChange([...value, ...uploaded]);
89
126
  }
90
127
  catch (err) {
91
- void normalizeError(err);
92
- setError(err instanceof Error ? err.message : "Upload failed");
128
+ const normalized = normalizeError(err);
129
+ // Never the thrown value's own text: `onUpload` reaches the signed-URL
130
+ // endpoint, so this is where a server sentence would surface.
131
+ setError(toUserMessage(normalized.code, undefined, {
132
+ fallback: "Upload failed. Please try again.",
133
+ }));
93
134
  }
94
135
  finally {
95
136
  setIsLoading(false);
@@ -107,5 +148,14 @@ export function MediaUploadList({ label, value, onChange, onUpload, accept = "im
107
148
  unstageUrl(target.url);
108
149
  }
109
150
  };
110
- return (_jsxs(Stack, { gap: "sm", children: [_jsx(Label, { className: "block", color: "muted", size: "sm", weight: "medium", children: label }), value.length > 0 && (_jsx(Div, { layout: "grid", gap: "3", className: "grid-cols-1 sm:grid-cols-2", children: value.map((item, index) => (_jsxs(Div, { rounded: "lg", padding: "sm", border: "default", surface: "muted", children: [item.type === "video" ? (_jsx(Div, { className: `relative aspect-square max-h-24 ${__O.hidden}`, rounded: "lg", children: _jsx(MediaVideo, { src: item.url, alt: item.alt || `Media ${index + 1}`, controls: true }) })) : item.type === "image" ? (_jsx(Div, { className: `relative aspect-square max-h-24 ${__O.hidden}`, rounded: "lg", children: _jsx(MediaImage, { src: item.url, alt: item.alt || `Media ${index + 1}`, size: "card", objectFit: "contain" }) })) : (_jsx(Anchor, { href: item.url, tone: "none", underline: "none", className: CLS_PDF_LINK, children: fileNameFromUrl(item.url) })), !disabled && (_jsx(Div, { className: "mt-2", children: _jsx(Button, { type: "button", size: "sm", variant: "danger", onClick: () => removeAt(index), children: "Remove" }) }))] }, `${item.url}-${index}`))) })), !disabled && value.length < maxItems && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => inputRef.current?.click(), disabled: isLoading, children: isLoading ? "Uploading..." : `+ Add Files (${value.length}/${maxItems})` })), isLoading && (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Spinner, { size: "sm" }), _jsx(Text, { size: "sm", variant: "secondary", children: "Uploading" })] })), _jsx("input", { ref: inputRef, type: "file", multiple: true, accept: accept, onChange: handleFileChange, className: "hidden", "aria-hidden": "true", "data-testid": "media-upload-list-input" }), helperText && !error && (_jsx(Text, { variant: "secondary", size: "xs", children: helperText })), error && _jsx(Alert, { variant: "error", children: error })] }));
151
+ return (_jsxs(Stack, { gap: "sm", children: [_jsx(Label, { className: "block", color: "muted", size: "sm", weight: "medium", children: label }), value.length > 0 && (_jsx(Div, { layout: "grid", gap: "3", className: "grid-cols-1 sm:grid-cols-2", children: value.map((item, index) => (_jsxs(Div, { rounded: "lg", padding: "sm", border: "default", surface: "muted", children: [item.type === "video" ? (_jsx(Div, { className: `relative aspect-square max-h-24 ${__O.hidden}`, rounded: "lg", children: _jsx(MediaVideo, { src: item.url, alt: item.alt || `Media ${index + 1}`, controls: true }) })) : item.type === "image" ? (_jsx(Div, { className: `relative aspect-square max-h-24 ${__O.hidden}`, rounded: "lg", children: _jsx(MediaImage, { src: item.url, alt: item.alt || `Media ${index + 1}`, size: "card", objectFit: "contain" }) })) : (_jsx(Anchor, { href: item.url, tone: "none", underline: "none", className: CLS_PDF_LINK, children: fileNameFromUrl(item.url) })), !disabled && (_jsx(Div, { className: "mt-2", children: _jsx(Button, { type: "button", size: "sm", variant: "danger", onClick: () => removeAt(index), children: "Remove" }) }))] }, `${item.url}-${index}`))) })), !disabled && value.length < maxItems && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => inputRef.current?.click(), disabled: isLoading, children: isLoading ? "Uploading..." : `+ Add Files (${value.length}/${maxItems})` })), isLoading && (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Spinner, { size: "sm" }), _jsx(Text, { size: "sm", variant: "secondary", children: "Uploading" })] })), _jsx("input", { ref: inputRef, type: "file", multiple: true, accept: accept, onChange: handleFileChange, className: "hidden", "aria-hidden": "true", "data-testid": "media-upload-list-input" }), (maxImages != null || maxVideos != null) && (_jsx(Text, { variant: "secondary", size: "xs", children: [
152
+ maxImages != null
153
+ ? `${value.filter((v) => v.type === "image").length}/${maxImages} images`
154
+ : null,
155
+ maxVideos != null
156
+ ? `${value.filter((v) => v.type === "video").length}/${maxVideos} video${maxVideos === 1 ? "" : "s"}`
157
+ : null,
158
+ ]
159
+ .filter(Boolean)
160
+ .join(" · ") })), helperText && !error && (_jsx(Text, { variant: "secondary", size: "xs", children: helperText })), error && _jsx(Alert, { variant: "error", children: error })] }));
111
161
  }
@@ -1,19 +1,35 @@
1
1
  /**
2
- * RefundRequestView — buyer-facing refund submission form.
2
+ * RefundRequestView — buyer-facing return / refund request form.
3
3
  *
4
- * Shows 3 acknowledgment checkboxes (via the appkit Checkbox primitive) that
5
- * the buyer must tick before the submit button becomes active. On submit the
6
- * parent's `onSubmitRequest` is called with the reason string; the parent
7
- * (page or modal) is responsible for calling the API.
4
+ * ## What changed and why
8
5
  *
9
- * This component is presentational: it has no server-action imports and
10
- * does not talk to the API directly.
6
+ * This component had **zero call sites** and was grandfathered in
7
+ * `audit-orphan-view-component`. It also predated three project rules:
8
+ * it hand-rolled `useState<string | null>` for its error (Rule #9.4), took a
9
+ * free-text reason with no schema (Rule #9.2), and had no `<FormErrorSummary>`.
10
+ *
11
+ * The free-text reason is the substantive change. "Final sale, unless the
12
+ * buyer didn't get what they paid for" cannot be enforced against prose — the
13
+ * server needs a coded reason to decide, so the buyer picks one here and the
14
+ * note is the optional elaboration beside it.
15
+ *
16
+ * ## Final sale is a filter, not a wall
17
+ *
18
+ * When a line in scope is final sale, this does NOT refuse to render. It says
19
+ * so, and narrows the picker to the reasons that remain valid. Blocking the
20
+ * page outright would hide the two claims a final sale can never refuse —
21
+ * which are exactly the ones a buyer in trouble needs.
11
22
  */
12
23
  import React from "react";
13
- import type { OrderDocument } from "../schemas";
24
+ import { type ReturnReason } from "../../../_internal/shared/features/orders/return-reasons";
25
+ import type { Order } from "../types";
26
+ export interface RefundRequestSubmission {
27
+ reasonCode: ReturnReason;
28
+ reasonNote?: string;
29
+ }
14
30
  export interface RefundRequestViewProps {
15
- order: Pick<OrderDocument, "id" | "totalPrice" | "currency" | "isNonRefundable" | "refunds" | "contestable">;
16
- onSubmitRequest: (reason: string) => Promise<void>;
31
+ order: Pick<Order, "id" | "total" | "currency" | "isNonRefundable" | "contestable" | "items">;
32
+ onSubmitRequest: (submission: RefundRequestSubmission) => Promise<void>;
17
33
  isLoading?: boolean;
18
34
  className?: string;
19
35
  }
@@ -1,48 +1,132 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /**
4
- * RefundRequestView — buyer-facing refund submission form.
4
+ * RefundRequestView — buyer-facing return / refund request form.
5
5
  *
6
- * Shows 3 acknowledgment checkboxes (via the appkit Checkbox primitive) that
7
- * the buyer must tick before the submit button becomes active. On submit the
8
- * parent's `onSubmitRequest` is called with the reason string; the parent
9
- * (page or modal) is responsible for calling the API.
6
+ * ## What changed and why
10
7
  *
11
- * This component is presentational: it has no server-action imports and
12
- * does not talk to the API directly.
8
+ * This component had **zero call sites** and was grandfathered in
9
+ * `audit-orphan-view-component`. It also predated three project rules:
10
+ * it hand-rolled `useState<string | null>` for its error (Rule #9.4), took a
11
+ * free-text reason with no schema (Rule #9.2), and had no `<FormErrorSummary>`.
12
+ *
13
+ * The free-text reason is the substantive change. "Final sale, unless the
14
+ * buyer didn't get what they paid for" cannot be enforced against prose — the
15
+ * server needs a coded reason to decide, so the buyer picks one here and the
16
+ * note is the optional elaboration beside it.
17
+ *
18
+ * ## Final sale is a filter, not a wall
19
+ *
20
+ * When a line in scope is final sale, this does NOT refuse to render. It says
21
+ * so, and narrows the picker to the reasons that remain valid. Blocking the
22
+ * page outright would hide the two claims a final sale can never refuse —
23
+ * which are exactly the ones a buyer in trouble needs.
13
24
  */
14
- import { useState } from "react";
15
- import { Button, Checkbox, Div, Span, Stack, Text, Textarea } from "../../../ui";
25
+ import { useMemo, useState } from "react";
26
+ import { Alert, Div, Span, Stack, Text, Checkbox, PaginatedSelect } from "../../../ui";
27
+ import { FormShellContext, useFormShellState, FormErrorSummary, FieldTextarea, } from "../../../ui/forms";
28
+ import { SectionForm, useSectionFormNav } from "../../shell";
29
+ import { z } from "zod";
16
30
  import { REFUND_COPY } from "../../../_internal/shared/features/orders/refund-copy";
31
+ import { RETURN_REASON_LABEL, selectableReturnReasons, } from "../../../_internal/shared/features/orders/return-reasons";
17
32
  import { formatCurrency } from "../../../utils/number.formatter";
18
33
  import { normalizeError } from "../../../errors/normalize";
19
34
  import { toUserMessage } from "../../../errors/error-display-map";
20
35
  const ACK_COUNT = REFUND_COPY.request.acknowledgments.length;
21
- const CLS_WARN_PANEL = "rounded-xl border border-warning bg-warning-surface p-[var(--appkit-space-4)] dark:border-warning";
22
36
  export function RefundRequestView({ order, onSubmitRequest, isLoading = false, className = "", }) {
23
- const [acknowledged, setAcknowledged] = useState(Array(ACK_COUNT).fill(false));
24
- const [reason, setReason] = useState("");
25
- const [error, setError] = useState(null);
26
- const allChecked = acknowledged.every(Boolean);
27
- const canSubmit = allChecked && reason.trim().length >= 10 && !isLoading;
37
+ /*
38
+ * Does anything in this order carry the final-sale term? Read from the
39
+ * ORDER's own per-line snapshot — the product may have been re-listed on
40
+ * different terms since, and the buyer agreed to the terms at checkout.
41
+ */
42
+ const hasFinalSaleLine = useMemo(() => (order.items ?? []).some((i) => i.finalSale === true), [order.items]);
43
+ const allowedReasons = useMemo(() => selectableReturnReasons(hasFinalSaleLine), [hasFinalSaleLine]);
44
+ /*
45
+ * The schema mirrors the server's `returnRequestSchema`, plus the
46
+ * acknowledgements, which are a client-side consent gate and are not stored.
47
+ * `reasonCode` is constrained to the ALLOWED set rather than the full enum,
48
+ * so a change-of-mind reason cannot be submitted on a final-sale order even
49
+ * if the option list were bypassed.
50
+ */
51
+ const schema = useMemo(() => z.object({
52
+ reasonCode: z.enum(allowedReasons, {
53
+ errorMap: () => ({ message: "Choose why you're returning this order." }),
54
+ }),
55
+ reasonNote: z.string().max(500).optional(),
56
+ acknowledged: z
57
+ .array(z.boolean())
58
+ .refine((a) => a.every(Boolean), {
59
+ message: "Please acknowledge all three statements before submitting.",
60
+ }),
61
+ }), [allowedReasons]);
62
+ const [draft, setDraft] = useState({
63
+ reasonCode: "",
64
+ reasonNote: "",
65
+ acknowledged: Array(ACK_COUNT).fill(false),
66
+ });
67
+ const update = (partial) => setDraft((prev) => ({ ...prev, ...partial }));
68
+ const sections = useMemo(() => [
69
+ {
70
+ id: "declare",
71
+ label: "Why are you returning this?",
72
+ required: true,
73
+ fields: ["reasonCode", "reasonNote"],
74
+ render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "sm", color: "muted", children: [REFUND_COPY.request.orderTotalLabel, " ", _jsx(Span, { weight: "bold", children: formatCurrency(order.total, order.currency ?? "INR") })] }), hasFinalSaleLine && (_jsx(Alert, { variant: "warning", title: REFUND_COPY.request.finalSaleNoticeHeading, children: "You can\u2019t return it for a change of mind, but you can still claim if it never arrived, arrived damaged, was the wrong item, wasn\u2019t as described, or is counterfeit. Only those reasons are listed below." })), _jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", weight: "semibold", children: [REFUND_COPY.request.reasonSelectLabel, " ", _jsx(Span, { "aria-hidden": true, children: "*" })] }), _jsx(PaginatedSelect, { value: values.reasonCode === "" ? null : values.reasonCode, onChange: (v) => onChange({ reasonCode: v ?? "" }), options: allowedReasons.map((r) => ({
75
+ value: r,
76
+ label: RETURN_REASON_LABEL[r],
77
+ })), placeholder: REFUND_COPY.request.reasonSelectPlaceholder, ariaLabel: REFUND_COPY.request.reasonSelectLabel })] }), _jsx(FieldTextarea, { name: "reasonNote", label: REFUND_COPY.request.reasonNoteLabel, value: values.reasonNote, onChange: (v) => onChange({ reasonNote: v }), rows: 3, maxLength: 500, showCharCount: true })] })),
78
+ },
79
+ {
80
+ id: "declare-acknowledgements",
81
+ label: REFUND_COPY.request.acknowledgeHeading,
82
+ required: true,
83
+ fields: ["acknowledged"],
84
+ render: ({ values, onChange }) => (_jsx(Stack, { gap: "sm", children: REFUND_COPY.request.acknowledgments.map((text, i) => (_jsx(Checkbox, { checked: values.acknowledged[i] ?? false, onChange: () => onChange({
85
+ acknowledged: values.acknowledged.map((v, idx) => idx === i ? !v : v),
86
+ }), label: _jsx(Text, { size: "sm", className: "leading-snug", children: text }), "aria-label": `Acknowledgment ${i + 1}` }, i))) })),
87
+ },
88
+ ], [order.total, order.currency, hasFinalSaleLine, allowedReasons]);
89
+ const nav = useSectionFormNav(sections, draft);
90
+ const { shellCtx, setFieldError, clearErrors } = useFormShellState(schema, {
91
+ sections: nav.sectionMeta,
92
+ onGoToSection: nav.goToSection,
93
+ fieldToSectionIndex: nav.fieldToSectionIndex,
94
+ });
95
+ // ── Early returns: states in which no request can be made at all ──────────
28
96
  if (order.isNonRefundable) {
29
- return (_jsx(Div, { className: `${className}`, rounded: "xl", padding: "md", surface: "muted", border: "default", children: _jsx(Text, { size: "sm", color: "muted", children: REFUND_COPY.request.nonRefundableMessage }) }));
97
+ return (_jsx(Div, { className: className, rounded: "xl", padding: "md", surface: "muted", border: "default", children: _jsx(Text, { size: "sm", color: "muted", children: REFUND_COPY.request.nonRefundableMessage }) }));
30
98
  }
31
99
  if (order.contestable === false) {
32
- return (_jsx(Div, { className: `${CLS_WARN_PANEL} ${className}`, children: _jsx(Text, { size: "sm", className: "text-warning", children: REFUND_COPY.request.alreadyRefundedMessage }) }));
100
+ return (_jsx(Alert, { variant: "warning", className: className, children: REFUND_COPY.request.alreadyRefundedMessage }));
33
101
  }
34
- const handleToggle = (i) => setAcknowledged((prev) => prev.map((v, idx) => (idx === i ? !v : v)));
35
102
  const handleSubmit = async () => {
36
- setError(null);
103
+ clearErrors();
104
+ const parsed = schema.safeParse(draft);
105
+ if (!parsed.success) {
106
+ for (const issue of parsed.error.issues) {
107
+ setFieldError(issue.path.map(String).join("."), issue.message);
108
+ }
109
+ return;
110
+ }
37
111
  try {
38
- await onSubmitRequest(reason.trim());
112
+ await onSubmitRequest({
113
+ reasonCode: parsed.data.reasonCode,
114
+ ...(parsed.data.reasonNote?.trim()
115
+ ? { reasonNote: parsed.data.reasonNote.trim() }
116
+ : {}),
117
+ });
39
118
  }
40
119
  catch (err) {
41
120
  const e = normalizeError(err);
42
- setError(toUserMessage(e.code, undefined, {
43
- fallback: REFUND_COPY.request.errorFallback,
44
- }));
121
+ /*
122
+ * Surfaced on the field the server actually rejected. A final-sale
123
+ * refusal is always about the chosen reason, so putting it on
124
+ * `reasonCode` puts the message next to the control that fixes it.
125
+ *
126
+ * `toUserMessage` and never the raw server text — Rule #9.6.
127
+ */
128
+ setFieldError("reasonCode", toUserMessage(e.code, undefined, { fallback: REFUND_COPY.request.errorFallback }));
45
129
  }
46
130
  };
47
- return (_jsxs(Stack, { gap: "md", className: className, children: [_jsxs(Text, { size: "sm", color: "muted", children: [REFUND_COPY.request.orderTotalLabel, " ", _jsx(Span, { weight: "bold", children: formatCurrency(order.totalPrice, order.currency ?? "INR") })] }), _jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", weight: "semibold", children: [REFUND_COPY.request.reasonLabel, " ", _jsx(Span, { "aria-hidden": true, children: "*" })] }), _jsx(Textarea, { value: reason, onChange: (e) => setReason(e.target.value), placeholder: REFUND_COPY.request.reasonPlaceholder, rows: 3, "aria-required": "true", "aria-label": REFUND_COPY.request.reasonLabel })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", children: REFUND_COPY.request.acknowledgeHeading }), REFUND_COPY.request.acknowledgments.map((text, i) => (_jsx(Checkbox, { checked: acknowledged[i], onChange: () => handleToggle(i), label: _jsx(Text, { size: "sm", className: "leading-snug", children: text }), "aria-label": `Acknowledgment ${i + 1}` }, i)))] }), error && (_jsx(Text, { size: "sm", color: "danger", role: "alert", children: error })), _jsx(Button, { variant: "danger", onClick: handleSubmit, disabled: !canSubmit, "aria-busy": isLoading, children: isLoading ? REFUND_COPY.request.submittingLabel : REFUND_COPY.request.submitLabel })] }));
131
+ return (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "md", className: className, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: update, onSubmit: handleSubmit, schema: schema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: REFUND_COPY.request.submitLabel, isLoading: isLoading })] }) }));
48
132
  }
@@ -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 {};