From 4fc0e3a5b6bc9cca4ea281497eeb45c78c98746f Mon Sep 17 00:00:00 2001 From: Cole Date: Sat, 18 Apr 2026 18:12:58 -0700 Subject: [PATCH] feat: build admin moderation view for reports (POLY-60) --- backend/convex/admin.ts | 382 +++++++++++++++++++ backend/convex/lib/authIdentity.ts | 12 + backend/convex/schema.ts | 6 + frontend/app/(tabs)/settings.tsx | 16 + frontend/app/_layout.tsx | 4 + frontend/app/admin/moderation.tsx | 582 +++++++++++++++++++++++++++++ 6 files changed, 1002 insertions(+) create mode 100644 backend/convex/admin.ts create mode 100644 frontend/app/admin/moderation.tsx diff --git a/backend/convex/admin.ts b/backend/convex/admin.ts new file mode 100644 index 0000000..de44573 --- /dev/null +++ b/backend/convex/admin.ts @@ -0,0 +1,382 @@ +import { v, ConvexError } from 'convex/values'; +import { query, mutation } from './_generated/server'; +import type { Id } from './_generated/dataModel'; +import { requireAdmin } from './lib/authIdentity'; + +/** + * Admin moderation queries and mutations. + * All functions require the caller to have isAdmin === true on their user record. + */ + +// --- Queries --- + +/** + * Get paginated reports for the admin moderation queue. + * Supports filtering by status and targetType. + * Null status on a report is treated as 'pending'. + */ +export const getReports = query({ + args: { + status: v.optional( + v.union(v.literal('pending'), v.literal('reviewed'), v.literal('dismissed')) + ), + targetType: v.optional(v.union(v.literal('listing'), v.literal('profile'))), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx); + + const limit = Math.min(args.limit ?? 50, 100); + + // Fetch reports ordered by newest first + let allReports = await ctx.db.query('reports').order('desc').take(500); + + // Filter by status (null treated as pending) + if (args.status) { + allReports = allReports.filter((r) => { + const reportStatus = r.status ?? 'pending'; + return reportStatus === args.status; + }); + } + + // Filter by targetType + if (args.targetType) { + allReports = allReports.filter((r) => r.targetType === args.targetType); + } + + // Limit results + const reports = allReports.slice(0, limit); + + // Enrich with target and reporter context + const enriched = await Promise.all( + reports.map(async (report) => { + let targetTitle: string | null = null; + let targetImage: string | null = null; + let targetIsHidden = false; + + if (report.targetType === 'listing') { + const listing = await ctx.db.get(report.targetId as Id<'listings'>).catch(() => null); + if (listing) { + targetTitle = listing.title; + targetImage = listing.images?.[0] ?? null; + targetIsHidden = listing.isHidden === true; + } + } else if (report.targetType === 'profile') { + const profile = await ctx.db.get(report.targetId as Id<'profiles'>).catch(() => null); + if (profile) { + targetTitle = profile.name; + targetIsHidden = profile.isHidden === true; + } + } + + // Get reporter profile name + const reporterProfile = await ctx.db + .query('profiles') + .withIndex('by_userId', (q) => q.eq('userId', report.reporterId)) + .first(); + + return { + ...report, + status: report.status ?? 'pending', + targetTitle, + targetImage, + targetIsHidden, + reporterName: reporterProfile?.name ?? 'Unknown user', + }; + }) + ); + + return enriched; + }, +}); + +/** + * Get detailed view of a single report with full target context and all reports for that target. + */ +export const getReportDetail = query({ + args: { reportId: v.id('reports') }, + handler: async (ctx, args) => { + await requireAdmin(ctx); + + const report = await ctx.db.get(args.reportId); + if (!report) { + throw new ConvexError('Report not found'); + } + + // Get full target data + let target: Record | null = null; + if (report.targetType === 'listing') { + const listing = await ctx.db.get(report.targetId as Id<'listings'>).catch(() => null); + target = listing ? { ...listing } : null; + } else if (report.targetType === 'profile') { + const profile = await ctx.db.get(report.targetId as Id<'profiles'>).catch(() => null); + target = profile ? { ...profile } : null; + } + + // Get all reports for this target + const allTargetReports = await ctx.db + .query('reports') + .withIndex('by_target', (q) => + q.eq('targetId', report.targetId).eq('targetType', report.targetType) + ) + .collect(); + + // Enrich each report with reporter name + const enrichedReports = await Promise.all( + allTargetReports.map(async (r) => { + const reporterProfile = await ctx.db + .query('profiles') + .withIndex('by_userId', (q) => q.eq('userId', r.reporterId)) + .first(); + return { + ...r, + status: r.status ?? 'pending', + reporterName: reporterProfile?.name ?? 'Unknown user', + }; + }) + ); + + // Get reporter profile for the primary report + const reporterProfile = await ctx.db + .query('profiles') + .withIndex('by_userId', (q) => q.eq('userId', report.reporterId)) + .first(); + + return { + report: { + ...report, + status: report.status ?? 'pending', + reporterName: reporterProfile?.name ?? 'Unknown user', + }, + target, + allReportsForTarget: enrichedReports, + uniqueReporterCount: new Set(allTargetReports.map((r) => r.reporterId)).size, + }; + }, +}); + +/** + * Get summary stats for the admin dashboard. + */ +export const getStats = query({ + args: {}, + handler: async (ctx) => { + await requireAdmin(ctx); + + const allReports = await ctx.db.query('reports').collect(); + + const pending = allReports.filter((r) => (r.status ?? 'pending') === 'pending').length; + const reviewed = allReports.filter((r) => r.status === 'reviewed').length; + const dismissed = allReports.filter((r) => r.status === 'dismissed').length; + + // Count hidden listings + const hiddenListings = await ctx.db + .query('listings') + .filter((q) => q.eq(q.field('isHidden'), true)) + .collect(); + + // Count hidden profiles + const hiddenProfiles = await ctx.db + .query('profiles') + .filter((q) => q.eq(q.field('isHidden'), true)) + .collect(); + + return { + pendingReports: pending, + reviewedReports: reviewed, + dismissedReports: dismissed, + totalReports: allReports.length, + hiddenListings: hiddenListings.length, + hiddenProfiles: hiddenProfiles.length, + }; + }, +}); + +/** + * Check if the current user is an admin. + */ +export const isCurrentUserAdmin = query({ + args: {}, + handler: async (ctx) => { + try { + await requireAdmin(ctx); + return true; + } catch { + return false; + } + }, +}); + +// --- Mutations --- + +/** + * Resolve a report by marking it as reviewed or dismissed. + * Optionally hides the target content. + */ +export const resolveReport = mutation({ + args: { + reportId: v.id('reports'), + resolution: v.union(v.literal('reviewed'), v.literal('dismissed')), + hideTarget: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const adminId = await requireAdmin(ctx); + + const report = await ctx.db.get(args.reportId); + if (!report) { + throw new ConvexError('Report not found'); + } + + // Update report status + await ctx.db.patch(args.reportId, { + status: args.resolution, + reviewedBy: adminId, + reviewedAt: Date.now(), + }); + + // Optionally hide the target + if (args.hideTarget) { + if (report.targetType === 'listing') { + const listing = await ctx.db.get(report.targetId as Id<'listings'>); + if (listing && !listing.isHidden) { + await ctx.db.patch(report.targetId as Id<'listings'>, { + isHidden: true, + hiddenAt: Date.now(), + hiddenReason: 'admin_action', + }); + } + } else if (report.targetType === 'profile') { + const profile = await ctx.db.get(report.targetId as Id<'profiles'>); + if (profile && !profile.isHidden) { + await ctx.db.patch(report.targetId as Id<'profiles'>, { + isHidden: true, + hiddenAt: Date.now(), + hiddenReason: 'admin_action', + }); + } + } + } + }, +}); + +/** + * Bulk resolve all reports for a given target. + */ +export const resolveAllForTarget = mutation({ + args: { + targetId: v.string(), + targetType: v.union(v.literal('listing'), v.literal('profile')), + resolution: v.union(v.literal('reviewed'), v.literal('dismissed')), + hideTarget: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const adminId = await requireAdmin(ctx); + + const reports = await ctx.db + .query('reports') + .withIndex('by_target', (q) => + q.eq('targetId', args.targetId).eq('targetType', args.targetType) + ) + .collect(); + + // Update all pending reports for this target + for (const report of reports) { + if ((report.status ?? 'pending') === 'pending') { + await ctx.db.patch(report._id, { + status: args.resolution, + reviewedBy: adminId, + reviewedAt: Date.now(), + }); + } + } + + // Optionally hide the target + if (args.hideTarget) { + if (args.targetType === 'listing') { + const listing = await ctx.db.get(args.targetId as Id<'listings'>); + if (listing && !listing.isHidden) { + await ctx.db.patch(args.targetId as Id<'listings'>, { + isHidden: true, + hiddenAt: Date.now(), + hiddenReason: 'admin_action', + }); + } + } else if (args.targetType === 'profile') { + const profile = await ctx.db.get(args.targetId as Id<'profiles'>); + if (profile && !profile.isHidden) { + await ctx.db.patch(args.targetId as Id<'profiles'>, { + isHidden: true, + hiddenAt: Date.now(), + hiddenReason: 'admin_action', + }); + } + } + } + }, +}); + +/** + * Manually hide a listing or profile. + */ +export const hideContent = mutation({ + args: { + targetId: v.string(), + targetType: v.union(v.literal('listing'), v.literal('profile')), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx); + + if (args.targetType === 'listing') { + const listing = await ctx.db.get(args.targetId as Id<'listings'>); + if (!listing) throw new ConvexError('Listing not found'); + if (listing.isHidden) return; // Already hidden + await ctx.db.patch(args.targetId as Id<'listings'>, { + isHidden: true, + hiddenAt: Date.now(), + hiddenReason: 'admin_action', + }); + } else { + const profile = await ctx.db.get(args.targetId as Id<'profiles'>); + if (!profile) throw new ConvexError('Profile not found'); + if (profile.isHidden) return; + await ctx.db.patch(args.targetId as Id<'profiles'>, { + isHidden: true, + hiddenAt: Date.now(), + hiddenReason: 'admin_action', + }); + } + }, +}); + +/** + * Unhide a listing or profile. + */ +export const unhideContent = mutation({ + args: { + targetId: v.string(), + targetType: v.union(v.literal('listing'), v.literal('profile')), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx); + + if (args.targetType === 'listing') { + const listing = await ctx.db.get(args.targetId as Id<'listings'>); + if (!listing) throw new ConvexError('Listing not found'); + if (!listing.isHidden) return; + await ctx.db.patch(args.targetId as Id<'listings'>, { + isHidden: false, + hiddenAt: undefined, + hiddenReason: undefined, + }); + } else { + const profile = await ctx.db.get(args.targetId as Id<'profiles'>); + if (!profile) throw new ConvexError('Profile not found'); + if (!profile.isHidden) return; + await ctx.db.patch(args.targetId as Id<'profiles'>, { + isHidden: false, + hiddenAt: undefined, + hiddenReason: undefined, + }); + } + }, +}); diff --git a/backend/convex/lib/authIdentity.ts b/backend/convex/lib/authIdentity.ts index a3667b5..14cae32 100644 --- a/backend/convex/lib/authIdentity.ts +++ b/backend/convex/lib/authIdentity.ts @@ -33,3 +33,15 @@ export async function requireAuthUserId( export async function getStableUserId(ctx: AuthCtx): Promise | null> { return await getAuthUserId(ctx); } + +/** + * Requires the current user to be an admin. Throws if not authenticated or not admin. + */ +export async function requireAdmin(ctx: QueryCtx | MutationCtx): Promise> { + const userId = await requireAuthUserId(ctx, 'Not authenticated'); + const user = await ctx.db.get(userId); + if (!user || user.isAdmin !== true) { + throw new ConvexError('Admin access required'); + } + return userId; +} diff --git a/backend/convex/schema.ts b/backend/convex/schema.ts index 5f23180..089ad68 100644 --- a/backend/convex/schema.ts +++ b/backend/convex/schema.ts @@ -16,6 +16,7 @@ export default defineSchema({ phoneVerificationTime: v.optional(v.number()), isAnonymous: v.optional(v.boolean()), messageNotificationsEnabled: v.optional(v.boolean()), + isAdmin: v.optional(v.boolean()), }) .index('phone', ['phone']) .index('email', ['email']), @@ -110,6 +111,11 @@ export default defineSchema({ reporterId: v.string(), // Auth identity subject reason: v.union(v.literal('scam'), v.literal('inappropriate'), v.literal('spam')), notes: v.optional(v.string()), + status: v.optional( + v.union(v.literal('pending'), v.literal('reviewed'), v.literal('dismissed')) + ), + reviewedBy: v.optional(v.string()), + reviewedAt: v.optional(v.number()), createdAt: v.number(), }) .index('by_target', ['targetId', 'targetType']) diff --git a/frontend/app/(tabs)/settings.tsx b/frontend/app/(tabs)/settings.tsx index a19eb23..fcf963f 100644 --- a/frontend/app/(tabs)/settings.tsx +++ b/frontend/app/(tabs)/settings.tsx @@ -58,6 +58,7 @@ export default function SettingsScreen() { const avatarUrl = avatarUrls[0]; const listingsCount = myListings?.filter((l) => l.status === 'active').length ?? 0; + const isAdmin = useQuery(api.admin.isCurrentUserAdmin, isAuthenticated ? {} : 'skip'); const itemsSoldCount = myListings?.filter((l) => l.status === 'sold').length ?? 0; const displayListings: Doc<'listings'>[] = myListings?.filter((l): l is Doc<'listings'> => l.status !== 'deleted') ?? []; @@ -213,6 +214,21 @@ export default function SettingsScreen() { + {isAdmin && ( + [styles.settingsRowCard, pressed && styles.buttonPressed]} + onPress={() => router.push('/admin/moderation' as never)} + accessibilityRole="button" + accessibilityLabel="Open moderation dashboard" + > + + Moderation + Review reports and manage content + + + + )} + + diff --git a/frontend/app/admin/moderation.tsx b/frontend/app/admin/moderation.tsx new file mode 100644 index 0000000..e70812a --- /dev/null +++ b/frontend/app/admin/moderation.tsx @@ -0,0 +1,582 @@ +import { useState } from 'react'; +import { + ActivityIndicator, + Alert, + Animated, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { useMutation, useQuery } from 'convex/react'; +import { useRouter } from 'expo-router'; +import { api } from 'convex/_generated/api'; +import type { Id } from 'convex/_generated/dataModel'; +import { useEntranceAnimation } from '../../hooks/useEntranceAnimation'; +import { colors, typography, spacing, borderRadius } from '../../theme/tokens'; + +type StatusFilter = 'pending' | 'reviewed' | 'dismissed'; +type TargetTypeFilter = 'all' | 'listing' | 'profile'; + +function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function showAlert(title: string, message: string) { + if (Platform.OS === 'web' && typeof window !== 'undefined') { + window.alert(`${title}\n\n${message}`); + } else { + Alert.alert(title, message); + } +} + +export default function AdminModerationScreen() { + const router = useRouter(); + const entranceStyle = useEntranceAnimation(); + + const [statusFilter, setStatusFilter] = useState('pending'); + const [targetTypeFilter, setTargetTypeFilter] = useState('all'); + const [actionLoading, setActionLoading] = useState(null); + + const isAdmin = useQuery(api.admin.isCurrentUserAdmin, {}); + const stats = useQuery(api.admin.getStats, isAdmin ? {} : 'skip'); + const reports = useQuery( + api.admin.getReports, + isAdmin + ? { + status: statusFilter, + targetType: targetTypeFilter === 'all' ? undefined : targetTypeFilter, + } + : 'skip' + ); + + const resolveReport = useMutation(api.admin.resolveReport); + const unhideContent = useMutation(api.admin.unhideContent); + + const handleResolve = async ( + reportId: Id<'reports'>, + resolution: 'reviewed' | 'dismissed', + hide?: boolean + ) => { + setActionLoading(reportId); + try { + await resolveReport({ reportId, resolution, hideTarget: hide }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Action failed'; + showAlert('Error', msg); + } finally { + setActionLoading(null); + } + }; + + const handleUnhide = async (targetId: string, targetType: 'listing' | 'profile') => { + setActionLoading(targetId); + try { + await unhideContent({ targetId, targetType }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Action failed'; + showAlert('Error', msg); + } finally { + setActionLoading(null); + } + }; + + // Loading state + if (isAdmin === undefined) { + return ( + + + + ); + } + + // Not admin + if (!isAdmin) { + return ( + + Access Denied + You do not have admin privileges. + [styles.button, pressed && styles.buttonPressed]} + onPress={() => router.back()} + > + Go Back + + + ); + } + + return ( + + + {/* Stats */} + {stats && ( + + + {stats.pendingReports} + Pending + + + {stats.reviewedReports} + Reviewed + + + {stats.dismissedReports} + Dismissed + + + {stats.hiddenListings + stats.hiddenProfiles} + Hidden + + + )} + + {/* Filters */} + + Status + + {(['pending', 'reviewed', 'dismissed'] as StatusFilter[]).map((s) => ( + setStatusFilter(s)} + > + + {s.charAt(0).toUpperCase() + s.slice(1)} + + + ))} + + + Type + + {(['all', 'listing', 'profile'] as TargetTypeFilter[]).map((t) => ( + setTargetTypeFilter(t)} + > + + {t === 'all' ? 'All' : t.charAt(0).toUpperCase() + t.slice(1) + 's'} + + + ))} + + + + {/* Reports list */} + + Reports ({reports?.length ?? 0}) + + {reports === undefined && ( + + )} + + {reports?.length === 0 && ( + + No {statusFilter} reports found. + + )} + + {reports?.map((report) => ( + + + + + + {report.targetType === 'listing' ? 'Listing' : 'Profile'} + + + + {report.reason} + + {report.targetIsHidden && ( + + Hidden + + )} + + {formatDate(report.createdAt)} + + + + {report.targetTitle ?? 'Unknown target'} + + + Reported by: {report.reporterName} + + {report.notes && ( + + + {report.notes} + + + )} + + {/* Actions */} + {(report.status ?? 'pending') === 'pending' && ( + + [ + styles.actionButton, + styles.actionDismiss, + pressed && styles.buttonPressed, + ]} + onPress={() => void handleResolve(report._id, 'dismissed')} + disabled={actionLoading === report._id} + > + Dismiss + + + [ + styles.actionButton, + styles.actionReview, + pressed && styles.buttonPressed, + ]} + onPress={() => void handleResolve(report._id, 'reviewed', false)} + disabled={actionLoading === report._id} + > + Mark Reviewed + + + {!report.targetIsHidden && ( + [ + styles.actionButton, + styles.actionHide, + pressed && styles.buttonPressed, + ]} + onPress={() => void handleResolve(report._id, 'reviewed', true)} + disabled={actionLoading === report._id} + > + Hide & Resolve + + )} + + {report.targetIsHidden && ( + [ + styles.actionButton, + styles.actionUnhide, + pressed && styles.buttonPressed, + ]} + onPress={() => void handleUnhide(report.targetId, report.targetType)} + disabled={actionLoading === report.targetId} + > + Unhide + + )} + + )} + + {report.status === 'reviewed' && report.targetIsHidden && ( + + [ + styles.actionButton, + styles.actionUnhide, + pressed && styles.buttonPressed, + ]} + onPress={() => void handleUnhide(report.targetId, report.targetType)} + disabled={actionLoading === report.targetId} + > + Unhide Content + + + )} + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + width: '100%', + maxWidth: 960, + alignSelf: 'center', + paddingHorizontal: spacing.lg, + paddingTop: spacing.xl, + paddingBottom: 40, + }, + centered: { + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + gap: spacing.md, + }, + errorTitle: { + ...typography.title1, + color: colors.textDark, + }, + errorMessage: { + ...typography.subhead, + color: colors.text, + }, + button: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: 12, + paddingHorizontal: spacing.xl, + marginTop: spacing.md, + }, + buttonPressed: { + opacity: 0.9, + }, + buttonText: { + color: colors.white, + ...typography.body, + fontWeight: '600', + }, + statsRow: { + flexDirection: 'row', + gap: spacing.md, + marginBottom: spacing.xl, + flexWrap: 'wrap', + }, + statCard: { + flex: 1, + minWidth: 100, + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.lg, + alignItems: 'center', + gap: 4, + }, + statNumber: { + fontSize: 28, + fontWeight: '700', + color: colors.textDark, + }, + statLabel: { + ...typography.footnote, + color: colors.text, + }, + filtersSection: { + marginBottom: spacing.xl, + }, + filterLabel: { + ...typography.footnote, + fontWeight: '600', + color: colors.textDark, + marginBottom: spacing.sm, + }, + filterRow: { + flexDirection: 'row', + gap: spacing.sm, + flexWrap: 'wrap', + }, + filterChip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.white, + }, + filterChipActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + filterChipText: { + ...typography.footnote, + color: colors.text, + fontWeight: '500', + }, + filterChipTextActive: { + color: colors.white, + }, + reportsSection: { + gap: spacing.md, + }, + sectionTitle: { + ...typography.title1, + fontSize: 18, + color: colors.textDark, + marginBottom: spacing.sm, + }, + emptyState: { + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.xxl, + alignItems: 'center', + }, + loadingIndicator: { + marginTop: 20, + }, + emptyText: { + ...typography.subhead, + color: colors.text, + }, + reportCard: { + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.lg, + gap: spacing.sm, + }, + reportHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + reportMeta: { + flexDirection: 'row', + gap: spacing.sm, + alignItems: 'center', + flexWrap: 'wrap', + }, + typeBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: borderRadius.sm, + }, + typeBadgeListing: { + backgroundColor: colors.location, + }, + typeBadgeProfile: { + backgroundColor: colors.infoBg, + }, + typeBadgeText: { + fontSize: 11, + fontWeight: '600', + color: colors.textDark, + }, + reasonBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: borderRadius.sm, + backgroundColor: colors.warningBg, + borderWidth: 1, + borderColor: colors.warningBorder, + }, + reasonBadgeText: { + fontSize: 11, + fontWeight: '600', + color: colors.warningText, + }, + hiddenBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: borderRadius.sm, + backgroundColor: colors.errorBg, + borderWidth: 1, + borderColor: colors.errorBorder, + }, + hiddenBadgeText: { + fontSize: 11, + fontWeight: '600', + color: colors.errorText, + }, + reportDate: { + ...typography.footnote, + color: colors.muted, + }, + reportTarget: { + ...typography.body, + fontWeight: '600', + color: colors.textDark, + }, + reportReporter: { + ...typography.footnote, + color: colors.text, + }, + notesBox: { + backgroundColor: colors.background, + borderRadius: borderRadius.sm, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.border, + }, + notesText: { + ...typography.footnote, + color: colors.text, + fontStyle: 'italic', + }, + actionsRow: { + flexDirection: 'row', + gap: spacing.sm, + marginTop: spacing.sm, + flexWrap: 'wrap', + }, + actionButton: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: borderRadius.sm, + borderWidth: 1, + }, + actionDismiss: { + borderColor: colors.border, + backgroundColor: colors.white, + }, + actionDismissText: { + ...typography.footnote, + fontWeight: '600', + color: colors.text, + }, + actionReview: { + borderColor: colors.primary, + backgroundColor: colors.location, + }, + actionReviewText: { + ...typography.footnote, + fontWeight: '600', + color: colors.primary, + }, + actionHide: { + borderColor: colors.errorBorder, + backgroundColor: colors.errorBg, + }, + actionHideText: { + ...typography.footnote, + fontWeight: '600', + color: colors.errorText, + }, + actionUnhide: { + borderColor: colors.primary, + backgroundColor: colors.white, + }, + actionUnhideText: { + ...typography.footnote, + fontWeight: '600', + color: colors.primary, + }, +});