diff --git a/backend/convex/__tests__/supportReports.test.ts b/backend/convex/__tests__/supportReports.test.ts new file mode 100644 index 00000000..e017820b --- /dev/null +++ b/backend/convex/__tests__/supportReports.test.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { convexTest } from 'convex-test'; +import schema from '../schema'; +import { api } from '../_generated/api'; +import * as supportReportsModule from '../supportReports'; +import * as apiModule from '../_generated/api'; +import * as serverModule from '../_generated/server'; +import { SUPPORT_REPORT_DESCRIPTION_MAX } from '@polybuys/shared'; + +const modules = { + '../supportReports.ts': () => Promise.resolve(supportReportsModule), + '../_generated/api.ts': () => Promise.resolve(apiModule), + '../_generated/server.ts': () => Promise.resolve(serverModule), +} as any; + +function asReporter(t: any) { + return t.withIdentity({ + name: 'Reporter', + subject: 'reporter-stable-id', + email: 'REPORTER@calpoly.edu', + }); +} + +describe('Support report mutations', () => { + it('submitSupportReport creates a durable support report with user and app context', async () => { + const t = convexTest(schema as any, modules); + const asUser = asReporter(t); + + const result = await asUser.mutation(api.supportReports.submitSupportReport, { + category: 'bug', + description: 'The inbox spinner never goes away.', + context: { + platform: 'ios', + appVersion: '1.0.0', + osVersion: '17.6', + route: '/account-settings', + }, + }); + + const report = await t.run(async (ctx: any) => { + return await ctx.db.get(result.supportReportId); + }); + + expect(report).toMatchObject({ + reporterId: 'reporter-stable-id', + reporterEmail: 'reporter@calpoly.edu', + category: 'bug', + description: 'The inbox spinner never goes away.', + context: { + platform: 'ios', + appVersion: '1.0.0', + osVersion: '17.6', + route: '/account-settings', + }, + }); + expect(typeof report?.createdAt).toBe('number'); + }); + + it('submitSupportReport requires authentication', async () => { + const t = convexTest(schema as any, modules); + + await expect(async () => { + await t.mutation(api.supportReports.submitSupportReport, { + category: 'other', + description: 'I need help with my account.', + }); + }).rejects.toThrow('You must be logged in to report a problem'); + }); + + it('submitSupportReport rejects empty or too-long descriptions', async () => { + const t = convexTest(schema as any, modules); + const asUser = asReporter(t); + + await expect(async () => { + await asUser.mutation(api.supportReports.submitSupportReport, { + category: 'other', + description: ' ', + }); + }).rejects.toThrow('Description is required'); + + await expect(async () => { + await asUser.mutation(api.supportReports.submitSupportReport, { + category: 'bug', + description: 'a'.repeat(SUPPORT_REPORT_DESCRIPTION_MAX + 1), + }); + }).rejects.toThrow(`Description must be ${SUPPORT_REPORT_DESCRIPTION_MAX} characters or less`); + }); + + it('submitSupportReport blocks duplicate rapid submissions', async () => { + const t = convexTest(schema as any, modules); + const asUser = asReporter(t); + const payload = { + category: 'messages' as const, + description: 'Messages are not sending.', + }; + + await asUser.mutation(api.supportReports.submitSupportReport, payload); + + await expect(async () => { + await asUser.mutation(api.supportReports.submitSupportReport, payload); + }).rejects.toThrow('You already submitted this problem recently.'); + }); + + it('submitSupportReport rate limits rapid support reports', async () => { + const t = convexTest(schema as any, modules); + const asUser = asReporter(t); + + for (let i = 0; i < 3; i++) { + await asUser.mutation(api.supportReports.submitSupportReport, { + category: 'bug', + description: `Distinct bug report ${i}`, + }); + } + + await expect(async () => { + await asUser.mutation(api.supportReports.submitSupportReport, { + category: 'listing', + description: 'Another issue after several quick reports.', + }); + }).rejects.toThrow('Support report limit reached. Please try again later.'); + }); +}); diff --git a/backend/convex/__tests__/testUtils.ts b/backend/convex/__tests__/testUtils.ts index cfe8dbb0..2ea0fc82 100644 --- a/backend/convex/__tests__/testUtils.ts +++ b/backend/convex/__tests__/testUtils.ts @@ -12,6 +12,7 @@ import * as usersModule from '../users'; import * as messagesModule from '../messages'; import * as blocksModule from '../blocks'; import * as reportsModule from '../reports'; +import * as supportReportsModule from '../supportReports'; import * as moderationModule from '../moderation'; import * as pushNotificationsModule from '../pushNotifications'; import * as apiModule from '../_generated/api'; @@ -26,6 +27,7 @@ export const modules = { '../messages.ts': () => Promise.resolve(messagesModule), '../blocks.ts': () => Promise.resolve(blocksModule), '../reports.ts': () => Promise.resolve(reportsModule), + '../supportReports.ts': () => Promise.resolve(supportReportsModule), '../moderation.ts': () => Promise.resolve(moderationModule), '../pushNotifications.ts': () => Promise.resolve(pushNotificationsModule), '../_generated/api.ts': () => Promise.resolve(apiModule), diff --git a/backend/convex/_generated/api.d.ts b/backend/convex/_generated/api.d.ts index d1b80b89..11cfe782 100644 --- a/backend/convex/_generated/api.d.ts +++ b/backend/convex/_generated/api.d.ts @@ -26,6 +26,7 @@ import type * as profiles from "../profiles.js"; import type * as pushNotifications from "../pushNotifications.js"; import type * as reports from "../reports.js"; import type * as savedListings from "../savedListings.js"; +import type * as supportReports from "../supportReports.js"; import type * as users from "../users.js"; import type { @@ -53,6 +54,7 @@ declare const fullApi: ApiFromModules<{ pushNotifications: typeof pushNotifications; reports: typeof reports; savedListings: typeof savedListings; + supportReports: typeof supportReports; users: typeof users; }>; diff --git a/backend/convex/schema.ts b/backend/convex/schema.ts index 79d926eb..6703f78b 100644 --- a/backend/convex/schema.ts +++ b/backend/convex/schema.ts @@ -123,6 +123,34 @@ export default defineSchema({ .index('by_target', ['targetId', 'targetType']) .index('by_reporter', ['reporterId']), + supportReports: defineTable({ + reporterId: v.string(), + reporterEmail: v.optional(v.string()), + category: v.union( + v.literal('bug'), + v.literal('account_login'), + v.literal('listing'), + v.literal('messages'), + v.literal('payments_offers'), + v.literal('safety'), + v.literal('other') + ), + description: v.string(), + context: v.optional( + v.object({ + platform: v.optional(v.string()), + appVersion: v.optional(v.string()), + osVersion: v.optional(v.string()), + route: v.optional(v.string()), + listingId: v.optional(v.string()), + conversationId: v.optional(v.string()), + }) + ), + createdAt: v.number(), + }) + .index('by_reporter_createdAt', ['reporterId', 'createdAt']) + .index('by_createdAt', ['createdAt']), + conversations: defineTable({ listingId: v.id('listings'), buyerId: v.string(), // Auth identity subject diff --git a/backend/convex/supportReports.ts b/backend/convex/supportReports.ts new file mode 100644 index 00000000..56487a6b --- /dev/null +++ b/backend/convex/supportReports.ts @@ -0,0 +1,138 @@ +import { ConvexError, v } from 'convex/values'; +import { + SUPPORT_REPORT_CONTEXT_VALUE_MAX, + SUPPORT_REPORT_DESCRIPTION_MAX, + SUPPORT_REPORTS_PER_DAY, + SUPPORT_REPORTS_PER_TEN_MINUTES, +} from '@polybuys/shared'; +import { mutation } from './_generated/server'; +import { requireAuthUserId } from './lib/authIdentity'; + +const TEN_MINUTES_MS = 10 * 60 * 1000; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +type SupportReportContext = { + platform?: string; + appVersion?: string; + osVersion?: string; + route?: string; + listingId?: string; + conversationId?: string; +}; + +function cleanOptionalText(value: string | undefined, maxLength: number) { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.slice(0, maxLength); +} + +function cleanContext(context: SupportReportContext | undefined) { + if (!context) { + return undefined; + } + + const cleaned: SupportReportContext = {}; + const platform = cleanOptionalText(context.platform, SUPPORT_REPORT_CONTEXT_VALUE_MAX); + const appVersion = cleanOptionalText(context.appVersion, SUPPORT_REPORT_CONTEXT_VALUE_MAX); + const osVersion = cleanOptionalText(context.osVersion, SUPPORT_REPORT_CONTEXT_VALUE_MAX); + const route = cleanOptionalText(context.route, SUPPORT_REPORT_CONTEXT_VALUE_MAX); + const listingId = cleanOptionalText(context.listingId, SUPPORT_REPORT_CONTEXT_VALUE_MAX); + const conversationId = cleanOptionalText( + context.conversationId, + SUPPORT_REPORT_CONTEXT_VALUE_MAX + ); + + if (platform) cleaned.platform = platform; + if (appVersion) cleaned.appVersion = appVersion; + if (osVersion) cleaned.osVersion = osVersion; + if (route) cleaned.route = route; + if (listingId) cleaned.listingId = listingId; + if (conversationId) cleaned.conversationId = conversationId; + + return Object.keys(cleaned).length > 0 ? cleaned : undefined; +} + +export const submitSupportReport = mutation({ + args: { + category: v.union( + v.literal('bug'), + v.literal('account_login'), + v.literal('listing'), + v.literal('messages'), + v.literal('payments_offers'), + v.literal('safety'), + v.literal('other') + ), + description: v.string(), + context: v.optional( + v.object({ + platform: v.optional(v.string()), + appVersion: v.optional(v.string()), + osVersion: v.optional(v.string()), + route: v.optional(v.string()), + listingId: v.optional(v.string()), + conversationId: v.optional(v.string()), + }) + ), + }, + handler: async (ctx, args) => { + const reporterId = await requireAuthUserId(ctx, 'You must be logged in to report a problem'); + const description = args.description.trim(); + + if (!description) { + throw new ConvexError('Description is required'); + } + + if (description.length > SUPPORT_REPORT_DESCRIPTION_MAX) { + throw new ConvexError( + `Description must be ${SUPPORT_REPORT_DESCRIPTION_MAX} characters or less` + ); + } + + const now = Date.now(); + const recentCutoff = now - TEN_MINUTES_MS; + const recentReports = await ctx.db + .query('supportReports') + .withIndex('by_reporter_createdAt', (q) => + q.eq('reporterId', reporterId).gt('createdAt', recentCutoff) + ) + .collect(); + + const matchingRecentReport = recentReports.find( + (report) => report.category === args.category && report.description === description + ); + if (matchingRecentReport) { + throw new ConvexError('You already submitted this problem recently.'); + } + + if (recentReports.length >= SUPPORT_REPORTS_PER_TEN_MINUTES) { + throw new ConvexError('Support report limit reached. Please try again later.'); + } + + const oneDayAgo = now - ONE_DAY_MS; + const dailyReports = await ctx.db + .query('supportReports') + .withIndex('by_reporter_createdAt', (q) => + q.eq('reporterId', reporterId).gt('createdAt', oneDayAgo) + ) + .collect(); + if (dailyReports.length >= SUPPORT_REPORTS_PER_DAY) { + throw new ConvexError('Support report limit reached. Please try again later.'); + } + + const identity = await ctx.auth.getUserIdentity(); + const reporterEmail = cleanOptionalText(identity?.email?.toLowerCase(), 320); + const supportReportId = await ctx.db.insert('supportReports', { + reporterId, + reporterEmail, + category: args.category, + description, + context: cleanContext(args.context), + createdAt: now, + }); + + return { supportReportId }; + }, +}); diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 9242fd53..ae39d400 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -128,6 +128,16 @@ function RootLayout() { name="account-settings" options={{ title: 'Settings', headerBackTitle: 'Profile' }} /> + { + router.push({ + pathname: '/report-problem', + params: { source: '/account-settings' }, + } as never); + }; + const handleMessageNotificationsToggle = async (value: boolean) => { if (isUpdatingMessageNotifications) return; @@ -346,6 +353,24 @@ export default function AccountSettingsScreen() { + + Support + [styles.supportButton, pressed && styles.buttonPressed]} + onPress={handleReportProblem} + accessibilityRole="button" + accessibilityLabel="Report a Problem" + > + + Report a Problem + + Tell us about app bugs, broken flows, or account issues. + + + + + + Blocked users @@ -476,6 +501,34 @@ const styles = StyleSheet.create({ ...typography.footnote, color: colors.muted, }, + supportButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md, + minHeight: 56, + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + backgroundColor: colors.surface, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + }, + supportButtonTextGroup: { + flex: 1, + minWidth: 0, + gap: 2, + }, + supportButtonTitle: { + ...typography.subhead, + color: colors.textDark, + fontWeight: '600', + }, + supportButtonChevron: { + ...typography.title1, + color: colors.muted, + fontWeight: '300', + }, blockedListEmpty: { paddingVertical: spacing.xl, paddingHorizontal: spacing.md, diff --git a/frontend/app/report-problem.tsx b/frontend/app/report-problem.tsx new file mode 100644 index 00000000..06bccba4 --- /dev/null +++ b/frontend/app/report-problem.tsx @@ -0,0 +1,332 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import Constants from 'expo-constants'; +import * as Device from 'expo-device'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useMutation } from 'convex/react'; +import { api } from 'convex/_generated/api'; +import { SUPPORT_REPORT_DESCRIPTION_MAX, type SupportReportCategory } from '@polybuys/shared'; +import { useAuth } from '../hooks/useAuth'; +import OpenInAppPrompt from '../components/OpenInAppPrompt'; +import { ScreenState } from '../components/ScreenState'; +import { FilterChips, ScreenScrollView, type FilterChipOption } from '../components/ui'; +import { getSignedOutFallback } from '../lib/navigation/guestAccess'; +import { getUserFlowErrorMessage } from '../lib/user-flow-errors'; +import { borderRadius, colors, spacing, typography } from '../theme/tokens'; + +const CATEGORY_OPTIONS: FilterChipOption[] = [ + { value: 'bug', label: 'Bug' }, + { value: 'account_login', label: 'Account/Login' }, + { value: 'listing', label: 'Listing' }, + { value: 'messages', label: 'Messages' }, + { value: 'payments_offers', label: 'Payments/Offers' }, + { value: 'safety', label: 'Safety' }, + { value: 'other', label: 'Other' }, +]; + +type ReportProblemParams = { + source?: string | string[]; + listingId?: string | string[]; + conversationId?: string | string[]; +}; + +function firstParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default function ReportProblemScreen() { + const router = useRouter(); + const isWeb = Platform.OS === 'web'; + const { isAuthenticated, isLoading } = useAuth(); + const params = useLocalSearchParams(); + const submitSupportReport = useMutation(api.supportReports.submitSupportReport); + + const [category, setCategory] = useState('bug'); + const [description, setDescription] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + + const sourceRoute = firstParam(params.source); + const listingId = firstParam(params.listingId); + const conversationId = firstParam(params.conversationId); + + const reportContext = useMemo( + () => ({ + platform: process.env.EXPO_OS ?? Platform.OS, + appVersion: Constants.expoConfig?.version ?? Constants.nativeApplicationVersion ?? undefined, + osVersion: Device.osVersion ?? undefined, + route: sourceRoute ?? '/report-problem', + listingId, + conversationId, + }), + [conversationId, listingId, sourceRoute] + ); + + useEffect(() => { + if (!isWeb && !isLoading && !isAuthenticated) { + router.replace((getSignedOutFallback('/report-problem') ?? '/home') as never); + } + }, [isAuthenticated, isLoading, isWeb, router]); + + const handleSubmit = async () => { + const trimmedDescription = description.trim(); + if (!trimmedDescription) { + setErrorMessage('Describe what happened before sending.'); + return; + } + + if (trimmedDescription.length > SUPPORT_REPORT_DESCRIPTION_MAX) { + setErrorMessage('That description is too long. Shorten it and try again.'); + return; + } + + setIsSubmitting(true); + setErrorMessage(null); + try { + await submitSupportReport({ + category, + description: trimmedDescription, + context: reportContext, + }); + setIsSubmitted(true); + } catch (error) { + setErrorMessage(getUserFlowErrorMessage(error, 'submit-support-report')); + } finally { + setIsSubmitting(false); + } + }; + + if (isWeb) { + return ( + router.replace('/home')} + /> + ); + } + + if (!isAuthenticated) { + return ( + + + + ); + } + + if (isSubmitted) { + return ( + + + Report sent + + Thanks for letting us know. The PolyBuys team will review it with your account and app + context. + + [styles.submitButton, pressed && styles.buttonPressed]} + onPress={() => router.back()} + accessibilityRole="button" + accessibilityLabel="Done" + > + Done + + + + ); + } + + return ( + + + Report a Problem + + Tell us about app bugs, broken flows, account issues, or anything that needs support. + + + + + Category + + + + + + Description + + {description.length}/{SUPPORT_REPORT_DESCRIPTION_MAX} + + + { + setDescription(value); + if (errorMessage) setErrorMessage(null); + }} + style={[styles.descriptionInput, errorMessage && styles.inputError]} + multiline + textAlignVertical="top" + maxLength={SUPPORT_REPORT_DESCRIPTION_MAX} + placeholder="What happened? Include what you expected and what you saw." + placeholderTextColor={colors.muted} + selectionColor={colors.primary} + cursorColor={colors.primary} + editable={!isSubmitting} + accessibilityLabel="Problem description" + /> + {errorMessage ? {errorMessage} : null} + + + + + We include your signed-in user, platform, app version, and current screen so the team can + debug faster. + + + + [ + styles.submitButton, + pressed && !isSubmitting && styles.buttonPressed, + isSubmitting && styles.buttonDisabled, + ]} + onPress={() => void handleSubmit()} + disabled={isSubmitting} + accessibilityRole="button" + accessibilityLabel="Submit support report" + > + {isSubmitting ? : null} + {isSubmitting ? 'Sending...' : 'Submit report'} + + + ); +} + +const styles = StyleSheet.create({ + loadingState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.surface, + }, + content: { + width: '100%', + maxWidth: 720, + alignSelf: 'center', + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg, + paddingBottom: spacing.xxl, + gap: spacing.xl, + }, + centeredContent: { + flexGrow: 1, + width: '100%', + maxWidth: 560, + alignSelf: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.xxl, + }, + header: { + gap: spacing.sm, + }, + title: { + ...typography.title1, + color: colors.textDark, + }, + bodyText: { + ...typography.subhead, + color: colors.text, + }, + formSection: { + gap: spacing.sm, + }, + labelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: spacing.md, + }, + label: { + ...typography.footnoteMed, + color: colors.textDark, + fontWeight: '700', + }, + counter: { + ...typography.footnote, + color: colors.muted, + fontVariant: ['tabular-nums'], + }, + descriptionInput: { + ...typography.body, + color: colors.textDark, + minHeight: 180, + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + backgroundColor: colors.white, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + }, + inputError: { + borderColor: colors.destructive, + }, + errorText: { + ...typography.footnote, + color: colors.destructive, + }, + contextBox: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + backgroundColor: colors.white, + padding: spacing.md, + }, + contextText: { + ...typography.footnote, + color: colors.text, + }, + submitButton: { + minHeight: 48, + borderRadius: borderRadius.md, + backgroundColor: colors.primary, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + boxShadow: '0 12px 24px rgba(21, 71, 52, 0.20)', + }, + submitButtonText: { + ...typography.subhead, + color: colors.white, + fontWeight: '700', + }, + buttonPressed: { + opacity: 0.92, + transform: [{ scale: 0.99 }], + }, + buttonDisabled: { + opacity: 0.7, + }, + successCard: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.lg, + backgroundColor: colors.white, + padding: spacing.xxl, + gap: spacing.lg, + }, +}); diff --git a/frontend/lib/__tests__/user-flow-errors.test.ts b/frontend/lib/__tests__/user-flow-errors.test.ts index 9acb1c70..c95e532c 100644 --- a/frontend/lib/__tests__/user-flow-errors.test.ts +++ b/frontend/lib/__tests__/user-flow-errors.test.ts @@ -12,4 +12,13 @@ describe('getUserFlowErrorMessage', () => { 'This conversation is no longer available.' ); }); + + it('maps support report rate-limit errors to retryable support copy', () => { + expect( + getUserFlowErrorMessage( + new Error('Support report limit reached. Please try again later.'), + 'submit-support-report' + ) + ).toBe('You have reached the support report limit for now. Try again later.'); + }); }); diff --git a/frontend/lib/user-flow-errors.ts b/frontend/lib/user-flow-errors.ts index 10841b0f..c3cc42dc 100644 --- a/frontend/lib/user-flow-errors.ts +++ b/frontend/lib/user-flow-errors.ts @@ -15,6 +15,7 @@ export type UserFlowErrorContext = | 'save-listing' | 'mark-listing-sold' | 'submit-report' + | 'submit-support-report' | 'open-in-app' | 'download-app'; @@ -286,6 +287,28 @@ export function getUserFlowErrorMessage(error: unknown, context: UserFlowErrorCo return 'We could not submit your report right now. Please try again.'; } + if (context === 'submit-support-report') { + if (message.includes('already submitted')) { + return 'You already sent this problem recently. Our team has it.'; + } + if (message.includes('support report limit reached')) { + return 'You have reached the support report limit for now. Try again later.'; + } + if (message.includes('description is required')) { + return 'Describe what happened before sending.'; + } + if (message.includes('description must be')) { + return 'That description is too long. Shorten it and try again.'; + } + if (isSessionIssue(message)) { + return 'Please sign in again before reporting a problem.'; + } + if (isNetworkIssue(message)) { + return 'We could not send your report right now. Check your connection and try again.'; + } + return 'We could not send your report right now. Please try again.'; + } + if (context === 'open-in-app') { return 'We could not open the app right now. Try again or use the download link below.'; } diff --git a/package-lock.json b/package-lock.json index 970283b8..1c17a58f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1195,14 +1195,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.28.5", + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz", + "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.5" + "@babel/traverse": "^7.29.0" }, "engines": { "node": ">=6.9.0" diff --git a/packages/shared/constants/index.ts b/packages/shared/constants/index.ts index 671f4256..07176dcd 100644 --- a/packages/shared/constants/index.ts +++ b/packages/shared/constants/index.ts @@ -1 +1,2 @@ export * from './profile'; +export * from './support'; diff --git a/packages/shared/constants/support.ts b/packages/shared/constants/support.ts new file mode 100644 index 00000000..071dc5eb --- /dev/null +++ b/packages/shared/constants/support.ts @@ -0,0 +1,16 @@ +export const SUPPORT_REPORT_CATEGORIES = [ + 'bug', + 'account_login', + 'listing', + 'messages', + 'payments_offers', + 'safety', + 'other', +] as const; + +export type SupportReportCategory = (typeof SUPPORT_REPORT_CATEGORIES)[number]; + +export const SUPPORT_REPORT_DESCRIPTION_MAX = 1200; +export const SUPPORT_REPORT_CONTEXT_VALUE_MAX = 240; +export const SUPPORT_REPORTS_PER_TEN_MINUTES = 3; +export const SUPPORT_REPORTS_PER_DAY = 10;