diff --git a/backend/convex/__tests__/profiles.test.ts b/backend/convex/__tests__/profiles.test.ts index e2068f4..accaec8 100644 --- a/backend/convex/__tests__/profiles.test.ts +++ b/backend/convex/__tests__/profiles.test.ts @@ -75,4 +75,58 @@ describe('Profiles mutations', () => { }); }).rejects.toThrowError('Email is required to create a profile'); }); + + it('updateProfile ignores attempts to change the stored email', async () => { + const t = convexTest(schema as any, modules); + const asUser = t.withIdentity({ + name: 'Alice', + subject: 'alice-stable-id', + email: 'alice@calpoly.edu', + }); + + const profileId = await asUser.mutation(api.profiles.createProfile, { + name: 'Alice', + major: 'Computer Science', + year: 2026, + }); + + await asUser.mutation(api.profiles.updateProfile, { + name: 'Alice Updated', + email: 'updated@calpoly.edu', + }); + + const profile = await t.run(async (ctx) => { + return await ctx.db.get(profileId); + }); + + expect(profile?.name).toBe('Alice Updated'); + expect(profile?.email).toBe('alice@calpoly.edu'); + }); + + it('updateProfile rejects email-only updates and keeps the stored email unchanged', async () => { + const t = convexTest(schema as any, modules); + const asUser = t.withIdentity({ + name: 'Alice', + subject: 'alice-stable-id', + email: 'alice@calpoly.edu', + }); + + const profileId = await asUser.mutation(api.profiles.createProfile, { + name: 'Alice', + major: 'Computer Science', + year: 2026, + }); + + await expect( + asUser.mutation(api.profiles.updateProfile, { + email: 'updated@calpoly.edu', + }) + ).rejects.toThrowError('No valid fields to update'); + + const profile = await t.run(async (ctx) => { + return await ctx.db.get(profileId); + }); + + expect(profile?.email).toBe('alice@calpoly.edu'); + }); }); diff --git a/backend/convex/profiles.ts b/backend/convex/profiles.ts index b3d1f53..07770c1 100644 --- a/backend/convex/profiles.ts +++ b/backend/convex/profiles.ts @@ -200,9 +200,8 @@ export const updateProfile = mutation({ } update.name = args.name; } - if (args.email !== undefined) { - update.email = normalizeEmailInput(args.email); - } + // Backwards compatibility: released clients still send `email` on profile updates. + // Ignore it so the authenticated Cal Poly email remains immutable after onboarding. if (args.bio !== undefined) { if (args.bio.length > PAYLOAD_BOUNDS.BIO_MAX) { throw new ConvexError(`Bio must be ${PAYLOAD_BOUNDS.BIO_MAX} characters or less`); diff --git a/frontend/.env.example b/frontend/.env.example index 39f5789..ccc7276 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -12,3 +12,5 @@ EXPO_PUBLIC_APP_ORIGIN=https://polybuys.com EXPO_PUBLIC_SUPPORT_EMAIL=support@polybuys.com EXPO_PUBLIC_ENABLE_SENTRY_PII=false EXPO_PUBLIC_APP_REVIEW_EMAIL= +# Optional fallback if the EAS project ID is not embedded in app config. +EXPO_PUBLIC_EAS_PROJECT_ID= diff --git a/frontend/app/(tabs)/home.tsx b/frontend/app/(tabs)/home.tsx index ff88a04..130308e 100644 --- a/frontend/app/(tabs)/home.tsx +++ b/frontend/app/(tabs)/home.tsx @@ -19,6 +19,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FilterBar } from '../../components/FilterBar'; import { CategoryPicker } from '../../components/CategoryPicker'; +import OpenInAppPrompt from '../../components/OpenInAppPrompt'; import { PriceRangePicker } from '../../components/PriceRangePicker'; import { SortPicker } from '../../components/SortPicker'; import ListingCard from '../../components/ListingCard'; @@ -28,11 +29,20 @@ import type { Filters, Category, ListingSortBy } from '../../types/filters'; import { useAuth } from '../../hooks/useAuth'; import type { Doc, Id } from 'convex/_generated/dataModel'; import { useEntranceAnimation } from '../../hooks/useEntranceAnimation'; +import { getUserFlowErrorMessage } from '../../lib/user-flow-errors'; import { borderRadius, colors, spacing, typography } from '../../theme/tokens'; const PAGE_SIZE = 20; const FOCUS_REFETCH_STALE_MS = 45_000; +type WebHandoffPrompt = { + key: 'create-listing' | 'save-listing'; + title: string; + body: string; + path: string; + buttonLabel: string; +}; + function buildWebRows(items: Doc<'listings'>[], size: number) { const rows: Array<{ key: string; @@ -72,6 +82,7 @@ export default function HomeScreen() { const [showCategoryPicker, setShowCategoryPicker] = useState(false); const [showPricePicker, setShowPricePicker] = useState(false); const [showSortPicker, setShowSortPicker] = useState(false); + const [webHandoffPrompt, setWebHandoffPrompt] = useState(null); const [allListings, setAllListings] = useState[]>([]); const [cursor, setCursor] = useState(null); @@ -203,7 +214,13 @@ export default function HomeScreen() { const handleToggleSave = useCallback( async (listingId: Id<'listings'>) => { if (isWeb) { - Alert.alert('Open in the PolyBuys app', 'Saving listings is available in the mobile app.'); + setWebHandoffPrompt({ + key: 'save-listing', + title: 'Save listings in the mobile app', + body: 'Bookmarks and saved listings are available in the PolyBuys mobile app.', + path: `/listings/${listingId}`, + buttonLabel: 'Open Listing in App', + }); return; } @@ -215,9 +232,7 @@ export default function HomeScreen() { try { await toggleSavedListing({ listingId }); } catch (error) { - const message = - error instanceof Error ? error.message : 'Unable to save listing right now.'; - Alert.alert('Save failed', message); + Alert.alert('Save Failed', getUserFlowErrorMessage(error, 'save-listing')); } }, [isAuthenticated, isWeb, router, toggleSavedListing] @@ -262,7 +277,13 @@ export default function HomeScreen() { const handleCreateListing = () => { if (isWeb) { - Alert.alert('Open in the PolyBuys app', 'Creating listings is available in the mobile app.'); + setWebHandoffPrompt({ + key: 'create-listing', + title: 'Create listings in the mobile app', + body: 'Posting items is available in the PolyBuys mobile app.', + path: '/listings/new', + buttonLabel: 'Open Create Listing in App', + }); return; } @@ -424,6 +445,20 @@ export default function HomeScreen() { onClearAll={handleClearAll} /> + {webHandoffPrompt ? ( + setWebHandoffPrompt(null)} + cardStyle={styles.webHandoffCard} + /> + ) : null} + {!hasLoadedOnceRef.current && listingsResult === undefined && cursor === null ? ( @@ -651,6 +686,9 @@ const styles = StyleSheet.create({ webGrid: { gap: spacing.lg, }, + webHandoffCard: { + maxWidth: '100%', + }, webGridItem: { flex: 1, minWidth: 0, diff --git a/frontend/app/(tabs)/settings.tsx b/frontend/app/(tabs)/settings.tsx index f48841b..1059c78 100644 --- a/frontend/app/(tabs)/settings.tsx +++ b/frontend/app/(tabs)/settings.tsx @@ -3,13 +3,11 @@ import { Animated, Platform, Pressable, - ScrollView, StyleSheet, Text, View, useWindowDimensions, } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useMutation, usePaginatedQuery, useQuery } from 'convex/react'; import { useRouter } from 'expo-router'; import { api } from 'convex/_generated/api'; @@ -20,7 +18,8 @@ import ListingCard from '../../components/ListingCard'; import OpenInAppPrompt from '../../components/OpenInAppPrompt'; import ProfileAvatar from '../../components/ProfileAvatar'; import { ScreenState } from '../../components/ScreenState'; -import { FilterChips, type FilterChipOption } from '../../components/ui'; +import { formatMajorLabel } from '../../constants/calPolyMajors'; +import { FilterChips, ScreenScrollView, type FilterChipOption } from '../../components/ui'; import { colors, typography, spacing, borderRadius } from '../../theme/tokens'; import type { Doc } from 'convex/_generated/dataModel'; @@ -45,11 +44,9 @@ const ACCOUNT_SETTINGS = '/account-settings'; export default function SettingsScreen() { const router = useRouter(); const { width } = useWindowDimensions(); - const insets = useSafeAreaInsets(); const isWeb = Platform.OS === 'web'; const { isAuthenticated, isSessionLoading } = useAuth(); const entranceStyle = useEntranceAnimation(); - const topSafeSpace = Platform.OS === 'ios' ? Math.max(insets.top - 6, 10) : 0; const [activeTab, setActiveTab] = useState('listings'); const profile = useQuery(api.profiles.getCurrentProfile, isAuthenticated && !isWeb ? {} : 'skip'); @@ -109,11 +106,7 @@ export default function SettingsScreen() { if (!profile) { return ( - + Complete your profile @@ -135,21 +128,16 @@ export default function SettingsScreen() { - + ); } const yearLabel = yearToOrdinal(profile.year); - const profileSubtitle = `${profile.major} • ${yearLabel}`; + const profileSubtitle = `${formatMajorLabel(profile.major)} • ${yearLabel}`; return ( - - {topSafeSpace > 0 && } + @@ -272,7 +260,7 @@ export default function SettingsScreen() { )} )} - + ); } @@ -440,7 +428,7 @@ const styles = StyleSheet.create({ }, primaryPill: { flex: 1, - minHeight: 48, + minHeight: 44, borderRadius: borderRadius.full, borderWidth: 1, borderColor: colors.primary, @@ -456,7 +444,7 @@ const styles = StyleSheet.create({ }, secondaryPill: { flex: 1, - minHeight: 48, + minHeight: 44, borderRadius: borderRadius.full, borderWidth: 1, borderColor: colors.border, diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 72fd49c..aeb0ff2 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -11,6 +11,7 @@ import { Platform, StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { FlashProvider } from '../contexts/FlashContext'; import { usePushNotifications } from '../hooks/usePushNotifications'; +import { getRequiredExpoPublicEnv } from '../lib/env'; import { colors, typography } from '../theme/tokens'; const navigationTheme = { @@ -27,6 +28,7 @@ const navigationTheme = { }; const allowSentryPii = process.env.EXPO_PUBLIC_ENABLE_SENTRY_PII === 'true'; +const convexUrl = getRequiredExpoPublicEnv('EXPO_PUBLIC_CONVEX_URL'); export const unstable_settings = { initialRouteName: '(tabs)', }; @@ -34,11 +36,11 @@ export const unstable_settings = { Sentry.init({ dsn: 'https://ed516d30275214d7429df46a33c04764@o4510288242933760.ingest.us.sentry.io/4511024032382976', sendDefaultPii: allowSentryPii, - enableLogs: true, + enableLogs: __DEV__, // spotlight: __DEV__, }); -const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, { +const convex = new ConvexReactClient(convexUrl, { unsavedChangesWarning: false, }); diff --git a/frontend/app/account-settings.tsx b/frontend/app/account-settings.tsx index 0db76f3..a100f70 100644 --- a/frontend/app/account-settings.tsx +++ b/frontend/app/account-settings.tsx @@ -18,6 +18,8 @@ import { useAuth } from '../hooks/useAuth'; import { requestPermissionAndSyncToken } from '../hooks/usePushNotifications'; import OpenInAppPrompt from '../components/OpenInAppPrompt'; import { ScreenState } from '../components/ScreenState'; +import { formatMajorLabel } from '../constants/calPolyMajors'; +import { getUserFlowErrorMessage } from '../lib/user-flow-errors'; import { borderRadius, colors, spacing, typography } from '../theme/tokens'; type BlockedRow = { @@ -80,8 +82,7 @@ export default function AccountSettingsScreen() { setIsSigningOut(true); await signOut(); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to sign out'; - Alert.alert('Sign Out Failed', message); + Alert.alert('Sign Out Failed', getUserFlowErrorMessage(error, 'sign-out')); } finally { signOutInProgressRef.current = false; setIsSigningOut(false); @@ -101,19 +102,17 @@ export default function AccountSettingsScreen() { try { await deleteAccount({}); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to delete account'; - Alert.alert('Delete Account Failed', message); + Alert.alert( + 'Delete Account Failed', + getUserFlowErrorMessage(error, 'delete-account') + ); return; } try { await signOut(); } catch (error) { - const details = error instanceof Error ? `\n\nDetails: ${error.message}` : ''; - Alert.alert( - 'Account Deleted', - `Your account was deleted, but we could not sign you out automatically. Please sign out manually.${details}` - ); + Alert.alert('Account Deleted', getUserFlowErrorMessage(error, 'post-delete-signout')); } }, }, @@ -136,10 +135,7 @@ export default function AccountSettingsScreen() { try { await unblockUser({ blockedId: row.blockedId }); } catch (err) { - Alert.alert( - 'Could not unblock', - err instanceof Error ? err.message : 'Please try again.' - ); + Alert.alert('Could Not Unblock', getUserFlowErrorMessage(err, 'unblock-user')); } finally { setUnblockingId(null); } @@ -167,9 +163,10 @@ export default function AccountSettingsScreen() { permissionGranted = await requestPermissionAndSyncToken(recordPushToken); } catch (error) { setMessageNotificationsValue(previousValue); - const message = - error instanceof Error ? error.message : 'Unable to enable notifications right now.'; - Alert.alert('Notification Update Failed', message); + Alert.alert( + 'Notification Update Failed', + getUserFlowErrorMessage(error, 'notifications-enable') + ); return; } @@ -186,9 +183,10 @@ export default function AccountSettingsScreen() { await updateMessageNotificationsEnabled({ enabled: true }); } catch (error) { setMessageNotificationsValue(previousValue); - const message = - error instanceof Error ? error.message : 'Failed to update notification preference'; - Alert.alert('Notification Update Failed', message); + Alert.alert( + 'Notification Update Failed', + getUserFlowErrorMessage(error, 'notifications-enable') + ); } } else { let removePushTokenSucceeded = false; @@ -203,36 +201,24 @@ export default function AccountSettingsScreen() { try { await updateMessageNotificationsEnabled({ enabled: false }); if (!removePushTokenSucceeded) { - const removeTokenMessage = - removePushTokenError instanceof Error - ? removePushTokenError.message - : 'Failed to remove this device push token.'; Alert.alert( - 'Notification partially disabled', - `Your notification preference was saved, but we could not remove this device's push token. You may still receive some notifications.\n\nDetails: ${removeTokenMessage}` + 'Notifications Turned Off', + 'Notifications were turned off for your account, but this device may still receive a few alerts for a short time.' ); } } catch (error) { - const updatePreferenceMessage = - error instanceof Error ? error.message : 'Failed to update notification preference'; - if (removePushTokenSucceeded) { Alert.alert( - 'Notification partially updated', - `This device push token was removed, but we could not save your notification preference.\n\nDetails: ${updatePreferenceMessage}` + 'Notification Preference Not Saved', + 'This device was updated, but we could not save your account preference. Try again in a moment.' ); return; } setMessageNotificationsValue(previousValue); - const removeTokenMessage = - removePushTokenError instanceof Error - ? removePushTokenError.message - : 'Failed to remove this device push token.'; - Alert.alert( 'Notification Update Failed', - `We could not disable notifications.\n\nToken removal: ${removeTokenMessage}\nPreference update: ${updatePreferenceMessage}` + getUserFlowErrorMessage(error ?? removePushTokenError, 'notifications-disable') ); return; } @@ -264,7 +250,7 @@ export default function AccountSettingsScreen() { {item.major ? ( - {item.major} + {formatMajorLabel(item.major)} ) : null} @@ -466,6 +452,7 @@ const styles = StyleSheet.create({ paddingVertical: spacing.xl, paddingHorizontal: spacing.md, alignItems: 'center', + justifyContent: 'center', gap: spacing.sm, minHeight: 88, }, diff --git a/frontend/app/auth/__tests__/loginRedirect.test.ts b/frontend/app/auth/__tests__/loginRedirect.test.ts index b4f7726..3f98e2d 100644 --- a/frontend/app/auth/__tests__/loginRedirect.test.ts +++ b/frontend/app/auth/__tests__/loginRedirect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@jest/globals'; -import { getLoginEntryAction } from '../loginRedirect'; +import { getLoginEntryAction } from '../../../lib/auth/loginRedirect'; describe('getLoginEntryAction', () => { it('keeps waiting while the profile query is unresolved', () => { diff --git a/frontend/app/auth/login.tsx b/frontend/app/auth/login.tsx index ec5305b..c5d1442 100644 --- a/frontend/app/auth/login.tsx +++ b/frontend/app/auth/login.tsx @@ -1,30 +1,44 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { Picker } from '@react-native-picker/picker'; import { ActivityIndicator, Animated, - View, - Text, - TextInput, - Pressable, - StyleSheet, + Alert, KeyboardAvoidingView, Platform, ScrollView, - Alert, + Pressable, + StyleSheet, + Text, + TextInput, + View, } from 'react-native'; import { useLocalSearchParams, useRouter, type Href } from 'expo-router'; import { useAuthActions } from '@convex-dev/auth/react'; import { useMutation, useQuery } from 'convex/react'; import { api } from 'convex/_generated/api'; import { getEmailValidationError, PROFILE_BOUNDS } from '@polybuys/shared'; +import { MajorPicker } from '../../components/MajorPicker'; +import { formatMajorLabel, isCalPolyMajor } from '../../constants/calPolyMajors'; +import { + GRADUATION_YEAR_DEFAULT, + GRADUATION_YEAR_MAX, + GRADUATION_YEAR_MIN, + GRADUATION_YEAR_OPTIONS, +} from '../../constants/graduationYears'; +import { KeyboardUnderlay } from '../../components/ui'; import { useEntranceAnimation } from '../../hooks/useEntranceAnimation'; import { useAuth } from '../../hooks/useAuth'; +import { useKeyboardHeight } from '../../hooks/useKeyboardHeight'; import { requestPermissionAndSyncToken } from '../../hooks/usePushNotifications'; -import { getLoginEntryAction, type LoginStep } from './loginRedirect'; +import { getLoginEntryAction, type LoginStep } from '../../lib/auth/loginRedirect'; +import { getUserFlowErrorMessage } from '../../lib/user-flow-errors'; import { colors, typography, spacing, borderRadius } from '../../theme/tokens'; const APP_REVIEW_EMAIL = (process.env.EXPO_PUBLIC_APP_REVIEW_EMAIL ?? '').toLowerCase().trim(); +type LoginErrorContext = 'send-code' | 'verify-code' | 'resend-code' | 'create-profile'; + function providerForEmail(emailAddress: string): 'resend-otp' | 'ios-review-otp' { const normalized = emailAddress.toLowerCase().trim(); return APP_REVIEW_EMAIL.length > 0 && normalized === APP_REVIEW_EMAIL @@ -32,6 +46,70 @@ function providerForEmail(emailAddress: string): 'resend-otp' | 'ios-review-otp' : 'resend-otp'; } +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message.trim() : ''; +} + +function isExistingProfileError(error: unknown): boolean { + return getErrorMessage(error).toLowerCase().includes('profile already exists'); +} + +function getLoginErrorMessage(error: unknown, context: LoginErrorContext): string { + const rawMessage = getErrorMessage(error); + const normalizedMessage = rawMessage.toLowerCase(); + + if (context === 'send-code' || context === 'resend-code') { + if ( + normalizedMessage.includes('too many') || + normalizedMessage.includes('rate limit') || + normalizedMessage.includes('rate-limit') + ) { + return 'Too many attempts. Wait a minute, then request a new code.'; + } + + return context === 'resend-code' + ? 'We could not resend your code right now. Please try again in a minute.' + : 'We could not send your code right now. Please try again in a minute.'; + } + + if (context === 'verify-code') { + if ( + normalizedMessage.includes('could not verify code') || + normalizedMessage.includes('invalid code') || + normalizedMessage.includes('expired') + ) { + return 'That code is invalid or expired. Request a new one and try again.'; + } + + if ( + normalizedMessage.includes('matching `email`') || + normalizedMessage.includes('requires an `email`') + ) { + return 'That code no longer matches this email. Request a new one and try again.'; + } + + return 'We could not verify that code. Request a new one and try again.'; + } + + if (normalizedMessage.includes('email is required')) { + return 'We could not finish setting up your profile. Please sign in again.'; + } + + if (normalizedMessage.includes('name must be')) { + return `Display name must be ${PROFILE_BOUNDS.NAME_MIN}-${PROFILE_BOUNDS.NAME_MAX} characters.`; + } + + if (normalizedMessage.includes('major must be')) { + return 'Choose your major from the official Cal Poly majors list.'; + } + + if (normalizedMessage.includes('bio must be')) { + return 'Your bio is too long. Shorten it and try again.'; + } + + return 'We could not complete your profile right now. Please try again.'; +} + export default function LoginScreen() { const router = useRouter(); const isWeb = Platform.OS === 'web'; @@ -55,11 +133,13 @@ export default function LoginScreen() { const [code, setCode] = useState(''); const [name, setName] = useState(''); const [major, setMajor] = useState(''); - const [year, setYear] = useState(''); + const [year, setYear] = useState(GRADUATION_YEAR_DEFAULT); const [isLoading, setIsLoading] = useState(false); + const [isMajorPickerVisible, setIsMajorPickerVisible] = useState(false); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [checkingTimedOut, setCheckingTimedOut] = useState(false); + const keyboardHeight = useKeyboardHeight(); const verifiedEmailRef = useRef(null); const retryTimerRef = useRef | null>(null); @@ -180,8 +260,7 @@ export default function LoginScreen() { await signIn(providerForEmail(normalizedEmail), { email: normalizedEmail }); setStep({ email: normalizedEmail }); } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to send code'; - setError(errorMessage); + setError(getLoginErrorMessage(err, 'send-code')); } finally { setIsLoading(false); } @@ -207,8 +286,7 @@ export default function LoginScreen() { verifiedEmailRef.current = step.email; setStep('checking'); } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Invalid code. Please try again.'; - setError(errorMessage); + setError(getLoginErrorMessage(err, 'verify-code')); } finally { setIsLoading(false); } @@ -226,8 +304,7 @@ export default function LoginScreen() { setCode(''); setSuccessMessage('A new code has been sent to your email'); } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to resend code'; - setError(errorMessage); + setError(getLoginErrorMessage(err, 'resend-code')); } finally { setIsLoading(false); } @@ -240,6 +317,11 @@ export default function LoginScreen() { setSuccessMessage(null); }; + function finishAndRedirect() { + setSuccessRedirect(postAuthRedirect); + setStep('success'); + } + const handleCompleteProfile = async () => { const trimmedName = name.trim(); const trimmedMajor = major.trim(); @@ -254,6 +336,14 @@ export default function LoginScreen() { ); return; } + if (!trimmedMajor) { + Alert.alert('Select major', 'Choose your Cal Poly major from the official majors list.'); + return; + } + if (!isCalPolyMajor(trimmedMajor)) { + Alert.alert('Invalid major', 'Choose a major from the official Cal Poly majors list.'); + return; + } if ( trimmedMajor.length < PROFILE_BOUNDS.MAJOR_MIN || trimmedMajor.length > PROFILE_BOUNDS.MAJOR_MAX @@ -265,21 +355,15 @@ export default function LoginScreen() { return; } - const currentYear = new Date().getFullYear(); - const boundedCurrentYear = Math.min( - Math.max(currentYear, PROFILE_BOUNDS.MIN_YEAR), - PROFILE_BOUNDS.MAX_YEAR - ); - const yearInput = year.trim().length > 0 ? year.trim() : String(boundedCurrentYear); - const parsedYear = Number(yearInput); + const parsedYear = Number(year); if ( !Number.isInteger(parsedYear) || - parsedYear < PROFILE_BOUNDS.MIN_YEAR || - parsedYear > PROFILE_BOUNDS.MAX_YEAR + parsedYear < GRADUATION_YEAR_MIN || + parsedYear > GRADUATION_YEAR_MAX ) { Alert.alert( 'Invalid year', - `Year must be between ${PROFILE_BOUNDS.MIN_YEAR} and ${PROFILE_BOUNDS.MAX_YEAR}.` + `Graduation year must be between ${GRADUATION_YEAR_MIN} and ${GRADUATION_YEAR_MAX}.` ); return; } @@ -296,8 +380,12 @@ export default function LoginScreen() { }); setStep('push'); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to create profile'; - setError(message); + if (isExistingProfileError(err)) { + finishAndRedirect(); + return; + } + + setError(getLoginErrorMessage(err, 'create-profile')); } finally { setIsLoading(false); } @@ -316,6 +404,8 @@ export default function LoginScreen() { return null; } + const selectedMajorLabel = major ? formatMajorLabel(major) : ''; + if (isWelcomeStep) { return ( @@ -375,11 +465,6 @@ export default function LoginScreen() { ); } - const finishAndRedirect = () => { - setSuccessRedirect(postAuthRedirect); - setStep('success'); - }; - const persistMessageNotificationsPreference = async (enabled: boolean) => { await updateMessageNotificationsEnabled({ enabled }); }; @@ -395,9 +480,10 @@ export default function LoginScreen() { try { await persistMessageNotificationsPreference(messageNotificationsEnabled); } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to save notification preference.'; - Alert.alert('Notification preference not saved', message); + Alert.alert( + 'Notification Preference Not Saved', + getUserFlowErrorMessage(error, 'notifications-enable') + ); return; } @@ -409,9 +495,10 @@ export default function LoginScreen() { try { await persistMessageNotificationsPreference(messageNotificationsEnabled); } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to save notification preference.'; - Alert.alert('Notification preference not saved', message); + Alert.alert( + 'Notification Preference Not Saved', + getUserFlowErrorMessage(error, 'notifications-disable') + ); return; } @@ -479,6 +566,7 @@ export default function LoginScreen() { style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} > + @@ -509,31 +597,48 @@ export default function LoginScreen() { Major - + [ + styles.input, + styles.selectionInput, + pressed && styles.buttonPressed, + isLoading && styles.buttonDisabled, + ]} + onPress={() => setIsMajorPickerVisible(true)} + disabled={isLoading} + accessibilityRole="button" + accessibilityLabel={ + selectedMajorLabel ? `Selected major ${selectedMajorLabel}` : 'Select major' + } + > + + {selectedMajorLabel || 'Search and select your major'} + + Graduation year - + + setYear(String(nextValue))} + enabled={!isLoading} + itemStyle={styles.yearPickerItem} + style={styles.yearPicker} + > + {GRADUATION_YEAR_OPTIONS.map((option) => ( + + ))} + + {error && ( @@ -560,6 +665,12 @@ export default function LoginScreen() { + setIsMajorPickerVisible(false)} + /> ); } @@ -569,6 +680,7 @@ export default function LoginScreen() { style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} > + @@ -808,6 +920,36 @@ const styles = StyleSheet.create({ ...typography.body, backgroundColor: colors.background, }, + selectionInput: { + minHeight: 52, + justifyContent: 'center', + }, + selectionInputText: { + ...typography.body, + color: colors.textDark, + }, + selectionInputPlaceholder: { + color: colors.muted, + }, + yearPickerFrame: { + height: Platform.OS === 'ios' ? 168 : 56, + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.lg, + backgroundColor: colors.background, + overflow: 'hidden', + }, + yearPickerDisabled: { + opacity: 0.75, + }, + yearPicker: { + flex: 1, + color: colors.textDark, + }, + yearPickerItem: { + color: colors.textDark, + fontSize: 20, + }, codeInput: { textAlign: 'center', fontSize: 24, diff --git a/frontend/app/conversations/[id].tsx b/frontend/app/conversations/[id].tsx index 3d72477..c7b4098 100644 --- a/frontend/app/conversations/[id].tsx +++ b/frontend/app/conversations/[id].tsx @@ -3,7 +3,6 @@ import { Alert, FlatList, Keyboard, - KeyboardAvoidingView, Platform, Pressable, StyleSheet, @@ -13,7 +12,6 @@ import { View, } from 'react-native'; import { useHeaderHeight } from '@react-navigation/elements'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useAction, useMutation, useQuery } from 'convex/react'; import { api } from 'convex/_generated/api'; @@ -25,6 +23,8 @@ import ProfileAvatar from '../../components/ProfileAvatar'; import { ReportModal } from '../../components/ReportModal'; import SafetyBanner from '../../components/SafetyBanner'; import { ScreenState } from '../../components/ScreenState'; +import { KeyboardDockScreen } from '../../components/ui'; +import { getUserFlowErrorMessage } from '../../lib/user-flow-errors'; import { colors, typography, spacing, borderRadius } from '../../theme/tokens'; type ConversationId = Id<'conversations'>; @@ -120,7 +120,6 @@ export default function ConversationDetailScreen() { const router = useRouter(); const isWeb = Platform.OS === 'web'; - const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const { user, isAuthenticated, isSessionLoading } = useAuth(); const sendMessage = useAction(api.messages.sendMessage); @@ -283,9 +282,16 @@ export default function ConversationDetailScreen() { if (!otherUserId) return; if (isBlockingOther === true) { - unblockUser({ blockedId: otherUserId }).catch((err) => { - Alert.alert('Could not unblock', err instanceof Error ? err.message : 'Please try again.'); - }); + const unblockAction = () => { + unblockUser({ blockedId: otherUserId }).catch((err) => { + Alert.alert('Could Not Unblock', getUserFlowErrorMessage(err, 'unblock-user')); + }); + }; + + Alert.alert('Unblock user', 'Allow this user to message you again?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Unblock', onPress: () => void unblockAction() }, + ]); return; } @@ -299,7 +305,7 @@ export default function ConversationDetailScreen() { Alert.alert('User blocked', 'You will no longer receive messages from this user.'); } catch (err) { - Alert.alert('Could not block', err instanceof Error ? err.message : 'Please try again.'); + Alert.alert('Could Not Block', getUserFlowErrorMessage(err, 'block-user')); } }; @@ -332,8 +338,7 @@ export default function ConversationDetailScreen() { setMessageBody(''); scrollToBottom(true); } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to send message right now.'; - Alert.alert('Message failed', message); + Alert.alert('Message Failed', getUserFlowErrorMessage(error, 'send-message')); } finally { setIsSending(false); } @@ -402,143 +407,147 @@ export default function ConversationDetailScreen() { return ( - - ( - [styles.headerAction, pressed && styles.buttonPressed]} - > - - {isBlockingOther === true ? 'Unblock' : 'Block'} - - - ) - : undefined, - }} - /> - - - - - - {headerOtherUserName} - - - {headerListingTitle} - - - {listingId ? ( - router.push(`/listings/${listingId}`)} - style={({ pressed }) => [styles.headerListingLink, pressed && styles.buttonPressed]} - accessibilityRole="button" - accessibilityLabel="View listing" - > - View listing - - ) : null} - - - - - item._id} - style={styles.messagesList} - contentInsetAdjustmentBehavior="automatic" - contentContainerStyle={styles.messagesContent} - keyboardShouldPersistTaps="handled" - onScrollBeginDrag={() => { - setActiveMessageActionId(null); - }} - renderItem={({ item }) => { - const isSent = currentUserId !== null && item.senderId === currentUserId; - const receiptLabel = item.readAt > 0 ? 'Read' : 'Sent'; - return ( - { - Alert.alert( - 'Report message', - 'This will submit a report and hide this conversation from your inbox.', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Report', - style: 'destructive', - onPress: () => setReportingMessageId(messageId), - }, - ] - ); - }} - /> - ); - }} - ListEmptyComponent={ - - No messages yet. Say hello. - + + + + You have blocked this user. Tap Unblock above to send messages. + + + ) : isBlockedByOther === true ? ( + + + You cannot send messages in this conversation because this user has blocked you. + + + ) : ( + <> + + { + void onSend(); + }} + style={({ pressed }) => [ + styles.sendButton, + (!messageBody.trim() || isSending) && styles.sendButtonDisabled, + pressed && styles.buttonPressed, + ]} + disabled={!messageBody.trim() || isSending} + > + {isSending ? 'Sending...' : 'Send'} + + + ) } - /> - - - {isBlockingOther === true ? ( - - - You have blocked this user. Tap Unblock above to send messages. + > + ( + [ + styles.headerAction, + pressed && styles.buttonPressed, + ]} + > + + {isBlockingOther === true ? 'Unblock' : 'Block'} + + + ) + : undefined, + }} + /> + + + + + + {headerOtherUserName} - - ) : isBlockedByOther === true ? ( - - - You cannot send messages in this conversation because this user has blocked you. + + {headerListingTitle} - ) : ( - <> - + {listingId ? ( { - void onSend(); - }} - style={({ pressed }) => [ - styles.sendButton, - (!messageBody.trim() || isSending) && styles.sendButtonDisabled, - pressed && styles.buttonPressed, - ]} - disabled={!messageBody.trim() || isSending} + onPress={() => router.push(`/listings/${listingId}`)} + style={({ pressed }) => [styles.headerListingLink, pressed && styles.buttonPressed]} + accessibilityRole="button" + accessibilityLabel="View listing" > - {isSending ? 'Sending...' : 'Send'} + View listing - - )} - + ) : null} + + + + + item._id} + style={styles.messagesList} + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={styles.messagesContent} + keyboardShouldPersistTaps="handled" + onScrollBeginDrag={() => { + setActiveMessageActionId(null); + }} + renderItem={({ item }) => { + const isSent = currentUserId !== null && item.senderId === currentUserId; + const receiptLabel = item.readAt > 0 ? 'Read' : 'Sent'; + return ( + { + Alert.alert( + 'Report message', + 'This will submit a report and hide this conversation from your inbox.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Report', + style: 'destructive', + onPress: () => setReportingMessageId(messageId), + }, + ] + ); + }} + /> + ); + }} + ListEmptyComponent={ + + No messages yet. Say hello. + + } + /> + setReportingMessageId(null)} @@ -549,7 +558,7 @@ export default function ConversationDetailScreen() { router.replace('/inbox' as never); }} /> - + ); } @@ -559,6 +568,9 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.surface, }, + touchWrapper: { + flex: 1, + }, centeredState: { flex: 1, justifyContent: 'center', diff --git a/frontend/app/conversations/new.tsx b/frontend/app/conversations/new.tsx index b65097a..f61226d 100644 --- a/frontend/app/conversations/new.tsx +++ b/frontend/app/conversations/new.tsx @@ -14,11 +14,14 @@ import { Stack, useLocalSearchParams, useRouter, type Href } from 'expo-router'; import { useAction, useQuery } from 'convex/react'; import { api } from 'convex/_generated/api'; import { Id } from 'convex/_generated/dataModel'; +import { KeyboardUnderlay } from '../../components/ui'; import { useAuth } from '../../hooks/useAuth'; +import { useKeyboardHeight } from '../../hooks/useKeyboardHeight'; import { useResolvedImageUrls } from '../../hooks/useResolvedImageUrls'; import OpenInAppPrompt from '../../components/OpenInAppPrompt'; import ProfileAvatar from '../../components/ProfileAvatar'; import { ScreenState } from '../../components/ScreenState'; +import { getUserFlowErrorMessage } from '../../lib/user-flow-errors'; import { colors, typography, spacing, borderRadius } from '../../theme/tokens'; export default function NewConversationScreen() { @@ -31,6 +34,7 @@ export default function NewConversationScreen() { const router = useRouter(); const headerHeight = useHeaderHeight(); const isWeb = Platform.OS === 'web'; + const keyboardHeight = useKeyboardHeight(); const { isAuthenticated, isSessionLoading } = useAuth(); const createConversationAndSendFirstMessage = useAction( api.messages.createConversationAndSendFirstMessage @@ -67,8 +71,7 @@ export default function NewConversationScreen() { params: { id: String(conversationId) }, }); } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to send message right now.'; - Alert.alert('Message failed', message); + Alert.alert('Message Failed', getUserFlowErrorMessage(error, 'send-first-message')); } finally { setIsSending(false); } @@ -136,6 +139,7 @@ export default function NewConversationScreen() { behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight : 0} > + 1; const hasPreviousImage = imageIndex > 0; const hasNextImage = imageIndex < mappedUrls.length - 1; + const bookmarkScale = useRef(new Animated.Value(1)).current; + const prevDisplayedSavedRef = useRef(null); + const displayedSaved = savedOptimistic ?? isSaved ?? false; + + const playBookmarkSavedAnimation = useCallback(() => { + if (reduceMotion) return; + bookmarkScale.setValue(1); + Animated.sequence([ + Animated.spring(bookmarkScale, { + toValue: 1.2, + friction: 5, + tension: 280, + useNativeDriver: true, + }), + Animated.spring(bookmarkScale, { + toValue: 1, + friction: 7, + tension: 200, + useNativeDriver: true, + }), + ]).start(); + }, [bookmarkScale, reduceMotion]); + + const playBookmarkUnsavedAnimation = useCallback(() => { + if (reduceMotion) return; + Animated.sequence([ + Animated.timing(bookmarkScale, { + toValue: 0.88, + duration: 90, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.spring(bookmarkScale, { + toValue: 1, + friction: 6, + tension: 220, + useNativeDriver: true, + }), + ]).start(); + }, [bookmarkScale, reduceMotion]); useEffect(() => { setImageIndex((currentIndex) => { @@ -146,6 +194,29 @@ export default function ListingDetailScreen() { }); }, [mappedUrls.length]); + useEffect(() => { + prevDisplayedSavedRef.current = null; + setSavedOptimistic(null); + bookmarkScale.setValue(1); + }, [bookmarkScale, listingId]); + + useEffect(() => { + if (prevDisplayedSavedRef.current === null) { + prevDisplayedSavedRef.current = displayedSaved; + return; + } + if (prevDisplayedSavedRef.current === displayedSaved) return; + + const wasSaved = prevDisplayedSavedRef.current; + prevDisplayedSavedRef.current = displayedSaved; + + if (displayedSaved && !wasSaved) { + playBookmarkSavedAnimation(); + } else if (!displayedSaved && wasSaved) { + playBookmarkUnsavedAnimation(); + } + }, [displayedSaved, playBookmarkSavedAnimation, playBookmarkUnsavedAnimation]); + const onMessageSellerPress = () => { if (!listing) return; @@ -191,8 +262,7 @@ export default function ListingDetailScreen() { params: { id: String(conversationId) }, } as never); } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to send message right now.'; - Alert.alert('Message failed', message); + Alert.alert('Message Failed', getUserFlowErrorMessage(error, 'send-first-message')); } finally { setIsSendingMessage(false); } @@ -270,9 +340,8 @@ export default function ListingDetailScreen() { const shareUrl = `${appOrigin}/l/${listing._id}`; try { await Share.share({ - message: `${listing.title} - $${formatPrice(listing.price)}\n${shareUrl}`, + message: shareUrl, url: shareUrl, - title: listing.title, }); } catch { Alert.alert('Unable to share listing right now.'); @@ -299,11 +368,7 @@ export default function ListingDetailScreen() { await updateListingStatus({ id: listing._id, status: 'sold' }); setFlash('Listing marked as sold.'); } catch (error) { - const message = - error instanceof Error && error.message - ? error.message - : 'Failed to mark listing as sold. Please try again.'; - Alert.alert('Error', message); + Alert.alert('Could Not Mark as Sold', getUserFlowErrorMessage(error, 'mark-listing-sold')); } finally { setMarkingSold(false); } @@ -557,26 +622,28 @@ export default function ListingDetailScreen() { > Message Seller - [styles.iconButton, pressed && styles.buttonPressed]} + void onSavePress()} - accessibilityLabel={(savedOptimistic ?? isSaved) ? 'Unsave listing' : 'Save listing'} - accessibilityRole="button" + accessibilityLabel={displayedSaved ? 'Unsave listing' : 'Save listing'} + pressedScale={0.94} > - - - [styles.iconButton, pressed && styles.buttonPressed]} + + + + + void shareListing()} accessibilityLabel="Share listing" - accessibilityRole="button" + pressedScale={0.94} > - + )} @@ -615,7 +682,7 @@ export default function ListingDetailScreen() { {sellerProfile.name} - {sellerProfile.major} · Year {sellerProfile.year} + {formatMajorLabel(sellerProfile.major)} · Year {sellerProfile.year} @@ -689,6 +756,10 @@ export default function ListingDetailScreen() { > + { it('collects required field errors for an empty submission', () => { diff --git a/frontend/app/listings/new.tsx b/frontend/app/listings/new.tsx index 71b69a2..a6f81b3 100644 --- a/frontend/app/listings/new.tsx +++ b/frontend/app/listings/new.tsx @@ -17,9 +17,6 @@ import ImageUploader from '@/components/ImageUploader'; import { useFlash } from '../../contexts/FlashContext'; import { useAuth } from '../../hooks/useAuth'; 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, @@ -29,7 +26,11 @@ import { validateListingFields, validatePrice, validateTitle, -} from './newListingValidation'; +} from '../../lib/listings/newListingValidation'; +import { getUserFlowErrorMessage } from '../../lib/user-flow-errors'; +import OpenInAppPrompt from '../../components/OpenInAppPrompt'; +import { KeyboardAwareScreen, ScreenHeader } from '../../components/ui'; +import { colors, typography, borderRadius, spacing } from '../../theme/tokens'; const categories = ['textbooks', 'electronics', 'furniture', 'tickets', 'other'] as const; const conditions = ['new', 'used', 'refurbished'] as const; @@ -65,7 +66,7 @@ function getListingActionError(error: unknown, fallbackTitle: string) { return { title: fallbackTitle, - message: rawMessage, + message: getUserFlowErrorMessage(error, 'create-listing'), }; } function RequiredLabel({ text }: { text: string }) { diff --git a/frontend/app/profile/[userId].tsx b/frontend/app/profile/[userId].tsx index 2b11f87..603cc55 100644 --- a/frontend/app/profile/[userId].tsx +++ b/frontend/app/profile/[userId].tsx @@ -1,4 +1,5 @@ import { + Alert, Platform, Pressable, ScrollView, @@ -9,7 +10,7 @@ import { } from 'react-native'; import { useState } from 'react'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useQuery } from 'convex/react'; +import { useMutation, useQuery } from 'convex/react'; import { api } from 'convex/_generated/api'; import type { Doc } from 'convex/_generated/dataModel'; import { useFlash } from '../../contexts/FlashContext'; @@ -19,7 +20,9 @@ import ListingCard from '../../components/ListingCard'; import ProfileAvatar from '../../components/ProfileAvatar'; import { ReportModal } from '../../components/ReportModal'; import { ScreenState } from '../../components/ScreenState'; +import { formatMajorLabel } from '../../constants/calPolyMajors'; import { REPORT_SUBMITTED_MESSAGE } from '../../constants/feedbackMessages'; +import { getUserFlowErrorMessage } from '../../lib/user-flow-errors'; import { colors, typography, spacing, borderRadius } from '../../theme/tokens'; function yearToOrdinal(gradYear: number): string { @@ -38,6 +41,7 @@ export default function PublicProfileScreen() { const isWeb = Platform.OS === 'web'; const { user, isAuthenticated } = useAuth(); const [reportOpen, setReportOpen] = useState(false); + const [isUpdatingBlock, setIsUpdatingBlock] = useState(false); let resolvedUserId: string | null = null; if (typeof rawUserId === 'string') { const trimmedUserId = rawUserId.trim(); @@ -63,8 +67,63 @@ export default function PublicProfileScreen() { ); const avatarUrl = avatarUrls[0]; const isWideLayout = width >= 980; - const canReportProfile = - !isWeb && isAuthenticated && resolvedUserId !== null && user?._id !== resolvedUserId; + const isOwnProfile = resolvedUserId !== null && user?._id === resolvedUserId; + const canManageProfile = !isWeb && isAuthenticated && resolvedUserId !== null && !isOwnProfile; + const canReportProfile = canManageProfile; + const blockUser = useMutation(api.blocks.blockUser); + const unblockUser = useMutation(api.blocks.unblockUser); + const isBlockingProfile = useQuery( + api.blocks.isBlocking, + canManageProfile && resolvedUserId ? { blockedId: resolvedUserId } : 'skip' + ); + + const handleBlockPress = () => { + if (!resolvedUserId || isUpdatingBlock || isBlockingProfile === undefined) { + return; + } + + const commitBlockChange = async (nextBlocked: boolean) => { + setIsUpdatingBlock(true); + try { + if (nextBlocked) { + const blockId = await blockUser({ blockedId: resolvedUserId }); + if (!blockId) { + Alert.alert('User unavailable', 'This user is no longer available to block.'); + return; + } + setFlash('User blocked. You will no longer receive messages from this user.'); + return; + } + + await unblockUser({ blockedId: resolvedUserId }); + setFlash('User unblocked.'); + } catch (error) { + Alert.alert( + nextBlocked ? 'Could Not Block' : 'Could Not Unblock', + getUserFlowErrorMessage(error, nextBlocked ? 'block-user' : 'unblock-user') + ); + } finally { + setIsUpdatingBlock(false); + } + }; + + if (isBlockingProfile === true) { + Alert.alert('Unblock user', 'Allow this user to message you again?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Unblock', onPress: () => void commitBlockChange(false) }, + ]); + return; + } + + Alert.alert( + 'Block user', + 'You will no longer receive messages from this user. They will not be notified.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Block', style: 'destructive', onPress: () => void commitBlockChange(true) }, + ] + ); + }; if (!resolvedUserId) { return ( @@ -109,7 +168,7 @@ export default function PublicProfileScreen() { {profile.name} - {profile.major} • {yearLabel} + {formatMajorLabel(profile.major)} • {yearLabel} {listings.length} {listings.length === 1 ? 'listing' : 'listings'} @@ -119,14 +178,52 @@ export default function PublicProfileScreen() { {profile.bio ? {profile.bio} : null} - {canReportProfile && ( - [styles.reportButton, pressed && styles.reportButtonPressed]} - onPress={() => setReportOpen(true)} - hitSlop={6} - > - Report - + {canManageProfile && ( + + [ + styles.actionButton, + isBlockingProfile === true ? styles.unblockButton : styles.blockButton, + pressed && styles.actionButtonPressed, + (isUpdatingBlock || isBlockingProfile === undefined) && styles.actionButtonDisabled, + ]} + onPress={handleBlockPress} + disabled={isUpdatingBlock || isBlockingProfile === undefined} + accessibilityRole="button" + accessibilityLabel={ + isBlockingProfile === true ? 'Unblock this user' : 'Block this user' + } + > + + {isUpdatingBlock + ? isBlockingProfile === true + ? 'Unblocking...' + : 'Blocking...' + : isBlockingProfile === true + ? 'Unblock' + : 'Block'} + + + + [ + styles.actionButton, + styles.reportButton, + pressed && styles.actionButtonPressed, + ]} + onPress={() => setReportOpen(true)} + disabled={isUpdatingBlock} + accessibilityRole="button" + accessibilityLabel="Report this profile" + > + Report + + )} @@ -208,23 +305,51 @@ const styles = StyleSheet.create({ color: colors.text, lineHeight: 22, }, - reportButton: { - alignSelf: 'flex-start', - paddingVertical: spacing.sm, - paddingHorizontal: 0, - minHeight: 44, - minWidth: 44, + profileActions: { + flexDirection: 'row', + gap: spacing.sm, + flexWrap: 'wrap', + }, + actionButton: { + minHeight: 42, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + borderWidth: 1, justifyContent: 'center', alignItems: 'center', + backgroundColor: colors.white, }, - reportButtonPressed: { - opacity: 0.7, + actionButtonPressed: { + opacity: 0.92, }, - reportButtonText: { + actionButtonDisabled: { + opacity: 0.65, + }, + actionButtonText: { ...typography.footnote, - color: colors.destructive, fontWeight: '600', }, + blockButton: { + borderColor: 'rgba(179, 38, 30, 0.2)', + backgroundColor: 'rgba(179, 38, 30, 0.06)', + }, + blockButtonText: { + color: colors.destructive, + }, + unblockButton: { + borderColor: 'rgba(21, 71, 52, 0.18)', + backgroundColor: 'rgba(21, 71, 52, 0.06)', + }, + unblockButtonText: { + color: colors.primary, + }, + reportButton: { + borderColor: 'rgba(179, 38, 30, 0.2)', + backgroundColor: colors.white, + }, + reportButtonText: { + color: colors.destructive, + }, sectionTitle: { ...typography.title2, color: colors.textDark, diff --git a/frontend/app/profile/edit.tsx b/frontend/app/profile/edit.tsx index a88568c..90211fd 100644 --- a/frontend/app/profile/edit.tsx +++ b/frontend/app/profile/edit.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Picker } from '@react-native-picker/picker'; import { ActivityIndicator, Alert, @@ -15,20 +16,22 @@ import { api } from 'convex/_generated/api'; import { Id } from 'convex/_generated/dataModel'; import * as ImagePicker from 'expo-image-picker'; import { SaveFormat, manipulateAsync } from 'expo-image-manipulator'; -import { getEmailValidationError } from '@polybuys/shared'; +import { MajorPicker } from '../../components/MajorPicker'; +import { formatMajorLabel, isCalPolyMajor } from '../../constants/calPolyMajors'; +import { + GRADUATION_YEAR_DEFAULT, + getGraduationYearOptions, + isSupportedGraduationYear, +} from '../../constants/graduationYears'; import { useAuth } from '../../hooks/useAuth'; import OpenInAppPrompt from '../../components/OpenInAppPrompt'; import ProfileAvatar from '../../components/ProfileAvatar'; import { KeyboardAwareScreen } from '../../components/ui'; import { useResolvedImageUrls } from '../../hooks/useResolvedImageUrls'; import { useFlash } from '../../contexts/FlashContext'; +import { getUserFlowErrorMessage } from '../../lib/user-flow-errors'; import { colors, typography, spacing, borderRadius } from '../../theme/tokens'; -const BOUNDS = { - MIN_YEAR: 1900, - MAX_YEAR: 9999, -}; -const DEFAULT_YEAR = '2026'; const PROFILE_IMAGE_BOUNDS = { MAX_WIDTH: 1200, MAX_FILE_SIZE_MB: 5, @@ -115,7 +118,7 @@ async function uploadImageToConvex( export default function ProfileEditScreen() { const router = useRouter(); const isWeb = Platform.OS === 'web'; - const { isAuthenticated } = useAuth(); + const { user, isAuthenticated, isUserLoading } = useAuth(); const { setFlash } = useFlash(); const profile = useQuery(api.profiles.getCurrentProfile, isAuthenticated && !isWeb ? {} : 'skip'); const createProfile = useMutation(api.profiles.createProfile); @@ -124,17 +127,23 @@ export default function ProfileEditScreen() { const uploadAbortRef = useRef(null); const [name, setName] = useState(''); - const [email, setEmail] = useState(''); const [bio, setBio] = useState(''); const [major, setMajor] = useState(''); - const [year, setYear] = useState(DEFAULT_YEAR); + const [year, setYear] = useState(GRADUATION_YEAR_DEFAULT); const [picture, setPicture] = useState | null>(null); const [pendingPictureUri, setPendingPictureUri] = useState(null); const [isPreparingPicture, setIsPreparingPicture] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const [isMajorPickerVisible, setIsMajorPickerVisible] = useState(false); const [loadedKey, setLoadedKey] = useState(null); const { mappedUrls: pictureUrls } = useResolvedImageUrls(picture ? [picture] : []); const pictureUrl = pendingPictureUri ?? pictureUrls[0] ?? null; + const profileEmail = profile?.email ?? user?.email ?? ''; + const isScreenLoading = !isAuthenticated || profile === undefined || (!profile && isUserLoading); + const yearOptions = useMemo( + () => getGraduationYearOptions({ preserveYear: profile?.year }), + [profile?.year] + ); useEffect(() => { if (!isAuthenticated || profile === undefined) return; @@ -144,17 +153,15 @@ export default function ProfileEditScreen() { if (profile) { setName(profile.name); - setEmail(profile.email); setBio(profile.bio ?? ''); setMajor(profile.major); setYear(String(profile.year)); setPicture(profile.picture ?? null); } else { setName(''); - setEmail(''); setBio(''); setMajor(''); - setYear(DEFAULT_YEAR); + setYear(GRADUATION_YEAR_DEFAULT); setPicture(null); } setPendingPictureUri(null); @@ -223,8 +230,7 @@ export default function ProfileEditScreen() { setPendingPictureUri(manipulated.uri); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to prepare profile image'; - setFlash(message); + setFlash(getUserFlowErrorMessage(error, 'prepare-profile-image')); } finally { setIsPreparingPicture(false); } @@ -239,32 +245,25 @@ export default function ProfileEditScreen() { const trimmedName = name.trim(); const trimmedMajor = major.trim(); const trimmedBio = bio.trim(); - const normalizedEmail = email.trim().toLowerCase(); if (!trimmedName) { setFlash('Name is required.'); return; } if (!trimmedMajor) { - setFlash('Major is required.'); + setFlash('Choose your major from the official Cal Poly majors list.'); return; } - - const emailError = getEmailValidationError(normalizedEmail); - if (emailError) { - setFlash(emailError); + if (!isCalPolyMajor(trimmedMajor)) { + setFlash('Choose your major from the official Cal Poly majors list.'); return; } - const parsedYear = Number(year); - if ( - !Number.isInteger(parsedYear) || - parsedYear < BOUNDS.MIN_YEAR || - parsedYear > BOUNDS.MAX_YEAR - ) { - setFlash(`Year must be between ${BOUNDS.MIN_YEAR} and ${BOUNDS.MAX_YEAR}.`); + if (!isSupportedGraduationYear(year, yearOptions)) { + setFlash('Choose a graduation year from the list.'); return; } + const parsedYear = Number(year); try { setIsSubmitting(true); @@ -285,7 +284,6 @@ export default function ProfileEditScreen() { if (!profile) { await createProfile({ name: trimmedName, - email: normalizedEmail, bio: trimmedBio || undefined, picture: nextPicture ?? undefined, major: trimmedMajor, @@ -294,7 +292,6 @@ export default function ProfileEditScreen() { } else { await updateProfile({ name: trimmedName, - email: normalizedEmail, bio: trimmedBio || undefined, picture: nextPicture, major: trimmedMajor, @@ -309,8 +306,7 @@ export default function ProfileEditScreen() { ]); } catch (error) { uploadAbortRef.current = null; - const message = error instanceof Error ? error.message : 'Failed to save profile'; - setFlash(message); + setFlash(getUserFlowErrorMessage(error, 'save-profile')); } finally { setIsSubmitting(false); } @@ -329,7 +325,7 @@ export default function ProfileEditScreen() { ); } - if (!isAuthenticated || profile === undefined) { + if (isScreenLoading) { return ( @@ -337,6 +333,9 @@ export default function ProfileEditScreen() { ); } + const selectedMajorLabel = major ? formatMajorLabel(major) : ''; + const hasInvalidMajorSelection = major.length > 0 && !isCalPolyMajor(major); + return ( @@ -394,19 +393,15 @@ export default function ProfileEditScreen() { /> - Email * - + Cal Poly email * + + + {profileEmail || 'Email unavailable'} + + + + Your campus email is managed by your sign-in and can't be changed here. + Bio @@ -423,28 +418,52 @@ export default function ProfileEditScreen() { Major * - + [ + styles.input, + styles.selectionInput, + pressed && styles.buttonPressed, + isSubmitting && styles.buttonDisabled, + ]} + onPress={() => setIsMajorPickerVisible(true)} + disabled={isSubmitting} + accessibilityRole="button" + accessibilityLabel={ + selectedMajorLabel ? `Selected major ${selectedMajorLabel}` : 'Select major' + } + > + + {selectedMajorLabel || 'Search and select your major'} + + + {hasInvalidMajorSelection ? ( + + Please reselect your major from the official Cal Poly majors list before saving. + + ) : null} Graduation year * - + + setYear(String(nextValue))} + enabled={!isSubmitting} + itemStyle={styles.yearPickerItem} + style={styles.yearPicker} + > + {yearOptions.map((option) => ( + + ))} + + Save profile )} + setIsMajorPickerVisible(false)} + /> ); } @@ -548,10 +573,56 @@ const styles = StyleSheet.create({ backgroundColor: colors.white, color: colors.textDark, }, + selectionInput: { + minHeight: 52, + justifyContent: 'center', + }, + selectionInputText: { + ...typography.body, + color: colors.textDark, + }, + selectionInputPlaceholder: { + color: colors.muted, + }, + selectionInputWarning: { + color: colors.warningText, + }, + readOnlyInput: { + minHeight: 52, + justifyContent: 'center', + backgroundColor: colors.placeholderBg, + }, + readOnlyValue: { + ...typography.body, + color: colors.textDark, + }, + helperText: { + ...typography.footnote, + color: colors.gray, + }, textArea: { minHeight: 90, textAlignVertical: 'top', }, + yearPickerFrame: { + height: Platform.OS === 'ios' ? 168 : 56, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: borderRadius.md, + backgroundColor: colors.white, + overflow: 'hidden', + }, + yearPickerDisabled: { + opacity: 0.75, + }, + yearPicker: { + flex: 1, + color: colors.textDark, + }, + yearPickerItem: { + color: colors.textDark, + fontSize: 20, + }, saveButton: { backgroundColor: colors.primary, borderRadius: borderRadius.md, diff --git a/frontend/components/ListingCard.tsx b/frontend/components/ListingCard.tsx index dd7ff70..9924a73 100644 --- a/frontend/components/ListingCard.tsx +++ b/frontend/components/ListingCard.tsx @@ -1,14 +1,14 @@ import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Ionicons from '@expo/vector-icons/Ionicons'; import { useRouter } from 'expo-router'; import { Animated, Easing, Image, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; -import { BlurView } from 'expo-blur'; import { motion } from '../theme/motion'; import { formatPrice } from '../lib/formatPrice'; import { formatRelativeDate } from '../lib/formatDate'; import { colors, typography, borderRadius, spacing } from '../theme/tokens'; -import { nativeChrome } from '../theme/nativeChrome'; import { useResolvedImageUrls } from '../hooks/useResolvedImageUrls'; import { useReducedMotion } from '../hooks/useReducedMotion'; +import { GlassIconButton } from './ui'; /** LRU-capped ids that already ran the entrance animation. */ const MAX_ANIMATED_CACHE = 200; @@ -308,38 +308,42 @@ export default function ListingCard({ )} - {onToggleSave && ( - [styles.saveButton, pressed && styles.saveButtonPressed]} - onPress={onToggleSave} - accessibilityLabel={`${isSaved ? 'Unsave' : 'Save'} listing: ${listing.title?.trim() || listing._id || 'listing'}`} - accessibilityRole="button" - hitSlop={8} - > - - - - {isSaved ? '♥' : '♡'} - - - - )} - {onManagePress && ( - [styles.manageButton, pressed && styles.manageButtonPressed]} - onPress={onManagePress} - accessibilityLabel={`Manage listing: ${listing.title?.trim() || listing._id || 'listing'}`} - accessibilityRole="button" - hitSlop={8} - > - - - - - - - - )} + {onToggleSave || onManagePress ? ( + + {onToggleSave ? ( + + + + + + ) : null} + {onManagePress ? ( + + + + + + + + ) : null} + + ) : null} {footer ? {footer} : null} @@ -383,6 +387,13 @@ const styles = StyleSheet.create({ cardContainer: { position: 'relative', }, + floatingActions: { + position: 'absolute', + top: spacing.xs, + right: spacing.xs, + alignItems: 'flex-end', + gap: spacing.xs, + }, listingCard: { borderRadius: borderRadius.md, backgroundColor: colors.surface, @@ -413,48 +424,14 @@ const styles = StyleSheet.create({ imageContainerHome: {}, imageContainerHomeWeb: {}, saveButton: { - position: 'absolute', - top: spacing.xs, - right: spacing.xs, width: 40, height: 40, borderRadius: 20, - overflow: 'hidden', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.6)', - borderWidth: 1, - borderColor: 'rgba(21, 71, 52, 0.12)', - boxShadow: '0 10px 20px rgba(0, 0, 0, 0.14)', - }, - saveButtonPressed: { - opacity: 0.9, - }, - saveIcon: { - fontSize: 15, - color: colors.textDark, - }, - saveIconActive: { - color: colors.category, }, manageButton: { - position: 'absolute', - top: spacing.xs, - right: spacing.xs, width: 36, height: 36, borderRadius: 18, - overflow: 'hidden', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.6)', - borderWidth: 1, - borderColor: 'rgba(21, 71, 52, 0.12)', - boxShadow: '0 10px 20px rgba(0, 0, 0, 0.14)', - }, - manageButtonPressed: { - opacity: 0.85, - transform: [{ scale: 0.96 }], }, manageDots: { flexDirection: 'row', diff --git a/frontend/components/MajorPicker.tsx b/frontend/components/MajorPicker.tsx new file mode 100644 index 0000000..3b5cee7 --- /dev/null +++ b/frontend/components/MajorPicker.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from 'react'; +import { FlatList, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { CAL_POLY_MAJORS, formatMajorLabel, majorMatchesQuery } from '../constants/calPolyMajors'; +import { useEntranceAnimation } from '../hooks/useEntranceAnimation'; +import { ModalSheet } from './ui'; +import { borderRadius, colors, spacing, typography } from '../theme/tokens'; + +type MajorPickerProps = { + visible: boolean; + selectedMajor?: string; + onSelect: (major: string) => void; + onClose: () => void; +}; + +export function MajorPicker({ visible, selectedMajor, onSelect, onClose }: MajorPickerProps) { + const entranceStyle = useEntranceAnimation(40, 8); + const [query, setQuery] = useState(''); + + useEffect(() => { + if (!visible) { + setQuery(''); + } + }, [visible]); + + const filteredMajors = useMemo( + () => CAL_POLY_MAJORS.filter((major) => majorMatchesQuery(major, query)), + [query] + ); + + const handleSelect = (major: string) => { + onSelect(major); + onClose(); + }; + + return ( + + + Select your major + + Search the official Cal Poly majors list and choose the best match. + + + item} + style={styles.list} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="interactive" + contentInsetAdjustmentBehavior="automatic" + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContent} + renderItem={({ item }) => { + const isSelected = item === selectedMajor; + const optionLabel = formatMajorLabel(item); + return ( + handleSelect(item)} + accessible + accessibilityRole="button" + accessibilityLabel={optionLabel} + accessibilityState={{ selected: isSelected }} + > + + {optionLabel} + + {isSelected ? : null} + + ); + }} + ListEmptyComponent={ + + No majors found + Try a broader search term. + + } + /> + + ); +} + +const styles = StyleSheet.create({ + handle: { + width: 40, + height: 4, + backgroundColor: colors.border, + borderRadius: borderRadius.full, + alignSelf: 'center', + marginVertical: spacing.md, + }, + title: { + ...typography.title2, + color: colors.textDark, + textAlign: 'center', + }, + subtitle: { + ...typography.footnote, + color: colors.text, + textAlign: 'center', + marginTop: spacing.xs, + marginBottom: spacing.md, + }, + searchInput: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + ...typography.subhead, + color: colors.textDark, + backgroundColor: colors.surface, + marginBottom: spacing.md, + }, + list: { + flex: 1, + }, + listContent: { + gap: spacing.xs, + paddingBottom: spacing.sm, + }, + option: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + optionSelected: { + backgroundColor: colors.background, + borderColor: colors.locationDark, + }, + optionText: { + flex: 1, + ...typography.subhead, + color: colors.textDark, + }, + optionTextSelected: { + color: colors.primary, + fontWeight: '600', + }, + checkmark: { + ...typography.subhead, + color: colors.primary, + fontWeight: '700', + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.xxl, + gap: spacing.xs, + }, + emptyStateTitle: { + ...typography.heading, + color: colors.textDark, + }, + emptyStateText: { + ...typography.footnote, + color: colors.text, + }, +}); diff --git a/frontend/components/OpenInAppPrompt.tsx b/frontend/components/OpenInAppPrompt.tsx index 8bca91b..209e38a 100644 --- a/frontend/components/OpenInAppPrompt.tsx +++ b/frontend/components/OpenInAppPrompt.tsx @@ -1,5 +1,7 @@ import * as ExpoLinking from 'expo-linking'; import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { getUserFlowErrorMessage } from '../lib/user-flow-errors'; import { borderRadius, colors, spacing, typography } from '../theme/tokens'; import { APP_SCHEME, APP_STORE_URL } from '../constants/app'; @@ -11,6 +13,9 @@ type OpenInAppPromptProps = { buttonLabel: string; secondaryActionLabel?: string; onSecondaryAction?: () => void; + variant?: 'page' | 'card'; + pageStyle?: StyleProp; + cardStyle?: StyleProp; }; export default function OpenInAppPrompt({ @@ -20,6 +25,9 @@ export default function OpenInAppPrompt({ buttonLabel, secondaryActionLabel, onSecondaryAction, + variant = 'page', + pageStyle, + cardStyle, }: OpenInAppPromptProps) { const normalizedPath = path.startsWith('/') ? path.slice(1) : path; const deepLink = `${APP_SCHEME}://${normalizedPath}`; @@ -28,57 +36,59 @@ export default function OpenInAppPrompt({ try { await ExpoLinking.openURL(deepLink); } catch (error) { - const message = - error instanceof Error ? error.message : 'Unable to open the PolyBuys app link.'; - Alert.alert('Open in app failed', message); + Alert.alert('Open in App Failed', getUserFlowErrorMessage(error, 'open-in-app')); } }; - return ( - - - Mobile app - {title} - {body} + const handleDownload = async () => { + try { + await ExpoLinking.openURL(APP_STORE_URL); + } catch (error) { + Alert.alert('Download Failed', getUserFlowErrorMessage(error, 'download-app')); + } + }; + + const card = ( + + Mobile app + {title} + {body} + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={() => void handleOpenInApp()} + accessibilityRole="button" + accessibilityLabel={buttonLabel} + > + {buttonLabel} + + + {deepLink} + + {secondaryActionLabel && onSecondaryAction ? ( [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={() => void handleOpenInApp()} + style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onSecondaryAction} accessibilityRole="button" - accessibilityLabel={buttonLabel} + accessibilityLabel={secondaryActionLabel} > - {buttonLabel} + {secondaryActionLabel} - - {deepLink} - - {secondaryActionLabel && onSecondaryAction ? ( - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={onSecondaryAction} - accessibilityRole="button" - accessibilityLabel={secondaryActionLabel} - > - {secondaryActionLabel} - - ) : null} - { - try { - await ExpoLinking.openURL(APP_STORE_URL); - } catch (error) { - const message = - error instanceof Error ? error.message : 'Unable to open the download link.'; - Alert.alert('Download failed', message); - } - }} - accessibilityRole="link" - accessibilityLabel="Download the PolyBuys app" - > - Don't have the app? Download it here. - - + ) : null} + void handleDownload()} + accessibilityRole="link" + accessibilityLabel="Download the PolyBuys app" + > + Don't have the app? Download it here. + ); + + if (variant === 'card') { + return card; + } + + return {card}; } const styles = StyleSheet.create({ diff --git a/frontend/components/PriceRangePicker.tsx b/frontend/components/PriceRangePicker.tsx index fcdec7c..d5e021d 100644 --- a/frontend/components/PriceRangePicker.tsx +++ b/frontend/components/PriceRangePicker.tsx @@ -1,19 +1,20 @@ import React, { useEffect, useRef, useState } from 'react'; import { Animated, - View, - Text, - TouchableOpacity, - StyleSheet, - Modal, - Pressable, - TextInput, Keyboard, + Modal, Platform, + Pressable, ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, useWindowDimensions, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { KeyboardUnderlay } from './ui'; import { useEntranceAnimation } from '../hooks/useEntranceAnimation'; import { motion } from '../theme/motion'; import { colors } from '../theme/tokens'; @@ -185,6 +186,7 @@ export function PriceRangePicker({ onPress={onClose} accessibilityLabel="Close price filter" /> + & { + children: ReactNode; + style?: PressableProps['style']; + containerStyle?: StyleProp; + pressedScale?: number; +}; + +export function GlassIconButton({ + children, + style, + containerStyle, + pressedScale = 0.96, + accessibilityRole = 'button', + ...props +}: GlassIconButtonProps) { + return ( + [ + styles.button, + state.pressed && styles.buttonPressed, + state.pressed && { transform: [{ scale: pressedScale }] }, + props.disabled && styles.buttonDisabled, + containerStyle, + typeof style === 'function' ? style(state) : style, + ]} + {...props} + > + + + + {children} + + ); +} + +const styles = StyleSheet.create({ + button: { + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.28)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.72)', + boxShadow: '0 12px 24px rgba(12, 22, 18, 0.16)', + }, + buttonPressed: { + opacity: 0.98, + }, + buttonDisabled: { + opacity: 0.55, + }, + glassBase: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(255,255,255,0.22)', + }, + glassHighlight: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '54%', + backgroundColor: 'rgba(255,255,255,0.18)', + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.22)', + }, +}); + +export default GlassIconButton; diff --git a/frontend/components/ui/KeyboardAwareScreen.tsx b/frontend/components/ui/KeyboardAwareScreen.tsx index a1a05e9..1980c35 100644 --- a/frontend/components/ui/KeyboardAwareScreen.tsx +++ b/frontend/components/ui/KeyboardAwareScreen.tsx @@ -1,9 +1,12 @@ import { ReactNode } from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; -import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet } from 'react-native'; +import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native'; import { useHeaderHeight } from '@react-navigation/elements'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useKeyboardHeight } from '../../hooks/useKeyboardHeight'; import { colors } from '../../theme/tokens'; +import { KeyboardUnderlay } from './KeyboardUnderlay'; +import { ScreenScrollView } from './ScreenScrollView'; interface KeyboardAwareScreenProps { children: ReactNode; @@ -14,6 +17,27 @@ interface KeyboardAwareScreenProps { /** Disable the default safe-area bottom padding (useful when the screen renders its own fixed footer). */ disableSafeAreaBottom?: boolean; keyboardShouldPersistTaps?: 'always' | 'handled' | 'never'; + keyboardUnderlayColor?: string; +} + +function getNumericPadding(value: ViewStyle['padding'] | ViewStyle['paddingVertical']) { + return typeof value === 'number' ? value : 0; +} + +function getCallerPaddingBottom(style?: ViewStyle) { + if (!style) { + return 0; + } + + if (style.paddingBottom !== undefined) { + return getNumericPadding(style.paddingBottom); + } + + if (style.paddingVertical !== undefined) { + return getNumericPadding(style.paddingVertical); + } + + return getNumericPadding(style.padding); } export function KeyboardAwareScreen({ @@ -23,10 +47,15 @@ export function KeyboardAwareScreen({ extraOffset = 0, disableSafeAreaBottom = false, keyboardShouldPersistTaps = 'handled', + keyboardUnderlayColor = colors.surface, }: KeyboardAwareScreenProps) { const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); + const keyboardHeight = useKeyboardHeight(); const bottomPadding = disableSafeAreaBottom ? 0 : insets.bottom + 8; + const flattenedContentContainerStyle = StyleSheet.flatten(contentContainerStyle); + const callerPaddingBottom = getCallerPaddingBottom(flattenedContentContainerStyle); + const mergedPaddingBottom = bottomPadding + callerPaddingBottom; return ( - + {children} - + ); } diff --git a/frontend/components/ui/KeyboardDockScreen.tsx b/frontend/components/ui/KeyboardDockScreen.tsx new file mode 100644 index 0000000..dadb0b6 --- /dev/null +++ b/frontend/components/ui/KeyboardDockScreen.tsx @@ -0,0 +1,63 @@ +import { ReactNode } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useKeyboardHeight } from '../../hooks/useKeyboardHeight'; +import { colors, spacing } from '../../theme/tokens'; +import { KeyboardUnderlay } from './KeyboardUnderlay'; + +type KeyboardDockScreenProps = { + children: ReactNode; + dock: ReactNode; + style?: StyleProp; + contentStyle?: StyleProp; + dockStyle?: StyleProp; + keyboardVerticalOffset?: number; + compactBottomPadding?: number; + expandedBottomPadding?: number; + keyboardUnderlayColor?: string; +}; + +export function KeyboardDockScreen({ + children, + dock, + style, + contentStyle, + dockStyle, + keyboardVerticalOffset = 0, + compactBottomPadding = spacing.xs, + expandedBottomPadding = spacing.sm, + keyboardUnderlayColor = colors.surface, +}: KeyboardDockScreenProps) { + const insets = useSafeAreaInsets(); + const keyboardHeight = useKeyboardHeight(); + const bottomPadding = + keyboardHeight > 0 ? compactBottomPadding : Math.max(insets.bottom, expandedBottomPadding); + + return ( + + + {children} + {dock} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.surface, + }, + content: { + flex: 1, + }, + dock: { + backgroundColor: colors.surface, + }, +}); + +export default KeyboardDockScreen; diff --git a/frontend/components/ui/KeyboardUnderlay.tsx b/frontend/components/ui/KeyboardUnderlay.tsx new file mode 100644 index 0000000..7fa7be7 --- /dev/null +++ b/frontend/components/ui/KeyboardUnderlay.tsx @@ -0,0 +1,36 @@ +import type { StyleProp, ViewStyle } from 'react-native'; +import { StyleSheet, View } from 'react-native'; + +type KeyboardUnderlayProps = { + keyboardHeight: number; + backgroundColor: string; + style?: StyleProp; +}; + +export function KeyboardUnderlay({ + keyboardHeight, + backgroundColor, + style, +}: KeyboardUnderlayProps) { + if (keyboardHeight <= 0) { + return null; + } + + return ( + + ); +} + +const styles = StyleSheet.create({ + underlay: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + }, +}); + +export default KeyboardUnderlay; diff --git a/frontend/components/ui/ModalSheet.tsx b/frontend/components/ui/ModalSheet.tsx new file mode 100644 index 0000000..8577f4c --- /dev/null +++ b/frontend/components/ui/ModalSheet.tsx @@ -0,0 +1,90 @@ +import { ReactNode } from 'react'; +import { + Animated, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + StyleSheet, + View, +} from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useKeyboardHeight } from '../../hooks/useKeyboardHeight'; +import { borderRadius, colors, spacing } from '../../theme/tokens'; +import { KeyboardUnderlay } from './KeyboardUnderlay'; + +type ModalSheetProps = { + visible: boolean; + onClose: () => void; + children: ReactNode; + sheetStyle?: StyleProp>; + keyboardUnderlayColor?: string; +}; + +export function ModalSheet({ + visible, + onClose, + children, + sheetStyle, + keyboardUnderlayColor = colors.white, +}: ModalSheetProps) { + const insets = useSafeAreaInsets(); + const keyboardHeight = useKeyboardHeight({ enabled: visible }); + + return ( + + + + + + + {children} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.18)', + }, + backdrop: { + flex: 1, + justifyContent: 'flex-end', + }, + sheet: { + flex: 1, + backgroundColor: colors.white, + borderTopLeftRadius: borderRadius.xl, + borderTopRightRadius: borderRadius.xl, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: spacing.xl, + paddingTop: spacing.sm, + }, +}); + +export default ModalSheet; diff --git a/frontend/components/ui/ScreenScrollView.tsx b/frontend/components/ui/ScreenScrollView.tsx new file mode 100644 index 0000000..43bc659 --- /dev/null +++ b/frontend/components/ui/ScreenScrollView.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from 'react'; +import type { ScrollViewProps, StyleProp, ViewStyle } from 'react-native'; +import { ScrollView, StyleSheet } from 'react-native'; +import { colors } from '../../theme/tokens'; + +export interface ScreenScrollViewProps extends Omit< + ScrollViewProps, + 'style' | 'contentContainerStyle' +> { + children: ReactNode; + style?: StyleProp; + contentContainerStyle?: StyleProp; +} + +export function ScreenScrollView({ + children, + style, + contentContainerStyle, + contentInsetAdjustmentBehavior = 'automatic', + keyboardShouldPersistTaps = 'handled', + showsVerticalScrollIndicator = false, + ...rest +}: ScreenScrollViewProps) { + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.surface, + }, + content: { + flexGrow: 1, + }, +}); + +export default ScreenScrollView; diff --git a/frontend/components/ui/index.ts b/frontend/components/ui/index.ts index 42507ac..720dbbe 100644 --- a/frontend/components/ui/index.ts +++ b/frontend/components/ui/index.ts @@ -3,6 +3,7 @@ export type { AppPressableProps } from './AppPressable'; export { AppText } from './AppText'; export type { AppTextProps, AppTextVariant } from './AppText'; export { AppButton } from './AppButton'; +export { GlassIconButton } from './GlassIconButton'; export { Chip } from './Chip'; export type { ChipProps, ChipVariant } from './Chip'; export { Field } from './Field'; @@ -11,4 +12,9 @@ export { ScreenHeader } from './ScreenHeader'; export { FilterChips } from './FilterChips'; export type { FilterChipOption } from './FilterChips'; export { SectionCard } from './SectionCard'; +export { KeyboardUnderlay } from './KeyboardUnderlay'; export { KeyboardAwareScreen } from './KeyboardAwareScreen'; +export { ScreenScrollView } from './ScreenScrollView'; +export type { ScreenScrollViewProps } from './ScreenScrollView'; +export { KeyboardDockScreen } from './KeyboardDockScreen'; +export { ModalSheet } from './ModalSheet'; diff --git a/frontend/constants/__tests__/calPolyMajors.test.ts b/frontend/constants/__tests__/calPolyMajors.test.ts new file mode 100644 index 0000000..c9e05f4 --- /dev/null +++ b/frontend/constants/__tests__/calPolyMajors.test.ts @@ -0,0 +1,15 @@ +import { majorMatchesQuery } from '../calPolyMajors'; + +describe('majorMatchesQuery', () => { + it('keeps campus parentheticals searchable', () => { + expect(majorMatchesQuery('Mechanical Engineering (BS) (Solano Campus)', 'solano')).toBe(true); + expect( + majorMatchesQuery('Mechanical Engineering (BS) (San Luis Obispo Campus)', 'luis obispo') + ).toBe(true); + }); + + it('continues to ignore degree suffixes while matching the major name', () => { + expect(majorMatchesQuery('Computer Science (BS)', 'computer science')).toBe(true); + expect(majorMatchesQuery('Computer Science (BS)', 'bs')).toBe(false); + }); +}); diff --git a/frontend/constants/__tests__/graduationYears.test.ts b/frontend/constants/__tests__/graduationYears.test.ts new file mode 100644 index 0000000..0c24933 --- /dev/null +++ b/frontend/constants/__tests__/graduationYears.test.ts @@ -0,0 +1,33 @@ +import { getGraduationYearOptions, isSupportedGraduationYear } from '../graduationYears'; + +describe('graduation year options', () => { + it('builds a six-year rolling window from the current year', () => { + expect(getGraduationYearOptions({ referenceDate: new Date('2027-02-10T12:00:00Z') })).toEqual([ + '2027', + '2028', + '2029', + '2030', + '2031', + '2032', + ]); + }); + + it('preserves an existing stored year outside the rolling window', () => { + const options = getGraduationYearOptions({ + referenceDate: new Date('2026-04-21T12:00:00Z'), + preserveYear: 2025, + }); + + expect(options).toEqual(['2025', '2026', '2027', '2028', '2029', '2030', '2031']); + expect(isSupportedGraduationYear('2025', options)).toBe(true); + }); + + it('does not duplicate a preserved year that already falls inside the rolling window', () => { + expect( + getGraduationYearOptions({ + referenceDate: new Date('2026-04-21T12:00:00Z'), + preserveYear: 2028, + }) + ).toEqual(['2026', '2027', '2028', '2029', '2030', '2031']); + }); +}); diff --git a/frontend/constants/calPolyMajors.ts b/frontend/constants/calPolyMajors.ts new file mode 100644 index 0000000..2cb7711 --- /dev/null +++ b/frontend/constants/calPolyMajors.ts @@ -0,0 +1,131 @@ +// Source: Official Cal Poly Academic Catalog "Programs" page +// https://catalog.calpoly.edu/programs/ +// Undergraduate bachelor degree majors, accessed April 21, 2026. + +export const CAL_POLY_MAJORS = [ + 'Aerospace Engineering (BS)', + 'Agricultural Business (BS)', + 'Agricultural Communication (BS)', + 'Agricultural Science (BS)', + 'Agricultural Systems Management (BS)', + 'Animal Science (BS)', + 'Anthropology and Geography (BS)', + 'Architectural Engineering (BS)', + 'Architecture (BArch)', + 'Art and Design (BFA)', + 'Biochemistry (BS)', + 'Biological Sciences (BS)', + 'Biomedical Engineering (BS)', + 'BioResource and Agricultural Engineering (BS)', + 'Business Administration (BS)', + 'Chemistry (BS)', + 'Child Development (BS)', + 'City and Regional Planning (BS)', + 'Civil Engineering (BS)', + 'Communication Studies (BA)', + 'Comparative Ethnic Studies (BA)', + 'Computer Engineering (BS)', + 'Computer Science (BS)', + 'Construction Management (BS)', + 'Dairy Science (BS)', + 'Economics (BS)', + 'Electrical Engineering (BS)', + 'English (BA)', + 'Environmental Earth and Soil Sciences (BS)', + 'Environmental Engineering (BS)', + 'Environmental Management and Protection (BS)', + 'Experience and Event Management (BS)', + 'Facilities Engineering Technology (BS)', + 'Food Science (BS)', + 'Forest and Fire Sciences (BS)', + 'General Engineering (BS)', + 'Graphic Communication (BS)', + 'History (BA)', + 'Industrial Engineering (BS)', + 'Industrial Technology and Packaging (BS)', + 'Interdisciplinary Studies (BA)', + 'International Strategy and Security (BA)', + 'Journalism (BS)', + 'Kinesiology (BS)', + 'Landscape Architecture (BLA)', + 'Liberal Arts and Engineering Studies (BS)', + 'Liberal Studies (BS)', + 'Manufacturing Engineering (BS)', + 'Marine Engineering Technology (BS)', + 'Marine Sciences (BS)', + 'Marine Transportation (BS)', + 'Materials Engineering (BS)', + 'Mathematics (BS)', + 'Mechanical Engineering (BS) (San Luis Obispo Campus)', + 'Mechanical Engineering (BS) (Solano Campus)', + 'Microbiology (BS)', + 'Music (BA)', + 'Nutrition (BS)', + 'Oceanography (BS)', + 'Philosophy (BA)', + 'Physics (BA)', + 'Physics (BS)', + 'Plant Sciences (BS)', + 'Political Science (BA)', + 'Psychology (BS)', + 'Public Health (BS)', + 'Sociology (BA)', + 'Software Engineering (BS)', + 'Spanish (BA)', + 'Statistics (BS)', + 'Theatre Arts (BA)', + 'Wine and Viticulture (BS)', +] as const; + +const SEARCH_STRIPPED_PARENTHETICAL_PATTERN = + /^(?:ba|barch|bfa|bla|bs|ma|mba|minor|ms|certificate|certificates|concentration|concentrations|credential|credentials)$/i; +const SEARCH_CAMPUS_TERMS = ['campus', 'san luis obispo', 'solano']; + +function normalizeParentheticalForSearch(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function shouldStripParentheticalFromMajorSearch(value: string): boolean { + const normalized = normalizeParentheticalForSearch(value); + if (!normalized) { + return true; + } + + if (SEARCH_CAMPUS_TERMS.some((term) => normalized.includes(term))) { + return false; + } + + return SEARCH_STRIPPED_PARENTHETICAL_PATTERN.test(normalized); +} + +function normalizeMajorSearchValue(value: string): string { + return value + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/\(([^)]*)\)/g, (_match, innerText: string) => + shouldStripParentheticalFromMajorSearch(innerText) ? ' ' : ` ${innerText} ` + ) + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +export function majorMatchesQuery(major: string, query: string): boolean { + const normalizedQuery = normalizeMajorSearchValue(query); + if (!normalizedQuery) { + return true; + } + + const normalizedMajor = normalizeMajorSearchValue(major); + return normalizedMajor.includes(normalizedQuery); +} + +export function isCalPolyMajor(value: string): boolean { + return CAL_POLY_MAJORS.includes(value as (typeof CAL_POLY_MAJORS)[number]); +} + +export function formatMajorLabel(major: string): string { + return major.replace(/\s+\((?:BA|BArch|BFA|BLA|BS)\)(?=\s+\(|$)/, ''); +} diff --git a/frontend/constants/graduationYears.ts b/frontend/constants/graduationYears.ts new file mode 100644 index 0000000..101d674 --- /dev/null +++ b/frontend/constants/graduationYears.ts @@ -0,0 +1,49 @@ +const GRADUATION_YEAR_WINDOW_SIZE = 6; + +type GraduationYearValue = string | number | null | undefined; + +type GraduationYearOptionsConfig = { + referenceDate?: Date; + preserveYear?: GraduationYearValue; +}; + +function normalizeGraduationYearValue(value: GraduationYearValue): string { + return String(value ?? '').trim(); +} + +function buildRollingGraduationYears(referenceDate: Date): string[] { + const startYear = referenceDate.getFullYear(); + return Array.from({ length: GRADUATION_YEAR_WINDOW_SIZE }, (_, index) => + String(startYear + index) + ); +} + +export function getGraduationYearOptions({ + referenceDate = new Date(), + preserveYear, +}: GraduationYearOptionsConfig = {}): string[] { + const rollingYears = buildRollingGraduationYears(referenceDate); + const normalizedPreserveYear = normalizeGraduationYearValue(preserveYear); + + if (!/^\d{4}$/.test(normalizedPreserveYear) || rollingYears.includes(normalizedPreserveYear)) { + return rollingYears; + } + + return [...rollingYears, normalizedPreserveYear].sort( + (left, right) => Number(left) - Number(right) + ); +} + +export const GRADUATION_YEAR_OPTIONS = getGraduationYearOptions(); +export const GRADUATION_YEAR_DEFAULT = GRADUATION_YEAR_OPTIONS[0]; +export const GRADUATION_YEAR_MIN = Number(GRADUATION_YEAR_OPTIONS[0]); +export const GRADUATION_YEAR_MAX = Number( + GRADUATION_YEAR_OPTIONS[GRADUATION_YEAR_OPTIONS.length - 1] +); + +export function isSupportedGraduationYear( + value: GraduationYearValue, + options: readonly string[] = GRADUATION_YEAR_OPTIONS +): boolean { + return options.includes(normalizeGraduationYearValue(value)); +} diff --git a/frontend/hooks/useKeyboardHeight.ts b/frontend/hooks/useKeyboardHeight.ts new file mode 100644 index 0000000..58fb642 --- /dev/null +++ b/frontend/hooks/useKeyboardHeight.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; +import { Keyboard, Platform } from 'react-native'; + +type UseKeyboardHeightOptions = { + enabled?: boolean; +}; + +export function useKeyboardHeight({ enabled = true }: UseKeyboardHeightOptions = {}): number { + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect(() => { + if (!enabled || Platform.OS === 'web') { + setKeyboardHeight(0); + return; + } + + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + + const showSubscription = Keyboard.addListener(showEvent, (event) => { + setKeyboardHeight(Math.max(event.endCoordinates.height, 0)); + }); + const hideSubscription = Keyboard.addListener(hideEvent, () => { + setKeyboardHeight(0); + }); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, [enabled]); + + return keyboardHeight; +} + +export default useKeyboardHeight; diff --git a/frontend/lib/__tests__/user-flow-errors.test.ts b/frontend/lib/__tests__/user-flow-errors.test.ts new file mode 100644 index 0000000..9acb1c7 --- /dev/null +++ b/frontend/lib/__tests__/user-flow-errors.test.ts @@ -0,0 +1,15 @@ +import { getUserFlowErrorMessage } from '../user-flow-errors'; + +describe('getUserFlowErrorMessage', () => { + it('handles string rejections', () => { + expect(getUserFlowErrorMessage('listing not found', 'save-listing')).toBe( + 'This listing is no longer available.' + ); + }); + + it('handles error-like objects with a message property', () => { + expect(getUserFlowErrorMessage({ message: 'conversation not found' }, 'send-message')).toBe( + 'This conversation is no longer available.' + ); + }); +}); diff --git a/frontend/app/auth/loginRedirect.ts b/frontend/lib/auth/loginRedirect.ts similarity index 100% rename from frontend/app/auth/loginRedirect.ts rename to frontend/lib/auth/loginRedirect.ts diff --git a/frontend/lib/env.ts b/frontend/lib/env.ts new file mode 100644 index 0000000..85a7168 --- /dev/null +++ b/frontend/lib/env.ts @@ -0,0 +1,26 @@ +const PUBLIC = { + EXPO_PUBLIC_CONVEX_URL: process.env.EXPO_PUBLIC_CONVEX_URL, +} as const; + +type PublicEnvName = keyof typeof PUBLIC; + +function readEnv(name: PublicEnvName): string | null { + const value = PUBLIC[name]; + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function getRequiredExpoPublicEnv(name: PublicEnvName): string { + const value = readEnv(name); + if (value) { + return value; + } + + throw new Error( + `Missing required environment variable ${name}. Set it in frontend/.env.local for local runs or configure it in the deployment environment before starting the app.` + ); +} diff --git a/frontend/app/listings/newListingValidation.ts b/frontend/lib/listings/newListingValidation.ts similarity index 100% rename from frontend/app/listings/newListingValidation.ts rename to frontend/lib/listings/newListingValidation.ts diff --git a/frontend/lib/user-flow-errors.ts b/frontend/lib/user-flow-errors.ts new file mode 100644 index 0000000..10841b0 --- /dev/null +++ b/frontend/lib/user-flow-errors.ts @@ -0,0 +1,298 @@ +export type UserFlowErrorContext = + | 'sign-out' + | 'delete-account' + | 'post-delete-signout' + | 'block-user' + | 'unblock-user' + | 'notifications-enable' + | 'notifications-disable' + | 'prepare-profile-image' + | 'save-profile' + | 'send-first-message' + | 'send-message' + | 'create-listing' + | 'update-listing' + | 'save-listing' + | 'mark-listing-sold' + | 'submit-report' + | 'open-in-app' + | 'download-app'; + +function getRawErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message.trim().toLowerCase(); + } + + if (typeof error === 'string') { + return error.trim().toLowerCase(); + } + + if (error && typeof (error as { message?: unknown }).message === 'string') { + return (error as { message: string }).message.trim().toLowerCase(); + } + + return ''; +} + +function includesAny(message: string, patterns: string[]): boolean { + return patterns.some((pattern) => message.includes(pattern)); +} + +function isNetworkIssue(message: string): boolean { + return includesAny(message, [ + 'network', + 'fetch', + 'timed out', + 'timeout', + 'connection', + 'socket', + 'offline', + 'internet', + 'dns', + 'temporarily unavailable', + ]); +} + +function isSessionIssue(message: string): boolean { + return includesAny(message, [ + 'not authenticated', + 'auth user not found', + 'user not found', + 'forbidden', + 'session', + 'expired', + 'unauthorized', + ]); +} + +function isUnavailableIssue(message: string): boolean { + return includesAny(message, [ + 'not found', + 'no longer available', + 'not available', + 'not active', + 'conversation not found', + 'listing not found', + 'profile not found', + 'message not found', + ]); +} + +export function getUserFlowErrorMessage(error: unknown, context: UserFlowErrorContext): string { + const message = getRawErrorMessage(error); + + if (context === 'post-delete-signout') { + return 'Your account was deleted, but we could not finish signing you out automatically. Close and reopen the app to clear your session.'; + } + + if (context === 'sign-out') { + if (isSessionIssue(message)) { + return 'Your session has already ended. Return to login if needed.'; + } + if (isNetworkIssue(message)) { + return 'We could not sign you out right now. Check your connection and try again.'; + } + return 'We could not sign you out right now. Please try again.'; + } + + if (context === 'delete-account') { + if (isSessionIssue(message)) { + return 'Your session expired. Sign in again before deleting your account.'; + } + if (isNetworkIssue(message)) { + return 'We could not delete your account right now. Check your connection and try again.'; + } + return 'We could not delete your account right now. Please try again.'; + } + + if (context === 'block-user' || context === 'unblock-user') { + if (message.includes('you cannot block yourself')) { + return 'You cannot block your own account.'; + } + if (isUnavailableIssue(message)) { + return 'This user is no longer available.'; + } + if (isSessionIssue(message)) { + return `Please sign in again before trying to ${context === 'block-user' ? 'block' : 'unblock'} this user.`; + } + if (isNetworkIssue(message)) { + return `We could not ${context === 'block-user' ? 'block' : 'unblock'} this user right now. Check your connection and try again.`; + } + return `We could not ${context === 'block-user' ? 'block' : 'unblock'} this user right now. Please try again.`; + } + + if (context === 'notifications-enable' || context === 'notifications-disable') { + const actionLabel = context === 'notifications-enable' ? 'turn on' : 'turn off'; + if (isSessionIssue(message)) { + return `Please sign in again before trying to ${actionLabel} notifications.`; + } + if (isNetworkIssue(message)) { + return `We could not ${actionLabel} notifications right now. Check your connection and try again.`; + } + return `We could not ${actionLabel} notifications right now. Please try again.`; + } + + if (context === 'prepare-profile-image') { + if (message.includes('too large after compression')) { + return 'That photo is still too large. Choose a smaller image and try again.'; + } + if (message.includes('permission')) { + return 'Please allow photo library access before choosing a profile picture.'; + } + return 'We could not prepare that photo. Try a different image and try again.'; + } + + if (context === 'save-profile') { + if (isSessionIssue(message)) { + return 'Please sign in again before saving your profile.'; + } + if ( + isNetworkIssue(message) || + includesAny(message, ['upload failed', 'upload response', 'storage id']) + ) { + return 'We could not save your profile right now. Check your connection and try again.'; + } + return 'We could not save your profile right now. Please try again.'; + } + + if (context === 'send-first-message' || context === 'send-message') { + if ( + context === 'send-first-message' && + includesAny(message, [ + 'listing not found', + 'listing is not active', + 'listing is not available', + ]) + ) { + return 'This listing is no longer available.'; + } + if (context === 'send-message' && message.includes('conversation not found')) { + return 'This conversation is no longer available.'; + } + if (message.includes("you can't message yourself")) { + return 'You cannot message your own listing.'; + } + if (message.includes('you cannot message this user')) { + return 'You cannot message this user.'; + } + if (message.includes('contains inappropriate content')) { + return 'That message could not be sent. Edit it and try again.'; + } + if (message.includes('message cannot be empty')) { + return 'Enter a message before sending.'; + } + if (message.includes('message must be')) { + return 'That message is too long. Shorten it and try again.'; + } + if (isSessionIssue(message)) { + return 'Please sign in again and try sending that message one more time.'; + } + if (isNetworkIssue(message)) { + return context === 'send-first-message' + ? 'We could not start this conversation right now. Check your connection and try again.' + : 'We could not send your message right now. Check your connection and try again.'; + } + return context === 'send-first-message' + ? 'We could not start this conversation right now. Please try again.' + : 'We could not send your message right now. Please try again.'; + } + + if (context === 'save-listing') { + if (message.includes('listing not found')) { + return 'This listing is no longer available.'; + } + if (isSessionIssue(message)) { + return 'Please sign in again before saving listings.'; + } + if (isNetworkIssue(message)) { + return 'We could not save this listing right now. Check your connection and try again.'; + } + return 'We could not save this listing right now. Please try again.'; + } + + if (context === 'create-listing') { + if (isSessionIssue(message)) { + return 'Please sign in again before creating a listing.'; + } + if (isNetworkIssue(message)) { + return 'We could not create your listing right now. Check your connection and try again.'; + } + return 'We could not create your listing right now. Please try again.'; + } + + if (context === 'update-listing') { + if ( + includesAny(message, [ + 'listing not found', + 'cannot update a sold listing', + 'cannot update a deleted listing', + ]) + ) { + return 'This listing can no longer be edited.'; + } + if (isSessionIssue(message)) { + return 'Please sign in again before updating this listing.'; + } + if (isNetworkIssue(message)) { + return 'We could not update this listing right now. Check your connection and try again.'; + } + return 'We could not update this listing right now. Please try again.'; + } + + if (context === 'mark-listing-sold') { + if ( + includesAny(message, [ + 'listing not found', + 'cannot change status of a sold listing', + 'cannot change status of a deleted listing', + ]) + ) { + return 'This listing can no longer be updated.'; + } + if (isSessionIssue(message)) { + return 'Please sign in again before updating this listing.'; + } + if (isNetworkIssue(message)) { + return 'We could not mark this listing as sold right now. Check your connection and try again.'; + } + return 'We could not mark this listing as sold right now. Please try again.'; + } + + if (context === 'submit-report') { + if (message.includes('already reported')) { + return 'You already reported this. Our team has it.'; + } + if (message.includes('report limit reached')) { + return 'You have reached the report limit for now. Try again later.'; + } + if (message.includes('notes must be')) { + return 'Your notes are too long. Shorten them and try again.'; + } + if (message.includes('please provide details when selecting "other"')) { + return 'Add a few details before submitting this report.'; + } + if (isUnavailableIssue(message)) { + return 'This content is no longer available to report.'; + } + if ( + message.includes('forbidden') || + message.includes('you can only report messages from the other participant') + ) { + return 'You cannot report this content.'; + } + if (isNetworkIssue(message)) { + return 'We could not submit your report right now. Check your connection and try again.'; + } + return 'We could not submit 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.'; + } + + if (context === 'download-app') { + return 'We could not open the download link right now. Try again in a moment.'; + } + + return 'Something went wrong. Please try again.'; +} diff --git a/frontend/package.json b/frontend/package.json index 9fbd4c7..b045e03 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@expo/vector-icons": "^15.0.3", "@polybuys/shared": "*", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-picker/picker": "2.11.4", "@sentry/react-native": "^8.3.0", "convex": "^1.32.0", "expo": "~55.0.5", diff --git a/package-lock.json b/package-lock.json index 07d6642..b03fe26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "@expo/vector-icons": "^15.0.3", "@polybuys/shared": "*", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-picker/picker": "2.11.4", "@sentry/react-native": "^8.3.0", "convex": "^1.32.0", "expo": "~55.0.5", @@ -1991,7 +1992,9 @@ "license": "Python-2.0" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2331,7 +2334,9 @@ } }, "node_modules/@expo/fingerprint/node_modules/brace-expansion": { - "version": "5.0.4", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2651,7 +2656,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2780,7 +2787,9 @@ } }, "node_modules/@jest/console/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -2972,7 +2981,9 @@ } }, "node_modules/@jest/core/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3163,7 +3174,9 @@ } }, "node_modules/@jest/fake-timers/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -3360,7 +3373,9 @@ } }, "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3447,7 +3462,9 @@ } }, "node_modules/@jest/reporters/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3639,7 +3656,9 @@ } }, "node_modules/@jest/test-sequencer/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -4225,6 +4244,19 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-picker/picker": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz", + "integrity": "sha512-Kf8h1AMnBo54b1fdiVylP2P/iFcZqzpMYcglC28EEFB1DEnOjsNr6Ucqc+3R9e91vHxEDnhZFbYDmAe79P2gjA==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.83.2", "license": "MIT", @@ -4320,7 +4352,9 @@ } }, "node_modules/@react-native/codegen/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5181,6 +5215,128 @@ "node": ">=18" } }, + "node_modules/@sentry/cli-linux-arm": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.3.0.tgz", + "integrity": "sha512-HTr08OF3+UGCt1RjcU4h37gHLbthSUf7dZzz4SGVHB3hiFzywBtnz6VibFY/U9HCqJNU9nW+WpdLkOpqzer5TA==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.3.0.tgz", + "integrity": "sha512-yNLmxu4mqep7YeJg29FQq9733EvWodZz9UillchtIzG3oX6DLGhf+ZGElSYNUMmNal4wEj30wOnXveMVLtNM0Q==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.3.0.tgz", + "integrity": "sha512-xpYeO9lU7ua0ksToOibJpYvG3HFgwqH9VG9gt2fym8lgANpqM+Nxk2tdjiVsqddeD7R3tRLMhj6PjKFxUnI8KQ==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.3.0.tgz", + "integrity": "sha512-sWgX7/kzGFHQYfJFEE2/k8I8+XqumznPJ+nEUnowjQZPZNKcK0gfX4kToJQ7BbyVS5VmxS45F3iM46+p1L2law==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.3.0.tgz", + "integrity": "sha512-GDKp3agjI56xxgSPH2Ycvd0rkwM6SawvIUIlY1SXXFU+2ecMTz+RqRKKVvyAF0p1lsTRMKtHQ2TzHpCYDPryig==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.3.0.tgz", + "integrity": "sha512-5ZllXD0feaTWW7Va32HKx/j2TVnP2hQ6cfkrZP/cyst9Wz7YDq0Q44cbEQlMTpZREuSL0tAzG92omBR9wdca7A==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.3.0.tgz", + "integrity": "sha512-kgycTi6S2Vj5ZK9lRtDR9HO7d5Ep/JopVkzcaNxhpI5TNwCd0odXb0fy5MA09PQgPQhkys+viLDPdnpem0AWEw==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/core": { "version": "10.42.0", "license": "MIT", @@ -5595,7 +5751,9 @@ "license": "ISC" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5648,7 +5806,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -5721,7 +5881,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -6188,7 +6350,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -6822,7 +6986,9 @@ } }, "node_modules/create-jest/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -7536,7 +7702,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -7620,7 +7788,9 @@ "license": "Python-2.0" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -7897,7 +8067,9 @@ } }, "node_modules/expect/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -8517,7 +8689,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -8749,7 +8923,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.4", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -8821,7 +8997,9 @@ "license": "MIT" }, "node_modules/handlebars": { - "version": "4.7.8", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9770,7 +9948,9 @@ } }, "node_modules/jest-changed-files/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -9859,7 +10039,9 @@ } }, "node_modules/jest-circus/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -9950,7 +10132,9 @@ } }, "node_modules/jest-cli/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -10126,7 +10310,9 @@ } }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -10228,7 +10414,9 @@ } }, "node_modules/jest-config/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -10344,7 +10532,9 @@ } }, "node_modules/jest-each/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -10414,7 +10604,9 @@ } }, "node_modules/jest-environment-node/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -10612,7 +10804,9 @@ } }, "node_modules/jest-mock/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -10765,7 +10959,9 @@ } }, "node_modules/jest-resolve/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -10942,7 +11138,9 @@ } }, "node_modules/jest-runner/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -11083,7 +11281,9 @@ } }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -11185,7 +11385,9 @@ } }, "node_modules/jest-runtime/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -11386,7 +11588,9 @@ } }, "node_modules/jest-snapshot/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -11575,7 +11779,9 @@ } }, "node_modules/jest-watcher/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -11643,7 +11849,9 @@ } }, "node_modules/jest-worker/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -12940,7 +13148,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -13101,7 +13311,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.3", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -13647,7 +13859,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -14360,7 +14574,9 @@ } }, "node_modules/react-native/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -14469,7 +14685,9 @@ } }, "node_modules/react-native/node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -14786,7 +15004,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -15634,7 +15854,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -15975,7 +16197,9 @@ } }, "node_modules/undici": { - "version": "6.23.0", + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -16435,7 +16659,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index cf8bf5d..7e8e40d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev": "npm run dev --workspace=frontend", "dev:frontend": "npm run dev --workspace=frontend", "dev:backend": "npm run dev --workspace=backend", + "start:prod:web": "node ./scripts/serve-static.mjs frontend/dist", "lint": "./scripts/lint.sh", "format": "./scripts/format.sh", "build": "npm run build --workspaces --if-present", diff --git a/scripts/serve-static.mjs b/scripts/serve-static.mjs new file mode 100644 index 0000000..6033b19 --- /dev/null +++ b/scripts/serve-static.mjs @@ -0,0 +1,95 @@ +import http from 'node:http'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +const distDir = path.resolve(process.cwd(), process.argv[2] ?? 'frontend/dist'); +const portValue = process.argv[3] ?? process.env.PORT ?? '4173'; +const port = Number.parseInt(portValue, 10); +const host = process.env.HOST ?? '127.0.0.1'; + +if (!Number.isInteger(port) || port <= 0) { + throw new Error(`Invalid port: ${portValue}`); +} + +const mimeTypes = new Map([ + ['.css', 'text/css; charset=utf-8'], + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.png', 'image/png'], + ['.svg', 'image/svg+xml'], + ['.ttf', 'font/ttf'], + ['.txt', 'text/plain; charset=utf-8'], + ['.webp', 'image/webp'], +]); + +async function fileExists(filePath) { + try { + const stat = await fs.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + +async function resolveAssetPath(urlPath) { + let decodedPath; + try { + decodedPath = decodeURIComponent(urlPath); + } catch { + return null; + } + + const safeRelativePath = decodedPath.replace(/^\/+/, ''); + const candidatePath = path.resolve(distDir, safeRelativePath); + const relativeCandidatePath = path.relative(distDir, candidatePath); + + if ( + relativeCandidatePath.startsWith('..') || + path.isAbsolute(relativeCandidatePath) + ) { + return null; + } + + if (await fileExists(candidatePath)) { + return candidatePath; + } + + if (path.extname(candidatePath)) { + return null; + } + + const indexPath = path.join(distDir, 'index.html'); + return (await fileExists(indexPath)) ? indexPath : null; +} + +const server = http.createServer(async (request, response) => { + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' }); + response.end('Method Not Allowed'); + return; + } + + const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? host}`); + const assetPath = await resolveAssetPath(requestUrl.pathname); + + if (!assetPath) { + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + response.end('Not Found'); + return; + } + + const extension = path.extname(assetPath); + const contentType = mimeTypes.get(extension) ?? 'application/octet-stream'; + const body = request.method === 'HEAD' ? null : await fs.readFile(assetPath); + + response.writeHead(200, { + 'content-type': contentType, + 'cache-control': extension === '.html' ? 'no-store' : 'public, max-age=300', + }); + response.end(body ?? undefined); +}); + +server.listen(port, host, () => { + console.log(`Serving ${distDir} at http://${host}:${port}`); +}); diff --git a/vercel.json b/vercel.json index 9c98181..d98e487 100644 --- a/vercel.json +++ b/vercel.json @@ -8,5 +8,32 @@ "NODE_VERSION": "20.x" } }, + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains; preload" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Permissions-Policy", + "value": "geolocation=(), microphone=()" + } + ] + } + ], "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }