@mohasinac/appkit 4.33.1 → 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.
- 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/site-settings/adapters.js +1 -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/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/seed-data/tester-checklist-seed-data.js +182 -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/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
|
@@ -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
|
-
|
|
92
|
-
|
|
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" }),
|
|
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
|
|
2
|
+
* RefundRequestView — buyer-facing return / refund request form.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
10
|
-
*
|
|
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
|
|
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<
|
|
16
|
-
onSubmitRequest: (
|
|
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
|
|
4
|
+
* RefundRequestView — buyer-facing return / refund request form.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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
|
|
12
|
-
*
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
43
|
-
|
|
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 (
|
|
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:
|
|
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 {};
|