diff --git a/app/(tabs)/media/index.tsx b/app/(tabs)/media/index.tsx
index 4a63fbb..b0b2909 100644
--- a/app/(tabs)/media/index.tsx
+++ b/app/(tabs)/media/index.tsx
@@ -893,6 +893,19 @@ const FeedVideoItem = memo(function FeedVideoItem({
)}
+ {/* Spot chip */}
+ {post.spot && (
+ router.push(`/(tabs)/spots/${post.spot?._id}`)}
+ >
+
+
+ {post.spot.name}
+
+
+ )}
+
{/* Tags */}
{post.sportTypes && post.sportTypes.length > 0 && (
@@ -1379,4 +1392,24 @@ const styles = StyleSheet.create({
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
},
+ spotChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ gap: 4,
+ maxWidth: '100%',
+ backgroundColor: 'rgba(0,0,0,0.4)',
+ borderWidth: 1,
+ borderColor: `${YELLOW}80`,
+ paddingHorizontal: 10,
+ paddingVertical: 5,
+ borderRadius: 16,
+ marginBottom: 8,
+ },
+ spotChipText: {
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '600',
+ flexShrink: 1,
+ },
});
diff --git a/app/(tabs)/media/post/[postId].tsx b/app/(tabs)/media/post/[postId].tsx
index d1f467d..ac61b16 100644
--- a/app/(tabs)/media/post/[postId].tsx
+++ b/app/(tabs)/media/post/[postId].tsx
@@ -327,6 +327,29 @@ export default function PostDetailScreen() {
)}
+ {/* Spot chip */}
+ {post.spot && (
+
+ router.push(`/(tabs)/spots/${post.spot?._id}`)}
+ >
+
+
+ {post.spot.name}
+
+ {(post.spot.city || post.spot.state) && (
+
+ {[post.spot.city, post.spot.state].filter(Boolean).join(', ')}
+
+ )}
+
+
+ )}
+
{/* Comments Section */}
@@ -565,6 +588,31 @@ const styles = StyleSheet.create({
fontSize: 15,
lineHeight: 22,
},
+ spotSection: {
+ paddingHorizontal: 20,
+ paddingBottom: 16,
+ },
+ spotChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ maxWidth: '100%',
+ gap: 6,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ borderRadius: 20,
+ borderWidth: 1,
+ borderColor: `${YELLOW}60`,
+ },
+ spotChipText: {
+ fontSize: 14,
+ fontWeight: '600',
+ flexShrink: 1,
+ },
+ spotChipMeta: {
+ fontSize: 13,
+ flexShrink: 1,
+ },
commentsSection: {
paddingHorizontal: 20,
paddingTop: 16,
diff --git a/app/(tabs)/media/upload.tsx b/app/(tabs)/media/upload.tsx
index 8142e70..6adef61 100644
--- a/app/(tabs)/media/upload.tsx
+++ b/app/(tabs)/media/upload.tsx
@@ -13,8 +13,10 @@ import {
ActivityIndicator,
Alert,
Dimensions,
+ FlatList,
Image,
KeyboardAvoidingView,
+ Modal,
Platform,
Pressable,
ScrollView,
@@ -25,7 +27,7 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { type CreatePostData, createPost } from '@/lib/api/feed';
-import { getSportTypes, type SportType } from '@/lib/api/spots';
+import { getSportTypes, type SportType, type Spot, searchSpots } from '@/lib/api/spots';
import {
createVideoEntry,
uploadImageToS3,
@@ -61,6 +63,13 @@ export default function UploadScreen() {
const [trickInput, setTrickInput] = useState('');
const [visibility, setVisibility] = useState<'public' | 'homies' | 'private'>('public');
+ // Spot tagging state
+ const [selectedSpot, setSelectedSpot] = useState(null);
+ const [spotModalVisible, setSpotModalVisible] = useState(false);
+ const [spotQuery, setSpotQuery] = useState('');
+ const [spotResults, setSpotResults] = useState([]);
+ const [spotSearching, setSpotSearching] = useState(false);
+
// Upload state
const [uploadStep, setUploadStep] = useState('idle');
const [uploadProgress, setUploadProgress] = useState(0);
@@ -145,6 +154,45 @@ export default function UploadScreen() {
setTricks(tricks.filter((t) => t !== trick));
};
+ // Debounced spot search while the picker modal is open
+ useEffect(() => {
+ if (!spotModalVisible) return;
+ const query = spotQuery.trim();
+ if (query.length < 2) {
+ setSpotResults([]);
+ setSpotSearching(false);
+ return;
+ }
+ let cancelled = false;
+ setSpotSearching(true);
+ const handle = setTimeout(async () => {
+ const response = await searchSpots(query);
+ if (!cancelled) {
+ setSpotResults(response.spots);
+ setSpotSearching(false);
+ }
+ }, 350);
+ return () => {
+ cancelled = true;
+ clearTimeout(handle);
+ };
+ }, [spotQuery, spotModalVisible]);
+
+ const openSpotModal = () => {
+ setSpotQuery('');
+ setSpotResults([]);
+ setSpotModalVisible(true);
+ };
+
+ const handleSelectSpot = (spot: Spot) => {
+ setSelectedSpot(spot);
+ setSpotModalVisible(false);
+ };
+
+ const handleRemoveSpot = () => {
+ setSelectedSpot(null);
+ };
+
const handleSubmit = async () => {
if (!selectedFile) {
setError('Please select a video or image to upload');
@@ -297,6 +345,7 @@ export default function UploadScreen() {
visibility,
duration: processedVideo.duration,
aspectRatio,
+ spotId: selectedSpot?._id,
};
const post = await createPost(postData);
@@ -344,6 +393,7 @@ export default function UploadScreen() {
sportTypes: selectedSports,
tricks,
visibility,
+ spotId: selectedSpot?._id,
};
const post = await createPost(postData);
@@ -527,6 +577,46 @@ export default function UploadScreen() {
)}
+ {/* Tag a Spot */}
+
+ Tag a Spot
+ {selectedSpot ? (
+
+
+
+
+ {selectedSpot.name}
+
+ {(selectedSpot.city || selectedSpot.state) && (
+
+ {[selectedSpot.city, selectedSpot.state].filter(Boolean).join(', ')}
+
+ )}
+
+ {!isUploading && (
+
+
+
+ )}
+
+ ) : (
+
+
+
+ Tag a spot (optional)
+
+
+
+ )}
+
+
{/* Visibility */}
Who can see this?
@@ -625,6 +715,90 @@ export default function UploadScreen() {
+
+ {/* Spot Picker Modal */}
+ setSpotModalVisible(false)}
+ >
+
+
+ Tag a Spot
+ setSpotModalVisible(false)}
+ >
+
+
+
+
+
+
+
+ {spotQuery.length > 0 && (
+ setSpotQuery('')} hitSlop={8}>
+
+
+ )}
+
+
+ {spotSearching ? (
+
+
+
+ ) : (
+ item._id}
+ keyboardShouldPersistTaps="handled"
+ contentContainerStyle={styles.modalListContent}
+ renderItem={({ item }) => (
+ handleSelectSpot(item)}
+ >
+
+
+
+ {item.name}
+
+ {(item.city || item.state) && (
+
+ {[item.city, item.state].filter(Boolean).join(', ')}
+
+ )}
+
+
+ )}
+ ListEmptyComponent={
+
+
+ {spotQuery.trim().length < 2
+ ? 'Type at least 2 characters to search.'
+ : 'No spots found.'}
+
+
+ }
+ />
+ )}
+
+
);
}
@@ -885,4 +1059,97 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
+ tagSpotButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ borderRadius: 12,
+ },
+ tagSpotButtonText: {
+ flex: 1,
+ fontSize: 15,
+ fontWeight: '500',
+ },
+ spotChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: `${YELLOW}60`,
+ },
+ spotChipInfo: {
+ flex: 1,
+ },
+ spotChipName: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ spotChipMeta: {
+ fontSize: 13,
+ marginTop: 2,
+ },
+ modalContainer: {
+ flex: 1,
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ },
+ modalTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ },
+ modalSearchRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginHorizontal: 16,
+ marginBottom: 12,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ borderRadius: 12,
+ backgroundColor: 'rgba(255,255,255,0.08)',
+ },
+ modalSearchInput: {
+ flex: 1,
+ fontSize: 15,
+ },
+ modalListContent: {
+ paddingHorizontal: 16,
+ },
+ spotResultRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ paddingVertical: 14,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ spotResultInfo: {
+ flex: 1,
+ },
+ spotResultName: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ spotResultMeta: {
+ fontSize: 13,
+ marginTop: 2,
+ },
+ modalEmpty: {
+ paddingTop: 40,
+ alignItems: 'center',
+ },
+ modalEmptyText: {
+ fontSize: 14,
+ textAlign: 'center',
+ paddingHorizontal: 40,
+ },
});
diff --git a/app/(tabs)/spots/[spotId].tsx b/app/(tabs)/spots/[spotId].tsx
index bdfe8ec..f35ee86 100644
--- a/app/(tabs)/spots/[spotId].tsx
+++ b/app/(tabs)/spots/[spotId].tsx
@@ -4,39 +4,96 @@
*/
import { Ionicons } from '@expo/vector-icons';
+import { Image as ExpoImage } from 'expo-image';
+import * as ImagePicker from 'expo-image-picker';
import { router, useLocalSearchParams } from 'expo-router';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
- Dimensions,
- Image,
+ Alert,
Linking,
+ Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
+ TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ShareToHomieModal } from '@/components/share';
-import { SpotMap, SpotReviewsList } from '@/components/spots';
-import { getSpotById, type Spot } from '@/lib/api/spots';
+import { AddToSpotListModal, SpotMap, SpotReviewsList } from '@/components/spots';
+import {
+ deleteSpot,
+ deleteSpotPhoto,
+ getSpotById,
+ isSpotSaved,
+ reportSpotPhoto,
+ type Spot,
+ type SpotPhoto,
+ saveSpot,
+ unsaveSpot,
+ updateSpot,
+ uploadSpotPhoto,
+} from '@/lib/api/spots';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
+import { useAuthStore } from '@/lib/stores/authStore';
+
+const SPOT_CATEGORIES: { id: Spot['category']; label: string }[] = [
+ { id: 'park', label: 'Park' },
+ { id: 'street', label: 'Street' },
+ { id: 'indoor', label: 'Indoor' },
+ { id: 'diy', label: 'DIY' },
+ { id: 'resort', label: 'Resort' },
+ { id: 'other', label: 'Other' },
+];
-const { width: SCREEN_WIDTH } = Dimensions.get('window');
const YELLOW = '#FCF150';
const DARK = '#1a1a1a';
export default function SpotDetailScreen() {
const { spotId } = useLocalSearchParams<{ spotId: string }>();
- const { theme, colors, isDark } = useThemeContext();
+ const { theme, isDark } = useThemeContext();
+ const { user } = useAuthStore();
const [isFavorite, setIsFavorite] = useState(false);
const [spot, setSpot] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [shareModalVisible, setShareModalVisible] = useState(false);
+ // Save state ("My Spots" saved bucket)
+ const [isSaved, setIsSaved] = useState(false);
+ const [savePending, setSavePending] = useState(false);
+ const [listModalVisible, setListModalVisible] = useState(false);
+
+ // Owner edit/delete state
+ const [editModalVisible, setEditModalVisible] = useState(false);
+ const [editName, setEditName] = useState('');
+ const [editDescription, setEditDescription] = useState('');
+ const [editCategory, setEditCategory] = useState('other');
+ const [savingEdit, setSavingEdit] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+
+ // Photo upload state (Google-Maps-style "add a photo" for any logged-in user)
+ const [uploadingPhoto, setUploadingPhoto] = useState(false);
+
+ // Is the current user the author/owner of this spot?
+ const currentUserId = user?.id || user?._id;
+ const isOwner = !!currentUserId && !!spot?.userId && spot.userId === currentUserId;
+ const isAdmin = user?.role === 'admin';
+ const isLoggedIn = !!currentUserId;
+
+ // Combined photo gallery: user photos first, then Google photos, else header image
+ const galleryPhotos = useMemo(() => {
+ if (!spot) return [];
+ const photos: SpotPhoto[] = [...(spot.userPhotos ?? []), ...(spot.googlePhotos ?? [])];
+ if (photos.length === 0 && spot.imageURL) {
+ photos.push({ url: spot.imageURL });
+ }
+ return photos;
+ }, [spot]);
+
// Fetch spot data from backend
const loadSpot = useCallback(async () => {
if (!spotId) return;
@@ -61,6 +118,256 @@ export default function SpotDetailScreen() {
loadSpot();
}, [loadSpot]);
+ // Load the current saved state for this spot
+ useEffect(() => {
+ if (!spotId) return;
+ let cancelled = false;
+ isSpotSaved(spotId).then((saved) => {
+ if (!cancelled) setIsSaved(saved);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [spotId]);
+
+ // One-tap save toggle
+ const handleToggleSave = useCallback(async () => {
+ if (!spotId || savePending) return;
+ setSavePending(true);
+ const next = !isSaved;
+ // Optimistic update
+ setIsSaved(next);
+ const ok = next ? await saveSpot(spotId) : await unsaveSpot(spotId);
+ if (ok) {
+ if (next) Alert.alert('Saved', 'Saved to My Spots');
+ } else {
+ // Revert on failure
+ setIsSaved(!next);
+ Alert.alert('Error', next ? 'Failed to save spot' : 'Failed to remove spot');
+ }
+ setSavePending(false);
+ }, [spotId, isSaved, savePending]);
+
+ // Long-press opens the named-list picker
+ const handleOpenListPicker = useCallback(() => {
+ setListModalVisible(true);
+ }, []);
+
+ // Open the inline edit modal, prefilled from the loaded spot
+ const handleOpenEdit = useCallback(() => {
+ if (!spot) return;
+ setEditName(spot.name ?? '');
+ setEditDescription(spot.description ?? '');
+ setEditCategory(spot.category ?? 'other');
+ setEditModalVisible(true);
+ }, [spot]);
+
+ // Save inline edits
+ const handleSaveEdit = useCallback(async () => {
+ if (!spotId || !editName.trim() || savingEdit) return;
+ setSavingEdit(true);
+ const updated = await updateSpot(spotId, {
+ name: editName.trim(),
+ description: editDescription.trim(),
+ category: editCategory,
+ });
+ setSavingEdit(false);
+ if (updated) {
+ setSpot(updated);
+ setEditModalVisible(false);
+ } else {
+ Alert.alert('Error', 'Failed to update spot');
+ }
+ }, [spotId, editName, editDescription, editCategory, savingEdit]);
+
+ // Delete this spot (owner only), with confirmation
+ const handleDelete = useCallback(() => {
+ if (!spotId) return;
+ Alert.alert(
+ 'Delete Spot',
+ 'Are you sure you want to delete this spot? This cannot be undone.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: async () => {
+ setDeleting(true);
+ const ok = await deleteSpot(spotId);
+ setDeleting(false);
+ if (ok) {
+ router.back();
+ } else {
+ Alert.alert('Error', 'Failed to delete spot');
+ }
+ },
+ },
+ ],
+ );
+ }, [spotId]);
+
+ // Upload one or more picked photos to this spot, then refetch to refresh gallery.
+ const uploadPickedPhotos = useCallback(
+ async (assets: { uri: string; mimeType: string }[]) => {
+ if (!spotId || assets.length === 0) return;
+ setUploadingPhoto(true);
+ let failed = 0;
+ for (const asset of assets) {
+ const uploaded = await uploadSpotPhoto(spotId, asset.uri, asset.mimeType);
+ if (!uploaded) failed++;
+ }
+ // Refetch the spot so the new photos show up in the gallery.
+ const refreshed = await getSpotById(spotId);
+ if (refreshed) setSpot(refreshed);
+ setUploadingPhoto(false);
+ if (failed > 0) {
+ Alert.alert(
+ 'Some photos failed',
+ `${failed} photo${failed > 1 ? 's' : ''} couldn't be uploaded. Please try again.`,
+ );
+ }
+ },
+ [spotId],
+ );
+
+ // Pick photos from the library and upload them.
+ const handleAddPhotoFromGallery = useCallback(async () => {
+ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
+ if (status !== 'granted') {
+ Alert.alert('Permission Required', 'Please allow access to your photo library.');
+ return;
+ }
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ['images'],
+ allowsMultipleSelection: true,
+ quality: 0.8,
+ });
+ if (!result.canceled && result.assets.length > 0) {
+ await uploadPickedPhotos(
+ result.assets.map((asset) => ({
+ uri: asset.uri,
+ mimeType: asset.mimeType || 'image/jpeg',
+ })),
+ );
+ }
+ }, [uploadPickedPhotos]);
+
+ // Capture a photo with the camera and upload it.
+ const handleAddPhotoFromCamera = useCallback(async () => {
+ const { status } = await ImagePicker.requestCameraPermissionsAsync();
+ if (status !== 'granted') {
+ Alert.alert('Permission Required', 'Please allow access to your camera.');
+ return;
+ }
+ const result = await ImagePicker.launchCameraAsync({
+ mediaTypes: ['images'],
+ quality: 0.8,
+ });
+ if (!result.canceled && result.assets[0]) {
+ const asset = result.assets[0];
+ await uploadPickedPhotos([{ uri: asset.uri, mimeType: asset.mimeType || 'image/jpeg' }]);
+ }
+ }, [uploadPickedPhotos]);
+
+ // "Add Photo" affordance — any logged-in user. Offers Gallery or Camera.
+ const handleAddPhoto = useCallback(() => {
+ if (!isLoggedIn) {
+ Alert.alert('Sign In Required', 'Please sign in to add a photo to this spot.', [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Sign In', onPress: () => router.push('/(auth)/login') },
+ ]);
+ return;
+ }
+ if (uploadingPhoto) return;
+ Alert.alert('Add Photo', 'Add a photo to this spot', [
+ { text: 'Choose from Gallery', onPress: handleAddPhotoFromGallery },
+ { text: 'Take Photo', onPress: handleAddPhotoFromCamera },
+ { text: 'Cancel', style: 'cancel' },
+ ]);
+ }, [isLoggedIn, uploadingPhoto, handleAddPhotoFromGallery, handleAddPhotoFromCamera]);
+
+ // Report a user photo, then thank the reporter.
+ const handleReportPhoto = useCallback(
+ (photoKey: string) => {
+ if (!spotId) return;
+ Alert.alert('Report Photo', 'Report this photo as inappropriate or incorrect?', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Report',
+ style: 'destructive',
+ onPress: async () => {
+ const ok = await reportSpotPhoto(spotId, photoKey);
+ if (ok) {
+ // A report may have crossed the auto-hide threshold — refresh so a
+ // now-hidden photo disappears.
+ const refreshed = await getSpotById(spotId);
+ if (refreshed) setSpot(refreshed);
+ }
+ Alert.alert(
+ ok ? 'Thanks' : 'Error',
+ ok ? "Thanks — we'll review this." : 'Failed to report photo. Please try again.',
+ );
+ },
+ },
+ ]);
+ },
+ [spotId],
+ );
+
+ // Delete a user photo (uploader/owner or admin), then refetch.
+ const handleDeletePhoto = useCallback(
+ (photoKey: string) => {
+ if (!spotId) return;
+ Alert.alert('Delete Photo', 'Delete this photo? This cannot be undone.', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: async () => {
+ const ok = await deleteSpotPhoto(spotId, photoKey);
+ if (ok) {
+ const refreshed = await getSpotById(spotId);
+ if (refreshed) setSpot(refreshed);
+ } else {
+ Alert.alert('Error', 'Failed to delete photo. Please try again.');
+ }
+ },
+ },
+ ]);
+ },
+ [spotId],
+ );
+
+ // Long-press / "..." on a user photo opens the report/delete menu.
+ const handlePhotoActions = useCallback(
+ (photo: SpotPhoto) => {
+ // Google photos have no key and are neither reportable nor deletable.
+ if (!photo.key) return;
+ const photoKey = photo.key;
+ const isOwnPhoto = !!currentUserId && photo.userId === currentUserId;
+ const canDelete = isAdmin || isOwnPhoto;
+ const buttons: {
+ text: string;
+ style?: 'default' | 'cancel' | 'destructive';
+ onPress?: () => void;
+ }[] = [];
+ // You can't report your own photo — you delete it instead.
+ if (!isOwnPhoto) {
+ buttons.push({ text: 'Report photo', onPress: () => handleReportPhoto(photoKey) });
+ }
+ if (canDelete) {
+ buttons.push({
+ text: 'Delete',
+ style: 'destructive',
+ onPress: () => handleDeletePhoto(photoKey),
+ });
+ }
+ buttons.push({ text: 'Cancel', style: 'cancel' });
+ Alert.alert('Photo', undefined, buttons);
+ },
+ [isAdmin, currentUserId, handleReportPhoto, handleDeletePhoto],
+ );
+
// Parse tags into features array
const getFeatures = (): string[] => {
if (!spot?.tags) return [];
@@ -152,8 +459,13 @@ export default function SpotDetailScreen() {
>
{/* Header Image */}
- {spot.imageURL ? (
-
+ {galleryPhotos.length > 0 ? (
+
) : (
@@ -170,6 +482,17 @@ export default function SpotDetailScreen() {
{/* Action buttons */}
+
+ {uploadingPhoto ? (
+
+ ) : (
+
+ )}
+
setIsFavorite(!isFavorite)}>
+ {/* Photo Gallery (user + Google photos) + "Add Photo" tile */}
+
+ {/* Skip index 0 — it's already shown as the large header image. */}
+ {galleryPhotos.slice(1).map((photo, index) => {
+ // Only user photos (which carry a `key`) can be reported/deleted.
+ const isUserPhoto = !!photo.key;
+ return (
+ handlePhotoActions(photo) : undefined}
+ delayLongPress={300}
+ >
+
+ {isUserPhoto && (
+ handlePhotoActions(photo)}
+ hitSlop={8}
+ >
+
+
+ )}
+
+ );
+ })}
+
+ {/* Add Photo tile — available to any logged-in user */}
+
+ {uploadingPhoto ? (
+
+ ) : (
+ <>
+
+
+ Add Photo
+
+ >
+ )}
+
+
+
{/* Content */}
+ {/* Owner Actions */}
+ {isOwner && (
+
+
+
+ Edit
+
+
+ {deleting ? (
+
+ ) : (
+ <>
+
+ Delete
+ >
+ )}
+
+
+ )}
+
{/* Title & Rating Row */}
@@ -404,12 +812,21 @@ export default function SpotDetailScreen() {
{
- // TODO: Add to spotlist
- }}
+ style={[
+ styles.addButton,
+ { borderColor: isSaved ? YELLOW : theme.border },
+ isSaved && { backgroundColor: `${YELLOW}20` },
+ ]}
+ onPress={handleToggleSave}
+ onLongPress={handleOpenListPicker}
+ delayLongPress={300}
+ disabled={savePending}
>
-
+
@@ -430,6 +847,108 @@ export default function SpotDetailScreen() {
}}
/>
)}
+
+ {/* Add to Spot List Modal (long-press on save) */}
+ {spot && (
+ setListModalVisible(false)}
+ onSuccess={() => {
+ // A saved-to-a-list spot also lives in "My Spots"; reflect saved state.
+ setIsSaved(true);
+ }}
+ />
+ )}
+
+ {/* Inline Edit Modal (owner only) */}
+ setEditModalVisible(false)}
+ >
+ setEditModalVisible(false)}>
+ e.stopPropagation()}
+ >
+
+ Edit Spot
+ setEditModalVisible(false)}>
+
+
+
+
+ Name
+
+
+ Description
+
+
+ Category
+
+ {SPOT_CATEGORIES.map((cat) => {
+ const selected = editCategory === cat.id;
+ return (
+ setEditCategory(cat.id)}
+ >
+
+ {cat.label}
+
+
+ );
+ })}
+
+
+
+ {savingEdit ? (
+
+ ) : (
+ Save Changes
+ )}
+
+
+
+
);
}
@@ -533,12 +1052,142 @@ const styles = StyleSheet.create({
color: DARK,
},
+ // Photo Gallery
+ gallery: {
+ marginTop: 12,
+ },
+ galleryContent: {
+ paddingHorizontal: 20,
+ gap: 10,
+ },
+ galleryThumb: {
+ width: 110,
+ height: 80,
+ borderRadius: 12,
+ },
+ galleryMoreButton: {
+ position: 'absolute',
+ top: 6,
+ right: 6,
+ width: 26,
+ height: 26,
+ borderRadius: 13,
+ backgroundColor: 'rgba(0,0,0,0.55)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ galleryAddTile: {
+ width: 110,
+ height: 80,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderStyle: 'dashed',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4,
+ },
+ galleryAddText: {
+ fontSize: 12,
+ fontWeight: '600',
+ },
+
// Content
content: {
paddingHorizontal: 20,
paddingTop: 20,
},
+ // Owner Actions
+ ownerActions: {
+ flexDirection: 'row',
+ gap: 12,
+ marginBottom: 20,
+ },
+ ownerButton: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6,
+ height: 44,
+ borderRadius: 12,
+ },
+ ownerButtonText: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+
+ // Edit Modal
+ editOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ justifyContent: 'flex-end',
+ },
+ editSheet: {
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ paddingHorizontal: 20,
+ paddingTop: 8,
+ paddingBottom: 40,
+ },
+ editHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: 16,
+ },
+ editTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ },
+ editLabel: {
+ fontSize: 13,
+ fontWeight: '600',
+ marginTop: 12,
+ marginBottom: 6,
+ },
+ editInput: {
+ borderWidth: 1,
+ borderRadius: 12,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ fontSize: 15,
+ },
+ editTextarea: {
+ minHeight: 90,
+ textAlignVertical: 'top',
+ },
+ editCategoryRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ },
+ editCategoryChip: {
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderRadius: 20,
+ borderWidth: 1,
+ },
+ editCategoryText: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ editSaveButton: {
+ height: 52,
+ borderRadius: 14,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 24,
+ },
+ editSaveButtonDisabled: {
+ opacity: 0.5,
+ },
+ editSaveButtonText: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: DARK,
+ },
+
// Title Row
titleRow: {
flexDirection: 'row',
diff --git a/app/(tabs)/spots/add.tsx b/app/(tabs)/spots/add.tsx
index 0406b30..78db3bc 100644
--- a/app/(tabs)/spots/add.tsx
+++ b/app/(tabs)/spots/add.tsx
@@ -10,6 +10,7 @@
*/
import { Ionicons } from '@expo/vector-icons';
+import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import { router } from 'expo-router';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -18,6 +19,7 @@ import {
Alert,
Dimensions,
FlatList,
+ Image,
KeyboardAvoidingView,
Linking,
Modal,
@@ -39,7 +41,10 @@ import {
reverseGeocode,
type SportType,
type SpotCategory,
+ saveSpot,
searchPlaces,
+ updateSpot,
+ uploadSpotPhoto,
} from '@/lib/api/spots';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
import { useAuthStore } from '@/lib/stores/authStore';
@@ -100,9 +105,13 @@ export default function AddSpotScreen() {
const [sportTypes, setSportTypes] = useState([]);
const [spotCategories, setSpotCategories] = useState([]);
+ // Photo state (local asset URIs selected for upload)
+ const [photos, setPhotos] = useState<{ uri: string; mimeType: string }[]>([]);
+
// UI state
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
+ const [uploadStatus, setUploadStatus] = useState(null);
const [sportModalVisible, setSportModalVisible] = useState(false);
// Get user location on mount
@@ -269,22 +278,93 @@ export default function AddSpotScreen() {
);
}, []);
- // Submit spot
- const handleSubmit = useCallback(async () => {
+ // Add photos from the gallery (multiple allowed)
+ const handleAddFromGallery = useCallback(async () => {
+ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
+ if (status !== 'granted') {
+ Alert.alert('Permission Required', 'Please allow access to your photo library.');
+ return;
+ }
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ['images'],
+ allowsMultipleSelection: true,
+ quality: 0.8,
+ });
+ if (!result.canceled && result.assets.length > 0) {
+ const picked = result.assets.map((asset) => ({
+ uri: asset.uri,
+ mimeType: asset.mimeType || 'image/jpeg',
+ }));
+ setPhotos((prev) => [...prev, ...picked]);
+ }
+ }, []);
+
+ // Capture a photo with the camera
+ const handleTakePhoto = useCallback(async () => {
+ const { status } = await ImagePicker.requestCameraPermissionsAsync();
+ if (status !== 'granted') {
+ Alert.alert('Permission Required', 'Please allow access to your camera.');
+ return;
+ }
+ const result = await ImagePicker.launchCameraAsync({
+ mediaTypes: ['images'],
+ quality: 0.8,
+ });
+ if (!result.canceled && result.assets[0]) {
+ const asset = result.assets[0];
+ setPhotos((prev) => [...prev, { uri: asset.uri, mimeType: asset.mimeType || 'image/jpeg' }]);
+ }
+ }, []);
+
+ // Remove a selected photo before upload
+ const handleRemovePhoto = useCallback((uri: string) => {
+ setPhotos((prev) => prev.filter((p) => p.uri !== uri));
+ }, []);
+
+ // Upload all selected photos to a spot; returns the first successfully-uploaded URL.
+ const uploadPhotosForSpot = useCallback(
+ async (spotId: string): Promise<{ firstUrl: string | null; failed: number }> => {
+ let firstUrl: string | null = null;
+ let failed = 0;
+ for (let i = 0; i < photos.length; i++) {
+ setUploadStatus(`Uploading photo ${i + 1} of ${photos.length}...`);
+ const uploaded = await uploadSpotPhoto(spotId, photos[i].uri, photos[i].mimeType);
+ if (uploaded?.url) {
+ if (!firstUrl) firstUrl = uploaded.url;
+ } else {
+ failed++;
+ }
+ }
+ return { firstUrl, failed };
+ },
+ [photos],
+ );
+
+ // Validate required fields before submitting; alerts and returns false if invalid.
+ const validateForm = useCallback((): boolean => {
if (!spotName.trim()) {
Alert.alert('Required', 'Please enter a spot name.');
- return;
+ return false;
}
if (!selectedLocation) {
Alert.alert('Required', 'Location is required.');
- return;
+ return false;
}
if (selectedSports.length === 0) {
Alert.alert('Required', 'Please select at least one sport type.');
+ return false;
+ }
+ return true;
+ }, [spotName, selectedLocation, selectedSports]);
+
+ // Submit spot
+ const handleSubmit = useCallback(async () => {
+ if (!validateForm() || !selectedLocation) {
return;
}
setSaving(true);
+ setUploadStatus(null);
try {
const spotData = {
name: spotName.trim(),
@@ -293,36 +373,58 @@ export default function AddSpotScreen() {
description: description.trim() || undefined,
city: city || undefined,
state: state || undefined,
- category: selectedCategory as 'park' | 'street' | 'indoor' | 'diy' | 'other',
+ category: selectedCategory as 'park' | 'street' | 'indoor' | 'diy' | 'resort' | 'other',
sportTypes: selectedSports,
isPublic,
googlePlaceId: selectedPlaceId || undefined,
};
+ // 1. Create the spot first so we have an _id to attach photos to.
const result = await createSpot(spotData);
- if (result) {
- Alert.alert(
- 'Spot Added!',
- isPublic
- ? 'Your spot has been submitted for review. It will appear publicly once approved.'
- : 'Your private spot has been saved.',
- [
- {
- text: 'OK',
- onPress: () => router.back(),
- },
- ],
- );
- } else {
+ if (!result) {
Alert.alert('Error', 'Failed to create spot. Please try again.');
+ return;
+ }
+
+ // If the backend deduped to an existing spot owned by someone else, we
+ // don't own it: save it to "My Spots" and DON'T attach our photos to it.
+ const currentUserId = user?._id || user?.id;
+ const isExistingOtherSpot =
+ !!result.userId && !!currentUserId && result.userId !== currentUserId;
+
+ let title = 'Spot Added!';
+ let message = isPublic
+ ? 'Your spot has been submitted for review. It will appear publicly once approved.'
+ : 'Your private spot has been saved.';
+
+ if (isExistingOtherSpot) {
+ await saveSpot(result._id);
+ title = 'Already Exists';
+ message =
+ 'That spot already existed, so we saved it to your My Spots.' +
+ (photos.length > 0 ? ' Your photos were not added to it.' : '');
+ } else if (photos.length > 0) {
+ // 2. Upload each selected photo; use the first as the spot's main image.
+ const { firstUrl, failed } = await uploadPhotosForSpot(result._id);
+ if (firstUrl && !result.imageURL) {
+ setUploadStatus('Finishing up...');
+ await updateSpot(result._id, { imageURL: firstUrl });
+ }
+ if (failed > 0) {
+ message += ` (${failed} photo${failed > 1 ? 's' : ''} couldn't be uploaded — you can add them later from the spot.)`;
+ }
}
+
+ Alert.alert(title, message, [{ text: 'OK', onPress: () => router.back() }]);
} catch (_error) {
Alert.alert('Error', 'Failed to create spot. Please try again.');
} finally {
setSaving(false);
+ setUploadStatus(null);
}
}, [
+ validateForm,
spotName,
selectedLocation,
description,
@@ -332,6 +434,8 @@ export default function AddSpotScreen() {
selectedSports,
isPublic,
selectedPlaceId,
+ uploadPhotosForSpot,
+ user,
]);
// Render location selection step
@@ -361,6 +465,14 @@ export default function AddSpotScreen() {
}
}
>
+ {/*
+ WARNING: react-native-maps can CRASH under the New Architecture.
+ This existing draggable drop-pin is intentionally left in place to avoid
+ breaking the working location step. If crashes appear, migrate this to a
+ fixed center-crosshair overlay (a centered pin icon rendered OUTSIDE the
+ MapView) that reads the map region center via onRegionChangeComplete,
+ instead of rendering a child.
+ */}
{selectedLocation && (
@@ -592,6 +704,55 @@ export default function AddSpotScreen() {
numberOfLines={3}
/>
+ {/* Photos */}
+ Photos
+
+
+
+ Add from Gallery
+
+
+
+ Take Photo
+
+
+ {photos.length > 0 && (
+
+ {photos.map((photo) => (
+
+
+ handleRemovePhoto(photo.uri)}
+ disabled={saving}
+ hitSlop={8}
+ >
+
+
+
+ ))}
+
+ )}
+
{/* Category */}
Category *
@@ -674,7 +835,10 @@ export default function AddSpotScreen() {
disabled={saving}
>
{saving ? (
-
+ <>
+
+ {uploadStatus && {uploadStatus}}
+ >
) : (
<>
@@ -1042,6 +1206,56 @@ const styles = StyleSheet.create({
borderWidth: 1,
marginBottom: 20,
},
+
+ // Photos
+ photoButtonsRow: {
+ flexDirection: 'row',
+ gap: 10,
+ marginBottom: 12,
+ },
+ photoButton: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ paddingVertical: 14,
+ borderRadius: 12,
+ borderWidth: 1,
+ },
+ photoButtonText: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ thumbnailRow: {
+ marginBottom: 20,
+ },
+ thumbnailRowContent: {
+ gap: 10,
+ paddingRight: 4,
+ },
+ thumbnailWrapper: {
+ width: 88,
+ height: 88,
+ borderRadius: 12,
+ },
+ thumbnail: {
+ width: 88,
+ height: 88,
+ borderRadius: 12,
+ backgroundColor: '#000',
+ },
+ thumbnailRemove: {
+ position: 'absolute',
+ top: -6,
+ right: -6,
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: 'rgba(0,0,0,0.75)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
toggleContainer: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/app/(tabs)/spots/index.tsx b/app/(tabs)/spots/index.tsx
index 31d1c20..04c0f6f 100644
--- a/app/(tabs)/spots/index.tsx
+++ b/app/(tabs)/spots/index.tsx
@@ -6,12 +6,11 @@
import { Ionicons } from '@expo/vector-icons';
import * as Location from 'expo-location';
-import { router } from 'expo-router';
+import { router, useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
- Dimensions,
FlatList,
Image,
KeyboardAvoidingView,
@@ -26,28 +25,33 @@ import {
View,
} from 'react-native';
import MapView, { PROVIDER_GOOGLE, type Region } from 'react-native-maps';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import { SpotListCard as SpotListCardComponent } from '@/components/spots';
+import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
+import { AddToSpotListModal, SpotListCard as SpotListCardComponent } from '@/components/spots';
import { useKeyboardVisible } from '@/hooks/useKeyboardVisible';
import { useMapClusters } from '@/hooks/useMapClusters';
import { createSpotList, getSpotLists } from '@/lib/api/spotlists';
import {
getMapPins,
+ getMySpots,
+ getSavedSpots,
getSportTypes,
getSpotCategories,
getSpots,
type MapPin,
+ type PlaceSearchResult,
type SportType,
type Spot,
type SpotCategory,
+ saveSpot,
+ searchPlaces,
+ searchSpots,
+ unsaveSpot,
} from '@/lib/api/spots';
-import { projectToScreen } from '@/lib/mapProjection';
+import { projectToScreenXY } from '@/lib/mapProjection';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
import { useAuthStore } from '@/lib/stores/authStore';
import type { CreateSpotListInput, SpotList } from '@/types/spots';
-const { width: SCREEN_WIDTH } = Dimensions.get('window');
-
/**
* Theme colors - see /src/constants/colors.ts for full policy
* YELLOW: Use for backgrounds with dark text, or icons. Never for text on light backgrounds.
@@ -74,6 +78,11 @@ const SPORT_ICONS: Record = {
type ViewMode = 'map' | 'list';
type TabType = 'allSpots' | 'mySpots';
+// My Spots sub-view: the flat authored+saved list vs. the named collections.
+type MySpotsView = 'spots' | 'collections';
+// A spot in the flat "My Spots" list, tagged with how the user relates to it.
+type MySpotBadge = 'Mine' | 'Saved';
+type MySpot = Spot & { mineOrSaved: MySpotBadge };
// Dark mode map styling
const darkMapStyle = [
@@ -87,8 +96,9 @@ const darkMapStyle = [
];
export default function SpotsScreen() {
- const { theme, colors, isDark } = useThemeContext();
+ const { theme, isDark } = useThemeContext();
const { user, token } = useAuthStore();
+ const insets = useSafeAreaInsets();
// Tab state
const [activeTab, setActiveTab] = useState('allSpots');
@@ -109,7 +119,11 @@ export default function SpotsScreen() {
const [userLocation, setUserLocation] = useState<{ latitude: number; longitude: number } | null>(
null,
);
- const [_spotToAddToList, _setSpotToAddToList] = useState(null);
+ // Saved-spot state: track which spot ids the user has saved so the bookmark
+ // control renders filled/outline. Seeded via isSpotSaved when a spot is selected.
+ const [savedSpotIds, setSavedSpotIds] = useState>(new Set());
+ // Spot whose named-list picker (AddToSpotListModal) is open, if any.
+ const [spotForListModal, setSpotForListModal] = useState(null);
const [mapPins, setMapPins] = useState([]);
// Current visible region, used to compute clusters for the viewport.
const [region, setRegion] = useState({
@@ -134,7 +148,22 @@ export default function SpotsScreen() {
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
const mapPinsDebounceRef = useRef | null>(null);
+ // Google-Maps-style in-map search: type a query, get matching places (Google
+ // Places, biased to the current viewport) + our spots, tap one to fly there.
+ const [mapSearchVisible, setMapSearchVisible] = useState(false);
+ const [mapSearchText, setMapSearchText] = useState('');
+ const [mapSearchPlaces, setMapSearchPlaces] = useState([]);
+ const [mapSearchSpots, setMapSearchSpots] = useState([]);
+ const [mapSearchLoading, setMapSearchLoading] = useState(false);
+
// My Spots state
+ // Sub-view within the My Spots tab: the flat authored+saved list ('spots')
+ // is primary; collections (named lists) live behind a toggle.
+ const [mySpotsView, setMySpotsView] = useState('spots');
+ // Flat list = spots the user created (badge "Mine") + saved (badge "Saved").
+ const [mySpots, setMySpots] = useState([]);
+ const [mySpotsLoading, setMySpotsLoading] = useState(false);
+ const [mySpotsRefreshing, setMySpotsRefreshing] = useState(false);
const [myLists, setMyLists] = useState([]);
const [listsLoading, setListsLoading] = useState(false);
const [listsRefreshing, setListsRefreshing] = useState(false);
@@ -158,10 +187,39 @@ export default function SpotsScreen() {
}
};
- const handleAddToList = (spot: Spot) => {
- // Navigate to add-to-list screen or show modal
- // For now, navigate to spot detail where they can add to list
- router.push(`/(tabs)/spots/${spot._id}`);
+ // One-tap save/unsave toggle for the selected-spot map card. Optimistically
+ // flips the saved state, then reconciles on the server response.
+ const handleToggleSave = async (spot: Spot) => {
+ const currentlySaved = savedSpotIds.has(spot._id);
+ // Optimistic update
+ setSavedSpotIds((prev) => {
+ const next = new Set(prev);
+ if (currentlySaved) {
+ next.delete(spot._id);
+ } else {
+ next.add(spot._id);
+ }
+ return next;
+ });
+ const ok = currentlySaved ? await unsaveSpot(spot._id) : await saveSpot(spot._id);
+ if (!ok) {
+ // Revert on failure
+ setSavedSpotIds((prev) => {
+ const next = new Set(prev);
+ if (currentlySaved) {
+ next.add(spot._id);
+ } else {
+ next.delete(spot._id);
+ }
+ return next;
+ });
+ Alert.alert('Error', currentlySaved ? 'Failed to remove spot' : 'Failed to save spot');
+ }
+ };
+
+ // Long-press on the save control opens the named-list picker for that spot.
+ const handleOpenListPicker = (spot: Spot) => {
+ setSpotForListModal(spot);
};
// Get user location on mount and center map
@@ -206,6 +264,81 @@ export default function SpotsScreen() {
return () => clearTimeout(timer);
}, [searchQuery]);
+ const openMapSearch = useCallback(() => {
+ setMapSearchVisible(true);
+ }, []);
+
+ const closeMapSearch = useCallback(() => {
+ setMapSearchVisible(false);
+ setMapSearchText('');
+ setMapSearchPlaces([]);
+ setMapSearchSpots([]);
+ }, []);
+
+ // In-map search: debounce the query, then fetch Google Places (biased to the
+ // current viewport center) and matching spots in parallel.
+ useEffect(() => {
+ if (!mapSearchVisible) return;
+ const q = mapSearchText.trim();
+ if (q.length < 2) {
+ setMapSearchPlaces([]);
+ setMapSearchSpots([]);
+ setMapSearchLoading(false);
+ return;
+ }
+ let cancelled = false;
+ setMapSearchLoading(true);
+ const timer = setTimeout(async () => {
+ const [places, spotsRes] = await Promise.all([
+ searchPlaces(q, region.latitude, region.longitude),
+ searchSpots(q),
+ ]);
+ if (cancelled) return;
+ setMapSearchPlaces(places.slice(0, 6));
+ setMapSearchSpots(spotsRes.spots.slice(0, 6));
+ setMapSearchLoading(false);
+ }, 350);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [mapSearchText, mapSearchVisible, region.latitude, region.longitude]);
+
+ // Fly the map to a searched Google place.
+ const handleSelectSearchPlace = useCallback(
+ (place: PlaceSearchResult) => {
+ closeMapSearch();
+ mapRef.current?.animateToRegion(
+ {
+ latitude: place.latitude,
+ longitude: place.longitude,
+ latitudeDelta: 0.02,
+ longitudeDelta: 0.02,
+ },
+ 500,
+ );
+ },
+ [closeMapSearch],
+ );
+
+ // Fly to a matched spot and select it (shows its card).
+ const handleSelectSearchSpot = useCallback(
+ (spot: Spot) => {
+ closeMapSearch();
+ setSelectedSpot(spot);
+ mapRef.current?.animateToRegion(
+ {
+ latitude: spot.latitude,
+ longitude: spot.longitude,
+ latitudeDelta: 0.02,
+ longitudeDelta: 0.02,
+ },
+ 500,
+ );
+ },
+ [closeMapSearch],
+ );
+
// Fetch spots when filters change
const fetchSpots = useCallback(async () => {
setLoading(true);
@@ -275,6 +408,63 @@ export default function SpotsScreen() {
setRefreshing(false);
}, [fetchSpots]);
+ // Load the full set of saved spot ids up front so every bookmark control
+ // (map preview cards, the selected-spot card, My Spots) renders filled/outline
+ // correctly. This replaces per-spot isSpotSaved checks — those only seeded the
+ // selected spot and, because they depended on savedSpotIds, re-added an id
+ // right after the user optimistically unsaved it (the bookmark bounced back).
+ const loadSavedSpotIds = useCallback(async () => {
+ if (!token) return;
+ try {
+ const res = await getSavedSpots({ limit: 500 });
+ setSavedSpotIds(new Set(res.spots.map((s) => s._id)));
+ } catch (_error) {
+ // Keep whatever we already know on failure.
+ }
+ }, [token]);
+
+ useEffect(() => {
+ loadSavedSpotIds();
+ }, [loadSavedSpotIds]);
+
+ // Fetch the flat "My Spots" list: spots the user created (badge "Mine") plus
+ // spots they saved (badge "Saved"), merged and de-duped by _id. If a spot is
+ // both authored and saved, it shows "Mine".
+ const fetchMySpots = useCallback(async () => {
+ if (!user?.id && !user?._id) return;
+ setMySpotsLoading(true);
+ try {
+ const [mine, saved] = await Promise.all([
+ getMySpots({ limit: 100 }),
+ getSavedSpots({ limit: 100 }),
+ ]);
+ const byId = new Map();
+ // Saved first so authored ("Mine") overwrites on conflict.
+ for (const spot of saved.spots) {
+ byId.set(spot._id, { ...spot, mineOrSaved: 'Saved' });
+ }
+ for (const spot of mine.spots) {
+ byId.set(spot._id, { ...spot, mineOrSaved: 'Mine' });
+ }
+ setMySpots(Array.from(byId.values()));
+ // Keep the bookmark state in sync with what the server considers saved.
+ setSavedSpotIds((prev) => {
+ const next = new Set(prev);
+ for (const spot of saved.spots) next.add(spot._id);
+ return next;
+ });
+ } catch (_error) {
+ } finally {
+ setMySpotsLoading(false);
+ }
+ }, [user?.id, user?._id]);
+
+ const onMySpotsRefresh = useCallback(async () => {
+ setMySpotsRefreshing(true);
+ await fetchMySpots();
+ setMySpotsRefreshing(false);
+ }, [fetchMySpots]);
+
// Fetch user's spot lists
const fetchMyLists = useCallback(async () => {
if (!user?.id && !user?._id) return;
@@ -294,12 +484,30 @@ export default function SpotsScreen() {
setListsRefreshing(false);
}, [fetchMyLists]);
- // Fetch lists when switching to My Spots tab
+ // Fetch My Spots content when switching to the tab / sub-view.
useEffect(() => {
- if (activeTab === 'mySpots') {
+ if (activeTab !== 'mySpots') return;
+ if (mySpotsView === 'spots') {
+ fetchMySpots();
+ } else {
fetchMyLists();
}
- }, [activeTab, fetchMyLists]);
+ }, [activeTab, mySpotsView, fetchMySpots, fetchMyLists]);
+
+ // Refresh My Spots content whenever the screen regains focus (e.g. after
+ // creating/saving a spot elsewhere), matching the previous refresh-on-focus.
+ useFocusEffect(
+ useCallback(() => {
+ // Reconcile saved-bookmark state with the server on every focus.
+ loadSavedSpotIds();
+ if (activeTab !== 'mySpots') return;
+ if (mySpotsView === 'spots') {
+ fetchMySpots();
+ } else {
+ fetchMyLists();
+ }
+ }, [activeTab, mySpotsView, fetchMySpots, fetchMyLists, loadSavedSpotIds]),
+ );
// Handle create spot list
const handleCreateList = async () => {
@@ -328,146 +536,155 @@ export default function SpotsScreen() {
return (
- {/* Header */}
-
- Spots
-
- {activeTab === 'allSpots' && (
- setFilterModalVisible(true)}>
-
- Filter
+ {/* Header — hidden in fullscreen map for an immersive view. */}
+ {!isMapFullscreen && (
+
+ Spots
+
+ {activeTab === 'allSpots' && (
+ setFilterModalVisible(true)}>
+
+ Filter
+
+ )}
+
+ activeTab === 'allSpots'
+ ? router.push('/(tabs)/spots/add')
+ : setCreateModalVisible(true)
+ }
+ >
+
- )}
-
- activeTab === 'allSpots'
- ? router.push('/(tabs)/spots/add')
- : setCreateModalVisible(true)
- }
- >
-
-
+
-
+ )}
- {/* Tab Toggle */}
-
- setActiveTab('allSpots')}
- >
-
+ setActiveTab('allSpots')}
>
- All Spots
-
-
- setActiveTab('mySpots')}
- >
-
+ All Spots
+
+
+ setActiveTab('mySpots')}
>
- My Spots
-
-
-
+
+ My Spots
+
+
+
+ )}
{activeTab === 'allSpots' ? (
<>
- {/* Search Bar */}
-
-
-
-
- {searchQuery.length > 0 && (
- setSearchQuery('')}>
-
-
- )}
-
-
-
- {/* View Toggle & Categories */}
-
- {/* View Toggle */}
-
- setViewMode('map')}
- >
-
+
+
+
-
- setViewMode('list')}
- >
-
-
+ {searchQuery.length > 0 && (
+ setSearchQuery('')}>
+
+
+ )}
+
+ )}
- {/* Category Pills */}
-
- {spotCategories.map((cat) => (
+ {/* View Toggle & Categories — hidden in fullscreen map. */}
+ {!isMapFullscreen && (
+
+ {/* View Toggle */}
+
setSelectedCategory(cat.id)}
+ onPress={() => setViewMode('map')}
>
-
+ setViewMode('list')}
+ >
+
+
+
+
+ {/* Category Pills */}
+
+ {spotCategories.map((cat) => (
+ setSelectedCategory(cat.id)}
>
- {cat.name}
-
-
- ))}
-
-
+
+
+ {cat.name}
+
+
+ ))}
+
+
+ )}
{/* Content */}
{loading && !refreshing ? (
@@ -515,36 +732,46 @@ export default function SpotsScreen() {
{mapReady && mapLayout.width > 0 && (
{clusters.map((item) => {
- const pt = projectToScreen(
+ const pt = projectToScreenXY(
item.latitude,
item.longitude,
projectionRegion,
mapLayout,
);
if (!pt) return null;
+ // NEVER unmount an off-screen marker while panning — that
+ // desyncs Fabric's touch registry and hard-crashes on the New
+ // Architecture (RN #53303). Keep it mounted but hidden and
+ // non-interactive instead.
+ const hidden = !pt.onScreen;
if (item.type === 'cluster') {
return (
- mapRef.current?.animateToRegion(
- getClusterExpansionRegion(
- item.clusterId as number,
- item.latitude,
- item.longitude,
- ),
- 300,
- )
- }
+ onPress={() => {
+ // Defer the camera animation past this touch's end so
+ // re-projection/re-clustering can't move or unmount the
+ // pressed marker while UIKit is still finalizing it.
+ const target = getClusterExpansionRegion(
+ item.clusterId as number,
+ item.latitude,
+ item.longitude,
+ );
+ requestAnimationFrame(() =>
+ mapRef.current?.animateToRegion(target, 300),
+ );
+ }}
>
{item.count}
@@ -554,28 +781,35 @@ export default function SpotsScreen() {
}
const selected = selectedSpot?._id === item.pin?._id;
+ const pin = item.pin;
return (
{
- setSelectedSpot(item.pin as unknown as Spot);
- mapRef.current?.animateToRegion(
- {
- latitude: item.latitude,
- longitude: item.longitude,
- latitudeDelta: 0.05,
- longitudeDelta: 0.05,
- },
- 300,
- );
+ const lat = item.latitude;
+ const lng = item.longitude;
+ requestAnimationFrame(() => {
+ setSelectedSpot(pin as unknown as Spot);
+ mapRef.current?.animateToRegion(
+ {
+ latitude: lat,
+ longitude: lng,
+ latitudeDelta: 0.05,
+ longitudeDelta: 0.05,
+ },
+ 300,
+ );
+ });
}}
>
@@ -600,8 +834,17 @@ export default function SpotsScreen() {
)}
- {/* Map Controls */}
-
+ {/* Map Controls — nudged below the status bar when fullscreen so
+ the top control clears the notch/safe area. */}
+
+ {/* Filter — reachable in fullscreen since the header is hidden. */}
+ setFilterModalVisible(true)}
+ >
+
+
+
{/* Center on user button */}
- {/* Zoom In */}
+ {/* Search places & spots (Google-Maps style). Replaces the +/-
+ zoom buttons — pinch-to-zoom covers zooming. */}
{
- mapRef.current?.getCamera().then((camera) => {
- if (camera) {
- mapRef.current?.animateCamera(
- {
- ...camera,
- zoom: (camera.zoom || 10) + 1,
- },
- { duration: 300 },
- );
- }
- });
- }}
+ onPress={openMapSearch}
>
-
-
-
- {/* Zoom Out */}
- {
- mapRef.current?.getCamera().then((camera) => {
- if (camera) {
- mapRef.current?.animateCamera(
- {
- ...camera,
- zoom: (camera.zoom || 10) - 1,
- },
- { duration: 300 },
- );
- }
- });
- }}
- >
-
+
{/* Toggle full-screen map */}
@@ -670,44 +882,27 @@ export default function SpotsScreen() {
- {/* Selected spot card or spots preview */}
-
- {selectedSpot ? (
+ {/* Selected-spot card — shown ONLY when a pin is tapped. Tapping
+ the map clears the selection (onPress on MapView) and hides it;
+ tapping another pin swaps in that spot. Lifted above the bottom
+ safe area when fullscreen (no tab bar padding then). */}
+ {selectedSpot && (
+
router.push(`/(tabs)/spots/${selectedSpot._id}`)}
- onAddToList={() => handleAddToList(selectedSpot)}
+ onToggleSave={() => handleToggleSave(selectedSpot)}
+ onOpenListPicker={() => handleOpenListPicker(selectedSpot)}
/>
- ) : spots.length > 0 ? (
-
- {spots.slice(0, 5).map((spot) => (
- {
- setSelectedSpot(spot);
- mapRef.current?.animateToRegion(
- {
- latitude: spot.latitude,
- longitude: spot.longitude,
- latitudeDelta: 0.05,
- longitudeDelta: 0.05,
- },
- 500,
- );
- }}
- onAddToList={() => handleAddToList(spot)}
- />
- ))}
-
- ) : null}
-
+
+ )}
) : (
// List View
@@ -871,7 +1066,102 @@ export default function SpotsScreen() {
) : (
// My Spots Tab
<>
- {listsLoading && !listsRefreshing ? (
+ {/* Sub-view toggle: flat Spots (authored + saved) vs. Collections. */}
+
+ setMySpotsView('spots')}
+ >
+
+
+ Spots
+
+
+ setMySpotsView('collections')}
+ >
+
+
+ Collections
+
+
+
+
+ {mySpotsView === 'spots' ? (
+ mySpotsLoading && !mySpotsRefreshing ? (
+
+
+
+ ) : (
+ item._id}
+ contentContainerStyle={styles.listContent}
+ showsVerticalScrollIndicator={false}
+ refreshControl={
+
+ }
+ ItemSeparatorComponent={() => }
+ ListEmptyComponent={
+
+
+ No spots yet
+
+ You haven't added or saved any spots yet
+
+
+ }
+ renderItem={({ item }) => (
+ router.push(`/(tabs)/spots/${item._id}`)}
+ />
+ )}
+ />
+ )
+ ) : listsLoading && !listsRefreshing ? (
@@ -1006,6 +1296,139 @@ export default function SpotsScreen() {
>
)}
+
+ {/* Named-list picker (opened via long-press on a save/bookmark control).
+ Mounted once at screen level and controlled by spotForListModal. */}
+ setSpotForListModal(null)}
+ onSuccess={() => {
+ // The picker may have added the spot to the default Saved bucket.
+ if (spotForListModal) {
+ const id = spotForListModal._id;
+ setSavedSpotIds((prev) => {
+ if (prev.has(id)) return prev;
+ const next = new Set(prev);
+ next.add(id);
+ return next;
+ });
+ }
+ }}
+ />
+
+ {/* Google-Maps-style in-map search: places (Google) + our spots. */}
+
+
+
+
+
+
+ {mapSearchText.length > 0 && (
+ setMapSearchText('')} hitSlop={8}>
+
+
+ )}
+
+
+ Cancel
+
+
+
+
+ {mapSearchLoading && }
+
+ {mapSearchSpots.length > 0 && (
+ Spots
+ )}
+ {mapSearchSpots.map((s) => (
+ handleSelectSearchSpot(s)}
+ >
+
+
+
+
+
+ {s.name}
+
+
+ {[s.city, s.state].filter(Boolean).join(', ') || 'Spot'}
+
+
+
+ ))}
+
+ {mapSearchPlaces.length > 0 && (
+ Places
+ )}
+ {mapSearchPlaces.map((p) => (
+ handleSelectSearchPlace(p)}
+ >
+
+
+
+ {p.name}
+
+
+ {p.address}
+
+
+
+ ))}
+
+ {!mapSearchLoading &&
+ mapSearchText.trim().length >= 2 &&
+ mapSearchSpots.length === 0 &&
+ mapSearchPlaces.length === 0 && (
+
+ No results for “{mapSearchText.trim()}”
+
+ )}
+
+
+
);
}
@@ -1014,11 +1437,22 @@ export default function SpotsScreen() {
interface SpotMapCardProps {
spot: Spot;
theme: any;
+ saved?: boolean;
onPress: () => void;
- onAddToList?: () => void;
+ /** Tap the bookmark: one-tap save/unsave. */
+ onToggleSave?: () => void;
+ /** Long-press the bookmark: open the named-list picker. */
+ onOpenListPicker?: () => void;
}
-function SpotMapCard({ spot, theme, onPress, onAddToList }: SpotMapCardProps) {
+function SpotMapCard({
+ spot,
+ theme,
+ saved,
+ onPress,
+ onToggleSave,
+ onOpenListPicker,
+}: SpotMapCardProps) {
const address = [spot.city, spot.state].filter(Boolean).join(', ');
return (
@@ -1053,13 +1487,27 @@ function SpotMapCard({ spot, theme, onPress, onAddToList }: SpotMapCardProps) {
{
e.stopPropagation();
- onAddToList?.();
+ onToggleSave?.();
+ }}
+ onLongPress={(e) => {
+ e.stopPropagation();
+ onOpenListPicker?.();
}}
+ delayLongPress={300}
+ hitSlop={6}
>
-
+
View
@@ -1076,27 +1524,37 @@ function SpotMapCard({ spot, theme, onPress, onAddToList }: SpotMapCardProps) {
interface SpotListCardProps {
spot: Spot;
theme: any;
+ /** Optional "Mine"/"Saved" badge shown on the image (My Spots flat list). */
+ badge?: MySpotBadge;
onPress: () => void;
}
-function SpotListCard({ spot, theme, onPress }: SpotListCardProps) {
+function SpotListCard({ spot, theme, badge, onPress }: SpotListCardProps) {
const address = [spot.city, spot.state].filter(Boolean).join(', ') || 'Unknown location';
return (
{/* Image */}
- {spot.imageURL ? (
-
- ) : (
-
-
-
- )}
+
+ {spot.imageURL ? (
+
+ ) : (
+
+
+
+ )}
+ {badge && (
+
+
+ {badge}
+
+ )}
+
{/* Content */}
@@ -1333,6 +1791,76 @@ const styles = StyleSheet.create({
shadowRadius: 5,
elevation: 6,
},
+ // In-map search (Google-Maps style)
+ mapSearchScreen: {
+ flex: 1,
+ },
+ mapSearchHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ gap: 8,
+ },
+ mapSearchBar: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingHorizontal: 12,
+ height: 44,
+ borderRadius: 12,
+ borderWidth: 1,
+ },
+ mapSearchInput: {
+ flex: 1,
+ fontSize: 16,
+ },
+ mapSearchCancel: {
+ paddingHorizontal: 4,
+ paddingVertical: 8,
+ },
+ mapSearchResults: {
+ flex: 1,
+ },
+ mapSearchSection: {
+ fontSize: 12,
+ fontWeight: '700',
+ textTransform: 'uppercase',
+ letterSpacing: 0.4,
+ paddingHorizontal: 16,
+ paddingTop: 16,
+ paddingBottom: 6,
+ },
+ mapSearchRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ mapSearchIcon: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: YELLOW,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ mapSearchRowTitle: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ mapSearchRowSub: {
+ fontSize: 13,
+ marginTop: 2,
+ },
+ mapSearchEmpty: {
+ textAlign: 'center',
+ marginTop: 32,
+ fontSize: 15,
+ },
overlayMarker: {
position: 'absolute',
alignItems: 'center',
@@ -1645,6 +2173,45 @@ const styles = StyleSheet.create({
color: DARK,
},
+ // My Spots sub-view switch (Spots | Collections)
+ mySpotsSwitch: {
+ flexDirection: 'row',
+ marginHorizontal: 20,
+ marginBottom: 16,
+ gap: 8,
+ },
+ mySpotsSwitchButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderRadius: 20,
+ },
+ mySpotsSwitchText: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+
+ // Mine / Saved badge on spot card image
+ cardBadge: {
+ position: 'absolute',
+ top: 6,
+ left: 6,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 3,
+ paddingHorizontal: 6,
+ paddingVertical: 3,
+ borderRadius: 8,
+ backgroundColor: YELLOW,
+ },
+ cardBadgeText: {
+ fontSize: 10,
+ fontWeight: '700',
+ color: DARK,
+ },
+
// My Spots List
myListsContent: {
paddingHorizontal: 20,
diff --git a/patches/react-native+0.81.5.patch b/patches/react-native+0.81.5.patch
new file mode 100644
index 0000000..978e27f
--- /dev/null
+++ b/patches/react-native+0.81.5.patch
@@ -0,0 +1,40 @@
+diff --git a/node_modules/react-native/React/Fabric/RCTSurfaceTouchHandler.mm b/node_modules/react-native/React/Fabric/RCTSurfaceTouchHandler.mm
+index a793251..db132bb 100644
+--- a/node_modules/react-native/React/Fabric/RCTSurfaceTouchHandler.mm
++++ b/node_modules/react-native/React/Fabric/RCTSurfaceTouchHandler.mm
+@@ -203,7 +203,10 @@ - (void)_updateTouches:(NSSet *)touches
+ {
+ for (UITouch *touch in touches) {
+ auto iterator = _activeTouches.find(touch);
+- RCTAssert(iterator != _activeTouches.end(), @"Inconsistency between local and UIKit touch registries");
++ // patched (facebook/react-native#53303): the fatal RCTAssert is removed so a
++ // touch that UIKit delivers after its responder view was unmounted mid-gesture
++ // is skipped by the guard below instead of hard-crashing the app. Common on the
++ // map's projected-overlay markers on iOS 26 + New Architecture.
+ if (iterator == _activeTouches.end()) {
+ continue;
+ }
+@@ -216,7 +219,10 @@ - (void)_unregisterTouches:(NSSet *)touches
+ {
+ for (UITouch *touch in touches) {
+ auto iterator = _activeTouches.find(touch);
+- RCTAssert(iterator != _activeTouches.end(), @"Inconsistency between local and UIKit touch registries");
++ // patched (facebook/react-native#53303): the fatal RCTAssert is removed so a
++ // touch that UIKit delivers after its responder view was unmounted mid-gesture
++ // is skipped by the guard below instead of hard-crashing the app. Common on the
++ // map's projected-overlay markers on iOS 26 + New Architecture.
+ if (iterator == _activeTouches.end()) {
+ continue;
+ }
+@@ -233,7 +239,10 @@ - (void)_unregisterTouches:(NSSet *)touches
+
+ for (UITouch *touch in touches) {
+ auto iterator = _activeTouches.find(touch);
+- RCTAssert(iterator != _activeTouches.end(), @"Inconsistency between local and UIKit touch registries");
++ // patched (facebook/react-native#53303): the fatal RCTAssert is removed so a
++ // touch that UIKit delivers after its responder view was unmounted mid-gesture
++ // is skipped by the guard below instead of hard-crashing the app. Common on the
++ // map's projected-overlay markers on iOS 26 + New Architecture.
+ if (iterator == _activeTouches.end()) {
+ continue;
+ }
diff --git a/src/hooks/useMapClusters.ts b/src/hooks/useMapClusters.ts
index a2ac442..ab791b7 100644
--- a/src/hooks/useMapClusters.ts
+++ b/src/hooks/useMapClusters.ts
@@ -63,7 +63,11 @@ export function useMapClusters(pins: MapPin[], region: Region | null) {
if (props.cluster) {
return {
type: 'cluster' as const,
- id: `cluster-${props.cluster_id}`,
+ // Position-derived key (NOT cluster_id — supercluster reassigns
+ // cluster_id across bbox/zoom queries, so a cluster at the same place
+ // would get a new React key and remount its Pressable, which can
+ // desync the Fabric touch registry mid-gesture).
+ id: `cluster-${Math.round(latitude * 1e4)}-${Math.round(longitude * 1e4)}`,
latitude,
longitude,
count: props.point_count as number,
diff --git a/src/lib/api/feed.ts b/src/lib/api/feed.ts
index 77583cf..ab45d35 100644
--- a/src/lib/api/feed.ts
+++ b/src/lib/api/feed.ts
@@ -52,6 +52,16 @@ export interface FeedPost {
status: 'processing' | 'published' | 'removed';
userReactions?: ('love' | 'respect')[];
saved?: boolean;
+ // Linked spot (backend enriches the post with this when spotId is set)
+ spotId?: string;
+ spot?: {
+ _id: string;
+ name: string;
+ city?: string;
+ state?: string;
+ imageURL?: string | null;
+ category?: string;
+ };
createdAt: string;
updatedAt: string;
}
@@ -240,6 +250,7 @@ export interface CreatePostData {
duration?: number;
aspectRatio?: string;
visibility?: 'public' | 'homies' | 'private';
+ spotId?: string;
}
/**
diff --git a/src/lib/api/spots.ts b/src/lib/api/spots.ts
index 888e11a..5f848c2 100644
--- a/src/lib/api/spots.ts
+++ b/src/lib/api/spots.ts
@@ -3,7 +3,8 @@
* Functions for fetching and managing spots
*/
-import { ENDPOINTS } from '@/constants/api';
+import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy';
+import { API_CONFIG, ENDPOINTS } from '@/constants/api';
import { apiClient } from './client';
// Types
@@ -28,7 +29,7 @@ export interface Spot {
state?: string;
isPublic?: boolean;
sportTypes?: string[];
- category?: 'park' | 'street' | 'indoor' | 'diy' | 'other';
+ category?: 'park' | 'street' | 'indoor' | 'diy' | 'resort' | 'other';
approvalStatus?: 'pending' | 'approved' | 'rejected' | 'private';
userId?: string;
createdAt?: string;
@@ -280,6 +281,18 @@ export async function updateSpot(id: string, updates: Partial): Promise {
+ try {
+ await apiClient.delete(ENDPOINTS.spots.detail(id));
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+
/**
* Get user's own spots
*/
@@ -312,6 +325,129 @@ export async function getMySpots(
}
}
+/**
+ * Get the current user's saved spots (the "Saved" half of My Spots).
+ */
+export async function getSavedSpots(
+ params: { page?: number; limit?: number } = {},
+): Promise {
+ try {
+ const queryParams = new URLSearchParams();
+ if (params.page) queryParams.append('page', params.page.toString());
+ if (params.limit) queryParams.append('limit', params.limit.toString());
+ const queryString = queryParams.toString();
+ const endpoint = queryString
+ ? `${ENDPOINTS.spots.list}/saved?${queryString}`
+ : `${ENDPOINTS.spots.list}/saved`;
+ return await apiClient.get(endpoint);
+ } catch (_error) {
+ return {
+ spots: [],
+ pagination: { page: 1, limit: 50, totalCount: 0, totalPages: 0, hasMore: false },
+ };
+ }
+}
+
+/**
+ * One-tap save: add a spot to the user's "Saved Spots". Returns true on success.
+ */
+export async function saveSpot(id: string): Promise {
+ try {
+ await apiClient.post(`${ENDPOINTS.spots.detail(id)}/save`);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+
+/**
+ * Remove a spot from the user's "Saved Spots". Returns true on success.
+ */
+export async function unsaveSpot(id: string): Promise {
+ try {
+ await apiClient.delete(`${ENDPOINTS.spots.detail(id)}/save`);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+
+/**
+ * Whether the current user has saved this spot (checks the default Saved bucket).
+ */
+export async function isSpotSaved(id: string): Promise {
+ try {
+ const lists = await apiClient.get<{ isDefaultSaved?: boolean }[]>(
+ `${ENDPOINTS.spots.detail(id)}/lists`,
+ );
+ return Array.isArray(lists) && lists.some((l) => l.isDefaultSaved === true);
+ } catch (_error) {
+ return false;
+ }
+}
+
+/**
+ * Upload one user photo to a spot (multipart). Uses expo-file-system for
+ * reliable uploads on physical iOS devices. Returns the created photo or null.
+ */
+export async function uploadSpotPhoto(
+ spotId: string,
+ fileUri: string,
+ mimeType: string = 'image/jpeg',
+): Promise {
+ try {
+ const token = await apiClient.getToken();
+ const result = await uploadAsync(
+ `${API_CONFIG.baseUrl}${ENDPOINTS.spots.detail(spotId)}/photos`,
+ fileUri,
+ {
+ httpMethod: 'POST',
+ uploadType: FileSystemUploadType.MULTIPART,
+ fieldName: 'photo',
+ mimeType,
+ headers: token ? { 'x-auth-token': token } : {},
+ },
+ );
+ if (result.status !== 200 && result.status !== 201) {
+ return null;
+ }
+ return JSON.parse(result.body) as SpotPhoto;
+ } catch (_error) {
+ return null;
+ }
+}
+
+/**
+ * Report a user-uploaded spot photo for review (any authenticated user).
+ * Returns true on success.
+ */
+export async function reportSpotPhoto(spotId: string, photoKey: string): Promise {
+ try {
+ // The S3 key contains a "/" (e.g. "spots/.jpg"); encode it so it stays
+ // a single path segment and the :photoKey route matches.
+ await apiClient.post(
+ `${ENDPOINTS.spots.detail(spotId)}/photos/${encodeURIComponent(photoKey)}/report`,
+ );
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+
+/**
+ * Delete a user-uploaded spot photo (uploader/owner or admin). Returns true on success.
+ */
+export async function deleteSpotPhoto(spotId: string, photoKey: string): Promise {
+ try {
+ await apiClient.delete(
+ `${ENDPOINTS.spots.detail(spotId)}/photos/${encodeURIComponent(photoKey)}`,
+ );
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+
// Google Places integration types
export interface PlaceSearchResult {
placeId: string;
diff --git a/src/lib/mapProjection.ts b/src/lib/mapProjection.ts
index f225f60..746db8b 100644
--- a/src/lib/mapProjection.ts
+++ b/src/lib/mapProjection.ts
@@ -38,3 +38,34 @@ export function projectToScreen(
if (x < -60 || x > layout.width + 60 || y < -80 || y > layout.height + 40) return null;
return { x, y };
}
+
+/**
+ * Like projectToScreen, but NEVER returns null for an off-screen point — instead
+ * it returns the coordinates plus an `onScreen` flag. Callers keep the marker
+ * view MOUNTED and merely hide it (opacity/pointerEvents) when off-screen.
+ *
+ * This is critical on the New Architecture: unmounting a touch-target view while
+ * a UIKit touch is still active desyncs Fabric's touch registry and hard-crashes
+ * ("Inconsistency between local and UIKit touch registries", RN #53303). Keeping
+ * markers mounted during pan/zoom removes that trigger.
+ */
+export function projectToScreenXY(
+ lat: number,
+ lng: number,
+ region: ProjRegion | null,
+ layout: { width: number; height: number },
+): { x: number; y: number; onScreen: boolean } | null {
+ if (!region || !layout.width || !layout.height) return null;
+ const { latitude, longitude, latitudeDelta, longitudeDelta } = region;
+
+ const west = longitude - longitudeDelta / 2;
+ const x = ((lng - west) / longitudeDelta) * layout.width;
+
+ const mercY = (l: number) => Math.log(Math.tan(Math.PI / 4 + (l * Math.PI) / 360));
+ const yN = mercY(latitude + latitudeDelta / 2);
+ const yS = mercY(latitude - latitudeDelta / 2);
+ const y = ((yN - mercY(lat)) / (yN - yS)) * layout.height;
+
+ const onScreen = !(x < -60 || x > layout.width + 60 || y < -80 || y > layout.height + 40);
+ return { x, y, onScreen };
+}