diff --git a/src/components/BackButton.tsx b/src/components/BackButton.tsx index eb8b307..04131c5 100644 --- a/src/components/BackButton.tsx +++ b/src/components/BackButton.tsx @@ -1,11 +1,8 @@ -import React from 'react'; +import HeaderButton from './HeaderButton'; import { useNavigation } from '@react-navigation/native'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; -import { Button, useTheme } from 'tamagui'; const BackButton = ({ ...props }) => { - const theme = useTheme(); const navigation = useNavigation(); const { onPress } = props; @@ -17,13 +14,7 @@ const BackButton = ({ ...props }) => { } }; - return ( - - ); + return ; }; export default BackButton; diff --git a/src/components/ExpandableSelect.tsx b/src/components/ExpandableSelect.tsx index 8f87e4b..350ddfd 100644 --- a/src/components/ExpandableSelect.tsx +++ b/src/components/ExpandableSelect.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { Animated, Easing } from 'react-native'; -import { XStack, YStack, Text, Button, Separator, useTheme } from 'tamagui'; +import { Spinner, XStack, YStack, Text, Button, Separator, useTheme } from 'tamagui'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; import { faChevronUp, faChevronDown } from '@fortawesome/free-solid-svg-icons'; import { isNone } from '../utils'; @@ -28,8 +28,8 @@ const ExpandableSelect = ({ value, optionValue, options = [], onSelect }) => { const dropdownAnimation = useRef(new Animated.Value(0)).current; // Animated values for the dropdown toggle - const toggleScale = useRef(new Animated.Value(0)).current; - const toggleOpacity = useRef(new Animated.Value(0)).current; + const toggleScale = useRef(new Animated.Value(selectedOption ? 1 : 0)).current; + const toggleOpacity = useRef(new Animated.Value(selectedOption ? 1 : 0)).current; useEffect(() => { // Initialize optionAnimations when options change @@ -260,7 +260,7 @@ const ExpandableSelect = ({ value, optionValue, options = [], onSelect }) => { // Ensure optionAnimations are ready before rendering if (optionAnimations.length !== options.length) { - return null; // or render a loading indicator + return ; // or render a loading indicator } // Render options or dropdown toggle based on state @@ -274,7 +274,6 @@ const ExpandableSelect = ({ value, optionValue, options = [], onSelect }) => { return ( - {/* Selected Option */} { onPressIn={onPressIn} onPressOut={onPressOut} size='$5' + flex={1} + width='100%' justifyContent='space-between' bg='white' borderWidth={1} @@ -315,7 +316,6 @@ const ExpandableSelect = ({ value, optionValue, options = [], onSelect }) => { - {/* Dropdown Options */} {shouldRenderDropdown && ( { + const theme = useTheme(); + + const handlePress = function () { + if (typeof onPress === 'function') { + onPress(); + } + }; + + return ( + + ); +}; + +export default HeaderButton; diff --git a/src/components/PlaceMapView.tsx b/src/components/PlaceMapView.tsx new file mode 100644 index 0000000..a72ed7a --- /dev/null +++ b/src/components/PlaceMapView.tsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import { TouchableOpacity, StyleSheet } from 'react-native'; +import MapView, { Marker } from 'react-native-maps'; +import { YStack, useTheme } from 'tamagui'; +import { restoreFleetbasePlace, getCoordinates } from '../utils/location'; + +const PlaceMapView = ({ place: _place, height = 200, onPress, mapViewProps = {}, ...props }) => { + const place = restoreFleetbasePlace(_place); + const [latitude, longitude] = getCoordinates(place); + const [mapRegion, setMapRegion] = useState({ + latitude, + longitude, + latitudeDelta: 0.0005, + longitudeDelta: 0.0005, + }); + + return ( + + + setMapRegion(region)} + scrollEnabled={false} + zoomEnabled={false} + pitchEnabled={false} + rotateEnabled={false} + {...mapViewProps} + > + + + + + ); +}; + +export default PlaceMapView; diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index 56b8432..6d98da2 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -85,6 +85,7 @@ const ProductCard = ({ sliderWidth={cardWidth} sliderHeight={sliderHeight} sliderStyle={{ borderTopRightRadius: 10, borderTopLeftRadius: 10 }} + onImagePress={handlePress} autoplay /> @@ -106,7 +107,7 @@ const ProductCard = ({ {product.getAttribute('on_sale') ? ( - + {formatCurrency(product.getAttribute('sale_price'), product.getAttribute('currency'))} @@ -114,7 +115,7 @@ const ProductCard = ({ ) : ( - + {formatCurrency(product.getAttribute('price'), product.getAttribute('currency'))} )} @@ -133,11 +134,11 @@ const ProductCard = ({ color='white' width='100%' hoverStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} pressStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} > diff --git a/src/components/QuantityButton.tsx b/src/components/QuantityButton.tsx index 6ee2bcf..95ccc98 100644 --- a/src/components/QuantityButton.tsx +++ b/src/components/QuantityButton.tsx @@ -65,11 +65,11 @@ const QuantityButton = ({ disabled={currentQuantity <= min || disabled} theme={currentQuantity > min ? 'primary' : 'gray'} hoverStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} pressStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} {...incrementButtonProps} @@ -88,11 +88,11 @@ const QuantityButton = ({ disabled={currentQuantity >= max || disabled} theme={currentQuantity < max ? 'primary' : 'gray'} hoverStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} pressStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} {...decrementButtonProps} diff --git a/src/hooks/use-current-location.ts b/src/hooks/use-current-location.ts index 6f84ea5..f7497c9 100644 --- a/src/hooks/use-current-location.ts +++ b/src/hooks/use-current-location.ts @@ -78,6 +78,20 @@ const useCurrentLocation = () => { } }; + // Update current location/default location as a promise + const updateDefaultLocationPromise = (instance) => { + return new Promise(async (resolve) => { + await updateDefaultLocation(instance); + resolve(instance); + }); + }; + + // Update current location/default location as a promise + const updateDefaultLocation = (instance) => { + updateCurrentLocation(instance); + setCustomerDefaultLocation(instance); + }; + // Set initial current location useEffect(() => { initializeLiveLocation(); @@ -93,6 +107,8 @@ const useCurrentLocation = () => { liveLocation: restoreFleetbasePlace(liveLocation), updateCurrentLocation, setCurrentLocation, + updateDefaultLocationPromise, + updateDefaultLocation, getCurrentLocationCoordinates, setCustomerDefaultLocation, initializeCurrentLocation, diff --git a/src/hooks/use-saved-locations.ts b/src/hooks/use-saved-locations.ts index 32aea10..b6a75f9 100644 --- a/src/hooks/use-saved-locations.ts +++ b/src/hooks/use-saved-locations.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { restoreFleetbasePlace } from '../utils/location'; -import { isResource } from '../utils'; +import { isResource, isEmpty } from '../utils'; import { useAuth } from '../contexts/AuthContext'; import useStorage from './use-storage'; import useFleetbase from './use-fleetbase'; @@ -38,11 +38,44 @@ const useSavedLocations = () => { } }; + // Handle location update + const updateLocation = async (attributes = {}) => { + const place = restoreFleetbasePlace(attributes); + + try { + const updatedPlace = await place.save(); + setCustomerLocations((prevLocations) => prevLocations.map((location) => (location.id === place.id ? place.serialize() : location))); + return updatedPlace; + } catch (err) { + setError(err); + } + }; + + // Handle delete location by ID + const deleteLocationById = async (placeId) => { + try { + const place = restoreFleetbasePlace({ id: placeId }); + const deletedPlace = await place.destroy(); + setCustomerLocations((prevLocations) => prevLocations.filter((location) => location.id !== deletedPlace.id)); + } catch (err) { + setError(err); + } + }; + + // Handle delete location + const deleteLocation = async (place) => { + try { + const deletedPlace = await place.destroy(); + setCustomerLocations((prevLocations) => prevLocations.filter((location) => location.id !== deletedPlace.id)); + } catch (err) { + setError(err); + } + }; + // Add location const addLocation = async (attributes = {}) => { - console.log('[addLocation isAuthenticated]', isAuthenticated); if (isAuthenticated) { - return addCustomerLocation(attributes); + return isEmpty(attributes.id) ? addCustomerLocation(attributes) : updateLocation(attributes); } return addLocalLocationPromise(attributes); @@ -86,6 +119,9 @@ const useSavedLocations = () => { addCustomerLocation, addLocalLocation, addLocalLocationPromise, + updateLocation, + deleteLocation, + deleteLocationById, isLoadingSavedLocations: loading, savedLocationsError: error, }; diff --git a/src/navigation/stacks/LocationStack.tsx b/src/navigation/stacks/LocationStack.tsx index c0c2761..cdbbc69 100644 --- a/src/navigation/stacks/LocationStack.tsx +++ b/src/navigation/stacks/LocationStack.tsx @@ -5,6 +5,8 @@ import AddNewLocationScreen from '../../screens/AddNewLocationScreen'; import EditLocationScreen from '../../screens/EditLocationScreen'; import AddressBookScreen from '../../screens/AddressBookScreen'; import BackButton from '../../components/BackButton'; +import HeaderButton from '../../components/HeaderButton'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; import { getTheme } from '../../utils'; export const LocationPermission = { @@ -28,6 +30,7 @@ export const AddressBook = { title: 'Address Book', headerTransparent: true, headerLeft: () => navigation.goBack()} size={40} />, + headerRight: () => navigation.navigate('AddNewLocation', { redirectTo: 'AddressBook' })} size={40} />, headerBlurEffect: 'light', }; }, diff --git a/src/screens/AddNewLocationScreen.tsx b/src/screens/AddNewLocationScreen.tsx index 8cb47b8..43c8700 100644 --- a/src/screens/AddNewLocationScreen.tsx +++ b/src/screens/AddNewLocationScreen.tsx @@ -10,9 +10,10 @@ import useStorage from '../hooks/use-storage'; import useCurrentLocation from '../hooks/use-current-location'; import BackButton from '../components/BackButton'; -const AddNewLocationScreen = () => { +const AddNewLocationScreen = ({ route = { params: {} } }) => { const navigation = useNavigation(); const theme = useTheme(); + const { params } = route; const { liveLocation: currentLocation, getCurrentLocationCoordinates } = useCurrentLocation(); const [inputFocused, setInputFocused] = useState(false); const [inputValue, setInputValue] = useState(''); @@ -32,7 +33,7 @@ const AddNewLocationScreen = () => { try { const details = await getPlaceDetails(location.place_id); const place = createFleetbasePlaceFromDetails(details); - navigation.navigate('EditLocation', { place: place.serialize() }); + navigation.navigate('EditLocation', { place: place.serialize(), redirectTo: params.redirectTo }); } catch (error) { toast.error(error.message); } @@ -40,12 +41,17 @@ const AddNewLocationScreen = () => { const handleUseCurrentLocation = async () => { try { - navigation.navigate('EditLocation', { place: currentLocation.serialize() }); + navigation.navigate('EditLocation', { place: currentLocation.serialize(), redirectTo: params.redirectTo }); } catch (error) { toast.error(error.message); } }; + const handleUseMapLocation = () => { + console.log('[handleUseMapLocation triggered!]'); + navigation.navigate('LocationPicker'); + }; + const searchPlaces = useCallback(async () => { if (inputValue.trim() === '') { setSearchResults([]); @@ -195,7 +201,7 @@ const AddNewLocationScreen = () => { ))} - navigation.navigate('LocationPicker')}> + diff --git a/src/screens/AddressBookScreen.tsx b/src/screens/AddressBookScreen.tsx index 99a3dd1..7ca1590 100644 --- a/src/screens/AddressBookScreen.tsx +++ b/src/screens/AddressBookScreen.tsx @@ -1,49 +1,129 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView, FlatList, Pressable } from 'react-native'; -import { Avatar, Text, YStack, XStack, Separator, useTheme } from 'tamagui'; +import { Animated, SafeAreaView, TouchableOpacity, FlatList, Pressable, LayoutAnimation, UIManager, Platform } from 'react-native'; +import { Spinner, Avatar, Text, YStack, XStack, Separator, useTheme } from 'tamagui'; import { toast, ToastPosition } from '@backpackapp-io/react-native-toast'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faChevronRight } from '@fortawesome/free-solid-svg-icons'; +import { faChevronRight, faPencilAlt, faTrash, faStar } from '@fortawesome/free-solid-svg-icons'; import { formattedAddressFromPlace } from '../utils/location'; +import Swipeable from 'react-native-gesture-handler/Swipeable'; import useCurrentLocation from '../hooks/use-current-location'; import useSavedLocations from '../hooks/use-saved-locations'; +import usePromiseWithLoading from '../hooks/use-promise-with-loading'; + +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} const AddressBookScreen = () => { const theme = useTheme(); const navigation = useNavigation(); - const { currentLocation, setCustomerDefaultLocation } = useCurrentLocation(); - const { savedLocations } = useSavedLocations(); - - const renderSavedLocation = ({ item }) => ( - navigation.navigate('EditLocation', { place: item.serialize() })} - style={({ pressed }) => ({ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - })} - > - - - {item.getAttribute('name')} - - {formattedAddressFromPlace(item)} - - + const { runWithLoading, isLoading } = usePromiseWithLoading(); + const { currentLocation, updateDefaultLocationPromise } = useCurrentLocation(); + const { savedLocations, deleteLocation } = useSavedLocations(); + const rowRefs = useRef({}); + + const handleEdit = (place) => { + navigation.navigate('EditLocation', { place: place.serialize(), redirectTo: 'AddressBook' }); + }; + + const handleDelete = async (place) => { + // In case deleting place is current location get the next place and make it the default location using `handleMakeDefaultLocation` + const isCurrentLocation = currentLocation?.id === place.id; + const nextPlace = savedLocations.find((loc) => loc.id !== place.id); + const placeName = place.getAttribute('name'); + + try { + await runWithLoading(deleteLocation(place), 'deleting'); + + // If the deleted place was the current location and there’s another saved location, make it the default + if (isCurrentLocation && nextPlace) { + handleMakeDefaultLocation(nextPlace); + } + + toast.success(`${placeName} was deleted.`); + } catch (error) { + console.error('Error deleting saved place: ', error); + toast.error(error.message); + } + }; + + const handleMakeDefaultLocation = async (place) => { + try { + await runWithLoading(updateDefaultLocationPromise(place), 'defaulting'); + toast.success(`${place.getAttribute('name')} is now your default location.`); + } catch (error) { + console.log('Error making address default location:', error); + toast.error(error.message); + } + }; + + const renderRightActions = (place) => ( + + handleDelete(place)}> + + {isLoading('deleting') ? : } + + + handleEdit(place)}> + + + + + handleMakeDefaultLocation(place)} + opacity={currentLocation.id === place.id ? 0.5 : 1} + disabled={currentLocation.id === place.id} + > + + {isLoading('defaulting') ? : } + + + ); + const renderItem = ({ item: place, index }) => { + const opacity = new Animated.Value(1); + const translateX = new Animated.Value(0); + rowRefs.current[place.id || index] = { opacity, translateX }; + + return ( + + renderRightActions(place)}> + handleEdit(place)} style={{ flex: 1 }}> + + + {place.getAttribute('name')} + + {formattedAddressFromPlace(place)} + + + + + ); + }; + return ( - - - item.id || index} - renderItem={renderSavedLocation} - ItemSeparatorComponent={() => } - /> - + + item.id || index} + contentContainerStyle={{ paddingBottom: 16 }} + ItemSeparatorComponent={() => } + /> ); diff --git a/src/screens/BootScreen.tsx b/src/screens/BootScreen.tsx index ac3850d..62799d6 100644 --- a/src/screens/BootScreen.tsx +++ b/src/screens/BootScreen.tsx @@ -15,6 +15,8 @@ const BootScreen = () => { const [info, setInfo] = useStorage('info', {}); const navigation = useNavigation(); + console.log('[BootScreen]'); + useEffect(() => { const checkLocationPermission = async () => { const permission = Platform.OS === 'ios' ? PERMISSIONS.IOS.LOCATION_WHEN_IN_USE : PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION; diff --git a/src/screens/CartScreen.tsx b/src/screens/CartScreen.tsx index c374b34..d069dd5 100644 --- a/src/screens/CartScreen.tsx +++ b/src/screens/CartScreen.tsx @@ -1,5 +1,4 @@ import React, { useRef, useState, useEffect } from 'react'; -import Swipeable from 'react-native-gesture-handler/Swipeable'; import { useNavigation } from '@react-navigation/native'; import { Animated, SafeAreaView, TouchableOpacity, StyleSheet, LayoutAnimation, UIManager, Platform } from 'react-native'; import { Spinner, View, Image, Text, YStack, XStack, Button, useTheme } from 'tamagui'; @@ -9,6 +8,7 @@ import { toast, ToastPosition } from '@backpackapp-io/react-native-toast'; import { formatCurrency } from '../utils/format'; import { delay, loadPersistedResource } from '../utils'; import { calculateCartTotal } from '../utils/cart'; +import Swipeable from 'react-native-gesture-handler/Swipeable'; import useCart from '../hooks/use-cart'; import usePromiseWithLoading from '../hooks/use-promise-with-loading'; @@ -20,9 +20,9 @@ const CartScreen = () => { const theme = useTheme(); const navigation = useNavigation(); const { runWithLoading, isLoading, isAnyLoading } = usePromiseWithLoading(); - const rowRefs = useRef({}); const [cart, updateCart] = useCart(); const [displayedItems, setDisplayedItems] = useState(cart ? cart.contents() : []); + const rowRefs = useRef({}); // Make sure cart items is latest useEffect(() => { diff --git a/src/screens/EditLocationScreen.tsx b/src/screens/EditLocationScreen.tsx index f5cd21a..97c9d08 100644 --- a/src/screens/EditLocationScreen.tsx +++ b/src/screens/EditLocationScreen.tsx @@ -1,19 +1,21 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { useNavigation } from '@react-navigation/native'; import { SafeAreaView, ScrollView } from 'react-native'; import { Spinner, Text, YStack, XStack, Button, Input, useTheme } from 'tamagui'; import { toast, ToastPosition } from '@backpackapp-io/react-native-toast'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faBuildingUser, faHouse, faBuilding, faHotel, faHospital, faSchool, faChair } from '@fortawesome/free-solid-svg-icons'; +import { faBuildingUser, faHouse, faBuilding, faHotel, faHospital, faSchool, faChair, faAsterisk } from '@fortawesome/free-solid-svg-icons'; import { Place } from '@fleetbase/sdk'; import { adapter } from '../hooks/use-storefront'; import { useAuth } from '../contexts/AuthContext'; -import { formattedAddressFromSerializedPlace } from '../utils/location'; +import { formattedAddressFromSerializedPlace, restoreFleetbasePlace } from '../utils/location'; import { isEmpty } from '../utils'; import usePromiseWithLoading from '../hooks/use-promise-with-loading'; import useStorefront from '../hooks/use-storefront'; +import useCurrentLocation from '../hooks/use-current-location'; import useSavedLocations from '../hooks/use-saved-locations'; import ExpandableSelect from '../components/ExpandableSelect'; +import PlaceMapView from '../components/PlaceMapView'; const LocationPropertyInput = ({ value, onChange, placeholder }) => { return ( @@ -36,22 +38,41 @@ const LocationPropertyInput = ({ value, onChange, placeholder }) => { ); }; -const EditLocationScreen = ({ route }) => { +const EditLocationScreen = ({ route = { params: {} } }) => { const navigation = useNavigation(); const theme = useTheme(); + const { params } = route; const { customer, isAuthenticated } = useAuth(); const { storefront } = useStorefront(); - const { runWithLoading, isLoading } = usePromiseWithLoading(); - const { addLocation } = useSavedLocations(); - const [place, setPlace] = useState({ ...route.params.place }); + const { runWithLoading, isLoading, isAnyLoading } = usePromiseWithLoading(); + const { currentLocation, updateDefaultLocationPromise } = useCurrentLocation(); + const { savedLocations, addLocation, deleteLocation } = useSavedLocations(); + const [place, setPlace] = useState({ ...params.place }); const [name, setName] = useState(place.name); const [street1, setStreet1] = useState(place.street1); const [street2, setStreet2] = useState(place.street2); const [neighborhood, setNeighborhood] = useState(place.neighborhood); const [city, setCity] = useState(place.city); const [postalCode, setPostalCode] = useState(place.postal_code); - const [instructions, setInstructions] = useState(''); - const ready = !isEmpty(place.type) && !isEmpty(place.name) && !isEmpty(place.street1); + const [instructions, setInstructions] = useState(place.meta.instructions); + const redirectTo = route.params.redirectTo ?? 'StoreHome'; + const isDefaultLocation = currentLocation?.id === place?.id; + + const isReady = useMemo(() => { + if (isEmpty(place.id)) { + return !isEmpty(place.type) && !isEmpty(place.name) && !isEmpty(place.street1); + } + + return ( + name !== place.name || + street1 !== place.street1 || + street2 !== place.street2 || + neighborhood !== place.neighborhood || + city !== place.city || + postalCode !== place.postal_code || + instructions !== place.meta?.instructions + ); + }, [place.type, name, street1, street2, neighborhood, city, postalCode, instructions]); useEffect(() => { setPlace({ @@ -61,20 +82,69 @@ const EditLocationScreen = ({ route }) => { }); }, [name, street1]); + const handleRedirect = () => { + navigation.reset({ + index: 0, + routes: [ + { + name: redirectTo, + }, + ], + }); + }; + const handleSavePlace = async () => { - if (!ready) { + if (!isReady) { return; } try { - await runWithLoading(addLocation({ ...place, street1, street2, neighborhood, city, postal_code: postalCode, meta: { instructions } })); + await runWithLoading(addLocation({ ...place, street1, street2, neighborhood, city, postal_code: postalCode, meta: { instructions } }), 'saving'); toast.success('Address saved.'); - navigation.navigate('StoreHome'); + handleRedirect(); } catch (error) { + console.log('Error saving address details:', error); toast.error(error.message); } }; + const handleMakeDefaultLocation = async () => { + const restoredInstance = restoreFleetbasePlace(place); + if (restoredInstance && restoredInstance.isSaved) { + try { + await runWithLoading(updateDefaultLocationPromise(restoredInstance), 'defaulting'); + toast.success(`${restoredInstance.getAttribute('name')} is now your default location.`); + handleRedirect(); + } catch (error) { + console.log('Error making address default location:', error); + toast.error(error.message); + } + } + }; + + const handleDelete = async () => { + const isCurrentLocation = currentLocation?.id === place.id; + const nextPlace = savedLocations.find((loc) => loc.id !== place.id); + const restoredInstance = restoreFleetbasePlace(place); + + if (restoredInstance && restoredInstance.isSaved) { + try { + await runWithLoading(deleteLocation(restoredInstance), 'deleting'); + toast.success(`${restoredInstance.getAttribute('name')} was deleted.`); + + // If the deleted place was the current location and there’s another saved location, make it the default + if (isCurrentLocation && nextPlace) { + handleMakeDefaultLocation(nextPlace); + } + + handleRedirect(); + } catch (error) { + console.error('Error deleting saved address: ', error); + toast.error(error.message); + } + } + }; + const handleTypeSelection = ({ type }) => { try { setPlace({ ...place, type }); @@ -131,15 +201,21 @@ const EditLocationScreen = ({ route }) => { - - Address label or name - + + + Address label or name + + + - - Street address or P.O. Box - + + + Street address or P.O. Box + + + @@ -187,17 +263,91 @@ const EditLocationScreen = ({ route }) => { Optional - + + + + Where exactly is the location? + + + + + + {place.id && ( + + {!isDefaultLocation && ( + + )} + + + )} + )} - {ready && ( - - diff --git a/src/screens/LocationPickerScreen.tsx b/src/screens/LocationPickerScreen.tsx index 1af0afe..947314c 100644 --- a/src/screens/LocationPickerScreen.tsx +++ b/src/screens/LocationPickerScreen.tsx @@ -1,11 +1,17 @@ -import React, { useState, useRef, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { StyleSheet, Dimensions } from 'react-native'; import MapView, { Marker } from 'react-native-maps'; -import { Input, View, Image, Button, Text, YStack, useTheme } from 'tamagui'; -import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'; +import { Input, View, Image, Button, Text, XStack, YStack, useTheme } from 'tamagui'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faMapLocation, faLocationDot } from '@fortawesome/free-solid-svg-icons'; +import BottomSheet, { BottomSheetView, BottomSheetFlatList } from '@gorhom/bottom-sheet'; +import { Portal } from '@gorhom/portal'; +import { Place } from '@fleetbase/sdk'; import { useNavigation } from '@react-navigation/native'; -import { getLocationFromRouteOrStorage } from '../utils/location'; +import { geocode, createFleetbasePlaceFromDetails, getLocationFromRouteOrStorage, formattedAddressFromPlace, getCoordinates } from '../utils/location'; import LocationMarker from '../components/LocationMarker'; +import useStorefront from '../hooks/use-storefront'; +import useCurrentLocation from '../hooks/use-current-location'; const LOCATION_MARKER_SIZE = { height: 70, width: 40 }; const styles = StyleSheet.create({ @@ -21,58 +27,112 @@ const styles = StyleSheet.create({ }, }); +const formatAddressSecondaryIdentifier = (place) => { + if (place.getAttribute('building')) { + return `Building: ${place.getAttribute('building')}`; + } + + if (place.getAttribute('neighborhood')) { + return `Neighborhood: ${place.getAttribute('neighborhood')}`; + } + + if (place.getAttribute('postal_code')) { + return `Postal Code: ${place.getAttribute('postal_code')}`; + } +}; + const LocationPickerScreen = ({ route }) => { - const { onLocationSelected } = route.params || {}; const navigation = useNavigation(); const theme = useTheme(); + const { storefront } = useStorefront(); const bottomSheetRef = useRef(null); - const initialLocation = getLocationFromRouteOrStorage('initialLocation', route.params || {}); - const [selectedLocation, setSelectedLocation] = useState(initialLocation || null); + const initialLocation = getLocationFromRouteOrStorage('initialLocation', route.params); + const [latitude, longitude] = getCoordinates(initialLocation); + const [results, setResults] = useState([]); const [mapRegion, setMapRegion] = useState({ - latitude: initialLocation?.latitude || 37.7749, - longitude: initialLocation?.longitude || -122.4194, - latitudeDelta: 0.05, - longitudeDelta: 0.05, + latitude, + longitude, + latitudeDelta: 0.01, + longitudeDelta: 0.01, }); const [isPanning, setIsPanning] = useState(false); - const snapPoints = useMemo(() => [200, 400, 600], []); + const snapPoints = useMemo(() => ['35%'], []); + + // Bottom sheet controls + const openBottomSheet = () => { + bottomSheetRef.current?.collapse(); + }; + + const closeBottomSheet = () => { + bottomSheetRef.current?.close(); + }; // Handle panning tracking const handleTouchStart = () => setIsPanning(true); const handlePanDrag = () => setIsPanning(true); const handleTouchEnd = () => setIsPanning(false); + // Zoom controls + const zoomIn = () => { + setMapRegion((prev) => ({ + ...prev, + latitudeDelta: prev.latitudeDelta / 2, + longitudeDelta: prev.longitudeDelta / 2, + })); + }; + + const zoomOut = () => { + setMapRegion((prev) => ({ + ...prev, + latitudeDelta: prev.latitudeDelta * 2, + longitudeDelta: prev.longitudeDelta * 2, + })); + }; + // Function to handle region change and update the center location const handleRegionChangeComplete = (region) => { setIsPanning(false); setMapRegion(region); - console.log('Region changed', region); - // Optionally perform geocoding here with region.latitude and region.longitude + updateNearbyResults(region); }; - const confirmLocation = () => { - if (mapRegion) { - const selected = { - latitude: mapRegion.latitude, - longitude: mapRegion.longitude, - }; - onLocationSelected?.(selected); - navigation.goBack(); // Go back to the previous screen + const updateNearbyResults = async ({ latitude, longitude }) => { + try { + const results = await geocode(latitude, longitude, { withAllResults: true }); + setResults( + results.map((result) => { + return createFleetbasePlaceFromDetails(result); + }) + ); + openBottomSheet(); + } catch (error) { + console.error('Error fetching nearby locations: ', error); + toast.error(error.message); } }; - const onLocationSelect = (event) => { - const { latitude, longitude } = event.nativeEvent.coordinate; - setSelectedLocation({ latitude, longitude }); + const handleLocationSelect = (place) => { + closeBottomSheet(); + navigation.navigate('EditLocation', { place: place.serialize() }); }; - const onFocusSearchInput = ({ nativeEvent }) => { - console.log('onFocusSearchInput', nativeEvent); - bottomSheetRef.current.snapToIndex(2); + const handleMarkerLocationSelect = () => { + closeBottomSheet(); + const geocoded = results[0] ?? new Place(); + const place = new Place({ + location: [mapRegion.latitude, mapRegion.longitude], + street1: geocoded.getAttribute('street1'), + city: geocoded.getAttribute('city'), + province: geocoded.getAttribute('province'), + neighborhood: geocoded.getAttribute('neighborhood'), + postal_code: geocoded.getAttribute('postal_code'), + country: geocoded.getAttribute('country'), + }); + navigation.navigate('EditLocation', { place: place.serialize() }); }; - const handleSheetChanges = useCallback((index: number) => { - console.log('handleSheetChanges', index); + useEffect(() => { + updateNearbyResults(mapRegion); }, []); return ( @@ -81,45 +141,90 @@ const LocationPickerScreen = ({ route }) => { style={{ ...StyleSheet.absoluteFillObject, width: '100%', height: '100%' }} onPress={handleTouchStart} onPanDrag={handlePanDrag} - onRegionChangeComplete={handleTouchEnd} + onRegionChangeComplete={handleRegionChangeComplete} initialRegion={mapRegion} - > - {selectedLocation && } - + /> - {/* - - - - - Add new address - - - - + + + + + index} + renderItem={({ item }) => ( + + )} /> - - - - */} + + + + + ); }; diff --git a/src/screens/ProductScreen.tsx b/src/screens/ProductScreen.tsx index 1597522..bb7ce4e 100644 --- a/src/screens/ProductScreen.tsx +++ b/src/screens/ProductScreen.tsx @@ -141,11 +141,11 @@ const ProductScreen = ({ route = {} }) => { disabled={!ready || isLoading('addToCart')} opacity={ready ? 1 : 0.5} hoverStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} pressStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} > diff --git a/src/screens/StoreMapScreen.tsx b/src/screens/StoreMapScreen.tsx index 8c2ae44..b60084d 100644 --- a/src/screens/StoreMapScreen.tsx +++ b/src/screens/StoreMapScreen.tsx @@ -5,12 +5,12 @@ import { YStack } from 'tamagui'; import { getLocationFromRouteOrStorage } from '../utils/location'; const StoreMapScreen = ({ route }) => { - const initialLocation = getLocationFromRouteOrStorage('initialLocation', route.params || {}); + const initialLocation = getLocationFromRouteOrStorage('initialLocation', route.params); const [mapRegion, setMapRegion] = useState({ - latitude: initialLocation?.latitude || 37.7749, - longitude: initialLocation?.longitude || -122.4194, - latitudeDelta: 0.05, - longitudeDelta: 0.05, + latitude: initialLocation?.getAttribute('location')[0] ?? 37.7749, + longitude: initialLocation?.getAttribute('location')[1] ?? -122.4194, + latitudeDelta: 0.01, + longitudeDelta: 0.01, }); return ( diff --git a/src/screens/StoreSearchScreen.tsx b/src/screens/StoreSearchScreen.tsx index 2811928..26b5ba9 100644 --- a/src/screens/StoreSearchScreen.tsx +++ b/src/screens/StoreSearchScreen.tsx @@ -139,11 +139,11 @@ const StoreSearch = (route = {}) => { width={40} animation='quick' hoverStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} pressStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} > @@ -180,11 +180,11 @@ const StoreSearch = (route = {}) => { bg='transparent' animation='quick' hoverStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} pressStyle={{ - scale: 0.75, + scale: 0.95, opacity: 0.5, }} > diff --git a/src/utils/index.js b/src/utils/index.js index 401842c..9631026 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -68,11 +68,11 @@ export function isEmpty(target) { } export function isResource(target, type = null) { - if (typeof type === 'string') { + if (isObject(target) && typeof type === 'string') { return hasResouceProperties(target) && target.resource === type; } - return hasResouceProperties(target); + return isObject(target) && hasResouceProperties(target); } export function isSerializedResource(target) { diff --git a/src/utils/location.js b/src/utils/location.js index 7bbb84e..1bbc368 100644 --- a/src/utils/location.js +++ b/src/utils/location.js @@ -2,9 +2,9 @@ import Geolocation from '@react-native-community/geolocation'; import { Platform } from 'react-native'; import { EventRegister } from 'react-native-event-listeners'; import { checkMultiple, request, PERMISSIONS, RESULTS } from 'react-native-permissions'; -import { GoogleAddress, Place } from '@fleetbase/sdk'; +import { GoogleAddress, Place, Point } from '@fleetbase/sdk'; import { StoreLocation } from '@fleetbase/storefront'; -import { adapter } from '../hooks/use-storefront'; +import { adapter } from '../hooks/use-fleetbase'; import { haversine } from './math'; import { config, uniqueArray, isObject, isArray, isEmpty, isResource, isSerializedResource, isPojoResource } from './'; import storage from './storage'; @@ -38,6 +38,13 @@ export async function geocode(latitude: number, longitude: number, options = {}) throw new Error('No geocode results for provided coordinates.'); } + // Allow full results + if (options.withAllResults === true) { + return response.data.results.map((result) => { + return options.asGoogleAddress === true ? new GoogleAddress(result) : result; + }); + } + const result = response.data.results[0]; return options.asGoogleAddress === true ? new GoogleAddress(result) : result; } catch (error) { @@ -50,7 +57,7 @@ export async function geocodeAutocomplete(input, coordinates = null) { try { const params = { input, - types: 'address', // Restrict results to addresses only + // types: 'address', // Restrict results to addresses only language: 'en-US', key: config('GOOGLE_MAPS_KEY'), }; @@ -112,7 +119,7 @@ export function createFleetbasePlaceFromDetails(details, meta = {}) { const placeObject = parsePlaceDetails(details); const attributes = { - name: null, + name: details.name ?? null, street1: placeObject.street ?? addressObject.street, city: placeObject.city ?? addressObject.city, province: placeObject.state ?? addressObject.state, @@ -121,10 +128,11 @@ export function createFleetbasePlaceFromDetails(details, meta = {}) { building: placeObject.building, security_access_code: null, country: placeObject.country ?? addressObject.country, - location: [placeObject.latitude, placeObject.longitude], + location: new Point(placeObject.latitude, placeObject.longitude), phone: null, meta: { ...meta, + coordinates: [placeObject.latitude, placeObject.longitude], location: addressObject.others } }; @@ -215,9 +223,15 @@ export async function getLiveLocation () { const details = await geocode(latitude, longitude); const place = createFleetbasePlaceFromDetails(details, { position }); + // Save the last known location + storage.setMap('_last_known_location', place.serialize()); + resolve(place); } catch (error) { - const place = new Place({ location: [latitude, longitude], meta: { position } }); + const place = new Place({ location: new Point(latitude, longitude), meta: { position } }); + + // Save the last known location + storage.setMap('_last_known_location', place.serialize()); resolve(place); } @@ -251,7 +265,7 @@ export async function getCurrentLocation () { storage.setMap('_current_location', place.serialize()); resolve(place); } catch (error) { - const place = new Place({ location: [latitude, longitude], meta: { position } }); + const place = new Place({ location: new Point(latitude, longitude), meta: { position } }); storage.setMap('_current_location', place.serialize()); resolve(place); @@ -267,30 +281,57 @@ export function getLastKnownPosition () { return storage.getArray('_last_known_position') ?? [0, 0]; } -export function getCoordinates (location) { - if (!location) return []; +// NOTICE: when fleetbase returns a geojson point the coordinates will always be in order of [longitude, latitude] +export function getCoordinates (target, options = { fallback: [0, 0] }) { + if (!target) { + return []; + } - if (location instanceof Place && location.coordinates) { - const [longitude, latitude] = location.coordinates; + if (isResource(target, 'place')) { + const [longitude, latitude] = target.getAttribute('location').coordinates; return [latitude, longitude]; } - if (location instanceof StoreLocation) { - const point = location.getAttribute('place.location'); - if (point) { - const [longitude, latitude] = point.coordinates; - return [latitude, longitude]; + if (isResource(target, 'store-location')) { + const location = target.getAttribute('place'); + return getCoordinates(location); + } + + if (isPojoResource(target) && target.resource === 'place') { + const [longitude, latitude] = typeof target.getAttribute === 'function' ? (target.getAttribute('location').coordinates ?? fallback) : (target.attributes?.location?.coordinates ?? fallback); + return [latitude, longitude] + } + + if (isPojoResource(target) && isObject(target.attributes.place) && target.resource === 'store-location') { + return getCoordinates(target.attributes.place); + } + + if (isSerializedResource(target)) { + if (isObject(target.location)) { + const [longitude, latitude] = target.location.coordinates; + return [latitude, longitude] } + + if (isObject(target.place)) { + return getCoordinates(target.place); + } + + return fallback; + } + + if (isArray(target)) { + return target; } - if (isArray(location)) return location; + if (isObject(target) && target.type === 'Point') { + return target.coordinates; + } - if (typeof location === 'object' && location.type === 'Point') { - const [longitude, latitude] = location.coordinates; - return [latitude, longitude]; + if (isObject(target) && target.latitude && target.longitude) { + return [target.latitude, target.longitude]; } - return [0, 0]; + return fallback; }; export function getDistance (origin, destination) { @@ -313,23 +354,26 @@ export function getLocationName(place) { }; export function getLocationFromRouteOrStorage(key, routeParams = {}) { - const lastLocation = storage.getMap('_current_location'); - const localLocations = storage.getArray('_local_locations'); + let location; const locationFromParams = routeParams[key]; + const lastKnownLocation = storage.getMap('_last_known_location') + const currentLocation = storage.getMap('_current_location'); + const localLocations = storage.getArray('_local_locations'); + const customerLocations = storage.getArray('_customer_locations'); if (locationFromParams) { - return locationFromParams; - } - - if (lastLocation) { - return lastLocation; - } - - if (isArray(localLocations) && localLocations.length) { - return localLocations[0]; + location = locationFromParams; + } else if (lastKnownLocation) { + location = lastKnownLocation; + } else if (currentLocation) { + location = currentLocation; + } else if (isArray(customerLocations) && customerLocations.length) { + location = customerLocations[0]; + }else if (isArray(localLocations) && localLocations.length) { + location = localLocations[0]; } - return null; + return restoreFleetbasePlace(location); } export function parsePlaceDetails(details) {