Initial commit
Generated by create-expo-app 3.2.0.
@@ -0,0 +1,45 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
ios
|
||||||
|
android
|
||||||
|
.idea
|
||||||
|
.windsurfrules
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Native
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
app-example
|
||||||
|
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Welcome to your Expo app 👋
|
||||||
|
|
||||||
|
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
In the output, you'll find options to open the app in a
|
||||||
|
|
||||||
|
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||||
|
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
|
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||||
|
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||||
|
|
||||||
|
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||||
|
|
||||||
|
## Get a fresh project
|
||||||
|
|
||||||
|
When you're ready, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run reset-project
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
To learn more about developing your project with Expo, look at the following resources:
|
||||||
|
|
||||||
|
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||||
|
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||||
|
|
||||||
|
## Join the community
|
||||||
|
|
||||||
|
Join our community of developers creating universal apps.
|
||||||
|
|
||||||
|
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||||
|
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "shoppingapp",
|
||||||
|
"slug": "shoppingapp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "mnkyshop",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": false,
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.anonymous.shoppingapp",
|
||||||
|
"appleTeamId": "8AF5S7C42H"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"package": "com.anonymous.shoppingapp"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 200,
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@sentry/react-native/expo",
|
||||||
|
{
|
||||||
|
"url": "https://sentry.io/",
|
||||||
|
"project": "shoppingapp",
|
||||||
|
"organization": "secure-whisper"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import CardButton from "@/components/CartButton";
|
||||||
|
import { useReactQueryDevTools } from "@dev-plugins/react-query";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
router,
|
||||||
|
Stack,
|
||||||
|
useNavigationContainerRef,
|
||||||
|
useRouter,
|
||||||
|
} from "expo-router";
|
||||||
|
import { useMMKVDevTools } from "@dev-plugins/react-native-mmkv";
|
||||||
|
import { storage } from "@/store/mmkv";
|
||||||
|
import * as Sentry from "@sentry/react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { TouchableOpacity } from "react-native";
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import { NavigationContainerRef } from "@react-navigation/native";
|
||||||
|
|
||||||
|
const navigationIntegration = Sentry.reactNavigationIntegration({
|
||||||
|
enableTimeToInitialDisplay: true, // Only in native builds, not in Expo Go.
|
||||||
|
});
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: "https://8af0004917f7c8cfb06b2beac6fd3a66@o4507486642765824.ingest.de.sentry.io/4508981409808464",
|
||||||
|
attachScreenshot: true,
|
||||||
|
debug: false,
|
||||||
|
tracesSampleRate: 1.0, // Adjust this value in production
|
||||||
|
_experiments: {
|
||||||
|
profilesSampleRate: 1.0, // Only during debugging, change to lower value in production
|
||||||
|
replaysSessionSampleRate: 1.0, // Only during debugging, change to lower value in production
|
||||||
|
replaysOnErrorSampleRate: 1,
|
||||||
|
},
|
||||||
|
integrations: [
|
||||||
|
Sentry.mobileReplayIntegration({
|
||||||
|
maskAllText: false,
|
||||||
|
maskAllImages: true,
|
||||||
|
maskAllVectors: false,
|
||||||
|
}),
|
||||||
|
Sentry.spotlightIntegration(),
|
||||||
|
navigationIntegration,
|
||||||
|
],
|
||||||
|
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
|
||||||
|
// spotlight: __DEV__,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const RootLayout = () => {
|
||||||
|
useReactQueryDevTools(queryClient);
|
||||||
|
useMMKVDevTools({
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const navigationRef = useNavigationContainerRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigationIntegration.registerNavigationContainer(navigationRef);
|
||||||
|
}, [navigationRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<GestureHandlerRootView>
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Products",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: "Search products",
|
||||||
|
hideWhenScrolling: false,
|
||||||
|
hideNavigationBar: false,
|
||||||
|
},
|
||||||
|
headerRight: () => <CardButton />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="product/[id]" options={{ title: "Product" }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="cart"
|
||||||
|
options={{
|
||||||
|
title: "Cart",
|
||||||
|
presentation: "modal",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => router.back()}>
|
||||||
|
<Ionicons name="arrow-back" size={20} color="black" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sentry.wrap(RootLayout);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import CartItem from "@/components/CartItem";
|
||||||
|
import useCartStore from "@/store/cartStore";
|
||||||
|
import { COLORS } from "@/utils/colors";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
const { products, total, clearCart } = useCartStore();
|
||||||
|
const { bottom } = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleCheckout = () => {
|
||||||
|
if (products.length === 0) {
|
||||||
|
Alert.alert("Add some products to your cart first!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearCart();
|
||||||
|
Alert.alert("Checkout successful!");
|
||||||
|
router.dismiss();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{products.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>No products in the cart</Text>
|
||||||
|
)}
|
||||||
|
<FlatList
|
||||||
|
data={products}
|
||||||
|
contentContainerStyle={{ gap: 10 }}
|
||||||
|
renderItem={({ item }) => <CartItem item={item} />}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
ListHeaderComponent={() => (
|
||||||
|
<>
|
||||||
|
{products.length && (
|
||||||
|
<Text style={styles.totalText}>Total: ${total.toFixed(2)}</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.addToCartButton,
|
||||||
|
{ paddingBottom: Platform.OS === "ios" ? bottom : 20 },
|
||||||
|
]}
|
||||||
|
onPress={handleCheckout}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark" size={20} color="white" />
|
||||||
|
<Text style={styles.addToCartText}>Checkout</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Page;
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
totalText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
addToCartButton: {
|
||||||
|
backgroundColor: COLORS.primary,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
addToCartText: {
|
||||||
|
color: "white",
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import ProductCard from "@/components/ProductCart";
|
||||||
|
import { fetchProducts, getCategories, Product } from "@/utils/api";
|
||||||
|
import { COLORS } from "@/utils/colors";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { useHeaderHeight } from "@react-navigation/elements";
|
||||||
|
import { ProductShimmerGrid } from "@/components/ProductListShimmer";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const [selectedCategory, setSelectedCategory] = React.useState("all");
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState("");
|
||||||
|
const headerHeight = useHeaderHeight();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: products,
|
||||||
|
refetch,
|
||||||
|
isLoading,
|
||||||
|
isRefetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["products"],
|
||||||
|
queryFn: fetchProducts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: categories = [] } = useQuery({
|
||||||
|
queryKey: ["categories"],
|
||||||
|
queryFn: getCategories,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderProduct = useCallback(
|
||||||
|
({ item }: { item: Product }) => <ProductCard product={item} />,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allCategories = ["all", ...categories];
|
||||||
|
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
return products?.filter((product) => {
|
||||||
|
if (selectedCategory !== "all") {
|
||||||
|
return product.category === selectedCategory;
|
||||||
|
}
|
||||||
|
return product.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
});
|
||||||
|
}, [products, selectedCategory, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{ marginTop: Platform.select({ ios: headerHeight, android: 0 }) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
onChangeText: (e) => setSearchQuery(e.nativeEvent.text),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View style={styles.categoryContainer}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.categoryScrollView}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{allCategories.map((category) => (
|
||||||
|
<Pressable
|
||||||
|
key={category}
|
||||||
|
onPress={() => setSelectedCategory(category)}
|
||||||
|
style={[
|
||||||
|
styles.categoryButton,
|
||||||
|
selectedCategory === category && styles.selectedCategoryButton,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.categoryText,
|
||||||
|
selectedCategory === category && styles.selectedCategoryText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<ProductShimmerGrid />
|
||||||
|
) : (
|
||||||
|
<FlashList
|
||||||
|
data={filteredProducts}
|
||||||
|
renderItem={renderProduct}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
numColumns={2}
|
||||||
|
contentContainerStyle={{ padding: 8 }}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
onRefresh={refetch}
|
||||||
|
refreshing={isRefetching}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
categoryContainer: {
|
||||||
|
height: 60,
|
||||||
|
zIndex: 1,
|
||||||
|
boxShadow: "0 0 10px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
categoryScrollView: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
categoryButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
backgroundColor: COLORS.lightGray,
|
||||||
|
alignSelf: "center",
|
||||||
|
},
|
||||||
|
categoryText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: COLORS.white,
|
||||||
|
},
|
||||||
|
selectedCategoryButton: {
|
||||||
|
backgroundColor: COLORS.primary,
|
||||||
|
},
|
||||||
|
selectedCategoryText: {
|
||||||
|
color: COLORS.white,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { ProductDetailsShimmer } from "@/components/ProductDetailsShimmer";
|
||||||
|
import { fetchProduct } from "@/utils/api";
|
||||||
|
import { COLORS } from "@/utils/colors";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import useCartStore from "@/store/cartStore";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Share,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const { bottom } = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { addProduct } = useCartStore();
|
||||||
|
|
||||||
|
const { data: product, isLoading } = useQuery({
|
||||||
|
queryKey: ["product", id],
|
||||||
|
queryFn: () => fetchProduct(Number(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onShare = async () => {
|
||||||
|
const url = `mnkyshop://product/${product?.id}`;
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
await Share.share({
|
||||||
|
url,
|
||||||
|
message: `Check out this product on Galaxies Shop: ${url}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await Share.share({
|
||||||
|
message: `Check out this product on Galaxies Shop: ${url}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ProductDetailsShimmer />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return <Text>Product not found</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToCart = () => {
|
||||||
|
addProduct(product);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: product?.title,
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={onShare}>
|
||||||
|
<Ionicons name="share-outline" size={24} color={COLORS.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ScrollView>
|
||||||
|
<Image
|
||||||
|
source={{ uri: product.image }}
|
||||||
|
style={styles.image}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>{product.title}</Text>
|
||||||
|
<Text style={styles.price}>${product.price}</Text>
|
||||||
|
<Text style={styles.category}>{product.category}</Text>
|
||||||
|
<Text style={styles.description}>{product.description}</Text>
|
||||||
|
<View style={styles.ratingContainer}>
|
||||||
|
<Text style={styles.rating}>★ {product.rating.rate}</Text>
|
||||||
|
<Text style={styles.ratingCount}>
|
||||||
|
({product.rating.count} reviews)
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.addToCartButton,
|
||||||
|
{ paddingBottom: Platform.OS === "ios" ? bottom : 20 },
|
||||||
|
]}
|
||||||
|
onPress={handleAddToCart}
|
||||||
|
>
|
||||||
|
<Ionicons name="cart" size={20} color="white" />
|
||||||
|
<Text style={styles.addToCartText}>Add to Cart</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: "100%",
|
||||||
|
height: 300,
|
||||||
|
backgroundColor: "#f9f9f9",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: COLORS.primary,
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#666",
|
||||||
|
textTransform: "capitalize",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
color: "#333",
|
||||||
|
},
|
||||||
|
ratingContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFB800",
|
||||||
|
},
|
||||||
|
ratingCount: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#666",
|
||||||
|
},
|
||||||
|
addToCartButton: {
|
||||||
|
backgroundColor: COLORS.primary,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
addToCartText: {
|
||||||
|
color: "white",
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = function(api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
['babel-preset-expo', {
|
||||||
|
unstable_transformImportMeta: true
|
||||||
|
}]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { StyleSheet, Text, View, TouchableOpacity } from "react-native";
|
||||||
|
import React from "react";
|
||||||
|
import useCartStore from "@/store/cartStore";
|
||||||
|
import { COLORS } from "@/utils/colors";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
|
||||||
|
const CardButton = () => {
|
||||||
|
const { count } = useCartStore();
|
||||||
|
return (
|
||||||
|
<Link href="/cart" asChild>
|
||||||
|
<TouchableOpacity onPress={() => {}}>
|
||||||
|
{count > 0 && (
|
||||||
|
<View style={styles.countContainer}>
|
||||||
|
<Text style={styles.countText}>{count}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Ionicons name="cart" size={28} color="black" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardButton;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
countContainer: {
|
||||||
|
position: "absolute",
|
||||||
|
right: -10,
|
||||||
|
bottom: -5,
|
||||||
|
backgroundColor: COLORS.primary,
|
||||||
|
borderRadius: 10,
|
||||||
|
zIndex: 1,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.white,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { Product } from "@/utils/api";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import useCartStore from "@/store/cartStore";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import { COLORS } from "@/utils/colors";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import Reanimated, {
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSequence,
|
||||||
|
withSpring,
|
||||||
|
Easing,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
import ReanimatedSwipeable, {
|
||||||
|
SwipeableMethods,
|
||||||
|
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||||
|
|
||||||
|
interface CartItemProps {
|
||||||
|
item: Product & { quantity: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const LeftActions = (
|
||||||
|
progress: SharedValue<number>,
|
||||||
|
dragX: SharedValue<number>,
|
||||||
|
onShouldDelete: () => void
|
||||||
|
) => {
|
||||||
|
const styleAnimation = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [{ translateX: dragX.value - 100 }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Reanimated.View style={styleAnimation}>
|
||||||
|
<TouchableOpacity style={styles.leftAction} onPress={onShouldDelete}>
|
||||||
|
<Ionicons name="trash" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Reanimated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CartItem = ({ item }: CartItemProps) => {
|
||||||
|
const { addProduct, reduceProduct } = useCartStore();
|
||||||
|
const reanimatedRef = useRef<SwipeableMethods>(null);
|
||||||
|
const opacityAnim = useSharedValue(1);
|
||||||
|
const scaleAnim = useSharedValue(1);
|
||||||
|
const heightAnim = useSharedValue(80);
|
||||||
|
|
||||||
|
const handleQuantityChanged = (type: "increment" | "decrement") => {
|
||||||
|
if (type === "increment") {
|
||||||
|
addProduct(item);
|
||||||
|
} else {
|
||||||
|
reduceProduct(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
scaleAnim.value = withSequence(
|
||||||
|
withSpring(1.2, { damping: 2, stiffness: 80 }),
|
||||||
|
withSpring(1, { damping: 2, stiffness: 80 })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onShouldDelete = async () => {
|
||||||
|
opacityAnim.value = withTiming(0, {
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
});
|
||||||
|
|
||||||
|
heightAnim.value = withTiming(0, {
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
reanimatedRef.current?.close();
|
||||||
|
for (let i = 0; i < item.quantity; i++) {
|
||||||
|
reduceProduct(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const quantityAnimatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [{ scale: scaleAnim.value }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: opacityAnim.value,
|
||||||
|
height: heightAnim.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Reanimated.View style={animatedStyle}>
|
||||||
|
<ReanimatedSwipeable
|
||||||
|
ref={reanimatedRef}
|
||||||
|
leftThreshold={50}
|
||||||
|
friction={2}
|
||||||
|
containerStyle={styles.swipeable}
|
||||||
|
renderLeftActions={(progress, dragX) =>
|
||||||
|
LeftActions(progress, dragX, onShouldDelete)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.cartItemContainer}>
|
||||||
|
<Image source={{ uri: item.image }} style={styles.image} />
|
||||||
|
<View style={styles.itemContainer}>
|
||||||
|
<Text style={styles.cartItemName} numberOfLines={2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text>Price: ${item.price}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.quantityContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleQuantityChanged("decrement")}
|
||||||
|
style={styles.quantityButton}
|
||||||
|
>
|
||||||
|
<Ionicons name="remove" size={24} color="black" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Reanimated.Text
|
||||||
|
style={[styles.cartItemQuantity, quantityAnimatedStyle]}
|
||||||
|
>
|
||||||
|
{item.quantity}
|
||||||
|
</Reanimated.Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleQuantityChanged("increment")}
|
||||||
|
style={styles.quantityButton}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={24} color="black" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ReanimatedSwipeable>
|
||||||
|
</Reanimated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default CartItem;
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
cartItemContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 20,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
height: 80,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: 50,
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 10,
|
||||||
|
resizeMode: "contain",
|
||||||
|
},
|
||||||
|
itemContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cartItemName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
quantityContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
quantityButton: {
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
cartItemQuantity: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
backgroundColor: COLORS.primary,
|
||||||
|
fontSize: 16,
|
||||||
|
padding: 5,
|
||||||
|
width: 30,
|
||||||
|
color: "white",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
swipeable: {
|
||||||
|
height: 80,
|
||||||
|
},
|
||||||
|
leftAction: {
|
||||||
|
backgroundColor: "red",
|
||||||
|
width: 100,
|
||||||
|
height: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Product } from "@/utils/api";
|
||||||
|
import { COLORS } from "@/utils/colors";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, Image, View } from "react-native";
|
||||||
|
|
||||||
|
interface ProductCardProps {
|
||||||
|
product: Product;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductCard = ({ product }: ProductCardProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={styles.productCard}
|
||||||
|
onPress={() => router.push(`/product/${product.id}`)}
|
||||||
|
>
|
||||||
|
<Image source={{ uri: product.image }} style={styles.image} />
|
||||||
|
<View style={styles.productInfo}>
|
||||||
|
<Text style={styles.title} numberOfLines={2}>
|
||||||
|
{product.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.price}>{product.price}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCard;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
productCard: {
|
||||||
|
flex: 1,
|
||||||
|
margin: 0,
|
||||||
|
gap: 8,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
boxShadow: "0 0 10px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: "100%",
|
||||||
|
height: 150,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
productInfo: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: COLORS.primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { createShimmerPlaceholder } from "react-native-shimmer-placeholder";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
|
||||||
|
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient);
|
||||||
|
|
||||||
|
export function ProductDetailsShimmer() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ShimmerPlaceholder
|
||||||
|
style={styles.image}
|
||||||
|
shimmerColors={["#ebebeb", "#ddd", "#ebebeb"]}
|
||||||
|
/>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<ShimmerPlaceholder
|
||||||
|
style={styles.title}
|
||||||
|
shimmerColors={["#ebebeb", "#ddd", "#ebebeb"]}
|
||||||
|
/>
|
||||||
|
<ShimmerPlaceholder
|
||||||
|
style={styles.price}
|
||||||
|
shimmerColors={["#ebebeb", "#ddd", "#ebebeb"]}
|
||||||
|
/>
|
||||||
|
<ShimmerPlaceholder
|
||||||
|
style={styles.category}
|
||||||
|
shimmerColors={["#ebebeb", "#ddd", "#ebebeb"]}
|
||||||
|
/>
|
||||||
|
<ShimmerPlaceholder
|
||||||
|
style={styles.description}
|
||||||
|
shimmerColors={["#ebebeb", "#ddd", "#ebebeb"]}
|
||||||
|
/>
|
||||||
|
<ShimmerPlaceholder
|
||||||
|
style={styles.rating}
|
||||||
|
shimmerColors={["#ebebeb", "#ddd", "#ebebeb"]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: "100%",
|
||||||
|
height: 400,
|
||||||
|
backgroundColor: "#f9f9f9",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
height: 28,
|
||||||
|
width: "70%",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
height: 24,
|
||||||
|
width: "20%",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
height: 20,
|
||||||
|
width: "40%",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
height: 80,
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
height: 20,
|
||||||
|
width: "30%",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { View, StyleSheet, Dimensions } from "react-native";
|
||||||
|
import React from "react";
|
||||||
|
import { createShimmerPlaceholder } from "react-native-shimmer-placeholder";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
|
||||||
|
const Placeholder = createShimmerPlaceholder(LinearGradient);
|
||||||
|
|
||||||
|
const { width } = Dimensions.get("window");
|
||||||
|
const CARD_WIDTH = width * 0.43;
|
||||||
|
|
||||||
|
const ProductShimmer = () => {
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
{/* Image placeholder */}
|
||||||
|
<Placeholder
|
||||||
|
style={styles.image}
|
||||||
|
shimmerColors={["#ebebeb", "#ddd", "#ebebeb"]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content container */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Title placeholder */}
|
||||||
|
<Placeholder
|
||||||
|
style={styles.title}
|
||||||
|
shimmerColors={["#ebebeb", "#ddd", "#ebebeb"]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rating container placeholder */}
|
||||||
|
<View style={styles.ratingContainer}>
|
||||||
|
<Placeholder
|
||||||
|
style={styles.rating}
|
||||||
|
shimmerColors={["#ebebeb", "#ddd", "#ebebeb"]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProductShimmerGrid = () => {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{[...Array(6)].map((_, index) => (
|
||||||
|
<ProductShimmer key={index} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
backgroundColor: "white",
|
||||||
|
borderRadius: 12,
|
||||||
|
margin: 8,
|
||||||
|
boxShadow: "0px 2px 4px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: "100%",
|
||||||
|
height: CARD_WIDTH, // Square image
|
||||||
|
borderTopLeftRadius: 12,
|
||||||
|
borderTopRightRadius: 12,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
height: 20,
|
||||||
|
width: "85%",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
ratingContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
height: 16,
|
||||||
|
width: "30%",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
|
||||||
|
|
||||||
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
|
const config = getSentryExpoConfig(__dirname, {
|
||||||
|
annotateReactComponents: true,
|
||||||
|
enableSourceContextInDevelopment: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
|
|
||||||
|
// https://docs.sentry.io/platforms/react-native/session-replay/#react-component-names
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "shoppingapp",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"test": "jest --watchAll",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "jest-expo"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dev-plugins/react-native-mmkv": "~0.2.0",
|
||||||
|
"@dev-plugins/react-query": "~0.2.0",
|
||||||
|
"@expo/vector-icons": "^14.0.2",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||||
|
"@react-navigation/native": "^7.0.14",
|
||||||
|
"@sentry/react-native": "~6.10.0",
|
||||||
|
"@shopify/flash-list": "1.7.3",
|
||||||
|
"@tanstack/react-query": "^5.67.3",
|
||||||
|
"expo": "~52.0.37",
|
||||||
|
"expo-blur": "~14.0.3",
|
||||||
|
"expo-constants": "~17.0.7",
|
||||||
|
"expo-dev-client": "~5.0.12",
|
||||||
|
"expo-font": "~13.0.4",
|
||||||
|
"expo-haptics": "~14.0.1",
|
||||||
|
"expo-image": "~2.0.6",
|
||||||
|
"expo-linear-gradient": "~14.0.2",
|
||||||
|
"expo-linking": "~7.0.5",
|
||||||
|
"expo-router": "~4.0.17",
|
||||||
|
"expo-splash-screen": "~0.29.22",
|
||||||
|
"expo-status-bar": "~2.0.1",
|
||||||
|
"expo-symbols": "~0.2.2",
|
||||||
|
"expo-system-ui": "~4.0.8",
|
||||||
|
"expo-web-browser": "~14.0.2",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-native": "0.76.9",
|
||||||
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
|
"react-native-mmkv": "^3.2.0",
|
||||||
|
"react-native-reanimated": "~3.16.1",
|
||||||
|
"react-native-safe-area-context": "4.12.0",
|
||||||
|
"react-native-screens": "~4.4.0",
|
||||||
|
"react-native-shimmer-placeholder": "^2.0.9",
|
||||||
|
"react-native-web": "~0.19.13",
|
||||||
|
"react-native-webview": "13.12.5",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.67.2",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/react": "~18.3.12",
|
||||||
|
"@types/react-test-renderer": "^18.3.0",
|
||||||
|
"jest": "^29.2.1",
|
||||||
|
"jest-expo": "~52.0.4",
|
||||||
|
"react-test-renderer": "18.3.1",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Product } from "@/utils/api";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { zustandStorage } from "@/store/mmkv";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export interface CartState {
|
||||||
|
products: Array<Product & { quantity: number }>;
|
||||||
|
addProduct: (product: Product) => void;
|
||||||
|
reduceProduct: (product: Product) => void;
|
||||||
|
clearCart: () => void;
|
||||||
|
total: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE = {
|
||||||
|
products: [],
|
||||||
|
total: 0,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCartStore = create<CartState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...INITIAL_STATE,
|
||||||
|
addProduct: (product) => {
|
||||||
|
set((state) => {
|
||||||
|
const hasProduct = state.products.find((p) => p.id === product.id);
|
||||||
|
const newTotal = +state.total.toFixed(2) + +product.price.toFixed(2);
|
||||||
|
const newCount = state.count + 1;
|
||||||
|
|
||||||
|
if (hasProduct) {
|
||||||
|
return {
|
||||||
|
products: state.products.map((p) =>
|
||||||
|
p.id === product.id ? { ...p, quantity: p.quantity + 1 } : p
|
||||||
|
),
|
||||||
|
total: +newTotal.toFixed(2),
|
||||||
|
count: newCount,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
products: [...state.products, { ...product, quantity: 1 }],
|
||||||
|
total: +newTotal.toFixed(2),
|
||||||
|
count: newCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reduceProduct: (product) => {
|
||||||
|
set((state) => {
|
||||||
|
const newTotal = +state.total.toFixed(2) - +product.price.toFixed(2);
|
||||||
|
const newCount = state.count - 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
products: state.products
|
||||||
|
.map((p) => {
|
||||||
|
if (p.id === product.id) {
|
||||||
|
return { ...p, quantity: p.quantity - 1 };
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
})
|
||||||
|
.filter((p) => p.quantity > 0),
|
||||||
|
total: newTotal,
|
||||||
|
count: newCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearCart: () => {
|
||||||
|
set(INITIAL_STATE);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "cart",
|
||||||
|
storage: createJSONStorage(() => zustandStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useCartStore;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
import { StateStorage } from "zustand/middleware";
|
||||||
|
|
||||||
|
export const storage = new MMKV({
|
||||||
|
id: "mmkv-storage",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zustandStorage: StateStorage = {
|
||||||
|
getItem: (key) => storage.getString(key) || null,
|
||||||
|
setItem: (key, value) => storage.set(key, value),
|
||||||
|
removeItem: (key) => storage.delete(key),
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const API_URL = process.env.EXPO_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
price: number;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
image: string;
|
||||||
|
rating: Rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rating {
|
||||||
|
rate: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchProducts = async (): Promise<Product[]> => {
|
||||||
|
const response = await fetch(`${API_URL}/products`);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchProduct = async (id: number): Promise<Product> => {
|
||||||
|
const response = await fetch(`${API_URL}/products/${id}`);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCategories = async (): Promise<string[]> => {
|
||||||
|
const response = await fetch(`${API_URL}/products/categories`);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const COLORS = {
|
||||||
|
primary: "#007AFF",
|
||||||
|
secondary: "#FF0000",
|
||||||
|
background: "#FFFFFF",
|
||||||
|
text: "#000000",
|
||||||
|
white: "#FFFFFF",
|
||||||
|
black: "#000000",
|
||||||
|
gray: "#666666",
|
||||||
|
lightGray: "#999999",
|
||||||
|
darkGray: "#333333",
|
||||||
|
success: "#4CAF50",
|
||||||
|
warning: "#FFC107",
|
||||||
|
error: "#F44336",
|
||||||
|
info: "#2196F3",
|
||||||
|
};
|
||||||