├── .gitignore ├── DUMMY.env ├── README.md ├── app.json ├── app ├── (modals) │ ├── booking.tsx │ └── login.tsx ├── (tabs) │ ├── _layout.tsx │ ├── inbox.tsx │ ├── index.tsx │ ├── profile.tsx │ ├── trips.tsx │ └── whishlists.tsx ├── _layout.tsx └── listing │ └── [id].tsx ├── assets ├── data │ ├── airbnb-listings.geo.json │ ├── airbnb-listings.json │ ├── places.ts │ ├── world-0.png │ ├── world-1.png │ ├── world-2.png │ ├── world-3.png │ ├── world-4.png │ └── world-5.png ├── fonts │ ├── Montserrat-Bold.ttf │ ├── Montserrat-Regular.ttf │ └── Montserrat-SemiBold.ttf └── images │ ├── adaptive-icon.png │ ├── favicon.png │ ├── google.svg │ ├── icon.png │ └── splash.png ├── babel.config.js ├── banner.png ├── components ├── ExploreHeader.tsx ├── Listings.tsx ├── ListingsBottomSheet.tsx ├── ListingsMap.tsx └── ModalHeaderText.tsx ├── constants ├── Colors.ts └── Styles.ts ├── expo-env.d.ts ├── hooks └── useWarmUpBrowser.ts ├── metro.config.js ├── package-lock.json ├── package.json ├── screenshots ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png └── demo.gif └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | 11 | # Native 12 | *.orig.* 13 | *.jks 14 | *.p8 15 | *.p12 16 | *.key 17 | *.mobileprovision 18 | 19 | # Metro 20 | .metro-health-check* 21 | 22 | # debug 23 | npm-debug.* 24 | yarn-debug.* 25 | yarn-error.* 26 | 27 | # macOS 28 | .DS_Store 29 | *.pem 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 38 | # The following patterns were generated by expo-cli 39 | 40 | expo-env.d.ts 41 | # @end expo-cli 42 | 43 | .env -------------------------------------------------------------------------------- /DUMMY.env: -------------------------------------------------------------------------------- 1 | EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native AirBnB Clone with Clerk 2 | 3 | This is a React Native AirBnB clone using [Clerk](https://clerk.com/?utm_source=sponsorship&utm_medium=github&utm_campaign=simong&utm_content=rn-airbnb) for user authentication. 4 | 5 | Additional features: 6 | 7 | - [Expo Router](https://docs.expo.dev/routing/introduction/) file-based navigation 8 | - [Google](https://clerk.com/docs/authentication/social-connections/google?utm_source=sponsorship&utm_medium=github&utm_campaign=simong&utm_content=rn-airbnb) & [Apple](https://clerk.com/docs/authentication/social-connections/apple?utm_source=sponsorship&utm_medium=github&utm_campaign=simong&utm_content=rn-airbnb) Auth with Clerk 9 | - [Reanimated](https://reanimated-beta-docs.swmansion.com/) 3 for animations 10 | - [MapView](https://docs.expo.dev/versions/latest/sdk/map-view/) with Marker and [Clustering](https://github.com/venits/react-native-map-clustering) 11 | - [Bottom Sheet](https://gorhom.github.io/react-native-bottom-sheet/) 12 | - Modal with Animations and Blurred Background 13 | 14 | ## Screenshots 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | ## Demo 26 | 27 | ![Demo](./screenshots/demo.gif) 28 | 29 | ## 🚀 More 30 | 31 | **Take a shortcut from web developer to mobile development fluency with guided learning** 32 | 33 | Enjoyed this project? Learn to use React Native to build production-ready, native mobile apps for both iOS and Android based on your existing web development skills. 34 | 35 | 36 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "airbnb", 4 | "slug": "airbnb", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/images/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "assetBundlePatterns": ["**/*"], 16 | "ios": { 17 | "supportsTablet": true 18 | }, 19 | "android": { 20 | "adaptiveIcon": { 21 | "foregroundImage": "./assets/images/adaptive-icon.png", 22 | "backgroundColor": "#ffffff" 23 | } 24 | }, 25 | "web": { 26 | "bundler": "metro", 27 | "output": "static", 28 | "favicon": "./assets/images/favicon.png" 29 | }, 30 | "plugins": [ 31 | "expo-router", 32 | [ 33 | "expo-location", 34 | { 35 | "locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location." 36 | } 37 | ], 38 | [ 39 | "expo-image-picker", 40 | { 41 | "photosPermission": "The app accesses your photos to let you share them with your friends." 42 | } 43 | ] 44 | ], 45 | "experiments": { 46 | "typedRoutes": true, 47 | "tsconfigPaths": true 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/(modals)/booking.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text, StyleSheet, ScrollView, Image, SafeAreaView } from 'react-native'; 2 | import { useState } from 'react'; 3 | import Animated, { FadeIn, FadeOut, SlideInDown } from 'react-native-reanimated'; 4 | import { Ionicons } from '@expo/vector-icons'; 5 | import { BlurView } from 'expo-blur'; 6 | import { TextInput } from 'react-native-gesture-handler'; 7 | import { TouchableOpacity } from '@gorhom/bottom-sheet'; 8 | import { defaultStyles } from '@/constants/Styles'; 9 | import Colors from '@/constants/Colors'; 10 | import { places } from '@/assets/data/places'; 11 | import { useRouter } from 'expo-router'; 12 | // @ts-ignore 13 | import DatePicker from 'react-native-modern-datepicker'; 14 | 15 | const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity); 16 | 17 | const guestsGropus = [ 18 | { 19 | name: 'Adults', 20 | text: 'Ages 13 or above', 21 | count: 0, 22 | }, 23 | { 24 | name: 'Children', 25 | text: 'Ages 2-12', 26 | count: 0, 27 | }, 28 | { 29 | name: 'Infants', 30 | text: 'Under 2', 31 | count: 0, 32 | }, 33 | { 34 | name: 'Pets', 35 | text: 'Pets allowed', 36 | count: 0, 37 | }, 38 | ]; 39 | 40 | const Page = () => { 41 | const [openCard, setOpenCard] = useState(0); 42 | const [selectedPlace, setSelectedPlace] = useState(0); 43 | 44 | const [groups, setGroups] = useState(guestsGropus); 45 | const router = useRouter(); 46 | const today = new Date().toISOString().substring(0, 10); 47 | 48 | const onClearAll = () => { 49 | setSelectedPlace(0); 50 | setOpenCard(0); 51 | }; 52 | 53 | return ( 54 | 55 | {/* Where */} 56 | 57 | {openCard != 0 && ( 58 | setOpenCard(0)} 60 | style={styles.cardPreview} 61 | entering={FadeIn.duration(200)} 62 | exiting={FadeOut.duration(200)}> 63 | Where 64 | I'm flexible 65 | 66 | )} 67 | 68 | {openCard == 0 && Where to?} 69 | {openCard == 0 && ( 70 | 71 | 72 | 73 | 78 | 79 | 80 | 84 | {places.map((item, index) => ( 85 | setSelectedPlace(index)} key={index}> 86 | 90 | {item.title} 91 | 92 | ))} 93 | 94 | 95 | )} 96 | 97 | 98 | {/* When */} 99 | 100 | {openCard != 1 && ( 101 | setOpenCard(1)} 103 | style={styles.cardPreview} 104 | entering={FadeIn.duration(200)} 105 | exiting={FadeOut.duration(200)}> 106 | When 107 | Any week 108 | 109 | )} 110 | 111 | {openCard == 1 && When's your trip?} 112 | 113 | {openCard == 1 && ( 114 | 115 | 126 | 127 | )} 128 | 129 | 130 | {/* Guests */} 131 | 132 | {openCard != 2 && ( 133 | setOpenCard(2)} 135 | style={styles.cardPreview} 136 | entering={FadeIn.duration(200)} 137 | exiting={FadeOut.duration(200)}> 138 | Who 139 | Add guests 140 | 141 | )} 142 | 143 | {openCard == 2 && Who's coming?} 144 | 145 | {openCard == 2 && ( 146 | 147 | {groups.map((item, index) => ( 148 | 154 | 155 | {item.name} 156 | 157 | {item.text} 158 | 159 | 160 | 161 | 168 | { 170 | const newGroups = [...groups]; 171 | newGroups[index].count = 172 | newGroups[index].count > 0 ? newGroups[index].count - 1 : 0; 173 | 174 | setGroups(newGroups); 175 | }}> 176 | 0 ? Colors.grey : '#cdcdcd'} 180 | /> 181 | 182 | 189 | {item.count} 190 | 191 | { 193 | const newGroups = [...groups]; 194 | newGroups[index].count++; 195 | setGroups(newGroups); 196 | }}> 197 | 198 | 199 | 200 | 201 | ))} 202 | 203 | )} 204 | 205 | 206 | {/* Footer */} 207 | 208 | 210 | 213 | 219 | Clear all 220 | 221 | 222 | 223 | router.back()}> 226 | 232 | Search 233 | 234 | 235 | 236 | 237 | ); 238 | }; 239 | 240 | const styles = StyleSheet.create({ 241 | container: { 242 | flex: 1, 243 | paddingTop: 100, 244 | }, 245 | card: { 246 | backgroundColor: '#fff', 247 | borderRadius: 14, 248 | margin: 10, 249 | elevation: 4, 250 | shadowColor: '#000', 251 | shadowOpacity: 0.3, 252 | shadowRadius: 4, 253 | shadowOffset: { 254 | width: 2, 255 | height: 2, 256 | }, 257 | gap: 20, 258 | }, 259 | cardHeader: { 260 | fontFamily: 'mon-b', 261 | fontSize: 24, 262 | padding: 20, 263 | }, 264 | cardBody: { 265 | paddingHorizontal: 20, 266 | paddingBottom: 20, 267 | }, 268 | cardPreview: { 269 | flexDirection: 'row', 270 | justifyContent: 'space-between', 271 | padding: 20, 272 | }, 273 | 274 | searchSection: { 275 | height: 50, 276 | flexDirection: 'row', 277 | justifyContent: 'center', 278 | alignItems: 'center', 279 | backgroundColor: '#fff', 280 | borderWidth: 1, 281 | borderColor: '#ABABAB', 282 | borderRadius: 8, 283 | marginBottom: 16, 284 | }, 285 | searchIcon: { 286 | padding: 10, 287 | }, 288 | inputField: { 289 | flex: 1, 290 | padding: 10, 291 | backgroundColor: '#fff', 292 | }, 293 | placesContainer: { 294 | flexDirection: 'row', 295 | gap: 25, 296 | }, 297 | place: { 298 | width: 100, 299 | height: 100, 300 | borderRadius: 10, 301 | }, 302 | placeSelected: { 303 | borderColor: Colors.grey, 304 | borderWidth: 2, 305 | borderRadius: 10, 306 | width: 100, 307 | height: 100, 308 | }, 309 | previewText: { 310 | fontFamily: 'mon-sb', 311 | fontSize: 14, 312 | color: Colors.grey, 313 | }, 314 | previewdData: { 315 | fontFamily: 'mon-sb', 316 | fontSize: 14, 317 | color: Colors.dark, 318 | }, 319 | 320 | guestItem: { 321 | flexDirection: 'row', 322 | justifyContent: 'space-between', 323 | alignItems: 'center', 324 | paddingVertical: 16, 325 | }, 326 | itemBorder: { 327 | borderBottomWidth: StyleSheet.hairlineWidth, 328 | borderBottomColor: Colors.grey, 329 | }, 330 | }); 331 | export default Page; 332 | -------------------------------------------------------------------------------- /app/(modals)/login.tsx: -------------------------------------------------------------------------------- 1 | import Colors from '@/constants/Colors'; 2 | import { useOAuth } from '@clerk/clerk-expo'; 3 | import { Ionicons } from '@expo/vector-icons'; 4 | import { useRouter } from 'expo-router'; 5 | import { View, StyleSheet, TextInput, Text, TouchableOpacity } from 'react-native'; 6 | 7 | // https://github.com/clerkinc/clerk-expo-starter/blob/main/components/OAuth.tsx 8 | import { useWarmUpBrowser } from '@/hooks/useWarmUpBrowser'; 9 | import { defaultStyles } from '@/constants/Styles'; 10 | 11 | enum Strategy { 12 | Google = 'oauth_google', 13 | Apple = 'oauth_apple', 14 | Facebook = 'oauth_facebook', 15 | } 16 | const Page = () => { 17 | useWarmUpBrowser(); 18 | 19 | const router = useRouter(); 20 | const { startOAuthFlow: googleAuth } = useOAuth({ strategy: 'oauth_google' }); 21 | const { startOAuthFlow: appleAuth } = useOAuth({ strategy: 'oauth_apple' }); 22 | const { startOAuthFlow: facebookAuth } = useOAuth({ strategy: 'oauth_facebook' }); 23 | 24 | const onSelectAuth = async (strategy: Strategy) => { 25 | const selectedAuth = { 26 | [Strategy.Google]: googleAuth, 27 | [Strategy.Apple]: appleAuth, 28 | [Strategy.Facebook]: facebookAuth, 29 | }[strategy]; 30 | 31 | try { 32 | const { createdSessionId, setActive } = await selectedAuth(); 33 | 34 | if (createdSessionId) { 35 | setActive!({ session: createdSessionId }); 36 | router.back(); 37 | } 38 | } catch (err) { 39 | console.error('OAuth error', err); 40 | } 41 | }; 42 | 43 | return ( 44 | 45 | 50 | 51 | 52 | Continue 53 | 54 | 55 | 56 | 63 | or 64 | 71 | 72 | 73 | 74 | 75 | 76 | Continue with Phone 77 | 78 | 79 | onSelectAuth(Strategy.Apple)}> 80 | 81 | Continue with Apple 82 | 83 | 84 | onSelectAuth(Strategy.Google)}> 85 | 86 | Continue with Google 87 | 88 | 89 | onSelectAuth(Strategy.Facebook)}> 90 | 91 | Continue with Facebook 92 | 93 | 94 | 95 | ); 96 | }; 97 | 98 | export default Page; 99 | 100 | const styles = StyleSheet.create({ 101 | container: { 102 | flex: 1, 103 | backgroundColor: '#fff', 104 | padding: 26, 105 | }, 106 | 107 | seperatorView: { 108 | flexDirection: 'row', 109 | gap: 10, 110 | alignItems: 'center', 111 | marginVertical: 30, 112 | }, 113 | seperator: { 114 | fontFamily: 'mon-sb', 115 | color: Colors.grey, 116 | fontSize: 16, 117 | }, 118 | btnOutline: { 119 | backgroundColor: '#fff', 120 | borderWidth: 1, 121 | borderColor: Colors.grey, 122 | height: 50, 123 | borderRadius: 8, 124 | alignItems: 'center', 125 | justifyContent: 'center', 126 | flexDirection: 'row', 127 | paddingHorizontal: 10, 128 | }, 129 | btnOutlineText: { 130 | color: '#000', 131 | fontSize: 16, 132 | fontFamily: 'mon-sb', 133 | }, 134 | }); 135 | -------------------------------------------------------------------------------- /app/(tabs)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from 'expo-router'; 2 | import { Ionicons } from '@expo/vector-icons'; 3 | import { FontAwesome5 } from '@expo/vector-icons'; 4 | import { MaterialCommunityIcons } from '@expo/vector-icons'; 5 | import Colors from '@/constants/Colors'; 6 | 7 | const Layout = () => { 8 | return ( 9 | 16 | , 21 | }} 22 | /> 23 | ( 28 | 29 | ), 30 | }} 31 | /> 32 | , 37 | }} 38 | /> 39 | ( 44 | 45 | ), 46 | }} 47 | /> 48 | ( 55 | 56 | ), 57 | }} 58 | /> 59 | 60 | ); 61 | }; 62 | 63 | export default Layout; 64 | -------------------------------------------------------------------------------- /app/(tabs)/inbox.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text } from 'react-native'; 2 | import React from 'react'; 3 | 4 | const Page = () => { 5 | return ( 6 | 7 | Page 8 | 9 | ); 10 | }; 11 | 12 | export default Page; 13 | -------------------------------------------------------------------------------- /app/(tabs)/index.tsx: -------------------------------------------------------------------------------- 1 | import { View } from 'react-native'; 2 | import React, { useMemo, useState } from 'react'; 3 | import ListingsBottomSheet from '@/components/ListingsBottomSheet'; 4 | import listingsData from '@/assets/data/airbnb-listings.json'; 5 | import ListingsMap from '@/components/ListingsMap'; 6 | import listingsDataGeo from '@/assets/data/airbnb-listings.geo.json'; 7 | import { Stack } from 'expo-router'; 8 | import ExploreHeader from '@/components/ExploreHeader'; 9 | 10 | const Page = () => { 11 | const items = useMemo(() => listingsData as any, []); 12 | const getoItems = useMemo(() => listingsDataGeo, []); 13 | const [category, setCategory] = useState('Tiny homes'); 14 | 15 | const onDataChanged = (category: string) => { 16 | setCategory(category); 17 | }; 18 | 19 | return ( 20 | 21 | {/* Define pour custom header */} 22 | , 25 | }} 26 | /> 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default Page; 34 | -------------------------------------------------------------------------------- /app/(tabs)/profile.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | View, 3 | Text, 4 | Button, 5 | StyleSheet, 6 | SafeAreaView, 7 | Image, 8 | TouchableOpacity, 9 | TextInput, 10 | } from 'react-native'; 11 | import React, { useEffect, useState } from 'react'; 12 | import { useAuth, useUser } from '@clerk/clerk-expo'; 13 | import { defaultStyles } from '@/constants/Styles'; 14 | import { Ionicons } from '@expo/vector-icons'; 15 | import Colors from '@/constants/Colors'; 16 | import { Link } from 'expo-router'; 17 | import * as ImagePicker from 'expo-image-picker'; 18 | 19 | const Page = () => { 20 | const { signOut, isSignedIn } = useAuth(); 21 | const { user } = useUser(); 22 | const [firstName, setFirstName] = useState(user?.firstName); 23 | const [lastName, setLastName] = useState(user?.lastName); 24 | const [email, setEmail] = useState(user?.emailAddresses[0].emailAddress); 25 | const [edit, setEdit] = useState(false); 26 | 27 | // Load user data on mount 28 | useEffect(() => { 29 | if (!user) { 30 | return; 31 | } 32 | 33 | setFirstName(user.firstName); 34 | setLastName(user.lastName); 35 | setEmail(user.emailAddresses[0].emailAddress); 36 | }, [user]); 37 | 38 | // Update Clerk user data 39 | const onSaveUser = async () => { 40 | try { 41 | await user?.update({ 42 | firstName: firstName!, 43 | lastName: lastName!, 44 | }); 45 | } catch (error) { 46 | console.log(error); 47 | } finally { 48 | setEdit(false); 49 | } 50 | }; 51 | 52 | // Capture image from camera roll 53 | // Upload to Clerk as avatar 54 | const onCaptureImage = async () => { 55 | let result = await ImagePicker.launchImageLibraryAsync({ 56 | mediaTypes: ImagePicker.MediaTypeOptions.Images, 57 | allowsEditing: true, 58 | quality: 0.75, 59 | base64: true, 60 | }); 61 | 62 | if (!result.canceled) { 63 | const base64 = `data:image/png;base64,${result.assets[0].base64}`; 64 | user?.setProfileImage({ 65 | file: base64, 66 | }); 67 | } 68 | }; 69 | 70 | return ( 71 | 72 | 73 | Profile 74 | 75 | 76 | 77 | {user && ( 78 | 79 | 80 | 81 | 82 | 83 | {!edit && ( 84 | 85 | 86 | {firstName} {lastName} 87 | 88 | setEdit(true)}> 89 | 90 | 91 | 92 | )} 93 | {edit && ( 94 | 95 | 101 | 107 | 108 | 109 | 110 | 111 | )} 112 | 113 | {email} 114 | Since {user?.createdAt!.toLocaleDateString()} 115 | 116 | )} 117 | 118 | {isSignedIn &&