From 286b435fa5aefd7077149ce2de894c64464c2c0a Mon Sep 17 00:00:00 2001 From: Tony Kharioki Date: Tue, 24 Sep 2024 08:11:18 +0300 Subject: [PATCH] Sessionize data, schedule, session page and zustand store --- app/(tabs)/_layout.tsx | 6 + app/(tabs)/home/bookmarks.tsx | 79 ++++++++-- app/(tabs)/home/index.tsx | 94 +++++++++-- app/(tabs)/home/speakers.tsx | 34 ++-- app/(tabs)/sessions/[id].tsx | 201 ++++++++++++++++++++++++ app/(tabs)/sessions/_layout.tsx | 20 +++ app/(tabs)/speakers/[id].tsx | 152 +++++++++--------- app/(tabs)/speakers/_layout.tsx | 8 +- app/_layout.tsx | 14 ++ components/cards/SessionCard.tsx | 116 ++++++++++++++ components/cards/SpeakerCard.tsx | 3 +- components/headers/ListHeaderButton.tsx | 63 ++++++++ components/types.ts | 17 -- constants/Colors.ts | 4 +- constants/types.ts | 36 +++++ hooks/useFetchSpeakers.tsx | 29 ---- hooks/useStoredState.ts | 24 --- mock/all.ts | 81 ++++++++++ mock/allSessions.json | 86 ++++++++++ package-lock.json | 41 ++++- package.json | 4 +- state/bookmarks.ts | 44 ++++++ state/store.ts | 101 ++++++++++++ utils/formatDate.ts | 77 +++++++++ utils/sessions.ts | 56 +++++++ 25 files changed, 1193 insertions(+), 197 deletions(-) create mode 100644 app/(tabs)/sessions/[id].tsx create mode 100644 app/(tabs)/sessions/_layout.tsx create mode 100644 components/cards/SessionCard.tsx create mode 100644 components/headers/ListHeaderButton.tsx delete mode 100644 components/types.ts create mode 100644 constants/types.ts delete mode 100644 hooks/useFetchSpeakers.tsx delete mode 100644 hooks/useStoredState.ts create mode 100644 mock/all.ts create mode 100644 mock/allSessions.json create mode 100644 state/bookmarks.ts create mode 100644 state/store.ts create mode 100644 utils/formatDate.ts create mode 100644 utils/sessions.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 25fb9ad..cb98bf4 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -16,6 +16,12 @@ export default function TabLayout() { headerShown: false, }} /> + ); } diff --git a/app/(tabs)/home/bookmarks.tsx b/app/(tabs)/home/bookmarks.tsx index 60807c6..73d5fc3 100644 --- a/app/(tabs)/home/bookmarks.tsx +++ b/app/(tabs)/home/bookmarks.tsx @@ -1,28 +1,83 @@ -import { StyleSheet, View } from 'react-native'; +import { FlatList, StyleSheet, View } from 'react-native'; import MainContainer from '@/components/containers/MainContainer'; import StyledText from '@/components/common/StyledText'; -import { spacing } from '@/constants/Styles'; -// import Colors from '@/constants/Colors'; +import { sizes, spacing } from '@/constants/Styles'; +import { useBookmarkStore } from '@/state/bookmarks'; +import { useStore } from '@/state/store'; +import Colors from '@/constants/Colors'; +import { getSpeaker, getRoom } from '@/utils/sessions'; +import SessionCard from '@/components/cards/SessionCard'; +import { useRouter } from 'expo-router'; + +export default function BookmarksPage() { + const router = useRouter(); + + const bookmarks = useBookmarkStore((state) => state.bookmarks); + const allSessions = useStore((state) => state.allSessions); + const sessions = useStore((state) => state.allSessions.sessions); + const bookmarkedSessions = sessions.filter((session) => + bookmarks.some((bookmark) => bookmark.sessionId === session.id), + ); -const home = () => { return ( - + - - Bookmarks + + Bookmarked Sessions + + { + const speakers = item.speakers.map((speakerId) => getSpeaker(speakerId, allSessions)); + return ( + router.push(`/sessions/${item.id}`)} + /> + ); + }} + keyExtractor={(item) => item.id} + initialNumToRender={10} + maxToRenderPerBatch={10} + showsVerticalScrollIndicator={false} + ItemSeparatorComponent={() => } + ListEmptyComponent={ + + No bookmarked sessions found. + + } + scrollEnabled={false} + /> ); -}; - -export default home; +} const styles = StyleSheet.create({ container: { flex: 1, - paddingHorizontal: spacing.lg, + paddingTop: sizes.header, + paddingHorizontal: sizes.md, + paddingBottom: sizes.xxxl, width: '100%', - paddingBottom: spacing.xl, + }, + header: { + color: Colors.palette.secondary, + marginVertical: sizes.xl, + }, + sessions: { + // flex: 1, + gap: spacing.md, + }, + error: { + textAlign: 'center', }, }); diff --git a/app/(tabs)/home/index.tsx b/app/(tabs)/home/index.tsx index 98bcfb1..f54411e 100644 --- a/app/(tabs)/home/index.tsx +++ b/app/(tabs)/home/index.tsx @@ -1,27 +1,97 @@ -import { StyleSheet, View } from 'react-native'; +import { FlatList, StyleSheet, View } from 'react-native'; import MainContainer from '@/components/containers/MainContainer'; import StyledText from '@/components/common/StyledText'; -import { spacing } from '@/constants/Styles'; -// import Colors from '@/constants/Colors'; +import { sizes, spacing } from '@/constants/Styles'; +import Colors from '@/constants/Colors'; +import { useStore } from '@/state/store'; +import { useRouter } from 'expo-router'; +import SessionCard from '@/components/cards/SessionCard'; +import { getRoom, getSpeaker } from '@/utils/sessions'; +import ListHeaderButton from '@/components/headers/ListHeaderButton'; +import { format } from 'date-fns'; + +export default function Schedule() { + const router = useRouter(); + const allSessions = useStore((state) => state.allSessions); + const sessions = useStore((state) => state.allSessions.sessions); -const home = () => { return ( - + - - Schedule page - + { + return ( + + {}} + /> + {}} + /> + + ); + }} + renderItem={({ item }) => { + const speakers = item.speakers.map((speakerId) => getSpeaker(speakerId, allSessions)); + return ( + router.push(`/sessions/${item.id}`)} + /> + ); + }} + keyExtractor={(item) => item.id} + initialNumToRender={10} + maxToRenderPerBatch={10} + showsVerticalScrollIndicator={false} + ItemSeparatorComponent={() => } + ListEmptyComponent={ + + No sessions found. + + } + scrollEnabled={false} + /> ); -}; - -export default home; +} const styles = StyleSheet.create({ container: { flex: 1, - padding: spacing.lg, + paddingTop: sizes.header + 40, + paddingHorizontal: sizes.md, + paddingBottom: sizes.xxxl, width: '100%', }, + header: { + color: Colors.palette.secondary, + marginVertical: sizes.md, + }, + sectionHeader: { + marginBottom: sizes.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + error: { + textAlign: 'center', + }, }); diff --git a/app/(tabs)/home/speakers.tsx b/app/(tabs)/home/speakers.tsx index 7114e8a..d84de22 100644 --- a/app/(tabs)/home/speakers.tsx +++ b/app/(tabs)/home/speakers.tsx @@ -1,15 +1,15 @@ -import { FlatList, View, ActivityIndicator, StyleSheet } from 'react-native'; +import { FlatList, View, StyleSheet } from 'react-native'; import Colors from '@/constants/Colors'; import SpeakerCard from '@/components/cards/SpeakerCard'; -import { useFetchSpeakers } from '@/hooks/useFetchSpeakers'; import StyledText from '@/components/common/StyledText'; import MainContainer from '@/components/containers/MainContainer'; import { sizes } from '@/constants/Styles'; import { useRouter } from 'expo-router'; +import { useStore } from '@/state/store'; const Speakers = () => { const router = useRouter(); - const { speakerList, loading, error } = useFetchSpeakers(); + const speakers = useStore((state) => state.allSessions.speakers); return ( { Speakers - {loading ? ( - - ) : error ? ( - - Failed to load speakers. Please try again later. - - ) : ( - ( - router.push(`/speakers/${item.id}`)} /> - )} - keyExtractor={(item) => item.id} - initialNumToRender={10} - maxToRenderPerBatch={10} - showsVerticalScrollIndicator={false} - /> - )} + router.push(`/speakers/${item.id}`)} />} + keyExtractor={(item) => item.id} + initialNumToRender={10} + maxToRenderPerBatch={10} + showsVerticalScrollIndicator={false} + ItemSeparatorComponent={() => } + ListEmptyComponent={No speakers found. Please try again later.} + /> ); diff --git a/app/(tabs)/sessions/[id].tsx b/app/(tabs)/sessions/[id].tsx new file mode 100644 index 0000000..790fdb1 --- /dev/null +++ b/app/(tabs)/sessions/[id].tsx @@ -0,0 +1,201 @@ +import { Image } from 'expo-image'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { AntDesign, Ionicons } from '@expo/vector-icons'; +import { View, StyleSheet, Pressable, ImageBackground, useWindowDimensions } from 'react-native'; +import MainContainer from '@/components/containers/MainContainer'; +import Colors from '@/constants/Colors'; +import { sizes, spacing } from '@/constants/Styles'; +import StyledText from '@/components/common/StyledText'; +import { useStore } from '@/state/store'; +import { getSpeaker, getRoom } from '@/utils/sessions'; +import { format } from 'date-fns'; +import { useBookmarkStore } from '@/state/bookmarks'; + +const SessionPage = () => { + const { id } = useLocalSearchParams(); + const allSessions = useStore((state) => state.allSessions); + const sessions = useStore((state) => state.allSessions.sessions); + const session = sessions.find((s) => s.id === id); + + const bookmarks = useBookmarkStore((state) => state.bookmarks); + const toggleBookmarked = useBookmarkStore((state) => state.toggleBookmarked); + const isBookmarked = bookmarks.some((b) => b.sessionId === id); + + const { height } = useWindowDimensions(); + + const router = useRouter(); + + return ( + + ( + router.back()} /> + ), + headerRight: () => ( + [ + { + opacity: pressed ? 0.5 : 1, + }, + ]} + onPress={() => session?.id && toggleBookmarked(session.id)} + > + + + ), + }} + /> + + + + + {session?.title} + + + + + {session?.speakers.map((speakerId: string) => { + const _speaker = getSpeaker(speakerId, allSessions); + + return ( + [ + styles.speaker, + { backgroundColor: pressed ? Colors.palette.cardBg : 'transparent' }, + ]} + onPress={() => router.push(`/speakers/${_speaker.id}`)} + > + {_speaker.profilePicture ? ( + + ) : ( + + )} + + + {_speaker.fullName} + + {_speaker?.tagLine} + + + ); + })} + + + + + About + + + + + {session?.description} + + + + + + + Date + + + + + {session?.startsAt ? format(session.startsAt, 'EEEE, MMM d, yyyy') : 'TBA'} + + + + + + + Time + + + + {session?.startsAt ? format(session.startsAt, 'h:mm a') : 'TBA'} + + + + + + Venue + + + + {session?.roomId ? getRoom(session.roomId, allSessions).name : 'TBA'} + + + + + ); +}; +const styles = StyleSheet.create({ + container: { + flex: 1, + width: '100%', + }, + topImage: { + width: '100%', + marginBottom: spacing.lg, + padding: spacing.sm, + justifyContent: 'center', + alignItems: 'center', + borderBottomColor: Colors.palette.border, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + header: { + color: Colors.palette.secondary, + textAlign: 'center', + }, + wrapper: { + flex: 1, + width: '100%', + paddingHorizontal: spacing.lg, + }, + speaker: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.lg, + }, + image: { + width: 72, + height: 72, + borderRadius: spacing.sm, + marginRight: spacing.lg, + backgroundColor: Colors.palette.primary, + }, + textContainer: { + flex: 1, + gap: spacing.sm, + }, + content: { + gap: spacing.sm, + marginTop: spacing.lg, + }, + label: { + maxWidth: 80, + backgroundColor: Colors.palette.secondary, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.lg, + borderRadius: spacing.lg, + }, + description: { + lineHeight: sizes.lg, + }, +}); + +export default SessionPage; diff --git a/app/(tabs)/sessions/_layout.tsx b/app/(tabs)/sessions/_layout.tsx new file mode 100644 index 0000000..6e4046e --- /dev/null +++ b/app/(tabs)/sessions/_layout.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Stack } from 'expo-router'; +import Colors from '@/constants/Colors'; + +export default function SessionsLayout() { + return ( + + + + ); +} diff --git a/app/(tabs)/speakers/[id].tsx b/app/(tabs)/speakers/[id].tsx index aeed2d4..bd1cbac 100644 --- a/app/(tabs)/speakers/[id].tsx +++ b/app/(tabs)/speakers/[id].tsx @@ -1,40 +1,32 @@ import { Image } from 'expo-image'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { AntDesign, FontAwesome6 } from '@expo/vector-icons'; -import { useMemo } from 'react'; -import { View, StyleSheet, ActivityIndicator, Linking, Pressable } from 'react-native'; +import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import { AntDesign, FontAwesome6, Ionicons } from '@expo/vector-icons'; +import { useEffect } from 'react'; +import { View, StyleSheet, Linking, Pressable, Platform } from 'react-native'; import MainContainer from '@/components/containers/MainContainer'; import Colors from '@/constants/Colors'; import { sizes, spacing } from '@/constants/Styles'; import StyledText from '@/components/common/StyledText'; -import { useFetchSpeakers } from '@/hooks/useFetchSpeakers'; +import { useStore } from '@/state/store'; +import { getSession } from '@/utils/sessions'; +import { formatSessionDate } from '@/utils/formatDate'; +import { useBookmarkStore } from '@/state/bookmarks'; const SpeakerPage = () => { const { id } = useLocalSearchParams(); + const allSessions = useStore((state) => state.allSessions); + const speakers = useStore((state) => state.allSessions.speakers); + const speaker = speakers.find((s) => s.id === id); - const { speakerList, loading, error } = useFetchSpeakers(); + const bookmarks = useBookmarkStore((state) => state.bookmarks); - const router = useRouter(); - - const speaker = useMemo(() => speakerList.find((s) => s.id === id), [speakerList, id]); + const navigation = useNavigation(); - if (loading) { - return ( - - - - ); - } + useEffect(() => { + navigation.setOptions({ title: speaker?.fullName }); + }, [speaker, navigation]); - if (error) { - return ( - - - Speaker not found - - - ); - } + const router = useRouter(); const openURL = (url: string) => { Linking.openURL(url); @@ -58,79 +50,74 @@ const SpeakerPage = () => { preset="scroll" safeAreaEdges={['top']} > + ( + router.back()} /> + ), + }} + /> - - router.back()} - style={({ pressed }) => [ - styles.backBtn, - { backgroundColor: pressed ? Colors.palette.cardBg : 'transparent' }, - ]} - > - - - - - - - {speaker?.fullName} - - - - - + {speaker?.tagLine} - {speaker?.links.map((link, index) => ( - openURL(link.url)} style={styles.iconBtn}> - {renderLinkIcon(link.linkType)} - - ))} + {speaker?.links && + speaker?.links.map((link: { title: string; url: string; linkType: string }, index) => ( + openURL(link.url)} style={styles.iconBtn}> + {renderLinkIcon(link.linkType)} + + ))} {speaker?.bio} - {speaker?.sessions.map((session, index) => ( - - - {session?.name} + + Session(s) + + + {speaker?.sessions.map((session: number, index) => ( + router.push(`/sessions/${session}`)}> + + + {formatSessionDate('2024-10-05T09:00:00Z')} + + bookmark.sessionId === String(session)) ? 'bookmark' : 'bookmark-outline' + } + size={24} + color={Colors.palette.secondary} + /> + + + {getSession(session, allSessions).title} - + ))} ); }; - const styles = StyleSheet.create({ container: { flex: 1, - padding: spacing.lg, - }, - loader: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - error: { - color: Colors.palette.error, - textAlign: 'center', - }, - header: { + paddingTop: Platform.OS === 'android' ? sizes.header : sizes.md, + paddingHorizontal: sizes.md, + paddingBottom: sizes.xxxl, width: '100%', - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.lg, - }, - backBtn: { - paddingVertical: spacing.sm, - paddingHorizontal: spacing.lg, - borderRadius: spacing.sm, }, name: { flex: 1, @@ -171,13 +158,26 @@ const styles = StyleSheet.create({ marginVertical: 20, lineHeight: 22, }, + subtitle: { + color: Colors.palette.secondary, + marginVertical: sizes.md, + }, sessionCard: { + width: '100%', backgroundColor: Colors.palette.cardBg, padding: spacing.lg, borderRadius: sizes.sm, marginBottom: spacing.lg, borderWidth: StyleSheet.hairlineWidth, borderColor: Colors.palette.border, + flexDirection: 'column', + gap: spacing.lg, + }, + topRow: { + width: '100%', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', }, }); diff --git a/app/(tabs)/speakers/_layout.tsx b/app/(tabs)/speakers/_layout.tsx index 341f5b9..ba67b28 100644 --- a/app/(tabs)/speakers/_layout.tsx +++ b/app/(tabs)/speakers/_layout.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Stack } from 'expo-router'; +import Colors from '@/constants/Colors'; export default function TabLayout() { return ( @@ -7,7 +8,12 @@ export default function TabLayout() { diff --git a/app/_layout.tsx b/app/_layout.tsx index e68d88b..f048fef 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,6 +4,8 @@ import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { useEffect } from 'react'; import 'react-native-reanimated'; +import { differenceInMinutes } from 'date-fns'; +import { useStore } from '@/state/store'; export { // Catch any errors thrown by the Layout component. @@ -43,6 +45,18 @@ export default function RootLayout() { } function RootLayoutNav() { + const { refreshData, lastRefreshed } = useStore(); + + useEffect(() => { + const fetchData = async () => { + if (!lastRefreshed || differenceInMinutes(new Date(), new Date(lastRefreshed)) > 5) { + await refreshData(); + } + }; + + fetchData(); + }, [lastRefreshed, refreshData]); + return ( diff --git a/components/cards/SessionCard.tsx b/components/cards/SessionCard.tsx new file mode 100644 index 0000000..e282065 --- /dev/null +++ b/components/cards/SessionCard.tsx @@ -0,0 +1,116 @@ +import { View, StyleSheet, Pressable } from 'react-native'; +import { Speaker, Session } from '@/constants/types'; +import Colors from '@/constants/Colors'; +import { sizes, spacing } from '@/constants/Styles'; +import StyledText from '../common/StyledText'; +import { Image } from 'expo-image'; +import { format } from 'date-fns'; +import { Ionicons } from '@expo/vector-icons'; + +interface SessionCardProps { + session: Session; + speakers: Speaker[]; + room: string; + onPress?: () => void; +} + +const SessionCard: React.FC = ({ session, speakers, room, onPress }) => { + return ( + [ + styles.card, + { backgroundColor: pressed ? Colors.palette.primary : Colors.palette.cardBg }, + ]} + onPress={onPress} + > + + {speakers.map((_speaker) => ( + + {_speaker.profilePicture ? ( + + ) : ( + + )} + + + {_speaker.fullName} + + {_speaker?.tagLine} + + + ))} + + + + + + + + {room} + + {session.title} + + {session?.startsAt ? format(session.startsAt, 'h:mm a') : 'TBA'} -{' '} + {session?.endsAt ? format(session.endsAt, 'h:mm a') : 'TBA'} + + + + ); +}; + +const styles = StyleSheet.create({ + card: { + borderColor: Colors.palette.border, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: sizes.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.lg, + }, + column: { + flexDirection: 'column', + }, + speaker: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.sm, + }, + image: { + width: 72, + height: 72, + borderRadius: spacing.sm, + marginRight: spacing.lg, + backgroundColor: Colors.palette.primary, + }, + divider: { + height: 1, + backgroundColor: Colors.palette.secondary, + marginVertical: spacing.xl, + }, + textContainer: { + flex: 1, + gap: spacing.xs, + }, + name: { + color: Colors.palette.secondary, + }, + room: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.sm, + }, + italic: { + fontStyle: 'italic', + color: Colors.palette.text, + }, + description: { + color: Colors.palette.text, + marginBottom: spacing.lg, + }, +}); + +export default SessionCard; diff --git a/components/cards/SpeakerCard.tsx b/components/cards/SpeakerCard.tsx index 4618db8..30ec11b 100644 --- a/components/cards/SpeakerCard.tsx +++ b/components/cards/SpeakerCard.tsx @@ -1,5 +1,5 @@ import { View, StyleSheet, Pressable } from 'react-native'; -import { Speaker } from '../types'; +import { Speaker } from '@/constants/types'; import Colors from '@/constants/Colors'; import { sizes, spacing, blurhash } from '@/constants/Styles'; import StyledText from '../common/StyledText'; @@ -52,7 +52,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', padding: spacing.sm, - marginBottom: spacing.lg, }, image: { width: 80, diff --git a/components/headers/ListHeaderButton.tsx b/components/headers/ListHeaderButton.tsx new file mode 100644 index 0000000..1d10eaa --- /dev/null +++ b/components/headers/ListHeaderButton.tsx @@ -0,0 +1,63 @@ +import { Pressable, StyleSheet } from 'react-native'; +import StyledText from '../common/StyledText'; +import Colors from '@/constants/Colors'; +import { Feather } from '@expo/vector-icons'; +import { spacing } from '@/constants/Styles'; + +const ListHeaderButton = ({ + onPress, + isBold, + title, + subtitle, +}: { + onPress: () => void; + isBold: boolean; + title: string; + subtitle: string | null; +}) => { + const opacity = { opacity: isBold ? 1 : 0.5 }; + const color = { color: isBold ? Colors.palette.secondary : Colors.palette.text }; + + return ( + + + + {title},{' '} + {subtitle ? ( + + {' '} + {subtitle}{' '} + + ) : null} + + + ); +}; + +export default ListHeaderButton; + +const styles = StyleSheet.create({ + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xl, + borderBottomWidth: 3, + paddingBottom: spacing.sm, + }, + text: { + letterSpacing: 1.5, + }, +}); diff --git a/components/types.ts b/components/types.ts deleted file mode 100644 index 3382587..0000000 --- a/components/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -// types.ts -interface Link { - title: string; - url: string; - linkType: string; -} - -export type Speaker = { - id: string; - fullName: string; - profilePicture?: string; - tagLine?: string; - bio?: string; - sessions: { name: string }[]; - isTopSpeaker: boolean; - links: Link[]; -}; diff --git a/constants/Colors.ts b/constants/Colors.ts index e9b7ba3..29d43e0 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -5,7 +5,8 @@ const black = '#141414'; const errorColor = '#ff3232'; const dark = '#000'; const cardBgColor = '#36333b40'; -const borderColor = '#fff9f9'; +const cardBorderColor = '#36333b'; +const borderColor = '#fff9f940'; const iconBgColor = '#eee71220'; export default { @@ -17,6 +18,7 @@ export default { dark: dark, error: errorColor, cardBg: cardBgColor, + cardBorder: cardBorderColor, border: borderColor, iconBg: iconBgColor, }, diff --git a/constants/types.ts b/constants/types.ts new file mode 100644 index 0000000..ccba38d --- /dev/null +++ b/constants/types.ts @@ -0,0 +1,36 @@ +import { allData } from '@/mock/all'; + +interface Link { + title: FunctionStringCallback; + url: FunctionStringCallback; + linkType: FunctionStringCallback; +} + +export type Speaker = { + id: string; + fullName: string; + firstName: string; + lastName: string; + profilePicture: string | null; + tagLine: string | null; + bio: string | null; + sessions: number[]; + isTopSpeaker: boolean; + links: Link[]; + categoryItems: number[]; +}; + +export type Session = { + id: string; + title: string; + description: string | null; + startsAt: string; + endsAt: string; + speakers: Speaker[]; + room: string; + isServiceSession: boolean; +}; + +export type ApiData = typeof allData; + +export type ApiSpeaker = (typeof allData)['speakers'][number]; diff --git a/hooks/useFetchSpeakers.tsx b/hooks/useFetchSpeakers.tsx deleted file mode 100644 index 4926a2e..0000000 --- a/hooks/useFetchSpeakers.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Speaker } from '@/components/types'; - -export const useFetchSpeakers = () => { - const [speakerList, setSpeakerList] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - - useEffect(() => { - const fetchSpeakers = async () => { - setLoading(true); - setError(false); - try { - const res = await fetch('https://sessionize.com/api/v2/d899srzm/view/Speakers'); - if (!res.ok) throw new Error('Failed to fetch speakers'); - const data = await res.json(); - setSpeakerList(data); - } catch (err) { - setError(true); - console.error(err); - } finally { - setLoading(false); - } - }; - fetchSpeakers(); - }, []); - - return { speakerList, loading, error }; -}; diff --git a/hooks/useStoredState.ts b/hooks/useStoredState.ts deleted file mode 100644 index 6c9e110..0000000 --- a/hooks/useStoredState.ts +++ /dev/null @@ -1,24 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { type SetStateAction, useState } from 'react'; -import useAsyncEffect from 'use-async-effect'; - -export const useStoredState = (key: string, initialValue: Type) => { - const [value, _setValue] = useState(initialValue); - - const setValue = (valueOrCallback: SetStateAction) => { - const newValue = typeof valueOrCallback === 'function' ? valueOrCallback(value) : valueOrCallback; - _setValue(newValue); - - AsyncStorage.setItem(key, JSON.stringify(newValue)); - }; - - useAsyncEffect(async () => { - const _stored = await AsyncStorage.getItem(key); - if (_stored) { - const _val = JSON.parse(_stored); - setValue(_val); - } - }, []); - - return [value, setValue] as const; -}; diff --git a/mock/all.ts b/mock/all.ts new file mode 100644 index 0000000..75fd7e8 --- /dev/null +++ b/mock/all.ts @@ -0,0 +1,81 @@ +// sample sessionize data - all + +export const allData = { + sessions: [ + { + id: '2e115a1f-803c-4c38-a0c9-a0c809457d51', + title: 'Registration', + description: null, + startsAt: '2024-05-15T01:00:00Z', + endsAt: '2024-05-15T04:00:00Z', + isServiceSession: true, + isPlenumSession: false, + speakers: [], + categoryItems: [], + questionAnswers: [], + roomId: 45058, + liveUrl: null, + recordingUrl: null, + status: null, + isInformed: false, + isConfirmed: false, + }, + { + id: 'be87952a-fb0d-409a-a953-dde2e0364e22', + title: 'Registration', + description: null, + startsAt: '2024-05-15T14:00:00Z', + endsAt: '2024-05-15T16:00:00Z', + isServiceSession: true, + isPlenumSession: false, + speakers: [], + categoryItems: [], + questionAnswers: [], + roomId: 45058, + liveUrl: null, + recordingUrl: null, + status: null, + isInformed: false, + isConfirmed: false, + }, + ], + speakers: [ + { + id: '58970fb3-d2e7-419c-944d-14d7d6335c92', + firstName: 'An Amazing', + lastName: 'Speaker', + bio: 'Hold tight till we reveal the detail of this speaker', + tagLine: 'Speaker @ ReactConf', + profilePicture: null, + isTopSpeaker: false, + links: [], + sessions: [665489], + fullName: 'An Amazing Speaker', + categoryItems: [], + questionAnswers: [], + }, + { + id: 'fd5447af-f5ce-47f0-8b2f-ee402233cf9f', + firstName: 'An Amazing', + lastName: 'Speaker', + bio: 'Hold tight till we reveal the detail of this speaker', + tagLine: 'Speaker @ ReactConf', + profilePicture: null, + isTopSpeaker: false, + links: [], + sessions: [665488], + fullName: 'An Amazing Speaker', + categoryItems: [], + questionAnswers: [], + }, + ], + questions: [], + categories: [], + rooms: [ + { + id: 1, + name: 'room 1', + sort: 0, + }, + ], +}; diff --git a/mock/allSessions.json b/mock/allSessions.json new file mode 100644 index 0000000..2a74240 --- /dev/null +++ b/mock/allSessions.json @@ -0,0 +1,86 @@ +{ + "sessions": [ + { + "id": "2e115a1f-803c-4c38-a0c9-a0c809457d51", + "title": "Registration", + "description": null, + "startsAt": "2024-05-15T01:00:00Z", + "endsAt": "2024-05-15T04:00:00Z", + "isServiceSession": true, + "isPlenumSession": false, + "speakers": [], + "categoryItems": [], + "questionAnswers": [], + "roomId": 45058, + "liveUrl": null, + "recordingUrl": null, + "status": null, + "isInformed": false, + "isConfirmed": false + }, + { + "id": "be87952a-fb0d-409a-a953-dde2e0364e22", + "title": "Registration", + "description": null, + "startsAt": "2024-05-15T14:00:00Z", + "endsAt": "2024-05-15T16:00:00Z", + "isServiceSession": true, + "isPlenumSession": false, + "speakers": [], + "categoryItems": [], + "questionAnswers": [], + "roomId": 45058, + "liveUrl": null, + "recordingUrl": null, + "status": null, + "isInformed": false, + "isConfirmed": false + } + ], + "speakers": [ + { + "id": "58970fb3-d2e7-419c-944d-14d7d6335c92", + "firstName": "An Amazing", + "lastName": "Speaker", + "bio": "Hold tight till we reveal the detail of this speaker", + "tagLine": "Speaker @ ReactConf", + "profilePicture": null, + "isTopSpeaker": false, + "links": [], + "sessions": [665489], + "fullName": "An Amazing Speaker", + "categoryItems": [], + "questionAnswers": [] + }, + { + "id": "fd5447af-f5ce-47f0-8b2f-ee402233cf9f", + "firstName": "An Amazing", + "lastName": "Speaker", + "bio": "Hold tight till we reveal the detail of this speaker", + "tagLine": "Speaker @ ReactConf", + "profilePicture": null, + "isTopSpeaker": false, + "links": [], + "sessions": [665488], + "fullName": "An Amazing Speaker", + "categoryItems": [], + "questionAnswers": [] + } + ], + "questions": [ + { + "id": 69203, + "question": "Github", + "questionType": "Short_Text", + "sort": 16 + } + ], + "categories": [], + "rooms": [ + { + "id": 1, + "name": "Room 1", + "sort": 0 + } + ] +} diff --git a/package-lock.json b/package-lock.json index 6774d9b..348255e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/native": "^6.0.2", + "date-fns": "^4.1.0", "expo": "~51.0.28", "expo-font": "~12.0.9", "expo-image": "~1.12.15", @@ -28,7 +29,8 @@ "react-native-screens": "3.31.1", "react-native-svg": "15.2.0", "react-native-web": "~0.19.10", - "use-async-effect": "^2.2.7" + "use-async-effect": "^2.2.7", + "zustand": "^5.0.0-rc.2" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -9238,6 +9240,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -20638,6 +20649,34 @@ "peerDependencies": { "zod": "^3.18.0" } + }, + "node_modules/zustand": { + "version": "5.0.0-rc.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0-rc.2.tgz", + "integrity": "sha512-o2Nwuvnk8vQBX7CcHL8WfFkZNJdxB/VKeWw0tNglw8p4cypsZ3tRT7rTRTDNeUPFS0qaMBRSKe+fVwL5xpcE3A==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 513112e..072aef6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/native": "^6.0.2", + "date-fns": "^4.1.0", "expo": "~51.0.28", "expo-font": "~12.0.9", "expo-image": "~1.12.15", @@ -39,7 +40,8 @@ "react-native-screens": "3.31.1", "react-native-svg": "15.2.0", "react-native-web": "~0.19.10", - "use-async-effect": "^2.2.7" + "use-async-effect": "^2.2.7", + "zustand": "^5.0.0-rc.2" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/state/bookmarks.ts b/state/bookmarks.ts new file mode 100644 index 0000000..2278f96 --- /dev/null +++ b/state/bookmarks.ts @@ -0,0 +1,44 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; + +type BookmarkState = { + bookmarks: { sessionId: string }[]; + toggleBookmarked: (sessionId: string) => void; + resetBookmarks: () => void; +}; + +export const useBookmarkStore = create( + persist( + (set) => ({ + bookmarks: [], + toggleBookmarked: (sessionId: string) => { + set((state) => { + const isBookmarked = state.bookmarks.find((b) => b.sessionId === sessionId); + + if (isBookmarked) { + // Remove the bookmark if already present + const newBookmarks = state.bookmarks.filter((b) => b.sessionId !== sessionId); + return { + bookmarks: newBookmarks, + }; + } else { + // Add the session ID to the bookmarks + return { + bookmarks: [...state.bookmarks, { sessionId }], + }; + } + }); + }, + resetBookmarks: () => { + set(() => ({ + bookmarks: [], + })); + }, + }), + { + name: 'renderconke-24-bookmarks', + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); diff --git a/state/store.ts b/state/store.ts new file mode 100644 index 0000000..d7a189f --- /dev/null +++ b/state/store.ts @@ -0,0 +1,101 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; + +import { ApiData, Session } from '@/constants/types'; +import { formatSessions } from '@/utils/sessions'; +import initialAllSessions from '@/mock/allSessions.json'; + +const doFetch = async (url: string) => { + try { + const result = await fetch(url); + return await result.json(); + } catch { + return null; + } +}; + +type State = { + schedule: { + dayOne: Session[]; + dayTwo: Session[]; + }; + allSessions: ApiData; + isRefreshing?: boolean; + lastRefreshed: string | null; + refreshData: (options?: { ttlMs?: number }) => Promise; + shouldUseLocalTz: boolean; + toggleLocalTz: () => void; +}; + +const getInitialSchedule = () => { + const [dayOne, dayTwo] = formatSessions(initialAllSessions as ApiData); + return { + schedule: { + dayOne, + dayTwo, + }, + allSessions: initialAllSessions as ApiData, + }; +}; + +export const useStore = create( + persist( + (set, get) => ({ + ...getInitialSchedule(), + isRefreshing: false, + lastRefreshed: null, + shouldUseLocalTz: false, + refreshData: async (options) => { + const ttlMs = options?.ttlMs; + const { isRefreshing, lastRefreshed } = get(); + + // Bail out if already refreshing + if (isRefreshing) { + return; + } + + // Bail out if last refresh was within TTL + if (lastRefreshed) { + const diff = new Date().getTime() - new Date(lastRefreshed).getTime(); + if (ttlMs && diff < ttlMs) { + return; + } + } + + try { + set({ isRefreshing: true }); + + const allSessions = await doFetch('https://sessionize.com/api/v2/d899srzm/view/All'); + + if (allSessions) { + const [dayOne, dayTwo] = formatSessions(allSessions); + set({ + schedule: { + dayOne, + dayTwo, + }, + allSessions, + lastRefreshed: new Date().toISOString(), + }); + } + } catch (e) { + console.warn(e); + } finally { + set({ isRefreshing: false }); + } + }, + toggleLocalTz: () => { + set((state) => ({ shouldUseLocalTz: !state.shouldUseLocalTz })); + }, + }), + { + name: 'renderconke-24', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => { + const { isRefreshing: _, ...dataToPersist } = state; + return dataToPersist; + }, + }, + ), +); diff --git a/utils/formatDate.ts b/utils/formatDate.ts new file mode 100644 index 0000000..1ce3ee3 --- /dev/null +++ b/utils/formatDate.ts @@ -0,0 +1,77 @@ +import { differenceInDays, format, formatDate } from 'date-fns'; + +import { Session } from '@/constants/types'; + +const timeFormat = 'h:mm aaa'; +const dateTimeFormat = `${timeFormat}, LLL d`; +const fullDateFormat = `${timeFormat}, LLL d, yyyy`; + +export const formatSessionTime = (session: Session) => { + try { + const startsAtDate = new Date(session.startsAt); + const endsAtDate = new Date(session.endsAt); + + return `${formatDate(startsAtDate, dateTimeFormat)} - ${formatDate(endsAtDate, dateTimeFormat)}`; + } catch { + return '...'; + } +}; + +export const formatFullDate = (dateString: string) => { + try { + return formatDate(new Date(dateString), fullDateFormat); + } catch { + return '...'; + } +}; + +export const getCurrentTimezone = () => { + try { + return formatDate(new Date(), 'zzzz'); + } catch { + return '...'; + } +}; + +export const isDayOneSession = (date: string) => { + const targetDate = new Date('2024-10-04'); + const session = new Date(date); + + try { + return ( + session.getUTCFullYear() === targetDate.getUTCFullYear() && + session.getUTCMonth() === targetDate.getUTCMonth() && + session.getUTCDate() === targetDate.getUTCDate() + ); + } catch { + return false; + } +}; + +export const isDayTwoSession = (date: string) => { + const targetDate = new Date('2024-10-05'); + const session = new Date(date); + + try { + return ( + session.getUTCFullYear() === targetDate.getUTCFullYear() && + session.getUTCMonth() === targetDate.getUTCMonth() && + session.getUTCDate() === targetDate.getUTCDate() + ); + } catch { + return false; + } +}; + +export const formatSessionDate = (sessionDate: string, referenceDate = '2024-10-04T00:00:00Z') => { + const session = new Date(sessionDate); + const reference = new Date(referenceDate); + + // Format the time and date + const formattedDate = format(session, 'h:mm a, MMMM do'); + + // Calculate the difference in days to show "Day 1", "Day 2", etc. + const dayDifference = differenceInDays(session, reference) + 1; + + return `${formattedDate} (Day ${dayDifference})`; +}; diff --git a/utils/sessions.ts b/utils/sessions.ts new file mode 100644 index 0000000..d5e1bd2 --- /dev/null +++ b/utils/sessions.ts @@ -0,0 +1,56 @@ +import { ApiData, Session, Speaker } from '@/constants/types'; +import { isDayOneSession, isDayTwoSession } from './formatDate'; +import { allData } from '@/mock/all'; + +export const formatSessions = (talks: ApiData): Session[][] => { + const allSessions = talks.sessions.map((talk) => ({ + id: talk.id, + title: talk.title, + description: talk.description, + startsAt: talk.startsAt, + endsAt: talk.endsAt, + isServiceSession: talk.isServiceSession, + speakers: (talk.speakers?.map((speakerId) => talks.speakers.find((sp) => sp.id === speakerId)).filter(Boolean) || + []) as unknown as Speaker[], + room: talks.rooms.find((room) => room.id === talk.roomId)?.name || '...', + })); + + const dayOne = allSessions.filter((session: Session) => isDayOneSession(session.startsAt)); + + const dayTwo = allSessions.filter((session: Session) => isDayTwoSession(session.startsAt)); + + return [dayOne, dayTwo]; +}; + +export const formatSession = (talk: ApiData['sessions'][number], talks: typeof allData): Session => { + return { + id: talk.id, + title: talk.title, + description: talk.description, + startsAt: talk.startsAt, + endsAt: talk.endsAt, + isServiceSession: talk.isServiceSession, + speakers: (talk.speakers + // @ts-ignore + ?.map((speakerId) => talks.speakers.find((sp) => sp.id === speakerId)) + .filter(Boolean) || []) as unknown as Speaker[], + // @ts-ignore + room: talks.rooms.find((room) => room.id === talk.roomId)?.name || '...', + }; +}; + +// getSession from the list of sessions by passing the id of the session +export const getSession = (id: number, talks: ApiData): Session => { + const _id = String(id); + return formatSession(talks.sessions.find((s) => s.id === _id) as ApiData['sessions'][number], talks); +}; + +// getSpeaker from the list of speakers by passing the id of the speaker +export const getSpeaker = (id: string, talks: ApiData): Speaker => { + return talks.speakers.find((s) => s.id === id) as ApiData['speakers'][number]; +}; + +// getRoom from the list of rooms by passing the id of the room +export const getRoom = (id: number, talks: ApiData): ApiData['rooms'][number] => { + return talks.rooms.find((r) => r.id === id) as ApiData['rooms'][number]; +};