From 3773d97d76f5aba8f3eb81dd052e6949d26357fe Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 24 Nov 2024 15:58:56 +0800 Subject: [PATCH] completed search screen, minus composability refactor wip --- src/components/CategoryProductSlider.tsx | 49 ++++ src/components/LocationPicker.tsx | 36 +-- src/components/ProductBookingPanel.tsx | 0 src/components/ProductCard.tsx | 189 ++++++++------ src/components/ProductOptionsForm.tsx | 159 ++++++++++++ src/components/QuantityButton.tsx | 62 ++++- src/components/StoreCategoriesGrid.tsx | 21 +- src/components/StoreHeader.tsx | 22 +- src/components/StoreTagCloud.tsx | 89 +++++++ src/hooks/use-cart.ts | 15 +- src/hooks/use-promise-with-loading.ts | 31 +++ src/hooks/use-storefront-data.ts | 26 +- src/navigation/StoreNavigator.tsx | 22 ++ src/navigation/stacks/StoreStack.tsx | 18 +- src/screens/BootScreen.tsx | 2 - src/screens/CartItemScreen.tsx | 211 ++++++++++++++++ src/screens/CartScreen.tsx | 298 ++++++++++++++++++++--- src/screens/CreateAccountScreen.tsx | 16 ++ src/screens/LoginScreen.tsx | 16 ++ src/screens/ProductScreen.tsx | 166 ++++++++++++- src/screens/ProfileScreen.tsx | 13 +- src/screens/StoreCategoryScreen.tsx | 8 +- src/screens/StoreHomeScreen.tsx | 95 +++++++- src/screens/StoreMapScreen.tsx | 23 +- src/screens/StoreOnboardScreen.tsx | 16 ++ src/screens/StoreSearchScreen.tsx | 244 ++++++++++++++++++- src/utils/animation.js | 28 +++ src/utils/cart.js | 100 ++++++++ src/utils/index.js | 107 +++++++- src/utils/product.js | 132 ++++++++++ 30 files changed, 1993 insertions(+), 221 deletions(-) create mode 100644 src/components/CategoryProductSlider.tsx create mode 100644 src/components/ProductBookingPanel.tsx create mode 100644 src/components/ProductOptionsForm.tsx create mode 100644 src/components/StoreTagCloud.tsx create mode 100644 src/hooks/use-promise-with-loading.ts create mode 100644 src/screens/CartItemScreen.tsx create mode 100644 src/screens/CreateAccountScreen.tsx create mode 100644 src/screens/LoginScreen.tsx create mode 100644 src/screens/StoreOnboardScreen.tsx create mode 100644 src/utils/animation.js create mode 100644 src/utils/product.js diff --git a/src/components/CategoryProductSlider.tsx b/src/components/CategoryProductSlider.tsx new file mode 100644 index 0000000..874b635 --- /dev/null +++ b/src/components/CategoryProductSlider.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { ScrollView, Pressable } from 'react-native'; +import { YStack, XStack, Text, Spinner } from 'tamagui'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; +import { useNavigation } from '@react-navigation/native'; +import useStorefrontData from '../hooks/use-storefront-data'; +import ProductCard from './ProductCard'; + +const CategoryProductSlider = ({ category, style = {}, onPressCategory }) => { + const navigation = useNavigation(); + const { data: products, loading: isLoadingProducts } = useStorefrontData((storefront) => storefront.products.query({ category: category.id }), { + defaultValue: [], + persistKey: `${category.id}_products`, + }); + + const handleCategoryPress = () => { + if (typeof onPressCategory === 'function') { + onPressCategory(category); + } + }; + + return ( + + + + [{ opacity: pressed ? 0.5 : 1.0, scale: pressed ? 0.8 : 1.0 }]}> + + + {category.getAttribute('name')} + + + + + {isLoadingProducts && } + + + + + {products.map((product, index) => ( + + ))} + + + + ); +}; + +export default CategoryProductSlider; diff --git a/src/components/LocationPicker.tsx b/src/components/LocationPicker.tsx index 91a112b..c00def0 100644 --- a/src/components/LocationPicker.tsx +++ b/src/components/LocationPicker.tsx @@ -12,7 +12,7 @@ import useStorage from '../hooks/use-storage'; const LocationPicker = ({ ...props }) => { const navigation = useNavigation(); - const { onPressAddNewLocation } = props; + const { onPressAddNewLocation, wrapperStyle = {}, triggerWrapperStyle = {}, triggerStyle = {}, triggerTextStyle = {}, triggerArrowStyle = {}, triggerProps = {} } = props; const { storefront, adapter } = useStorefront(); const [currentLocation, setCurrentLocation] = useStorage('location'); const [savedLocations, setSavedLocations] = useState([]); @@ -70,32 +70,38 @@ const LocationPicker = ({ ...props }) => { }; return ( - + - + {currentLocation ? currentLocation.name : 'Loading...'} - + @@ -113,7 +119,7 @@ const LocationPicker = ({ ...props }) => { backgroundColor='transparent' width={dropdownWidth} position='absolute' - top={triggerPosition.height + 80} + top={triggerPosition.height + 60} left={triggerPosition.x - 15} zIndex={1} enterStyle={{ @@ -135,7 +141,7 @@ const LocationPicker = ({ ...props }) => { originY={0} > - + {savedLocations.map((location) => ( { const theme = useTheme(); + const navigation = useNavigation(); + const { runWithLoading, isLoading } = usePromiseWithLoading(); const [cardWidth, setCardWidth] = useState(0); const [cart, updateCart] = useCart(); const [quantity, setQuantity] = useState(1); const handlePress = () => { - if (typeof onPress === 'function') { - onPress(product); + if (isLoading('addToCart')) { + return; } + + navigation.navigate('Product', { product: product.serialize(), quantity }); }; const handleAddToCart = async () => { - if (typeof onAddToCart === 'function') { - onAddToCart(product); + if (isLoading('addToCart')) { + return; + } + + if (productHasOptions(product)) { + return navigation.navigate('Product', { product: product.serialize(), quantity }); } try { - const updatedCart = await cart.add(product.id, quantity, { addons: [], variants: [] }); + const updatedCart = await runWithLoading(cart.add(product.id, quantity), 'addToCart'); updateCart(updatedCart); - console.log(`Product ${product.name} added to cart!`); + setQuantity(1); + toast.success(`${product.getAttribute('name')} added to cart.`, { position: ToastPosition.BOTTOM }); } catch (error) { - console.log('Cart Error: ' + error.message); + console.log('Error Adding to Cart', error.message); } }; return ( - { - setCardWidth(width); - }} - > - - - - - - {/* {favoriteIcon} */} - - - - - - - - - {product.getAttribute('name')} - - {product.isAttributeFilled('description') && ( - - {product.getAttribute('description')} - - )} + + { + setCardWidth((prevWidth) => (prevWidth !== width ? width : prevWidth)); + }} + > + + + + + - - {product.getAttribute('on_sale') ? ( - - - {formatCurrency(product.getAttribute('sale_price'), product.getAttribute('currency'))} - - - {formatCurrency(product.getAttribute('price'), product.getAttribute('currency'))} - + + + + + + + {product.getAttribute('name')} + + {product.isAttributeFilled('description') && ( + + {product.getAttribute('description')} + + )} + + + {product.getAttribute('on_sale') ? ( + + + {formatCurrency(product.getAttribute('sale_price'), product.getAttribute('currency'))} + + + {formatCurrency(product.getAttribute('price'), product.getAttribute('currency'))} + + + ) : ( + + {formatCurrency(product.getAttribute('price'), product.getAttribute('currency'))} + + )} + - ) : ( - - {formatCurrency(product.getAttribute('price'), product.getAttribute('currency'))} - - )} - - {/* Quantity Button */} - console.log('Selected Quantity:', quantity)} /> + + + - - - - - + + Add to Cart + + + + + + + + ); }; diff --git a/src/components/ProductOptionsForm.tsx b/src/components/ProductOptionsForm.tsx new file mode 100644 index 0000000..0e254e2 --- /dev/null +++ b/src/components/ProductOptionsForm.tsx @@ -0,0 +1,159 @@ +import React, { useState, useEffect } from 'react'; +import { Text, YStack, XStack, Label, RadioGroup, Checkbox, useTheme } from 'tamagui'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faTimes, faAsterisk, faCheck } from '@fortawesome/free-solid-svg-icons'; +import { formatCurrency } from '../utils/format'; +import { isEmpty } from '../utils'; +import { + createAddonSelectionDefaults, + selectAddon, + isAddonSelected, + createVariationSelectionDefaults, + selectVariant, + getVariantOptionById, + isVariantSelected, + getSelectedVariantId, +} from '../utils/product'; + +const ProductOptionsForm = ({ product, onAddonsChanged, onVariationsChanged, defaultSelectedAddons = null, defaultSelectedVariants = null, wrapperProps = {} }) => { + const theme = useTheme(); + const [selectedAddons, setSelectedAddons] = useState(isEmpty(defaultSelectedAddons) ? createAddonSelectionDefaults(product) : defaultSelectedAddons); + const [selectedVariants, setSelectedVariants] = useState(isEmpty(defaultSelectedVariants) ? createVariationSelectionDefaults(product) : defaultSelectedVariants); + + const handleAddonToggle = (checked, addon, addonCategory) => { + selectAddon(checked, addon, addonCategory, setSelectedAddons); + }; + + const handleVariationToggle = (variationOptionId, variation) => { + const variant = getVariantOptionById(variationOptionId, product); + if (variant) { + const updatedSelection = selectVariant(selectedVariants, variation, variant); + setSelectedVariants(updatedSelection); + } + }; + + useEffect(() => { + if (typeof onVariationsChanged === 'function') { + onVariationsChanged(selectedVariants); + } + }, [selectedVariants]); + + useEffect(() => { + if (typeof onAddonsChanged === 'function') { + onAddonsChanged(selectedAddons); + } + }, [selectedAddons]); + + return ( + + {product.variants().length > 0 && ( + + {product.variants().map((variation, i) => ( + + + + {variation.name} + + {variation.is_required && } + + + + Choose one item + + + handleVariationToggle(id, variation)} + aria-labelledby='Select one item' + defaultValue='3' + name='form' + > + + {variation.options.map((variant, j) => ( + + + + + + + + + ))} + + + + ))} + + )} + {product.addons().length > 0 && ( + + {product.addons().map((addonCategory, i) => ( + + + + {addonCategory.name} + + + + + Choose up to 4 items + + + + {addonCategory.addons.map((addon, j) => ( + + + handleAddonToggle(checked, addon, addonCategory)} + animation='quick' + id={addon.id} + value={addon.id} + checked={isAddonSelected(addon.id, selectedAddons)} + size={26} + bg='$background' + borderColor='$primary' + borderWidth={2} + circular + > + + + + + + + + + ))} + + + ))} + + )} + + ); +}; + +export default ProductOptionsForm; diff --git a/src/components/QuantityButton.tsx b/src/components/QuantityButton.tsx index 9d635a0..6ee2bcf 100644 --- a/src/components/QuantityButton.tsx +++ b/src/components/QuantityButton.tsx @@ -3,7 +3,20 @@ import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; import { faPlus, faMinus } from '@fortawesome/free-solid-svg-icons'; import { XStack, Button, Text } from 'tamagui'; -const QuantityButton = ({ quantity = 1, onIncrement, onDecrement, onChange, min = 1, max = 9999, wrapperProps = {}, incrementButtonProps = {}, decrementButtonProps = {}, style = {} }) => { +const QuantityButton = ({ + quantity = 1, + onIncrement, + onDecrement, + onChange, + min = 1, + max = 9999, + buttonSize = '$2', + disabled = false, + wrapperProps = {}, + incrementButtonProps = {}, + decrementButtonProps = {}, + style = {}, +}) => { const [currentQuantity, setCurrentQuantity] = useState(quantity); const handleIncrement = () => { @@ -33,8 +46,34 @@ const QuantityButton = ({ quantity = 1, onIncrement, onDecrement, onChange, min }; return ( - - + + + + + + + + + {product.getAttribute('name')} + + {isService && ( + + Service + + )} + + + + {formatCurrency(product.getAttribute('price'), product.getAttribute('currency'))} + + + {product.isAttributeFilled('description') && ( + + + {product.getAttribute('description')} + + + )} + + + + + + + + + + + + + + + + + ); +}; + +export default CartItemScreen; diff --git a/src/screens/CartScreen.tsx b/src/screens/CartScreen.tsx index 8cde637..c374b34 100644 --- a/src/screens/CartScreen.tsx +++ b/src/screens/CartScreen.tsx @@ -1,54 +1,294 @@ -import React, { useEffect, useState } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; +import Swipeable from 'react-native-gesture-handler/Swipeable'; import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native'; -import { Stack, Text, YStack, useTheme, Button } from 'tamagui'; +import { Animated, SafeAreaView, TouchableOpacity, StyleSheet, LayoutAnimation, UIManager, Platform } from 'react-native'; +import { Spinner, View, Image, Text, YStack, XStack, Button, useTheme } from 'tamagui'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faPencilAlt, faTrash } from '@fortawesome/free-solid-svg-icons'; +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 useCart from '../hooks/use-cart'; +import usePromiseWithLoading from '../hooks/use-promise-with-loading'; + +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} 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() : []); + + // Make sure cart items is latest + useEffect(() => { + setDisplayedItems(cart ? cart.contents() : []); + }, [cart]); - if (cart) { - console.log('[cart]', cart); - } + const handleEdit = async (cartItem) => { + const product = await loadPersistedResource((storefront) => storefront.products.findRecord(cartItem.product_id), { type: 'product', persistKey: `${cartItem.product_id}_product` }); + if (product) { + navigation.navigate('CartItem', { cartItem, product: product.serialize() }); + } + }; - const emptyCart = async () => { - console.log('Emptying cart', cart); - if (!cart) return false; + const handleDelete = async (cartItem) => { + const rowRef = rowRefs.current[cartItem.id]; + + if (!rowRef) { + toast.error('Could not find item to delete.', { position: ToastPosition.BOTTOM }); + return; + } try { - const emptiedCart = await cart.empty(); - updateCart(emptiedCart); - console.log('Cart emptied!', emptiedCart); + await new Promise((resolve) => { + Animated.parallel([ + Animated.timing(rowRef.opacity, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(rowRef.translateX, { + toValue: -100, + duration: 300, + useNativeDriver: true, + }), + ]).start(resolve); + }); + + // Remove item visually + setDisplayedItems((prevItems) => prevItems.filter((item) => item.id !== cartItem.id)); + toast.success(`${cartItem.name} removed from cart.`, { position: ToastPosition.BOTTOM }); + + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + + const updatedCart = await runWithLoading(cart.remove(cartItem.id), `removeCartItem_${cartItem.id}`); + updateCart(updatedCart); } catch (error) { - console.log(error.message); + toast.error('Failed to remove item from cart'); + console.error('Error removing cart item:', error.message); } }; - const reloadCart = async () => { - console.log('Reloading cart', cart); - if (!cart) return false; + const handleEmpty = async () => { + const cartItems = cart.contents(); + + if (!cartItems.length) { + toast.error('Cart is already empty', { position: ToastPosition.BOTTOM }); + return; + } try { - const refreshedCart = await cart.refresh(); - updateCart(refreshedCart); - console.log('Cart reloaded!', refreshedCart); + const animations = cartItems.map((cartItem) => { + const rowRef = rowRefs.current[cartItem.id]; + + if (rowRef) { + return new Promise((resolve) => { + Animated.parallel([ + Animated.timing(rowRef.opacity, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(rowRef.translateX, { + toValue: -100, + duration: 300, + useNativeDriver: true, + }), + ]).start(resolve); + }); + } + + return Promise.resolve(); + }); + + await Promise.all(animations); + toast.success('Cart emptied', { position: ToastPosition.BOTTOM }); + + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + + const emptiedCart = await runWithLoading(cart.empty(), 'emptyCart'); + updateCart(emptiedCart); } catch (error) { - console.log(error.message); + toast.error('Failed to empty cart'); + console.error('Error emptying cart:', error.message); } }; + const renderRightActions = (cartItem) => ( + + handleEdit(cartItem)}> + + + + + handleDelete(cartItem)}> + + {isLoading(`removeCartItem_${cartItem.id}`) ? : } + + + + ); + + const renderItem = ({ item: cartItem }) => { + const opacity = new Animated.Value(1); + const translateX = new Animated.Value(0); + rowRefs.current[cartItem.id] = { opacity, translateX }; + + return ( + + renderRightActions(cartItem)}> + + + + handleEdit(cartItem)} style={{ flex: 1 }}> + + + + + + x + + + {cartItem.quantity} + + + + + + + + + + + + {cartItem.name} + + + {cartItem.description && ( + + {cartItem.description} + + )} + + + {cartItem.variants.map((variant) => ( + + + {variant.name} + + + ))} + {cartItem.addons.map((addon) => ( + + + {addon.name} + + + ))} + + + + + + + + + {formatCurrency(cartItem.subtotal, cart.getAttribute('currency'))} + + + + + + + + ); + }; + return ( - {cart && ( - - Cart has {cart.contents().length} items - - + + + + Order items ({displayedItems.length}) + + {isAnyLoading() && ( + + + + )} + + + + + Empty Cart + + + + + item.id} contentContainerStyle={{ paddingBottom: 16 }} /> + {cart.isNotEmpty && ( + + + + + {formatCurrency(calculateCartTotal(), cart.getAttribute('currency'))} + + + + + + )} diff --git a/src/screens/CreateAccountScreen.tsx b/src/screens/CreateAccountScreen.tsx new file mode 100644 index 0000000..672a732 --- /dev/null +++ b/src/screens/CreateAccountScreen.tsx @@ -0,0 +1,16 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native'; +import { Stack, Text, YStack, useTheme } from 'tamagui'; + +const CreateAccountScreen = () => { + const theme = useTheme(); + + return ( + + + + ); +}; + +export default CreateAccountScreen; diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx new file mode 100644 index 0000000..d8c530c --- /dev/null +++ b/src/screens/LoginScreen.tsx @@ -0,0 +1,16 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native'; +import { Stack, Text, YStack, useTheme } from 'tamagui'; + +const LoginScreen = () => { + const theme = useTheme(); + + return ( + + + + ); +}; + +export default LoginScreen; diff --git a/src/screens/ProductScreen.tsx b/src/screens/ProductScreen.tsx index 24655f3..1597522 100644 --- a/src/screens/ProductScreen.tsx +++ b/src/screens/ProductScreen.tsx @@ -1,15 +1,169 @@ import React, { useEffect, useState } from 'react'; +import { ScrollView } from 'react-native'; +import { Spinner, Image, Text, View, YStack, XStack, Button, Paragraph, Label, RadioGroup, Checkbox, useTheme } from 'tamagui'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faTimes, faAsterisk, faCheck } from '@fortawesome/free-solid-svg-icons'; import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native'; -import { Stack, Text, YStack, useTheme } from 'tamagui'; +import { toast, ToastPosition } from '@backpackapp-io/react-native-toast'; +import { restoreStorefrontInstance } from '../utils'; +import { formatCurrency } from '../utils/format'; +import { calculateProductSubtotal, getCartItem } from '../utils/cart'; +import { isProductReadyForCheckout, getSelectedVariants, getSelectedAddons } from '../utils/product'; +import QuantityButton from '../components/QuantityButton'; +import ProductOptionsForm from '../components/ProductOptionsForm'; +import LinearGradient from 'react-native-linear-gradient'; +import useCart from '../hooks/use-cart'; +import usePromiseWithLoading from '../hooks/use-promise-with-loading'; -const ProductScreen = () => { +const ProductScreen = ({ route = {} }) => { const theme = useTheme(); + const navigation = useNavigation(); + const { runWithLoading, isLoading } = usePromiseWithLoading(); + const [cart, updateCart] = useCart(); + const product = restoreStorefrontInstance(route.params.product, 'product'); + const isService = product.getAttribute('is_service') === true; + const [selectedAddons, setSelectedAddons] = useState({}); + const [selectedVariants, setSelectedVariants] = useState({}); + const [subtotal, setSubtotal] = useState(calculateProductSubtotal(product, selectedVariants, selectedAddons)); + const [quantity, setQuantity] = useState(route.params.quantity ?? 1); + const [ready, setReady] = useState(false); + + useEffect(() => { + setSubtotal(calculateProductSubtotal(product, selectedVariants, selectedAddons)); + setReady(isProductReadyForCheckout(product, selectedVariants)); + }, [selectedAddons, selectedVariants]); + + useEffect(() => { + setReady(isProductReadyForCheckout(product, selectedVariants)); + }, []); + + const handleClose = () => { + navigation.goBack(); + }; + + const handleAddToCart = async () => { + if (isLoading('addToCart') || !isProductReadyForCheckout(product, selectedVariants)) { + console.log('Product is not ready for checkout'); + return; + } + + // @TODO need to handle cases when item is already in cart/ we restore and update that existing cart item + const addons = getSelectedAddons(selectedAddons); + const variants = getSelectedVariants(selectedVariants); + + try { + const updatedCart = await runWithLoading(cart.add(product.id, quantity, { addons, variants }), 'addToCart'); + updateCart(updatedCart); + toast.success(`${product.getAttribute('name')} added to cart.`, { position: ToastPosition.BOTTOM }); + navigation.goBack(); + } catch (error) { + console.log('Error Adding to Cart', error.message); + } + }; return ( - - - + + + + + + + + + + + + + + {product.getAttribute('name')} + + {isService && ( + + Service + + )} + + + + {formatCurrency(product.getAttribute('price'), product.getAttribute('currency'))} + + + {product.isAttributeFilled('description') && ( + + + {product.getAttribute('description')} + + + )} + + + + + + + + + + + + + ); }; diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index b65a440..2aedff9 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -1,14 +1,23 @@ import React, { useEffect, useState } from 'react'; import { useNavigation } from '@react-navigation/native'; import { SafeAreaView } from 'react-native'; -import { Stack, Text, YStack, useTheme } from 'tamagui'; +import { Stack, Text, YStack, useTheme, Button } from 'tamagui'; +import { toast, ToastPosition } from '@backpackapp-io/react-native-toast'; +import storage from '../utils/storage'; const ProfileScreen = () => { const theme = useTheme(); + const handleClearCache = () => { + storage.clearStore(); + toast.success('Cache cleared.', { position: ToastPosition.BOTTOM }); + }; + return ( - + + + ); }; diff --git a/src/screens/StoreCategoryScreen.tsx b/src/screens/StoreCategoryScreen.tsx index d55c10b..fcf6b79 100644 --- a/src/screens/StoreCategoryScreen.tsx +++ b/src/screens/StoreCategoryScreen.tsx @@ -19,7 +19,7 @@ const StoreCategoryScreen = ({ route }) => { return ( - + {isLoadingProducts && ( @@ -27,9 +27,11 @@ const StoreCategoryScreen = ({ route }) => { )} - + {products.map((product, index) => ( - navigation.navigate('Product', { product: product.serialize() })} style={{ width: '50%' }} /> + + navigation.navigate('Product', { product: product.serialize() })} /> + ))} diff --git a/src/screens/StoreHomeScreen.tsx b/src/screens/StoreHomeScreen.tsx index c4c2d66..67b4b9f 100644 --- a/src/screens/StoreHomeScreen.tsx +++ b/src/screens/StoreHomeScreen.tsx @@ -1,29 +1,100 @@ -import React, { useEffect, useState } from 'react'; +import React, { useRef, useEffect } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native'; -import { Stack, Text, YStack, useTheme } from 'tamagui'; -import { Collection } from '@fleetbase/sdk'; +import { useHeaderHeight } from '@react-navigation/elements'; +import { ScrollView, Animated } from 'react-native'; +import { YStack, useTheme } from 'tamagui'; import StoreHeader from '../components/StoreHeader'; import StoreCategoriesGrid from '../components/StoreCategoriesGrid'; +import CategoryProductSlider from '../components/CategoryProductSlider'; import useStorefrontData from '../hooks/use-storefront-data'; import useStorefrontInfo from '../hooks/use-storefront-info'; +import LocationPicker from '../components/LocationPicker'; const StoreHome = ({ route }) => { const theme = useTheme(); const navigation = useNavigation(); + const headerHeight = useHeaderHeight(); // Default header height + const customHeaderHeight = 200; // Adjust if StoreHeader uses a fixed height + const scrollY = useRef(new Animated.Value(0)).current; const { info } = useStorefrontInfo(); const { data: categories } = useStorefrontData((storefront) => storefront.categories.findAll(), { defaultValue: [], persistKey: `${info.id}_categories` }); + // Interpolated animations + const headerOpacity = scrollY.interpolate({ + inputRange: [0, customHeaderHeight], + outputRange: [1, 0], + extrapolate: 'clamp', + }); + + const headerTranslateY = scrollY.interpolate({ + inputRange: [0, customHeaderHeight], + outputRange: [0, -customHeaderHeight], + extrapolate: 'clamp', + }); + + const storeHeaderWrapperStyle = { + opacity: headerOpacity, + transform: [{ translateY: headerTranslateY }], + }; + + useEffect(() => { + navigation.setOptions({ + headerLeft: () => ( + + navigation.navigate('StoreHomeTab', { screen: 'AddNewLocation' })} + /> + + ), + }); + }, [navigation, headerOpacity, headerTranslateY]); + return ( - - navigation.navigate('StoreCategory', { category: category.serialize() })} - /> - + + + + + + + navigation.navigate('StoreCategory', { category: category.serialize() })} + /> + + + {categories.map((category, index) => ( + navigation.navigate('StoreCategory', { category: category.serialize() })} /> + ))} + + + ); }; diff --git a/src/screens/StoreMapScreen.tsx b/src/screens/StoreMapScreen.tsx index 81b160b..8c2ae44 100644 --- a/src/screens/StoreMapScreen.tsx +++ b/src/screens/StoreMapScreen.tsx @@ -1,15 +1,22 @@ import React, { useEffect, useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native'; -import { Stack, Text, YStack, useTheme } from 'tamagui'; +import { StyleSheet } from 'react-native'; +import MapView, { Marker } from 'react-native-maps'; +import { YStack } from 'tamagui'; +import { getLocationFromRouteOrStorage } from '../utils/location'; -const StoreMapScreen = () => { - const theme = useTheme(); +const StoreMapScreen = ({ route }) => { + 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, + }); return ( - - - + + + ); }; diff --git a/src/screens/StoreOnboardScreen.tsx b/src/screens/StoreOnboardScreen.tsx new file mode 100644 index 0000000..50b79f3 --- /dev/null +++ b/src/screens/StoreOnboardScreen.tsx @@ -0,0 +1,16 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native'; +import { Stack, Text, YStack, useTheme } from 'tamagui'; + +const StoreOnboardScreen = () => { + const theme = useTheme(); + + return ( + + + + ); +}; + +export default StoreOnboardScreen; diff --git a/src/screens/StoreSearchScreen.tsx b/src/screens/StoreSearchScreen.tsx index c2978b4..8c54ef4 100644 --- a/src/screens/StoreSearchScreen.tsx +++ b/src/screens/StoreSearchScreen.tsx @@ -1,15 +1,243 @@ -import React, { useEffect, useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native'; -import { Stack, Text, YStack, useTheme } from 'tamagui'; +import React, { useEffect, useState, useRef } from 'react'; +import { SafeAreaView, Keyboard, Animated, StyleSheet } from 'react-native'; +import { Spinner, Button, Stack, Text, YStack, XStack, Input, useTheme } from 'tamagui'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faMagnifyingGlass, faArrowLeft, faCircleXmark } from '@fortawesome/free-solid-svg-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import StoreTagCloud from '../components/StoreTagCloud'; +import ProductCard from '../components/ProductCard'; +import useStorefrontInfo from '../hooks/use-storefront-info'; +import useStorefront from '../hooks/use-storefront'; +import { debounce, delay } from '../utils'; +import { pluralize } from 'inflected'; -const StoreSearch = () => { +const StoreSearch = (route = {}) => { const theme = useTheme(); + const insets = useSafeAreaInsets(); + const { info } = useStorefrontInfo(); + const { storefront } = useStorefront(); + const [searchQuery, setSearchQuery] = useState(''); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [inputFocused, setInputFocused] = useState(false); + const [tagCloudHeight, setTagCloudHeight] = useState('auto'); + const searchInput = useRef(null); + + // Animated values for the tag cloud + const tagCloudTranslateY = useRef(new Animated.Value(0)).current; + const tagCloudOpacity = useRef(new Animated.Value(1)).current; + + // Debounced search function + const performSearch = debounce(async (query) => { + if (!query.trim()) { + setResults([]); + setIsLoading(false); + return; + } + setIsLoading(true); + try { + const results = await storefront.search(query, { store: info.id }); + setResults(results); + } catch (error) { + console.error('Error searching:', error); + } finally { + setIsLoading(false); + } + }, 300); + + const setTag = (tag) => { + setSearchQuery(tag); + handleFocus(); + if (searchInput.current && typeof searchInput.current.focus === 'function') { + searchInput.current.focus(); + } + }; + + const handleFocus = () => { + setInputFocused(true); + // Animate tag cloud away + Animated.parallel([ + Animated.timing(tagCloudTranslateY, { + toValue: -50, // Slide up + duration: 300, + useNativeDriver: true, + }), + Animated.timing(tagCloudOpacity, { + toValue: 0, // Fade out + duration: 300, + useNativeDriver: true, + }), + ]).start(); + }; + + const handleBlur = () => { + setInputFocused(false); + // Animate tag cloud back + Animated.parallel([ + Animated.timing(tagCloudTranslateY, { + toValue: 0, // Reset position + duration: 300, + useNativeDriver: true, + }), + Animated.timing(tagCloudOpacity, { + toValue: 1, // Fade back in + duration: 300, + useNativeDriver: true, + }), + ]).start(); + }; + + const handleClearInput = () => setSearchQuery(''); + + const handleDismissFocus = () => { + setInputFocused(false); + Keyboard.dismiss(); + if (searchInput.current && typeof searchInput.current.blur === 'function') { + searchInput.current.blur(); + } + }; + + useEffect(() => { + performSearch(searchQuery); + }, [searchQuery]); return ( - - - + + + + + {inputFocused ? ( + + ) : ( + + + + )} + + + {inputFocused && ( + + )} + + + + + + + + {inputFocused && ( + + {isLoading ? ( + + + + ) : results.length ? ( + + + Found {results.length} {pluralize('result', results.length)} for "{searchQuery}" + + {results.map((result, index) => ( + + ))} + + ) : searchQuery.trim() ? ( + + + No results found for "{searchQuery}" + + + ) : ( + + + Search for products, categories, or more! + + + )} + + )} + ); }; diff --git a/src/utils/animation.js b/src/utils/animation.js new file mode 100644 index 0000000..ca8d83d --- /dev/null +++ b/src/utils/animation.js @@ -0,0 +1,28 @@ +import { Animated } from 'react-native'; +import LocationPicker from '../components/LocationPicker'; + +export function createHiddenLocationPickerAnimation(navigation, scrollY, headerHeight, additionalOptions = {}) { + scrollY.addListener(({ value }) => { + const opacity = Math.max(0, 1 - value / headerHeight); + const translateY = Math.min(0, -value); + + navigation.setOptions({ + headerLeft: () => { + return ( + navigation.navigate('StoreHomeTab', { screen: 'AddNewLocation' })} + /> + ); + }, + }); + }); +} diff --git a/src/utils/cart.js b/src/utils/cart.js index d1e765b..90b1149 100644 --- a/src/utils/cart.js +++ b/src/utils/cart.js @@ -1,6 +1,7 @@ import { Cart } from '@fleetbase/storefront'; import { adapter } from '../hooks/use-storefront'; import { getMap } from './storage'; +import { isArray } from './'; export function canAddProductToCart(cart, product, currentStore = null) { const info = getMap('info'); @@ -31,3 +32,102 @@ export function getCartContents() { export function getCartCount() { return getCartContents().length; } + +export function productInCart(product) { + const contents = getCartContents(); + if (contents) { + return contents.some((item) => item.product_id === product.id); + } + + return false; +} + +export function getProductCartItem(product) { + const contents = getCartContents(); + if (contents) { + return contents.find((item) => item.product_id === product.id); + } + + return null; +} + +export function calculateProductSubtotal(product, variations = {}, addons = {}) { + // Start with the base product price + let sum = Number(product.isOnSale ? product.getAttribute('sale_price') : product.getAttribute('price')) || 0; + + // Add variation costs + for (const variationId in variations) { + const variant = variations[variationId]; + if (variant && variant.additional_cost) { + sum += Number(variant.additional_cost) || 0; + } + } + + // Add addon costs + for (const addonCategoryId in addons) { + if (Array.isArray(addons[addonCategoryId])) { + addons[addonCategoryId].forEach((addon) => { + if (addon) { + const addonCost = addon.is_on_sale ? addon.sale_price : addon.price; + sum += Number(addonCost) || 0; + } + }); + } + } + + return sum; +} + +export function cartHasProduct(product) { + const cart = getCart(); + return cart ? cart.hasProduct(product.id) : false; +} + +export function getProductCartItem(product) { + const contents = getCartContents(); + if (contents) { + return contents.find((item) => item.product_id === product.id); + } + + return null; +} + +export function getCartItem(cartItemId) { + const contents = getCartContents(); + if (contents) { + return contents.find((item) => item.id === cartItemId); + } + + return null; +} + +export function calculateCartTotal(cart = null) { + cart = cart === null ? getCart() : cart; + let subtotal = cart.subtotal(); + + if (cart.isEmpty) { + return 0; + } + + // if (isTipping) { + // if (typeof tip === 'string' && tip.endsWith('%')) { + // subtotal += calculatePercentage(parseInt(tip), cart.subtotal()); + // } else { + // subtotal += tip; + // } + // } + + // if (isTippingDriver && !isPickupOrder) { + // if (typeof deliveryTip === 'string' && deliveryTip.endsWith('%')) { + // subtotal += calculatePercentage(parseInt(deliveryTip), cart.subtotal()); + // } else { + // subtotal += deliveryTip; + // } + // } + + // if (isPickupOrder) { + // return subtotal; + // } + + return subtotal; +} diff --git a/src/utils/index.js b/src/utils/index.js index 202cf90..eb76efd 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,6 +1,10 @@ import Config from 'react-native-config'; -import { getString } from './storage'; +import { Collection } from '@fleetbase/sdk'; +import { lookup } from '@fleetbase/storefront'; +import storage, { getString } from './storage'; +import { adapter, instance as storefrontInstance } from '../hooks/use-storefront'; import { themes } from '../../tamagui.config'; +import { pluralize } from 'inflected'; export function get(target, path, defaultValue = null) { let current = target; @@ -49,6 +53,18 @@ export function isObject(target) { return target && typeof target === 'object' && Object.prototype.toString.call(target) === '[object Object]'; } +export function isEmpty(target) { + if (isArray(target)) { + return target.length === 0; + } + + if (isObject(target)) { + return Object.keys(target).length === 0; + } + + return target === null || target === undefined; +} + export function isResource(target, type = null) { if (typeof type === 'string') { return hasResouceProperties(target) && target.resource === type; @@ -57,8 +73,16 @@ export function isResource(target, type = null) { return hasResouceProperties(target); } +export function isSerializedResource(target) { + return isObject(target) && hasProperties(target, ['id', 'created_at'], true); +} + +export function isPojoResource(target) { + return isObject(target) && hasProperties(target, ['adapter', 'resource', 'attributes'], true); +} + export function hasResouceProperties(target) { - return hasProperties(target, ['id', 'serialize', 'resource'], true); + return isObject(target) && hasProperties(target, ['id', 'serialize', 'resource'], true); } export function isNone(target) { @@ -120,3 +144,82 @@ export function defaults(object, defs) { } return object; } + +export function restoreStorefrontInstance(data, type = null) { + // If serialized resource object + if (isSerializedResource(data) && type) { + return lookup('resource', type, data, adapter); + } + + // If POJO resource object + if (isPojoResource(data)) { + return lookup('resource', type ? type : data.resource, data.attributes, adapter); + } + + // If array of resources + if (isArray(data) && data.length) { + const isCollectionData = data.every((_resource) => _resource && isObject(_resource.attributes)); + if (isCollectionData) { + const collectionData = data.map(({ resource, attributes }) => lookup('resource', resource, attributes, adapter)); + return new Collection(collectionData); + } + } + + return data; +} + +export async function loadPersistedResource(request, options = {}) { + const { persistKey = null, type = null, defaultValue = null } = options; + + try { + if (persistKey) { + // Retrieve data using `getMultipleItems` for flexibility + const results = await storage.getMultipleItems([persistKey], 'map'); + const dataPair = results.find((item) => item[0] === persistKey); + + if (dataPair && dataPair[1]) { + console.log(`[loadPersistedResource] Found persisted data for key: ${persistKey}`); + + // Use `restoreStorefrontInstance` to process the data + return restoreStorefrontInstance(dataPair[1], type) || defaultValue; + } + } + + // Fetch data from the request function if not found in storage + console.log(`[loadPersistedResource] Fetching data from request for key: ${persistKey}`); + const fetchedData = await request(storefrontInstance); + + // Optional: Save fetched data to storage for future use + if (persistKey && fetchedData) { + if (isArray(fetchedData)) { + storage.setArray(persistKey, fetchedData); + } else { + storage.setMap(persistKey, fetchedData); + } + } + + return restoreStorefrontInstance(fetchedData, type) || defaultValue; + } catch (error) { + console.error('[loadPersistedResource] Error loading resource:', error); + return defaultValue; // Ensure a fallback value + } +} + +export async function delay(ms = 300, callback = null) { + return new Promise((resolve) => { + setTimeout(() => { + if (typeof callback === 'function') { + callback(); + } + resolve(true); + }, ms); + }); +} + +export function debounce(func, delay) { + let timeoutId; + return (...args) => { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), delay); + }; +} diff --git a/src/utils/product.js b/src/utils/product.js new file mode 100644 index 0000000..508ffca --- /dev/null +++ b/src/utils/product.js @@ -0,0 +1,132 @@ +import { isArray } from './'; + +export function createAddonSelectionDefaults(product) { + const addonDefaults = {}; + const addonCategories = product.addons() || []; + addonCategories.forEach((addonCategory) => { + addonDefaults[addonCategory.id] = []; + }); + + return addonDefaults; +} + +export function updateAddonSelection(checked, addon, addonCategoryId, currentSelection) { + // Ensure the category exists in the selection state + const categorySelection = currentSelection[addonCategoryId] || []; + + // Add or remove addon based on the checked status + if (checked) { + // Prevent duplicates + if (!categorySelection.some((selectedAddon) => selectedAddon.id === addon.id)) { + return { + ...currentSelection, + [addonCategoryId]: [...categorySelection, addon], + }; + } + } else { + return { + ...currentSelection, + [addonCategoryId]: categorySelection.filter((selectedAddon) => selectedAddon.id !== addon.id), + }; + } + + return currentSelection; // Return unchanged if no modifications are made +} + +export function selectAddon(checked, addon, addonCategory, setSelectedAddons) { + if (!addon || !addonCategory) { + console.warn('Addon or category is missing in selectAddon function'); + return; + } + + setSelectedAddons((prevSelectedAddons) => updateAddonSelection(checked, addon, addonCategory.id, prevSelectedAddons)); +} + +export function isAddonSelected(addonId, selectedAddons) { + for (const categoryId in selectedAddons) { + if (selectedAddons[categoryId].some((addon) => addon.id === addonId)) { + return true; + } + } + return false; +} + +export function createVariationSelectionDefaults(product) { + const variationDefaults = {}; + const variations = product.variants() || []; + variations.forEach((variation) => { + variationDefaults[variation.id] = null; + }); + + return variationDefaults; +} + +export function selectVariant(currentSelection, variation, variant) { + return { + ...currentSelection, + [variation.id]: variant, + }; +} + +export function getVariantOptionById(variantOptionId, product) { + const variations = product.variants() || []; + + for (const variation of variations) { + if (isArray(variation?.options)) { + const variationOption = variation.options.find((option) => option.id === variantOptionId); + if (variationOption) { + return variationOption; // Return immediately when a match is found + } + } + } + + return null; +} + +export function getSelectedVariantId(variation, selectedVariations) { + // Check if the variation exists in the selected variations and return the id + return selectedVariations[variation.id]?.id || null; +} + +export function isVariantSelected(variationId, selectedVariations) { + return !!selectedVariations[variationId]; +} + +export function getSelectedVariants(selectedVariants) { + return Object.values(selectedVariants); +} + +export function getSelectedAddons(selectedAddons) { + return Object.values(selectedAddons).flatMap((categoryAddons) => categoryAddons); +} + +export function isProductReadyForCheckout(product, selectedVariants) { + // Ensure all required variants are selected + return product.variants().every((variant) => !variant.is_required || selectedVariants[variant.id]); +} + +export function productHasOptions(product) { + return product.addons().length > 0 || product.variants().length > 0; +} + +export function getVariantSelectionsFromCartItem(cartItem, product) { + const productVariations = product.variants() || []; + const cartVariants = cartItem.variants || []; + + return productVariations.reduce((selections, variation) => { + const selectedVariant = cartVariants.find((variant) => variation.options.some((option) => option.id === variant.id)); + selections[variation.id] = selectedVariant || null; + return selections; + }, {}); +} + +export function getAddonSelectionsFromCartItem(cartItem, product) { + const productAddonCategories = product.addons() || []; + const cartAddons = cartItem.addons || []; + + return productAddonCategories.reduce((selections, category) => { + const selectedAddons = cartAddons.filter((addon) => category.addons.some((categoryAddon) => categoryAddon.id === addon.id)); + selections[category.id] = selectedAddons; + return selections; + }, {}); +}