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",
|
||||
};
|
||||