@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.
- package/dist/_internal/client/features/layout/useSidebarSearch.d.ts +19 -0
- package/dist/_internal/client/features/layout/useSidebarSearch.js +21 -0
- package/dist/_internal/server/features/auth/permissions.d.ts +2 -0
- package/dist/_internal/server/features/auth/permissions.js +4 -2
- package/dist/_internal/server/features/seo/sitemap.js +3 -2
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/features/about/components/FAQPageView.d.ts +4 -1
- package/dist/features/about/components/FAQPageView.js +17 -7
- package/dist/features/account/components/UserSidebar.js +15 -9
- package/dist/features/admin/components/AdminSidebar.js +15 -9
- package/dist/features/admin/components/AdminTesterChecklistItemEditorView.js +4 -1
- package/dist/features/admin/components/AdminUserEditorView.d.ts +3 -1
- package/dist/features/admin/components/AdminUserEditorView.js +10 -2
- package/dist/features/admin/components/AdminUsersView.js +1 -1
- package/dist/features/auth/role-predicates.d.ts +14 -0
- package/dist/features/auth/role-predicates.js +1 -0
- package/dist/features/auth/schemas/firestore.d.ts +2 -0
- package/dist/features/auth/schemas/firestore.js +1 -0
- package/dist/features/events/actions/event-actions.js +15 -8
- package/dist/features/events/repository/events.repository.d.ts +2 -0
- package/dist/features/events/repository/events.repository.js +9 -0
- package/dist/features/events/schemas/firestore.d.ts +1 -0
- package/dist/features/events/schemas/firestore.js +1 -0
- package/dist/features/faq/components/FAQSearchableList.d.ts +11 -0
- package/dist/features/faq/components/FAQSearchableList.js +21 -0
- package/dist/features/homepage/components/BeforeAfterCard.js +2 -1
- package/dist/features/homepage/components/BrandsSection.js +2 -1
- package/dist/features/homepage/components/CharacterHotspot.js +2 -1
- package/dist/features/homepage/components/CharacterHotspotForm.js +4 -3
- package/dist/features/homepage/components/HeroBanner.js +2 -1
- package/dist/features/homepage/components/PromoGrid.js +2 -1
- package/dist/features/homepage/components/ShopByCategorySection.js +2 -1
- package/dist/features/seller/components/SellerSidebar.js +15 -9
- package/dist/features/tester/actions/checklist-item-actions.d.ts +6 -0
- package/dist/features/tester/actions/checklist-item-actions.js +2 -0
- package/dist/features/tester/components/TesterHubView.js +5 -4
- package/dist/features/tester/schemas/firestore.d.ts +3 -1
- package/dist/features/tester/schemas/firestore.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +159 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/next/api/routeHandler.js +25 -3
- package/dist/react/contexts/SessionContext.d.ts +2 -0
- package/dist/react/contexts/SessionContext.js +2 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- 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
|
|
63
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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: [
|
|
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
|
|
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 (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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";
|
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
250
|
+
await eventRepository.incrementTotalEntries(resolvedEventId);
|
|
244
251
|
if (autoApprove) {
|
|
245
|
-
await eventRepository.incrementApprovedEntries(
|
|
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";
|
|
@@ -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 {};
|