Initial commit

Generated by create-expo-app 3.2.0.
This commit is contained in:
Dennis Hundertmark
2025-03-10 19:49:23 +01:00
commit b24544f115
30 changed files with 17173 additions and 0 deletions
+45
View File
@@ -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
+50
View File
@@ -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.
+52
View File
@@ -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
}
}
}
+103
View File
@@ -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);
+91
View File
@@ -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,
},
});
+145
View File
@@ -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,
},
});
+167
View File
@@ -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,
},
});
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+11
View File
@@ -0,0 +1,11 @@
module.exports = function(api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', {
unstable_transformImportMeta: true
}]
]
};
};
+44
View File
@@ -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,
},
});
+189
View File
@@ -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",
},
});
+58
View File
@@ -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,
},
});
+79
View File
@@ -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,
},
});
+88
View File
@@ -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,
},
});
+11
View File
@@ -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
+15821
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -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
}
+78
View File
@@ -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;
+12
View File
@@ -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),
};
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}
+31
View File
@@ -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();
};
+15
View File
@@ -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",
};