index.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. import React, { useCallback, useEffect, useRef, useState } from 'react';
  2. import { View, Text, Image, TouchableOpacity, LayoutAnimation, Modal } from 'react-native';
  3. import { FlashList } from '@shopify/flash-list';
  4. import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native';
  5. import { popupStyles, styles } from './styles';
  6. import Animated, {
  7. useSharedValue,
  8. useAnimatedStyle,
  9. withTiming,
  10. runOnJS,
  11. interpolate,
  12. Extrapolation
  13. } from 'react-native-reanimated';
  14. import { FilterImage } from 'react-native-svg/filter-image';
  15. import { NAVIGATION_PAGES } from 'src/types';
  16. import { StoreType, storage } from 'src/storage';
  17. import { Header, Input, PageWrapper } from 'src/components';
  18. import { Colors } from 'src/theme';
  19. import SearchIcon from 'assets/icons/search.svg';
  20. import CalendarIcon from 'assets/icons/events/calendar-solid.svg';
  21. import EarthIcon from 'assets/icons/travels-section/earth.svg';
  22. import NomadsIcon from 'assets/icons/bottom-navigation/travellers.svg';
  23. import CalendarPlusIcon from 'assets/icons/events/calendar-plus.svg';
  24. import ShoppingCartIcon from 'assets/icons/events/shopping-cart.svg';
  25. import StarIcon from 'assets/icons/events/star.svg';
  26. import {
  27. PostGetEventsListReturn,
  28. SingleEvent,
  29. useGetCanAddEventQuery,
  30. useGetEventsListQuery
  31. } from '@api/events';
  32. import moment from 'moment';
  33. import { API_HOST } from 'src/constants';
  34. import { renderSpotsText } from './utils';
  35. import ChevronIcon from 'assets/icons/chevron-left.svg';
  36. import Tooltip from 'react-native-walkthrough-tooltip';
  37. import InfoIcon from 'assets/icons/info-solid.svg';
  38. import { SafeAreaView } from 'react-native-safe-area-context';
  39. import TabViewWrapper from 'src/components/TabViewWrapper';
  40. function TabViewDelayed({
  41. children,
  42. waitBeforeShow = 0
  43. }: {
  44. children: React.ReactNode;
  45. waitBeforeShow?: number;
  46. }) {
  47. const [isShown, setIsShown] = useState(false);
  48. useEffect(() => {
  49. const timer = setTimeout(() => {
  50. setIsShown(true);
  51. }, waitBeforeShow);
  52. return () => clearTimeout(timer);
  53. }, [waitBeforeShow]);
  54. return isShown ? children : null;
  55. }
  56. const EventsScreen = () => {
  57. const token = (storage.get('token', StoreType.STRING) as string) ?? null;
  58. const { data, refetch } = useGetEventsListQuery(token, 0, true);
  59. const { data: pastData } = useGetEventsListQuery(token, 1, true);
  60. const { data: canAddEvent } = useGetCanAddEventQuery(token, true);
  61. const navigation = useNavigation();
  62. const [searchQuery, setSearchQuery] = useState('');
  63. const [events, setEvents] = useState<PostGetEventsListReturn>({
  64. local_meetings: [],
  65. nm: [],
  66. shared_trips: []
  67. } as never);
  68. const [pastEvents, setPastEvents] = useState<PostGetEventsListReturn>({
  69. local_meetings: [],
  70. nm: [],
  71. shared_tripsv: []
  72. } as never);
  73. const [filteredEvents, setFilteredEvents] = useState<PostGetEventsListReturn>({
  74. local_meetings: [],
  75. nm: [],
  76. shared_trips: []
  77. } as never);
  78. const [filteredPastEvents, setFilteredPastEvents] = useState<PostGetEventsListReturn>({
  79. local_meetings: [],
  80. nm: [],
  81. shared_trips: []
  82. } as never);
  83. const [tooltipStates, setTooltipStates] = useState<Record<number, boolean>>({});
  84. const date = new Date();
  85. const [expandedStates, setExpandedStates] = useState<Record<string, boolean>>({
  86. nm: false,
  87. shared_trips: false,
  88. local_meetings: false
  89. });
  90. const scrollViewRefs = useRef<Record<string, FlashList<SingleEvent> | null>>({
  91. nm: null,
  92. shared_trips: null,
  93. local_meetings: null
  94. });
  95. const sectionRef = useRef<View>(null);
  96. const [showPopup, setShowPopup] = useState(false);
  97. const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
  98. const [toolTipVisible, setToolTipVisible] = useState<boolean>(false);
  99. const buttonRef = useRef<TouchableOpacity>(null);
  100. const [index, setIndex] = useState<number>(0);
  101. const [routes] = useState<{ key: 'nm' | 'shared_trips' | 'local_meetings'; title: string }[]>([
  102. { key: 'nm', title: 'NomadMania Events' },
  103. { key: 'shared_trips', title: 'Shared Trips' },
  104. { key: 'local_meetings', title: 'Local Meetings' }
  105. ]);
  106. const SEARCH_CONTAINER_HEIGHT = 44;
  107. const searchContainerHeight = useSharedValue(SEARCH_CONTAINER_HEIGHT);
  108. const lastScrollY = useRef(0);
  109. const isSearchVisible = useRef(true);
  110. const hideSearchContainer = useCallback(() => {
  111. 'worklet';
  112. if (isSearchVisible.current) {
  113. isSearchVisible.current = false;
  114. searchContainerHeight.value = withTiming(0, {
  115. duration: 150
  116. });
  117. }
  118. }, []);
  119. const showSearchContainer = useCallback(() => {
  120. 'worklet';
  121. if (!isSearchVisible.current) {
  122. isSearchVisible.current = true;
  123. searchContainerHeight.value = withTiming(SEARCH_CONTAINER_HEIGHT, {
  124. duration: 150
  125. });
  126. }
  127. }, []);
  128. const handleScroll = useCallback(
  129. (event: any) => {
  130. const currentScrollY = event.nativeEvent.contentOffset.y;
  131. const diff = currentScrollY - lastScrollY.current;
  132. if (diff > 3 && currentScrollY > 20 && isSearchVisible.current) {
  133. runOnJS(hideSearchContainer)();
  134. } else if (currentScrollY <= 5 && !isSearchVisible.current) {
  135. runOnJS(showSearchContainer)();
  136. }
  137. lastScrollY.current = currentScrollY;
  138. },
  139. [hideSearchContainer, showSearchContainer]
  140. );
  141. const searchContainerAnimatedStyle = useAnimatedStyle(() => {
  142. return {
  143. height: searchContainerHeight.value,
  144. overflow: 'hidden',
  145. opacity: interpolate(
  146. searchContainerHeight.value,
  147. [0, SEARCH_CONTAINER_HEIGHT],
  148. [0, 1],
  149. Extrapolation.CLAMP
  150. )
  151. };
  152. });
  153. const handleAddButtonPress = () => {
  154. if (buttonRef.current) {
  155. buttonRef.current.measure((x, y, width, height, pageX, pageY) => {
  156. setPopupPosition({
  157. x: pageX - 120,
  158. y: pageY + height + 5
  159. });
  160. setShowPopup(true);
  161. });
  162. }
  163. };
  164. const handlePopupOption = (option: 'meeting' | 'trip') => {
  165. setShowPopup(false);
  166. if (option === 'meeting') {
  167. navigation.navigate(NAVIGATION_PAGES.CREATE_EVENT as never);
  168. } else if (option === 'trip') {
  169. navigation.navigate(NAVIGATION_PAGES.CREATE_SHARED_TRIP as never);
  170. }
  171. };
  172. const toggleExpand = (tabKey: string) => {
  173. LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut, () => {
  174. if (!expandedStates[tabKey] && sectionRef.current && scrollViewRefs.current[tabKey]) {
  175. scrollViewRefs.current[tabKey]?.scrollToEnd({
  176. animated: true
  177. });
  178. }
  179. });
  180. setExpandedStates((prev) => ({
  181. ...prev,
  182. [tabKey]: !prev[tabKey]
  183. }));
  184. };
  185. useEffect(() => {
  186. if (data && data.nm) {
  187. setEvents(data);
  188. setFilteredEvents(data);
  189. }
  190. }, [data]);
  191. useEffect(() => {
  192. if (pastData && pastData.nm) {
  193. setPastEvents(pastData);
  194. setFilteredPastEvents(pastData);
  195. }
  196. }, [pastData]);
  197. useFocusEffect(
  198. useCallback(() => {
  199. refetch();
  200. }, [navigation])
  201. );
  202. const handleSearch = (text: string) => {
  203. if (text) {
  204. const searchData =
  205. (index === 0
  206. ? events.nm
  207. : index === 1
  208. ? events.shared_trips
  209. : events.local_meetings
  210. ).filter((item: any) => {
  211. const itemData = item.name ? item.name.toLowerCase() : ''.toLowerCase();
  212. const textData = text.toLowerCase();
  213. return itemData.indexOf(textData) > -1;
  214. }) ?? [];
  215. setFilteredEvents(
  216. index === 0
  217. ? { ...events, nm: searchData }
  218. : index === 1
  219. ? { ...events, shared_trips: searchData }
  220. : { ...events, local_meetings: searchData }
  221. );
  222. const searchPastData =
  223. (index === 0
  224. ? pastEvents.nm
  225. : index === 1
  226. ? pastEvents.shared_trips
  227. : pastEvents.local_meetings
  228. ).filter((item: any) => {
  229. const itemData = item.name ? item.name.toLowerCase() : ''.toLowerCase();
  230. const textData = text.toLowerCase();
  231. return itemData.indexOf(textData) > -1;
  232. }) ?? [];
  233. setFilteredPastEvents(
  234. index === 0
  235. ? { ...events, nm: searchPastData }
  236. : index === 1
  237. ? { ...events, shared_trips: searchPastData }
  238. : { ...events, local_meetings: searchPastData }
  239. );
  240. setSearchQuery(text);
  241. } else {
  242. setFilteredEvents(events);
  243. setFilteredPastEvents(pastEvents);
  244. setSearchQuery(text);
  245. }
  246. };
  247. const formatEventDate = (event: SingleEvent) => {
  248. if (event.date_from && event.date_to) {
  249. if (event.date_tentative) {
  250. const dateFrom = moment(event.date_from, 'YYYY-MM').format('MMM YYYY');
  251. const dateTo = moment(event.date_to, 'YYYY-MM').format('MMM YYYY');
  252. if (dateFrom === dateTo) {
  253. return dateFrom;
  254. }
  255. return `${dateFrom} - ${dateTo}`;
  256. }
  257. return `${moment(event.date_from, 'YYYY-MM-DD').format('DD MMM YYYY')} - ${moment(event.date_to, 'YYYY-MM-DD').format('DD MMM YYYY')}`;
  258. } else {
  259. if (event.date_tentative) {
  260. return `${moment(event.date, 'YYYY-MM').format('MMM YYYY')}`;
  261. }
  262. return moment(event.date, 'YYYY-MM-DD').format('DD MMMM YYYY');
  263. }
  264. };
  265. const renderEventCard = ({ item }: { item: SingleEvent }) => {
  266. let staticImgUrl = '/static/img/events/meeting.webp';
  267. let badgeColor = Colors.DARK_BLUE;
  268. let badgeText = '';
  269. if (item.full) {
  270. badgeColor = Colors.LIGHT_GRAY;
  271. badgeText = 'FULL';
  272. } else if (item.closed) {
  273. badgeColor = Colors.LIGHT_GRAY;
  274. badgeText = 'CLOSED';
  275. } else if (item.type === 2) {
  276. badgeColor = Colors.ORANGE;
  277. badgeText = 'TOUR';
  278. staticImgUrl = '/static/img/events/trip.webp';
  279. } else if (item.type === 3) {
  280. badgeColor = Colors.DARK_BLUE;
  281. badgeText = 'CONF';
  282. staticImgUrl = '/static/img/events/conference.webp';
  283. }
  284. const photo = item.photo
  285. ? API_HOST + '/webapi/events/get-square-photo/' + item.id
  286. : API_HOST + staticImgUrl;
  287. return (
  288. <View>
  289. <TouchableOpacity
  290. style={[
  291. styles.card,
  292. item.type === 2 || item.type === 3 || item.full || item.closed
  293. ? { backgroundColor: Colors.FILL_LIGHT }
  294. : { backgroundColor: Colors.WHITE }
  295. ]}
  296. onPress={() =>
  297. navigation.navigate(...([NAVIGATION_PAGES.EVENT, { url: item.url }] as never))
  298. }
  299. disabled={item.active === 0}
  300. >
  301. <View style={styles.imageWrapper}>
  302. {item.active === 0 ? (
  303. <FilterImage
  304. source={{ uri: photo }}
  305. style={[
  306. styles.image,
  307. {
  308. filter: 'grayscale(100%)'
  309. }
  310. ]}
  311. />
  312. ) : (
  313. <Image
  314. source={{ uri: photo, cache: 'reload' }}
  315. style={styles.image}
  316. resizeMode="cover"
  317. />
  318. )}
  319. {item.star === 1 && (
  320. <View style={styles.iconOverlay}>
  321. <StarIcon fill={Colors.WHITE} width={12} />
  322. </View>
  323. )}
  324. {item.joined && token ? (
  325. <View style={styles.joinedOverlay}>
  326. <Text style={{ color: Colors.WHITE, fontSize: 12, fontFamily: 'redhat-700' }}>
  327. Joined
  328. </Text>
  329. </View>
  330. ) : null}
  331. </View>
  332. <View style={styles.info}>
  333. <Text style={styles.title} numberOfLines={1}>
  334. {item.name}
  335. </Text>
  336. <View style={styles.row}>
  337. {item.type === 1 && item?.flag ? (
  338. <Tooltip
  339. key={item.id}
  340. isVisible={!!tooltipStates[item.id]}
  341. content={<Text style={{ color: Colors.BLACK }}>{item.country}</Text>}
  342. contentStyle={{ backgroundColor: Colors.FILL_LIGHT }}
  343. tooltipStyle={{
  344. position: 'absolute',
  345. zIndex: 1000
  346. }}
  347. arrowStyle={{
  348. width: 16,
  349. height: 8
  350. }}
  351. placement="top"
  352. onClose={() => setTooltipStates((prev) => ({ ...prev, [item.id]: false }))}
  353. backgroundColor="transparent"
  354. allowChildInteraction={false}
  355. >
  356. <TouchableOpacity
  357. onPress={() => setTooltipStates((prev) => ({ ...prev, [item.id]: true }))}
  358. hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
  359. >
  360. <Image
  361. source={{ uri: API_HOST + item.flag }}
  362. style={{
  363. width: 14,
  364. height: 14,
  365. borderRadius: 7,
  366. borderWidth: 0.5,
  367. borderColor: Colors.DARK_LIGHT
  368. }}
  369. />
  370. </TouchableOpacity>
  371. </Tooltip>
  372. ) : (
  373. <EarthIcon fill={Colors.DARK_BLUE} height={14} width={14} />
  374. )}
  375. <Text style={styles.dateAndLocation} numberOfLines={1}>
  376. {item.address1}
  377. </Text>
  378. </View>
  379. <View style={[styles.row]}>
  380. <View style={styles.row}>
  381. <CalendarIcon fill={Colors.DARK_BLUE} height={14} width={14} />
  382. <Text style={[styles.dateAndLocation, { flex: 0 }]} numberOfLines={1}>
  383. {formatEventDate(item)}
  384. </Text>
  385. </View>
  386. </View>
  387. {item.registrations_info !== 1 && (
  388. <View style={styles.row}>
  389. <NomadsIcon fill={Colors.DARK_BLUE} height={14} width={14} />
  390. <Text style={styles.dateAndLocation} numberOfLines={1}>
  391. {renderSpotsText(item)}
  392. </Text>
  393. </View>
  394. )}
  395. </View>
  396. {item.type === 2 || item.type === 3 || item.full || item.closed ? (
  397. <View
  398. style={[
  399. styles.statusBadge,
  400. { backgroundColor: item.active ? badgeColor : Colors.LIGHT_GRAY }
  401. ]}
  402. >
  403. <View style={styles.rotatedContainer}>
  404. <Text style={styles.statusText}>{badgeText}</Text>
  405. </View>
  406. </View>
  407. ) : null}
  408. </TouchableOpacity>
  409. </View>
  410. );
  411. };
  412. const renderScene = ({
  413. route
  414. }: {
  415. route: { key: 'nm' | 'shared_trips' | 'local_meetings'; title: string };
  416. }) => {
  417. const isCurrentTabExpanded = expandedStates[route.key];
  418. return (
  419. <>
  420. <FlashList
  421. ref={(ref) => {
  422. scrollViewRefs.current[route.key] = ref;
  423. }}
  424. data={filteredEvents[route.key] || []}
  425. ListHeaderComponent={
  426. route.key === 'shared_trips' ? (
  427. <TouchableOpacity
  428. onPress={() => setToolTipVisible(true)}
  429. style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 12 }}
  430. >
  431. <Tooltip
  432. isVisible={toolTipVisible}
  433. content={
  434. <Text style={{ fontSize: 12, color: Colors.DARK_BLUE }}>
  435. Disclaimer: All trips listed here are shared by members of our travel
  436. community. NomadMania is not the organizer of these trips, and we do not
  437. verify their details, safety, or suitability. Participation is at your own
  438. discretion and risk. We do not take any responsibility for arrangements,
  439. agreements, or outcomes related to these community-shared trips.
  440. </Text>
  441. }
  442. contentStyle={{ backgroundColor: Colors.WHITE }}
  443. placement="bottom"
  444. onClose={() => setToolTipVisible(false)}
  445. backgroundColor="transparent"
  446. allowChildInteraction={false}
  447. >
  448. <TouchableOpacity
  449. onPress={() => setToolTipVisible(true)}
  450. hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
  451. >
  452. <InfoIcon fill={Colors.DARK_BLUE} width={16} height={16} />
  453. </TouchableOpacity>
  454. </Tooltip>
  455. <Text style={{ fontSize: 12, color: Colors.DARK_BLUE, fontWeight: '600' }}>
  456. Disclaimer [read more]
  457. </Text>
  458. </TouchableOpacity>
  459. ) : null
  460. }
  461. scrollEnabled={true}
  462. keyExtractor={(item) => `${route.key}-${item.id}`}
  463. renderItem={renderEventCard}
  464. estimatedItemSize={120}
  465. contentContainerStyle={styles.listContainer}
  466. showsVerticalScrollIndicator={false}
  467. onScroll={handleScroll}
  468. scrollEventThrottle={16}
  469. ListFooterComponent={
  470. filteredPastEvents[route.key] && filteredPastEvents[route.key].length ? (
  471. <View ref={sectionRef} style={styles.sectionContainer}>
  472. <TouchableOpacity onPress={() => toggleExpand(route.key)} style={styles.header}>
  473. <View style={styles.headerContainer}>
  474. <Text style={styles.headerText}>Past Events</Text>
  475. </View>
  476. <View style={styles.chevronContainer}>
  477. <ChevronIcon
  478. fill={Colors.DARK_BLUE}
  479. style={[styles.headerIcon, isCurrentTabExpanded ? styles.rotate : null]}
  480. />
  481. </View>
  482. </TouchableOpacity>
  483. {isCurrentTabExpanded ? (
  484. <FlashList
  485. data={filteredPastEvents[route.key] || []}
  486. scrollEnabled={true}
  487. keyExtractor={(item) => item.id.toString()}
  488. renderItem={renderEventCard}
  489. estimatedItemSize={100}
  490. contentContainerStyle={styles.listContainer}
  491. showsVerticalScrollIndicator={false}
  492. />
  493. ) : null}
  494. </View>
  495. ) : null
  496. }
  497. />
  498. </>
  499. );
  500. };
  501. const handleIndexChange = useCallback(
  502. (newIndex: number) => {
  503. setSearchQuery('');
  504. if (newIndex >= 0 && newIndex < routes.length) {
  505. setIndex(newIndex);
  506. }
  507. },
  508. [routes.length]
  509. );
  510. return (
  511. <SafeAreaView style={{ height: '100%' }} edges={['top']}>
  512. <View style={{ marginLeft: '5%', marginRight: '5%' }}>
  513. <Header
  514. label="Events"
  515. rightElement={
  516. canAddEvent?.can ? (
  517. <TouchableOpacity
  518. ref={buttonRef}
  519. onPress={handleAddButtonPress}
  520. style={{ width: 30 }}
  521. hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
  522. >
  523. <CalendarPlusIcon fill={Colors.DARK_BLUE} />
  524. </TouchableOpacity>
  525. ) : null
  526. }
  527. />
  528. <Animated.View style={[styles.searchContainer, searchContainerAnimatedStyle]}>
  529. <Input
  530. inputMode={'search'}
  531. placeholder={'Search'}
  532. onChange={(text) => handleSearch(text)}
  533. value={searchQuery}
  534. icon={<SearchIcon fill={'#C8C8C8'} width={14} height={14} />}
  535. height={38}
  536. />
  537. </Animated.View>
  538. </View>
  539. <TabViewWrapper
  540. routes={routes}
  541. renderScene={renderScene as never}
  542. setIndex={setIndex}
  543. lazy={false}
  544. selectedIndex={index}
  545. />
  546. <Modal
  547. visible={showPopup}
  548. transparent={true}
  549. animationType="fade"
  550. onRequestClose={() => setShowPopup(false)}
  551. >
  552. <TouchableOpacity
  553. style={{
  554. flex: 1,
  555. backgroundColor: 'rgba(0,0,0,0.1)'
  556. }}
  557. onPress={() => setShowPopup(false)}
  558. activeOpacity={1}
  559. >
  560. <View
  561. style={[
  562. popupStyles.popup,
  563. {
  564. top: popupPosition.y,
  565. left: popupPosition.x
  566. }
  567. ]}
  568. >
  569. <TouchableOpacity
  570. style={popupStyles.popupOption}
  571. onPress={() => handlePopupOption('meeting')}
  572. >
  573. <Text style={popupStyles.popupText}>Add meeting</Text>
  574. </TouchableOpacity>
  575. <TouchableOpacity
  576. style={[popupStyles.popupOption, popupStyles.popupOptionLast]}
  577. onPress={() => handlePopupOption('trip')}
  578. >
  579. <Text style={popupStyles.popupText}>Add shared trip</Text>
  580. </TouchableOpacity>
  581. </View>
  582. </TouchableOpacity>
  583. </Modal>
  584. </SafeAreaView>
  585. );
  586. };
  587. export default EventsScreen;