index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. import React, { useEffect, useRef, useState } from 'react';
  2. import {
  3. View,
  4. Text,
  5. TouchableOpacity,
  6. KeyboardAvoidingView,
  7. TouchableWithoutFeedback,
  8. Keyboard,
  9. ScrollView
  10. } from 'react-native';
  11. import { SafeAreaView } from 'react-native-safe-area-context';
  12. import MapView, { Marker } from 'react-native-maps';
  13. import ReactModal from 'react-native-modal';
  14. import axios from 'axios';
  15. import 'react-native-get-random-values';
  16. import {
  17. GooglePlaceData,
  18. GooglePlaceDetail,
  19. GooglePlacesAutocomplete
  20. } from 'react-native-google-places-autocomplete';
  21. import { FlashList } from '@shopify/flash-list';
  22. import { Formik } from 'formik';
  23. import * as yup from 'yup';
  24. import { styles } from './styles';
  25. import { Header, Input, Button, WarningModal } from 'src/components';
  26. import { GOOGLE_MAP_PLACES_APIKEY } from 'src/constants';
  27. import { Colors } from 'src/theme';
  28. import {
  29. SubmitSuggestionTypes,
  30. useGetDataFromPoint,
  31. useGetSuggestionData,
  32. useSubmitSuggestionMutation
  33. } from '@api/series';
  34. import { StoreType, storage } from 'src/storage';
  35. import { ButtonVariants } from 'src/types/components';
  36. import SeriesSelector from './SeriesSelector';
  37. import SearchIcon from 'assets/icons/search.svg';
  38. import InfoIcon from 'assets/icons/info-solid.svg';
  39. import Tooltip from 'react-native-walkthrough-tooltip';
  40. import { GooglePlacesAutocompleteDefaultProps } from './GooglePlacesAutocompleteProps';
  41. interface Series {
  42. id: number;
  43. name: string;
  44. group_name: string | null;
  45. }
  46. const SuggestionSchema = yup.object({
  47. comment: yup.string().required('comment is required')
  48. });
  49. const SuggestSeriesScreen = ({ navigation }: { navigation: any }) => {
  50. const token = storage.get('token', StoreType.STRING) as string;
  51. const [isModalVisible, setIsModalVisible] = useState(false);
  52. const [seriesVisible, setSeriesVisible] = useState(false);
  53. const [marker, setMarker] = useState<any>(null);
  54. const [coordinates, setCoordinates] = useState<any>(null);
  55. const [groupedSeries, setGroupedSeries] = useState<any>(null);
  56. const [region, setRegion] = useState<any>({ nmRegion: null, dareRegion: null });
  57. const [selectedSeries, setSelectedSeries] = useState<Series | null>(null);
  58. const [submitedModalVisible, setSubmitedModalVisible] = useState(false);
  59. const [keyboardVisible, setKeyboardVisible] = useState(false);
  60. const { data: suggestionData } = useGetSuggestionData();
  61. const { data } = useGetDataFromPoint(
  62. token,
  63. coordinates?.lat,
  64. coordinates?.lng,
  65. coordinates ? true : false
  66. );
  67. const { mutateAsync: submitSuggestion } = useSubmitSuggestionMutation();
  68. const [tooltipVisible, setTooltipVisible] = useState(false);
  69. const mapRef = useRef<MapView>(null);
  70. useEffect(() => {
  71. const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => {
  72. setKeyboardVisible(true);
  73. });
  74. const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
  75. setKeyboardVisible(false);
  76. });
  77. return () => {
  78. keyboardDidHideListener.remove();
  79. keyboardDidShowListener.remove();
  80. };
  81. }, []);
  82. useEffect(() => {
  83. if (data && data.result === 'OK') {
  84. const nmRegion = data.nm ? suggestionData?.nm.find((item) => item.id === data.nm.id) : null;
  85. const dareRegion = data.dare
  86. ? suggestionData?.dare.find((item) => item.id === data.dare.id)
  87. : null;
  88. setRegion({ nmRegion, dareRegion });
  89. setSelectedSeries(null);
  90. setIsModalVisible(true);
  91. }
  92. }, [data]);
  93. useEffect(() => {
  94. if (suggestionData && suggestionData.result === 'OK') {
  95. const groupedData = Object.keys(suggestionData.grouped).map((key) => ({
  96. title: key,
  97. data: suggestionData.grouped[key]
  98. }));
  99. setGroupedSeries(groupedData);
  100. }
  101. }, [suggestionData]);
  102. const findPlace = async (placeId: string) => {
  103. const response = await axios.get(
  104. `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&key=${GOOGLE_MAP_PLACES_APIKEY}`
  105. );
  106. return { url: response.data.result.url, name: response.data.result.name };
  107. };
  108. const animateMapToRegion = (latitude: number, longitude: number) => {
  109. const region = {
  110. latitude,
  111. longitude,
  112. latitudeDelta: 0.015,
  113. longitudeDelta: 0.0121
  114. };
  115. mapRef.current?.animateToRegion(region, 500);
  116. };
  117. const handlePoiClick = async (event: any) => {
  118. const { placeId, coordinate } = event.nativeEvent;
  119. const { url, name } = await findPlace(placeId);
  120. setMarker({
  121. placeId,
  122. name,
  123. coordinate,
  124. url
  125. });
  126. setCoordinates({ lat: coordinate.latitude, lng: coordinate.longitude });
  127. animateMapToRegion(coordinate.latitude, coordinate.longitude);
  128. };
  129. const handlePlaceSelection = (data: GooglePlaceData, details: GooglePlaceDetail | null) => {
  130. if (details) {
  131. const { geometry } = details;
  132. setMarker({
  133. placeId: data.place_id,
  134. name: data.structured_formatting.main_text,
  135. coordinate: {
  136. latitude: geometry.location.lat,
  137. longitude: geometry.location.lng
  138. },
  139. url: details.url
  140. });
  141. setCoordinates({ lat: geometry.location.lat, lng: geometry.location.lng });
  142. animateMapToRegion(geometry.location.lat, geometry.location.lng);
  143. }
  144. };
  145. const renderGroup = ({ item }: { item: { title: string; data: Series[] } }) => {
  146. return (
  147. <View>
  148. {item.title !== '-' && <Text style={styles.groupTitle}>{item.title}</Text>}
  149. {item.data.map((series: Series) => (
  150. <TouchableOpacity
  151. key={series.id}
  152. style={styles.seriesItem}
  153. onPress={() => {
  154. setSelectedSeries(series);
  155. setSeriesVisible(false);
  156. }}
  157. >
  158. <Text style={styles.seriesText}>{series.name}</Text>
  159. </TouchableOpacity>
  160. ))}
  161. </View>
  162. );
  163. };
  164. const handleClose = () => {
  165. setSubmitedModalVisible(false);
  166. setIsModalVisible(false);
  167. navigation.goBack();
  168. };
  169. return (
  170. <SafeAreaView
  171. style={{
  172. height: '100%'
  173. }}
  174. edges={['top']}
  175. >
  176. <View style={styles.wrapper}>
  177. <Header
  178. label={'Suggest new Series item'}
  179. rightElement={
  180. <Tooltip
  181. isVisible={tooltipVisible}
  182. onClose={() => setTooltipVisible(false)}
  183. content={
  184. <Text style={{ color: Colors.DARK_BLUE, fontSize: 13 }}>
  185. We value the enthusiasm of travelers who want to share their favorite places with
  186. the NomadMania community. Every suggestion reflects passion for exploration, and
  187. we appreciate the time and thought that goes into each contribution to the series.
  188. {'\n\n'}At the same time, not all submissions will be added to the map. We have a
  189. dedicated team that is tasked to review all suggestions and maintain an overall
  190. balance across regions and topics and to prevent overcrowding in certain areas.
  191. {'\n\n'}For this reason, even good suggestions may occasionally be declined. We
  192. hope you understand this approach, and we thank you for helping us shape a map
  193. that highlights the richness of travel across the world.{'\n\n'}Best regards, the
  194. Series Team
  195. </Text>
  196. }
  197. contentStyle={{ backgroundColor: Colors.WHITE }}
  198. backgroundColor="transparent"
  199. allowChildInteraction={false}
  200. placement="bottom"
  201. >
  202. <TouchableOpacity onPress={() => setTooltipVisible(true)} style={{ width: 30 }}>
  203. <InfoIcon />
  204. </TouchableOpacity>
  205. </Tooltip>
  206. }
  207. />
  208. <View style={styles.searchContainer}>
  209. <GooglePlacesAutocomplete
  210. {...GooglePlacesAutocompleteDefaultProps}
  211. placeholder="Add a landmark"
  212. onPress={handlePlaceSelection}
  213. query={{
  214. key: GOOGLE_MAP_PLACES_APIKEY,
  215. language: 'en',
  216. types: 'establishment'
  217. }}
  218. nearbyPlacesAPI="GooglePlacesSearch"
  219. fetchDetails={true}
  220. styles={{
  221. textInput: styles.searchInput
  222. }}
  223. isRowScrollable={true}
  224. renderLeftButton={() => (
  225. <View style={styles.searchIcon}>
  226. <SearchIcon fill={Colors.LIGHT_GRAY} />
  227. </View>
  228. )}
  229. />
  230. </View>
  231. </View>
  232. <View style={styles.container}>
  233. <MapView
  234. ref={mapRef}
  235. style={styles.map}
  236. showsMyLocationButton={true}
  237. showsUserLocation={true}
  238. showsCompass={false}
  239. zoomControlEnabled={false}
  240. mapType={'standard'}
  241. maxZoomLevel={18}
  242. minZoomLevel={0}
  243. initialRegion={{
  244. latitude: 0,
  245. longitude: 0,
  246. latitudeDelta: 180,
  247. longitudeDelta: 180
  248. }}
  249. provider="google"
  250. onPoiClick={handlePoiClick}
  251. >
  252. {marker && (
  253. <Marker coordinate={marker.coordinate} onPress={() => setIsModalVisible(true)} />
  254. )}
  255. </MapView>
  256. </View>
  257. <ReactModal
  258. isVisible={isModalVisible}
  259. onBackdropPress={() => setIsModalVisible(false)}
  260. style={styles.modal}
  261. statusBarTranslucent={true}
  262. presentationStyle="overFullScreen"
  263. >
  264. <Formik
  265. initialValues={{
  266. comment: '',
  267. nm: region.nmRegion ? region.nmRegion.region_name : '-',
  268. dare: region.dareRegion ? region.dareRegion.name : '-',
  269. series: selectedSeries ? selectedSeries.id : null,
  270. name: marker?.name,
  271. link: marker?.url,
  272. lat: coordinates?.lat,
  273. lng: coordinates?.lng,
  274. item: -1
  275. }}
  276. validationSchema={SuggestionSchema}
  277. onSubmit={(values) => {
  278. const { comment, name, link, lat, lng, item } = values;
  279. if (!selectedSeries) return;
  280. const submitData: SubmitSuggestionTypes = {
  281. token,
  282. comment,
  283. name,
  284. link,
  285. lat,
  286. lng,
  287. item,
  288. nm: region.nmRegion.id,
  289. dare: region.dareRegion ? region.dareRegion.id : 0,
  290. series: selectedSeries.id
  291. };
  292. submitSuggestion(submitData, {
  293. onSuccess: () => {
  294. setSubmitedModalVisible(true);
  295. }
  296. });
  297. }}
  298. >
  299. {(props) => (
  300. <KeyboardAvoidingView
  301. behavior={'padding'}
  302. style={[styles.modalContent, { maxHeight: keyboardVisible ? undefined : '90%' }]}
  303. >
  304. <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
  305. <ScrollView
  306. contentContainerStyle={{ gap: 16 }}
  307. showsVerticalScrollIndicator={false}
  308. keyboardShouldPersistTaps="handled"
  309. >
  310. <Input
  311. placeholder="NM region"
  312. value={props.values.nm}
  313. editable={false}
  314. header="NM region"
  315. height={40}
  316. />
  317. <Input
  318. placeholder="DARE place"
  319. value={props.values.dare}
  320. editable={false}
  321. header="DARE place"
  322. height={40}
  323. />
  324. <SeriesSelector
  325. selectedSeries={selectedSeries}
  326. setSeriesVisible={setSeriesVisible}
  327. props={props}
  328. />
  329. <Input
  330. placeholder="Name"
  331. value={props.values.name}
  332. editable={false}
  333. header="Name"
  334. height={40}
  335. />
  336. <Input
  337. placeholder="URL"
  338. value={props.values.link}
  339. editable={false}
  340. header="Google maps link"
  341. height={40}
  342. />
  343. <Input
  344. multiline={true}
  345. header="Comment"
  346. value={props.values.comment}
  347. onChange={props.handleChange('comment')}
  348. formikError={props.touched.comment && props.errors.comment}
  349. />
  350. <View style={{ paddingBottom: 24, gap: 16 }}>
  351. <Button children="Send" onPress={props.handleSubmit} />
  352. <Button
  353. children="Close"
  354. onPress={() => setIsModalVisible(false)}
  355. variant={ButtonVariants.OPACITY}
  356. containerStyles={styles.closeBtn}
  357. textStyles={{ color: Colors.DARK_BLUE }}
  358. />
  359. </View>
  360. </ScrollView>
  361. </TouchableWithoutFeedback>
  362. </KeyboardAvoidingView>
  363. )}
  364. </Formik>
  365. <ReactModal
  366. isVisible={seriesVisible}
  367. onBackdropPress={() => setSeriesVisible(false)}
  368. style={styles.modal}
  369. statusBarTranslucent={true}
  370. presentationStyle="overFullScreen"
  371. >
  372. <View style={styles.modalWrapper}>
  373. <ScrollView
  374. style={{ paddingBottom: 16 }}
  375. showsVerticalScrollIndicator={false}
  376. nestedScrollEnabled={true}
  377. >
  378. {groupedSeries ? (
  379. <View style={{ minHeight: 100, paddingBottom: 16, paddingTop: 8 }}>
  380. <TouchableOpacity
  381. style={styles.seriesItem}
  382. onPress={() => {
  383. setSelectedSeries(null);
  384. setSeriesVisible(false);
  385. }}
  386. >
  387. <Text style={styles.seriesText}>Select series</Text>
  388. </TouchableOpacity>
  389. <FlashList
  390. viewabilityConfig={{
  391. waitForInteraction: true,
  392. itemVisiblePercentThreshold: 50,
  393. minimumViewTime: 1000
  394. }}
  395. data={groupedSeries}
  396. renderItem={renderGroup}
  397. keyExtractor={(item) => item.title}
  398. nestedScrollEnabled={true}
  399. />
  400. </View>
  401. ) : null}
  402. </ScrollView>
  403. </View>
  404. </ReactModal>
  405. <WarningModal
  406. isVisible={submitedModalVisible}
  407. onClose={handleClose}
  408. type="success"
  409. message="Thank you for your suggestion!"
  410. title="Success!"
  411. />
  412. </ReactModal>
  413. </SafeAreaView>
  414. );
  415. };
  416. export default SuggestSeriesScreen;