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 (
-
- min ? 'primary' : 'gray'} {...incrementButtonProps}>
+
+ min ? 'primary' : 'gray'}
+ hoverStyle={{
+ scale: 0.75,
+ opacity: 0.5,
+ }}
+ pressStyle={{
+ scale: 0.75,
+ opacity: 0.5,
+ }}
+ {...incrementButtonProps}
+ >
@@ -42,7 +81,22 @@ const QuantityButton = ({ quantity = 1, onIncrement, onDecrement, onChange, min
{currentQuantity}
- = max} theme={currentQuantity < max ? 'primary' : 'gray'} {...decrementButtonProps}>
+ = max || disabled}
+ theme={currentQuantity < max ? 'primary' : 'gray'}
+ hoverStyle={{
+ scale: 0.75,
+ opacity: 0.5,
+ }}
+ pressStyle={{
+ scale: 0.75,
+ opacity: 0.5,
+ }}
+ {...decrementButtonProps}
+ >
diff --git a/src/components/StoreCategoriesGrid.tsx b/src/components/StoreCategoriesGrid.tsx
index e0a54a6..61289f2 100644
--- a/src/components/StoreCategoriesGrid.tsx
+++ b/src/components/StoreCategoriesGrid.tsx
@@ -36,8 +36,8 @@ const StoreCategoriesGrid = ({
// Calculate item width with fixed padding
const screenWidth = Dimensions.get('window').width;
- const sidePadding = 16; // Padding on each side
- const itemSpacing = 8; // Space between items
+ const sidePadding = 16;
+ const itemSpacing = 8;
const totalItemWidth = screenWidth - sidePadding * 2 - itemSpacing * (adjustedCategoriesPerRow - 1);
const itemWidth = itemContainerWidth ? itemContainerWidth : totalItemWidth / adjustedCategoriesPerRow;
const rows = organizeCategoriesIntoRows(categories, adjustedCategoriesPerRow);
@@ -49,14 +49,25 @@ const StoreCategoriesGrid = ({
};
return (
-
+
{rows.map((row, rowIndex) => (
{row.map((category, index) => (
handleCategoryPress(category)}>
-
+
-
+
{category.getAttribute('name')}
diff --git a/src/components/StoreHeader.tsx b/src/components/StoreHeader.tsx
index 9aadd94..7f62214 100644
--- a/src/components/StoreHeader.tsx
+++ b/src/components/StoreHeader.tsx
@@ -1,11 +1,11 @@
import React from 'react';
-import { Image } from 'tamagui';
-import { YStack, Text, XStack } from 'tamagui';
+import { Animated } from 'react-native';
+import { YStack, Text, XStack, Image } from 'tamagui';
import LinearGradient from 'react-native-linear-gradient';
-const StoreHeader = ({ storeName, description, logoUrl, backgroundUrl }) => {
+const StoreHeader = ({ storeName, description, logoUrl, backgroundUrl, height = 200, wrapperStyle = {} }) => {
return (
-
+
{
/>
)}
-
+
{storeName}
-
- {description}
-
+ {description && (
+
+ {description}
+
+ )}
{
width: '100%',
}}
/>
-
+
);
};
diff --git a/src/components/StoreTagCloud.tsx b/src/components/StoreTagCloud.tsx
new file mode 100644
index 0000000..8c13211
--- /dev/null
+++ b/src/components/StoreTagCloud.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { Animated, TouchableWithoutFeedback, StyleSheet } from 'react-native';
+import { Text, YStack, XStack, useTheme } from 'tamagui';
+
+const StoreTagCloud = ({ tags = [], maxTags = 20, onTagPress = () => {}, gap = '$2', padding = '$3', fontColor }) => {
+ const theme = useTheme();
+
+ // Limit displayed tags
+ const displayedTags = tags.slice(0, maxTags);
+
+ return (
+
+
+ {displayedTags.map((tag, index) => (
+ onTagPress(tag)} theme={theme} color={fontColor ?? theme.textSecondary.val} />
+ ))}
+
+
+ );
+};
+
+export const Tag = ({ label, onPress, theme, fontSize = '$5', color }) => {
+ const scale = new Animated.Value(1);
+ const opacity = new Animated.Value(1);
+
+ const handlePressIn = () => {
+ Animated.parallel([
+ Animated.spring(scale, {
+ toValue: 1.1,
+ useNativeDriver: true,
+ }),
+ Animated.timing(opacity, {
+ toValue: 0.8,
+ duration: 100,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ };
+
+ const handlePressOut = () => {
+ Animated.parallel([
+ Animated.spring(scale, {
+ toValue: 1,
+ useNativeDriver: true,
+ }),
+ Animated.timing(opacity, {
+ toValue: 1,
+ duration: 100,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ };
+
+ return (
+
+
+
+ {label}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ tag: {
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ borderRadius: 16,
+ margin: 4,
+ justifyContent: 'center',
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.2,
+ shadowRadius: 2,
+ },
+});
+
+export default StoreTagCloud;
diff --git a/src/hooks/use-cart.ts b/src/hooks/use-cart.ts
index 348999b..490194d 100644
--- a/src/hooks/use-cart.ts
+++ b/src/hooks/use-cart.ts
@@ -3,14 +3,14 @@ import { EventRegister } from 'react-native-event-listeners';
import { getUniqueId } from 'react-native-device-info';
import { Cart } from '@fleetbase/storefront';
import useStorage from './use-storage';
-import useStorefront from './use-storefront';
+import useStorefront, { adapter } from './use-storefront';
const { emit } = EventRegister;
-const useCart = () => {
+const useCart = (cartId = null) => {
const { storefront } = useStorefront();
const [storedCart, setStoredCart] = useStorage('cart');
- const [cart, setCart] = useState(null); // Actual Cart instance
+ const [cart, setCart] = useState(storedCart ? new Cart(storedCart, adapter) : new Cart({ items: [] }), adapter); // Actual Cart instance
// Initialize the Cart instance when storefront and storedCart are available
useEffect(() => {
@@ -19,14 +19,15 @@ const useCart = () => {
// Load the cart from server
const loadCartFromServer = async () => {
- const deviceId = await getUniqueId();
- const cartInstance = await storefront.cart.retrieve(deviceId);
+ cartId = cartId ? cartId : await getUniqueId();
+ const cartInstance = await storefront.cart.retrieve(cartId);
setCart(cartInstance);
+ setStoredCart(cartInstance.serialize());
};
// Load the cart from storage
const loadCartFromStorage = () => {
- const cartInstance = new Cart(storedCart, storefront.getAdapter());
+ const cartInstance = new Cart(storedCart, adapter);
setCart(cartInstance);
};
@@ -49,7 +50,7 @@ const useCart = () => {
}
// Ensure we always have a Cart instance
- const cartInstance = newCart instanceof Cart ? newCart : new Cart(newCart, storefront.getAdapter());
+ const cartInstance = newCart instanceof Cart ? newCart : new Cart(newCart, adapter);
// Persist serialized cart and update state
setStoredCart(cartInstance.serialize());
diff --git a/src/hooks/use-promise-with-loading.ts b/src/hooks/use-promise-with-loading.ts
new file mode 100644
index 0000000..6e7a192
--- /dev/null
+++ b/src/hooks/use-promise-with-loading.ts
@@ -0,0 +1,31 @@
+import { useState, useCallback } from 'react';
+
+export function usePromiseWithLoading() {
+ const [loadingStates, setLoadingStates] = useState({});
+
+ const runWithLoading = useCallback(async (promise, loadingKey = 'default') => {
+ setLoadingStates((prev) => ({ ...prev, [loadingKey]: true }));
+ try {
+ const result = await promise;
+ return result;
+ } finally {
+ setLoadingStates((prev) => ({ ...prev, [loadingKey]: false }));
+ }
+ }, []);
+
+ const isLoading = useCallback(
+ (loadingKey = 'default') => {
+ return !!loadingStates[loadingKey];
+ },
+ [loadingStates]
+ );
+
+ const isAnyLoading = useCallback(() => {
+ const anyLoading = Object.values(loadingStates).some((_) => _ === true);
+ return anyLoading;
+ }, [loadingStates]);
+
+ return { runWithLoading, isLoading, isAnyLoading };
+}
+
+export default usePromiseWithLoading;
diff --git a/src/hooks/use-storefront-data.ts b/src/hooks/use-storefront-data.ts
index 11396d9..6d801c1 100644
--- a/src/hooks/use-storefront-data.ts
+++ b/src/hooks/use-storefront-data.ts
@@ -3,30 +3,10 @@ import { Collection } from '@fleetbase/sdk';
import { lookup } from '@fleetbase/storefront';
import useStorefront from './use-storefront';
import useStorage from './use-storage';
-import { isObject, isArray, isResource } from '../utils';
-
-export const restoreStorefrontResource = (data) => {
- // If array of resources
- if (isArray(data) && data.length) {
- const isCollectionData = data.every((resource) => resource && isObject(resource.attributes));
- const resourceType = data[0].resource;
-
- if (isCollectionData) {
- const collectionData = data.map(({ attributes, resource: type }) => lookup('resource', type, attributes));
- return new Collection(collectionData);
- }
- }
-
- // If single resource
- if (isResource(data)) {
- return lookup('resource', data.resource, data.attributes);
- }
-
- return data;
-};
+import { isObject, isArray, isResource, restoreStorefrontInstance } from '../utils';
const useStorefrontData = (sdkMethod, onDataLoaded, options = {}) => {
- const { persistKey, defaultValue = null, dependencies = [] } = isObject(onDataLoaded) ? onDataLoaded : options;
+ const { persistKey, defaultValue = null, dependencies = [], restoreType = null } = isObject(onDataLoaded) ? onDataLoaded : options;
const { storefront } = useStorefront();
// Use either useState or useStorage depending on whether persistKey is provided
@@ -56,7 +36,7 @@ const useStorefrontData = (sdkMethod, onDataLoaded, options = {}) => {
fetchData();
}, [storefront, ...dependencies]); // Watch dependencies
- return { data: persistKey ? restoreStorefrontResource(data) : data, error, loading };
+ return { data: persistKey ? restoreStorefrontInstance(data, restoreType) : data, error, loading };
};
export default useStorefrontData;
diff --git a/src/navigation/StoreNavigator.tsx b/src/navigation/StoreNavigator.tsx
index b0d07aa..5390749 100644
--- a/src/navigation/StoreNavigator.tsx
+++ b/src/navigation/StoreNavigator.tsx
@@ -11,6 +11,7 @@ import { StoreHome, StoreSearch, StoreMap, StoreCategory } from './stacks/StoreS
import { PortalHost } from '@gorhom/portal';
import LocationStack from './stacks/LocationStack';
import CartScreen from '../screens/CartScreen';
+import CartItemScreen from '../screens/CartItemScreen';
import ProfileScreen from '../screens/ProfileScreen';
import ProductScreen from '../screens/ProductScreen';
import LocationPicker from '../components/LocationPicker';
@@ -24,6 +25,10 @@ const StoreHomeTab = createNativeStackNavigator({
StoreCategory,
Product: {
screen: ProductScreen,
+ options: {
+ presentation: 'modal',
+ headerShown: false,
+ },
},
...LocationStack,
},
@@ -33,6 +38,13 @@ const StoreSearchTab = createNativeStackNavigator({
initialRouteName: 'StoreSearch',
screens: {
StoreSearch,
+ Product: {
+ screen: ProductScreen,
+ options: {
+ presentation: 'modal',
+ headerShown: false,
+ },
+ },
},
});
@@ -48,6 +60,16 @@ const StoreCartTab = createNativeStackNavigator({
screens: {
Cart: {
screen: CartScreen,
+ options: {
+ headerShown: false,
+ },
+ },
+ CartItem: {
+ screen: CartItemScreen,
+ options: {
+ presentation: 'modal',
+ headerShown: false,
+ },
},
},
});
diff --git a/src/navigation/stacks/StoreStack.tsx b/src/navigation/stacks/StoreStack.tsx
index b7f1877..0e8dc51 100644
--- a/src/navigation/stacks/StoreStack.tsx
+++ b/src/navigation/stacks/StoreStack.tsx
@@ -13,14 +13,19 @@ export const StoreHome = {
return {
title: '',
headerLeft: () => {
- return navigation.navigate('StoreHomeTab', { screen: 'AddNewLocation' })} />;
+ return (
+ navigation.navigate('StoreHomeTab', { screen: 'AddNewLocation' })}
+ />
+ );
},
headerTransparent: true,
headerShadowVisible: false,
gestureEnabled: false,
animation: 'none',
headerStyle: {
- backgroundColor: 'transparent',
+ transform: [{ translateY: -15 }],
},
};
},
@@ -57,14 +62,7 @@ export const StoreSearch = {
screen: StoreSearchScreen,
options: ({ route }) => {
return {
- title: 'Search',
- headerShadowVisible: false,
- headerBlurEffect: 'regular',
- gestureEnabled: false,
- animation: 'none',
- headerStyle: {
- backgroundColor: getTheme('background'),
- },
+ headerShown: false,
};
},
};
diff --git a/src/screens/BootScreen.tsx b/src/screens/BootScreen.tsx
index fecb3df..ac3850d 100644
--- a/src/screens/BootScreen.tsx
+++ b/src/screens/BootScreen.tsx
@@ -14,7 +14,6 @@ const BootScreen = () => {
const { storefront, error: storefrontError, hasStorefrontConfig } = useStorefront();
const [info, setInfo] = useStorage('info', {});
const navigation = useNavigation();
- console.log('BootScreen');
useEffect(() => {
const checkLocationPermission = async () => {
@@ -47,7 +46,6 @@ const BootScreen = () => {
if (!storefront) return;
const info = await storefront.about();
- console.log('About this Storefront', info);
setInfo(info);
// Navigate based on storefront type
diff --git a/src/screens/CartItemScreen.tsx b/src/screens/CartItemScreen.tsx
new file mode 100644
index 0000000..0e93c6a
--- /dev/null
+++ b/src/screens/CartItemScreen.tsx
@@ -0,0 +1,211 @@
+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, faTrash } from '@fortawesome/free-solid-svg-icons';
+import { useNavigation } from '@react-navigation/native';
+import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
+import { restoreStorefrontInstance, isEmpty } from '../utils';
+import { formatCurrency } from '../utils/format';
+import { calculateProductSubtotal, getCartItem } from '../utils/cart';
+import { isProductReadyForCheckout, getSelectedVariants, getSelectedAddons, getAddonSelectionsFromCartItem, getVariantSelectionsFromCartItem } 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 CartItemScreen = ({ route = {} }) => {
+ const theme = useTheme();
+ const navigation = useNavigation();
+ const { runWithLoading, isLoading } = usePromiseWithLoading();
+ const [cart, updateCart] = useCart();
+ const [cartItem, setCartItem] = useState(route.params.cartItem);
+ const [product, setProduct] = useState(restoreStorefrontInstance(route.params.product, 'product'));
+ const [selectedAddons, setSelectedAddons] = useState(getAddonSelectionsFromCartItem(cartItem, product));
+ const [selectedVariants, setSelectedVariants] = useState(getVariantSelectionsFromCartItem(cartItem, product));
+ const [subtotal, setSubtotal] = useState(0);
+ const [quantity, setQuantity] = useState(cartItem.quantity ?? 1);
+ const [ready, setReady] = useState(false);
+
+ const isService = product && product.getAttribute('is_service') === true;
+
+ useEffect(() => {
+ if (product) {
+ setSubtotal(calculateProductSubtotal(product, selectedVariants, selectedAddons));
+ setReady(isProductReadyForCheckout(product, selectedVariants));
+ }
+ }, [product, selectedAddons, selectedVariants]);
+
+ useEffect(() => {
+ if (product) {
+ if (isEmpty(selectedVariants)) {
+ setSelectedVariants(getVariantSelectionsFromCartItem(cartItem, product));
+ }
+
+ if (isEmpty(selectedAddons)) {
+ setSelectedAddons(getAddonSelectionsFromCartItem(cartItem, product));
+ }
+
+ setReady(isProductReadyForCheckout(product, selectedVariants));
+ }
+ }, [product, cartItem]);
+
+ const handleClose = () => {
+ navigation.goBack();
+ };
+
+ const handleRemoveFromCart = async () => {
+ try {
+ const updatedCart = await runWithLoading(cart.remove(cartItem.id), 'removeCartItem');
+ updateCart(updatedCart);
+ toast.success(`${product.getAttribute('name')} removed from cart.`, { position: ToastPosition.BOTTOM });
+ navigation.goBack();
+ } catch (error) {
+ toast.error('Failed to remove item from cart', { position: ToastPosition.BOTTOM });
+ console.error('Error removing cart item:', error.message);
+ }
+ };
+
+ const handleAddToCart = async () => {
+ if (!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.update(cartItem.id, quantity, { addons, variants }), 'updateCart');
+ updateCart(updatedCart);
+ toast.success(`${product.getAttribute('name')} updated in 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')}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {isLoading('updateCart') && (
+
+
+
+ )}
+
+ Update
+
+
+ {formatCurrency(subtotal * quantity, product.getAttribute('currency'))}
+
+
+
+
+
+ {isLoading('removeCartItem') ? : }
+
+
+
+
+ );
+};
+
+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
-
- Empty Cart
-
-
- Reload Cart
-
+
+
+
+ Order items ({displayedItems.length})
+
+ {isAnyLoading() && (
+
+
+
+ )}
+
+
+
+
+ Empty Cart
+
+
+
+
+ item.id} contentContainerStyle={{ paddingBottom: 16 }} />
+ {cart.isNotEmpty && (
+
+
+
+
+ {formatCurrency(calculateCartTotal(), cart.getAttribute('currency'))}
+
+
+
+
+
+ Checkout
+
+
+
+
)}
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')}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {isLoading('addToCart') && (
+
+
+
+ )}
+
+ Add to Cart
+
+
+ {formatCurrency(subtotal * quantity, product.getAttribute('currency'))}
+
+
+
+
+
);
};
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 (
-
+
+ Clear Cache
+
);
};
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;
+ }, {});
+}