@mohasinac/appkit 4.42.0 → 4.42.2
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/client/features/layout/DashboardLayoutClient.js +2 -30
- package/dist/_internal/server/features/offers/adapters.d.ts +29 -7
- package/dist/_internal/server/features/offers/adapters.js +14 -3
- package/dist/_internal/server/features/orders/adapters.js +54 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.js +1 -0
- package/dist/features/account/components/UserOrdersView.js +30 -2
- package/dist/features/account/hooks/useAddresses.d.ts +21 -6
- package/dist/features/account/hooks/useAddresses.js +1 -1
- package/dist/features/addresses/hooks/usePostalLookup.js +84 -12
- package/dist/features/admin/components/AdminCategoriesView.js +37 -2
- package/dist/features/admin/components/AdminOffersView.js +2 -2
- package/dist/features/admin/constants/filter-tabs.d.ts +25 -0
- package/dist/features/admin/constants/filter-tabs.js +30 -0
- package/dist/features/admin/hooks/useAdminListingData.d.ts +35 -0
- package/dist/features/admin/hooks/useAdminListingData.js +45 -9
- package/dist/features/cart/schemas/index.d.ts +15 -0
- package/dist/features/cart/schemas/index.js +2 -0
- package/dist/features/cart/types/index.d.ts +22 -0
- package/dist/features/categories/components/CategoriesIndexListing.js +19 -1
- package/dist/features/categories/components/CategoriesIndexPageView.js +14 -1
- package/dist/features/classified/components/ClassifiedDetailPageView.d.ts +8 -0
- package/dist/features/classified/components/ClassifiedDetailPageView.js +1 -0
- package/dist/features/events/actions/event-actions.d.ts +13 -0
- package/dist/features/events/actions/event-actions.js +75 -1
- package/dist/features/events/repository/event-entry.repository.d.ts +17 -0
- package/dist/features/events/repository/event-entry.repository.js +36 -0
- package/dist/features/homepage/components/BrandsSection.js +1 -1
- package/dist/features/homepage/components/CustomerReviewsSection.js +1 -1
- package/dist/features/homepage/components/FeaturedBundlesSection.js +1 -1
- package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
- package/dist/features/layout/AppLayoutShell.js +2 -2
- package/dist/features/layout/BottomNavLayout.d.ts +9 -3
- package/dist/features/layout/BottomNavLayout.js +9 -3
- package/dist/features/media/upload/MediaUploadField.js +21 -3
- package/dist/features/orders/components/OrdersList.js +2 -1
- package/dist/features/orders/index.d.ts +1 -0
- package/dist/features/orders/index.js +1 -0
- package/dist/features/orders/types/index.d.ts +2 -0
- package/dist/features/orders/utils/order-ref.d.ts +45 -0
- package/dist/features/orders/utils/order-ref.js +58 -0
- package/dist/features/products/components/MakeOfferButton.d.ts +18 -1
- package/dist/features/products/components/MakeOfferButton.js +23 -1
- package/dist/features/products/components/ProductDetailPageView.d.ts +5 -0
- package/dist/features/products/components/ProductDetailPageView.js +3 -2
- package/dist/features/products/repository/products.repository.js +29 -2
- package/dist/features/seller/actions/offer-actions.js +32 -0
- package/dist/features/seller/components/QuickProductForm.js +15 -1
- package/dist/features/seller/components/SellerBidsView.js +3 -1
- package/dist/features/seller/components/SellerOffersView.js +2 -2
- package/dist/features/seller/components/SellerOrdersView.js +49 -6
- package/dist/features/seller/components/SellerProductsCards.js +23 -1
- package/dist/features/seller/components/SellerProductsView.js +27 -2
- package/dist/features/seller/hooks/useSellerListingData.d.ts +0 -1
- package/dist/features/seller/hooks/useSellerListingData.js +17 -19
- package/dist/features/shell/SectionForm.js +33 -2
- package/dist/features/tester/seed-data/_happy-path.d.ts +23 -0
- package/dist/features/tester/seed-data/_happy-path.js +710 -0
- package/dist/features/tester/seed-data/_money-flows.js +34 -23
- package/dist/features/tester/seed-data/authored/account-auth__profile-settings.js +22 -3
- package/dist/features/tester/seed-data/authored/addresses__address-filters.js +17 -12
- package/dist/features/tester/seed-data/authored/addresses__postal-lookup.js +7 -6
- package/dist/features/tester/seed-data/authored/addresses__postal-validation.js +10 -4
- package/dist/features/tester/seed-data/authored/addresses__state-picker.js +13 -8
- package/dist/features/tester/seed-data/authored/addresses__unban-request.js +49 -11
- package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +10 -6
- package/dist/features/tester/seed-data/authored/admin__bans-and-trust.js +71 -32
- package/dist/features/tester/seed-data/authored/admin__blog-faqs.js +80 -31
- package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.js +62 -25
- package/dist/features/tester/seed-data/authored/admin__bulk-actions.js +61 -31
- package/dist/features/tester/seed-data/authored/admin__bundles.js +84 -37
- package/dist/features/tester/seed-data/authored/admin__buyer-data-admin.js +90 -57
- package/dist/features/tester/seed-data/authored/admin__catalog-listings.js +38 -26
- package/dist/features/tester/seed-data/authored/admin__category-brand-authoring.js +47 -28
- package/dist/features/tester/seed-data/authored/admin__classifieds-digitalcodes-live.js +118 -31
- package/dist/features/tester/seed-data/authored/admin__content-deletes.js +30 -24
- package/dist/features/tester/seed-data/authored/admin__content-marketing.js +83 -46
- package/dist/features/tester/seed-data/authored/admin__coupons.js +8 -8
- package/dist/features/tester/seed-data/authored/admin__events-raffles-spin.js +9 -9
- package/dist/features/tester/seed-data/authored/admin__firebase-function-effects.js +19 -4
- package/dist/features/tester/seed-data/authored/admin__media-watermark.js +77 -33
- package/dist/features/tester/seed-data/authored/admin__orders-fulfillment.js +5 -5
- package/dist/features/tester/seed-data/authored/admin__prize-draws-lotteries.js +29 -5
- package/dist/features/tester/seed-data/authored/admin__site-system.js +45 -23
- package/dist/features/tester/seed-data/authored/admin__uncovered-admin-pages.js +53 -28
- package/dist/features/tester/seed-data/authored/admin__users-trust.js +20 -20
- package/dist/features/tester/seed-data/authored/buying__bidding.js +19 -2
- package/dist/features/tester/seed-data/authored/buying__browsing-search.js +20 -5
- package/dist/features/tester/seed-data/authored/buying__buying-checkout.js +28 -10
- package/dist/features/tester/seed-data/authored/buying__cart.js +10 -3
- package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +0 -16
- package/dist/features/tester/seed-data/authored/buying__my-orders.js +27 -5
- package/dist/features/tester/seed-data/authored/buying__offers.js +22 -7
- package/dist/features/tester/seed-data/authored/buying__order-detail-actions.js +3 -3
- package/dist/features/tester/seed-data/authored/buying__product-detail.js +35 -4
- package/dist/features/tester/seed-data/authored/buying__return-request.js +19 -6
- package/dist/features/tester/seed-data/authored/buying__reviews-pagination.js +36 -18
- package/dist/features/tester/seed-data/authored/buying__reviews.js +16 -1
- package/dist/features/tester/seed-data/authored/buying__user-dashboard-navigation.js +8 -6
- package/dist/features/tester/seed-data/authored/buying__user-uncovered-pages.js +16 -1
- package/dist/features/tester/seed-data/authored/community-support__public-profile.js +3 -2
- package/dist/features/tester/seed-data/authored/community-support__support-tickets.js +4 -4
- package/dist/features/tester/seed-data/authored/content-discovery__blog.js +19 -2
- package/dist/features/tester/seed-data/authored/content-discovery__category-brand-relations.js +4 -4
- package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +5 -5
- package/dist/features/tester/seed-data/authored/content-discovery__coupons.js +41 -6
- package/dist/features/tester/seed-data/authored/content-discovery__event-detail-subroutes.js +63 -8
- package/dist/features/tester/seed-data/authored/content-discovery__event-participation.js +29 -29
- package/dist/features/tester/seed-data/authored/content-discovery__events.js +2 -2
- package/dist/features/tester/seed-data/authored/content-discovery__faq-help.js +23 -1
- package/dist/features/tester/seed-data/authored/content-discovery__notifications.js +6 -6
- package/dist/features/tester/seed-data/authored/content-discovery__search.js +1 -1
- package/dist/features/tester/seed-data/authored/content-discovery__store-reviews-aggregate.js +1 -1
- package/dist/features/tester/seed-data/authored/cta-layout__checkout-bottom-bar.js +31 -6
- package/dist/features/tester/seed-data/authored/cta-layout__dialog-footers.js +34 -10
- package/dist/features/tester/seed-data/authored/cta-layout__editor-action-bar.js +35 -8
- package/dist/features/tester/seed-data/authored/cta-layout__navbar-ctas.js +31 -9
- package/dist/features/tester/seed-data/authored/cta-layout__product-bottom-bar.js +29 -3
- package/dist/features/tester/seed-data/authored/design-ux__back-to-top-button.js +1 -1
- package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +10 -10
- package/dist/features/tester/seed-data/authored/design-ux__dashboard-layout.js +3 -3
- package/dist/features/tester/seed-data/authored/design-ux__footer-theme.js +2 -2
- package/dist/features/tester/seed-data/authored/design-ux__form-validation-errors.js +2 -2
- package/dist/features/tester/seed-data/authored/design-ux__general-design.js +4 -4
- package/dist/features/tester/seed-data/authored/design-ux__hand-mode-layout.js +4 -4
- package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +8 -8
- package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +2 -2
- package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +2 -2
- package/dist/features/tester/seed-data/authored/page-wiring__data-loss.js +39 -16
- package/dist/features/tester/seed-data/authored/page-wiring__detail-pages.js +43 -8
- package/dist/features/tester/seed-data/authored/page-wiring__drawer-pages.js +40 -14
- package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +31 -14
- package/dist/features/tester/seed-data/authored/public-pages__auth-error-pages.js +7 -6
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +14 -5
- package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +3 -3
- package/dist/features/tester/seed-data/authored/public-pages__help-how-it-works.js +1 -1
- package/dist/features/tester/seed-data/authored/public-pages__help-scams-guides-subpages.js +4 -2
- package/dist/features/tester/seed-data/authored/public-pages__legal-policy-pages.js +3 -3
- package/dist/features/tester/seed-data/authored/public-pages__newly-wired-browse-indexes.js +5 -5
- package/dist/features/tester/seed-data/authored/public-pages__store-tabs.js +5 -5
- package/dist/features/tester/seed-data/authored/public-pages__stores-sellers-directories.js +3 -3
- package/dist/features/tester/seed-data/authored/search-and-nav__employee-permissions.js +45 -11
- package/dist/features/tester/seed-data/authored/search-and-nav__header-search.js +57 -17
- package/dist/features/tester/seed-data/authored/search-and-nav__settings-deep-links.js +36 -5
- package/dist/features/tester/seed-data/authored/search-and-nav__sidebar-search.js +29 -4
- package/dist/features/tester/seed-data/authored/selling__become-seller.js +23 -0
- package/dist/features/tester/seed-data/authored/selling__final-sale-authoring.js +14 -13
- package/dist/features/tester/seed-data/authored/selling__listing-a-product.js +14 -9
- package/dist/features/tester/seed-data/authored/selling__listing-edit-roundtrip.js +34 -7
- package/dist/features/tester/seed-data/authored/selling__listing-lifecycle.js +17 -7
- package/dist/features/tester/seed-data/authored/selling__listing-type-fields-roundtrip.js +9 -9
- package/dist/features/tester/seed-data/authored/selling__media-limits.js +9 -4
- package/dist/features/tester/seed-data/authored/selling__product-upload-details.js +3 -3
- package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.js +3 -3
- package/dist/features/tester/seed-data/authored/selling__sectionised-forms.js +25 -1
- package/dist/features/tester/seed-data/authored/selling__seller-analytics-payouts.js +27 -6
- package/dist/features/tester/seed-data/authored/selling__seller-bids-bundles-filters.js +19 -1
- package/dist/features/tester/seed-data/authored/selling__seller-catalog-org.js +63 -22
- package/dist/features/tester/seed-data/authored/selling__seller-custom-brands.js +9 -5
- package/dist/features/tester/seed-data/authored/selling__seller-guide.js +7 -6
- package/dist/features/tester/seed-data/authored/selling__seller-listing-types.js +3 -2
- package/dist/features/tester/seed-data/authored/selling__seller-marketing-extras.js +18 -18
- package/dist/features/tester/seed-data/authored/selling__seller-ops-comms.js +13 -12
- package/dist/features/tester/seed-data/authored/selling__seller-orders-at-scale.js +19 -3
- package/dist/features/tester/seed-data/authored/selling__seller-orders.js +22 -4
- package/dist/features/tester/seed-data/authored/selling__seller-shipping-payouts-setup.js +20 -8
- package/dist/features/tester/seed-data/authored/selling__store-dashboard-navigation.js +10 -9
- package/dist/features/tester/seed-data/authored/selling__store-uncovered-pages.js +2 -2
- package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +5 -5
- package/dist/features/tester/seed-data/authored/seo__og-images.js +1 -1
- package/dist/features/tester/seed-data/authored/seo__page-metadata.js +7 -7
- package/dist/features/tester/seed-data/authored/seo__sitemap-and-robots.js +3 -3
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +126 -24
- package/dist/features/tester/utils/phases.d.ts +17 -0
- package/dist/features/tester/utils/phases.js +29 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/seo/json-ld.d.ts +23 -0
- package/dist/seo/json-ld.js +3 -2
- package/dist/server.d.ts +1 -0
- package/dist/server.js +2 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/PaginatedSelect.js +0 -0
- package/package.json +1 -1
|
@@ -63,6 +63,41 @@ export function toCurrency(value) {
|
|
|
63
63
|
}
|
|
64
64
|
return formatCurrency(value);
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* A timestamp as human-relative text — **in either direction**.
|
|
68
|
+
*
|
|
69
|
+
* 🛑 **Every FUTURE date used to render as "1m ago".** `deltaMs` is
|
|
70
|
+
* `now - date`, which is negative ahead of now; `deltaMs < hour` then matched,
|
|
71
|
+
* and `Math.max(1, Math.floor(negative))` clamped the result to exactly `1`.
|
|
72
|
+
* So a deadline 48 hours away and one 5 minutes away both read "1m ago", and
|
|
73
|
+
* the clamp is what hid it — without it the string would have been
|
|
74
|
+
* "-2880m ago", which nobody could have mistaken for working.
|
|
75
|
+
*
|
|
76
|
+
* Measured live on `/store/offers` as tyson@beybladearena.in: an offer accepted
|
|
77
|
+
* seconds earlier showed **"Buyer must pay by 1m ago"** against a real
|
|
78
|
+
* `checkoutDeadline` of `now + 48h` (`OFFER_CHECKOUT_WINDOW_MS`). The data was
|
|
79
|
+
* correct and the sentence told the seller — and on the admin view, the admin —
|
|
80
|
+
* that a buyer had already missed a window that had barely opened. On the
|
|
81
|
+
* buyer's own side that reads as "too late to pay for the thing you just
|
|
82
|
+
* negotiated", i.e. an abandoned purchase caused entirely by a formatter.
|
|
83
|
+
*
|
|
84
|
+
* Five call sites render a future-capable field today (offer `expiresAt` and
|
|
85
|
+
* `checkoutDeadline` on both the seller and admin views, and session
|
|
86
|
+
* `expiresAt`), and any of the other 61 becomes one the moment its field can
|
|
87
|
+
* hold a future date — which is why this is fixed here rather than at the call
|
|
88
|
+
* sites.
|
|
89
|
+
*
|
|
90
|
+
* The `Math.max(1, …)` floors stay for the PAST direction: they keep a
|
|
91
|
+
* 40-second-old row reading "1m ago" instead of "0m ago". They are applied to
|
|
92
|
+
* an absolute magnitude now, so they can no longer mask a sign error.
|
|
93
|
+
*
|
|
94
|
+
* NOTE ON ITS HOME: a pure date formatter belongs in `ui/` or
|
|
95
|
+
* `_internal/client/`, not in an admin hook module that 44 files reach into.
|
|
96
|
+
* Moving it means rewriting all 44 imports, so it stays put for now and the
|
|
97
|
+
* seller-side duplicate was deleted instead (see `useSellerListingData.ts`) —
|
|
98
|
+
* that copy had already drifted, returning "just now" for the same future date
|
|
99
|
+
* this one called "1m ago". Two answers to one question is the whole argument.
|
|
100
|
+
*/
|
|
66
101
|
export function toRelativeDate(value) {
|
|
67
102
|
const date = parseDate(value);
|
|
68
103
|
if (!date) {
|
|
@@ -72,17 +107,18 @@ export function toRelativeDate(value) {
|
|
|
72
107
|
const minute = 60000;
|
|
73
108
|
const hour = 60 * minute;
|
|
74
109
|
const day = 24 * hour;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
110
|
+
const future = deltaMs < 0;
|
|
111
|
+
const magnitude = Math.abs(deltaMs);
|
|
112
|
+
// "in 3h" vs "3h ago" — the only difference between the two directions.
|
|
113
|
+
const phrase = (n, unit) => (future ? `in ${n}${unit}` : `${n}${unit} ago`);
|
|
114
|
+
if (magnitude < hour) {
|
|
115
|
+
return phrase(Math.max(1, Math.floor(magnitude / minute)), "m");
|
|
78
116
|
}
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
return `${hours}h ago`;
|
|
117
|
+
if (magnitude < day) {
|
|
118
|
+
return phrase(Math.max(1, Math.floor(magnitude / hour)), "h");
|
|
82
119
|
}
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
return `${days}d ago`;
|
|
120
|
+
if (magnitude < 7 * day) {
|
|
121
|
+
return phrase(Math.max(1, Math.floor(magnitude / day)), "d");
|
|
86
122
|
}
|
|
87
123
|
return date.toLocaleDateString("en-IN", {
|
|
88
124
|
day: "2-digit",
|
|
@@ -5,17 +5,20 @@ export declare const cartItemMetaSchema: z.ZodObject<{
|
|
|
5
5
|
price: z.ZodNumber;
|
|
6
6
|
currency: z.ZodDefault<z.ZodString>;
|
|
7
7
|
slug: z.ZodOptional<z.ZodString>;
|
|
8
|
+
storeName: z.ZodOptional<z.ZodString>;
|
|
8
9
|
attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
9
10
|
}, "strip", z.ZodTypeAny, {
|
|
10
11
|
currency: string;
|
|
11
12
|
title: string;
|
|
12
13
|
price: number;
|
|
14
|
+
storeName?: string | undefined;
|
|
13
15
|
slug?: string | undefined;
|
|
14
16
|
attributes?: Record<string, string> | undefined;
|
|
15
17
|
}, {
|
|
16
18
|
title: string;
|
|
17
19
|
price: number;
|
|
18
20
|
currency?: string | undefined;
|
|
21
|
+
storeName?: string | undefined;
|
|
19
22
|
slug?: string | undefined;
|
|
20
23
|
attributes?: Record<string, string> | undefined;
|
|
21
24
|
}>;
|
|
@@ -40,17 +43,20 @@ export declare const cartItemSchema: z.ZodObject<{
|
|
|
40
43
|
price: z.ZodNumber;
|
|
41
44
|
currency: z.ZodDefault<z.ZodString>;
|
|
42
45
|
slug: z.ZodOptional<z.ZodString>;
|
|
46
|
+
storeName: z.ZodOptional<z.ZodString>;
|
|
43
47
|
attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
44
48
|
}, "strip", z.ZodTypeAny, {
|
|
45
49
|
currency: string;
|
|
46
50
|
title: string;
|
|
47
51
|
price: number;
|
|
52
|
+
storeName?: string | undefined;
|
|
48
53
|
slug?: string | undefined;
|
|
49
54
|
attributes?: Record<string, string> | undefined;
|
|
50
55
|
}, {
|
|
51
56
|
title: string;
|
|
52
57
|
price: number;
|
|
53
58
|
currency?: string | undefined;
|
|
59
|
+
storeName?: string | undefined;
|
|
54
60
|
slug?: string | undefined;
|
|
55
61
|
attributes?: Record<string, string> | undefined;
|
|
56
62
|
}>>;
|
|
@@ -63,6 +69,7 @@ export declare const cartItemSchema: z.ZodObject<{
|
|
|
63
69
|
currency: string;
|
|
64
70
|
title: string;
|
|
65
71
|
price: number;
|
|
72
|
+
storeName?: string | undefined;
|
|
66
73
|
slug?: string | undefined;
|
|
67
74
|
attributes?: Record<string, string> | undefined;
|
|
68
75
|
} | undefined;
|
|
@@ -76,6 +83,7 @@ export declare const cartItemSchema: z.ZodObject<{
|
|
|
76
83
|
title: string;
|
|
77
84
|
price: number;
|
|
78
85
|
currency?: string | undefined;
|
|
86
|
+
storeName?: string | undefined;
|
|
79
87
|
slug?: string | undefined;
|
|
80
88
|
attributes?: Record<string, string> | undefined;
|
|
81
89
|
} | undefined;
|
|
@@ -96,17 +104,20 @@ export declare const cartSummarySchema: z.ZodObject<{
|
|
|
96
104
|
price: z.ZodNumber;
|
|
97
105
|
currency: z.ZodDefault<z.ZodString>;
|
|
98
106
|
slug: z.ZodOptional<z.ZodString>;
|
|
107
|
+
storeName: z.ZodOptional<z.ZodString>;
|
|
99
108
|
attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
100
109
|
}, "strip", z.ZodTypeAny, {
|
|
101
110
|
currency: string;
|
|
102
111
|
title: string;
|
|
103
112
|
price: number;
|
|
113
|
+
storeName?: string | undefined;
|
|
104
114
|
slug?: string | undefined;
|
|
105
115
|
attributes?: Record<string, string> | undefined;
|
|
106
116
|
}, {
|
|
107
117
|
title: string;
|
|
108
118
|
price: number;
|
|
109
119
|
currency?: string | undefined;
|
|
120
|
+
storeName?: string | undefined;
|
|
110
121
|
slug?: string | undefined;
|
|
111
122
|
attributes?: Record<string, string> | undefined;
|
|
112
123
|
}>>;
|
|
@@ -119,6 +130,7 @@ export declare const cartSummarySchema: z.ZodObject<{
|
|
|
119
130
|
currency: string;
|
|
120
131
|
title: string;
|
|
121
132
|
price: number;
|
|
133
|
+
storeName?: string | undefined;
|
|
122
134
|
slug?: string | undefined;
|
|
123
135
|
attributes?: Record<string, string> | undefined;
|
|
124
136
|
} | undefined;
|
|
@@ -132,6 +144,7 @@ export declare const cartSummarySchema: z.ZodObject<{
|
|
|
132
144
|
title: string;
|
|
133
145
|
price: number;
|
|
134
146
|
currency?: string | undefined;
|
|
147
|
+
storeName?: string | undefined;
|
|
135
148
|
slug?: string | undefined;
|
|
136
149
|
attributes?: Record<string, string> | undefined;
|
|
137
150
|
} | undefined;
|
|
@@ -153,6 +166,7 @@ export declare const cartSummarySchema: z.ZodObject<{
|
|
|
153
166
|
currency: string;
|
|
154
167
|
title: string;
|
|
155
168
|
price: number;
|
|
169
|
+
storeName?: string | undefined;
|
|
156
170
|
slug?: string | undefined;
|
|
157
171
|
attributes?: Record<string, string> | undefined;
|
|
158
172
|
} | undefined;
|
|
@@ -170,6 +184,7 @@ export declare const cartSummarySchema: z.ZodObject<{
|
|
|
170
184
|
title: string;
|
|
171
185
|
price: number;
|
|
172
186
|
currency?: string | undefined;
|
|
187
|
+
storeName?: string | undefined;
|
|
173
188
|
slug?: string | undefined;
|
|
174
189
|
attributes?: Record<string, string> | undefined;
|
|
175
190
|
} | undefined;
|
|
@@ -6,6 +6,8 @@ export const cartItemMetaSchema = z.object({
|
|
|
6
6
|
price: z.number(),
|
|
7
7
|
currency: z.string().default(getDefaultCurrency()),
|
|
8
8
|
slug: z.string().optional(),
|
|
9
|
+
/* Named field, not an `attributes` entry — see CartItemMeta.storeName. */
|
|
10
|
+
storeName: z.string().optional(),
|
|
9
11
|
attributes: z.record(z.string(), z.string()).optional(),
|
|
10
12
|
});
|
|
11
13
|
/**
|
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
export interface CartItemMeta {
|
|
2
2
|
productId: string;
|
|
3
3
|
storeId?: string;
|
|
4
|
+
/**
|
|
5
|
+
* The seller's display name, for grouping lines by seller and for the cart's
|
|
6
|
+
* own search box.
|
|
7
|
+
*
|
|
8
|
+
* 🛑 It lives here and NOT in `attributes`, which is rendered verbatim.
|
|
9
|
+
* `CartItemRow` prints every attribute entry as `` `${key}: ${value}` ``, so
|
|
10
|
+
* putting the store name in that bag printed the literal string
|
|
11
|
+
* `storeName: Beyblade Arena` under the product title on `/cart` — a field
|
|
12
|
+
* name shown to a buyer on the highest-intent page in the funnel, one line
|
|
13
|
+
* below the same value already rendered properly as `SOLD BY BEYBLADE ARENA`.
|
|
14
|
+
*/
|
|
15
|
+
storeName?: string;
|
|
4
16
|
title: string;
|
|
5
17
|
image?: string;
|
|
6
18
|
price: number;
|
|
7
19
|
currency?: string;
|
|
8
20
|
slug?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Genuine per-line product attributes — the kind a buyer needs in order to
|
|
23
|
+
* tell two lines of the same product apart (size, colour, edition).
|
|
24
|
+
*
|
|
25
|
+
* 🛑 DISPLAY ONLY. Every entry is rendered to the buyer as
|
|
26
|
+
* `` `${key}: ${value}` ``, joined with commas. Never use this to carry
|
|
27
|
+
* plumbing a component needs: a field put here to be *read* is also a field
|
|
28
|
+
* printed with its own name, and the leak looks like a debug string rather
|
|
29
|
+
* than like a bug. Add a named field to this interface instead.
|
|
30
|
+
*/
|
|
9
31
|
attributes?: Record<string, string>;
|
|
10
32
|
}
|
|
11
33
|
export interface CartItem {
|
|
@@ -38,9 +38,27 @@ function renderCategoryFilterDrawer(props) {
|
|
|
38
38
|
function getNumericParam(table, key) {
|
|
39
39
|
return table.get(key) ? Number(table.get(key)) : undefined;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
/*
|
|
42
|
+
* 🛑 TIER FIRST, NOT NAME. Found by
|
|
43
|
+
* checklist-happy-path-guest-browse-categories-index-lists-roots, 2026-09-29.
|
|
44
|
+
*
|
|
45
|
+
* This was `name ASC`, which sorts 47 categories alphabetically with no
|
|
46
|
+
* awareness of the tree — so BOTH roots fell off page 1. "Spinning Tops" sorts
|
|
47
|
+
* under S onto page 3 and "Living Collectibles" under L onto page 2, while
|
|
48
|
+
* their own descendants filled page 1: Bits, Blades, Discs, Drivers, Energy
|
|
49
|
+
* Rings. A visitor opening /categories met component-level leaves, eight of
|
|
50
|
+
* them reading "0 items", and never saw either root.
|
|
51
|
+
*
|
|
52
|
+
* `tier` is already `canSort: true` in the repository's SIEVE_FIELDS, and a
|
|
53
|
+
* single-field sort needs no composite index, so this costs nothing to deploy.
|
|
54
|
+
* Deliberately NOT `tier ASC, name ASC`: that pairing has no
|
|
55
|
+
* `(tier, name)` index and a missing composite fails as an empty page rather
|
|
56
|
+
* than an error (Root Cause #59).
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_SORT = sortBy(CATEGORY_FIELDS.TIER, "ASC");
|
|
42
59
|
const FILTER_KEYS = [TABLE_KEYS.IS_FEATURED, "isBrand", "rootOnly", "tier", "minItemCount", "maxItemCount"];
|
|
43
60
|
const SORT_OPTIONS = [
|
|
61
|
+
{ value: sortBy(CATEGORY_FIELDS.TIER, "ASC"), label: "Top level first" },
|
|
44
62
|
{ value: sortBy(CATEGORY_FIELDS.NAME, "ASC"), label: "Name A–Z" },
|
|
45
63
|
{ value: sortBy(CATEGORY_FIELDS.NAME), label: "Name Z–A" },
|
|
46
64
|
{ value: sortBy("metrics.productCount", "DESC"), label: "Most Products" },
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
|
|
3
|
+
import { sortBy } from "../../../constants/sort";
|
|
4
|
+
import { CATEGORY_FIELDS } from "../../../constants/field-names";
|
|
3
5
|
import { categoriesRepository } from "../../../repositories";
|
|
4
6
|
import { Container, Heading, Main, Section } from "../../../ui";
|
|
5
7
|
import { AdSlot } from "../../homepage/components/AdSlot";
|
|
@@ -38,7 +40,18 @@ function buildCategoryFilters(params) {
|
|
|
38
40
|
return parts.join(",");
|
|
39
41
|
}
|
|
40
42
|
export async function CategoriesIndexPageView({ searchParams = {} }) {
|
|
41
|
-
|
|
43
|
+
/*
|
|
44
|
+
* 🛑 MUST MATCH CategoriesIndexListing's DEFAULT_SORT.
|
|
45
|
+
*
|
|
46
|
+
* This defaulted to "name" while the client listing defaulted to the same,
|
|
47
|
+
* and both were wrong: the roots fell off page 1. Now both default to tier
|
|
48
|
+
* ascending — and they have to agree, because this view hands its result to
|
|
49
|
+
* the listing as initialData and every public listing hook sets
|
|
50
|
+
* `staleTime: Infinity` when given it. A disagreement here is not a
|
|
51
|
+
* transient wrong first paint; it is frozen for that query key
|
|
52
|
+
* (Root Cause #30).
|
|
53
|
+
*/
|
|
54
|
+
const sort = sp(searchParams, "sort") || sortBy(CATEGORY_FIELDS.TIER, "ASC");
|
|
42
55
|
const page = Number(sp(searchParams, "page")) || 1;
|
|
43
56
|
const filters = buildCategoryFilters(searchParams);
|
|
44
57
|
const result = await safeRead(() => categoriesRepository.list({
|
|
@@ -18,6 +18,14 @@ export interface ClassifiedDetailPageViewProps {
|
|
|
18
18
|
currency: string;
|
|
19
19
|
minOfferPercent: number;
|
|
20
20
|
bounds: OfferBounds;
|
|
21
|
+
/**
|
|
22
|
+
* The owning store's SLUG, so the control can suppress itself for the
|
|
23
|
+
* seller who owns the listing. Kept identical to `ProductDetailPageView`'s
|
|
24
|
+
* ctx — the comment at the `/classified/[slug]` call site asserts the two
|
|
25
|
+
* contracts match precisely so a consumer snippet can be shared, and an
|
|
26
|
+
* added field on one is drift unless it lands on both.
|
|
27
|
+
*/
|
|
28
|
+
listingStoreId?: string;
|
|
21
29
|
}) => React.ReactNode;
|
|
22
30
|
}
|
|
23
31
|
export declare function ClassifiedDetailPageView({ slug, initialProduct, renderOfferAction }: ClassifiedDetailPageViewProps): Promise<import("react").JSX.Element>;
|
|
@@ -105,6 +105,7 @@ export async function ClassifiedDetailPageView({ slug, initialProduct, renderOff
|
|
|
105
105
|
minOfferPercent: typeof p.minOfferPercent === "number"
|
|
106
106
|
? p.minOfferPercent
|
|
107
107
|
: DEFAULT_MIN_OFFER_PERCENT,
|
|
108
|
+
listingStoreId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
108
109
|
bounds: offerBounds,
|
|
109
110
|
}), _jsx(Text, { size: "xs", color: "muted", children: offerBounds.isBuyRequest
|
|
110
111
|
? "Send a request to buy and the seller will confirm. Payment and collection are arranged after they accept."
|
|
@@ -101,6 +101,19 @@ export declare function getEventLeaderboard(eventId: string): Promise<import("..
|
|
|
101
101
|
* caller can render a generic "Guest" label instead of a raw hash.
|
|
102
102
|
*/
|
|
103
103
|
export declare function getEventSpinResults(eventId: string, limit?: number): Promise<import("../types").SpinResultEntry[]>;
|
|
104
|
+
/**
|
|
105
|
+
* The CALLER'S OWN winning spins for an event, newest first.
|
|
106
|
+
*
|
|
107
|
+
* The companion to `getEventSpinResults`, which is a public masked feed of
|
|
108
|
+
* everyone's recent spins. That feed cannot answer "what did I win", and a
|
|
109
|
+
* checklist case asserting it did was the thing that surfaced the gap: read as
|
|
110
|
+
* a buyer who had never spun, the page listed three other people's prizes and
|
|
111
|
+
* showed no empty state, because from its point of view nothing was empty.
|
|
112
|
+
*
|
|
113
|
+
* Unmasked on purpose — these are the caller's own results, and `userId` is
|
|
114
|
+
* supplied by the server from a verified session, never by the client.
|
|
115
|
+
*/
|
|
116
|
+
export declare function getUserSpinResultsForEvent(eventId: string, userId: string): Promise<import("../types").SpinResultEntry[]>;
|
|
104
117
|
/**
|
|
105
118
|
* Per-option vote tally for a poll event — replaces the voter-ranked
|
|
106
119
|
* leaderboard for event.type === "poll", where ranking "who happened to
|
|
@@ -16,6 +16,7 @@ const ERR_EVENT_NOT_FOUND = "Event not found";
|
|
|
16
16
|
import { coerceMediaField, getMediaUrl } from "../../../utils";
|
|
17
17
|
import { finalizeStagedMediaField, finalizeStagedMediaObject, finalizeStagedMediaObjectArray, } from "../../media/finalize";
|
|
18
18
|
import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
|
|
19
|
+
import { maskName } from "../../../security/pii-mask";
|
|
19
20
|
// --- Admin: Create Event --------------------------------------------------
|
|
20
21
|
export async function createEvent(adminId, input) {
|
|
21
22
|
const finalizedCoverUrl = await finalizeStagedMediaField(getMediaUrl(input.coverImage) ??
|
|
@@ -194,13 +195,56 @@ export async function getEventSpinResults(eventId, limit = 10) {
|
|
|
194
195
|
const entries = await eventEntryRepository.getRecentSpinResults(event.id, limit);
|
|
195
196
|
return entries.map((entry) => ({
|
|
196
197
|
id: entry.id,
|
|
197
|
-
|
|
198
|
+
/*
|
|
199
|
+
* 🛑 MASKED. This is a public feed on a public event page — the route is
|
|
200
|
+
* not viewer-scoped and never has been — so it was publishing real winners'
|
|
201
|
+
* full display names beside the prize each one won, to anybody with the
|
|
202
|
+
* event's URL.
|
|
203
|
+
*
|
|
204
|
+
* Root Cause #50 is the precedent and it is the same shape: a real bidder's
|
|
205
|
+
* display name was going out on public Bid History because `maskPublicBid`
|
|
206
|
+
* spread its input instead of calling `maskName`. Nothing rendered the field
|
|
207
|
+
* conspicuously there either, which is why it sat unnoticed.
|
|
208
|
+
*
|
|
209
|
+
* `maskName` returns "Anonymous" for a missing name, so the guest case still
|
|
210
|
+
* reads sensibly, and `isGuest` below is what the renderer actually branches
|
|
211
|
+
* on for its own label. A caller's OWN spins are served unmasked by
|
|
212
|
+
* `getUserSpinResultsForEvent` — masking is for other people's.
|
|
213
|
+
*/
|
|
214
|
+
userDisplayName: entry.userId ? maskName(entry.userDisplayName) : undefined,
|
|
198
215
|
isGuest: !entry.userId,
|
|
199
216
|
spinPrizeId: entry.spinPrizeId,
|
|
200
217
|
spinPrizeTitle: entry.spinPrizeId ? prizeTitleById.get(entry.spinPrizeId) : undefined,
|
|
201
218
|
spinWonAt: entry.spinWonAt ? new Date(entry.spinWonAt).toISOString() : undefined,
|
|
202
219
|
}));
|
|
203
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* The CALLER'S OWN winning spins for an event, newest first.
|
|
223
|
+
*
|
|
224
|
+
* The companion to `getEventSpinResults`, which is a public masked feed of
|
|
225
|
+
* everyone's recent spins. That feed cannot answer "what did I win", and a
|
|
226
|
+
* checklist case asserting it did was the thing that surfaced the gap: read as
|
|
227
|
+
* a buyer who had never spun, the page listed three other people's prizes and
|
|
228
|
+
* showed no empty state, because from its point of view nothing was empty.
|
|
229
|
+
*
|
|
230
|
+
* Unmasked on purpose — these are the caller's own results, and `userId` is
|
|
231
|
+
* supplied by the server from a verified session, never by the client.
|
|
232
|
+
*/
|
|
233
|
+
export async function getUserSpinResultsForEvent(eventId, userId) {
|
|
234
|
+
const event = await eventRepository.findByIdOrSlug(eventId);
|
|
235
|
+
if (!event || event.type !== "spin_wheel")
|
|
236
|
+
return [];
|
|
237
|
+
const prizeTitleById = new Map((event.spinPrizes ?? []).map((p) => [p.id, p.label]));
|
|
238
|
+
const entries = await eventEntryRepository.getUserSpinResults(event.id, userId);
|
|
239
|
+
return entries.map((entry) => ({
|
|
240
|
+
id: entry.id,
|
|
241
|
+
userDisplayName: entry.userDisplayName,
|
|
242
|
+
isGuest: false,
|
|
243
|
+
spinPrizeId: entry.spinPrizeId,
|
|
244
|
+
spinPrizeTitle: entry.spinPrizeId ? prizeTitleById.get(entry.spinPrizeId) : undefined,
|
|
245
|
+
spinWonAt: entry.spinWonAt ? new Date(entry.spinWonAt).toISOString() : undefined,
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
204
248
|
/**
|
|
205
249
|
* Per-option vote tally for a poll event — replaces the voter-ranked
|
|
206
250
|
* leaderboard for event.type === "poll", where ranking "who happened to
|
|
@@ -324,6 +368,36 @@ export async function enterEvent(eventId, input, user, request) {
|
|
|
324
368
|
throw new ValidationError(ERROR_MESSAGES.EVENT.ALREADY_ENTERED);
|
|
325
369
|
}
|
|
326
370
|
}
|
|
371
|
+
/*
|
|
372
|
+
* 🛑 A signed-in user could vote in a poll as many times as they liked, and
|
|
373
|
+
* every vote counted.
|
|
374
|
+
*
|
|
375
|
+
* Three paths reach this point and only two of them guarded. A GUEST is
|
|
376
|
+
* deduped by hashed IP just above; a SURVEY enforces `maxEntriesPerUser`
|
|
377
|
+
* immediately above that; a poll had neither, so the one case that is trivial
|
|
378
|
+
* to abuse — an authenticated voter clicking Cast Vote again — was the
|
|
379
|
+
* unguarded one. Measured on /events/event-favourite-blader-poll: voted for
|
|
380
|
+
* one option, reloaded, voted for a DIFFERENT option, and the second was
|
|
381
|
+
* accepted with the same "Vote recorded!" confirmation. The event's
|
|
382
|
+
* Participants counter went 362 -> 364 on those two votes from one account, so
|
|
383
|
+
* the tally itself was corrupted, not merely the UI.
|
|
384
|
+
*
|
|
385
|
+
* A poll is one vote per person by definition — there is no
|
|
386
|
+
* `maxEntriesPerUser` to consult and no change-your-vote affordance anywhere
|
|
387
|
+
* in the UI — so the rule is simply "not twice". Uses the same
|
|
388
|
+
* `countUserEntries` the survey path already relies on rather than a new
|
|
389
|
+
* query, so the read cost and the index it needs are both already proven.
|
|
390
|
+
*
|
|
391
|
+
* ValidationError, so the caller gets `ALREADY_ENTERED` and a real message:
|
|
392
|
+
* the previous behaviour silently produced a duplicate, which is the failure
|
|
393
|
+
* mode this refusal exists to replace.
|
|
394
|
+
*/
|
|
395
|
+
if (user && event.type === "poll") {
|
|
396
|
+
const priorVotes = await eventEntryRepository.countUserEntries(resolvedEventId, user.uid);
|
|
397
|
+
if (priorVotes >= 1) {
|
|
398
|
+
throw new ValidationError(ERROR_MESSAGES.EVENT.ALREADY_ENTERED);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
327
401
|
if (event.type === "poll" && event.pollConfig) {
|
|
328
402
|
const validOptionIds = event.pollConfig.options.map((o) => o.id);
|
|
329
403
|
const votes = input.pollVotes ?? [];
|
|
@@ -22,6 +22,23 @@ declare class EventEntryRepository extends BaseRepository<EventEntryDocument> {
|
|
|
22
22
|
* (every write path sets `spinWonAt` and `spinPrizeId` together).
|
|
23
23
|
*/
|
|
24
24
|
getRecentSpinResults(eventId: string, limit?: number): Promise<EventEntryDocument[]>;
|
|
25
|
+
/**
|
|
26
|
+
* One caller's own winning spins for an event, newest first.
|
|
27
|
+
*
|
|
28
|
+
* 🛑 Deliberately the SAME query shape as `countUserEntries` — two equalities
|
|
29
|
+
* on `(eventId, userId)` and no `orderBy` — so it needs no composite index
|
|
30
|
+
* that does not already exist. `getRecentSpinResults` orders by `spinWonAt`,
|
|
31
|
+
* which is fine with its single equality but would demand a new
|
|
32
|
+
* `(eventId, userId, spinWonAt)` index here, and an index that has not been
|
|
33
|
+
* deployed fails with FAILED_PRECONDITION at runtime while looking perfectly
|
|
34
|
+
* correct in source (Root Cause #2).
|
|
35
|
+
*
|
|
36
|
+
* Sorting in memory is safe precisely because this set is tiny and bounded by
|
|
37
|
+
* the event's own `spinMaxPerUser` — it is "my spins", not a feed. Rows with
|
|
38
|
+
* no `spinWonAt` are dropped rather than sorted to an arbitrary end: an entry
|
|
39
|
+
* that never resolved a prize is not a spin result.
|
|
40
|
+
*/
|
|
41
|
+
getUserSpinResults(eventId: string, userId: string): Promise<EventEntryDocument[]>;
|
|
25
42
|
countUserEntries(eventId: string, userId: string): Promise<number>;
|
|
26
43
|
getLeaderboard(eventId: string, limit?: number): Promise<import("../types").LeaderboardEntry[]>;
|
|
27
44
|
/**
|
|
@@ -92,6 +92,42 @@ class EventEntryRepository extends BaseRepository {
|
|
|
92
92
|
throw new DatabaseError(`Failed to get recent spin results for event ${eventId}`, error);
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* One caller's own winning spins for an event, newest first.
|
|
97
|
+
*
|
|
98
|
+
* 🛑 Deliberately the SAME query shape as `countUserEntries` — two equalities
|
|
99
|
+
* on `(eventId, userId)` and no `orderBy` — so it needs no composite index
|
|
100
|
+
* that does not already exist. `getRecentSpinResults` orders by `spinWonAt`,
|
|
101
|
+
* which is fine with its single equality but would demand a new
|
|
102
|
+
* `(eventId, userId, spinWonAt)` index here, and an index that has not been
|
|
103
|
+
* deployed fails with FAILED_PRECONDITION at runtime while looking perfectly
|
|
104
|
+
* correct in source (Root Cause #2).
|
|
105
|
+
*
|
|
106
|
+
* Sorting in memory is safe precisely because this set is tiny and bounded by
|
|
107
|
+
* the event's own `spinMaxPerUser` — it is "my spins", not a feed. Rows with
|
|
108
|
+
* no `spinWonAt` are dropped rather than sorted to an arbitrary end: an entry
|
|
109
|
+
* that never resolved a prize is not a spin result.
|
|
110
|
+
*/
|
|
111
|
+
async getUserSpinResults(eventId, userId) {
|
|
112
|
+
try {
|
|
113
|
+
const snapshot = await this.getCollection()
|
|
114
|
+
.where(EVENT_ENTRY_FIELDS.EVENT_ID, "==", eventId)
|
|
115
|
+
.where(EVENT_ENTRY_FIELDS.USER_ID, "==", userId)
|
|
116
|
+
.get();
|
|
117
|
+
return snapshot.docs
|
|
118
|
+
.map((doc) => this.mapDoc(doc))
|
|
119
|
+
.filter((entry) => Boolean(entry.spinWonAt))
|
|
120
|
+
.sort((a, b) => {
|
|
121
|
+
const at = new Date(a.spinWonAt).getTime();
|
|
122
|
+
const bt = new Date(b.spinWonAt).getTime();
|
|
123
|
+
return (Number.isFinite(bt) ? bt : 0) - (Number.isFinite(at) ? at : 0);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
void normalizeError(error);
|
|
128
|
+
throw new DatabaseError(`Failed to load spin results for user ${userId} on event ${eventId}`, error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
95
131
|
async countUserEntries(eventId, userId) {
|
|
96
132
|
try {
|
|
97
133
|
const snapshot = await this.getCollection()
|
|
@@ -45,5 +45,5 @@ export function BrandsSection({ title = "Top Brands", subtitle, limit = 12, view
|
|
|
45
45
|
});
|
|
46
46
|
if (!isLoading && allBrands.length === 0)
|
|
47
47
|
return null;
|
|
48
|
-
return (_jsx(Section, { className: `${className}`, surface: "subtle", paddingY: "y-3xl", paddingX: "x-md", children: _jsxs(Div, { className: "mx-auto max-w-7xl", children: [_jsxs(Row, { className: "mb-6", align: "center", justify: "between", reverse: hand === "left", children: [_jsxs(_Fragment, { children: [_jsx(Heading, { level: 2, className: themed.textPrimary, size: "2xl", mdSize: "3xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { variant: "secondary", className: "mt-1", size: "sm", children: subtitle }))] }), !cta && viewMoreHref && (_jsx(Link, { href: viewMoreHref, className: "
|
|
48
|
+
return (_jsx(Section, { className: `${className}`, surface: "subtle", paddingY: "y-3xl", paddingX: "x-md", children: _jsxs(Div, { className: "mx-auto max-w-7xl", children: [_jsxs(Row, { className: "mb-6", align: "center", justify: "between", reverse: hand === "left", children: [_jsxs(_Fragment, { children: [_jsx(Heading, { level: 2, className: themed.textPrimary, size: "2xl", mdSize: "3xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { variant: "secondary", className: "mt-1", size: "sm", children: subtitle }))] }), !cta && viewMoreHref && (_jsx(Link, { href: viewMoreHref, className: "inline-flex items-center gap-[0.375rem] rounded-[var(--appkit-radius-md)] px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-sm)] font-semibold transition-colors bg-primary text-white hover:bg-primary-600", children: viewMoreLabel }))] }), showFeaturedChip && !isLoading && (_jsxs(Row, { wrap: true, gap: "sm", className: "mb-4", children: [_jsx(BrandFilterChip, { label: "All", active: activeFilter === "all", onClick: () => setActiveFilter("all") }), _jsx(BrandFilterChip, { label: "Featured", active: activeFilter === "featured", onClick: () => setActiveFilter(activeFilter === "featured" ? "all" : "featured") })] })), isLoading ? (_jsx(Div, { layout: "flex", gap: "3", className: `${__O.hidden}`, children: Array.from({ length: 8 }).map((_, i) => (_jsx(Div, { className: "h-32 w-36 flex-none animate-pulse md:h-40", surface: "subtle", rounded: "xl" }, i))) })) : (_jsx(HorizontalScroller, { items: brands, renderItem: (brand) => _jsx(BrandLogo, { brand: brand }), keyExtractor: (brand) => brand.id, gap: 12, showArrows: true, showScrollbar: false, autoScroll: autoScroll, autoScrollInterval: scrollInterval, pauseOnHover: true, loop: loop })), cta && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: cta.href, className: CTA_CLASSES[cta.variant], children: cta.label }) }))] }) }));
|
|
49
49
|
}
|
|
@@ -17,5 +17,5 @@ export function CustomerReviewsSection({ title, subtitle, items, renderItem, vie
|
|
|
17
17
|
}
|
|
18
18
|
if (items.length === 0)
|
|
19
19
|
return null;
|
|
20
|
-
return (_jsx(Section, { className: `${className}`, surface: "muted", padding: "y-3xl", children: _jsxs(Div, { className: "w-full max-w-7xl mx-auto md:px-[2rem]", padding: "x-md", children: [_jsxs(Div, { className: "text-left mb-10", children: [_jsx(Heading, { level: 2, className: `${themed.textPrimary} mb-3`, size: "3xl", mdSize: "4xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { className: `${themed.textSecondary}`, size: "base", children: subtitle }))] }), _jsx(HorizontalScroller, { items: items, renderItem: (item) => renderItem(item), perView: perView ?? CAROUSEL_PER_VIEW.reviews, gap: 24, autoScroll: autoScroll, autoScrollInterval: scrollInterval, pauseOnHover: true, snapToItems: true, showArrows: true, keyExtractor: keyExtractor, loop: loop }), viewMoreHref && (_jsx(Div, { className: "text-left mt-8", children: _jsx(TextLink, { href: viewMoreHref, className: "
|
|
20
|
+
return (_jsx(Section, { className: `${className}`, surface: "muted", padding: "y-3xl", children: _jsxs(Div, { className: "w-full max-w-7xl mx-auto md:px-[2rem]", padding: "x-md", children: [_jsxs(Div, { className: "text-left mb-10", children: [_jsx(Heading, { level: 2, className: `${themed.textPrimary} mb-3`, size: "3xl", mdSize: "4xl", weight: "bold", children: title }), subtitle && (_jsx(Text, { className: `${themed.textSecondary}`, size: "base", children: subtitle }))] }), _jsx(HorizontalScroller, { items: items, renderItem: (item) => renderItem(item), perView: perView ?? CAROUSEL_PER_VIEW.reviews, gap: 24, autoScroll: autoScroll, autoScrollInterval: scrollInterval, pauseOnHover: true, snapToItems: true, showArrows: true, keyExtractor: keyExtractor, loop: loop }), viewMoreHref && (_jsx(Div, { className: "text-left mt-8", children: _jsx(TextLink, { href: viewMoreHref, rounded: "md", align: "center", gap: "xs", className: "inline-flex px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] transition-colors bg-primary text-white hover:bg-primary-600", size: "sm", weight: "semibold", children: viewMoreLabel }) }))] }) }));
|
|
21
21
|
}
|
|
@@ -30,7 +30,7 @@ export function FeaturedBundlesSection({ title = BUNDLE_COPY.featuredDefaultTitl
|
|
|
30
30
|
const items = maxItems && maxItems > 0 ? bundles.slice(0, maxItems) : bundles;
|
|
31
31
|
if (items.length === 0)
|
|
32
32
|
return null;
|
|
33
|
-
return (_jsx(Section, { className: `${className}`, padding: "y-2xl", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Row, { gap: "sm", align: "center", justify: "between", wrap: true, reverse: hand === "left", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, size: "2xl", weight: "semibold", color: "primary", children: title }), description && (_jsx(Text, { size: "sm", color: "muted", children: description }))] }), _jsx(Link, { href: String(ROUTES.PUBLIC.BUNDLES), className: "
|
|
33
|
+
return (_jsx(Section, { className: `${className}`, padding: "y-2xl", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Row, { gap: "sm", align: "center", justify: "between", wrap: true, reverse: hand === "left", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, size: "2xl", weight: "semibold", color: "primary", children: title }), description && (_jsx(Text, { size: "sm", color: "muted", children: description }))] }), _jsx(Link, { href: String(ROUTES.PUBLIC.BUNDLES), className: "inline-flex items-center gap-[0.375rem] rounded-[var(--appkit-radius-md)] px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-sm)] font-semibold transition-colors bg-primary text-white hover:bg-primary-600", children: viewMoreLabel })] }), _jsx(Grid, { cols: "cards", gap: "3", children: items.map((bundle) => (_jsx(FeaturedBundleCard, { bundle: bundle, showSavingsBadge: showSavingsBadge, onBuyNow: onBuyNow }, bundle.id))) })] }) }));
|
|
34
34
|
}
|
|
35
35
|
function FeaturedBundleCard({ bundle, showSavingsBadge = true, onBuyNow, }) {
|
|
36
36
|
const stock = bundle.bundleStockStatus ?? "in_stock";
|
|
@@ -69,5 +69,5 @@ export function ShopByCategorySection({ title = "Shop by Category", subtitle, li
|
|
|
69
69
|
if (!cat)
|
|
70
70
|
return null;
|
|
71
71
|
return (_jsx(FilterChip, { label: cat.name, active: activeFilter === id, onClick: () => setActiveFilter(activeFilter === id ? "all" : id) }, id));
|
|
72
|
-
})] })), isLoading ? (_jsx(Div, { layout: "flex", gap: "3", className: `${__O.hidden} px-[0.25rem]`, children: Array.from({ length: 6 }).map((_, i) => (_jsx(Div, { className: "flex-none h-[104px] w-[108px] animate-pulse", surface: "subtle", rounded: "xl" }, i))) })) : (_jsx(HorizontalScroller, { items: categories, renderItem: (cat) => _jsx(CategoryChip, { category: cat }), perView: CAROUSEL_PER_VIEW.standard, gap: 16, keyExtractor: (cat) => cat.id, autoScroll: autoScroll, autoScrollInterval: scrollInterval, showArrows: true, snapToItems: true, showScrollbar: false, loop: loop, pauseOnHover: true })), cta && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: cta.href, className: CTA_CLASSES[cta.variant], children: cta.label }) })), !cta && viewMoreHref && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: viewMoreHref, className: "inline-flex items-center gap-[var(--appkit-
|
|
72
|
+
})] })), isLoading ? (_jsx(Div, { layout: "flex", gap: "3", className: `${__O.hidden} px-[0.25rem]`, children: Array.from({ length: 6 }).map((_, i) => (_jsx(Div, { className: "flex-none h-[104px] w-[108px] animate-pulse", surface: "subtle", rounded: "xl" }, i))) })) : (_jsx(HorizontalScroller, { items: categories, renderItem: (cat) => _jsx(CategoryChip, { category: cat }), perView: CAROUSEL_PER_VIEW.standard, gap: 16, keyExtractor: (cat) => cat.id, autoScroll: autoScroll, autoScrollInterval: scrollInterval, showArrows: true, snapToItems: true, showScrollbar: false, loop: loop, pauseOnHover: true })), cta && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: cta.href, className: CTA_CLASSES[cta.variant], children: cta.label }) })), !cta && viewMoreHref && !isLoading && (_jsx(Div, { className: "mt-6 text-left", children: _jsx(Link, { href: viewMoreHref, className: "inline-flex items-center gap-[0.375rem] rounded-[var(--appkit-radius-md)] px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-sm)] font-semibold transition-colors bg-primary text-white hover:bg-primary-600", children: viewMoreLabel }) }))] }) }));
|
|
73
73
|
}
|
|
@@ -139,7 +139,7 @@ export function AppLayoutShell({ children, navItems, sidebarItems = [], sidebarS
|
|
|
139
139
|
const pathname = usePathname();
|
|
140
140
|
useEffect(() => { setSidebarOpen(false); }, [pathname]);
|
|
141
141
|
const { theme, toggleTheme, activeTheme } = useTheme();
|
|
142
|
-
const { closeNav: closeDashboardNav
|
|
142
|
+
const { closeNav: closeDashboardNav } = useDashboardNav();
|
|
143
143
|
const { user: authUser } = useAuth();
|
|
144
144
|
const headerRef = useRef(null);
|
|
145
145
|
useEffect(() => {
|
|
@@ -200,5 +200,5 @@ export function AppLayoutShell({ children, navItems, sidebarItems = [], sidebarS
|
|
|
200
200
|
// main-to-footer — it never clears the bottom of the page, which is
|
|
201
201
|
// what the fixed nav + tier actually overlap. That reservation now
|
|
202
202
|
// lives on the footer's own padding-bottom.
|
|
203
|
-
className: "w-full flex-1 flex flex-col", children: _jsx(Div, { padding: "y-lg", className: `flex-1 ${contentClassName ?? "mx-auto w-full max-w-screen-xl px-[var(--appkit-space-5)] md:px-[var(--appkit-space-6)] lg:px-[var(--appkit-space-8)]"}`, children: children }) })] }), _jsx(BackToTop, {}), _jsx(FooterLayout, { ...footer }), _jsx(BottomChrome, { children: _jsx(BottomActions, {}) }), showBottomNav && (_jsx(BottomNavbar, { user: user, userId: userId, homeHref: homeHref, shopHref: shopHref, cartHref: cartHref, wishlistHref: wishlistHref, profileHref: profileHref, loginHref: loginHref, onSearchToggle: () => setSearchOpen((prev) => !prev), navItems: navItems, onMoreToggle:
|
|
203
|
+
className: "w-full flex-1 flex flex-col", children: _jsx(Div, { padding: "y-lg", className: `flex-1 ${contentClassName ?? "mx-auto w-full max-w-screen-xl px-[var(--appkit-space-5)] md:px-[var(--appkit-space-6)] lg:px-[var(--appkit-space-8)]"}`, children: children }) })] }), _jsx(BackToTop, {}), _jsx(FooterLayout, { ...footer }), _jsx(BottomChrome, { children: _jsx(BottomActions, {}) }), showBottomNav && (_jsx(BottomNavbar, { user: user, userId: userId, homeHref: homeHref, shopHref: shopHref, cartHref: cartHref, wishlistHref: wishlistHref, profileHref: profileHref, loginHref: loginHref, onSearchToggle: () => setSearchOpen((prev) => !prev), navItems: navItems, onMoreToggle: handleTogglePublicSidebar })), _jsx(UnsavedChangesModal, {})] }) }));
|
|
204
204
|
}
|
|
@@ -18,8 +18,14 @@ export interface BottomNavLayoutProps {
|
|
|
18
18
|
*
|
|
19
19
|
* Hand mode: the item strip carries `appkit-hand-mirror`, so slot order
|
|
20
20
|
* reverses in left-hand mode (CSS-only via `[data-hand]` — applies on the
|
|
21
|
-
* first paint, no flash).
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* first paint, no flash).
|
|
22
|
+
*
|
|
23
|
+
* `BottomNavbar` (`./BottomNavbar.tsx`) is the sole consumer, and it renders
|
|
24
|
+
* unconditionally on every route including admin/store/user dashboards —
|
|
25
|
+
* there is deliberately no second, dashboard-specific bottom bar (removed
|
|
26
|
+
* 2026-09-28; dashboard section navigation lives on the header hamburger
|
|
27
|
+
* instead, via `DashboardNavContext`). Never add a second component that
|
|
28
|
+
* mounts through this shell — exactly one bottom nav may exist per route,
|
|
29
|
+
* since this is the sole publisher of `--bottom-nav-height`.
|
|
24
30
|
*/
|
|
25
31
|
export declare function BottomNavLayout({ ariaLabel, children, id, className, }: BottomNavLayoutProps): React.JSX.Element;
|
|
@@ -16,9 +16,15 @@ import { useVisualViewportInset } from "../../react/hooks/useVisualViewportInset
|
|
|
16
16
|
*
|
|
17
17
|
* Hand mode: the item strip carries `appkit-hand-mirror`, so slot order
|
|
18
18
|
* reverses in left-hand mode (CSS-only via `[data-hand]` — applies on the
|
|
19
|
-
* first paint, no flash).
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* first paint, no flash).
|
|
20
|
+
*
|
|
21
|
+
* `BottomNavbar` (`./BottomNavbar.tsx`) is the sole consumer, and it renders
|
|
22
|
+
* unconditionally on every route including admin/store/user dashboards —
|
|
23
|
+
* there is deliberately no second, dashboard-specific bottom bar (removed
|
|
24
|
+
* 2026-09-28; dashboard section navigation lives on the header hamburger
|
|
25
|
+
* instead, via `DashboardNavContext`). Never add a second component that
|
|
26
|
+
* mounts through this shell — exactly one bottom nav may exist per route,
|
|
27
|
+
* since this is the sole publisher of `--bottom-nav-height`.
|
|
22
28
|
*/
|
|
23
29
|
export function BottomNavLayout({ ariaLabel, children, id = "bottom-navbar", className, }) {
|
|
24
30
|
const isKeyboardOpen = useVisualViewportInset();
|