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;