@mohasinac/appkit 4.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/_internal/client/features/layout/useSidebarSearch.d.ts +19 -0
  2. package/dist/_internal/client/features/layout/useSidebarSearch.js +21 -0
  3. package/dist/_internal/server/features/auth/permissions.d.ts +2 -0
  4. package/dist/_internal/server/features/auth/permissions.js +4 -2
  5. package/dist/_internal/server/features/seo/sitemap.js +3 -2
  6. package/dist/client.d.ts +1 -1
  7. package/dist/client.js +1 -1
  8. package/dist/features/about/components/FAQPageView.d.ts +4 -1
  9. package/dist/features/about/components/FAQPageView.js +17 -7
  10. package/dist/features/account/components/UserSidebar.js +15 -9
  11. package/dist/features/admin/components/AdminSidebar.js +15 -9
  12. package/dist/features/admin/components/AdminTesterChecklistItemEditorView.js +4 -1
  13. package/dist/features/admin/components/AdminUserEditorView.d.ts +3 -1
  14. package/dist/features/admin/components/AdminUserEditorView.js +10 -2
  15. package/dist/features/admin/components/AdminUsersView.js +1 -1
  16. package/dist/features/auth/role-predicates.d.ts +14 -0
  17. package/dist/features/auth/role-predicates.js +1 -0
  18. package/dist/features/auth/schemas/firestore.d.ts +2 -0
  19. package/dist/features/auth/schemas/firestore.js +1 -0
  20. package/dist/features/events/actions/event-actions.js +15 -8
  21. package/dist/features/events/repository/events.repository.d.ts +2 -0
  22. package/dist/features/events/repository/events.repository.js +9 -0
  23. package/dist/features/events/schemas/firestore.d.ts +1 -0
  24. package/dist/features/events/schemas/firestore.js +1 -0
  25. package/dist/features/faq/components/FAQSearchableList.d.ts +11 -0
  26. package/dist/features/faq/components/FAQSearchableList.js +21 -0
  27. package/dist/features/homepage/components/BeforeAfterCard.js +2 -1
  28. package/dist/features/homepage/components/BrandsSection.js +2 -1
  29. package/dist/features/homepage/components/CharacterHotspot.js +2 -1
  30. package/dist/features/homepage/components/CharacterHotspotForm.js +4 -3
  31. package/dist/features/homepage/components/HeroBanner.js +2 -1
  32. package/dist/features/homepage/components/PromoGrid.js +2 -1
  33. package/dist/features/homepage/components/ShopByCategorySection.js +2 -1
  34. package/dist/features/seller/components/SellerSidebar.js +15 -9
  35. package/dist/features/tester/actions/checklist-item-actions.d.ts +6 -0
  36. package/dist/features/tester/actions/checklist-item-actions.js +2 -0
  37. package/dist/features/tester/components/TesterHubView.js +5 -4
  38. package/dist/features/tester/schemas/firestore.d.ts +3 -1
  39. package/dist/features/tester/schemas/firestore.js +2 -0
  40. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +159 -6
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/next/api/routeHandler.js +25 -3
  44. package/dist/react/contexts/SessionContext.d.ts +2 -0
  45. package/dist/react/contexts/SessionContext.js +2 -0
  46. package/dist/styles.css +1 -1
  47. package/dist/tailwind-utilities.css +1 -1
  48. package/package.json +1 -1
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared inline-filter logic for the Admin/Store/User sidebars — lowercase
3
+ * substring match against item labels, collapsing to only groups with a
4
+ * surviving match. Each sidebar keeps its own accordion/markup; only this
5
+ * pure filtering algorithm is shared so it isn't rewritten three times.
6
+ */
7
+ export declare function useSidebarSearch<Item extends {
8
+ href: string;
9
+ label: string;
10
+ }, Group extends {
11
+ title: string;
12
+ items: Item[];
13
+ defaultOpen?: boolean;
14
+ }>(groups: Group[]): {
15
+ query: string;
16
+ setQuery: import("react").Dispatch<import("react").SetStateAction<string>>;
17
+ isSearching: boolean;
18
+ filteredGroups: Group[];
19
+ };
@@ -0,0 +1,21 @@
1
+ "use client";
2
+ import { useMemo, useState } from "react";
3
+ /**
4
+ * Shared inline-filter logic for the Admin/Store/User sidebars — lowercase
5
+ * substring match against item labels, collapsing to only groups with a
6
+ * surviving match. Each sidebar keeps its own accordion/markup; only this
7
+ * pure filtering algorithm is shared so it isn't rewritten three times.
8
+ */
9
+ export function useSidebarSearch(groups) {
10
+ const [query, setQuery] = useState("");
11
+ const isSearching = query.trim().length > 0;
12
+ const filteredGroups = useMemo(() => {
13
+ if (!isSearching)
14
+ return groups;
15
+ const q = query.trim().toLowerCase();
16
+ return groups
17
+ .map((group) => ({ ...group, items: group.items.filter((item) => item.label.toLowerCase().includes(q)) }))
18
+ .filter((group) => group.items.length > 0);
19
+ }, [groups, query, isSearching]);
20
+ return { query, setQuery, isSearching, filteredGroups };
21
+ }
@@ -26,6 +26,8 @@ export declare function checkAnyPermission(resolved: ResolvedPermissions, requir
26
26
  type GetUser = () => Promise<{
27
27
  uid: string;
28
28
  role: string;
29
+ isTester?: boolean;
30
+ canTestAdmin?: boolean;
29
31
  } | null>;
30
32
  export interface AdminSectionLayoutOpts {
31
33
  getUser: GetUser;
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { userRepository } from "../../../../repositories";
12
12
  import { ROUTES } from "../../../../next/routing/route-map";
13
+ import { isEffectiveAdminUser } from "../../../../features/auth/role-predicates";
13
14
  // ── Core resolver ─────────────────────────────────────────────────────────────
14
15
  /**
15
16
  * Fetch and resolve permissions for a user.
@@ -59,8 +60,9 @@ export function makeAdminSectionLayout(permission, opts) {
59
60
  redirect(opts.loginPath ?? String(ROUTES.AUTH.LOGIN));
60
61
  return null;
61
62
  }
62
- // admin role passes without a permission check
63
- if (user.role === "admin")
63
+ // admin role, or a tester explicitly flagged to test admin areas
64
+ // (isTester && canTestAdmin), passes without a permission check
65
+ if (isEffectiveAdminUser(user))
64
66
  return children;
65
67
  // non-employee non-admin roles have no business in /admin
66
68
  if (user.role !== "employee") {
@@ -113,13 +113,14 @@ async function fetchEventUrls(baseUrl) {
113
113
  const snap = await db
114
114
  .collection(EVENTS_COLLECTION)
115
115
  .where(EVENT_FIELDS.STATUS, "==", EVENT_FIELDS.STATUS_VALUES.ACTIVE)
116
- .select(EVENT_FIELDS.UPDATED_AT)
116
+ .select(EVENT_FIELDS.UPDATED_AT, EVENT_FIELDS.SLUG)
117
117
  .limit(500)
118
118
  .get();
119
119
  return snap.docs.map((doc) => {
120
120
  const data = doc.data();
121
+ const slug = typeof data[EVENT_FIELDS.SLUG] === "string" ? data[EVENT_FIELDS.SLUG] : undefined;
121
122
  return {
122
- url: `${baseUrl}${ROUTES.PUBLIC.EVENT_DETAIL(doc.id)}`,
123
+ url: `${baseUrl}${ROUTES.PUBLIC.EVENT_DETAIL(slug ?? doc.id)}`,
123
124
  lastModified: data[EVENT_FIELDS.UPDATED_AT]?.toDate?.() ?? new Date(),
124
125
  changeFrequency: "daily",
125
126
  priority: 0.7,
package/dist/client.d.ts CHANGED
@@ -301,7 +301,7 @@ export { actionTracker, setActionTrackerSink, resetActionTrackerSink, type Actio
301
301
  export { cartRequiresShipping, cartIsDigitalOnly, cartIsChatOnly, } from "./_internal/shared/listing-types/cart-shipping";
302
302
  export { ACTIONS, action, act, canPerformAction, actionsForListingType, actionLabel, type ActionDef, type ActionKind, type ActionResource, type ActionTree, type ActionConfirmation, } from "./_internal/shared/actions/action-registry";
303
303
  export { buildBulkAction } from "./_internal/shared/actions/bulk-helpers";
304
- export { isAdminUser, isSellerUser, isModeratorUser, isEmployeeUser, isBuyerUser, } from "./features/auth/role-predicates";
304
+ export { isAdminUser, isSellerUser, isModeratorUser, isEmployeeUser, isBuyerUser, isEffectiveAdminUser, } from "./features/auth/role-predicates";
305
305
  export { DashboardLayoutClient, RoleGuard } from "./_internal/client/features/layout/index";
306
306
  export type { DashboardLayoutClientProps, RoleGuardProps, } from "./_internal/client/features/layout/index";
307
307
  export type { LayoutBreakpoint, DashboardVariant, LayoutRole, SidebarNavItem, SidebarNavGroup, MainNavItem as LayoutMainNavItem, BrandingConfig, FooterConfig, SectionResponsive, SectionTheming, LayoutConfig, DashboardLayoutConfig, } from "./_internal/shared/features/layout/index";
package/dist/client.js CHANGED
@@ -304,7 +304,7 @@ export { cartRequiresShipping, cartIsDigitalOnly, cartIsChatOnly, } from "./_int
304
304
  export { ACTIONS, action, act, canPerformAction, actionsForListingType, actionLabel, } from "./_internal/shared/actions/action-registry";
305
305
  export { buildBulkAction } from "./_internal/shared/actions/bulk-helpers";
306
306
  // SB-UNI-E user-role predicates (pure functions, client-safe).
307
- export { isAdminUser, isSellerUser, isModeratorUser, isEmployeeUser, isBuyerUser, } from "./features/auth/role-predicates";
307
+ export { isAdminUser, isSellerUser, isModeratorUser, isEmployeeUser, isBuyerUser, isEffectiveAdminUser, } from "./features/auth/role-predicates";
308
308
  // Layout feature — client islands (unifies admin/store/user dashboard layouts).
309
309
  export { DashboardLayoutClient, RoleGuard } from "./_internal/client/features/layout/index";
310
310
  // Listing-type capability registry — SB-UNI X1.
@@ -1,5 +1,8 @@
1
+ import type { FAQDocument } from "../../faq/schemas/firestore";
1
2
  export interface FAQPageViewProps {
2
3
  /** If provided, filter to this category slug */
3
4
  category?: string;
5
+ /** Active FAQs fetched server-side by the caller (Firestore-backed). */
6
+ faqs?: FAQDocument[];
4
7
  }
5
- export declare function FAQPageView({ category, }?: FAQPageViewProps): Promise<import("react").JSX.Element>;
8
+ export declare function FAQPageView({ category, faqs, }?: FAQPageViewProps): Promise<import("react").JSX.Element>;
@@ -1,10 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ROUTES } from "../../../constants";
3
3
  import { FLEX_ROW } from "../../../_internal/shared/styles/themed";
4
- import { Aside, Container, Details, Div, Heading, Nav, RichTextRenderer, Row, Section, Span, Stack, Summary, Text } from "../../../ui";
4
+ import { Aside, Container, Div, Heading, Nav, Row, Section, Span, Stack, Text } from "../../../ui";
5
5
  import { TextLink } from "../../../ui";
6
6
  import { HelpCircle, ChevronRight } from "lucide-react";
7
- export async function FAQPageView({ category, } = {}) {
7
+ import { FAQSearchableList } from "../../faq/components/FAQSearchableList";
8
+ function toDisplayFaq(doc) {
9
+ return {
10
+ id: doc.id,
11
+ question: doc.question,
12
+ answer: typeof doc.answer === "string" ? { text: doc.answer, format: "plain" } : doc.answer,
13
+ category: doc.category,
14
+ tags: doc.tags,
15
+ };
16
+ }
17
+ export async function FAQPageView({ category, faqs = [], } = {}) {
8
18
  const flex = { row: FLEX_ROW };
9
19
  const { getTranslations, getMessages } = await import("next-intl/server");
10
20
  const t = await getTranslations("faqs");
@@ -13,10 +23,10 @@ export async function FAQPageView({ category, } = {}) {
13
23
  const categories = Array.isArray(faqMessages?.categories)
14
24
  ? faqMessages.categories
15
25
  : [];
16
- const allItems = Array.isArray(faqMessages?.items) ? faqMessages.items : [];
17
- const visibleItems = category
18
- ? allItems.filter((item) => item.category === category)
19
- : allItems;
26
+ const visibleFaqs = (category ? faqs.filter((f) => f.category === category) : faqs).map(toDisplayFaq);
20
27
  const activeCategory = categories.find((c) => c.slug === category);
21
- return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [_jsx(Section, { color: "inverse", tone: "accent-banner", padding: "banner", children: _jsxs(Container, { size: "md", className: "text-center", children: [_jsx(Heading, { color: "inverse", level: 1, variant: "none", className: "mb-3", children: activeCategory ? activeCategory.label : t("title") }), _jsx(Text, { color: "inverse", variant: "none", className: "/80 max-w-2xl mx-auto", children: t("subtitle") })] }) }), _jsx(Container, { size: "md", padding: "content-banner", children: _jsxs(Stack, { direction: "md-row", gap: "xl", children: [categories.length > 0 && (_jsxs(Aside, { className: "md:w-56 flex-shrink-0", children: [_jsx(Heading, { level: 3, className: "uppercase tracking-wide mb-3", color: "muted", size: "sm", weight: "semibold", children: t("categoriesLabel") }), _jsxs(Nav, { "aria-label": "FAQ categories", spacing: "xs", children: [_jsxs(TextLink, { rounded: "lg", href: String(ROUTES.PUBLIC.FAQS), paddingX: "sm", paddingY: "xs", layout: "flex", align: "center", justify: "between", weight: !category ? "semibold" : undefined, className: `transition-colors ${!category ? "bg-primary/10 text-primary" : "hover:bg-[var(--appkit-color-surface)] hover:bg-[var(--appkit-color-surface-elevated)]"}`, size: "sm", children: [_jsxs(Span, { className: `${flex.row}`, gap: "md", children: [_jsx(HelpCircle, { className: "w-4 h-4" }), t("allCategories")] }), _jsx(ChevronRight, { className: "w-3.5 h-3.5 opacity-50" })] }), categories.map((cat) => (_jsxs(TextLink, { rounded: "lg", href: String(ROUTES.PUBLIC.FAQ_CATEGORY(cat.slug)), paddingX: "sm", paddingY: "xs", layout: "flex", align: "center", justify: "between", weight: category === cat.slug ? "semibold" : undefined, className: `transition-colors ${category === cat.slug ? "bg-primary/10 text-primary" : "hover:bg-[var(--appkit-color-surface)] hover:bg-[var(--appkit-color-surface-elevated)]"}`, size: "sm", children: [_jsxs(Span, { className: `${flex.row}`, gap: "md", children: [_jsx(Span, { children: cat.icon }), cat.label] }), _jsx(ChevronRight, { className: "w-3.5 h-3.5 opacity-50" })] }, cat.slug)))] })] })), _jsxs(Div, { className: "flex-1 min-w-0", children: [visibleItems.length === 0 ? (_jsxs(Section, { className: `text-center`, border: "default", surface: "subtle", rounded: "2xl", padding: "y-4xl", children: [_jsx(HelpCircle, { className: "w-10 h-10 mx-auto mb-3 text-zinc-300 dark:text-[var(--appkit-color-text-muted)]" }), _jsx(Heading, { level: 3, className: "mb-2", size: "base", children: t("emptyTitle") }), _jsx(Text, { variant: "secondary", size: "sm", children: t("emptyText") })] })) : (_jsx(Stack, { gap: "sm", children: visibleItems.map((item, i) => (_jsxs(Details, { tone: "card", className: "group overflow-hidden", children: [_jsxs(Summary, { paddingX: "x-5", paddingY: "y-md", size: "sm", weight: "medium", layout: "flex", align: "center", justify: "between", className: "hover:bg-neutral-50 hover:bg-[var(--appkit-color-surface-elevated)]/50 transition-colors", children: [_jsx(Span, { children: item.question }), _jsx(ChevronRight, { className: "w-4 h-4 flex-shrink-0 ml-3 transition-transform group-open:rotate-90" })] }), _jsx(Div, { padding: "x-md", paddingY: "b-md-lg", className: "pt-[0.25rem]", children: _jsx(RichTextRenderer, { html: item.answer, proseClass: "prose prose-sm max-w-none dark:prose-invert" }) })] }, i))) })), _jsxs(Section, { className: `mt-10 text-center`, border: "default", surface: "subtle", rounded: "2xl", padding: "lg", children: [_jsx(Heading, { level: 3, className: "mb-2", size: "base", children: t("stillNeedHelpTitle") }), _jsx(Text, { variant: "secondary", className: "mb-4", size: "sm", children: t("stillNeedHelpText") }), _jsxs(Row, { align: "center", justify: "center", gap: "md", wrap: true, children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.HELP), children: t("helpCenter") }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.CONTACT), variant: "muted", children: t("contactUs") })] })] })] })] }) })] }));
28
+ return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [_jsx(Section, { color: "inverse", tone: "accent-banner", padding: "banner", children: _jsxs(Container, { size: "md", className: "text-center", children: [_jsx(Heading, { color: "inverse", level: 1, variant: "none", className: "mb-3", children: activeCategory ? activeCategory.label : t("title") }), _jsx(Text, { color: "inverse", variant: "none", className: "/80 max-w-2xl mx-auto", children: t("subtitle") })] }) }), _jsx(Container, { size: "md", padding: "content-banner", children: _jsxs(Stack, { direction: "md-row", gap: "xl", children: [categories.length > 0 && (_jsxs(Aside, { className: "md:w-56 flex-shrink-0", children: [_jsx(Heading, { level: 3, className: "uppercase tracking-wide mb-3", color: "muted", size: "sm", weight: "semibold", children: t("categoriesLabel") }), _jsxs(Nav, { "aria-label": "FAQ categories", spacing: "xs", children: [_jsxs(TextLink, { rounded: "lg", href: String(ROUTES.PUBLIC.FAQS), paddingX: "sm", paddingY: "xs", layout: "flex", align: "center", justify: "between", weight: !category ? "semibold" : undefined, className: `transition-colors ${!category ? "bg-primary/10 text-primary" : "hover:bg-[var(--appkit-color-surface)] hover:bg-[var(--appkit-color-surface-elevated)]"}`, size: "sm", children: [_jsxs(Span, { className: `${flex.row}`, gap: "md", children: [_jsx(HelpCircle, { className: "w-4 h-4" }), t("allCategories")] }), _jsx(ChevronRight, { className: "w-3.5 h-3.5 opacity-50" })] }), categories.map((cat) => (_jsxs(TextLink, { rounded: "lg", href: String(ROUTES.PUBLIC.FAQ_CATEGORY(cat.slug)), paddingX: "sm", paddingY: "xs", layout: "flex", align: "center", justify: "between", weight: category === cat.slug ? "semibold" : undefined, className: `transition-colors ${category === cat.slug ? "bg-primary/10 text-primary" : "hover:bg-[var(--appkit-color-surface)] hover:bg-[var(--appkit-color-surface-elevated)]"}`, size: "sm", children: [_jsxs(Span, { className: `${flex.row}`, gap: "md", children: [_jsx(Span, { children: cat.icon }), cat.label] }), _jsx(ChevronRight, { className: "w-3.5 h-3.5 opacity-50" })] }, cat.slug)))] })] })), _jsxs(Div, { className: "flex-1 min-w-0", children: [visibleFaqs.length === 0 ? (_jsxs(Section, { className: `text-center`, border: "default", surface: "subtle", rounded: "2xl", padding: "y-4xl", children: [_jsx(HelpCircle, { className: "w-10 h-10 mx-auto mb-3 text-zinc-300 dark:text-[var(--appkit-color-text-muted)]" }), _jsx(Heading, { level: 3, className: "mb-2", size: "base", children: t("emptyTitle") }), _jsx(Text, { variant: "secondary", size: "sm", children: t("emptyText") })] })) : (_jsx(FAQSearchableList, { faqs: visibleFaqs, labels: {
29
+ searchPlaceholder: t("searchPlaceholder"),
30
+ noResults: t("searchNoResults"),
31
+ } })), _jsxs(Section, { className: `mt-10 text-center`, border: "default", surface: "subtle", rounded: "2xl", padding: "lg", children: [_jsx(Heading, { level: 3, className: "mb-2", size: "base", children: t("stillNeedHelpTitle") }), _jsx(Text, { variant: "secondary", className: "mb-4", size: "sm", children: t("stillNeedHelpText") }), _jsxs(Row, { align: "center", justify: "center", gap: "md", wrap: true, children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.HELP), children: t("helpCenter") }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.CONTACT), variant: "muted", children: t("contactUs") })] })] })] })] }) })] }));
22
32
  }
@@ -4,9 +4,10 @@ import { useState, useCallback, useEffect } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import Link from "next/link";
6
6
  import { usePathname } from "next/navigation";
7
- import { Button, ConfirmDeleteModal, Div, IconButton, Li, Nav, Row, Span, Stack, Ul } from "../../../ui";
7
+ import { Button, ConfirmDeleteModal, Div, IconButton, Input, Li, Nav, Row, Span, Stack, Ul } from "../../../ui";
8
8
  import { BottomSheet } from "../../layout/BottomSheet";
9
9
  import { SidebarCollapseToggle } from "../../../_internal/client/features/layout/SidebarCollapseToggle";
10
+ import { useSidebarSearch } from "../../../_internal/client/features/layout/useSidebarSearch";
10
11
  const __O = {
11
12
  hidden: "overflow-hidden",
12
13
  yAuto: "overflow-y-auto",
@@ -41,20 +42,25 @@ function DrawerContent({ groups, items, activeHref, onItemClick, }) {
41
42
  const match = groups.find((g) => g.defaultOpen === true || g.items.some((i) => activeHref === i.href || activeHref.startsWith(i.href + "/")));
42
43
  return match?.title ?? null;
43
44
  });
44
- const toggle = useCallback((title) => setOpenGroup((prev) => (prev === title ? null : title)), []);
45
+ const { query, setQuery, isSearching, filteredGroups } = useSidebarSearch(groups ?? []);
46
+ const toggle = useCallback((title) => {
47
+ if (isSearching)
48
+ return;
49
+ setOpenGroup((prev) => (prev === title ? null : title));
50
+ }, [isSearching]);
45
51
  if (!groups || groups.length === 0) {
46
52
  return (_jsx(Nav, { "aria-label": "User navigation", padding: "y-sm", children: _jsx(Ul, { paddingX: "x-sm", spacing: "2xs", children: items.map((item) => {
47
53
  const isActive = activeHref === item.href || activeHref.startsWith(item.href + "/");
48
54
  return (_jsx(Li, { children: _jsx(NavLink, { item: item, isActive: isActive, onClick: onItemClick }) }, item.href));
49
55
  }) }) }));
50
56
  }
51
- return (_jsx(Nav, { "aria-label": "User navigation", padding: "y-xs", children: groups.map((group) => {
52
- const isOpen = openGroup === group.title;
53
- const hasActive = group.items.some((i) => activeHref === i.href || activeHref.startsWith(i.href + "/"));
54
- return (_jsxs(Div, { className: "mb-0.5", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => toggle(group.title), paddingX: "md", paddingY: "sm", weight: "semibold", rounded: "none", className: `w-full text-[0.6875rem] uppercase tracking-widest transition-colors ${hasActive && !isOpen
55
- ? "text-primary-600 dark:text-primary-400"
56
- : "text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]"}`, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { children: group.title }), _jsx("svg", { className: `w-3 h-3 transition-transform duration-150 ${isOpen ? "rotate-180" : ""}`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M19 9l-7 7-7-7" }) })] }) }), isOpen && (_jsx(Ul, { paddingX: "x-sm", paddingY: "y-bottom-xs", spacing: "2xs", children: group.items.map((item) => (_jsx(Li, { children: _jsx(NavLink, { item: item, isActive: isNavItemActive(item, activeHref), onClick: onItemClick }) }, item.href))) }))] }, group.title));
57
- }) }));
57
+ return (_jsxs(Nav, { "aria-label": "User navigation", padding: "y-xs", children: [_jsx(Div, { padding: "x-md", paddingY: "y-sm", children: _jsx(Input, { value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search navigation\u2026", "aria-label": "Search navigation" }) }), isSearching && filteredGroups.length === 0 && (_jsx(Div, { padding: "x-md", paddingY: "y-sm", children: _jsxs(Span, { size: "xs", color: "muted", children: ["No matches for \u201C", query, "\u201D."] }) })), filteredGroups.map((group) => {
58
+ const isOpen = isSearching || openGroup === group.title;
59
+ const hasActive = group.items.some((i) => activeHref === i.href || activeHref.startsWith(i.href + "/"));
60
+ return (_jsxs(Div, { className: "mb-0.5", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => toggle(group.title), paddingX: "md", paddingY: "sm", weight: "semibold", rounded: "none", className: `w-full text-[0.6875rem] uppercase tracking-widest transition-colors ${hasActive && !isOpen
61
+ ? "text-primary-600 dark:text-primary-400"
62
+ : "text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]"}`, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { children: group.title }), _jsx("svg", { className: `w-3 h-3 transition-transform duration-150 ${isOpen ? "rotate-180" : ""}`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M19 9l-7 7-7-7" }) })] }) }), isOpen && (_jsx(Ul, { paddingX: "x-sm", paddingY: "y-bottom-xs", spacing: "2xs", children: group.items.map((item) => (_jsx(Li, { children: _jsx(NavLink, { item: item, isActive: isNavItemActive(item, activeHref), onClick: onItemClick }) }, item.href))) }))] }, group.title));
63
+ })] }));
58
64
  }
59
65
  function DrawerPanel({ title, onClose, children, }) {
60
66
  return (_jsxs(Div, { className: "hidden lg:block", children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 z-40 backdrop-blur-sm", onClick: onClose, "aria-hidden": "true" }), _jsxs(Stack, { border: "default", shadow: "2xl", role: "dialog", "aria-modal": "true", "aria-label": title, className: "fixed top-0 right-0 z-50 h-full w-64 border-l", surface: "default", children: [_jsxs(Row, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", align: "center", justify: "between", children: [_jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: title }), _jsx(IconButton, { "aria-label": "Close", variant: "ghost", size: "sm", onClick: onClose, icon: _jsx("svg", { className: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: children })] })] }));
@@ -3,9 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useEffect, useState, useCallback } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import Link from "next/link";
6
- import { Button, Div, IconButton, Li, Nav, Row, Span, Stack, Ul } from "../../../ui";
6
+ import { Button, Div, IconButton, Input, Li, Nav, Row, Span, Stack, Ul } from "../../../ui";
7
7
  import { BottomSheet } from "../../layout/BottomSheet";
8
8
  import { SidebarCollapseToggle } from "../../../_internal/client/features/layout/SidebarCollapseToggle";
9
+ import { useSidebarSearch } from "../../../_internal/client/features/layout/useSidebarSearch";
9
10
  const __O = {
10
11
  hidden: "overflow-hidden",
11
12
  yAuto: "overflow-y-auto",
@@ -25,14 +26,19 @@ function GroupsContent({ groups, activePath, onItemClick, }) {
25
26
  const match = groups.find((g) => g.defaultOpen ?? g.items.some((i) => activePath === i.href || activePath.startsWith(i.href + "/")));
26
27
  return match?.title ?? null;
27
28
  });
28
- const toggle = useCallback((title) => setOpenGroup((p) => (p === title ? null : title)), []);
29
- return (_jsx(Nav, { "aria-label": "Admin navigation", padding: "y-xs", children: groups.map((group) => {
30
- const isOpen = openGroup === group.title;
31
- const hasActive = group.items.some((i) => activePath === i.href || activePath.startsWith(i.href + "/"));
32
- return (_jsxs(Div, { className: "mb-0.5", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => toggle(group.title), paddingX: "md", paddingY: "sm", weight: "semibold", rounded: "none", className: `w-full text-[0.6875rem] uppercase tracking-widest transition-colors ${hasActive && !isOpen
33
- ? "text-[var(--appkit-color-text-muted)]"
34
- : "text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]"}`, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { children: group.title }), _jsx("svg", { className: `w-3 h-3 transition-transform duration-150 ${isOpen ? "rotate-180" : ""}`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M19 9l-7 7-7-7" }) })] }) }), isOpen && (_jsx(Ul, { paddingX: "x-sm", paddingY: "y-bottom-xs", spacing: "2xs", children: group.items.map((item) => (_jsx(Li, { children: _jsx(NavLink, { item: item, isActive: isNavItemActive(item, activePath), onClick: onItemClick }) }, item.href))) }))] }, group.title));
35
- }) }));
29
+ const { query, setQuery, isSearching, filteredGroups } = useSidebarSearch(groups);
30
+ const toggle = useCallback((title) => {
31
+ if (isSearching)
32
+ return;
33
+ setOpenGroup((p) => (p === title ? null : title));
34
+ }, [isSearching]);
35
+ return (_jsxs(Nav, { "aria-label": "Admin navigation", padding: "y-xs", children: [_jsx(Div, { padding: "x-md", paddingY: "y-sm", children: _jsx(Input, { value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search navigation\u2026", "aria-label": "Search navigation" }) }), isSearching && filteredGroups.length === 0 && (_jsx(Div, { padding: "x-md", paddingY: "y-sm", children: _jsxs(Span, { size: "xs", color: "muted", children: ["No matches for \u201C", query, "\u201D."] }) })), filteredGroups.map((group) => {
36
+ const isOpen = isSearching || openGroup === group.title;
37
+ const hasActive = group.items.some((i) => activePath === i.href || activePath.startsWith(i.href + "/"));
38
+ return (_jsxs(Div, { className: "mb-0.5", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => toggle(group.title), paddingX: "md", paddingY: "sm", weight: "semibold", rounded: "none", className: `w-full text-[0.6875rem] uppercase tracking-widest transition-colors ${hasActive && !isOpen
39
+ ? "text-[var(--appkit-color-text-muted)]"
40
+ : "text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]"}`, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { children: group.title }), _jsx("svg", { className: `w-3 h-3 transition-transform duration-150 ${isOpen ? "rotate-180" : ""}`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M19 9l-7 7-7-7" }) })] }) }), isOpen && (_jsx(Ul, { paddingX: "x-sm", paddingY: "y-bottom-xs", spacing: "2xs", children: group.items.map((item) => (_jsx(Li, { children: _jsx(NavLink, { item: item, isActive: isNavItemActive(item, activePath), onClick: onItemClick }) }, item.href))) }))] }, group.title));
41
+ })] }));
36
42
  }
37
43
  function DrawerPanel({ title, onClose, children, }) {
38
44
  return (_jsxs(Div, { className: "hidden lg:block", children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 z-40 backdrop-blur-sm", onClick: onClose, "aria-hidden": "true" }), _jsxs(Stack, { border: "default", shadow: "2xl", role: "dialog", "aria-modal": "true", "aria-label": title, className: "fixed top-0 right-0 z-50 h-full w-64 border-l", surface: "default", children: [_jsxs(Row, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", align: "center", justify: "between", children: [_jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: title }), _jsx(IconButton, { "aria-label": "Close", variant: "ghost", size: "sm", onClick: onClose, icon: _jsx("svg", { className: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: children })] })] }));
@@ -40,6 +40,7 @@ export function AdminTesterChecklistItemEditorView({ itemId, onSaved, onDeleted,
40
40
  const [href, setHref] = React.useState("");
41
41
  const [order, setOrder] = React.useState(0);
42
42
  const [isActive, setIsActive] = React.useState(true);
43
+ const [adminOnly, setAdminOnly] = React.useState(false);
43
44
  const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
44
45
  const { showToast } = useToast();
45
46
  const itemQuery = useQuery({
@@ -63,6 +64,7 @@ export function AdminTesterChecklistItemEditorView({ itemId, onSaved, onDeleted,
63
64
  setHref(item.href ?? "");
64
65
  setOrder(typeof item.order === "number" ? item.order : 0);
65
66
  setIsActive(item.isActive ?? true);
67
+ setAdminOnly(item.adminOnly ?? false);
66
68
  }, [itemQuery.data]);
67
69
  const handleGroupLabelChange = (value) => {
68
70
  setGroupLabel(value);
@@ -86,6 +88,7 @@ export function AdminTesterChecklistItemEditorView({ itemId, onSaved, onDeleted,
86
88
  href: href || "",
87
89
  order,
88
90
  isActive,
91
+ adminOnly,
89
92
  };
90
93
  if (isEdit) {
91
94
  return apiClient.put(ADMIN_ENDPOINTS.TESTER_CHECKLIST_ITEM_BY_ID(itemId), payload);
@@ -113,7 +116,7 @@ export function AdminTesterChecklistItemEditorView({ itemId, onSaved, onDeleted,
113
116
  });
114
117
  const isSubmitting = saveMutation.isPending || itemQuery.isLoading;
115
118
  const canSave = Boolean(groupLabel.trim()) && Boolean(pageLabel.trim()) && Boolean(label.trim());
116
- const formSection = (_jsxs(_Fragment, { children: [_jsx(Form, { schema: checklistItemFormSchema, onSubmit: (e) => e.preventDefault(), spacing: "md", children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(FieldInput, { name: "groupLabel", label: "Group", value: groupLabel, onChange: handleGroupLabelChange, required: true, placeholder: "e.g. Buying", hint: "Top-level accordion section on the Tester Hub." }), _jsx(Input, { label: "Group key", value: groupKey, onChange: (e) => setGroupKey(e.target.value), placeholder: "buying", helperText: "Auto-generated from the group name." })] }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(FieldInput, { name: "pageLabel", label: "Page", value: pageLabel, onChange: handlePageLabelChange, required: true, placeholder: "e.g. Checkout", hint: "Sub-accordion within the group." }), _jsx(Input, { label: "Page key", value: pageKey, onChange: (e) => setPageKey(e.target.value), placeholder: "checkout", helperText: "Auto-generated from the page name." })] }), _jsx(FieldInput, { name: "label", label: "Test case (Yes/No question)", value: label, onChange: setLabel, required: true, placeholder: "e.g. Coupon discount is correctly reflected in the order total" }), _jsx(FieldTextarea, { name: "description", label: "Description (optional)", value: description, onChange: setDescription, placeholder: "Extra context for the tester." }), _jsx(FieldInput, { name: "href", label: "Deep link (optional)", value: href, onChange: setHref, placeholder: "/cart", hint: "Jumps the tester straight to the feature being tested." }), _jsx(Input, { label: "Display order", value: String(order), onChange: (e) => setOrder(parseInt(e.target.value, 10) || 0), type: "number", min: 0, helperText: "Lower = shown first within the page." }), _jsxs(Stack, { className: `${__P.p4}`, gap: "3", rounded: "lg", border: "default", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Visibility" }), _jsx(Toggle, { label: "Active (visible to testers)", checked: isActive, onChange: setIsActive })] }), _jsxs(Row, { gap: "3", padding: "t-xs", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: !canSave || isSubmitting, onClick: () => {
119
+ const formSection = (_jsxs(_Fragment, { children: [_jsx(Form, { schema: checklistItemFormSchema, onSubmit: (e) => e.preventDefault(), spacing: "md", children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(FieldInput, { name: "groupLabel", label: "Group", value: groupLabel, onChange: handleGroupLabelChange, required: true, placeholder: "e.g. Buying", hint: "Top-level accordion section on the Tester Hub." }), _jsx(Input, { label: "Group key", value: groupKey, onChange: (e) => setGroupKey(e.target.value), placeholder: "buying", helperText: "Auto-generated from the group name." })] }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(FieldInput, { name: "pageLabel", label: "Page", value: pageLabel, onChange: handlePageLabelChange, required: true, placeholder: "e.g. Checkout", hint: "Sub-accordion within the group." }), _jsx(Input, { label: "Page key", value: pageKey, onChange: (e) => setPageKey(e.target.value), placeholder: "checkout", helperText: "Auto-generated from the page name." })] }), _jsx(FieldInput, { name: "label", label: "Test case (Yes/No question)", value: label, onChange: setLabel, required: true, placeholder: "e.g. Coupon discount is correctly reflected in the order total" }), _jsx(FieldTextarea, { name: "description", label: "Description (optional)", value: description, onChange: setDescription, placeholder: "Extra context for the tester." }), _jsx(FieldInput, { name: "href", label: "Deep link (optional)", value: href, onChange: setHref, placeholder: "/cart", hint: "Jumps the tester straight to the feature being tested." }), _jsx(Input, { label: "Display order", value: String(order), onChange: (e) => setOrder(parseInt(e.target.value, 10) || 0), type: "number", min: 0, helperText: "Lower = shown first within the page." }), _jsxs(Stack, { className: `${__P.p4}`, gap: "3", rounded: "lg", border: "default", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Visibility" }), _jsx(Toggle, { label: "Active (visible to testers)", checked: isActive, onChange: setIsActive }), _jsx(Toggle, { label: "Admin-only (requires canTestAdmin)", checked: adminOnly, onChange: setAdminOnly })] }), _jsxs(Row, { gap: "3", padding: "t-xs", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: !canSave || isSubmitting, onClick: () => {
117
120
  clearErrors();
118
121
  if (!label.trim()) {
119
122
  setFieldError("label", "Test case label is required");
@@ -16,6 +16,8 @@ export interface AdminUserEditorViewProps {
16
16
  currentEmailVerified?: boolean;
17
17
  /** Tester program flag — orthogonal to role. Grants access to the Tester Hub and auto-approves the user's store. */
18
18
  currentIsTester?: boolean;
19
+ /** Orthogonal to isTester — grants real /admin/** RBAC access + admin-only checklist items. Meaningless unless currentIsTester is also true. */
20
+ currentCanTestAdmin?: boolean;
19
21
  /** Store the user owns (for sellers/admins). storeId === storeSlug in this project. */
20
22
  ownedStoreId?: string;
21
23
  ownedStoreName?: string;
@@ -35,5 +37,5 @@ export interface AdminUserEditorViewProps {
35
37
  linkedin?: string;
36
38
  };
37
39
  }
38
- export declare function AdminUserEditorView({ open, onClose, userId, displayName, currentRole, currentIsDisabled: _currentIsDisabled, currentEmailVerified, currentIsTester, ownedStoreId, ownedStoreName, currentSoftBans, currentIsHardBanned, currentHardBanReason, currentPhoneNumber, currentBio, currentLocation, currentWebsite, currentSocialLinks, }: AdminUserEditorViewProps): React.JSX.Element;
40
+ export declare function AdminUserEditorView({ open, onClose, userId, displayName, currentRole, currentIsDisabled: _currentIsDisabled, currentEmailVerified, currentIsTester, currentCanTestAdmin, ownedStoreId, ownedStoreName, currentSoftBans, currentIsHardBanned, currentHardBanReason, currentPhoneNumber, currentBio, currentLocation, currentWebsite, currentSocialLinks, }: AdminUserEditorViewProps): React.JSX.Element;
39
41
  export {};
@@ -41,13 +41,14 @@ function SoftBanPanel({ userId, softBans, showAddSoftBan, setShowAddSoftBan, sof
41
41
  return (_jsxs(Div, { surface: "muted", padding: "sm", rounded: "lg", border: "default", children: [_jsxs(Row, { justify: "between", gap: "sm", className: "mb-2", children: [_jsxs(Span, { size: "sm", weight: "medium", color: "muted", children: ["Soft bans", softBans.length > 0 ? ` (${softBans.length})` : ""] }), !showAddSoftBan && (_jsx(Button, { type: "button", variant: "secondary", size: "sm", disabled: !userId, onClick: () => setShowAddSoftBan(true), children: "Add soft ban" }))] }), softBans.length > 0 && (_jsx(Stack, { as: "ul", gap: "xs", className: "mb-3", children: softBans.map((ban) => (_jsxs(Row, { as: "li", align: "start", justify: "between", gap: "xs", surface: "default", padding: "inlineSm", rounded: "md", border: "default", children: [_jsxs(Stack, { gap: "none", className: "min-w-0 flex-1", children: [_jsx(Span, { size: "xs", weight: "semibold", children: formatBanAction(ban.action) }), _jsx(Span, { size: "xs", color: "muted", children: ban.reason }), _jsx(Span, { size: "xs", color: "muted", children: formatExpiry(ban.expiresAt) })] }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", isLoading: liftPending, disabled: liftPending, onClick: () => onLiftSoftBan(ban.action), children: "Lift" })] }, ban.action))) })), showAddSoftBan && (_jsxs(Stack, { gap: "xs", children: [_jsx(Select, { label: "Action to restrict", options: BANNED_ACTION_OPTIONS, value: softBanAction, onValueChange: setSoftBanAction }), _jsx(Textarea, { label: "Reason (required)", value: softBanReason, onChange: (e) => setSoftBanReason(e.target.value), rows: 2, placeholder: "e.g. Suspicious bid activity\u2026" }), _jsx(Input, { label: "Expires at (optional \u2014 leave blank for permanent)", type: "datetime-local", value: softBanExpiry, onChange: (e) => setSoftBanExpiry(e.target.value) }), _jsxs(Row, { gap: "xs", children: [_jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: softBanPending, disabled: !softBanReason.trim() || softBanPending, onClick: () => onAddSoftBan({ action: softBanAction, reason: softBanReason.trim(), ...(softBanExpiry ? { expiresAt: new Date(softBanExpiry).toISOString() } : {}) }), children: "Apply soft ban" }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => { setShowAddSoftBan(false); setSoftBanReason(""); setSoftBanExpiry(""); }, children: "Cancel" })] })] })), softBans.length === 0 && !showAddSoftBan && (_jsx(Text, { size: "xs", color: "muted", children: "No active soft bans." }))] }));
42
42
  }
43
43
  // --- Component ---------------------------------------------------------------
44
- export function AdminUserEditorView({ open, onClose, userId, displayName, currentRole, currentIsDisabled: _currentIsDisabled, currentEmailVerified, currentIsTester, ownedStoreId, ownedStoreName, currentSoftBans, currentIsHardBanned, currentHardBanReason, currentPhoneNumber, currentBio, currentLocation, currentWebsite, currentSocialLinks, }) {
44
+ export function AdminUserEditorView({ open, onClose, userId, displayName, currentRole, currentIsDisabled: _currentIsDisabled, currentEmailVerified, currentIsTester, currentCanTestAdmin, ownedStoreId, ownedStoreName, currentSoftBans, currentIsHardBanned, currentHardBanReason, currentPhoneNumber, currentBio, currentLocation, currentWebsite, currentSocialLinks, }) {
45
45
  const queryClient = useQueryClient();
46
46
  const { showToast } = useToast();
47
47
  // --- General fields -------------------------------------------------------
48
48
  const [role, setRole] = React.useState(currentRole ?? "user");
49
49
  const [emailVerified, setEmailVerified] = React.useState(currentEmailVerified ?? false);
50
50
  const [isTester, setIsTester] = React.useState(currentIsTester ?? false);
51
+ const [canTestAdmin, setCanTestAdmin] = React.useState(currentCanTestAdmin ?? false);
51
52
  const [adminNotes, setAdminNotes] = React.useState("");
52
53
  const [deleteOpen, setDeleteOpen] = React.useState(false);
53
54
  // --- ST-2 profile fields --------------------------------------------------
@@ -73,6 +74,7 @@ export function AdminUserEditorView({ open, onClose, userId, displayName, curren
73
74
  setRole(currentRole ?? "user");
74
75
  setEmailVerified(currentEmailVerified ?? false);
75
76
  setIsTester(currentIsTester ?? false);
77
+ setCanTestAdmin(currentCanTestAdmin ?? false);
76
78
  setAdminNotes("");
77
79
  setEditDisplayName(displayName ?? "");
78
80
  setPhoneNumber(currentPhoneNumber ?? "");
@@ -95,6 +97,7 @@ export function AdminUserEditorView({ open, onClose, userId, displayName, curren
95
97
  currentRole,
96
98
  currentEmailVerified,
97
99
  currentIsTester,
100
+ currentCanTestAdmin,
98
101
  displayName,
99
102
  currentPhoneNumber,
100
103
  currentBio,
@@ -133,6 +136,7 @@ export function AdminUserEditorView({ open, onClose, userId, displayName, curren
133
136
  role,
134
137
  emailVerified,
135
138
  isTester,
139
+ canTestAdmin: isTester ? canTestAdmin : false,
136
140
  adminNotes: adminNotes || undefined,
137
141
  displayName: editDisplayName.trim() || undefined,
138
142
  phoneNumber: phoneNumber.trim() || undefined,
@@ -223,7 +227,11 @@ export function AdminUserEditorView({ open, onClose, userId, displayName, curren
223
227
  const renderInfoCard = () => userId ? (_jsx(Div, { textSize: "xs", surface: "muted", rounded: "lg", border: "default", padding: "inlineSm", children: _jsxs(Stack, { color: "primary", gap: "xs", children: [_jsxs(Text, { size: "xs", children: [_jsx(Span, { weight: "semibold", children: "Owner ID (Firebase UID):" }), " ", _jsx(Code, { className: "select-all font-mono", children: userId })] }), ownedStoreId && (_jsxs(Text, { size: "xs", children: [_jsx(Span, { weight: "semibold", children: "Owns store:" }), " ", _jsx(Code, { className: "select-all font-mono", children: ownedStoreId }), ownedStoreName ? ` — ${ownedStoreName}` : ""] }))] }) })) : null;
224
228
  const renderRoleSection = () => (_jsx(Select, { label: "Role", options: ROLE_OPTIONS, value: role, onValueChange: setRole }));
225
229
  const renderEmailVerifiedSection = () => (_jsx(Toggle, { label: "Email verified", checked: emailVerified, onChange: setEmailVerified }));
226
- const renderIsTesterSection = () => (_jsx(Toggle, { label: "Is Tester", checked: isTester, onChange: setIsTester }));
230
+ const renderIsTesterSection = () => (_jsxs(Stack, { gap: "sm", children: [_jsx(Toggle, { label: "Is Tester", checked: isTester, onChange: (next) => {
231
+ setIsTester(next);
232
+ if (!next)
233
+ setCanTestAdmin(false);
234
+ } }), isTester && (_jsx(Toggle, { label: "Can Test Admin Areas", checked: canTestAdmin, onChange: setCanTestAdmin }))] }));
227
235
  const renderProfileSection = () => (_jsxs(Stack, { border: "default", as: "section", gap: "sm", className: "border-t", padding: "t-sm", children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Profile details" }), _jsx(Input, { label: "Display name", value: editDisplayName, onChange: (e) => setEditDisplayName(e.target.value), placeholder: "Full name shown on profile" }), _jsx(Input, { label: "Phone number", type: "tel", value: phoneNumber, onChange: (e) => setPhoneNumber(e.target.value), placeholder: "+91 90000 00000" }), _jsx(Textarea, { label: "Bio", value: bio, onChange: (e) => setBio(e.target.value), rows: 3, placeholder: "Short bio shown on the public profile\u2026" }), _jsx(Input, { label: "Location", value: location, onChange: (e) => setLocation(e.target.value), placeholder: "City, Country" }), _jsx(Input, { label: "Website", type: "url", value: website, onChange: (e) => setWebsite(e.target.value), placeholder: "https://" }), _jsx(Text, { size: "xs", color: "muted", children: "Social links" }), _jsx(Input, { label: "Twitter / X", value: twitter, onChange: (e) => setTwitter(e.target.value), placeholder: "@handle or full URL" }), _jsx(Input, { label: "Instagram", value: instagram, onChange: (e) => setInstagram(e.target.value), placeholder: "@handle or full URL" }), _jsx(Input, { label: "Facebook", value: facebook, onChange: (e) => setFacebook(e.target.value), placeholder: "https://facebook.com/\u2026" }), _jsx(Input, { label: "LinkedIn", value: linkedin, onChange: (e) => setLinkedin(e.target.value), placeholder: "https://linkedin.com/in/\u2026" })] }));
228
236
  const renderAdminNotesSection = () => (_jsx(Textarea, { label: "Admin notes (optional)", value: adminNotes, onChange: (e) => setAdminNotes(e.target.value), rows: 3, placeholder: "Internal notes about this user\u2026" }));
229
237
  const renderActionsSection = () => (_jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "danger", onClick: () => setDeleteOpen(true), disabled: !userId, children: "Delete user" }), _jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: !userId || saveMutation.isPending, children: "Save changes" })] }));
@@ -214,7 +214,7 @@ export function AdminUsersView({ children, ...props }) {
214
214
  },
215
215
  renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(_Fragment, { children: [_jsx(FilterChipGroup, { label: "Status", tabs: ADMIN_USER_STATUS_TABS, value: pendingFilters.status ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, status: id })) }), _jsx(FilterChipGroup, { label: "Role", tabs: ADMIN_USER_ROLE_TABS, value: pendingFilters.role ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, role: id })) })] })),
216
216
  };
217
- return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(AdminUserEditorView, { open: drawerOpen, onClose: () => setDrawerOpen(false), userId: selectedRow?.id, displayName: selectedRow?.primary, currentRole: toStringValue(selectedRow?._raw?.role, "user"), currentEmailVerified: Boolean(selectedRow?._raw?.emailVerified), currentIsTester: Boolean(selectedRow?._raw?.isTester), ownedStoreId: toStringValue(selectedRow?._raw?.storeId, "") || undefined, ownedStoreName: toStringValue(selectedRow?._raw?.storeName, "") || undefined, currentIsHardBanned: Boolean((selectedRow?._raw?.isDisabled ?? selectedRow?._raw?.disabled) &&
217
+ return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(AdminUserEditorView, { open: drawerOpen, onClose: () => setDrawerOpen(false), userId: selectedRow?.id, displayName: selectedRow?.primary, currentRole: toStringValue(selectedRow?._raw?.role, "user"), currentEmailVerified: Boolean(selectedRow?._raw?.emailVerified), currentIsTester: Boolean(selectedRow?._raw?.isTester), currentCanTestAdmin: Boolean(selectedRow?._raw?.canTestAdmin), ownedStoreId: toStringValue(selectedRow?._raw?.storeId, "") || undefined, ownedStoreName: toStringValue(selectedRow?._raw?.storeName, "") || undefined, currentIsHardBanned: Boolean((selectedRow?._raw?.isDisabled ?? selectedRow?._raw?.disabled) &&
218
218
  selectedRow?._raw?.hardBanReason), currentHardBanReason: toStringValue(selectedRow?._raw?.hardBanReason, "") || undefined, currentSoftBans: Array.isArray(selectedRow?._raw?.softBans)
219
219
  ? selectedRow._raw.softBans.map((b) => ({
220
220
  action: toStringValue(b.action, ""),
@@ -17,4 +17,18 @@ export declare const isSellerUser: (input: RoleCarrier) => boolean;
17
17
  export declare const isModeratorUser: (input: RoleCarrier) => boolean;
18
18
  export declare const isEmployeeUser: (input: RoleCarrier) => boolean;
19
19
  export declare const isBuyerUser: (input: RoleCarrier) => boolean;
20
+ /**
21
+ * True for real admins OR a tester explicitly flagged to test admin areas
22
+ * (`isTester && canTestAdmin`). Used at the two RBAC chokepoints — the API route
23
+ * guard (`createRouteHandler`) and the admin layout guard (`makeAdminSectionLayout`)
24
+ * — so a flagged tester gets real read/write access to `/admin/**` without changing
25
+ * their `role`. Callers must supply `isTester`/`canTestAdmin` from a live Firestore
26
+ * read; these are never present in the session-cookie JWT claims (only `role` is).
27
+ */
28
+ type EffectiveAdminCarrier = {
29
+ role?: string | null;
30
+ isTester?: boolean;
31
+ canTestAdmin?: boolean;
32
+ } | null | undefined;
33
+ export declare const isEffectiveAdminUser: (input: EffectiveAdminCarrier) => boolean;
20
34
  export {};
@@ -6,3 +6,4 @@ export const isSellerUser = (input) => normalizeRole(input) === "seller";
6
6
  export const isModeratorUser = (input) => normalizeRole(input) === "moderator";
7
7
  export const isEmployeeUser = (input) => normalizeRole(input) === "employee";
8
8
  export const isBuyerUser = (input) => normalizeRole(input) === "user";
9
+ export const isEffectiveAdminUser = (input) => isAdminUser(input) || Boolean(input?.isTester && input?.canTestAdmin);
@@ -50,6 +50,7 @@ export interface UserDocument extends BaseDocument {
50
50
  storeSlug?: string;
51
51
  storeStatus?: "pending" | "approved" | "rejected";
52
52
  isTester?: boolean;
53
+ canTestAdmin?: boolean;
53
54
  publicProfile?: {
54
55
  isPublic: boolean;
55
56
  showEmail: boolean;
@@ -222,6 +223,7 @@ export declare const USER_FIELDS: {
222
223
  readonly STORE_SLUG: "storeSlug";
223
224
  readonly STORE_STATUS: "storeStatus";
224
225
  readonly IS_TESTER: "isTester";
226
+ readonly CAN_TEST_ADMIN: "canTestAdmin";
225
227
  readonly AVATAR: {
226
228
  readonly URL: "avatarMetadata.url";
227
229
  readonly POSITION: "avatarMetadata.position";
@@ -110,6 +110,7 @@ export const USER_FIELDS = {
110
110
  STORE_SLUG: "storeSlug",
111
111
  STORE_STATUS: "storeStatus",
112
112
  IS_TESTER: "isTester",
113
+ CAN_TEST_ADMIN: "canTestAdmin",
113
114
  AVATAR: {
114
115
  URL: "avatarMetadata.url",
115
116
  POSITION: "avatarMetadata.position",
@@ -137,13 +137,16 @@ export async function listPublicEvents(params) {
137
137
  });
138
138
  }
139
139
  export async function getPublicEventById(id) {
140
- const event = await eventRepository.findById(id);
140
+ const event = await eventRepository.findByIdOrSlug(id);
141
141
  if (!event || event.status !== "active")
142
142
  return null;
143
143
  return event;
144
144
  }
145
145
  export async function getEventLeaderboard(eventId) {
146
- return eventEntryRepository.getLeaderboard(eventId);
146
+ const event = await eventRepository.findByIdOrSlug(eventId);
147
+ if (!event)
148
+ return [];
149
+ return eventEntryRepository.getLeaderboard(event.id);
147
150
  }
148
151
  export async function adminListEvents(params) {
149
152
  const sieve = {
@@ -181,10 +184,14 @@ export async function adminGetEventStats(eventId) {
181
184
  }
182
185
  // --- Public: Enter Event ---------------------------------------------------
183
186
  export async function enterEvent(eventId, input, user) {
184
- const event = await eventRepository.findById(eventId);
187
+ const event = await eventRepository.findByIdOrSlug(eventId);
185
188
  if (!event || event.status !== "active") {
186
189
  throw new NotFoundError(ERROR_MESSAGES.EVENT.ENTRIES_CLOSED);
187
190
  }
191
+ // eventId may have arrived as a slug (public URLs link by slug) — resolve
192
+ // to the real doc ID once here and use it for every downstream write, so
193
+ // entries/increments always land against the actual document.
194
+ const resolvedEventId = event.id;
188
195
  const now = new Date();
189
196
  const endsAt = resolveDate(event.endsAt);
190
197
  if (endsAt && now > endsAt) {
@@ -197,7 +204,7 @@ export async function enterEvent(eventId, input, user) {
197
204
  throw new AuthorizationError(ERROR_MESSAGES.EVENT.LOGIN_REQUIRED);
198
205
  }
199
206
  if (user && event.type === "survey" && event.surveyConfig) {
200
- const userEntryCount = await eventEntryRepository.countUserEntries(eventId, user.uid);
207
+ const userEntryCount = await eventEntryRepository.countUserEntries(resolvedEventId, user.uid);
201
208
  if (userEntryCount >= event.surveyConfig.maxEntriesPerUser) {
202
209
  throw new ValidationError(ERROR_MESSAGES.EVENT.ALREADY_ENTERED);
203
210
  }
@@ -231,7 +238,7 @@ export async function enterEvent(eventId, input, user) {
231
238
  !event.surveyConfig?.entryReviewRequired);
232
239
  const reviewStatus = autoApprove ? "approved" : "pending";
233
240
  const entry = await eventEntryRepository.createEntry({
234
- eventId,
241
+ eventId: resolvedEventId,
235
242
  userId: user?.uid,
236
243
  userDisplayName: user?.displayName,
237
244
  userEmail: user?.email,
@@ -240,13 +247,13 @@ export async function enterEvent(eventId, input, user) {
240
247
  formResponses: input.formResponses,
241
248
  reviewStatus,
242
249
  });
243
- await eventRepository.incrementTotalEntries(eventId);
250
+ await eventRepository.incrementTotalEntries(resolvedEventId);
244
251
  if (autoApprove) {
245
- await eventRepository.incrementApprovedEntries(eventId);
252
+ await eventRepository.incrementApprovedEntries(resolvedEventId);
246
253
  }
247
254
  serverLogger.info("enterEvent", {
248
255
  entryId: entry.id,
249
- eventId,
256
+ eventId: resolvedEventId,
250
257
  type: event.type,
251
258
  userId: user?.uid,
252
259
  });
@@ -5,6 +5,8 @@ declare class EventRepository extends BaseRepository<EventDocument> {
5
5
  static readonly SIEVE_FIELDS: FirebaseSieveFields;
6
6
  constructor();
7
7
  list(model: SieveModel): Promise<FirebaseSieveResult<EventDocument>>;
8
+ findBySlug(slug: string): Promise<EventDocument | null>;
9
+ findByIdOrSlug(idOrSlug: string): Promise<EventDocument | null>;
8
10
  listActive(): Promise<EventDocument[]>;
9
11
  createEvent(input: EventCreateInput): Promise<EventDocument>;
10
12
  updateEvent(id: string, input: EventUpdateInput): Promise<EventDocument>;
@@ -12,6 +12,15 @@ class EventRepository extends BaseRepository {
12
12
  async list(model) {
13
13
  return this.sieveQuery(model, EventRepository.SIEVE_FIELDS);
14
14
  }
15
+ async findBySlug(slug) {
16
+ return this.findOneBy(EVENT_FIELDS.SLUG, slug);
17
+ }
18
+ async findByIdOrSlug(idOrSlug) {
19
+ const bySlug = await this.findBySlug(idOrSlug);
20
+ if (bySlug)
21
+ return bySlug;
22
+ return this.findById(idOrSlug);
23
+ }
15
24
  async listActive() {
16
25
  try {
17
26
  const now = new Date();
@@ -84,6 +84,7 @@ export declare const EVENT_INDEXED_FIELDS: readonly ["type", "status", "startsAt
84
84
  export declare const EVENT_ENTRY_INDEXED_FIELDS: readonly ["eventId", "userId", "reviewStatus", "status", "submittedAt", "points"];
85
85
  export declare const EVENT_FIELDS: {
86
86
  readonly ID: "id";
87
+ readonly SLUG: "slug";
87
88
  readonly TYPE: "type";
88
89
  readonly TITLE: "title";
89
90
  readonly DESCRIPTION: "description";
@@ -20,6 +20,7 @@ export const EVENT_ENTRY_INDEXED_FIELDS = [
20
20
  ];
21
21
  export const EVENT_FIELDS = {
22
22
  ID: "id",
23
+ SLUG: "slug",
23
24
  TYPE: "type",
24
25
  TITLE: "title",
25
26
  DESCRIPTION: "description",
@@ -0,0 +1,11 @@
1
+ import type { FAQ } from "../types";
2
+ interface FAQSearchableListLabels {
3
+ searchPlaceholder: string;
4
+ noResults: string;
5
+ }
6
+ export interface FAQSearchableListProps {
7
+ faqs: FAQ[];
8
+ labels: FAQSearchableListLabels;
9
+ }
10
+ export declare function FAQSearchableList({ faqs, labels }: FAQSearchableListProps): import("react").JSX.Element;
11
+ export {};