浏览代码

series search func

Viktoriia 12 小时之前
父节点
当前提交
f19b02626d

+ 20 - 17
src/components/TabViewWrapper/index.tsx

@@ -49,24 +49,27 @@ const CustomTabItem = ({
         }
       ]}
     >
-      <Text
-        style={{
-          color: Colors.DARK_BLUE,
-          textAlign: 'center',
-          fontSize: 14,
-          fontWeight: '600',
-          paddingHorizontal: 4
-        }}
-        numberOfLines={2}
-        adjustsFontSizeToFit
-        minimumFontScale={0.8}
-      >
-        {typeof label === 'string' ? label : ''}
-      </Text>
+      <View style={{ position: 'relative', alignSelf: 'center' }}>
+        <Text
+          style={{
+            color: Colors.DARK_BLUE,
+            textAlign: 'center',
+            fontSize: 14,
+            fontWeight: '600'
+          }}
+          numberOfLines={2}
+          adjustsFontSizeToFit
+          minimumFontScale={0.8}
+        >
+          {typeof label === 'string' ? label : ''}
+        </Text>
 
-      {renderBadge && (
-        <View style={{ position: 'absolute', top: 4, right: 10 }}>{renderBadge({ route })}</View>
-      )}
+        {renderBadge && (
+          <View style={{ position: 'absolute', top: -16, right: -14 }}>
+            {renderBadge({ route })}
+          </View>
+        )}
+      </View>
     </Pressable>
   );
 };

+ 1 - 1
src/modules/api/search/search-query-keys.tsx

@@ -1,3 +1,3 @@
 export const searchQueryKeys = {
-  getUniversal: (search: string) => ['getLastRegionsUpdate', search] as const,
+  getUniversal: (search: string) => ['getUniversalSearch', search] as const,
 };

+ 6 - 2
src/modules/api/series/queries/use-post-get-toggle-item.tsx

@@ -1,5 +1,4 @@
-import { useQuery } from '@tanstack/react-query';
-import { useMutation } from '@tanstack/react-query';
+import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
 
 import { seriesQueryKeys } from '../series-query-keys';
 import { seriesApi, type PostSetToggleItemReturn, type PostSetToggleItem } from '../series-api';
@@ -7,6 +6,8 @@ import { seriesApi, type PostSetToggleItemReturn, type PostSetToggleItem } from
 import type { BaseAxiosError } from '../../../../types';
 
 export const usePostSetToggleItem = () => {
+  const queryClient = useQueryClient();
+
   return useMutation<
     PostSetToggleItemReturn,
     BaseAxiosError,
@@ -23,6 +24,9 @@ export const usePostSetToggleItem = () => {
         variables.double
       );
       return response.data;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries(['getUniversalSearch']);
     }
   });
 };

+ 108 - 9
src/screens/InAppScreens/MapScreen/UniversalSearch/index.tsx

@@ -10,6 +10,9 @@ import { Loading, WarningModal } from 'src/components';
 import MessagesDot from 'src/components/MessagesDot';
 import ActionSheet, { ActionSheetRef } from 'react-native-actions-sheet';
 import TabViewWrapper from 'src/components/TabViewWrapper';
+import LocationIcon from 'assets/icons/travels-screens/map-location.svg';
+import { usePostSetToggleItem } from '@api/series';
+import { Colors } from 'src/theme';
 
 export function TabViewDelayed({
   children,
@@ -34,6 +37,7 @@ const SearchModal = ({
   searchVisible,
   handleCloseModal,
   handleFindRegion,
+  handleFindSeries,
   index,
   searchData,
   setIndex,
@@ -42,6 +46,7 @@ const SearchModal = ({
   searchVisible: boolean;
   handleCloseModal: () => void;
   handleFindRegion: (id: number, type: 'regions' | 'countries' | 'places') => void;
+  handleFindSeries?: (item: any) => void;
   index: number;
   searchData: any;
   setIndex: (index: number) => void;
@@ -54,13 +59,43 @@ const SearchModal = ({
   const [routes] = useState([
     { key: 'users', title: 'Nomads' },
     { key: 'regions', title: 'NM regions' },
-    { key: 'dare', title: 'DARE places' }
+    { key: 'dare', title: 'DARE places' },
+    { key: 'series', title: 'Series' }
   ]);
   const [shouldOpenModal, setShouldOpenModal] = useState<{
     id: number;
     type: 'regions' | 'countries' | 'places';
   } | null>(null);
   const [warningVisible, setWarningVisible] = useState(false);
+  const { mutate: updateSeriesItem } = usePostSetToggleItem();
+  const [localSeriesStatus, setLocalSeriesStatus] = useState<Record<number, boolean>>({});
+
+  const toggleSeries = useCallback(
+    async (item: any) => {
+      if (!token) {
+        setWarningVisible(true);
+        return;
+      }
+
+      const isCompleted = localSeriesStatus[item.item_id] !== undefined ? localSeriesStatus[item.item_id] : item.visited === 1;
+      
+      const itemData = {
+        token,
+        series_id: item.series_id,
+        item_id: item.item_id,
+        checked: (isCompleted ? 0 : 1) as 0 | 1,
+        double: 0 as 0 | 1
+      };
+
+      try {
+        updateSeriesItem(itemData);
+        setLocalSeriesStatus(prev => ({ ...prev, [item.item_id]: !isCompleted }));
+      } catch (error) {
+        console.error('Failed to update series state', error);
+      }
+    },
+    [token, updateSeriesItem, localSeriesStatus]
+  );
 
   useEffect(() => {
     if (searchVisible) {
@@ -81,17 +116,80 @@ const SearchModal = ({
       setIndex(1);
     } else if (searchData['dare'] && searchData['dare'].length > 0) {
       setIndex(2);
+    } else if (searchData['series'] && searchData['series'].length > 0) {
+      setIndex(3);
     } else {
       setIndex(0);
     }
   }, [searchData]);
 
-  const renderItem = useCallback(
-    ({ item }: { item: any }) => {
-      const [name, ...rest] = item.name?.split(/ – | - /);
+    const renderItem = useCallback(
+    ({ item, routeKey }: { item: any; routeKey: string }) => {
+      if (routeKey === 'series') {
+        const isCompleted = localSeriesStatus[item.item_id] !== undefined ? localSeriesStatus[item.item_id] : item.visited === 1;
+        return (
+          <View style={styles.seriesOption}>
+            <View style={styles.seriesImageContainer}>
+              <Image
+                source={{ uri: API_HOST + item.series_icon }}
+                style={styles.seriesIcon}
+                resizeMode="contain"
+              />
+            </View>
+
+            <View style={{ flex: 1, gap: 8 }}>
+              <View style={{ justifyContent: 'space-between', flex: 1 }}>
+                <Text style={styles.seriesName}>{item.series_name}</Text>
+                <Text style={styles.seriesDescription}>{item.item_name}</Text>
+              </View>
+
+              <View
+                style={{
+                  flexDirection: 'row',
+                  justifyContent: 'space-between',
+                  alignItems: 'center',
+                  flex: 1
+                }}
+              >
+                {item.location_geojson ? (
+                  <TouchableOpacity
+                    onPress={() => {
+                      handleCloseModal();
+                      handleFindSeries && handleFindSeries({
+                        ...item,
+                        visited: isCompleted ? 1 : 0
+                      });
+                    }}
+                    style={styles.navButton}
+                  >
+                    <LocationIcon fill={Colors.DARK_BLUE} width={16} height={16} />
+                  </TouchableOpacity>
+                ) : <View/>}
+
+                <TouchableOpacity
+                  onPress={() => toggleSeries(item)}
+                  style={[styles.markButton, isCompleted && styles.visitedButton]}
+                >
+                  {isCompleted ? (
+                    <View style={styles.completedContainer}>
+                      <Text style={[styles.calloutButtonText, { color: Colors.DARK_BLUE }]}>
+                        Completed
+                      </Text>
+                    </View>
+                  ) : (
+                    <Text style={styles.calloutButtonText}>To Complete</Text>
+                  )}
+                </TouchableOpacity>
+              </View>
+            </View>
+          </View>
+        );
+      }
+
+      const [name, ...rest] = item.name?.split(/ – | - /) || [];
       const subname = rest?.join(' - ');
 
-      return index === 0 ? (
+      return routeKey === 'users' ? (
         <TouchableOpacity
           style={{ paddingVertical: 12 }}
           onPress={() => {
@@ -138,7 +236,7 @@ const SearchModal = ({
           style={{ paddingVertical: 12 }}
           onPress={() => {
             handleCloseModal();
-            if (index === 1) {
+            if (routeKey === 'regions') {
               setShouldOpenModal({ id: item.id, type: 'regions' });
             } else {
               setShouldOpenModal({ id: item.id, type: 'places' });
@@ -166,7 +264,7 @@ const SearchModal = ({
         </TouchableOpacity>
       );
     },
-    [index]
+    [index, token, handleCloseModal, handleFindSeries, navigation, toggleSeries, localSeriesStatus]
   );
 
   const renderScene = ({ route }: { route: any }) => {
@@ -180,10 +278,11 @@ const SearchModal = ({
               minimumViewTime: 1000
             }}
             data={searchData?.[route.key]}
-            renderItem={renderItem}
-            keyExtractor={(item) => item.id.toString()}
+            renderItem={({ item }) => renderItem({ item, routeKey: route.key })}
+            keyExtractor={(item: any, idx) => `${item?.id || item?.item_id}-${idx}`}
             showsVerticalScrollIndicator={false}
             contentContainerStyle={{ paddingVertical: 16, paddingHorizontal: 8 }}
+            extraData={localSeriesStatus}
           />
         ) : (
           <Loading />

+ 60 - 0
src/screens/InAppScreens/MapScreen/UniversalSearch/styles.tsx

@@ -59,5 +59,65 @@ export const styles = StyleSheet.create({
     gap: 12,
     paddingVertical: 16,
     height: '80%'
+  },
+  seriesOption: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    backgroundColor: Colors.FILL_LIGHT,
+    paddingVertical: 8,
+    paddingHorizontal: 12,
+    borderRadius: 8,
+    marginBottom: 6
+  },
+  seriesImageContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+    marginRight: 8
+  },
+  seriesIcon: {
+    width: 32,
+    height: 32
+  },
+  seriesName: {
+    fontSize: getFontSize(14),
+    fontFamily: 'montserrat-700',
+    color: Colors.DARK_BLUE,
+    flex: 1
+  },
+  seriesDescription: {
+    fontSize: getFontSize(12),
+    fontWeight: '600',
+    color: Colors.DARK_BLUE,
+    flex: 1
+  },
+  navButton: {
+    width: 30,
+    height: 30,
+    borderRadius: 15,
+    borderWidth: 1,
+    borderColor: Colors.DARK_BLUE,
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  markButton: {
+    paddingHorizontal: 12,
+    paddingVertical: 6,
+    backgroundColor: Colors.ORANGE,
+    borderRadius: 6,
+    height: 30,
+    alignItems: 'center',
+    justifyContent: 'center'
+  },
+  visitedButton: {
+    backgroundColor: Colors.WHITE,
+    borderWidth: 1,
+    borderColor: Colors.BORDER_LIGHT
+  },
+  completedContainer: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+  calloutButtonText: {
+    color: Colors.WHITE,
+    fontSize: getFontSize(13),
+    fontWeight: '700'
   }
 });

+ 119 - 17
src/screens/InAppScreens/MapScreen/index.tsx

@@ -1813,6 +1813,104 @@ const MapScreen: any = ({ navigation, route }: { navigation: any; route: any })
     }
   };
 
+  const handleFindSeries = async (seriesItem: any) => {
+    let coordinates = null;
+    try {
+      if (seriesItem.location_geojson) {
+        const geojsonObj = JSON.parse(seriesItem.location_geojson);
+        
+        if (geojsonObj.type === 'Point') {
+          const parsedCoords = geojsonObj.coordinates;
+          if (parsedCoords && parsedCoords.length === 2) {
+            coordinates = [parsedCoords[1], parsedCoords[0]];
+          }
+        } else if (
+          geojsonObj.type === 'Polygon' ||
+          geojsonObj.type === 'MultiPolygon' ||
+          geojsonObj.type === 'GeometryCollection'
+        ) {
+          try {
+            const center = turf.centroid(geojsonObj);
+            if (center && center.geometry && center.geometry.coordinates) {
+              const parsedCoords = center.geometry.coordinates;
+              coordinates = [parsedCoords[1], parsedCoords[0]];
+            }
+          } catch (err) {
+            console.error('Error finding centroid for series', err);
+            let firstPoint;
+            if (geojsonObj.type === 'Polygon' && geojsonObj.coordinates?.[0]?.[0]) {
+              firstPoint = geojsonObj.coordinates[0][0];
+            } else if (geojsonObj.type === 'MultiPolygon' && geojsonObj.coordinates?.[0]?.[0]?.[0]) {
+              firstPoint = geojsonObj.coordinates[0][0][0];
+            } else if (geojsonObj.type === 'GeometryCollection' && geojsonObj.geometries?.[0]) {
+              const firstGeom = geojsonObj.geometries[0];
+              if (firstGeom.type === 'Point' && firstGeom.coordinates) {
+                firstPoint = firstGeom.coordinates;
+              } else if (firstGeom.type === 'Polygon' && firstGeom.coordinates?.[0]?.[0]) {
+                firstPoint = firstGeom.coordinates[0][0];
+              }
+            }
+            if (firstPoint && firstPoint.length >= 2) {
+              coordinates = [firstPoint[1], firstPoint[0]];
+            }
+          }
+        }
+      }
+    } catch (e) {
+      console.error('Error parsing coordinates for series', e);
+    }
+
+    if (coordinates) {
+      cameraController.setCamera({
+        centerCoordinate: coordinates,
+        zoomLevel: 10,
+        animationDuration: 1000,
+        animationMode: 'flyTo'
+      });
+
+      setSelectedMarker({
+        coordinates,
+        name: seriesItem.item_name,
+        icon: { uri: API_HOST + seriesItem.series_icon },
+        description: seriesItem.series_name,
+        series_name: seriesItem.series_name,
+        visited: seriesItem.visited,
+        series_id: seriesItem.series_id,
+        id: seriesItem.item_id,
+        avatars: null,
+        no_visited: 0
+      });
+      setSelectedUser(null);
+
+      setTimeout(async () => {
+        try {
+          if (!mapRef.current) return;
+          const pointInView = await mapRef.current.getPointInView(coordinates);
+          
+          if (pointInView) {
+            const { features } = await mapRef.current.queryRenderedFeaturesAtPoint(
+              pointInView,
+              undefined,
+              []
+            );
+
+            if (features && features.length > 0) {
+              const feature = features.find(
+                (f: any) => f.properties && f.properties.id === seriesItem.item_id
+              );
+              if (feature) {
+                feature.properties.override_visited = seriesItem.visited;
+                handleMarkerPress({ features: [feature] });
+              }
+            }
+          }
+        } catch (e) {
+          console.log('Failed to query map features for series', e);
+        }
+      }, 1500);
+    }
+  };
+
   const handleMarkerPress = async (event: any) => {
     const { features } = event;
 
@@ -1833,7 +1931,7 @@ const MapScreen: any = ({ navigation, route }: { navigation: any; route: any })
               icon: images[f.properties.series_id],
               description: f.properties.description,
               series_name: f.properties.series_name,
-              visited: seriesVisited.includes(f.properties.id) ? 1 : 0,
+              visited: f.properties.override_visited !== undefined ? f.properties.override_visited : (seriesVisited.includes(f.properties.id) ? 1 : 0),
               series_id: f.properties.series_id,
               id: f.properties.id,
               avatars: f.properties.avatars,
@@ -1860,24 +1958,27 @@ const MapScreen: any = ({ navigation, route }: { navigation: any; route: any })
 
       const selectedFeature = features[0];
       const { coordinates } = selectedFeature.geometry;
-      const visited = seriesVisited.includes(selectedFeature.properties.id) ? 1 : 0;
-      const icon = images[selectedFeature.properties.series_id];
-
-      const { name, description, series_name, series_id, id, avatars, no_visited } =
-        selectedFeature.properties;
+      const { name, description, series_name, series_id, id, avatars, no_visited, override_visited } = selectedFeature.properties;
+      const visited = override_visited !== undefined ? override_visited : (seriesVisited.includes(id) ? 1 : 0);
+      const icon = images[series_id];
 
       if (coordinates) {
-        setSelectedMarker({
-          coordinates,
-          name,
-          icon,
-          description,
-          series_name,
-          visited,
-          series_id,
-          id,
-          avatars,
-          no_visited
+        setSelectedMarker((prev: any) => {
+          const isSameMarker = prev && prev.id === id;
+          const currentVisited = isSameMarker ? prev.visited : visited;
+
+          return {
+            coordinates,
+            name,
+            icon,
+            description,
+            series_name,
+            visited: currentVisited,
+            series_id,
+            id,
+            avatars,
+            no_visited
+          };
         });
         setSelectedUser(null);
       }
@@ -2939,6 +3040,7 @@ const MapScreen: any = ({ navigation, route }: { navigation: any; route: any })
         searchVisible={searchVisible}
         handleCloseModal={handleCloseModal}
         handleFindRegion={handleFindRegion}
+        handleFindSeries={handleFindSeries}
         index={index}
         searchData={searchData}
         setIndex={setIndex}

+ 112 - 1
src/screens/InAppScreens/ProfileScreen/UsersMap/index.tsx

@@ -33,7 +33,10 @@ import SearchIcon from 'assets/icons/search.svg';
 
 import FilterModal, { FilterModalRef } from '../../MapScreen/FilterModal';
 import SearchModal from '../../MapScreen/UniversalSearch';
+import MarkerItem from '../../MapScreen/MarkerItem';
 import { useGetUniversalSearch } from '@api/search';
+import { usePostSetToggleItem } from '@api/series';
+import { useSubscription } from 'src/screens/OfflineMapsScreen/useSubscription';
 import { storage, StoreType } from 'src/storage';
 import { NAVIGATION_PAGES } from 'src/types';
 import { SafeAreaView } from 'react-native-safe-area-context';
@@ -224,6 +227,100 @@ const UsersMapScreen: FC<Props> = ({ navigation, route }) => {
   const [search, setSearch] = useState('');
   const [searchInput, setSearchInput] = useState('');
   const { data: searchData } = useGetUniversalSearch(search, search.length > 0);
+  
+  const [selectedMarker, setSelectedMarker] = useState<any>(null);
+  const { isPremium } = useSubscription();
+  const { mutate: updateSeriesItem } = usePostSetToggleItem();
+
+  const toggleSeries = useCallback(
+    async (item: any) => {
+      if (!token) return;
+
+      const itemData = {
+        token,
+        series_id: item.series_id,
+        item_id: item.id,
+        checked: (item.visited === 0 ? 1 : 0) as 0 | 1,
+        double: 0 as 0 | 1
+      };
+
+      try {
+        updateSeriesItem(itemData);
+        setSelectedMarker((current: any) => ({ ...current, visited: item.visited === 1 ? 0 : 1 }));
+      } catch (error) {
+        console.error('Failed to update series state', error);
+      }
+    },
+    [token, updateSeriesItem]
+  );
+
+  const handleFindSeries = async (seriesItem: any) => {
+    try {
+      const geojsonObj = JSON.parse(seriesItem.location_geojson);
+      let coordinates = null;
+      if (geojsonObj.type === 'Point') {
+        const parsedCoords = geojsonObj.coordinates;
+        if (parsedCoords && parsedCoords.length === 2) {
+          coordinates = [parsedCoords[1], parsedCoords[0]];
+        }
+      } else if (
+        geojsonObj.type === 'Polygon' ||
+        geojsonObj.type === 'MultiPolygon' ||
+        geojsonObj.type === 'GeometryCollection'
+      ) {
+        try {
+          const center = turf.centroid(geojsonObj);
+          if (center && center.geometry && center.geometry.coordinates) {
+            const parsedCoords = center.geometry.coordinates;
+            coordinates = [parsedCoords[1], parsedCoords[0]];
+          }
+        } catch (err) {
+          let firstPoint;
+          if (geojsonObj.type === 'Polygon' && geojsonObj.coordinates?.[0]?.[0]) {
+            firstPoint = geojsonObj.coordinates[0][0];
+          } else if (geojsonObj.type === 'MultiPolygon' && geojsonObj.coordinates?.[0]?.[0]?.[0]) {
+            firstPoint = geojsonObj.coordinates[0][0][0];
+          } else if (geojsonObj.type === 'GeometryCollection' && geojsonObj.geometries?.[0]) {
+            const firstGeom = geojsonObj.geometries[0];
+            if (firstGeom.type === 'Point' && firstGeom.coordinates) {
+              firstPoint = firstGeom.coordinates;
+            } else if (firstGeom.type === 'Polygon' && firstGeom.coordinates?.[0]?.[0]) {
+              firstPoint = firstGeom.coordinates[0][0];
+            }
+          }
+          if (firstPoint && firstPoint.length >= 2) {
+            coordinates = [firstPoint[1], firstPoint[0]];
+          }
+        }
+      }
+
+      if (coordinates) {
+        cameraRef.current?.setCamera({
+          centerCoordinate: coordinates,
+          zoomLevel: 10,
+          animationDuration: 1000,
+          animationMode: 'flyTo'
+        });
+
+        setSelectedMarker({
+          coordinates,
+          name: seriesItem.item_name,
+          icon: { uri: API_HOST + seriesItem.series_icon },
+          description: seriesItem.series_name,
+          series_name: seriesItem.series_name,
+          visited: seriesItem.visited,
+          series_id: seriesItem.series_id,
+          id: seriesItem.item_id,
+          avatars: null,
+          no_visited: 0
+        });
+        setRegionPopupVisible(false);
+      }
+    } catch (e) {
+      console.error('Error parsing coordinates for series', e);
+    }
+  };
+
   const [isLocationLoading, setIsLocationLoading] = useState(false);
 
   const [zoom, setZoom] = useState(0);
@@ -644,6 +741,7 @@ const UsersMapScreen: FC<Props> = ({ navigation, route }) => {
 
   const onMapPress = async (event: any) => {
     if (!mapRef.current) return;
+    if (selectedMarker) setSelectedMarker(null);
     if (type === 'blank') return;
     try {
       const { screenPointX, screenPointY } = event.properties;
@@ -970,10 +1068,20 @@ const UsersMapScreen: FC<Props> = ({ navigation, route }) => {
         {(Platform.OS === 'ios' || renderCamera) && <MapLibreRN.Camera ref={cameraRef} />}
         {location && (
           <MapLibreRN.UserLocation
-            animated={true}
             showsUserHeadingIndicator={true}
+            visible={true}
           ></MapLibreRN.UserLocation>
         )}
+          {selectedMarker && (
+            <MarkerItem
+              marker={selectedMarker}
+              setSelectedMarker={setSelectedMarker}
+              toggleSeries={toggleSeries}
+              isPremium={isPremium}
+              setPremiumModalVisible={() => {}}
+              token={token}
+            />
+          )}
       </MapLibreRN.MapView>
 
       {center ? (
@@ -1172,6 +1280,9 @@ const UsersMapScreen: FC<Props> = ({ navigation, route }) => {
         searchVisible={searchVisible}
         handleCloseModal={handleCloseModal}
         handleFindRegion={handleFindRegion}
+        handleFindSeries={(item) => {
+          handleFindSeries(item);
+        }}
         index={index}
         searchData={searchData}
         setIndex={setIndex}