@mohasinac/appkit 4.5.1 → 4.5.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/client/features/layout/RoleGuard.d.ts +1 -1
- package/dist/_internal/client/features/layout/RoleGuard.js +23 -2
- package/dist/features/categories/components/BrandDetailTabs.js +23 -14
- package/dist/features/categories/components/CategoryDetailTabs.js +21 -16
- package/dist/features/stores/components/StoreDetailLayoutView.js +20 -6
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +38 -0
- package/package.json +1 -1
|
@@ -12,13 +12,34 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
12
12
|
* route overrides. Everything else defaults to appkit's ROUTES.
|
|
13
13
|
*/
|
|
14
14
|
import { useRouter } from "next/navigation";
|
|
15
|
+
import { useEffect, useRef, useState } from "react";
|
|
15
16
|
import { ProtectedRoute } from "../../../../features/auth/components/Guards";
|
|
16
17
|
import { useSession } from "../../../../react/contexts/SessionContext";
|
|
17
18
|
import { ROUTES } from "../../../../next/routing/route-map";
|
|
18
19
|
export function RoleGuard({ role, requireAuth = true, loginPath, unauthorizedPath, loadingComponent, children, }) {
|
|
19
|
-
const { user, loading } = useSession();
|
|
20
|
+
const { user, loading, refreshUser } = useSession();
|
|
20
21
|
const router = useRouter();
|
|
21
|
-
|
|
22
|
+
// SessionContext only refreshes role/disabled periodically (every 5
|
|
23
|
+
// minutes) or on a hard reload/re-login — never on ordinary client-side
|
|
24
|
+
// navigation. A user just approved as a seller, or just un-banned, would
|
|
25
|
+
// otherwise keep getting redirected to /unauthorized by this exact guard
|
|
26
|
+
// for up to 5 minutes even though the same check against live Firestore
|
|
27
|
+
// data would already pass. Force one fresh check per mount (i.e. once per
|
|
28
|
+
// navigation into a role-gated layout, since Next.js layouts persist
|
|
29
|
+
// across sibling route changes) before trusting a denial.
|
|
30
|
+
const hasRefreshedRef = useRef(false);
|
|
31
|
+
const [verifying, setVerifying] = useState(true);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (loading || hasRefreshedRef.current)
|
|
34
|
+
return;
|
|
35
|
+
hasRefreshedRef.current = true;
|
|
36
|
+
if (!user) {
|
|
37
|
+
setVerifying(false);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
refreshUser().finally(() => setVerifying(false));
|
|
41
|
+
}, [loading, user, refreshUser]);
|
|
42
|
+
return (_jsx(ProtectedRoute, { user: user, loading: loading || verifying, requireAuth: requireAuth, requireRole: role, onNavigate: (path) => router.push(path), routes: {
|
|
22
43
|
loginPath: loginPath ?? String(ROUTES.AUTH.LOGIN),
|
|
23
44
|
unauthorizedPath: unauthorizedPath ?? String(ROUTES.ERRORS.UNAUTHORIZED),
|
|
24
45
|
}, loadingComponent: loadingComponent, children: children }));
|
|
@@ -16,20 +16,6 @@ const TAB_TYPE_MAP = {
|
|
|
16
16
|
bundles: { kind: "category", type: "bundle" },
|
|
17
17
|
};
|
|
18
18
|
export function BrandDetailTabs({ brandName, initialProductsData, initialBundles = [], counts, enabledListingTypes, enabledCategoryTypes, }) {
|
|
19
|
-
const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
|
|
20
|
-
const mapping = TAB_TYPE_MAP[t.id];
|
|
21
|
-
if (!mapping)
|
|
22
|
-
return true;
|
|
23
|
-
if (mapping.kind === "listing" && enabledListingTypes) {
|
|
24
|
-
return enabledListingTypes.includes(mapping.type);
|
|
25
|
-
}
|
|
26
|
-
if (mapping.kind === "category" && enabledCategoryTypes) {
|
|
27
|
-
return enabledCategoryTypes.includes(mapping.type);
|
|
28
|
-
}
|
|
29
|
-
return true;
|
|
30
|
-
});
|
|
31
|
-
const firstTabId = (visibleTabs[0]?.id ?? "products");
|
|
32
|
-
const [activeTab, setActiveTab] = useState(firstTabId);
|
|
33
19
|
const countFor = (id) => {
|
|
34
20
|
switch (id) {
|
|
35
21
|
case "products": return counts?.products;
|
|
@@ -40,5 +26,28 @@ export function BrandDetailTabs({ brandName, initialProductsData, initialBundles
|
|
|
40
26
|
default: return undefined;
|
|
41
27
|
}
|
|
42
28
|
};
|
|
29
|
+
const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
|
|
30
|
+
const mapping = TAB_TYPE_MAP[t.id];
|
|
31
|
+
// This view only has content renderers for the 5 tab ids in
|
|
32
|
+
// TAB_TYPE_MAP (products/auctions/pre-orders/prize-draws/bundles) — any
|
|
33
|
+
// other CATEGORY_PAGE_TABS id (stores, classifieds, etc.) has no case in
|
|
34
|
+
// the render switch below and would show a blank tab, so exclude those
|
|
35
|
+
// here rather than rely on downstream JSX to silently render nothing.
|
|
36
|
+
if (!mapping)
|
|
37
|
+
return false;
|
|
38
|
+
if (mapping.kind === "listing" && enabledListingTypes) {
|
|
39
|
+
if (!enabledListingTypes.includes(mapping.type))
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (mapping.kind === "category" && enabledCategoryTypes) {
|
|
43
|
+
if (!enabledCategoryTypes.includes(mapping.type))
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
// Hide a tab only when its count is known and explicitly zero.
|
|
47
|
+
const count = countFor(t.id);
|
|
48
|
+
return count === undefined || count > 0;
|
|
49
|
+
});
|
|
50
|
+
const firstTabId = (visibleTabs[0]?.id ?? "products");
|
|
51
|
+
const [activeTab, setActiveTab] = useState(firstTabId);
|
|
43
52
|
return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), activeTab === "products" && (_jsx(CategoryProductsListing, { categorySlug: "", brandName: brandName, initialData: initialProductsData })), activeTab === "auctions" && (_jsx(AuctionsIndexListing, { brandName: brandName })), activeTab === "pre-orders" && (_jsx(PreOrdersIndexListing, { brandName: brandName })), activeTab === "prize-draws" && (_jsx(PrizeDrawsIndexListing, { brandName: brandName })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles, brandName: brandName }))] }));
|
|
44
53
|
}
|
|
@@ -19,22 +19,6 @@ const TAB_TYPE_MAP = {
|
|
|
19
19
|
stores: { kind: "entity", type: "stores" },
|
|
20
20
|
};
|
|
21
21
|
export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles = [], initialStores = [], counts, enabledListingTypes, enabledCategoryTypes, }) {
|
|
22
|
-
const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
|
|
23
|
-
const mapping = TAB_TYPE_MAP[t.id];
|
|
24
|
-
if (!mapping)
|
|
25
|
-
return true;
|
|
26
|
-
if (mapping.kind === "listing" && enabledListingTypes) {
|
|
27
|
-
return enabledListingTypes.includes(mapping.type);
|
|
28
|
-
}
|
|
29
|
-
if (mapping.kind === "category" && enabledCategoryTypes) {
|
|
30
|
-
return enabledCategoryTypes.includes(mapping.type);
|
|
31
|
-
}
|
|
32
|
-
if (mapping.kind === "entity")
|
|
33
|
-
return true;
|
|
34
|
-
return true;
|
|
35
|
-
});
|
|
36
|
-
const firstTabId = (visibleTabs[0]?.id ?? "products");
|
|
37
|
-
const [activeTab, setActiveTab] = useState(firstTabId);
|
|
38
22
|
const countFor = (id) => {
|
|
39
23
|
switch (id) {
|
|
40
24
|
case "products": return counts?.products;
|
|
@@ -46,5 +30,26 @@ export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsDa
|
|
|
46
30
|
default: return undefined;
|
|
47
31
|
}
|
|
48
32
|
};
|
|
33
|
+
const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
|
|
34
|
+
const mapping = TAB_TYPE_MAP[t.id];
|
|
35
|
+
if (mapping) {
|
|
36
|
+
if (mapping.kind === "listing" && enabledListingTypes) {
|
|
37
|
+
if (!enabledListingTypes.includes(mapping.type))
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (mapping.kind === "category" && enabledCategoryTypes) {
|
|
41
|
+
if (!enabledCategoryTypes.includes(mapping.type))
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Hide a tab only when its count is known and explicitly zero — a tab
|
|
46
|
+
// whose count was never fetched (undefined) stays visible so we don't
|
|
47
|
+
// silently hide a listing type this page hasn't wired count-tracking
|
|
48
|
+
// for yet.
|
|
49
|
+
const count = countFor(t.id);
|
|
50
|
+
return count === undefined || count > 0;
|
|
51
|
+
});
|
|
52
|
+
const firstTabId = (visibleTabs[0]?.id ?? "products");
|
|
53
|
+
const [activeTab, setActiveTab] = useState(firstTabId);
|
|
49
54
|
return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), activeTab === "products" && (_jsx(CategoryProductsListing, { categorySlug: categorySlug, categoryId: categoryId, initialData: initialProductsData })), activeTab === "auctions" && (_jsx(AuctionsIndexListing, { categorySlug: categorySlug })), activeTab === "pre-orders" && (_jsx(PreOrdersIndexListing, { categorySlug: categorySlug })), activeTab === "prize-draws" && (_jsx(PrizeDrawsIndexListing, { categorySlug: categorySlug })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles })), activeTab === "stores" && (_jsx(CategoryStoresListing, { stores: initialStores }))] }));
|
|
50
55
|
}
|
|
@@ -111,14 +111,24 @@ export async function StoreDetailLayoutView({ storeSlug, activeTab, children, sc
|
|
|
111
111
|
live: "live",
|
|
112
112
|
};
|
|
113
113
|
const visibleStoreTabs = STORE_PAGE_TABS.filter((tab) => {
|
|
114
|
-
if (tab.id === "bundles")
|
|
115
|
-
|
|
114
|
+
if (tab.id === "bundles") {
|
|
115
|
+
if (!isCategoryTypeEnabled("bundle", settings))
|
|
116
|
+
return false;
|
|
117
|
+
return listingCounts[tab.id] > 0;
|
|
118
|
+
}
|
|
116
119
|
// Combined tab — visible if either underlying listing type is enabled.
|
|
117
120
|
if (tab.id === "art") {
|
|
118
|
-
|
|
121
|
+
if (!(isListingTypeEnabled("art", settings) || isListingTypeEnabled("stickers", settings)))
|
|
122
|
+
return false;
|
|
123
|
+
return listingCounts[tab.id] > 0;
|
|
119
124
|
}
|
|
120
125
|
const lt = TAB_LISTING_TYPE[tab.id];
|
|
121
|
-
|
|
126
|
+
if (lt && !isListingTypeEnabled(lt, settings))
|
|
127
|
+
return false;
|
|
128
|
+
// A store with zero items of a given listing type shouldn't offer a tab
|
|
129
|
+
// that leads to an empty page — matches the "hide empty tab" rule below
|
|
130
|
+
// for coupons/reviews.
|
|
131
|
+
return listingCounts[tab.id] > 0;
|
|
122
132
|
});
|
|
123
133
|
const dropdownTabs = visibleStoreTabs.map((tab) => ({
|
|
124
134
|
value: tab.id,
|
|
@@ -126,8 +136,12 @@ export async function StoreDetailLayoutView({ storeSlug, activeTab, children, sc
|
|
|
126
136
|
href: STORE_LISTING_HREF[tab.id](storeSlug),
|
|
127
137
|
}));
|
|
128
138
|
const tabs = [
|
|
129
|
-
|
|
130
|
-
|
|
139
|
+
...(couponsCount > 0
|
|
140
|
+
? [{ value: "coupons", label: tabLabel("Coupons", couponsCount), href: String(ROUTES.PUBLIC.STORE_COUPONS(storeSlug)) }]
|
|
141
|
+
: []),
|
|
142
|
+
...(reviewsCount > 0
|
|
143
|
+
? [{ value: "reviews", label: tabLabel("Reviews", reviewsCount), href: String(ROUTES.PUBLIC.STORE_REVIEWS(storeSlug)) }]
|
|
144
|
+
: []),
|
|
131
145
|
{ value: "about", label: "About", href: String(ROUTES.PUBLIC.STORE_ABOUT(storeSlug)) },
|
|
132
146
|
];
|
|
133
147
|
return (_jsxs(Main, { children: [_jsx(StoreHeader, { store: store, trust: trust }), _jsxs(Container, { size: "xl", className: "mt-6", children: [_jsx(StoreNavTabs, { dropdownTabs: dropdownTabs, dropdownPlaceholder: "Browse listings", tabs: tabs, activeValue: activeTab }), _jsx(Section, { padding: "t-lg", children: children })] })] }));
|
|
@@ -128,6 +128,38 @@ const rawTesterChecklistItems = [
|
|
|
128
128
|
description: "Verify against the seeded fixtures: \"Test Collectible — Sold Out\" (standard, hidden until \"Show sold\" is on), \"Test Auction — Already Won\" (hidden until \"Show ended\" is on), \"Test Prize Draw — Already Closed\" (hidden until \"Show closed\" is on). All three should be genuinely absent by default, not just from an unrelated broken query.",
|
|
129
129
|
href: "/products",
|
|
130
130
|
},
|
|
131
|
+
{
|
|
132
|
+
key: "auctions-show-ended-off-shows-live",
|
|
133
|
+
label: "With \"Show ended\" off (the default), the Auctions listing shows LIVE auctions — not empty, and not requiring the toggle to see anything",
|
|
134
|
+
description: "Fixed 2026-08-20 — the bounded fetch behind the \"unsafe filter\" workaround used to be sorted by the same field the date filter was about to reject on (auctionEndDate ASC = oldest/most-ended first), so once a store accumulated enough already-ended auctions the entire batch could be all-ended and live ones never got fetched at all — you had to turn \"Show ended\" ON to see anything, including live auctions. Load /products?listingType=auction fresh with the toggle off and confirm live auctions appear without touching the toggle.",
|
|
135
|
+
href: "/products",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: "auctions-show-ended-with-nondefault-sort",
|
|
139
|
+
label: "Switching the Auctions sort to something other than \"Ending Soon\" (e.g. \"Highest Current Bid\") while \"Show ended\" stays off still shows live auctions, correctly sorted by the chosen field",
|
|
140
|
+
description: "Same root cause as auctions-show-ended-off-shows-live, but for the case where the sort field doesn't match the date field being filtered on — a separate code path (in-memory re-sort after filtering) that needs its own check.",
|
|
141
|
+
href: "/products",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: "sort-options-per-listing-type",
|
|
145
|
+
label: "Every sort dropdown option (Price, Newest, Ending Soon, Highest/Lowest Bid, Most Bids, Delivery Date, etc.) actually reorders the results on Products, Auctions, and Pre-Orders listing pages",
|
|
146
|
+
href: "/products",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: "filter-drawer-combines-correctly",
|
|
150
|
+
label: "Applying multiple filters together (price range + brand + category + condition) narrows results correctly, and clearing filters restores the full list — on Products, Auctions, and Pre-Orders",
|
|
151
|
+
href: "/products",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
key: "search-filter-sort-combo",
|
|
155
|
+
label: "Typing a search query, then applying a filter, then changing sort — all three stay applied together and pagination reflects the combined result count (not just the last action applied)",
|
|
156
|
+
href: "/products",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
key: "listing-toggles-persist-across-pagination",
|
|
160
|
+
label: "Toggling \"Show sold\"/\"Show ended\"/\"Show closed\" and then navigating to page 2 keeps the toggle state — page 2 doesn't silently reset back to hiding those items",
|
|
161
|
+
href: "/products",
|
|
162
|
+
},
|
|
131
163
|
],
|
|
132
164
|
},
|
|
133
165
|
{
|
|
@@ -728,6 +760,12 @@ const rawTesterChecklistItems = [
|
|
|
728
760
|
cases: [
|
|
729
761
|
{ key: "store-directory", label: "The store directory page loads correctly", href: "/stores" },
|
|
730
762
|
{ key: "store-detail-tabs", label: "A store detail page's listing-type dropdown (Products/Auctions/Pre-Orders/Prize Draws/Bundles/Classifieds/Digital Codes/Live Items/Art & Stickers) switches correctly between listing types, and the separate Coupons/Reviews/About tabs next to it all load correctly", description: "The listing-type dropdown is always a dropdown (not just on narrow/mobile widths, unlike category/brand/product/event tabs) since a store can have up to 9 listing types — Coupons, Reviews, and About stay as standalone tabs beside it, never folded into the dropdown." },
|
|
763
|
+
{
|
|
764
|
+
key: "empty-tabs-hidden",
|
|
765
|
+
label: "A store/category/brand detail page never shows a tab for a listing type it has zero items of — e.g. a store with no products doesn't show a \"Products\" tab at all, not an empty products page",
|
|
766
|
+
description: "Fixed 2026-08-20 — tab visibility now checks the already-fetched per-type count and omits the tab entirely when it's zero, instead of always rendering all tabs regardless of whether they'd show anything. \"About\" always stays visible on store pages (no item-count concept). Verify on a real store/category/brand that's genuinely missing at least one listing type.",
|
|
767
|
+
href: "/stores",
|
|
768
|
+
},
|
|
731
769
|
{ key: "sellers-directory", label: "The sellers directory page loads correctly", href: "/sellers" },
|
|
732
770
|
{ key: "seller-detail-page", label: "An individual seller's public detail page loads correctly" },
|
|
733
771
|
{ key: "scams-registry", label: "The scams registry page and an individual scam detail page load correctly", href: "/scams" },
|