@mohasinac/appkit 4.14.0 → 4.15.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/auctions/actions.js +2 -2
- package/dist/_internal/server/features/cart/service.d.ts +3 -3
- package/dist/_internal/server/features/checkout/actions.js +19 -0
- package/dist/_internal/server/features/checkout/source-context.d.ts +5 -0
- package/dist/_internal/server/features/checkout/source-context.js +157 -0
- package/dist/_internal/server/features/offers/adapters.d.ts +103 -0
- package/dist/_internal/server/features/offers/adapters.js +88 -0
- package/dist/_internal/server/features/offers/index.d.ts +2 -0
- package/dist/_internal/server/features/offers/index.js +1 -0
- package/dist/_internal/server/features/orders/adapters.js +24 -0
- package/dist/_internal/server/jobs/core/offerExpiry.js +10 -10
- package/dist/_internal/shared/actions/action-registry.js +3 -1
- package/dist/_internal/shared/features/auctions/schema.d.ts +14 -2
- package/dist/_internal/shared/features/auctions/schema.js +13 -1
- package/dist/_internal/shared/features/cart/schema.d.ts +14 -9
- package/dist/_internal/shared/features/cart/schema.js +15 -0
- package/dist/_internal/shared/features/products/schema.d.ts +2 -2
- package/dist/_internal/shared/history/history.d.ts +22 -0
- package/dist/_internal/shared/history/history.js +41 -1
- package/dist/client.d.ts +13 -2
- package/dist/client.js +20 -2
- package/dist/constants/field-names.d.ts +8 -6
- package/dist/constants/field-names.js +48 -6
- package/dist/features/account/components/UserBidsView.js +12 -4
- package/dist/features/account/components/UserReturnsView.d.ts +3 -0
- package/dist/features/account/components/UserReturnsView.js +17 -1
- package/dist/features/admin/components/AdminAuditLogView.js +1 -0
- package/dist/features/admin/components/AdminBidsView.js +15 -2
- package/dist/features/admin/components/AdminBlogEditorView.js +65 -33
- package/dist/features/admin/components/AdminFeatureEditorView.js +35 -6
- package/dist/features/admin/components/AdminNavEditorView.js +15 -5
- package/dist/features/admin/components/AdminNewsletterView.js +18 -2
- package/dist/features/admin/components/AdminOffersView.js +62 -18
- package/dist/features/admin/components/AdminPaymentMethodsView.js +24 -4
- package/dist/features/admin/components/AdminScammerEditorView.d.ts +2 -1
- package/dist/features/admin/components/AdminScammerEditorView.js +3 -13
- package/dist/features/admin/components/AdminScammersView.js +3 -8
- package/dist/features/admin/components/AdminSectionsView.js +32 -23
- package/dist/features/admin/components/AdminSessionsView.js +17 -2
- package/dist/features/admin/components/ViewAuditLogEntryModal.js +1 -0
- package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +4 -2
- package/dist/features/admin/components/sections/adminSectionsTypes.js +45 -27
- package/dist/features/admin/constants/filter-tabs.d.ts +3 -0
- package/dist/features/admin/constants/filter-tabs.js +1 -0
- package/dist/features/admin/schemas/homepage-section-form.d.ts +22 -0
- package/dist/features/admin/schemas/homepage-section-form.js +68 -0
- package/dist/features/admin/schemas/nav-item-form.d.ts +74 -0
- package/dist/features/admin/schemas/nav-item-form.js +72 -0
- package/dist/features/auctions/components/AuctionBidsTable.d.ts +10 -1
- package/dist/features/auctions/components/AuctionBidsTable.js +22 -4
- package/dist/features/auctions/components/bid-detail-fields.d.ts +29 -0
- package/dist/features/auctions/components/bid-detail-fields.js +103 -0
- package/dist/features/audit-log/schemas/firestore.d.ts +14 -0
- package/dist/features/audit-log/schemas/firestore.js +7 -0
- package/dist/features/blog/schemas/blog-form.d.ts +80 -0
- package/dist/features/blog/schemas/blog-form.js +100 -0
- package/dist/features/events/components/AdminEventEditorView.js +32 -54
- package/dist/features/events/components/AdminEventEntriesView.js +22 -3
- package/dist/features/events/schemas/event-form.d.ts +113 -0
- package/dist/features/events/schemas/event-form.js +82 -0
- package/dist/features/grouped/schemas/grouped-listing-form.d.ts +101 -0
- package/dist/features/grouped/schemas/grouped-listing-form.js +89 -0
- package/dist/features/homepage/actions/homepage-section-actions.d.ts +11 -3
- package/dist/features/homepage/actions/homepage-section-actions.js +33 -1
- package/dist/features/homepage/schemas/index.d.ts +31 -0
- package/dist/features/homepage/schemas/index.js +24 -0
- package/dist/features/orders/components/OrderStatusTimeline.d.ts +5 -1
- package/dist/features/orders/components/OrderStatusTimeline.js +39 -20
- package/dist/features/orders/repository/orders.repository.js +5 -0
- package/dist/features/orders/schemas/firestore.d.ts +36 -1
- package/dist/features/orders/types/index.d.ts +35 -7
- package/dist/features/products/components/GroupSettingsPanel.js +5 -1
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/products/schemas/product-features.validators.d.ts +73 -0
- package/dist/features/products/schemas/product-features.validators.js +63 -0
- package/dist/features/scams/constants/scammer-status-display.d.ts +28 -0
- package/dist/features/scams/constants/scammer-status-display.js +79 -0
- package/dist/features/seller/actions/offer-actions.js +32 -2
- package/dist/features/seller/components/OfferPhaseTimeline.d.ts +59 -0
- package/dist/features/seller/components/OfferPhaseTimeline.js +107 -0
- package/dist/features/seller/components/SellerBidsView.js +11 -4
- package/dist/features/seller/components/SellerCouponEditorView.js +17 -17
- package/dist/features/seller/components/SellerOffersView.js +2 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +38 -14
- package/dist/features/seller/repository/offer.repository.d.ts +69 -8
- package/dist/features/seller/repository/offer.repository.js +127 -23
- package/dist/features/seller/schemas/coupon-form.d.ts +72 -0
- package/dist/features/seller/schemas/coupon-form.js +116 -0
- package/dist/features/seller/schemas/firestore.d.ts +56 -2
- package/dist/features/seller/schemas/firestore.js +27 -0
- package/dist/features/seller/schemas/offer-forms.d.ts +21 -0
- package/dist/features/seller/schemas/offer-forms.js +21 -0
- package/dist/features/shell/build-sections.d.ts +63 -0
- package/dist/features/shell/build-sections.js +207 -0
- package/dist/features/shell/field-ui-meta.d.ts +115 -0
- package/dist/features/shell/field-ui-meta.js +324 -0
- package/dist/features/shell/index.d.ts +4 -0
- package/dist/features/shell/index.js +2 -0
- package/dist/features/status-history/components/StatusTimeline.d.ts +74 -0
- package/dist/features/status-history/components/StatusTimeline.js +46 -0
- package/dist/features/status-history/index.d.ts +11 -0
- package/dist/features/status-history/index.js +10 -0
- package/dist/features/store-extensions/components/ReviewDecisionModal.d.ts +27 -0
- package/dist/features/store-extensions/components/ReviewDecisionModal.js +82 -0
- package/dist/features/store-extensions/index.d.ts +12 -0
- package/dist/features/store-extensions/index.js +12 -0
- package/dist/features/store-extensions/schemas/admin-notification-form.d.ts +36 -0
- package/dist/features/store-extensions/schemas/admin-notification-form.js +114 -0
- package/dist/features/store-extensions/schemas/analytics-forms.d.ts +57 -0
- package/dist/features/store-extensions/schemas/analytics-forms.js +158 -0
- package/dist/features/store-extensions/schemas/custom-role-form.d.ts +85 -0
- package/dist/features/store-extensions/schemas/custom-role-form.js +118 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +21 -1
- package/dist/features/store-extensions/schemas/firestore.js +60 -0
- package/dist/features/store-extensions/schemas/google-config-form.d.ts +53 -0
- package/dist/features/store-extensions/schemas/google-config-form.js +105 -0
- package/dist/features/store-extensions/schemas/item-request-create-form.d.ts +28 -0
- package/dist/features/store-extensions/schemas/item-request-create-form.js +80 -0
- package/dist/features/store-extensions/schemas/listing-template-form.d.ts +78 -0
- package/dist/features/store-extensions/schemas/listing-template-form.js +87 -0
- package/dist/features/store-extensions/schemas/moderation-review-form.d.ts +49 -0
- package/dist/features/store-extensions/schemas/moderation-review-form.js +101 -0
- package/dist/features/store-extensions/schemas/payout-method-form.d.ts +153 -0
- package/dist/features/store-extensions/schemas/payout-method-form.js +155 -0
- package/dist/features/store-extensions/schemas/report-create-form.d.ts +25 -0
- package/dist/features/store-extensions/schemas/report-create-form.js +79 -0
- package/dist/features/store-extensions/schemas/report-review-form.d.ts +47 -0
- package/dist/features/store-extensions/schemas/report-review-form.js +111 -0
- package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +148 -0
- package/dist/features/store-extensions/schemas/shipping-config-form.js +104 -0
- package/dist/features/store-extensions/schemas/store-category-form.d.ts +54 -0
- package/dist/features/store-extensions/schemas/store-category-form.js +73 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +326 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +12 -2
- package/dist/next/routing/route-map.d.ts +16 -2
- package/dist/next/routing/route-map.js +9 -2
- package/dist/schemas/firestore-value.d.ts +8 -0
- package/dist/schemas/firestore-value.js +44 -0
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/schemas/registry.d.ts +634 -1
- package/dist/schemas/registry.js +64 -1
- package/dist/seed/bids-seed-data.js +30 -0
- package/dist/seed/offers-seed-data.js +212 -7
- package/dist/seed/orders-seed-data.js +336 -1
- package/dist/seed/products-auctions-seed-data.js +41 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +5 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/RecordDetailModal.d.ts +10 -1
- package/dist/ui/components/RecordDetailModal.js +2 -2
- package/package.json +2 -1
- package/scripts/node-esm-loader.mjs +65 -0
- package/scripts/seed-cli-loader.mjs +6 -49
- package/scripts/seed-cli.mjs +1 -1
|
@@ -95,6 +95,11 @@ class OrderRepository extends BaseRepository {
|
|
|
95
95
|
reason: ctx?.reason,
|
|
96
96
|
note: ctx?.note,
|
|
97
97
|
extraChanges: ctx?.extraChanges,
|
|
98
|
+
// `encryptPiiFields` is a flat top-level loop and never descends into
|
|
99
|
+
// arrays, so anything PII-named reaching `changes` would persist in
|
|
100
|
+
// PLAINTEXT inside statusHistory. None of ORDER_TRACKED_FIELDS is PII
|
|
101
|
+
// today — this is defence for the next field somebody adds.
|
|
102
|
+
piiFields: ORDER_PII_FIELDS,
|
|
98
103
|
historyField: "statusHistory",
|
|
99
104
|
truncatedField: "statusHistoryTruncated",
|
|
100
105
|
});
|
|
@@ -24,12 +24,39 @@ export interface OrderSourceAuctionWon {
|
|
|
24
24
|
/** Second-highest bid, when there was one. */
|
|
25
25
|
runnerUpAmount?: number;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* A buyout is a REAL BID, not a bid-free purchase.
|
|
29
|
+
*
|
|
30
|
+
* An earlier draft of this type asserted "zero bids BY DEFINITION, because
|
|
31
|
+
* buy-now is only valid while `!bidsHaveStarted`". That gate no longer
|
|
32
|
+
* exists: `buyNowAuction` places an actual `BidDocument` (`isBuyout: true`)
|
|
33
|
+
* plus a 1h locked cart line and leaves the auction **live**, and Buy Now is
|
|
34
|
+
* offered whenever `buyNowPrice` still beats the standing bid. Recording
|
|
35
|
+
* `bidCount: 0` would bake a falsehood into an immutable audit record.
|
|
36
|
+
*
|
|
37
|
+
* `standingBidAtBuyout` is the highest NON-buyout bid the buyout beat — read
|
|
38
|
+
* from the bids rather than `product.currentBid`, because a pending buyout
|
|
39
|
+
* deliberately never writes to that mirror.
|
|
40
|
+
*
|
|
41
|
+
* An unpaid buyout lapses to **`cancelled`**, not `forfeited`, and the seller
|
|
42
|
+
* is deliberately not notified: nothing was ever sold. That makes its
|
|
43
|
+
* timeline materially different from a lapsed auction win.
|
|
44
|
+
*/
|
|
27
45
|
export interface OrderSourceAuctionBuyNow {
|
|
28
46
|
path: "auction-buy-now";
|
|
29
47
|
auctionId: string;
|
|
48
|
+
/** The buyout bid itself — a buyout IS a bid. */
|
|
49
|
+
bidId: string;
|
|
30
50
|
buyNowPrice: number;
|
|
31
51
|
listPrice: number;
|
|
52
|
+
/** Highest non-buyout bid at the moment of buyout. 0 when nobody had bid. */
|
|
53
|
+
standingBidAtBuyout: number;
|
|
54
|
+
/** Active non-buyout bids at buyout time. NOT always zero. */
|
|
55
|
+
bidCount: number;
|
|
32
56
|
boughtAt: Date;
|
|
57
|
+
/** `min(now + AUCTION_BUYOUT_WINDOW_MS, auctionEndDate)`. */
|
|
58
|
+
checkoutDeadline: Date;
|
|
59
|
+
auctionEndDate?: Date;
|
|
33
60
|
}
|
|
34
61
|
export interface OrderSourceOfferAccepted {
|
|
35
62
|
path: "offer-accepted";
|
|
@@ -38,7 +65,15 @@ export interface OrderSourceOfferAccepted {
|
|
|
38
65
|
/** The asking price at the moment the offer was made. */
|
|
39
66
|
listPriceAtOffer: number;
|
|
40
67
|
counterAmount?: number;
|
|
41
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* How many counter rounds before agreement. 1 = accepted outright.
|
|
70
|
+
*
|
|
71
|
+
* Read straight off the accepted offer's `counterRound`, which W2 made a
|
|
72
|
+
* persisted field. Before that this number was **unknowable**: each counter
|
|
73
|
+
* created an unlinked document and `previousOfferId` lived only inside a
|
|
74
|
+
* `serverLogger.info` call, so a three-round negotiation left no trace of
|
|
75
|
+
* having been one.
|
|
76
|
+
*/
|
|
42
77
|
counterRounds: number;
|
|
43
78
|
acceptedBy: string;
|
|
44
79
|
acceptedAt: Date;
|
|
@@ -2,7 +2,8 @@ import { type PaymentGateway, PaymentGatewayValues } from "../../payments/schema
|
|
|
2
2
|
export { type PaymentGateway, PaymentGatewayValues };
|
|
3
3
|
import type { ListingType } from "../../products/types/index";
|
|
4
4
|
import type { OrderType } from "../utils/order-splitter";
|
|
5
|
-
import type { AppliedOrderDiscount } from "../schemas/firestore";
|
|
5
|
+
import type { AppliedOrderDiscount, OrderSourceContext } from "../schemas/firestore";
|
|
6
|
+
import type { FieldChange } from "../../../_internal/shared/history/index";
|
|
6
7
|
export type PaymentStatus = "pending" | "processing" | "paid" | "failed" | "refunded" | "partial_refund";
|
|
7
8
|
export interface UserAddress {
|
|
8
9
|
id: string;
|
|
@@ -46,12 +47,34 @@ export interface OrderItem {
|
|
|
46
47
|
/** Snapshot of bundle.bundleProductIds at order-creation time. */
|
|
47
48
|
bundleProductIds?: string[];
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
/**
|
|
51
|
+
* One entry on an order's timeline — the client-facing shape of
|
|
52
|
+
* `StatusChangeEntry`, with dates as ISO strings.
|
|
53
|
+
*
|
|
54
|
+
* The previous declaration here was `{status, message?, timestamp, actor?}`
|
|
55
|
+
* and had **zero writers** in the entire codebase since the day it was
|
|
56
|
+
* added, so nothing depended on it. `OrderStatusTimeline` synthesised its
|
|
57
|
+
* steps from four scalar date fields instead, which meant any transition
|
|
58
|
+
* without a dedicated date field — payment reviewed, proof re-upload asked
|
|
59
|
+
* for, a partial refund posted — could not appear at all.
|
|
60
|
+
*/
|
|
61
|
+
export interface OrderTimelineEntry {
|
|
62
|
+
at: string;
|
|
63
|
+
actorUid?: string;
|
|
64
|
+
actorRole: "buyer" | "seller" | "admin" | "system";
|
|
65
|
+
/**
|
|
66
|
+
* Delta only: `{ status: { from: "confirmed", to: "shipped" } }`.
|
|
67
|
+
* Reuses the primitive's `FieldChange` rather than restating the shape, so
|
|
68
|
+
* the client type cannot drift from what the repository writes.
|
|
69
|
+
*/
|
|
70
|
+
changes: Record<string, FieldChange>;
|
|
71
|
+
reason?: string;
|
|
72
|
+
note?: string;
|
|
73
|
+
/** The function or job that caused it — what the timeline renders by. */
|
|
74
|
+
trigger: string;
|
|
54
75
|
}
|
|
76
|
+
/** @deprecated Use {@link OrderTimelineEntry}. Kept only to avoid breaking a stale import. */
|
|
77
|
+
export type OrderTimeline = OrderTimelineEntry;
|
|
55
78
|
export interface Order {
|
|
56
79
|
id: string;
|
|
57
80
|
userId: string;
|
|
@@ -108,7 +131,12 @@ export interface Order {
|
|
|
108
131
|
/** Carrier tracking page URL — rendered as an external link that opens in a new tab. No live-tracking API integration; this is just the passthrough URL a seller/admin entered. */
|
|
109
132
|
trackingUrl?: string;
|
|
110
133
|
notes?: string;
|
|
111
|
-
|
|
134
|
+
/** Real recorded transitions — see {@link OrderTimelineEntry}. */
|
|
135
|
+
timeline?: OrderTimelineEntry[];
|
|
136
|
+
/** Entries trimmed off the front of the capped history, if any. */
|
|
137
|
+
timelineTruncated?: number;
|
|
138
|
+
/** How the buyer acquired the right to buy. Written once, at creation. */
|
|
139
|
+
sourceContext?: OrderSourceContext;
|
|
112
140
|
physicalLocation?: {
|
|
113
141
|
zone: string;
|
|
114
142
|
shelf: string;
|
|
@@ -235,7 +235,11 @@ export function GroupSettingsPanel({ productId, productSlug, groupId, isGroupPar
|
|
|
235
235
|
setOpen(next);
|
|
236
236
|
if (next && isGroupParent && children === null)
|
|
237
237
|
loadChildren();
|
|
238
|
-
}, paddingX: "none", paddingY: "xs", justify: "between", className: "w-full text-left group", "aria-expanded": open, children: [_jsx(Heading, { level: 3, size: "sm", weight: "semibold", color: "muted", children: "Group Settings" }), _jsx(Span, { size: "xs", className: "group-hover:text-[var(--appkit-color-text-muted)] dark:group-hover:text-[var(--appkit-color-text-faint)] transition-colors", color: "faint", children: open ? "▲" : "▼" })] }), open && (_jsxs(Stack, { gap: "sm", className: `mt-3 ${__P.p4}`, surface: "muted", rounded: "lg", border: "default", children: [!groupId && !groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Group related listings together \u2014 e.g. a set, bundle, or multi-part item. Parts can be sold individually but shown together." }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: startGroup, isLoading: loading, children: "Start a group" })] })), isGroupParent && groupId && (_jsxs(Stack, { gap: "md", children: [_jsxs(FormShellContext.Provider, { value: titleShellCtx, children: [_jsxs(Row, { align: "start", gap: "sm", wrap: true, children: [_jsx(Div, { className: "flex-1 min-w-[200px]", children: _jsx(FormField, { name: "groupTitle", label: "Group title", type: "text", value: editTitle, onChange: setEditTitle, placeholder: "e.g. Human Toy Complete Set" }) }), _jsx(Div, { padding: "t-lg", children: _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: saveTitle, isLoading: loading, children: "Save title" }) })] }), _jsx(FormErrorSummary, {})] }), _jsxs(Div, { children: [_jsxs(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: ["Members (", childSlugsCount + 1, " \u2014 including this listing)"] }), loading && !children ? (_jsx(Text, { size: "xs", color: "faint", children: "Loading\u2026" })) : (_jsxs(Div, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", children: [_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [_jsx(Span, { padding: "pill-2xs", weight: "semibold", className: "bg-[var(--appkit-color-primary)]/10 text-[var(--appkit-color-primary)] text-[10px]", rounded: "default", children: "Parent" }), _jsx(Text, { className: "flex-1", color: "primary", size: "sm", children: productSlug })] }), (children ?? []).filter((c) => c.id !== productId).map((child) => (_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [child.images?.[0] ? (_jsx(Div, { className: "relative w-8 h-8 flex-shrink-0 border border-[var(--appkit-color-border)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: child.images[0], alt: child.title, size: "thumbnail" }) })) : (_jsx(Div, { className: "w-8 h-8", surface: "subtle", rounded: "full" })), _jsx(Text, { className: "flex-1 truncate", color: "primary", size: "sm", children: child.title }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => requestUnlinkChild(child.id), isLoading: loading, className: `${CLS_DELETE_LINK} text-[length:var(--appkit-text-xs)]`, children: "Unlink" })] }, child.id)))] }))] }), _jsxs(Row, { gap: "sm", wrap: true, children: [_jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => setShowAddModal(true), children: "Add child listing" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestDissolveGroup, isLoading: loading, className: CLS_DELETE_LINK, children: "Dissolve group" })] })] })), !isGroupParent && groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", color: "muted", children: ["Part of:", " ", _jsx(Span, { weight: "medium", color: "primary", children: groupTitle ?? groupParentSlug })] }), _jsxs(Text, { size: "xs", color: "muted", children: ["Parent listing: ", _jsx(Code, { className: "font-mono", children: groupParentSlug })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestLeaveGroup, isLoading: loading, className: `${CLS_DELETE_LINK} w-fit`, children: "Leave group" })] }))] })), (
|
|
238
|
+
}, paddingX: "none", paddingY: "xs", justify: "between", className: "w-full text-left group", "aria-expanded": open, children: [_jsx(Heading, { level: 3, size: "sm", weight: "semibold", color: "muted", children: "Group Settings" }), _jsx(Span, { size: "xs", className: "group-hover:text-[var(--appkit-color-text-muted)] dark:group-hover:text-[var(--appkit-color-text-faint)] transition-colors", color: "faint", children: open ? "▲" : "▼" })] }), open && (_jsxs(Stack, { gap: "sm", className: `mt-3 ${__P.p4}`, surface: "muted", rounded: "lg", border: "default", children: [!groupId && !groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Group related listings together \u2014 e.g. a set, bundle, or multi-part item. Parts can be sold individually but shown together." }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: startGroup, isLoading: loading, children: "Start a group" })] })), isGroupParent && groupId && (_jsxs(Stack, { gap: "md", children: [_jsxs(FormShellContext.Provider, { value: titleShellCtx, children: [_jsxs(Row, { align: "start", gap: "sm", wrap: true, children: [_jsx(Div, { className: "flex-1 min-w-[200px]", children: _jsx(FormField, { name: "groupTitle", label: "Group title", type: "text", value: editTitle, onChange: setEditTitle, placeholder: "e.g. Human Toy Complete Set" }) }), _jsx(Div, { padding: "t-lg", children: _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: saveTitle, isLoading: loading, children: "Save title" }) })] }), _jsx(FormErrorSummary, {})] }), _jsxs(Div, { children: [_jsxs(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: ["Members (", childSlugsCount + 1, " \u2014 including this listing)"] }), loading && !children ? (_jsx(Text, { size: "xs", color: "faint", children: "Loading\u2026" })) : (_jsxs(Div, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", children: [_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [_jsx(Span, { padding: "pill-2xs", weight: "semibold", className: "bg-[var(--appkit-color-primary)]/10 text-[var(--appkit-color-primary)] text-[10px]", rounded: "default", children: "Parent" }), _jsx(Text, { className: "flex-1", color: "primary", size: "sm", children: productSlug })] }), (children ?? []).filter((c) => c.id !== productId).map((child) => (_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [child.images?.[0] ? (_jsx(Div, { className: "relative w-8 h-8 flex-shrink-0 border border-[var(--appkit-color-border)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: child.images[0], alt: child.title, size: "thumbnail" }) })) : (_jsx(Div, { className: "w-8 h-8", surface: "subtle", rounded: "full" })), _jsx(Text, { className: "flex-1 truncate", color: "primary", size: "sm", children: child.title }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => requestUnlinkChild(child.id), isLoading: loading, className: `${CLS_DELETE_LINK} text-[length:var(--appkit-text-xs)]`, children: "Unlink" })] }, child.id)))] }))] }), _jsxs(Row, { gap: "sm", wrap: true, children: [_jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => setShowAddModal(true), children: "Add child listing" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestDissolveGroup, isLoading: loading, className: CLS_DELETE_LINK, children: "Dissolve group" })] })] })), !isGroupParent && groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", color: "muted", children: ["Part of:", " ", _jsx(Span, { weight: "medium", color: "primary", children: groupTitle ?? groupParentSlug })] }), _jsxs(Text, { size: "xs", color: "muted", children: ["Parent listing: ", _jsx(Code, { className: "font-mono", children: groupParentSlug })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestLeaveGroup, isLoading: loading, className: `${CLS_DELETE_LINK} w-fit`, children: "Leave group" })] }))] })), (() => {
|
|
239
|
+
const content = (_jsx(AddChildContent, { addTab: addTab, setAddTab: setAddTab, createForm: createForm, setCreateForm: setCreateForm, productSlug: productSlug, storeProductsEndpoint: storeProductsEndpoint, linkTargets: linkTargets, setLinkTargets: setLinkTargets, onAddCreate: addCreateChild, onAddLink: addLinkChild, loading: loading }));
|
|
240
|
+
const close = () => setShowAddModal(false);
|
|
241
|
+
return (groupChildSlugs?.length ?? 0) >= 4 ? (_jsx(SideDrawer, { isOpen: showAddModal, onClose: close, title: "Add child listing", children: content })) : (_jsx(Modal, { open: showAddModal, onClose: close, title: "Add child listing", size: "lg", children: content }));
|
|
242
|
+
})(), confirmAction && (_jsx(ConfirmDeleteModal, { isOpen: true, title: confirmAction.title, message: confirmAction.message, onConfirm: confirmAction.onConfirm, onClose: () => setConfirmAction(null), isDeleting: loading }))] }));
|
|
239
243
|
}
|
|
240
244
|
function AddChildContent({ addTab, setAddTab, createForm, setCreateForm, productSlug, storeProductsEndpoint, linkTargets, setLinkTargets, onAddCreate, onAddLink, loading, }) {
|
|
241
245
|
const { shellCtx, validate } = useFormShellState(createChildSchema);
|
|
@@ -377,7 +377,7 @@ export declare const productItemSchema: z.ZodObject<{
|
|
|
377
377
|
preOrderDepositAmount?: number | undefined;
|
|
378
378
|
preOrderMaxQuantity?: number | undefined;
|
|
379
379
|
preOrderCurrentCount?: number | undefined;
|
|
380
|
-
preOrderProductionStatus?: "
|
|
380
|
+
preOrderProductionStatus?: "upcoming" | "in_production" | "ready_to_ship" | undefined;
|
|
381
381
|
preOrderCancellable?: boolean | undefined;
|
|
382
382
|
isPromoted?: boolean | undefined;
|
|
383
383
|
pickupAddressId?: string | undefined;
|
|
@@ -516,7 +516,7 @@ export declare const productItemSchema: z.ZodObject<{
|
|
|
516
516
|
preOrderDepositAmount?: number | undefined;
|
|
517
517
|
preOrderMaxQuantity?: number | undefined;
|
|
518
518
|
preOrderCurrentCount?: number | undefined;
|
|
519
|
-
preOrderProductionStatus?: "
|
|
519
|
+
preOrderProductionStatus?: "upcoming" | "in_production" | "ready_to_ship" | undefined;
|
|
520
520
|
preOrderCancellable?: boolean | undefined;
|
|
521
521
|
isPromoted?: boolean | undefined;
|
|
522
522
|
pickupAddressId?: string | undefined;
|
|
@@ -102,6 +102,79 @@ export declare const productFeatureUpdateSchema: z.ZodObject<{
|
|
|
102
102
|
iconColor?: string | undefined;
|
|
103
103
|
productTypes?: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[] | undefined;
|
|
104
104
|
}>;
|
|
105
|
+
/**
|
|
106
|
+
* The FORM schema for the product-feature editor, with UI metadata.
|
|
107
|
+
*
|
|
108
|
+
* `AdminFeatureEditorView` had **no validation whatsoever** before W5 — ten
|
|
109
|
+
* `useState` fields, `required` on the inputs (an HTML attribute any
|
|
110
|
+
* programmatic submit bypasses), and nothing else. Meanwhile these validators
|
|
111
|
+
* had existed all along and simply were not wired: the "schema exists, the
|
|
112
|
+
* consumer ignores it" case the plan catalogues.
|
|
113
|
+
*
|
|
114
|
+
* It reuses `productFeatureAdminCreateSchema`'s field shapes rather than
|
|
115
|
+
* restating them — a second copy of the same bounds is how the two would come
|
|
116
|
+
* to disagree. Only the annotations are new.
|
|
117
|
+
*
|
|
118
|
+
* 🛑 `annotate()` must be the OUTERMOST call — it keys a WeakMap by schema
|
|
119
|
+
* instance and every zod wrapper returns a new one.
|
|
120
|
+
*/
|
|
121
|
+
export declare const productFeatureFormSchema: z.ZodEffects<z.ZodObject<{
|
|
122
|
+
label: z.ZodString;
|
|
123
|
+
description: z.ZodOptional<z.ZodString>;
|
|
124
|
+
icon: z.ZodString;
|
|
125
|
+
iconColor: z.ZodOptional<z.ZodString>;
|
|
126
|
+
category: z.ZodEnum<["shipping", "seller", "condition", "platform", "auction", "preorder", "custom"]>;
|
|
127
|
+
scope: z.ZodEnum<["platform", "store"]>;
|
|
128
|
+
productTypes: z.ZodArray<z.ZodEnum<["product", "auction", "preorder", "prize-draw", "classified", "digital-code", "live", "all"]>, "many">;
|
|
129
|
+
storeId: z.ZodOptional<z.ZodString>;
|
|
130
|
+
isActive: z.ZodBoolean;
|
|
131
|
+
displayOrder: z.ZodNumber;
|
|
132
|
+
}, "strip", z.ZodTypeAny, {
|
|
133
|
+
label: string;
|
|
134
|
+
scope: "store" | "platform";
|
|
135
|
+
icon: string;
|
|
136
|
+
category: "shipping" | "custom" | "seller" | "condition" | "auction" | "platform" | "preorder";
|
|
137
|
+
isActive: boolean;
|
|
138
|
+
displayOrder: number;
|
|
139
|
+
productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
|
|
140
|
+
storeId?: string | undefined;
|
|
141
|
+
description?: string | undefined;
|
|
142
|
+
iconColor?: string | undefined;
|
|
143
|
+
}, {
|
|
144
|
+
label: string;
|
|
145
|
+
scope: "store" | "platform";
|
|
146
|
+
icon: string;
|
|
147
|
+
category: "shipping" | "custom" | "seller" | "condition" | "auction" | "platform" | "preorder";
|
|
148
|
+
isActive: boolean;
|
|
149
|
+
displayOrder: number;
|
|
150
|
+
productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
|
|
151
|
+
storeId?: string | undefined;
|
|
152
|
+
description?: string | undefined;
|
|
153
|
+
iconColor?: string | undefined;
|
|
154
|
+
}>, {
|
|
155
|
+
label: string;
|
|
156
|
+
scope: "store" | "platform";
|
|
157
|
+
icon: string;
|
|
158
|
+
category: "shipping" | "custom" | "seller" | "condition" | "auction" | "platform" | "preorder";
|
|
159
|
+
isActive: boolean;
|
|
160
|
+
displayOrder: number;
|
|
161
|
+
productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
|
|
162
|
+
storeId?: string | undefined;
|
|
163
|
+
description?: string | undefined;
|
|
164
|
+
iconColor?: string | undefined;
|
|
165
|
+
}, {
|
|
166
|
+
label: string;
|
|
167
|
+
scope: "store" | "platform";
|
|
168
|
+
icon: string;
|
|
169
|
+
category: "shipping" | "custom" | "seller" | "condition" | "auction" | "platform" | "preorder";
|
|
170
|
+
isActive: boolean;
|
|
171
|
+
displayOrder: number;
|
|
172
|
+
productTypes: ("all" | "classified" | "auction" | "prize-draw" | "digital-code" | "live" | "product" | "preorder")[];
|
|
173
|
+
storeId?: string | undefined;
|
|
174
|
+
description?: string | undefined;
|
|
175
|
+
iconColor?: string | undefined;
|
|
176
|
+
}>;
|
|
177
|
+
export type ProductFeatureFormValues = z.infer<typeof productFeatureFormSchema>;
|
|
105
178
|
export type ProductFeatureAdminCreatePayload = z.infer<typeof productFeatureAdminCreateSchema>;
|
|
106
179
|
export type ProductFeatureStoreCreatePayload = z.infer<typeof productFeatureStoreCreateSchema>;
|
|
107
180
|
export type ProductFeatureUpdatePayload = z.infer<typeof productFeatureUpdateSchema>;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* of hand-rolling parallel schemas.
|
|
7
7
|
*/
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import { annotate } from "../../shell/field-ui-meta";
|
|
9
10
|
export const PRODUCT_FEATURE_PRODUCT_TYPE_ENUM = z.enum([
|
|
10
11
|
"product",
|
|
11
12
|
"auction",
|
|
@@ -66,3 +67,65 @@ export const productFeatureUpdateSchema = z.object({
|
|
|
66
67
|
isActive: z.boolean().optional(),
|
|
67
68
|
displayOrder: z.number().int().min(0).max(FEATURE_DISPLAY_ORDER_MAX).optional(),
|
|
68
69
|
});
|
|
70
|
+
/**
|
|
71
|
+
* The FORM schema for the product-feature editor, with UI metadata.
|
|
72
|
+
*
|
|
73
|
+
* `AdminFeatureEditorView` had **no validation whatsoever** before W5 — ten
|
|
74
|
+
* `useState` fields, `required` on the inputs (an HTML attribute any
|
|
75
|
+
* programmatic submit bypasses), and nothing else. Meanwhile these validators
|
|
76
|
+
* had existed all along and simply were not wired: the "schema exists, the
|
|
77
|
+
* consumer ignores it" case the plan catalogues.
|
|
78
|
+
*
|
|
79
|
+
* It reuses `productFeatureAdminCreateSchema`'s field shapes rather than
|
|
80
|
+
* restating them — a second copy of the same bounds is how the two would come
|
|
81
|
+
* to disagree. Only the annotations are new.
|
|
82
|
+
*
|
|
83
|
+
* 🛑 `annotate()` must be the OUTERMOST call — it keys a WeakMap by schema
|
|
84
|
+
* instance and every zod wrapper returns a new one.
|
|
85
|
+
*/
|
|
86
|
+
export const productFeatureFormSchema = z.object({
|
|
87
|
+
label: annotate(productFeatureAdminCreateSchema.shape.label, {
|
|
88
|
+
section: "basics", sectionLabel: "Feature", sectionRequired: true,
|
|
89
|
+
quick: true, order: 1, row: "full",
|
|
90
|
+
}),
|
|
91
|
+
description: annotate(productFeatureAdminCreateSchema.shape.description, {
|
|
92
|
+
section: "basics", order: 2, row: "full",
|
|
93
|
+
}),
|
|
94
|
+
icon: annotate(productFeatureAdminCreateSchema.shape.icon, {
|
|
95
|
+
section: "basics", quick: true, order: 3, row: "pair",
|
|
96
|
+
}),
|
|
97
|
+
iconColor: annotate(productFeatureAdminCreateSchema.shape.iconColor, {
|
|
98
|
+
section: "basics", order: 4, row: "pair",
|
|
99
|
+
}),
|
|
100
|
+
category: annotate(productFeatureAdminCreateSchema.shape.category, {
|
|
101
|
+
section: "scope", sectionLabel: "Scope & Applicability",
|
|
102
|
+
quick: true, order: 1, row: "pair",
|
|
103
|
+
}),
|
|
104
|
+
scope: annotate(productFeatureAdminCreateSchema.shape.scope, {
|
|
105
|
+
section: "scope", quick: true, order: 2, row: "pair",
|
|
106
|
+
}),
|
|
107
|
+
productTypes: annotate(productFeatureAdminCreateSchema.shape.productTypes, {
|
|
108
|
+
section: "scope", order: 3, row: "full", kind: "list",
|
|
109
|
+
}),
|
|
110
|
+
storeId: annotate(productFeatureAdminCreateSchema.shape.storeId, {
|
|
111
|
+
section: "scope", order: 4, row: "pair",
|
|
112
|
+
help: "Only used when scope is \"store\".",
|
|
113
|
+
}),
|
|
114
|
+
isActive: annotate(productFeatureAdminCreateSchema.shape.isActive, {
|
|
115
|
+
section: "visibility", sectionLabel: "Visibility", order: 1, row: "quarter",
|
|
116
|
+
}),
|
|
117
|
+
displayOrder: annotate(productFeatureAdminCreateSchema.shape.displayOrder, {
|
|
118
|
+
section: "visibility", order: 2, row: "pair",
|
|
119
|
+
}),
|
|
120
|
+
}).superRefine((v, ctx) => {
|
|
121
|
+
// The one rule no per-field bound can state: a store-scoped feature must
|
|
122
|
+
// name its store. The server enforces it too — this exists so the admin is
|
|
123
|
+
// told before submitting rather than by a 400.
|
|
124
|
+
if (v.scope === "store" && !v.storeId?.trim()) {
|
|
125
|
+
ctx.addIssue({
|
|
126
|
+
code: z.ZodIssueCode.custom,
|
|
127
|
+
path: ["storeId"],
|
|
128
|
+
message: "A store-scoped feature must name a store.",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ScammerStatus } from "../schemas/firestore";
|
|
2
|
+
/** Human-readable label. Replaces two `.replace(/_/g, " ")` call sites, which
|
|
3
|
+
* rendered "pending review" — mechanically correct, and not a written label. */
|
|
4
|
+
export declare const SCAMMER_STATUS_LABEL: Record<ScammerStatus, string>;
|
|
5
|
+
/**
|
|
6
|
+
* Inline-chip styling. Every entry is a `bg-{status}-surface` + `text-{status}`
|
|
7
|
+
* pair per Recurrent Root Cause #67 — the two tokens invert together with the
|
|
8
|
+
* theme, which a literal `text-white` would not.
|
|
9
|
+
*
|
|
10
|
+
* `removed` is the neutral case: it has no status colour because it is not a
|
|
11
|
+
* status outcome, it is a withdrawal. ONE background, ONE ink.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SCAMMER_STATUS_BADGE: Record<ScammerStatus, string>;
|
|
14
|
+
/** Dropdown options, derived from the union so one cannot go missing. */
|
|
15
|
+
export declare const SCAMMER_STATUS_OPTIONS: {
|
|
16
|
+
value: "verified" | "rejected" | "removed" | "pending_review";
|
|
17
|
+
label: string;
|
|
18
|
+
}[];
|
|
19
|
+
/**
|
|
20
|
+
* Narrow an unknown API value to a real status.
|
|
21
|
+
*
|
|
22
|
+
* The list view used `toStringValue(item.status, "pending_review")`, which
|
|
23
|
+
* produces a `string` — so an unrecognised value flowed on as if it were a
|
|
24
|
+
* status and every consumer fell back to the pending styling. This maps
|
|
25
|
+
* anything unrecognised to `pending_review` explicitly, and the return type
|
|
26
|
+
* says so.
|
|
27
|
+
*/
|
|
28
|
+
export declare function toScammerStatus(value: unknown): ScammerStatus;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: "How does a scammer status look and read" had FOUR hand-written answers
|
|
3
|
+
* and they had already diverged in a way that shipped a real defect:
|
|
4
|
+
*
|
|
5
|
+
* · `AdminScammersView.STATUS_BADGE` — Record<string, string>
|
|
6
|
+
* · `AdminScammerEditorView.STATUS_COLOR` — Record<string, string>
|
|
7
|
+
* · `AdminScammerEditorView.STATUS_OPTIONS` — a literal label list
|
|
8
|
+
* · `ADMIN_SCAMMER_STATUS_TABS` — the filter chips
|
|
9
|
+
*
|
|
10
|
+
* Both colour maps carried the SAME broken `removed` entry:
|
|
11
|
+
*
|
|
12
|
+
* "bg-[…surface] text-[…muted] bg-[…surface-elevated] text-[…muted]"
|
|
13
|
+
*
|
|
14
|
+
* — two backgrounds and two inks on one element. Tailwind emits all four
|
|
15
|
+
* and the winner is decided by stylesheet order, not by position in the
|
|
16
|
+
* class string, so which background actually painted was not what either
|
|
17
|
+
* map said. One bug, two copies: the Duplication Framework's bug-fix
|
|
18
|
+
* multiplier, which is what makes this a consolidate rather than a
|
|
19
|
+
* third copy.
|
|
20
|
+
*
|
|
21
|
+
* WHAT: One display module beside the union it describes.
|
|
22
|
+
*
|
|
23
|
+
* ## Everything here is keyed `Record<ScammerStatus, …>`
|
|
24
|
+
*
|
|
25
|
+
* Not `Record<string, …>`. A status added to the union without a label or a
|
|
26
|
+
* badge is then a COMPILE error rather than a row that silently renders with
|
|
27
|
+
* the fallback styling of a status it is not — which is precisely how the
|
|
28
|
+
* loose maps hid the broken `removed` entry.
|
|
29
|
+
*
|
|
30
|
+
* EXPORTS:
|
|
31
|
+
* SCAMMER_STATUS_LABEL, SCAMMER_STATUS_BADGE, SCAMMER_STATUS_OPTIONS,
|
|
32
|
+
* toScammerStatus
|
|
33
|
+
*
|
|
34
|
+
* @tag domain:scams
|
|
35
|
+
* @tag layer:constants
|
|
36
|
+
* @tag pattern:none
|
|
37
|
+
* @tag access:isomorphic
|
|
38
|
+
* @tag consumers:AdminScammersView,AdminScammerEditorView
|
|
39
|
+
* @tag sideEffects:none
|
|
40
|
+
*/
|
|
41
|
+
import { ScammerStatusValues } from "../schemas/firestore";
|
|
42
|
+
/** Human-readable label. Replaces two `.replace(/_/g, " ")` call sites, which
|
|
43
|
+
* rendered "pending review" — mechanically correct, and not a written label. */
|
|
44
|
+
export const SCAMMER_STATUS_LABEL = {
|
|
45
|
+
pending_review: "Pending review",
|
|
46
|
+
verified: "Verified",
|
|
47
|
+
rejected: "Rejected",
|
|
48
|
+
removed: "Removed",
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Inline-chip styling. Every entry is a `bg-{status}-surface` + `text-{status}`
|
|
52
|
+
* pair per Recurrent Root Cause #67 — the two tokens invert together with the
|
|
53
|
+
* theme, which a literal `text-white` would not.
|
|
54
|
+
*
|
|
55
|
+
* `removed` is the neutral case: it has no status colour because it is not a
|
|
56
|
+
* status outcome, it is a withdrawal. ONE background, ONE ink.
|
|
57
|
+
*/
|
|
58
|
+
export const SCAMMER_STATUS_BADGE = {
|
|
59
|
+
pending_review: "bg-warning-surface text-warning",
|
|
60
|
+
verified: "bg-success-surface text-success",
|
|
61
|
+
rejected: "bg-error-surface text-error",
|
|
62
|
+
removed: "bg-[var(--appkit-color-surface-elevated)] text-[var(--appkit-color-text-muted)]",
|
|
63
|
+
};
|
|
64
|
+
/** Dropdown options, derived from the union so one cannot go missing. */
|
|
65
|
+
export const SCAMMER_STATUS_OPTIONS = Object.values(ScammerStatusValues).map((value) => ({ value, label: SCAMMER_STATUS_LABEL[value] }));
|
|
66
|
+
/**
|
|
67
|
+
* Narrow an unknown API value to a real status.
|
|
68
|
+
*
|
|
69
|
+
* The list view used `toStringValue(item.status, "pending_review")`, which
|
|
70
|
+
* produces a `string` — so an unrecognised value flowed on as if it were a
|
|
71
|
+
* status and every consumer fell back to the pending styling. This maps
|
|
72
|
+
* anything unrecognised to `pending_review` explicitly, and the return type
|
|
73
|
+
* says so.
|
|
74
|
+
*/
|
|
75
|
+
export function toScammerStatus(value) {
|
|
76
|
+
return Object.values(ScammerStatusValues).includes(value)
|
|
77
|
+
? value
|
|
78
|
+
: ScammerStatusValues.PENDING_REVIEW;
|
|
79
|
+
}
|
|
@@ -210,7 +210,24 @@ export async function counterOfferByBuyer(userId, userEmail, input) {
|
|
|
210
210
|
const offerCount = await offerRepository.countByBuyerAndProduct(userId, offer.productId, product.updatedAt);
|
|
211
211
|
if (offerCount >= 3)
|
|
212
212
|
throw new ValidationError(ERROR_MESSAGES.OFFER.LIMIT_REACHED);
|
|
213
|
-
|
|
213
|
+
// ── Create BEFORE superseding. Order matters. ───────────────────────────
|
|
214
|
+
//
|
|
215
|
+
// This used to `withdraw()` first and `create()` second. If `create` threw —
|
|
216
|
+
// a validation slip, a Firestore blip — the buyer's live counter was ALREADY
|
|
217
|
+
// withdrawn, no replacement existed, the negotiation was dead, and one of
|
|
218
|
+
// their three offer slots had been burnt for nothing.
|
|
219
|
+
//
|
|
220
|
+
// Reversed, the worst failure is a transient extra pending offer, which
|
|
221
|
+
// `hasActiveOffer` and the expiry sweep both already handle.
|
|
222
|
+
//
|
|
223
|
+
// The chain fields are what make a multi-round negotiation readable at all:
|
|
224
|
+
// `previousOfferId` used to exist ONLY inside the serverLogger call below
|
|
225
|
+
// and never reached Firestore, so three rounds were three unrelated
|
|
226
|
+
// documents. `chainRootOfferId` is denormalised onto every round so a list
|
|
227
|
+
// row can render "Round 2" with zero extra reads, and `findChain` stays a
|
|
228
|
+
// single-field equality served by the automatic index.
|
|
229
|
+
const chainRoot = offer.chainRootOfferId ?? offer.id;
|
|
230
|
+
const nextRound = (offer.counterRound ?? 1) + 1;
|
|
214
231
|
const newOffer = await offerRepository.create({
|
|
215
232
|
productId: offer.productId,
|
|
216
233
|
productTitle: offer.productTitle,
|
|
@@ -225,7 +242,18 @@ export async function counterOfferByBuyer(userId, userEmail, input) {
|
|
|
225
242
|
listedPrice: offer.listedPrice,
|
|
226
243
|
currency: offer.currency,
|
|
227
244
|
buyerNote,
|
|
245
|
+
// NOT carrying `sellerNote` forward: it would render as "Seller note" on
|
|
246
|
+
// the new round, making the seller look like they had already answered a
|
|
247
|
+
// counter they have never seen. Round N-1 keeps its own note and is now
|
|
248
|
+
// reachable via `previousOfferId`.
|
|
249
|
+
previousOfferId: offer.id,
|
|
250
|
+
chainRootOfferId: chainRoot,
|
|
251
|
+
counterRound: nextRound,
|
|
228
252
|
});
|
|
253
|
+
// Now supersede the old round — one write, one history entry. A `withdrawn`
|
|
254
|
+
// document carrying `supersededByOfferId` renders as *Superseded* (neutral),
|
|
255
|
+
// not *Withdrawn* (negative): the buyer did not walk away, they countered.
|
|
256
|
+
await offerRepository.withdraw(offerId, newOffer.id, { actor: { uid: userId, role: "buyer" } }, offer);
|
|
229
257
|
const buyerCounterStore = offer.storeId ? await storeRepository.findById(offer.storeId) : null;
|
|
230
258
|
if (buyerCounterStore?.ownerId)
|
|
231
259
|
await sendNotification({
|
|
@@ -237,12 +265,14 @@ export async function counterOfferByBuyer(userId, userEmail, input) {
|
|
|
237
265
|
relatedId: newOffer.id,
|
|
238
266
|
relatedType: "offer",
|
|
239
267
|
});
|
|
268
|
+
// `previousOfferId` is no longer logged as its only record — it is a
|
|
269
|
+
// persisted field on `newOffer` now. A log line is not a data model.
|
|
240
270
|
serverLogger.info("counterOfferByBuyer", {
|
|
241
|
-
previousOfferId: offerId,
|
|
242
271
|
newOfferId: newOffer.id,
|
|
243
272
|
buyerUid: userId,
|
|
244
273
|
productId: offer.productId,
|
|
245
274
|
counterAmount,
|
|
275
|
+
counterRound: nextRound,
|
|
246
276
|
});
|
|
247
277
|
return newOffer;
|
|
248
278
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OfferPhaseTimeline — the offer-shaped wrapper around `<StatusTimeline>`.
|
|
3
|
+
*
|
|
4
|
+
* An offer is an order with more phases in front of it, so it shares the rail
|
|
5
|
+
* and supplies only its own vocabulary:
|
|
6
|
+
*
|
|
7
|
+
* made → countered → accepted → paid
|
|
8
|
+
* with declined / withdrawn / expired as the terminal-negative branch
|
|
9
|
+
*
|
|
10
|
+
* ## `superseded` is a RENDER KEY, never an OfferStatus
|
|
11
|
+
*
|
|
12
|
+
* A document that is `withdrawn` **and** carries `supersededByOfferId` is not
|
|
13
|
+
* a walk-away — the buyer countered, and the negotiation continued into the
|
|
14
|
+
* next round. It renders neutral ("Superseded"), not negative ("Withdrawn").
|
|
15
|
+
*
|
|
16
|
+
* Deliberately NOT an eighth `OfferStatus` value: `ADMIN_OFFER_STATUS_TABS`
|
|
17
|
+
* would then gain a chip that matches zero stored rows, which is exactly the
|
|
18
|
+
* defect `audit-filter-tab-enums` exists to catch.
|
|
19
|
+
*
|
|
20
|
+
* ## Back-compat
|
|
21
|
+
*
|
|
22
|
+
* Offers written before `statusHistory` existed have none. Branch B derives
|
|
23
|
+
* what it can from the scalars — and `expired` gets **no date at all**,
|
|
24
|
+
* because `expireMany` never wrote one. `updatedAt` is "last write of any
|
|
25
|
+
* kind" and `expiresAt` is a deadline, not an event; using either would be a
|
|
26
|
+
* fabricated timestamp.
|
|
27
|
+
*/
|
|
28
|
+
import { type TimelineEntry } from "../../status-history/components/StatusTimeline";
|
|
29
|
+
/** One round of a negotiation, as returned by the API. */
|
|
30
|
+
export interface OfferChainRound {
|
|
31
|
+
id: string;
|
|
32
|
+
counterRound: number;
|
|
33
|
+
status: string;
|
|
34
|
+
offerAmount: number;
|
|
35
|
+
counterAmount?: number;
|
|
36
|
+
superseded: boolean;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
respondedAt?: string;
|
|
39
|
+
sellerNote?: string;
|
|
40
|
+
buyerNote?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface OfferPhaseTimelineProps {
|
|
43
|
+
status: string;
|
|
44
|
+
timeline?: TimelineEntry[];
|
|
45
|
+
timelineTruncated?: number;
|
|
46
|
+
superseded?: boolean;
|
|
47
|
+
createdAt?: string;
|
|
48
|
+
respondedAt?: string;
|
|
49
|
+
acceptedAt?: string;
|
|
50
|
+
paidAt?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Earlier rounds, oldest first. Concatenated ahead of this offer's own
|
|
53
|
+
* steps into ONE rail, each introduced by a "Round N" divider — a
|
|
54
|
+
* negotiation reads as one story, not N disconnected cards.
|
|
55
|
+
*/
|
|
56
|
+
chain?: OfferChainRound[];
|
|
57
|
+
className?: string;
|
|
58
|
+
}
|
|
59
|
+
export declare function OfferPhaseTimeline({ status, timeline, timelineTruncated, superseded, createdAt, respondedAt, acceptedAt, paidAt, chain, className, }: OfferPhaseTimelineProps): import("react").JSX.Element;
|