@mohasinac/appkit 4.11.2 → 4.11.3
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.d.ts +4 -0
- package/dist/_internal/server/features/checkout/actions.js +111 -4
- package/dist/_internal/server/features/checkout/data.d.ts +10 -0
- package/dist/_internal/server/features/orders/adapters.js +7 -0
- package/dist/_internal/server/features/products/data.d.ts +12 -3
- package/dist/_internal/server/features/products/data.js +55 -4
- package/dist/_internal/server/features/products/index.d.ts +1 -1
- package/dist/_internal/server/features/products/index.js +1 -1
- package/dist/_internal/server/features/products/list-public.d.ts +19 -0
- package/dist/_internal/server/features/products/list-public.js +39 -6
- package/dist/_internal/server/features/reviews/data.d.ts +1 -1
- package/dist/_internal/server/features/reviews/data.js +3 -2
- package/dist/_internal/server/features/tester/visibility.js +3 -3
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +4 -1
- package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +2 -0
- package/dist/_internal/server/jobs/handlers/_helpers.js +4 -1
- package/dist/_internal/shared/features/promotions/schema.d.ts +0 -16
- package/dist/_internal/shared/features/promotions/schema.js +0 -2
- package/dist/_internal/shared/features/reviews/config.d.ts +8 -1
- package/dist/_internal/shared/features/reviews/config.js +8 -1
- package/dist/_internal/shared/listing-types/_registry.d.ts +88 -1
- package/dist/_internal/shared/listing-types/_registry.js +63 -1
- package/dist/_internal/shared/listing-types/art/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/art/config.js +12 -0
- package/dist/_internal/shared/listing-types/auction/config.d.ts +53 -0
- package/dist/_internal/shared/listing-types/auction/config.js +17 -0
- package/dist/_internal/shared/listing-types/classified/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/classified/config.js +14 -0
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/digital-code/config.js +12 -0
- package/dist/_internal/shared/listing-types/live/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/live/config.js +14 -0
- package/dist/_internal/shared/listing-types/pre-order/config.d.ts +110 -0
- package/dist/_internal/shared/listing-types/pre-order/config.js +14 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +44 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.js +10 -0
- package/dist/_internal/shared/listing-types/standard/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/standard/config.js +9 -0
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/stickers/config.js +12 -0
- package/dist/client.d.ts +5 -1
- package/dist/client.js +4 -1
- package/dist/constants/field-names.d.ts +12 -0
- package/dist/constants/field-names.js +12 -0
- package/dist/constants/table-keys.d.ts +2 -0
- package/dist/constants/table-keys.js +2 -0
- package/dist/features/account/components/UserOrdersView.js +27 -5
- package/dist/features/admin/components/AdminAddressesView.js +3 -1
- package/dist/features/admin/components/AdminCouponEditorView.js +1 -4
- package/dist/features/admin/components/AdminPaymentMethodsView.js +3 -1
- package/dist/features/admin/components/AdminProductsView.js +34 -18
- package/dist/features/admin/constants/filter-tabs.d.ts +26 -9
- package/dist/features/admin/constants/filter-tabs.js +22 -9
- package/dist/features/auctions/components/AuctionBidsTable.js +1 -1
- package/dist/features/auctions/components/AuctionBottomActions.js +1 -0
- package/dist/features/auctions/components/AuctionDetailPageView.js +13 -11
- package/dist/features/cart/schemas/firestore.d.ts +0 -2
- package/dist/features/categories/components/BrandDetailTabs.js +31 -11
- package/dist/features/categories/components/CategoryDetailTabs.js +27 -7
- package/dist/features/categories/components/CategoryProductsListing.d.ts +9 -1
- package/dist/features/categories/components/CategoryProductsListing.js +5 -2
- package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -1
- package/dist/features/contact/email.js +5 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +17 -16
- package/dist/features/homepage/components/FeaturedResultsSection.js +1 -1
- package/dist/features/homepage/components/SectionCarousel.js +1 -1
- package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
- package/dist/features/layout/AppLayoutShell.js +9 -2
- package/dist/features/layout/BottomActions.js +26 -3
- package/dist/features/layout/BottomActionsContext.d.ts +14 -0
- package/dist/features/layout/BottomActionsContext.js +13 -1
- package/dist/features/layout/hooks/useBottomActions.d.ts +6 -1
- package/dist/features/layout/hooks/useBottomActions.js +4 -1
- package/dist/features/layout/index.d.ts +1 -1
- package/dist/features/live/components/LiveItemDetailPageView.js +7 -6
- package/dist/features/orders/repository/orders.repository.d.ts +4 -0
- package/dist/features/orders/repository/orders.repository.js +4 -0
- package/dist/features/orders/types/index.d.ts +16 -0
- package/dist/features/pre-orders/components/PreOrderBottomActions.d.ts +1 -1
- package/dist/features/pre-orders/components/PreOrderBottomActions.js +3 -19
- package/dist/features/products/components/AuctionsIndexListing.js +1 -2
- package/dist/features/products/components/ListingBottomActions.d.ts +28 -0
- package/dist/features/products/components/ListingBottomActions.js +25 -0
- package/dist/features/products/components/PrizeDrawBottomActions.d.ts +1 -1
- package/dist/features/products/components/PrizeDrawBottomActions.js +3 -21
- package/dist/features/products/components/ProductDetailActions.js +4 -0
- package/dist/features/products/components/ProductDetailPageView.js +7 -7
- package/dist/features/products/constants/listing-tabs.d.ts +43 -198
- package/dist/features/products/constants/listing-tabs.js +71 -58
- package/dist/features/products/constants/sieve.d.ts +104 -1
- package/dist/features/products/constants/sieve.js +27 -7
- package/dist/features/products/repository/products.repository.d.ts +45 -1
- package/dist/features/products/repository/products.repository.js +26 -3
- package/dist/features/products/utils/listing-badge-variant.d.ts +20 -1
- package/dist/features/products/utils/listing-badge-variant.js +26 -1
- package/dist/features/products/utils/listing-type.d.ts +24 -0
- package/dist/features/products/utils/listing-type.js +48 -0
- package/dist/features/promotions/actions/coupon-actions.d.ts +7 -0
- package/dist/features/promotions/actions/coupon-actions.js +12 -1
- package/dist/features/promotions/actions/coupon-stacking.d.ts +24 -0
- package/dist/features/promotions/actions/coupon-stacking.js +21 -0
- package/dist/features/promotions/actions/index.d.ts +1 -0
- package/dist/features/promotions/actions/index.js +1 -0
- package/dist/features/promotions/actions/seller-coupon-actions.d.ts +0 -1
- package/dist/features/promotions/actions/seller-coupon-actions.js +0 -1
- package/dist/features/promotions/components/CouponHelpDetails.d.ts +20 -0
- package/dist/features/promotions/components/CouponHelpDetails.js +17 -0
- package/dist/features/promotions/components/CouponsIndexListing.js +2 -1
- package/dist/features/promotions/components/index.d.ts +2 -0
- package/dist/features/promotions/components/index.js +1 -0
- package/dist/features/promotions/constants/coupon-help.d.ts +27 -0
- package/dist/features/promotions/constants/coupon-help.js +42 -0
- package/dist/features/promotions/repository/coupons.repository.d.ts +10 -1
- package/dist/features/promotions/repository/coupons.repository.js +22 -8
- package/dist/features/promotions/schemas/firestore.d.ts +0 -1
- package/dist/features/promotions/schemas/firestore.js +1 -1
- package/dist/features/promotions/schemas/index.d.ts +0 -20
- package/dist/features/promotions/schemas/index.js +0 -1
- package/dist/features/reviews/actions/review-actions.js +5 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +2 -1
- package/dist/features/reviews/components/ReviewFilters.d.ts +7 -1
- package/dist/features/reviews/components/ReviewFilters.js +4 -3
- package/dist/features/reviews/components/ReviewsIndexListing.d.ts +6 -0
- package/dist/features/reviews/components/ReviewsIndexListing.js +9 -109
- package/dist/features/reviews/components/ReviewsListingPanel.d.ts +41 -0
- package/dist/features/reviews/components/ReviewsListingPanel.js +226 -0
- package/dist/features/reviews/components/index.d.ts +2 -0
- package/dist/features/reviews/components/index.js +1 -0
- package/dist/features/reviews/hooks/useReviews.d.ts +8 -0
- package/dist/features/reviews/hooks/useReviews.js +14 -6
- package/dist/features/reviews/repository/reviews.repository.d.ts +29 -1
- package/dist/features/reviews/repository/reviews.repository.js +77 -41
- package/dist/features/reviews/types/index.d.ts +8 -1
- package/dist/features/seller/components/SellerProductsCards.js +2 -2
- package/dist/features/seller/components/SellerProductsView.js +21 -18
- package/dist/features/stores/actions/store-query-actions.js +1 -1
- package/dist/features/stores/components/StoreAuctionsListing.js +5 -11
- package/dist/features/stores/components/StorePreOrdersListing.js +6 -11
- package/dist/features/stores/components/StoreReviewsListing.d.ts +4 -0
- package/dist/features/stores/components/StoreReviewsListing.js +7 -108
- package/dist/features/stores/schemas/index.d.ts +2 -2
- package/dist/features/tester/seed-data/coupons-tester-seed-data.js +3 -3
- package/dist/features/tester/seed-data/index.d.ts +1 -0
- package/dist/features/tester/seed-data/index.js +1 -0
- package/dist/features/tester/seed-data/offers-tester-seed-data.d.ts +2 -0
- package/dist/features/tester/seed-data/offers-tester-seed-data.js +61 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +337 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.js +11 -2
- package/dist/react/hooks/useListingTypeFlags.d.ts +12 -19
- package/dist/react/hooks/useListingTypeFlags.js +4 -9
- package/dist/schemas/registry.d.ts +0 -12
- package/dist/seed/claimed-coupons-seed-data.js +3 -3
- package/dist/seed/coupons-seed-data.js +29 -10
- package/dist/seed/faq-seed-data.js +2 -2
- package/dist/seed/manifest.js +2 -2
- package/dist/styles.css +44 -39
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/FilterChipGroup.d.ts +21 -3
- package/dist/ui/components/FilterChipGroup.js +33 -4
- package/dist/ui/components/HorizontalScroller.d.ts +1 -2
- package/dist/ui/components/HorizontalScroller.js +9 -5
- package/dist/ui/components/HorizontalScroller.style.css +43 -38
- package/package.json +1 -1
|
@@ -109,6 +109,10 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
109
109
|
canFilter: boolean;
|
|
110
110
|
canSort: boolean;
|
|
111
111
|
};
|
|
112
|
+
isOnSale: {
|
|
113
|
+
canFilter: boolean;
|
|
114
|
+
canSort: boolean;
|
|
115
|
+
};
|
|
112
116
|
price: {
|
|
113
117
|
canFilter: boolean;
|
|
114
118
|
canSort: boolean;
|
|
@@ -117,6 +121,10 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
117
121
|
canFilter: boolean;
|
|
118
122
|
canSort: boolean;
|
|
119
123
|
};
|
|
124
|
+
availableQuantity: {
|
|
125
|
+
canFilter: boolean;
|
|
126
|
+
canSort: boolean;
|
|
127
|
+
};
|
|
120
128
|
viewCount: {
|
|
121
129
|
canFilter: boolean;
|
|
122
130
|
canSort: boolean;
|
|
@@ -129,6 +137,10 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
129
137
|
canFilter: boolean;
|
|
130
138
|
canSort: boolean;
|
|
131
139
|
};
|
|
140
|
+
bidsHaveStarted: {
|
|
141
|
+
canFilter: boolean;
|
|
142
|
+
canSort: boolean;
|
|
143
|
+
};
|
|
132
144
|
isSold: {
|
|
133
145
|
canFilter: boolean;
|
|
134
146
|
canSort: boolean;
|
|
@@ -137,6 +149,18 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
137
149
|
canFilter: boolean;
|
|
138
150
|
canSort: boolean;
|
|
139
151
|
};
|
|
152
|
+
isTestData: {
|
|
153
|
+
canFilter: boolean;
|
|
154
|
+
canSort: boolean;
|
|
155
|
+
};
|
|
156
|
+
brandSlug: {
|
|
157
|
+
canFilter: boolean;
|
|
158
|
+
canSort: boolean;
|
|
159
|
+
};
|
|
160
|
+
sublistingCategoryId: {
|
|
161
|
+
canFilter: boolean;
|
|
162
|
+
canSort: boolean;
|
|
163
|
+
};
|
|
140
164
|
createdAt: {
|
|
141
165
|
canFilter: boolean;
|
|
142
166
|
canSort: boolean;
|
|
@@ -166,6 +190,22 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
166
190
|
canFilter: boolean;
|
|
167
191
|
canSort: boolean;
|
|
168
192
|
};
|
|
193
|
+
prizeDrawMode: {
|
|
194
|
+
canFilter: boolean;
|
|
195
|
+
canSort: boolean;
|
|
196
|
+
};
|
|
197
|
+
pricePerEntry: {
|
|
198
|
+
canFilter: boolean;
|
|
199
|
+
canSort: boolean;
|
|
200
|
+
};
|
|
201
|
+
prizeCurrentEntries: {
|
|
202
|
+
canFilter: boolean;
|
|
203
|
+
canSort: boolean;
|
|
204
|
+
};
|
|
205
|
+
prizeMaxEntries: {
|
|
206
|
+
canFilter: boolean;
|
|
207
|
+
canSort: boolean;
|
|
208
|
+
};
|
|
169
209
|
startingBid: {
|
|
170
210
|
canFilter: boolean;
|
|
171
211
|
canSort: boolean;
|
|
@@ -215,6 +255,10 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
215
255
|
canFilter: boolean;
|
|
216
256
|
canSort: boolean;
|
|
217
257
|
};
|
|
258
|
+
preOrderClosed: {
|
|
259
|
+
canFilter: boolean;
|
|
260
|
+
canSort: boolean;
|
|
261
|
+
};
|
|
218
262
|
tags: {
|
|
219
263
|
canFilter: boolean;
|
|
220
264
|
canSort: boolean;
|
|
@@ -231,7 +275,7 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
231
275
|
canFilter: boolean;
|
|
232
276
|
canSort: boolean;
|
|
233
277
|
};
|
|
234
|
-
|
|
278
|
+
shippingPaidBy: {
|
|
235
279
|
canFilter: boolean;
|
|
236
280
|
canSort: boolean;
|
|
237
281
|
};
|
|
@@ -493,22 +493,38 @@ ProductRepository.SIEVE_FIELDS = {
|
|
|
493
493
|
status: { canFilter: true, canSort: true },
|
|
494
494
|
storeId: { canFilter: true, canSort: false },
|
|
495
495
|
storeName: { canFilter: true, canSort: true },
|
|
496
|
-
|
|
496
|
+
// canSort:true — STANDARD_SORT_OPTIONS offers "Featured First" and
|
|
497
|
+
// "Promoted First". They were `canSort:false` until 2026-08-21, so sievejs
|
|
498
|
+
// silently dropped the sort and both dropdown options did nothing on the
|
|
499
|
+
// admin and seller product lists.
|
|
500
|
+
featured: { canFilter: true, canSort: true },
|
|
497
501
|
listingType: { canFilter: true, canSort: false },
|
|
498
|
-
isPromoted: { canFilter: true, canSort:
|
|
502
|
+
isPromoted: { canFilter: true, canSort: true },
|
|
503
|
+
isOnSale: { canFilter: true, canSort: false },
|
|
499
504
|
price: { canFilter: true, canSort: true },
|
|
500
505
|
stockQuantity: { canFilter: true, canSort: true },
|
|
506
|
+
availableQuantity: { canFilter: true, canSort: true },
|
|
501
507
|
viewCount: { canFilter: true, canSort: true },
|
|
502
508
|
currentBid: { canFilter: true, canSort: true },
|
|
503
509
|
bidCount: { canFilter: true, canSort: true },
|
|
510
|
+
bidsHaveStarted: { canFilter: true, canSort: false },
|
|
504
511
|
isSold: { canFilter: true, canSort: true },
|
|
505
512
|
isPartOfBundle: { canFilter: true, canSort: false },
|
|
513
|
+
isTestData: { canFilter: true, canSort: false },
|
|
514
|
+
brandSlug: { canFilter: true, canSort: false },
|
|
515
|
+
sublistingCategoryId: { canFilter: true, canSort: false },
|
|
506
516
|
createdAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
507
517
|
updatedAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
508
518
|
auctionEndDate: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
509
519
|
prizeRevealWindowStart: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
510
520
|
prizeRevealWindowEnd: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
511
521
|
prizeRevealStatus: { canFilter: true, canSort: false },
|
|
522
|
+
prizeDrawMode: { canFilter: true, canSort: false },
|
|
523
|
+
// Prize-draw entry price. PrizeDrawsListingView emits min/max filters on
|
|
524
|
+
// this field; without an entry here sievejs dropped both clauses silently.
|
|
525
|
+
pricePerEntry: { canFilter: true, canSort: true },
|
|
526
|
+
prizeCurrentEntries: { canFilter: true, canSort: true },
|
|
527
|
+
prizeMaxEntries: { canFilter: true, canSort: true },
|
|
512
528
|
startingBid: { canFilter: true, canSort: true },
|
|
513
529
|
buyNowPrice: { canFilter: true, canSort: true },
|
|
514
530
|
minBidIncrement: { canFilter: true, canSort: false },
|
|
@@ -521,11 +537,18 @@ ProductRepository.SIEVE_FIELDS = {
|
|
|
521
537
|
preOrderCurrentCount: { canFilter: true, canSort: true },
|
|
522
538
|
preOrderProductionStatus: { canFilter: true, canSort: false },
|
|
523
539
|
preOrderCancellable: { canFilter: true, canSort: false },
|
|
540
|
+
preOrderClosed: { canFilter: true, canSort: false },
|
|
524
541
|
tags: { canFilter: true, canSort: false },
|
|
525
542
|
features: { canFilter: true, canSort: false },
|
|
526
543
|
insurance: { canFilter: true, canSort: false },
|
|
527
544
|
currency: { canFilter: true, canSort: false },
|
|
528
|
-
freeShipping
|
|
545
|
+
// `freeShipping` used to be listed here and is NOT a ProductDocument field
|
|
546
|
+
// at all — free shipping is derived from `shippingPaidBy === "seller"`.
|
|
547
|
+
// The public "Free shipping" toggle emitted `shippingPaidBy==seller`
|
|
548
|
+
// (buildFirestoreSafeFilters), which sievejs then dropped because the real
|
|
549
|
+
// field had no entry: `throwExceptions:false` means an unknown field is
|
|
550
|
+
// silently skipped. The toggle did nothing at all until 2026-08-21.
|
|
551
|
+
shippingPaidBy: { canFilter: true, canSort: false },
|
|
529
552
|
searchTokens: { canFilter: true, canSort: false },
|
|
530
553
|
"classified.meetupArea": { canFilter: true, canSort: false },
|
|
531
554
|
"classified.acceptsShipping": { canFilter: true, canSort: false },
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ListingType } from "../types/index";
|
|
2
|
+
export type ListingBadgeVariant = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
|
|
1
3
|
/**
|
|
2
4
|
* Listing-kind → Badge variant map.
|
|
3
5
|
*
|
|
@@ -6,5 +8,22 @@
|
|
|
6
8
|
* deprecated `KIND_BADGE_VARIANT` export that previously lived in
|
|
7
9
|
* `seller-products-styles.ts` (deleted 2026-06-17 in Phase 3 of the
|
|
8
10
|
* Three-Layer Style System refactor).
|
|
11
|
+
*
|
|
12
|
+
* Typed `Record<ListingType, …>` (2026-08-21) so a new listing type is a
|
|
13
|
+
* COMPILE error here rather than a silent fallthrough. It previously covered
|
|
14
|
+
* only auction / pre-order / prize-draw / standard, so the other five types
|
|
15
|
+
* fell through to the caller's `?? "default"` and all rendered identically.
|
|
16
|
+
*/
|
|
17
|
+
export declare const LISTING_BADGE_VARIANT: Record<ListingType, ListingBadgeVariant>;
|
|
18
|
+
/**
|
|
19
|
+
* Badge variant for a listing kind that may not be a real `ListingType` —
|
|
20
|
+
* a raw API string, or a UI-only sentinel like the seller dropdown's `"all"`.
|
|
21
|
+
*
|
|
22
|
+
* Callers used to index `LISTING_BADGE_VARIANT[kind] ?? "default"` directly
|
|
23
|
+
* with a plain `string`, which is what let the map stay incomplete for years
|
|
24
|
+
* without anyone noticing: the `??` swallowed every missing type. Going
|
|
25
|
+
* through this accessor keeps the map itself exhaustively typed (so a new
|
|
26
|
+
* listing type is a compile error there) while still tolerating a
|
|
27
|
+
* non-ListingType input here, where tolerance is genuinely needed.
|
|
9
28
|
*/
|
|
10
|
-
export declare
|
|
29
|
+
export declare function listingBadgeVariant(kind: string | undefined): ListingBadgeVariant;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isListingType } from "./listing-type";
|
|
1
2
|
/**
|
|
2
3
|
* Listing-kind → Badge variant map.
|
|
3
4
|
*
|
|
@@ -6,10 +7,34 @@
|
|
|
6
7
|
* deprecated `KIND_BADGE_VARIANT` export that previously lived in
|
|
7
8
|
* `seller-products-styles.ts` (deleted 2026-06-17 in Phase 3 of the
|
|
8
9
|
* Three-Layer Style System refactor).
|
|
10
|
+
*
|
|
11
|
+
* Typed `Record<ListingType, …>` (2026-08-21) so a new listing type is a
|
|
12
|
+
* COMPILE error here rather than a silent fallthrough. It previously covered
|
|
13
|
+
* only auction / pre-order / prize-draw / standard, so the other five types
|
|
14
|
+
* fell through to the caller's `?? "default"` and all rendered identically.
|
|
9
15
|
*/
|
|
10
16
|
export const LISTING_BADGE_VARIANT = {
|
|
17
|
+
standard: "default",
|
|
11
18
|
auction: "warning",
|
|
12
19
|
"pre-order": "secondary",
|
|
13
20
|
"prize-draw": "primary",
|
|
14
|
-
|
|
21
|
+
classified: "secondary",
|
|
22
|
+
"digital-code": "success",
|
|
23
|
+
live: "danger",
|
|
24
|
+
art: "primary",
|
|
25
|
+
stickers: "primary",
|
|
15
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Badge variant for a listing kind that may not be a real `ListingType` —
|
|
29
|
+
* a raw API string, or a UI-only sentinel like the seller dropdown's `"all"`.
|
|
30
|
+
*
|
|
31
|
+
* Callers used to index `LISTING_BADGE_VARIANT[kind] ?? "default"` directly
|
|
32
|
+
* with a plain `string`, which is what let the map stay incomplete for years
|
|
33
|
+
* without anyone noticing: the `??` swallowed every missing type. Going
|
|
34
|
+
* through this accessor keeps the map itself exhaustively typed (so a new
|
|
35
|
+
* listing type is a compile error there) while still tolerating a
|
|
36
|
+
* non-ListingType input here, where tolerance is genuinely needed.
|
|
37
|
+
*/
|
|
38
|
+
export function listingBadgeVariant(kind) {
|
|
39
|
+
return kind && isListingType(kind) ? LISTING_BADGE_VARIANT[kind] : "default";
|
|
40
|
+
}
|
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
import type { ListingType } from "../types/index";
|
|
2
|
+
/** True when `value` is a member of the canonical `ListingType` union. */
|
|
3
|
+
export declare function isListingType(value: string): value is ListingType;
|
|
4
|
+
/**
|
|
5
|
+
* Parse a multi-select listing-type URL value into canonical types.
|
|
6
|
+
*
|
|
7
|
+
* The type filter is a checkbox group, so its URL value is a pipe-joined set
|
|
8
|
+
* (`auction|pre-order`) — the same shape the Sieve adapter upgrades to a
|
|
9
|
+
* Firestore `in` query. Unknown tokens are DROPPED rather than passed through:
|
|
10
|
+
* an unrecognised value reaching `where("listingType","==",x)` matches zero
|
|
11
|
+
* documents forever with no error (Root Cause #33), so silently ignoring it and
|
|
12
|
+
* showing the wider result set is the safer failure mode.
|
|
13
|
+
*
|
|
14
|
+
* An empty result means "no type filter" — i.e. every type — which is exactly
|
|
15
|
+
* what an empty checkbox group should mean.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseSelectedListingTypes(raw: string | undefined | null): ListingType[];
|
|
18
|
+
/**
|
|
19
|
+
* Toggle one type in a pipe-joined selection, returning the new URL value
|
|
20
|
+
* (`""` when nothing is left selected). Keeps canonical iteration order so the
|
|
21
|
+
* URL is stable regardless of the order the user ticked the boxes — otherwise
|
|
22
|
+
* `auction|art` and `art|auction` would be two different React Query keys for
|
|
23
|
+
* the same result set.
|
|
24
|
+
*/
|
|
25
|
+
export declare function toggleListingTypeSelection(raw: string | undefined | null, type: ListingType): string;
|
|
2
26
|
/**
|
|
3
27
|
* Canonical accessor for the listing-kind discriminator. `listingType` is the
|
|
4
28
|
* single source of truth on every `ProductDocument` / `ProductItem` — the
|
|
@@ -1,3 +1,51 @@
|
|
|
1
|
+
import { ALL_LISTING_TYPES } from "../../../_internal/shared/listing-types/feature-flags";
|
|
2
|
+
const LISTING_TYPE_SET = new Set(ALL_LISTING_TYPES);
|
|
3
|
+
/** True when `value` is a member of the canonical `ListingType` union. */
|
|
4
|
+
export function isListingType(value) {
|
|
5
|
+
return LISTING_TYPE_SET.has(value);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse a multi-select listing-type URL value into canonical types.
|
|
9
|
+
*
|
|
10
|
+
* The type filter is a checkbox group, so its URL value is a pipe-joined set
|
|
11
|
+
* (`auction|pre-order`) — the same shape the Sieve adapter upgrades to a
|
|
12
|
+
* Firestore `in` query. Unknown tokens are DROPPED rather than passed through:
|
|
13
|
+
* an unrecognised value reaching `where("listingType","==",x)` matches zero
|
|
14
|
+
* documents forever with no error (Root Cause #33), so silently ignoring it and
|
|
15
|
+
* showing the wider result set is the safer failure mode.
|
|
16
|
+
*
|
|
17
|
+
* An empty result means "no type filter" — i.e. every type — which is exactly
|
|
18
|
+
* what an empty checkbox group should mean.
|
|
19
|
+
*/
|
|
20
|
+
export function parseSelectedListingTypes(raw) {
|
|
21
|
+
if (!raw)
|
|
22
|
+
return [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
for (const token of raw.split("|")) {
|
|
25
|
+
const trimmed = token.trim();
|
|
26
|
+
// "All" is the legacy single-select sentinel — treated as "no filter".
|
|
27
|
+
if (!trimmed || trimmed === "All")
|
|
28
|
+
continue;
|
|
29
|
+
if (isListingType(trimmed))
|
|
30
|
+
seen.add(trimmed);
|
|
31
|
+
}
|
|
32
|
+
return [...seen];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Toggle one type in a pipe-joined selection, returning the new URL value
|
|
36
|
+
* (`""` when nothing is left selected). Keeps canonical iteration order so the
|
|
37
|
+
* URL is stable regardless of the order the user ticked the boxes — otherwise
|
|
38
|
+
* `auction|art` and `art|auction` would be two different React Query keys for
|
|
39
|
+
* the same result set.
|
|
40
|
+
*/
|
|
41
|
+
export function toggleListingTypeSelection(raw, type) {
|
|
42
|
+
const current = new Set(parseSelectedListingTypes(raw));
|
|
43
|
+
if (current.has(type))
|
|
44
|
+
current.delete(type);
|
|
45
|
+
else
|
|
46
|
+
current.add(type);
|
|
47
|
+
return ALL_LISTING_TYPES.filter((t) => current.has(t)).join("|");
|
|
48
|
+
}
|
|
1
49
|
/**
|
|
2
50
|
* Canonical accessor for the listing-kind discriminator. `listingType` is the
|
|
3
51
|
* single source of truth on every `ProductDocument` / `ProductItem` — the
|
|
@@ -26,4 +26,11 @@ export type CouponCartItem = {
|
|
|
26
26
|
listingType: ListingType;
|
|
27
27
|
};
|
|
28
28
|
export declare function validateCoupon(userId: string, code: string, orderTotal: number): Promise<CouponValidationResult>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolves productId → categorySlugs[] in a single batched `getAll` round-trip.
|
|
31
|
+
* Passed into the coupon repository so it can enforce category restrictions
|
|
32
|
+
* without importing the products repository itself. Invoked only when the
|
|
33
|
+
* coupon actually has a category restriction.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveCouponCategorySlugs(productIds: string[]): Promise<Map<string, string[]>>;
|
|
29
36
|
export declare function validateCouponForCart(userId: string, code: string, cartItems: CouponCartItem[]): Promise<CouponCartValidationResult>;
|
|
@@ -5,11 +5,22 @@
|
|
|
5
5
|
* are handled by the calling server action in the consumer.
|
|
6
6
|
*/
|
|
7
7
|
import { couponsRepository } from "../repository/coupons.repository";
|
|
8
|
+
import { productRepository } from "../../products/repository/products.repository";
|
|
8
9
|
export async function validateCoupon(userId, code, orderTotal) {
|
|
9
10
|
return couponsRepository.validateCoupon(code, userId, orderTotal);
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolves productId → categorySlugs[] in a single batched `getAll` round-trip.
|
|
14
|
+
* Passed into the coupon repository so it can enforce category restrictions
|
|
15
|
+
* without importing the products repository itself. Invoked only when the
|
|
16
|
+
* coupon actually has a category restriction.
|
|
17
|
+
*/
|
|
18
|
+
export async function resolveCouponCategorySlugs(productIds) {
|
|
19
|
+
const products = await productRepository.listByIds(productIds);
|
|
20
|
+
return new Map(products.map((p) => [p.id, p.categorySlugs ?? []]));
|
|
21
|
+
}
|
|
11
22
|
export async function validateCouponForCart(userId, code, cartItems) {
|
|
12
|
-
const result = await couponsRepository.validateCouponForCart(code, userId, cartItems);
|
|
23
|
+
const result = await couponsRepository.validateCouponForCart(code, userId, cartItems, { resolveCategorySlugs: resolveCouponCategorySlugs });
|
|
13
24
|
return {
|
|
14
25
|
valid: result.valid,
|
|
15
26
|
discountAmount: result.discountAmount ?? 0,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coupon Stacking Rules
|
|
3
|
+
*
|
|
4
|
+
* A cart may hold at most one seller-scoped coupon per store, plus at most
|
|
5
|
+
* one admin (platform-wide) coupon on top. Both scopes stack with each
|
|
6
|
+
* other freely — the old `combineWithSellerCoupons` opt-out was removed
|
|
7
|
+
* (2026-08-21): stacking is now unconditional as long as the two "buckets"
|
|
8
|
+
* (per-store, and the single platform-wide slot) don't collide.
|
|
9
|
+
*
|
|
10
|
+
* Pure function, no I/O — safe to call from both the cart-apply route and
|
|
11
|
+
* the checkout re-validation pass.
|
|
12
|
+
*/
|
|
13
|
+
import type { CartAppliedCoupon } from "../../cart/schemas/firestore";
|
|
14
|
+
export interface IncomingCoupon {
|
|
15
|
+
code: string;
|
|
16
|
+
scope: "admin" | "seller";
|
|
17
|
+
storeId?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Returns a human-readable conflict message, or null when `incoming` can be
|
|
21
|
+
* added alongside `existing` without violating the one-per-store +
|
|
22
|
+
* one-global rule.
|
|
23
|
+
*/
|
|
24
|
+
export declare function detectCouponConflict(existing: CartAppliedCoupon[], incoming: IncomingCoupon): string | null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a human-readable conflict message, or null when `incoming` can be
|
|
3
|
+
* added alongside `existing` without violating the one-per-store +
|
|
4
|
+
* one-global rule.
|
|
5
|
+
*/
|
|
6
|
+
export function detectCouponConflict(existing, incoming) {
|
|
7
|
+
for (const applied of existing) {
|
|
8
|
+
if (applied.code === incoming.code) {
|
|
9
|
+
return `Coupon ${incoming.code} is already applied.`;
|
|
10
|
+
}
|
|
11
|
+
if (incoming.scope === "seller" &&
|
|
12
|
+
applied.scope === "seller" &&
|
|
13
|
+
applied.storeId === incoming.storeId) {
|
|
14
|
+
return `A coupon for this store is already applied (${applied.code}). Remove it first.`;
|
|
15
|
+
}
|
|
16
|
+
if (incoming.scope === "admin" && applied.scope === "admin") {
|
|
17
|
+
return `Only one platform-wide coupon can be applied at a time (${applied.code}). Remove it first.`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
@@ -32,7 +32,6 @@ export interface SellerCreateCouponInput {
|
|
|
32
32
|
excludeProducts?: string[];
|
|
33
33
|
excludeCategories?: string[];
|
|
34
34
|
firstTimeUserOnly: boolean;
|
|
35
|
-
combineWithSellerCoupons: boolean;
|
|
36
35
|
};
|
|
37
36
|
}
|
|
38
37
|
export type SellerUpdateCouponInput = Partial<Omit<SellerCreateCouponInput, "sellerCode" | "applicableToAuctions">>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface CouponHelpDetailsProps {
|
|
3
|
+
/**
|
|
4
|
+
* Show the note explaining that coupons are re-checked at order placement.
|
|
5
|
+
* Only meaningful where an order is actually about to be placed, so it is
|
|
6
|
+
* off by default and enabled at checkout.
|
|
7
|
+
*/
|
|
8
|
+
showRevalidationNote?: boolean;
|
|
9
|
+
defaultOpen?: boolean;
|
|
10
|
+
/** Collapses sibling <Details> sharing the same name (browser-native). */
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Expandable "How coupons work" explainer. Collapsed by default so it never
|
|
15
|
+
* competes with the coupon input it sits beside.
|
|
16
|
+
*
|
|
17
|
+
* All copy comes from COUPON_HELP so the cart, checkout and public coupons
|
|
18
|
+
* listing render identical text.
|
|
19
|
+
*/
|
|
20
|
+
export declare function CouponHelpDetails({ showRevalidationNote, defaultOpen, name, }: CouponHelpDetailsProps): React.JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Details, Summary, Stack, Text, Ul, Li, Span } from "../../../ui";
|
|
4
|
+
import { COUPON_HELP } from "../constants/coupon-help";
|
|
5
|
+
function HelpSection({ heading, items }) {
|
|
6
|
+
return (_jsxs(Stack, { gap: "dense", children: [_jsx(Text, { size: "sm", weight: "semibold", children: heading }), _jsx(Ul, { marker: "disc", indent: "md", spacing: "tight", size: "sm", children: items.map((item) => (_jsx(Li, { color: "muted", children: item }, item))) })] }));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Expandable "How coupons work" explainer. Collapsed by default so it never
|
|
10
|
+
* competes with the coupon input it sits beside.
|
|
11
|
+
*
|
|
12
|
+
* All copy comes from COUPON_HELP so the cart, checkout and public coupons
|
|
13
|
+
* listing render identical text.
|
|
14
|
+
*/
|
|
15
|
+
export function CouponHelpDetails({ showRevalidationNote = false, defaultOpen = false, name, }) {
|
|
16
|
+
return (_jsxs(Details, { tone: "card", padding: "none", defaultOpen: defaultOpen, name: name, children: [_jsxs(Summary, { paddingX: "x-md", paddingY: "y-md", size: "sm", weight: "semibold", layout: "flex", align: "center", justify: "between", children: [_jsx(Span, { children: COUPON_HELP.title }), _jsx(Span, { size: "xs", color: "muted", children: "Stacking rules" })] }), _jsxs(Stack, { gap: "comfortable", padding: "md", children: [_jsx(HelpSection, { heading: COUPON_HELP.stackingHeading, items: COUPON_HELP.stackingRules }), _jsx(HelpSection, { heading: COUPON_HELP.applicationHeading, items: COUPON_HELP.applicationRules }), _jsx(HelpSection, { heading: COUPON_HELP.failureHeading, items: COUPON_HELP.failureReasons }), showRevalidationNote && (_jsx(Text, { size: "xs", color: "muted", children: COUPON_HELP.revalidationNote }))] })] }));
|
|
17
|
+
}
|
|
@@ -6,6 +6,7 @@ import { useUrlTable } from "../../../react/hooks/useUrlTable";
|
|
|
6
6
|
import { Button, DateInput, IconButton, Input, ListingFilterDrawer, Pagination, RadioGroup, SortDropdown, Div, Grid, Row, Span, Stack, Text, Heading, StickyToolbar } from "../../../ui";
|
|
7
7
|
import { usePromotions } from "../hooks/usePromotions";
|
|
8
8
|
import { CouponCard } from "./CouponCard";
|
|
9
|
+
import { CouponHelpDetails } from "./CouponHelpDetails";
|
|
9
10
|
import { TABLE_KEYS } from "../../../constants/table-keys";
|
|
10
11
|
import { COUPON_FIELDS } from "../../../constants/field-names";
|
|
11
12
|
import { sortBy } from "../../../constants/sort";
|
|
@@ -106,7 +107,7 @@ export function CouponsIndexListing({ initialCoupons, storeId, }) {
|
|
|
106
107
|
const clearFilters = () => {
|
|
107
108
|
table.setMany({ type: "", [TABLE_KEYS.DATE_FROM]: "", [TABLE_KEYS.DATE_TO]: "" });
|
|
108
109
|
};
|
|
109
|
-
return (_jsxs(Div, { className: "min-h-[40vh]", children: [_jsxs(StickyToolbar, { offset: "header", tone: "default", border: true, padding: "md", z: "above-toolbar", dismissible: true, id: "coupons-toolbar", children: [_jsxs(Row, { gap: "xs", className: "max-w-full", children: [_jsxs(Button, { type: "button", variant: hasActiveFilters ? "outline" : "ghost", onClick: openFilters, gap: "sm", rounded: "lg", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: hasActiveFilters ? "border-primary bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 shrink-0" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] shrink-0", children: [_jsx(SlidersHorizontal, { className: "h-4 w-4" }), _jsxs(Span, { className: "hidden sm:inline", children: ["Filters", hasActiveFilters ? " •" : ""] })] }), _jsxs(Row, { surface: "default", className: `flex-1 ${__O.hidden}`, border: "strong", rounded: "lg", children: [_jsx(Input, { bare: true, type: "text", value: searchInput, onChange: (e) => setSearchInput(e.target.value), onKeyDown: handleKeyDown, placeholder: "Search by name or description\u2026", className: "min-w-0 flex-1 bg-transparent px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text)] placeholder-zinc-400 outline-none" }), searchInput && (_jsx(IconButton, { type: "button", onClick: () => { setSearchInput(""); table.set(TABLE_KEYS.QUERY, ""); }, "aria-label": "Clear search", variant: "ghost", size: "sm", children: _jsx(X, { className: "h-3.5 w-3.5" }) })), _jsx(IconButton, { type: "button", onClick: commitSearch, "aria-label": "Search", variant: "ghost", size: "sm", children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs(Row, { color: "muted", textSize: "sm", gap: "xs", className: "shrink-0", children: [_jsx(Span, { className: "hidden md:inline whitespace-nowrap", children: "Sort by" }), _jsx(SortDropdown, { value: table.get(TABLE_KEYS.SORT) || DEFAULT_SORT, onChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, options: COUPON_SORT_OPTIONS })] })] }), hasActiveFilters && (_jsxs(Row, { gap: "xs", wrap: true, className: "mt-2", children: [activeType && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: [COUPON_TYPES.find((t) => t.value === activeType)?.label ?? activeType, _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.TYPE, ""); }, "aria-label": "Remove type filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), table.get(TABLE_KEYS.DATE_FROM) && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: ["From: ", table.get(TABLE_KEYS.DATE_FROM), _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.DATE_FROM, ""); }, "aria-label": "Remove from-date filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), table.get(TABLE_KEYS.DATE_TO) && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: ["To: ", table.get(TABLE_KEYS.DATE_TO), _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.DATE_TO, ""); }, "aria-label": "Remove to-date filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsx(Button, { variant: "ghost", type: "button", onClick: clearFilters, textSize: "xs", textColor: "muted", className: "underline", children: "Clear all" })] }))] }), _jsxs(Div, { paddingY: "y-lg", paddingX: "x-md", children: [isLoading ? (_jsx(Grid, { gap: "sm", className: "md:grid-cols-2 lg:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Stack, { border: "skeleton", gap: "sm", rounded: "xl", padding: "md", className: "animate-pulse", children: [_jsx(Div, { className: "h-6 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" })] }, i))) })) : displayCoupons.length === 0 ? (_jsx(Div, { className: "text-left", padding: "y-4xl", children: _jsx(Text, { color: "faint", children: "No coupons match your search." }) })) : (_jsx(Grid, { gap: "sm", className: "md:grid-cols-2 lg:grid-cols-3", children: displayCoupons.map((coupon) => (_jsx(CouponCard, { coupon: coupon, labels: {
|
|
110
|
+
return (_jsxs(Div, { className: "min-h-[40vh]", children: [_jsxs(StickyToolbar, { offset: "header", tone: "default", border: true, padding: "md", z: "above-toolbar", dismissible: true, id: "coupons-toolbar", children: [_jsxs(Row, { gap: "xs", className: "max-w-full", children: [_jsxs(Button, { type: "button", variant: hasActiveFilters ? "outline" : "ghost", onClick: openFilters, gap: "sm", rounded: "lg", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: hasActiveFilters ? "border-primary bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 shrink-0" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] shrink-0", children: [_jsx(SlidersHorizontal, { className: "h-4 w-4" }), _jsxs(Span, { className: "hidden sm:inline", children: ["Filters", hasActiveFilters ? " •" : ""] })] }), _jsxs(Row, { surface: "default", className: `flex-1 ${__O.hidden}`, border: "strong", rounded: "lg", children: [_jsx(Input, { bare: true, type: "text", value: searchInput, onChange: (e) => setSearchInput(e.target.value), onKeyDown: handleKeyDown, placeholder: "Search by name or description\u2026", className: "min-w-0 flex-1 bg-transparent px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text)] placeholder-zinc-400 outline-none" }), searchInput && (_jsx(IconButton, { type: "button", onClick: () => { setSearchInput(""); table.set(TABLE_KEYS.QUERY, ""); }, "aria-label": "Clear search", variant: "ghost", size: "sm", children: _jsx(X, { className: "h-3.5 w-3.5" }) })), _jsx(IconButton, { type: "button", onClick: commitSearch, "aria-label": "Search", variant: "ghost", size: "sm", children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs(Row, { color: "muted", textSize: "sm", gap: "xs", className: "shrink-0", children: [_jsx(Span, { className: "hidden md:inline whitespace-nowrap", children: "Sort by" }), _jsx(SortDropdown, { value: table.get(TABLE_KEYS.SORT) || DEFAULT_SORT, onChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, options: COUPON_SORT_OPTIONS })] })] }), hasActiveFilters && (_jsxs(Row, { gap: "xs", wrap: true, className: "mt-2", children: [activeType && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: [COUPON_TYPES.find((t) => t.value === activeType)?.label ?? activeType, _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.TYPE, ""); }, "aria-label": "Remove type filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), table.get(TABLE_KEYS.DATE_FROM) && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: ["From: ", table.get(TABLE_KEYS.DATE_FROM), _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.DATE_FROM, ""); }, "aria-label": "Remove from-date filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), table.get(TABLE_KEYS.DATE_TO) && (_jsxs(Span, { layout: "flex", gap: "xs", size: "xs", weight: "medium", className: "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", padding: "pill-sm-tall", children: ["To: ", table.get(TABLE_KEYS.DATE_TO), _jsx(Button, { variant: "ghost", type: "button", onClick: () => { table.set(TABLE_KEYS.DATE_TO, ""); }, "aria-label": "Remove to-date filter", className: CLS_CHIP_BTN, children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsx(Button, { variant: "ghost", type: "button", onClick: clearFilters, textSize: "xs", textColor: "muted", className: "underline", children: "Clear all" })] }))] }), _jsx(Div, { paddingY: "y-md", paddingX: "x-md", children: _jsx(CouponHelpDetails, {}) }), _jsxs(Div, { paddingY: "y-lg", paddingX: "x-md", children: [isLoading ? (_jsx(Grid, { gap: "sm", className: "md:grid-cols-2 lg:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Stack, { border: "skeleton", gap: "sm", rounded: "xl", padding: "md", className: "animate-pulse", children: [_jsx(Div, { className: "h-6 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" })] }, i))) })) : displayCoupons.length === 0 ? (_jsx(Div, { className: "text-left", padding: "y-4xl", children: _jsx(Text, { color: "faint", children: "No coupons match your search." }) })) : (_jsx(Grid, { gap: "sm", className: "md:grid-cols-2 lg:grid-cols-3", children: displayCoupons.map((coupon) => (_jsx(CouponCard, { coupon: coupon, labels: {
|
|
110
111
|
copy: "Copy",
|
|
111
112
|
copied: "Copied!",
|
|
112
113
|
expires: "Expires",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { CouponCard } from "./CouponCard";
|
|
2
|
+
export { CouponHelpDetails } from "./CouponHelpDetails";
|
|
3
|
+
export type { CouponHelpDetailsProps } from "./CouponHelpDetails";
|
|
2
4
|
export { CouponsIndexListing } from "./CouponsIndexListing";
|
|
3
5
|
export type { CouponsIndexListingProps } from "./CouponsIndexListing";
|
|
4
6
|
export { PromotionsView, PromotionsViewProductSection, PromotionsHero, } from "./PromotionsView";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coupon Help Copy
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the buyer-facing explanation of how coupon
|
|
5
|
+
* scoping and stacking work. Rendered by <CouponHelpDetails> on the cart,
|
|
6
|
+
* the checkout coupon box, and the public coupons listing — so the three
|
|
7
|
+
* surfaces can never drift (same reasoning as REFUND_COPY).
|
|
8
|
+
*
|
|
9
|
+
* Keep this in sync with `detectCouponConflict()`
|
|
10
|
+
* (features/promotions/actions/coupon-stacking.ts), which is what actually
|
|
11
|
+
* enforces the stacking rules described here, and with the seeded FAQ
|
|
12
|
+
* `faq-coupons-and-discounts`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const COUPON_HELP: {
|
|
15
|
+
readonly title: "How coupons work";
|
|
16
|
+
/** The stacking rules — the part buyers most often get wrong. */
|
|
17
|
+
readonly stackingHeading: "You can use more than one coupon";
|
|
18
|
+
readonly stackingRules: readonly ["One store coupon per store. If your cart has items from three stores, you can apply each of those stores' coupons at the same time.", "Plus one platform-wide LetItRip coupon, on top of any store coupons.", "A second coupon for the same store, or a second platform-wide coupon, will be declined — remove the first one to swap it."];
|
|
19
|
+
/** How the discount is actually distributed. */
|
|
20
|
+
readonly applicationHeading: "Where each discount lands";
|
|
21
|
+
readonly applicationRules: readonly ["A store coupon only discounts that store's items.", "A platform-wide coupon is spread proportionally across every store in your cart.", "Orders are placed per store, so each store's order shows its own share of the discount."];
|
|
22
|
+
/** Reuses the failure reasons already written for the buyer shopping guide. */
|
|
23
|
+
readonly failureHeading: "Why a coupon might not apply";
|
|
24
|
+
readonly failureReasons: readonly ["The eligible items in your cart are below the coupon's minimum purchase amount — minimum spend is measured against the items the coupon actually covers, not your whole cart.", "The coupon has expired, or hasn't started yet.", "The coupon is limited to certain products or categories, and your cart has none of them.", "The coupon is first-order-only and you've ordered before.", "You've already used the coupon the maximum number of times allowed.", "The coupon ran out — some coupons have a total limit across all buyers."];
|
|
25
|
+
/** Shown at checkout only, where a coupon can be dropped at payment time. */
|
|
26
|
+
readonly revalidationNote: "Coupons are re-checked when you place the order. If one has expired or run out while it sat in your cart, it's removed and your total is recalculated before payment.";
|
|
27
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coupon Help Copy
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the buyer-facing explanation of how coupon
|
|
5
|
+
* scoping and stacking work. Rendered by <CouponHelpDetails> on the cart,
|
|
6
|
+
* the checkout coupon box, and the public coupons listing — so the three
|
|
7
|
+
* surfaces can never drift (same reasoning as REFUND_COPY).
|
|
8
|
+
*
|
|
9
|
+
* Keep this in sync with `detectCouponConflict()`
|
|
10
|
+
* (features/promotions/actions/coupon-stacking.ts), which is what actually
|
|
11
|
+
* enforces the stacking rules described here, and with the seeded FAQ
|
|
12
|
+
* `faq-coupons-and-discounts`.
|
|
13
|
+
*/
|
|
14
|
+
export const COUPON_HELP = {
|
|
15
|
+
title: "How coupons work",
|
|
16
|
+
/** The stacking rules — the part buyers most often get wrong. */
|
|
17
|
+
stackingHeading: "You can use more than one coupon",
|
|
18
|
+
stackingRules: [
|
|
19
|
+
"One store coupon per store. If your cart has items from three stores, you can apply each of those stores' coupons at the same time.",
|
|
20
|
+
"Plus one platform-wide LetItRip coupon, on top of any store coupons.",
|
|
21
|
+
"A second coupon for the same store, or a second platform-wide coupon, will be declined — remove the first one to swap it.",
|
|
22
|
+
],
|
|
23
|
+
/** How the discount is actually distributed. */
|
|
24
|
+
applicationHeading: "Where each discount lands",
|
|
25
|
+
applicationRules: [
|
|
26
|
+
"A store coupon only discounts that store's items.",
|
|
27
|
+
"A platform-wide coupon is spread proportionally across every store in your cart.",
|
|
28
|
+
"Orders are placed per store, so each store's order shows its own share of the discount.",
|
|
29
|
+
],
|
|
30
|
+
/** Reuses the failure reasons already written for the buyer shopping guide. */
|
|
31
|
+
failureHeading: "Why a coupon might not apply",
|
|
32
|
+
failureReasons: [
|
|
33
|
+
"The eligible items in your cart are below the coupon's minimum purchase amount — minimum spend is measured against the items the coupon actually covers, not your whole cart.",
|
|
34
|
+
"The coupon has expired, or hasn't started yet.",
|
|
35
|
+
"The coupon is limited to certain products or categories, and your cart has none of them.",
|
|
36
|
+
"The coupon is first-order-only and you've ordered before.",
|
|
37
|
+
"You've already used the coupon the maximum number of times allowed.",
|
|
38
|
+
"The coupon ran out — some coupons have a total limit across all buyers.",
|
|
39
|
+
],
|
|
40
|
+
/** Shown at checkout only, where a coupon can be dropped at payment time. */
|
|
41
|
+
revalidationNote: "Coupons are re-checked when you place the order. If one has expired or run out while it sat in your cart, it's removed and your total is recalculated before payment.",
|
|
42
|
+
};
|
|
@@ -143,7 +143,16 @@ export declare class CouponsRepository extends BaseRepository<CouponDocument> {
|
|
|
143
143
|
quantity: number;
|
|
144
144
|
/** Canonical listing-kind snapshot (SB1-G Phase 4). */
|
|
145
145
|
listingType: ListingType;
|
|
146
|
-
}
|
|
146
|
+
}>, opts?: {
|
|
147
|
+
/**
|
|
148
|
+
* Lazily resolves productId → categorySlugs[]. Supplied by the action
|
|
149
|
+
* layer (which may import the products repository) so this repository
|
|
150
|
+
* never takes a cross-repository dependency. Called at most once, and
|
|
151
|
+
* only when the coupon actually carries a category restriction — so a
|
|
152
|
+
* coupon without one costs zero extra Firestore reads.
|
|
153
|
+
*/
|
|
154
|
+
resolveCategorySlugs?: (productIds: string[]) => Promise<Map<string, string[]>>;
|
|
155
|
+
}): Promise<{
|
|
147
156
|
valid: boolean;
|
|
148
157
|
coupon?: CouponDocument;
|
|
149
158
|
discountAmount?: number;
|