@mohasinac/appkit 2.6.7 → 2.6.9

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 (43) hide show
  1. package/dist/_internal/server/jobs/core/onSupportTicketCreate.d.ts +10 -0
  2. package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +26 -0
  3. package/dist/_internal/server/jobs/core/onSupportTicketUpdate.d.ts +15 -0
  4. package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +50 -0
  5. package/dist/_internal/server/jobs/core/onUserBanChange.d.ts +17 -0
  6. package/dist/_internal/server/jobs/core/onUserBanChange.js +59 -0
  7. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -0
  8. package/dist/_internal/server/jobs/handlers/index.js +4 -0
  9. package/dist/_internal/server/jobs/handlers/onSupportTicketCreate.d.ts +2 -0
  10. package/dist/_internal/server/jobs/handlers/onSupportTicketCreate.js +7 -0
  11. package/dist/_internal/server/jobs/handlers/onSupportTicketUpdate.d.ts +2 -0
  12. package/dist/_internal/server/jobs/handlers/onSupportTicketUpdate.js +8 -0
  13. package/dist/_internal/server/jobs/handlers/onUserBanChange.d.ts +2 -0
  14. package/dist/_internal/server/jobs/handlers/onUserBanChange.js +10 -0
  15. package/dist/configs/next.js +14 -0
  16. package/dist/features/admin/components/AdminScammerEditorView.d.ts +15 -0
  17. package/dist/features/admin/components/AdminScammerEditorView.js +61 -0
  18. package/dist/features/admin/components/AdminScammersView.d.ts +4 -0
  19. package/dist/features/admin/components/AdminScammersView.js +147 -0
  20. package/dist/features/admin/components/index.d.ts +4 -0
  21. package/dist/features/admin/components/index.js +2 -0
  22. package/dist/features/admin/constants/filter-tabs.d.ts +17 -0
  23. package/dist/features/admin/constants/filter-tabs.js +8 -0
  24. package/dist/features/faq/schemas/firestore.js +1 -0
  25. package/dist/features/faq/types/index.d.ts +1 -1
  26. package/dist/features/layout/AppLayoutShell.d.ts +2 -1
  27. package/dist/features/layout/AppLayoutShell.js +10 -4
  28. package/dist/features/layout/TitleBarLayout.d.ts +3 -1
  29. package/dist/features/layout/TitleBarLayout.js +8 -4
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.js +4 -0
  32. package/dist/jobs.d.ts +1 -1
  33. package/dist/jobs.js +2 -0
  34. package/dist/next/routing/route-map.d.ts +14 -0
  35. package/dist/next/routing/route-map.js +7 -0
  36. package/dist/seed/faq-seed-data.js +303 -0
  37. package/dist/seed/users-seed-data.js +81 -3
  38. package/dist/tailwind-utilities.css +1 -1
  39. package/dist/ui/components/Badge.d.ts +1 -0
  40. package/dist/ui/components/Badge.js +1 -0
  41. package/dist/ui/components/Badge.style.css +11 -0
  42. package/dist/ui/components/RoleBadge.js +2 -0
  43. package/package.json +1 -1
@@ -0,0 +1,10 @@
1
+ import type { JobContext } from "../runtime/types";
2
+ export interface HandleSupportTicketCreateInput {
3
+ ticketId: string;
4
+ ticket: {
5
+ userId?: string;
6
+ subject?: string;
7
+ category?: string;
8
+ };
9
+ }
10
+ export declare function handleSupportTicketCreate(input: HandleSupportTicketCreateInput, ctx: JobContext): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { notificationRepository } from "../../../../repositories";
2
+ export async function handleSupportTicketCreate(input, ctx) {
3
+ const { ticketId, ticket } = input;
4
+ const userId = ticket.userId;
5
+ if (!userId) {
6
+ ctx.logger.warn("onSupportTicketCreate: no userId on ticket", { ticketId });
7
+ return;
8
+ }
9
+ // Confirm to the user their ticket was received
10
+ try {
11
+ await notificationRepository.create({
12
+ userId,
13
+ type: "account_action",
14
+ title: "Support ticket received",
15
+ body: `We received your support request: "${ticket.subject ?? "your ticket"}". We'll get back to you soon.`,
16
+ isRead: false,
17
+ entityId: ticketId,
18
+ entityType: "support_ticket",
19
+ createdAt: new Date(),
20
+ });
21
+ }
22
+ catch (err) {
23
+ ctx.logger.error("Failed to send ticket confirmation notification (non-fatal)", err, { ticketId, userId });
24
+ }
25
+ ctx.logger.info("onSupportTicketCreate complete", { ticketId, userId });
26
+ }
@@ -0,0 +1,15 @@
1
+ import type { JobContext } from "../runtime/types";
2
+ export interface HandleSupportTicketUpdateInput {
3
+ ticketId: string;
4
+ before: {
5
+ status?: string;
6
+ userId?: string;
7
+ subject?: string;
8
+ } | null;
9
+ after: {
10
+ status?: string;
11
+ userId?: string;
12
+ subject?: string;
13
+ } | null;
14
+ }
15
+ export declare function handleSupportTicketUpdate(input: HandleSupportTicketUpdateInput, ctx: JobContext): Promise<void>;
@@ -0,0 +1,50 @@
1
+ import { notificationRepository } from "../../../../repositories";
2
+ const USER_NOTIFY_STATUSES = new Set(["resolved", "closed", "waiting_on_user", "in_progress"]);
3
+ const STATUS_MESSAGES = {
4
+ resolved: {
5
+ title: "Support ticket resolved",
6
+ body: (subject) => `Your support ticket "${subject}" has been marked as resolved.`,
7
+ },
8
+ closed: {
9
+ title: "Support ticket closed",
10
+ body: (subject) => `Your support ticket "${subject}" has been closed.`,
11
+ },
12
+ waiting_on_user: {
13
+ title: "Response required on your ticket",
14
+ body: (subject) => `Your support ticket "${subject}" is waiting for your reply.`,
15
+ },
16
+ in_progress: {
17
+ title: "Support ticket in progress",
18
+ body: (subject) => `Your support ticket "${subject}" is being reviewed by our team.`,
19
+ },
20
+ };
21
+ export async function handleSupportTicketUpdate(input, ctx) {
22
+ const { ticketId, before, after } = input;
23
+ const prevStatus = before?.status;
24
+ const nextStatus = after?.status;
25
+ const userId = after?.userId ?? before?.userId;
26
+ const subject = after?.subject ?? before?.subject ?? "your ticket";
27
+ if (!nextStatus || !userId || prevStatus === nextStatus)
28
+ return;
29
+ if (!USER_NOTIFY_STATUSES.has(nextStatus))
30
+ return;
31
+ const msg = STATUS_MESSAGES[nextStatus];
32
+ if (!msg)
33
+ return;
34
+ try {
35
+ await notificationRepository.create({
36
+ userId,
37
+ type: "account_action",
38
+ title: msg.title,
39
+ body: msg.body(subject),
40
+ isRead: false,
41
+ entityId: ticketId,
42
+ entityType: "support_ticket",
43
+ createdAt: new Date(),
44
+ });
45
+ }
46
+ catch (err) {
47
+ ctx.logger.error("Failed to notify user of ticket status change (non-fatal)", err, { ticketId, userId, nextStatus });
48
+ }
49
+ ctx.logger.info("onSupportTicketUpdate complete", { ticketId, userId, prevStatus, nextStatus });
50
+ }
@@ -0,0 +1,17 @@
1
+ import type { JobContext } from "../runtime/types";
2
+ export interface HandleUserBanChangeInput {
3
+ uid: string;
4
+ before: {
5
+ isDisabled?: boolean;
6
+ hardBanReason?: string;
7
+ hardBannedBy?: string;
8
+ softBans?: unknown[];
9
+ } | null;
10
+ after: {
11
+ isDisabled?: boolean;
12
+ hardBanReason?: string;
13
+ hardBannedBy?: string;
14
+ softBans?: unknown[];
15
+ } | null;
16
+ }
17
+ export declare function handleUserBanChange(input: HandleUserBanChangeInput, ctx: JobContext): Promise<void>;
@@ -0,0 +1,59 @@
1
+ export async function handleUserBanChange(input, ctx) {
2
+ const { uid, before, after } = input;
3
+ if (!after)
4
+ return;
5
+ const entries = [];
6
+ const wasDisabled = Boolean(before?.isDisabled);
7
+ const isNowDisabled = Boolean(after.isDisabled);
8
+ // Hard ban applied
9
+ if (!wasDisabled && isNowDisabled && after.hardBanReason) {
10
+ entries.push({
11
+ type: "hard_ban",
12
+ reason: after.hardBanReason,
13
+ performedBy: after.hardBannedBy,
14
+ at: new Date(),
15
+ });
16
+ }
17
+ // Hard ban lifted
18
+ if (wasDisabled && !isNowDisabled) {
19
+ entries.push({
20
+ type: "hard_unban",
21
+ at: new Date(),
22
+ });
23
+ }
24
+ // Soft ban changes — detect additions
25
+ const prevSoftBans = before?.softBans ?? [];
26
+ const nextSoftBans = after.softBans ?? [];
27
+ const prevActions = new Set(prevSoftBans.map((b) => b.action));
28
+ const nextActions = new Set(nextSoftBans.map((b) => b.action));
29
+ for (const nextBan of nextSoftBans) {
30
+ if (!prevActions.has(nextBan.action)) {
31
+ entries.push({
32
+ type: "soft_ban",
33
+ action: nextBan.action,
34
+ reason: nextBan.reason,
35
+ performedBy: nextBan.bannedBy,
36
+ at: new Date(),
37
+ });
38
+ }
39
+ }
40
+ for (const action of prevActions) {
41
+ if (!nextActions.has(action)) {
42
+ entries.push({ type: "soft_unban", action, at: new Date() });
43
+ }
44
+ }
45
+ if (entries.length === 0)
46
+ return;
47
+ const banHistoryRef = ctx.db.collection("users").doc(uid).collection("banHistory");
48
+ const batch = ctx.db.batch();
49
+ for (const entry of entries) {
50
+ batch.set(banHistoryRef.doc(), entry);
51
+ }
52
+ try {
53
+ await batch.commit();
54
+ ctx.logger.info("onUserBanChange: ban history entries written", { uid, count: entries.length });
55
+ }
56
+ catch (err) {
57
+ ctx.logger.error("onUserBanChange: failed to write ban history (non-fatal)", err, { uid });
58
+ }
59
+ }
@@ -41,3 +41,6 @@ export { bundleStockSyncHandler } from "./bundleStockSync";
41
41
  export { onProductStockChangeHandler } from "./onProductStockChange";
42
42
  export { triggerEventRaffleHandler } from "./triggerEventRaffle";
43
43
  export { assignSpinPrizeHandler } from "./assignSpinPrize";
44
+ export { onSupportTicketCreateHandler } from "./onSupportTicketCreate";
45
+ export { onSupportTicketUpdateHandler } from "./onSupportTicketUpdate";
46
+ export { onUserBanChangeHandler } from "./onUserBanChange";
@@ -46,3 +46,7 @@ export { bundleStockSyncHandler } from "./bundleStockSync";
46
46
  export { onProductStockChangeHandler } from "./onProductStockChange";
47
47
  export { triggerEventRaffleHandler } from "./triggerEventRaffle";
48
48
  export { assignSpinPrizeHandler } from "./assignSpinPrize";
49
+ // BAN9 — support ticket lifecycle + user ban audit
50
+ export { onSupportTicketCreateHandler } from "./onSupportTicketCreate";
51
+ export { onSupportTicketUpdateHandler } from "./onSupportTicketUpdate";
52
+ export { onUserBanChangeHandler } from "./onUserBanChange";
@@ -0,0 +1,2 @@
1
+ import type { FirestoreTriggerHandler } from "../runtime/types";
2
+ export declare const onSupportTicketCreateHandler: FirestoreTriggerHandler<null, Record<string, unknown>>;
@@ -0,0 +1,7 @@
1
+ import { handleSupportTicketCreate } from "../core/onSupportTicketCreate";
2
+ export const onSupportTicketCreateHandler = async (event, ctx) => {
3
+ const ticket = event.after;
4
+ if (!ticket)
5
+ return;
6
+ await handleSupportTicketCreate({ ticketId: event.params.ticketId, ticket }, ctx);
7
+ };
@@ -0,0 +1,2 @@
1
+ import type { FirestoreTriggerHandler } from "../runtime/types";
2
+ export declare const onSupportTicketUpdateHandler: FirestoreTriggerHandler<Record<string, unknown>, Record<string, unknown>>;
@@ -0,0 +1,8 @@
1
+ import { handleSupportTicketUpdate } from "../core/onSupportTicketUpdate";
2
+ export const onSupportTicketUpdateHandler = async (event, ctx) => {
3
+ await handleSupportTicketUpdate({
4
+ ticketId: event.params.ticketId,
5
+ before: event.before,
6
+ after: event.after,
7
+ }, ctx);
8
+ };
@@ -0,0 +1,2 @@
1
+ import type { FirestoreTriggerHandler } from "../runtime/types";
2
+ export declare const onUserBanChangeHandler: FirestoreTriggerHandler<Record<string, unknown>, Record<string, unknown>>;
@@ -0,0 +1,10 @@
1
+ import { handleUserBanChange } from "../core/onUserBanChange";
2
+ export const onUserBanChangeHandler = async (event, ctx) => {
3
+ const before = event.before;
4
+ const after = event.after;
5
+ const didBanChange = before?.isDisabled !== after?.isDisabled ||
6
+ (before?.softBans?.length ?? 0) !== (after?.softBans?.length ?? 0);
7
+ if (!didBanChange)
8
+ return;
9
+ await handleUserBanChange({ uid: event.params.uid, before, after }, ctx);
10
+ };
@@ -93,6 +93,20 @@ export function defineNextConfig(override = {}) {
93
93
  "./node_modules/gtoken/**",
94
94
  "./node_modules/jws/**",
95
95
  "./node_modules/gaxios/**",
96
+ // gRPC transport layer used by @google-cloud/firestore via google-gax
97
+ "./node_modules/@grpc/**",
98
+ // Transitive deps of google-gax / @grpc hoisted to root node_modules
99
+ "./node_modules/protobufjs/**",
100
+ "./node_modules/@protobufjs/**",
101
+ "./node_modules/object-hash/**",
102
+ "./node_modules/proto3-json-serializer/**",
103
+ "./node_modules/long/**",
104
+ "./node_modules/node-fetch/**",
105
+ "./node_modules/abort-controller/**",
106
+ "./node_modules/retry-request/**",
107
+ "./node_modules/duplexify/**",
108
+ "./node_modules/uuid/**",
109
+ "./node_modules/lodash.camelcase/**",
96
110
  ],
97
111
  };
98
112
  const mergedOutputFileTracingIncludes = {
@@ -0,0 +1,15 @@
1
+ export interface AdminScammerEditorViewProps {
2
+ open: boolean;
3
+ onClose: () => void;
4
+ scammerId?: string;
5
+ displayNames?: string[];
6
+ scamType?: string;
7
+ description?: string;
8
+ phones?: string[];
9
+ upiIds?: string[];
10
+ currentStatus?: string;
11
+ verificationNote?: string;
12
+ reportedBy?: string;
13
+ reportedByAnon?: boolean;
14
+ }
15
+ export declare function AdminScammerEditorView({ open, onClose, scammerId, displayNames, scamType, description, phones, upiIds, currentStatus, verificationNote, reportedBy, reportedByAnon, }: AdminScammerEditorViewProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,61 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import React from "react";
4
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
5
+ import { Button, Div, FormActions, Select, SideDrawer, Text, useToast, } from "../../../ui";
6
+ import { apiClient } from "../../../http";
7
+ import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
8
+ const STATUS_OPTIONS = [
9
+ { label: "Pending review", value: "pending_review" },
10
+ { label: "Verified", value: "verified" },
11
+ { label: "Rejected", value: "rejected" },
12
+ { label: "Removed", value: "removed" },
13
+ ];
14
+ const STATUS_COLOR = {
15
+ pending_review: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300",
16
+ verified: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
17
+ rejected: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
18
+ removed: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
19
+ };
20
+ export function AdminScammerEditorView({ open, onClose, scammerId, displayNames = [], scamType, description, phones = [], upiIds = [], currentStatus, verificationNote, reportedBy, reportedByAnon, }) {
21
+ const queryClient = useQueryClient();
22
+ const { showToast } = useToast();
23
+ const [status, setStatus] = React.useState(currentStatus ?? "pending_review");
24
+ const [note, setNote] = React.useState(verificationNote ?? "");
25
+ React.useEffect(() => {
26
+ if (open) {
27
+ setStatus(currentStatus ?? "pending_review");
28
+ setNote(verificationNote ?? "");
29
+ }
30
+ }, [open, currentStatus, verificationNote]);
31
+ const updateMutation = useMutation({
32
+ mutationFn: async () => {
33
+ await apiClient.patch(ADMIN_ENDPOINTS.SCAMMER_BY_ID(scammerId), {
34
+ status,
35
+ verificationNote: note || undefined,
36
+ });
37
+ },
38
+ onSuccess: () => {
39
+ showToast("Scammer profile updated.", "success");
40
+ queryClient.invalidateQueries({ queryKey: ["admin", "scammers"] });
41
+ onClose();
42
+ },
43
+ onError: (err) => {
44
+ showToast(err?.message ?? "Failed to update profile.", "error");
45
+ },
46
+ });
47
+ const deleteMutation = useMutation({
48
+ mutationFn: async () => {
49
+ await apiClient.delete(ADMIN_ENDPOINTS.SCAMMER_BY_ID(scammerId));
50
+ },
51
+ onSuccess: () => {
52
+ showToast("Scammer profile deleted.", "success");
53
+ queryClient.invalidateQueries({ queryKey: ["admin", "scammers"] });
54
+ onClose();
55
+ },
56
+ onError: (err) => {
57
+ showToast(err?.message ?? "Failed to delete profile.", "error");
58
+ },
59
+ });
60
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: displayNames.length > 0 ? displayNames[0] : "Scammer Profile", children: _jsxs(Div, { className: "flex flex-col gap-4 p-4", children: [_jsxs(Div, { className: "flex items-center gap-2", children: [_jsx("span", { className: `inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_COLOR[currentStatus ?? "pending_review"] ?? STATUS_COLOR.pending_review}`, children: (currentStatus ?? "pending_review").replace(/_/g, " ") }), scamType && (_jsx("span", { className: "rounded-full bg-zinc-100 px-2.5 py-1 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300", children: scamType.replace(/_/g, " ") }))] }), displayNames.length > 0 && (_jsxs(Div, { children: [_jsx(Text, { className: "mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400", children: "Names / Aliases" }), _jsx("div", { className: "flex flex-wrap gap-1", children: displayNames.map((name, i) => (_jsx("span", { className: "rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs dark:bg-zinc-800", children: name }, i))) })] })), (phones.length > 0 || upiIds.length > 0) && (_jsxs(Div, { className: "rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900/40", children: [phones.length > 0 && (_jsxs("div", { className: "mb-2", children: [_jsx(Text, { className: "mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500", children: "Phone numbers" }), _jsx("div", { className: "flex flex-wrap gap-1", children: phones.map((p, i) => (_jsx("code", { className: "rounded bg-zinc-200 px-1.5 py-0.5 text-xs dark:bg-zinc-700", children: p }, i))) })] })), upiIds.length > 0 && (_jsxs("div", { children: [_jsx(Text, { className: "mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500", children: "UPI IDs" }), _jsx("div", { className: "flex flex-wrap gap-1", children: upiIds.map((u, i) => (_jsx("code", { className: "rounded bg-zinc-200 px-1.5 py-0.5 text-xs dark:bg-zinc-700", children: u }, i))) })] }))] })), description && (_jsxs(Div, { className: "rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900/40", children: [_jsx(Text, { className: "mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap text-sm text-zinc-700 dark:text-zinc-200", children: description })] })), _jsxs(Div, { className: "text-xs text-zinc-500 dark:text-zinc-400", children: ["Reported by:", " ", _jsx("span", { className: "font-medium text-zinc-700 dark:text-zinc-300", children: reportedByAnon ? "Anonymous" : (reportedBy ?? "Unknown") })] }), _jsx("hr", { className: "border-zinc-200 dark:border-zinc-700" }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsxs(Div, { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400", children: "Verification note (internal)" }), _jsx("textarea", { value: note, onChange: (e) => setNote(e.target.value), rows: 3, placeholder: "e.g. Verified via 3 independent reports\u2026", className: "w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary-500" })] }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "danger", size: "sm", disabled: !scammerId, isLoading: deleteMutation.isPending, onClick: () => deleteMutation.mutate(), children: "Delete" }), _jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", isLoading: updateMutation.isPending, disabled: !scammerId || updateMutation.isPending, onClick: () => updateMutation.mutate(), children: "Save changes" })] })] }) }));
61
+ }
@@ -0,0 +1,4 @@
1
+ import type { ListingViewShellProps } from "../../../ui";
2
+ export interface AdminScammersViewProps extends ListingViewShellProps {
3
+ }
4
+ export declare function AdminScammersView({ children, ...props }: AdminScammersViewProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,147 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import React, { useState, useCallback } from "react";
4
+ import { X } from "lucide-react";
5
+ import { useUrlTable } from "../../../react/hooks/useUrlTable";
6
+ import { FilterChipGroup, ListingToolbar, ListingViewShell, Pagination, RowActionMenu, } from "../../../ui";
7
+ import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
8
+ import { ADMIN_SCAMMER_STATUS_TABS } from "../constants/filter-tabs";
9
+ import { toRecordArray, toRelativeDate, toStringValue, useAdminListingData, } from "../hooks/useAdminListingData";
10
+ import { DataTable } from "./DataTable";
11
+ import { AdminScammerEditorView } from "./AdminScammerEditorView";
12
+ const PAGE_SIZE = 25;
13
+ const FILTER_KEYS = ["status"];
14
+ const DEFAULT_SORT = "-createdAt";
15
+ const SORT_OPTIONS = [
16
+ { value: "-createdAt", label: "Newest" },
17
+ { value: "createdAt", label: "Oldest" },
18
+ { value: "-views", label: "Most viewed" },
19
+ { value: "-incidentCount", label: "Most incidents" },
20
+ ];
21
+ const STATUS_BADGE = {
22
+ pending_review: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300",
23
+ verified: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
24
+ rejected: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
25
+ removed: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
26
+ };
27
+ export function AdminScammersView({ children, ...props }) {
28
+ const hasChildren = React.Children.count(children) > 0;
29
+ const table = useUrlTable({ defaults: { pageSize: String(PAGE_SIZE), sort: DEFAULT_SORT } });
30
+ const [searchInput, setSearchInput] = useState(table.get("q") || "");
31
+ const [filterOpen, setFilterOpen] = useState(false);
32
+ const [pendingFilters, setPendingFilters] = useState(() => Object.fromEntries(FILTER_KEYS.map((k) => [k, table.get(k)])));
33
+ const [drawerOpen, setDrawerOpen] = useState(false);
34
+ const [selectedRow, setSelectedRow] = useState(null);
35
+ const openFilters = useCallback(() => {
36
+ setPendingFilters(Object.fromEntries(FILTER_KEYS.map((k) => [k, table.get(k)])));
37
+ setFilterOpen(true);
38
+ }, [table]);
39
+ const applyFilters = useCallback(() => {
40
+ const updates = { page: "1" };
41
+ for (const k of FILTER_KEYS)
42
+ updates[k] = pendingFilters[k] ?? "";
43
+ table.setMany(updates);
44
+ setFilterOpen(false);
45
+ }, [pendingFilters, table]);
46
+ const clearFilters = useCallback(() => {
47
+ setPendingFilters(Object.fromEntries(FILTER_KEYS.map((k) => [k, ""])));
48
+ }, []);
49
+ const resetAll = useCallback(() => {
50
+ const updates = { q: "", sort: "" };
51
+ for (const k of FILTER_KEYS)
52
+ updates[k] = "";
53
+ table.setMany(updates);
54
+ setSearchInput("");
55
+ }, [table]);
56
+ const commitSearch = useCallback(() => {
57
+ table.set("q", searchInput.trim());
58
+ }, [searchInput, table]);
59
+ const activeFilterCount = FILTER_KEYS.filter((k) => !!table.get(k)).length;
60
+ const hasActiveState = !!table.get("q") || table.get("sort") !== DEFAULT_SORT || activeFilterCount > 0;
61
+ const filterParts = [];
62
+ const statusRaw = table.get("status");
63
+ if (statusRaw && statusRaw !== "All")
64
+ filterParts.push(`status==${statusRaw}`);
65
+ const filters = filterParts.join(",") || undefined;
66
+ const { rows, total, isLoading, errorMessage } = useAdminListingData({
67
+ queryKey: ["admin", "scammers", "listing"],
68
+ endpoint: ADMIN_ENDPOINTS.SCAMMERS,
69
+ page: table.getNumber("page", 1),
70
+ pageSize: PAGE_SIZE,
71
+ sorts: table.get("sort") || DEFAULT_SORT,
72
+ filters,
73
+ q: table.get("q") || undefined,
74
+ mapRows: (response) => toRecordArray(response.scammers).map((item, index) => ({
75
+ id: toStringValue(item.id, `scammer-${index}`),
76
+ primary: Array.isArray(item.displayNames)
77
+ ? item.displayNames.join(", ")
78
+ : toStringValue(item.displayNames, "Unknown"),
79
+ secondary: [
80
+ toStringValue(item.scamType, ""),
81
+ Array.isArray(item.phones) ? `${item.phones.length} phone(s)` : null,
82
+ `${Number(item.incidentCount ?? 0)} incident(s)`,
83
+ ]
84
+ .filter(Boolean)
85
+ .join(" · "),
86
+ status: toStringValue(item.status, "pending_review"),
87
+ updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
88
+ _raw: item,
89
+ })),
90
+ getTotal: (response, mappedRows) => {
91
+ if (typeof response.meta?.filteredTotal === "number")
92
+ return response.meta.filteredTotal;
93
+ if (typeof response.meta?.total === "number")
94
+ return response.meta.total;
95
+ if (typeof response.total === "number")
96
+ return response.total;
97
+ return mappedRows.length;
98
+ },
99
+ });
100
+ const currentPage = table.getNumber("page", 1);
101
+ const totalPages = Math.ceil(total / PAGE_SIZE);
102
+ if (hasChildren) {
103
+ return (_jsx(ListingViewShell, { portal: "admin", ...props, children: children }));
104
+ }
105
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search by name, phone, UPI ID", onSearchChange: setSearchInput, onSearchCommit: commitSearch, sortValue: table.get("sort") || DEFAULT_SORT, sortOptions: SORT_OPTIONS, onSortChange: (v) => {
106
+ table.set("sort", v);
107
+ table.setPage(1);
108
+ }, hideViewToggle: true, onResetAll: resetAll, hasActiveState: hasActiveState }), totalPages > 1 && (_jsx("div", { className: "sticky top-[calc(var(--header-height,0px)+44px)] z-10 flex justify-center bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm border-b border-zinc-200 dark:border-slate-700 px-3 py-1.5", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) })), _jsxs("div", { className: "py-4 px-3 sm:px-4", children: [errorMessage && (_jsx("div", { className: "mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-200", children: errorMessage })), _jsx(DataTable, { rows: rows, isLoading: isLoading, emptyLabel: "No scammer profiles found", renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [
109
+ {
110
+ label: "Review",
111
+ onClick: () => {
112
+ setSelectedRow(row);
113
+ setDrawerOpen(true);
114
+ },
115
+ },
116
+ ] })), columns: [
117
+ {
118
+ key: "primary",
119
+ header: "Name / Aliases",
120
+ render: (row) => {
121
+ const r = row;
122
+ return (_jsxs("div", { className: "space-y-0.5", children: [_jsx("p", { className: "font-medium text-zinc-900 dark:text-zinc-100", children: r.primary }), r.secondary ? (_jsx("p", { className: "text-xs text-zinc-500 dark:text-zinc-400", children: r.secondary })) : null] }));
123
+ },
124
+ },
125
+ {
126
+ key: "status",
127
+ header: "Status",
128
+ className: "w-36",
129
+ render: (row) => {
130
+ const r = row;
131
+ return (_jsx("span", { className: `inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_BADGE[r.status] ?? STATUS_BADGE.pending_review}`, children: r.status.replace(/_/g, " ") }));
132
+ },
133
+ },
134
+ {
135
+ key: "updatedAt",
136
+ header: "Updated",
137
+ className: "w-32",
138
+ render: (row) => (_jsx("span", { className: "text-sm text-zinc-500 dark:text-zinc-400", children: row.updatedAt })),
139
+ },
140
+ ] })] }), filterOpen && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40 bg-black/40", "aria-hidden": "true", onClick: () => setFilterOpen(false) }), _jsxs("div", { className: "fixed inset-y-0 left-0 z-50 flex w-80 flex-col bg-white dark:bg-slate-900 shadow-2xl", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: [_jsx("span", { className: "text-base font-semibold text-zinc-900 dark:text-zinc-100", children: "Filters" }), _jsxs("div", { className: "flex items-center gap-2", children: [activeFilterCount > 0 && (_jsx("button", { type: "button", onClick: clearFilters, className: "text-xs text-zinc-500 hover:text-rose-500 dark:text-zinc-400 transition-colors", children: "Clear all" })), _jsx("button", { type: "button", onClick: () => setFilterOpen(false), "aria-label": "Close", className: "rounded-lg p-1.5 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-slate-800 transition-colors", children: _jsx(X, { className: "h-5 w-5" }) })] })] }), _jsx("div", { className: "flex-1 overflow-y-auto px-4 py-4 space-y-5", children: _jsx(FilterChipGroup, { label: "Status", tabs: ADMIN_SCAMMER_STATUS_TABS, value: pendingFilters.status ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, status: id })) }) }), _jsx("div", { className: "border-t border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: _jsxs("button", { type: "button", onClick: applyFilters, className: "w-full rounded-lg bg-primary py-2.5 text-sm font-semibold text-white hover:bg-primary-600 transition-colors active:scale-[0.98]", children: ["Apply Filters", activeFilterCount > 0 ? ` (${activeFilterCount})` : ""] }) })] })] }))] }), _jsx(AdminScammerEditorView, { open: drawerOpen, onClose: () => setDrawerOpen(false), scammerId: selectedRow?.id, displayNames: Array.isArray(selectedRow?._raw?.displayNames)
141
+ ? selectedRow._raw.displayNames
142
+ : [selectedRow?.primary ?? ""], scamType: toStringValue(selectedRow?._raw?.scamType, ""), description: toStringValue(selectedRow?._raw?.description, ""), phones: Array.isArray(selectedRow?._raw?.phones)
143
+ ? selectedRow._raw.phones
144
+ : [], upiIds: Array.isArray(selectedRow?._raw?.upiIds)
145
+ ? selectedRow._raw.upiIds
146
+ : [], currentStatus: selectedRow?.status, verificationNote: toStringValue(selectedRow?._raw?.verificationNote, "") || undefined, reportedBy: toStringValue(selectedRow?._raw?.reportedBy, ""), reportedByAnon: Boolean(selectedRow?._raw?.reportedByAnon) })] }));
147
+ }
@@ -141,3 +141,7 @@ export { AdminSupportTicketsView } from "./AdminSupportTicketsView";
141
141
  export type { AdminSupportTicketsViewProps } from "./AdminSupportTicketsView";
142
142
  export { AdminSupportTicketDetailView } from "./AdminSupportTicketDetailView";
143
143
  export type { AdminSupportTicketDetailViewProps } from "./AdminSupportTicketDetailView";
144
+ export { AdminScammersView } from "./AdminScammersView";
145
+ export type { AdminScammersViewProps } from "./AdminScammersView";
146
+ export { AdminScammerEditorView } from "./AdminScammerEditorView";
147
+ export type { AdminScammerEditorViewProps } from "./AdminScammerEditorView";
@@ -73,3 +73,5 @@ export { AdminTeamView } from "./AdminTeamView";
73
73
  export { AdminEmployeeEditorView } from "./AdminEmployeeEditorView";
74
74
  export { AdminSupportTicketsView } from "./AdminSupportTicketsView";
75
75
  export { AdminSupportTicketDetailView } from "./AdminSupportTicketDetailView";
76
+ export { AdminScammersView } from "./AdminScammersView";
77
+ export { AdminScammerEditorView } from "./AdminScammerEditorView";
@@ -266,6 +266,23 @@ export declare const ADMIN_EVENT_STATUS_TABS: readonly [{
266
266
  readonly id: "ended";
267
267
  readonly label: "Ended";
268
268
  }];
269
+ /** Admin > Scammers — scammer profile status filter chip set. */
270
+ export declare const ADMIN_SCAMMER_STATUS_TABS: readonly [{
271
+ readonly id: "All";
272
+ readonly label: "All";
273
+ }, {
274
+ readonly id: "pending_review";
275
+ readonly label: "Pending";
276
+ }, {
277
+ readonly id: "verified";
278
+ readonly label: "Verified";
279
+ }, {
280
+ readonly id: "rejected";
281
+ readonly label: "Rejected";
282
+ }, {
283
+ readonly id: "removed";
284
+ readonly label: "Removed";
285
+ }];
269
286
  /** Admin > Support Tickets — ticket-status filter chip set. */
270
287
  export declare const ADMIN_SUPPORT_TICKET_STATUS_TABS: readonly [{
271
288
  readonly id: "All";
@@ -130,6 +130,14 @@ export const ADMIN_EVENT_STATUS_TABS = [
130
130
  { id: "active", label: "Active" },
131
131
  { id: "ended", label: "Ended" },
132
132
  ];
133
+ /** Admin > Scammers — scammer profile status filter chip set. */
134
+ export const ADMIN_SCAMMER_STATUS_TABS = [
135
+ ALL_TAB,
136
+ { id: "pending_review", label: "Pending" },
137
+ { id: "verified", label: "Verified" },
138
+ { id: "rejected", label: "Rejected" },
139
+ { id: "removed", label: "Removed" },
140
+ ];
133
141
  /** Admin > Support Tickets — ticket-status filter chip set. */
134
142
  export const ADMIN_SUPPORT_TICKET_STATUS_TABS = [
135
143
  ALL_TAB,
@@ -27,6 +27,7 @@ export const FAQ_CATEGORY_LABELS = {
27
27
  account_security: "Account & Security",
28
28
  technical_support: "Technical Support",
29
29
  general: "General Questions",
30
+ scam_awareness: "Scam Awareness",
30
31
  };
31
32
  export const DEFAULT_FAQ_DATA = {
32
33
  showOnHomepage: false,
@@ -1,5 +1,5 @@
1
1
  export type FAQAnswerFormat = "plain" | "markdown" | "html";
2
- export type FAQCategory = "orders_payment" | "shipping_delivery" | "returns_refunds" | "product_information" | "account_security" | "technical_support" | "general";
2
+ export type FAQCategory = "orders_payment" | "shipping_delivery" | "returns_refunds" | "product_information" | "account_security" | "technical_support" | "general" | "scam_awareness";
3
3
  export interface FAQAnswer {
4
4
  text: string;
5
5
  format: FAQAnswerFormat;
@@ -41,6 +41,7 @@ export interface AppLayoutShellProps {
41
41
  userId?: string | null;
42
42
  profileHref: string;
43
43
  loginHref: string;
44
+ registerHref?: string;
44
45
  homeHref: string;
45
46
  shopHref: string;
46
47
  footer: FooterLayoutProps;
@@ -106,4 +107,4 @@ export interface AppLayoutShellProps {
106
107
  };
107
108
  };
108
109
  }
109
- export declare function AppLayoutShell({ children, navItems, sidebarItems, sidebarSections, sidebarPrimaryActions, sidebarTitle, hiddenNavItems, user, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, cartHref, wishlistHref, userId, profileHref, loginHref, homeHref, shopHref, footer, searchSlot, searchSlotRenderer, titleBarNavSlot, titleBarNotificationSlot, titleBarDevSlot, titleBarPromoStripText, showThemeToggle, suppressDashboardNav, hideSidebarToggle, onLogout, adminHref, storeHref, sellerHref, userOrdersHref, userWishlistHref, userSettingsHref, sidebarLocaleSlot, showThemeToggleInSidebar, sidebarProfileLabels, eventBannerSlot, lightBackground, darkBackground, }: AppLayoutShellProps): import("react/jsx-runtime").JSX.Element;
110
+ export declare function AppLayoutShell({ children, navItems, sidebarItems, sidebarSections, sidebarPrimaryActions, sidebarTitle, hiddenNavItems, user, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, cartHref, wishlistHref, userId, profileHref, loginHref, registerHref, homeHref, shopHref, footer, searchSlot, searchSlotRenderer, titleBarNavSlot, titleBarNotificationSlot, titleBarDevSlot, titleBarPromoStripText, showThemeToggle, suppressDashboardNav, hideSidebarToggle, onLogout, adminHref, storeHref, sellerHref, userOrdersHref, userWishlistHref, userSettingsHref, sidebarLocaleSlot, showThemeToggleInSidebar, sidebarProfileLabels, eventBannerSlot, lightBackground, darkBackground, }: AppLayoutShellProps): import("react/jsx-runtime").JSX.Element;