diff --git a/frontend/app/listings/__tests__/newListingValidation.test.ts b/frontend/app/listings/__tests__/newListingValidation.test.ts
new file mode 100644
index 0000000..e350c51
--- /dev/null
+++ b/frontend/app/listings/__tests__/newListingValidation.test.ts
@@ -0,0 +1,86 @@
+import {
+ hasFieldErrors,
+ upsertFieldError,
+ validateDescription,
+ validateImages,
+ validateListingFields,
+ validatePrice,
+ validateTitle,
+} from '../newListingValidation';
+
+describe('new listing validation', () => {
+ it('collects required field errors for an empty submission', () => {
+ expect(
+ validateListingFields({
+ title: '',
+ description: ' ',
+ price: '',
+ images: [],
+ })
+ ).toEqual({
+ title: 'Title is required.',
+ description: 'Description is required.',
+ price: 'Price is required.',
+ images: 'At least 1 photo is required.',
+ });
+ });
+
+ it('treats trimmed valid values as valid', () => {
+ expect(validateTitle(' Desk lamp ')).toBeUndefined();
+ expect(validateDescription(' Clean, lightly used. ')).toBeUndefined();
+ expect(validatePrice(' 15.50 ')).toBeUndefined();
+ expect(validateImages(['image-1'])).toBeUndefined();
+ });
+
+ it('keeps invalid edits invalid until the value is actually corrected', () => {
+ expect(validateTitle('Hey')).toBe('Title must be at least 5 characters.');
+ expect(validatePrice('.')).toBe('Enter a valid non-negative price.');
+ });
+
+ it('rejects titles longer than 100 characters', () => {
+ expect(validateTitle('a'.repeat(101))).toBe('Title must be 100 characters or less.');
+ });
+
+ it('rejects more than 8 photos', () => {
+ expect(
+ validateImages([
+ 'image-1',
+ 'image-2',
+ 'image-3',
+ 'image-4',
+ 'image-5',
+ 'image-6',
+ 'image-7',
+ 'image-8',
+ 'image-9',
+ ])
+ ).toBe('Maximum 8 photos allowed.');
+ });
+
+ it('removes cleared field keys so banner state reflects active errors only', () => {
+ const errors = validateListingFields({
+ title: 'Hey',
+ description: '',
+ price: '',
+ images: [],
+ });
+
+ const withoutTitle = upsertFieldError(errors, 'title', undefined);
+
+ expect(withoutTitle.title).toBeUndefined();
+ expect(Object.prototype.hasOwnProperty.call(withoutTitle, 'title')).toBe(false);
+ expect(hasFieldErrors(withoutTitle)).toBe(true);
+
+ const noErrors = upsertFieldError(
+ upsertFieldError(
+ upsertFieldError(withoutTitle, 'description', undefined),
+ 'price',
+ undefined
+ ),
+ 'images',
+ undefined
+ );
+
+ expect(hasFieldErrors(noErrors)).toBe(false);
+ });
+});
diff --git a/frontend/app/listings/new.tsx b/frontend/app/listings/new.tsx
index 88f23cf..34db1c6 100644
--- a/frontend/app/listings/new.tsx
+++ b/frontend/app/listings/new.tsx
@@ -20,6 +20,16 @@ import { useEntranceAnimation } from '../../hooks/useEntranceAnimation';
import OpenInAppPrompt from '../../components/OpenInAppPrompt';
import { KeyboardAwareScreen, ScreenHeader } from '../../components/ui';
import { colors, typography, borderRadius, spacing } from '../../theme/tokens';
+import {
+ hasFieldErrors,
+ type FieldErrors,
+ upsertFieldError,
+ validateDescription,
+ validateImages,
+ validateListingFields,
+ validatePrice,
+ validateTitle,
+} from './newListingValidation';
const categories = ['textbooks', 'electronics', 'furniture', 'tickets', 'other'] as const;
const conditions = ['new', 'used', 'refurbished'] as const;
@@ -58,6 +68,19 @@ function getListingActionError(error: unknown, fallbackTitle: string) {
message: rawMessage,
};
}
+function RequiredLabel({ text }: { text: string }) {
+ return (
+
+ {text}
+ *
+
+ );
+}
+
+function FieldError({ message }: { message?: string }) {
+ if (!message) return null;
+ return {message};
+}
export default function NewListingScreen() {
const router = useRouter();
@@ -76,7 +99,10 @@ export default function NewListingScreen() {
const [images, setImages] = useState([]);
const [hasPendingUploads, setHasPendingUploads] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const submittingRef = useRef(false);
+ const hasActiveFieldErrors = hasFieldErrors(fieldErrors);
useEffect(() => {
if (!isWeb && !isSessionLoading && !isAuthenticated) {
@@ -98,6 +124,40 @@ export default function NewListingScreen() {
);
}
+ // After the first failed submit, keep each field's error state in sync with its current value.
+ const handleTitleChange = (text: string) => {
+ setTitle(text);
+ if (hasAttemptedSubmit) {
+ setFieldErrors((prev) => upsertFieldError(prev, 'title', validateTitle(text)));
+ }
+ };
+
+ const handleDescriptionChange = (text: string) => {
+ setDescription(text);
+ if (hasAttemptedSubmit) {
+ setFieldErrors((prev) => upsertFieldError(prev, 'description', validateDescription(text)));
+ }
+ };
+
+ const handlePriceChange = (text: string) => {
+ const filtered = text.replace(/[^0-9.]/g, '');
+ const parts = filtered.split('.');
+ if (parts.length > 2) return;
+ if (parts[1]?.length > 2) return;
+ setPrice(filtered);
+ if (hasAttemptedSubmit) {
+ setFieldErrors((prev) => upsertFieldError(prev, 'price', validatePrice(filtered)));
+ }
+ };
+
+ const handleImagesChange = (newImages: string[] | ((prev: string[]) => string[])) => {
+ const nextImages = typeof newImages === 'function' ? newImages(images) : newImages;
+ setImages(nextImages);
+ if (hasAttemptedSubmit) {
+ setFieldErrors((prev) => upsertFieldError(prev, 'images', validateImages(nextImages)));
+ }
+ };
+
async function onSubmit() {
if (submittingRef.current) {
return;
@@ -116,35 +176,28 @@ export default function NewListingScreen() {
return;
}
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || trimmedTitle.length < 5) {
- showAlert('Missing fields', 'Title must be at least 5 characters.');
+ if (hasPendingUploads) {
+ showAlert('Uploads in progress', 'Please wait for image uploads to finish.');
return;
}
- if (!trimmedDescription) {
- showAlert('Missing fields', 'Description is required.');
- return;
- }
+ setHasAttemptedSubmit(true);
- const trimmed = price.trim();
- const parsedPrice = trimmed === '' ? NaN : Number(trimmed);
- if (!Number.isFinite(parsedPrice) || parsedPrice < 0) {
- showAlert('Invalid price', 'Please enter a valid non-negative price in dollars.');
- return;
- }
+ const errors = validateListingFields({
+ title,
+ description,
+ price,
+ images,
+ });
+ setFieldErrors(errors);
- if (hasPendingUploads) {
- showAlert('Uploads in progress', 'Please wait for image uploads to finish.');
+ if (hasFieldErrors(errors)) {
return;
}
- if (images.length < 1 || images.length > 8) {
- showAlert('Invalid images', 'Please upload between 1 and 8 images.');
- return;
- }
+ const trimmedTitle = title.trim();
+ const trimmedDescription = description.trim();
+ const parsedPrice = Number(price.trim());
try {
submittingRef.current = true;
@@ -198,15 +251,16 @@ export default function NewListingScreen() {
/>
- Photos
+
Add 1–8 photos. Listings with clear photos sell faster.
+
{profile === null && (
@@ -224,57 +278,56 @@ export default function NewListingScreen() {
)}
- Title
+
+
- Description
+
+
- Price
-
+
+
$
{
- const filtered = text.replace(/[^0-9.]/g, '');
- const parts = filtered.split('.');
- if (parts.length > 2) return;
- if (parts[1]?.length > 2) return;
- setPrice(filtered);
- }}
+ onChangeText={handlePriceChange}
placeholder="15"
placeholderTextColor={colors.muted}
selectionColor={colors.primary}
cursorColor={colors.primary}
keyboardType="decimal-pad"
+ accessibilityLabel="Price (required)"
/>
- Enter amount in dollars
+
+ {!fieldErrors.price && Enter amount in dollars}
@@ -327,6 +380,12 @@ export default function NewListingScreen() {
+ {hasAttemptedSubmit && hasActiveFieldErrors && (
+
+ Please fix the errors above before submitting.
+
+ )}
+
[
@@ -398,6 +457,10 @@ const styles = StyleSheet.create({
color: colors.textDark,
marginBottom: 4,
},
+ requiredAsterisk: {
+ color: colors.errorText,
+ fontWeight: '700',
+ },
labelHint: {
...typography.footnote,
color: colors.muted,
@@ -412,6 +475,9 @@ const styles = StyleSheet.create({
color: colors.textDark,
backgroundColor: colors.white,
},
+ inputError: {
+ borderColor: colors.errorText,
+ },
textArea: {
minHeight: 112,
textAlignVertical: 'top',
@@ -424,6 +490,9 @@ const styles = StyleSheet.create({
borderRadius: borderRadius.md,
backgroundColor: colors.white,
},
+ priceInputWrapError: {
+ borderColor: colors.errorText,
+ },
pricePrefix: {
...typography.body,
color: colors.text,
@@ -439,6 +508,24 @@ const styles = StyleSheet.create({
color: colors.muted,
marginTop: spacing.xs,
},
+ fieldError: {
+ ...typography.footnote,
+ color: colors.errorText,
+ marginTop: spacing.xs,
+ },
+ formErrorBanner: {
+ backgroundColor: colors.errorBg,
+ borderRadius: borderRadius.sm,
+ borderWidth: 1,
+ borderColor: colors.errorBorder,
+ padding: spacing.md,
+ marginBottom: spacing.md,
+ },
+ formErrorText: {
+ ...typography.footnote,
+ color: colors.errorText,
+ textAlign: 'center',
+ },
optionsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
diff --git a/frontend/app/listings/newListingValidation.ts b/frontend/app/listings/newListingValidation.ts
new file mode 100644
index 0000000..5f8a391
--- /dev/null
+++ b/frontend/app/listings/newListingValidation.ts
@@ -0,0 +1,110 @@
+export type FieldErrors = {
+ title?: string;
+ description?: string;
+ price?: string;
+ images?: string;
+};
+
+type ListingFieldValues = {
+ title: string;
+ description: string;
+ price: string;
+ images: string[];
+};
+
+export function validateTitle(title: string): string | undefined {
+ const trimmedTitle = title.trim();
+
+ if (!trimmedTitle) {
+ return 'Title is required.';
+ }
+
+ if (trimmedTitle.length < 5) {
+ return 'Title must be at least 5 characters.';
+ }
+
+ if (trimmedTitle.length > 100) {
+ return 'Title must be 100 characters or less.';
+ }
+
+ return undefined;
+}
+
+export function validateDescription(description: string): string | undefined {
+ if (!description.trim()) {
+ return 'Description is required.';
+ }
+
+ return undefined;
+}
+
+export function validatePrice(price: string): string | undefined {
+ const trimmedPrice = price.trim();
+
+ if (!trimmedPrice) {
+ return 'Price is required.';
+ }
+
+ const parsedPrice = Number(trimmedPrice);
+ if (!Number.isFinite(parsedPrice) || parsedPrice < 0) {
+ return 'Enter a valid non-negative price.';
+ }
+
+ return undefined;
+}
+
+export function validateImages(images: string[]): string | undefined {
+ if (images.length < 1) {
+ return 'At least 1 photo is required.';
+ }
+
+ if (images.length > 8) {
+ return 'Maximum 8 photos allowed.';
+ }
+
+ return undefined;
+}
+
+export function validateListingFields(values: ListingFieldValues): FieldErrors {
+ const errors: FieldErrors = {};
+
+ const titleError = validateTitle(values.title);
+ if (titleError) {
+ errors.title = titleError;
+ }
+
+ const descriptionError = validateDescription(values.description);
+ if (descriptionError) {
+ errors.description = descriptionError;
+ }
+
+ const priceError = validatePrice(values.price);
+ if (priceError) {
+ errors.price = priceError;
+ }
+
+ const imagesError = validateImages(values.images);
+ if (imagesError) {
+ errors.images = imagesError;
+ }
+
+ return errors;
+}
+
+export function hasFieldErrors(errors: FieldErrors): boolean {
+ return Boolean(errors.title || errors.description || errors.price || errors.images);
+}
+
+export function upsertFieldError(
+ errors: FieldErrors,
+ key: K,
+ error: FieldErrors[K]
+): FieldErrors {
+ if (error) {
+ return { ...errors, [key]: error };
+ }
+
+ const nextErrors: FieldErrors = { ...errors };
+ delete nextErrors[key];
+ return nextErrors;
+}