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; +}