From 2bd8bb2dfc083fa630926587502b9b9b3e2e4f9c Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Thu, 24 Apr 2025 20:17:52 +0545 Subject: [PATCH] feat(whole project): huge changes, huge updates --- Backend/app/admin.py | 106 - Backend/app/carts.py | 198 -- Backend/app/models.py | 334 ---- Backend/app/serializers.py | 178 -- Backend/app/views.py | 282 --- {Backend => backend}/.gitignore | 0 {Backend => backend}/.python-version | 0 {Backend => backend}/README.md | 0 {Backend => backend}/app/__init__.py | 0 backend/app/admin.py | 239 +++ {Backend => backend}/app/apps.py | 0 {Backend => backend}/app/auth.py | 0 backend/app/carts.py | 242 +++ {Backend => backend}/app/middleware.py | 0 .../app/migrations/0001_initial.py | 0 .../app/migrations/0002_cart_cartitem.py | 0 ...ription_product_average_rating_and_more.py | 0 backend/app/migrations/0004_user_gender.py | 22 + .../migrations/0005_user_profile_picture.py | 18 + backend/app/migrations/0006_privacypolicy.py | 26 + backend/app/migrations/0007_faq.py | 29 + backend/app/migrations/0008_contact.py | 28 + .../migrations/0009_alter_contact_value.py | 18 + backend/app/migrations/0010_invitemessage.py | 28 + .../app/migrations/0011_invitemessage_link.py | 18 + .../0012_alter_invitemessage_link.py | 18 + .../migrations/0013_delete_invitemessage.py | 16 + backend/app/migrations/0014_slider.py | 30 + .../migrations/0015_alter_slider_category.py | 19 + .../app/migrations/0016_alter_slider_image.py | 18 + backend/app/migrations/0017_coupon.py | 34 + .../app/migrations/0018_alter_user_gender.py | 18 + backend/app/migrations/0019_notification.py | 31 + .../app/migrations/__init__.py | 0 backend/app/models.py | 586 ++++++ {Backend => backend}/app/permissions.py | 0 backend/app/serializers.py | 326 ++++ {Backend => backend}/app/tests.py | 0 {Backend => backend}/app/urls.py | 18 +- backend/app/views.py | 685 +++++++ {Backend => backend}/main.py | 0 {Backend => backend}/manage.py | 0 {Backend => backend}/pyproject.toml | 1 + {Backend => backend}/requirements.txt | 1 + {Backend => backend}/shoppingApp/__init__.py | 0 {Backend => backend}/shoppingApp/asgi.py | 0 {Backend => backend}/shoppingApp/settings.py | 45 +- {Backend => backend}/shoppingApp/urls.py | 0 {Backend => backend}/shoppingApp/wsgi.py | 0 {Backend => backend}/uv.lock | 15 + frontend/.expo/types/router.d.ts | 6 +- frontend/.gitignore | 1 - frontend/__MACOSX/._README.md | Bin 276 -> 0 bytes frontend/__MACOSX/._app | Bin 163 -> 0 bytes frontend/__MACOSX/._app.json | Bin 319 -> 0 bytes frontend/__MACOSX/._babel.config.js | Bin 319 -> 0 bytes frontend/__MACOSX/._expo-env.d.ts | Bin 319 -> 0 bytes frontend/__MACOSX/._global.css | Bin 319 -> 0 bytes frontend/__MACOSX/._metro.config.js | Bin 319 -> 0 bytes frontend/__MACOSX/._nativewind-env.d.ts | Bin 319 -> 0 bytes frontend/__MACOSX/._package-lock.json | Bin 319 -> 0 bytes frontend/__MACOSX/._package.json | Bin 319 -> 0 bytes frontend/__MACOSX/._tailwind.config.js | Bin 319 -> 0 bytes frontend/__MACOSX/._tsconfig.json | Bin 319 -> 0 bytes frontend/__MACOSX/app/.__layout.jsx | Bin 263 -> 0 bytes frontend/__MACOSX/app/._api | Bin 163 -> 0 bytes frontend/__MACOSX/app/._components | Bin 163 -> 0 bytes frontend/__MACOSX/app/._constants | Bin 163 -> 0 bytes frontend/__MACOSX/app/._index.jsx | Bin 263 -> 0 bytes frontend/__MACOSX/app/._screens | Bin 163 -> 0 bytes frontend/__MACOSX/app/._themes | Bin 163 -> 0 bytes frontend/__MACOSX/app/api/._index.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/components/._ActiveOrder.jsx | Bin 163 -> 0 bytes .../app/components/._CancelledOrder.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/components/._Category.jsx | Bin 163 -> 0 bytes .../app/components/._CompletedOrder.jsx | Bin 163 -> 0 bytes .../app/components/._ContactUsCenter.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/components/._FaqCenter.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/components/._FlashSales.jsx | Bin 163 -> 0 bytes .../app/components/._HomeCarousel.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/components/._ItemCard.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/constants/._clothColor.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/constants/._couponData.jsx | Bin 163 -> 0 bytes .../app/constants/._flashSalesData.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/constants/._myCartData.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/constants/._paymentData.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/constants/._recentData.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/constants/._subShipping.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/constants/._usersData.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/constants/._wishlistData.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._AccessLocation.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._AddCardScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._CheckOutScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._ChooseShippingScreen.jsx | Bin 163 -> 0 bytes .../screens/._CompleteYourProfileScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._ConfirmPayment.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._CouponScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._FashionScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._FilterScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._HelpCenterScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._HomeScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._InviteFriendsScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._JacketScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._LeaveReviewScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._LocationScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._MyCartScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._NewPasswordScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._NotificationScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._OnboardingScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._OrderScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._PasswordManagerScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._PaymentMethodScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._PaymentSuccess.jsx | Bin 163 -> 0 bytes .../app/screens/._PrivacyPolicyScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._ProductDetails.jsx | Bin 163 -> 0 bytes .../app/screens/._ProductDetailsScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._ProfileScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._SearchResultScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._SearchScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._SettingScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._ShippingAddressScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._SignInScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._TrackOrderScreen.jsx | Bin 163 -> 0 bytes .../app/screens/._VerifyCodeScreen.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/screens/._WellcomeScreen.jsx | Bin 163 -> 0 bytes frontend/__MACOSX/app/screens/._Wishlist.jsx | Bin 163 -> 0 bytes .../__MACOSX/app/themes/._BottomTabBar.jsx | Bin 163 -> 0 bytes frontend/__MACOSX/app/themes/._TabBar.jsx | Bin 163 -> 0 bytes frontend/__MACOSX/assets/images/._pic.jpg | Bin 163 -> 0 bytes frontend/app/_layout.jsx | 17 +- frontend/app/api.jsx | 1718 +++++++++-------- frontend/app/api/index.jsx | 95 +- frontend/app/components/ActiveOrder.jsx | 69 +- frontend/app/components/CancelledOrder.jsx | 63 +- frontend/app/components/Category.jsx | 67 +- frontend/app/components/CompletedOrder.jsx | 120 +- frontend/app/components/ContactUsCenter.jsx | 222 ++- frontend/app/components/FaqCenter.jsx | 188 +- frontend/app/components/FavoriteButton.jsx | 40 +- frontend/app/components/FlashSales.jsx | 144 +- frontend/app/components/HomeCarousel.jsx | 58 +- frontend/app/components/ItemCard.jsx | 60 +- frontend/app/components/LocationModal.jsx | 280 +++ .../app/components/ProductQuantityInput.jsx | 65 +- frontend/app/config.js | 31 + frontend/app/constants/couponData.jsx | 46 - frontend/app/constants/flashSalesData.jsx | 32 - frontend/app/constants/myCartData.jsx | 68 - frontend/app/constants/paymentData.jsx | 18 - frontend/app/constants/recentData.jsx | 79 - frontend/app/constants/storageKeys.js | 12 + frontend/app/constants/usersData.jsx | 60 - frontend/app/constants/wishlistData.jsx | 5 - frontend/app/context/AuthContext.jsx | 116 ++ frontend/app/context/CartContext.jsx | 77 +- frontend/app/context/NotificationContext.jsx | 106 + frontend/app/index.jsx | 2 + frontend/app/screens/AddBankScreen.jsx | 142 ++ frontend/app/screens/AddCardScreen.jsx | 186 +- frontend/app/screens/CheckOutScreen.jsx | 451 ++++- .../app/screens/CompleteYourProfileScreen.jsx | 363 +++- frontend/app/screens/ConfirmPayment.jsx | 340 +++- frontend/app/screens/CouponScreen.jsx | 84 +- frontend/app/screens/FashionScreen.jsx | 18 - frontend/app/screens/FilterScreen.jsx | 132 +- frontend/app/screens/FlashSalesScreen.jsx | 306 +-- frontend/app/screens/HomeScreen.jsx | 345 +++- frontend/app/screens/InviteFriendsScreen.jsx | 148 +- frontend/app/screens/JacketScreen.jsx | 173 +- frontend/app/screens/LocationScreen.jsx | 35 +- frontend/app/screens/MyCartScreen.jsx | 367 ++-- frontend/app/screens/NotificationScreen.jsx | 257 ++- frontend/app/screens/OrderScreen.jsx | 61 +- .../app/screens/PasswordManagerScreen.jsx | 159 +- frontend/app/screens/PaymentMethodScreen.jsx | 668 ++++++- frontend/app/screens/PaymentSuccess.jsx | 36 +- frontend/app/screens/PrivacyPolicyScreen.jsx | 95 +- frontend/app/screens/ProductDetails.jsx | 466 ----- frontend/app/screens/ProfileScreen.jsx | 91 +- frontend/app/screens/RegisterScreen.jsx | 85 +- frontend/app/screens/SearchResultScreen.jsx | 4 +- frontend/app/screens/SearchScreen.jsx | 26 +- frontend/app/screens/SettingScreen.jsx | 164 +- frontend/app/screens/SignInScreen.jsx | 10 +- frontend/app/screens/TrackOrderScreen.jsx | 291 ++- frontend/app/screens/Wishlist.jsx | 124 -- frontend/app/screens/WishlistScreen.jsx | 33 +- frontend/app/theme.js | 98 + frontend/assets/images/esewa.jpg | Bin 0 -> 20377 bytes frontend/assets/images/imepay.png | Bin 0 -> 24779 bytes frontend/assets/images/khalti.jpg | Bin 0 -> 69843 bytes frontend/babel.config.js | 1 - frontend/navigation_fix_script.sh | 53 - frontend/package-lock.json | 146 +- frontend/package.json | 6 + frontend/tsconfig.json | 6 +- 196 files changed, 9146 insertions(+), 4316 deletions(-) delete mode 100644 Backend/app/admin.py delete mode 100644 Backend/app/carts.py delete mode 100644 Backend/app/models.py delete mode 100644 Backend/app/serializers.py delete mode 100644 Backend/app/views.py rename {Backend => backend}/.gitignore (100%) rename {Backend => backend}/.python-version (100%) rename {Backend => backend}/README.md (100%) rename {Backend => backend}/app/__init__.py (100%) create mode 100644 backend/app/admin.py rename {Backend => backend}/app/apps.py (100%) rename {Backend => backend}/app/auth.py (100%) create mode 100644 backend/app/carts.py rename {Backend => backend}/app/middleware.py (100%) rename {Backend => backend}/app/migrations/0001_initial.py (100%) rename {Backend => backend}/app/migrations/0002_cart_cartitem.py (100%) rename {Backend => backend}/app/migrations/0003_category_description_product_average_rating_and_more.py (100%) create mode 100644 backend/app/migrations/0004_user_gender.py create mode 100644 backend/app/migrations/0005_user_profile_picture.py create mode 100644 backend/app/migrations/0006_privacypolicy.py create mode 100644 backend/app/migrations/0007_faq.py create mode 100644 backend/app/migrations/0008_contact.py create mode 100644 backend/app/migrations/0009_alter_contact_value.py create mode 100644 backend/app/migrations/0010_invitemessage.py create mode 100644 backend/app/migrations/0011_invitemessage_link.py create mode 100644 backend/app/migrations/0012_alter_invitemessage_link.py create mode 100644 backend/app/migrations/0013_delete_invitemessage.py create mode 100644 backend/app/migrations/0014_slider.py create mode 100644 backend/app/migrations/0015_alter_slider_category.py create mode 100644 backend/app/migrations/0016_alter_slider_image.py create mode 100644 backend/app/migrations/0017_coupon.py create mode 100644 backend/app/migrations/0018_alter_user_gender.py create mode 100644 backend/app/migrations/0019_notification.py rename {Backend => backend}/app/migrations/__init__.py (100%) create mode 100644 backend/app/models.py rename {Backend => backend}/app/permissions.py (100%) create mode 100644 backend/app/serializers.py rename {Backend => backend}/app/tests.py (100%) rename {Backend => backend}/app/urls.py (59%) create mode 100644 backend/app/views.py rename {Backend => backend}/main.py (100%) rename {Backend => backend}/manage.py (100%) rename {Backend => backend}/pyproject.toml (89%) rename {Backend => backend}/requirements.txt (83%) rename {Backend => backend}/shoppingApp/__init__.py (100%) rename {Backend => backend}/shoppingApp/asgi.py (100%) rename {Backend => backend}/shoppingApp/settings.py (80%) rename {Backend => backend}/shoppingApp/urls.py (100%) rename {Backend => backend}/shoppingApp/wsgi.py (100%) rename {Backend => backend}/uv.lock (93%) delete mode 100644 frontend/__MACOSX/._README.md delete mode 100644 frontend/__MACOSX/._app delete mode 100644 frontend/__MACOSX/._app.json delete mode 100644 frontend/__MACOSX/._babel.config.js delete mode 100644 frontend/__MACOSX/._expo-env.d.ts delete mode 100644 frontend/__MACOSX/._global.css delete mode 100644 frontend/__MACOSX/._metro.config.js delete mode 100644 frontend/__MACOSX/._nativewind-env.d.ts delete mode 100644 frontend/__MACOSX/._package-lock.json delete mode 100644 frontend/__MACOSX/._package.json delete mode 100644 frontend/__MACOSX/._tailwind.config.js delete mode 100644 frontend/__MACOSX/._tsconfig.json delete mode 100644 frontend/__MACOSX/app/.__layout.jsx delete mode 100644 frontend/__MACOSX/app/._api delete mode 100644 frontend/__MACOSX/app/._components delete mode 100644 frontend/__MACOSX/app/._constants delete mode 100644 frontend/__MACOSX/app/._index.jsx delete mode 100644 frontend/__MACOSX/app/._screens delete mode 100644 frontend/__MACOSX/app/._themes delete mode 100644 frontend/__MACOSX/app/api/._index.jsx delete mode 100644 frontend/__MACOSX/app/components/._ActiveOrder.jsx delete mode 100644 frontend/__MACOSX/app/components/._CancelledOrder.jsx delete mode 100644 frontend/__MACOSX/app/components/._Category.jsx delete mode 100644 frontend/__MACOSX/app/components/._CompletedOrder.jsx delete mode 100644 frontend/__MACOSX/app/components/._ContactUsCenter.jsx delete mode 100644 frontend/__MACOSX/app/components/._FaqCenter.jsx delete mode 100644 frontend/__MACOSX/app/components/._FlashSales.jsx delete mode 100644 frontend/__MACOSX/app/components/._HomeCarousel.jsx delete mode 100644 frontend/__MACOSX/app/components/._ItemCard.jsx delete mode 100644 frontend/__MACOSX/app/constants/._clothColor.jsx delete mode 100644 frontend/__MACOSX/app/constants/._couponData.jsx delete mode 100644 frontend/__MACOSX/app/constants/._flashSalesData.jsx delete mode 100644 frontend/__MACOSX/app/constants/._myCartData.jsx delete mode 100644 frontend/__MACOSX/app/constants/._paymentData.jsx delete mode 100644 frontend/__MACOSX/app/constants/._recentData.jsx delete mode 100644 frontend/__MACOSX/app/constants/._subShipping.jsx delete mode 100644 frontend/__MACOSX/app/constants/._usersData.jsx delete mode 100644 frontend/__MACOSX/app/constants/._wishlistData.jsx delete mode 100644 frontend/__MACOSX/app/screens/._AccessLocation.jsx delete mode 100644 frontend/__MACOSX/app/screens/._AddCardScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._CheckOutScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._ChooseShippingScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._CompleteYourProfileScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._ConfirmPayment.jsx delete mode 100644 frontend/__MACOSX/app/screens/._CouponScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._FashionScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._FilterScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._HelpCenterScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._HomeScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._InviteFriendsScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._JacketScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._LeaveReviewScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._LocationScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._MyCartScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._NewPasswordScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._NotificationScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._OnboardingScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._OrderScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._PasswordManagerScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._PaymentMethodScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._PaymentSuccess.jsx delete mode 100644 frontend/__MACOSX/app/screens/._PrivacyPolicyScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._ProductDetails.jsx delete mode 100644 frontend/__MACOSX/app/screens/._ProductDetailsScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._ProfileScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._SearchResultScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._SearchScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._SettingScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._ShippingAddressScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._SignInScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._TrackOrderScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._VerifyCodeScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._WellcomeScreen.jsx delete mode 100644 frontend/__MACOSX/app/screens/._Wishlist.jsx delete mode 100644 frontend/__MACOSX/app/themes/._BottomTabBar.jsx delete mode 100644 frontend/__MACOSX/app/themes/._TabBar.jsx delete mode 100644 frontend/__MACOSX/assets/images/._pic.jpg create mode 100644 frontend/app/components/LocationModal.jsx create mode 100644 frontend/app/config.js delete mode 100644 frontend/app/constants/couponData.jsx delete mode 100644 frontend/app/constants/flashSalesData.jsx delete mode 100644 frontend/app/constants/myCartData.jsx delete mode 100644 frontend/app/constants/paymentData.jsx delete mode 100644 frontend/app/constants/recentData.jsx create mode 100644 frontend/app/constants/storageKeys.js delete mode 100644 frontend/app/constants/usersData.jsx delete mode 100644 frontend/app/constants/wishlistData.jsx create mode 100644 frontend/app/context/AuthContext.jsx create mode 100644 frontend/app/context/NotificationContext.jsx create mode 100644 frontend/app/screens/AddBankScreen.jsx delete mode 100644 frontend/app/screens/FashionScreen.jsx delete mode 100644 frontend/app/screens/ProductDetails.jsx delete mode 100644 frontend/app/screens/Wishlist.jsx create mode 100644 frontend/app/theme.js create mode 100644 frontend/assets/images/esewa.jpg create mode 100644 frontend/assets/images/imepay.png create mode 100644 frontend/assets/images/khalti.jpg delete mode 100644 frontend/navigation_fix_script.sh diff --git a/Backend/app/admin.py b/Backend/app/admin.py deleted file mode 100644 index eeb02c0..0000000 --- a/Backend/app/admin.py +++ /dev/null @@ -1,106 +0,0 @@ -from django.contrib import admin -from .models import User, Category, Product, Rating, Order, OrderItem, Cart, CartItem -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin - -@admin.register(User) -class UserAdmin(BaseUserAdmin): - list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'created_at') - search_fields = ('username', 'email', 'first_name', 'last_name') - list_filter = ('is_staff', 'is_active', 'created_at') - readonly_fields = ('created_at', 'updated_at') - fieldsets = ( - ('Personal Info', {'fields': ('username', 'first_name', 'last_name', 'email', 'password')}), - ('Customer Details', {'fields': ('phone', 'address')}), - ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), - ('Dates', {'fields': ('created_at', 'updated_at', 'last_login')}), - ) - add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ('username', 'email', 'first_name', 'last_name', 'password1', 'password2', 'phone', 'address'), - }), - ) - -@admin.register(Category) -class CategoryAdmin(admin.ModelAdmin): - list_display = ('name', 'created_at') - search_fields = ('name',) - list_filter = ('created_at',) - readonly_fields = ('created_at', 'updated_at') - - -class RatingInline(admin.TabularInline): - model = Rating - extra = 0 - readonly_fields = ('created_at',) - - -@admin.register(Product) -class ProductAdmin(admin.ModelAdmin): - list_display = ('name', 'category', 'price','stock','color','size', 'created_at', 'updated_at') - search_fields = ('name', 'description') - list_filter = ('category', 'created_at') - readonly_fields = ('created_at', 'updated_at') - inlines = [RatingInline] - - -@admin.register(Rating) -class RatingAdmin(admin.ModelAdmin): - list_display = ('user', 'product', 'rating', 'created_at') - search_fields = ('user__username', 'product__name', 'comment') - list_filter = ('rating', 'created_at') - readonly_fields = ('created_at', 'updated_at') - - -class OrderItemInline(admin.TabularInline): - model = OrderItem - extra = 0 - readonly_fields = ('get_subtotal',) - - -@admin.register(Order) -class OrderAdmin(admin.ModelAdmin): - list_display = ('id', 'user', 'total_price', 'status', 'created_at') - search_fields = ('user__username', 'shipping_address') - list_filter = ('status', 'created_at') - readonly_fields = ('created_at', 'updated_at', 'total_price') - inlines = [OrderItemInline] - list_editable = ('status',) - - -class OrderItemAdmin(admin.ModelAdmin): - list_display = ('order', 'product', 'quantity', 'price', 'get_subtotal') - list_filter = ('order__status',) - search_fields = ('order__id', 'product__name') - readonly_fields = ('get_subtotal',) - - def get_subtotal(self, obj): - return obj.get_subtotal() - get_subtotal.short_description = 'Subtotal' - - -admin.site.register(OrderItem, OrderItemAdmin) - - -class CartItemInline(admin.TabularInline): - model = CartItem - extra = 0 - readonly_fields = ['created_at', 'updated_at'] - -@admin.register(Cart) -class CartAdmin(admin.ModelAdmin): - list_display = ['user', 'get_total', 'created_at', 'updated_at'] - list_filter = ['created_at', 'updated_at'] - search_fields = ['user__username', 'user__email'] - readonly_fields = ['created_at', 'updated_at'] - inlines = [CartItemInline] - - def get_total(self, obj): - return obj.get_total() - get_total.short_description = 'Total' - - def get_subtotal(self, obj): - return obj.get_subtotal() - get_subtotal.short_description = 'Subtotal' - - diff --git a/Backend/app/carts.py b/Backend/app/carts.py deleted file mode 100644 index 5d8cd63..0000000 --- a/Backend/app/carts.py +++ /dev/null @@ -1,198 +0,0 @@ -from rest_framework import viewsets, status -from rest_framework.decorators import action -from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated -from django.shortcuts import get_object_or_404 -from django.db import transaction -from .models import Cart, CartItem, Product, Order, OrderItem -from .serializers import CartSerializer, CartItemSerializer - -class CartViewSet(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - - def get_cart(self, request): - """Get or create cart for the authenticated user""" - cart, created = Cart.objects.get_or_create(user=request.user) - return cart - - @action(detail=False, methods=['get']) - def items(self, request): - """Get all items in the cart""" - cart = self.get_cart(request) - serializer = CartSerializer(cart, context={'request': request}) - return Response(serializer.data) - - @action(detail=False, methods=['post']) - def add_item(self, request): - """Add an item to the cart""" - product_id = request.data.get('product_id') - quantity = int(request.data.get('quantity', 1)) - - if not product_id: - return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) - - try: - product = Product.objects.get(id=product_id) - except Product.DoesNotExist: - return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) - - # Check if product is in stock - if product.stock < quantity: - return Response({ - 'error': f'Not enough stock available. Available: {product.stock}' - }, status=status.HTTP_400_BAD_REQUEST) - - cart = self.get_cart(request) - cart_item = cart.add_product(product, quantity) - - if cart_item: - serializer = CartItemSerializer(cart_item, context={'request': request}) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response({'error': 'Invalid quantity'}, status=status.HTTP_400_BAD_REQUEST) - - @action(detail=False, methods=['post']) - def remove_item(self, request): - """Remove an item from the cart""" - try: - product_id = request.data.get('product_id') - if not product_id: - return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) - - try: - product = Product.objects.get(id=product_id) - except Product.DoesNotExist: - return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) - - cart = self.get_cart(request) - cart_item = CartItem.objects.filter(cart=cart, product=product).first() - - if cart_item: - cart_item.delete() - return Response({'message': 'Item removed from cart successfully'}) - return Response({'error': 'Item not found in cart'}, status=status.HTTP_404_NOT_FOUND) - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) - - @action(detail=False, methods=['post']) - def update_quantity(self, request): - """Update the quantity of an item in the cart""" - try: - product_id = request.data.get('product_id') - quantity = int(request.data.get('quantity', 1)) - - if not product_id: - return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) - - if quantity <= 0: - return Response({'error': 'Quantity must be greater than 0'}, status=status.HTTP_400_BAD_REQUEST) - - try: - product = Product.objects.get(id=product_id) - except Product.DoesNotExist: - return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) - - # Check if product is in stock - if product.stock < quantity: - return Response({ - 'error': f'Not enough stock available. Available: {product.stock}' - }, status=status.HTTP_400_BAD_REQUEST) - - cart = self.get_cart(request) - try: - cart_item = CartItem.objects.get(cart=cart, product=product) - cart_item.quantity = quantity - cart_item.save() - serializer = CartItemSerializer(cart_item, context={'request': request}) - return Response(serializer.data) - except CartItem.DoesNotExist: - return Response({'error': 'Item not found in cart'}, status=status.HTTP_404_NOT_FOUND) - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) - - @action(detail=False, methods=['post']) - def clear(self, request): - """Clear all items from the cart""" - cart = self.get_cart(request) - cart.clear() - return Response({'message': 'Cart cleared'}) - - @action(detail=False, methods=['delete']) - def delete(self, request): - """Delete the entire cart""" - cart = self.get_cart(request) - cart.delete() - return Response({'message': 'Cart deleted'}, status=status.HTTP_204_NO_CONTENT) - - @action(detail=False, methods=['post']) - def checkout(self, request): - """Convert cart to order""" - cart = self.get_cart(request) - - if not cart.items.exists(): - return Response({'error': 'Cart is empty'}, status=status.HTTP_400_BAD_REQUEST) - - # Check stock availability for all items - for cart_item in cart.items.all(): - if cart_item.product.stock < cart_item.quantity: - return Response({ - 'error': f'Not enough stock for {cart_item.product.name}. Available: {cart_item.product.stock}' - }, status=status.HTTP_400_BAD_REQUEST) - - with transaction.atomic(): - # Create order from cart - order = Order.objects.create( - user=request.user, - status='Pending', - shipping_address=request.user.address or '', - total_price=cart.get_total() - ) - - # Add cart items to order - for cart_item in cart.items.all(): - OrderItem.objects.create( - order=order, - product=cart_item.product, - quantity=cart_item.quantity, - price=cart_item.product.price - ) - - # Update product stock - product = cart_item.product - product.stock -= cart_item.quantity - product.save() - - # Clear the cart - cart.clear() - - return Response({ - 'message': 'Order created successfully', - 'order_id': order.id - }, status=status.HTTP_201_CREATED) - - @action(detail=False, methods=['post']) - def merge(self, request): - """Merge another cart into the current cart""" - other_cart_id = request.data.get('cart_id') - - if not other_cart_id: - return Response({'error': 'Cart ID is required'}, status=status.HTTP_400_BAD_REQUEST) - - try: - other_cart = Cart.objects.get(id=other_cart_id) - except Cart.DoesNotExist: - return Response({'error': 'Cart not found'}, status=status.HTTP_404_NOT_FOUND) - - if other_cart.user != request.user: - return Response({'error': 'Cannot merge carts from different users'}, - status=status.HTTP_403_FORBIDDEN) - - current_cart = self.get_cart(request) - - with transaction.atomic(): - for cart_item in other_cart.items.all(): - current_cart.add_product(cart_item.product, cart_item.quantity) - - # Delete the other cart - other_cart.delete() - - return Response({'message': 'Carts merged successfully'}) \ No newline at end of file diff --git a/Backend/app/models.py b/Backend/app/models.py deleted file mode 100644 index 4804ac1..0000000 --- a/Backend/app/models.py +++ /dev/null @@ -1,334 +0,0 @@ -from django.db import models -from django.contrib.auth.hashers import make_password, check_password -from django.dispatch import receiver -from rest_framework.authtoken.models import Token -from django.db.models.signals import post_save -from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin -from django.core.validators import MinValueValidator, MaxValueValidator - -class UserManager(BaseUserManager): - def create_user(self, username, email, password=None, **extra_fields): - if not email: - raise ValueError('Users must have an email address') - if not username: - raise ValueError('Users must have a username') - - email = self.normalize_email(email) - user = self.model( - username=username, - email=email, - **extra_fields - ) - user.set_password(password) - user.save(using=self._db) - return user - - def create_superuser(self, username, email, password=None, **extra_fields): - extra_fields.setdefault('is_staff', True) - extra_fields.setdefault('is_superuser', True) - - return self.create_user(username, email, password, **extra_fields) - -class User(AbstractBaseUser, PermissionsMixin): - id = models.BigAutoField(primary_key=True) - username = models.CharField(max_length=150, unique=True) - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - email = models.EmailField(unique=True) - is_active = models.BooleanField(default=True) - is_staff = models.BooleanField(default=False) - phone = models.CharField(max_length=15, blank=True, null=True) - address = models.TextField(blank=True, null=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - phone_number = models.CharField(max_length=15, blank=True, null=True) - - objects = UserManager() - - USERNAME_FIELD = 'username' - EMAIL_FIELD = 'email' - REQUIRED_FIELDS = ['email', 'first_name', 'last_name'] - - def __str__(self): - return self.username - - def set_password(self, raw_password): - self.password = make_password(raw_password) - - def check_password(self, raw_password): - return check_password(raw_password, self.password) - - class Meta: - verbose_name = 'User' - verbose_name_plural = 'Users' - ordering = ['username'] - - def get_full_name(self): - return f"{self.first_name} {self.last_name}" - - def get_short_name(self): - return self.first_name - -@receiver(post_save, sender=User) -def create_auth_token(sender, instance=None, created=False, **kwargs): - if created: - Token.objects.create(user=instance) - -class Category(models.Model): - id = models.BigAutoField(primary_key=True) - name = models.CharField(max_length=100, unique=True) - image = models.ImageField(upload_to='categories/', blank=True, null=True) - description = models.TextField(blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return self.name - class Meta: - verbose_name = 'Category' - verbose_name_plural = 'Categories' - ordering = ['name'] - @property - def image_url(self): - if self.image: - return f'/media/{self.image.name}' - return None - -class Product(models.Model): - id = models.BigAutoField(primary_key= True) - name = models.CharField(max_length=100) - description = models.TextField() - price = models.DecimalField(max_digits=10, decimal_places=2) - image = models.ImageField(upload_to='products/', blank=True, null=True) - category = models.ForeignKey(Category, related_name='products', on_delete=models.CASCADE) - size = models.CharField(max_length=10, blank=True, null=True) - color = models.CharField(max_length=20, blank=True, null=True) - stock = models.IntegerField(default=0) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - average_rating = models.DecimalField(max_digits=3, decimal_places=2, default=0) - - def __str__(self): - return self.name - class Meta: - verbose_name = 'Product' - verbose_name_plural = 'Products' - ordering = ['name'] - @property - def image_url(self): - if self.image: - return f'/media/{self.image.name}' - return None - - def update_average_rating(self): - avg_rating = self.ratings.aggregate(avg_rating=models.Avg('rating'))['avg_rating'] - self.average_rating = avg_rating or 0 - self.save() - -class Rating(models.Model): - id = models.BigAutoField(primary_key= True) - product = models.ForeignKey(Product, related_name='ratings', on_delete=models.CASCADE) - user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE) - rating = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)]) - comment = models.TextField(blank=True, null=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - class Meta: - unique_together = ('product', 'user') - verbose_name = 'Rating' - verbose_name_plural = 'Ratings' - ordering = ['-created_at'] - - def __str__(self): - return f'{self.user.username} - {self.product.name} - {self.rating}' - -class Order(models.Model): - id = models.AutoField(primary_key=True) - user = models.ForeignKey(User, related_name='orders', on_delete=models.CASCADE) - total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) - status = models.CharField(max_length=20, default='Pending', choices=[ - ('Pending', 'Pending'), - ('Shipped', 'Shipped'), - ('Delivered', 'Delivered'), - ('Cancelled', 'Cancelled') - ]) - shipping_address = models.CharField(max_length=255, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return f'Order {self.id} by {self.user.username}' - - def update_total(self): - """Automatically calculate total price from order items""" - order_items = self.order_items.all() - if not order_items.exists(): - self.total_price = 0 - else: - self.total_price = sum(item.get_subtotal() for item in order_items) - self.save() - - def add_product(self, product, quantity=1): - """Add a product to the order""" - if product is None: - return None - - if quantity is None or quantity <= 0: - quantity = 1 - - order_item, created = OrderItem.objects.get_or_create( - order=self, - product=product, - defaults={ - 'quantity': quantity, - 'price': product.price if product.price else 0 - } - ) - - if not created: - order_item.quantity += quantity - order_item.save() - - self.update_total() - return order_item - - class Meta: - verbose_name = 'Order' - verbose_name_plural = 'Orders' - ordering = ['-created_at'] - -class OrderItem(models.Model): - order = models.ForeignKey(Order, related_name='order_items', on_delete=models.CASCADE) - product = models.ForeignKey(Product, related_name='order_items', on_delete=models.CASCADE) - quantity = models.IntegerField() - price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) - - def get_subtotal(self): - """Calculate the subtotal for this item""" - if self.price is None or self.quantity is None: - return 0 - return self.price * self.quantity - - def __str__(self): - return f'{self.product.name} - {self.quantity}' - - def save(self, *args, **kwargs): - # Ensure price is set correctly if it's none or a new order item - if self.price is None or (not self.pk and not self.price): - self.price = self.product.price if self.product and self.product.price else 0 - - # Ensure quantity is never None - if self.quantity is None: - self.quantity = 1 - - super().save(*args, **kwargs) - # Update the order total when an item is saved - if self.order: - self.order.update_total() - - def delete(self, *args, **kwargs): - order = self.order - super().delete(*args, **kwargs) - # Update the order total when an item is deleted - if order: - order.update_total() - - class Meta: - verbose_name = 'Order Item' - verbose_name_plural = 'Order Items' - ordering = ['order'] - -@receiver(post_save, sender=Order) -def update_order_status(sender, instance, created, **kwargs): - """When an order status changes to Delivered, update product stock""" - if not created and instance.status == 'Delivered': - for item in instance.order_items.all(): - product = item.product - # Only update stock if we haven't already updated it - if product.stock >= item.quantity: - product.stock -= item.quantity - product.save() - -class Cart(models.Model): - user = models.OneToOneField(User, related_name='cart', on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return f"Cart for {self.user.username}" - - def get_total(self): - """Calculate total price of all items in cart""" - return sum(item.get_subtotal() for item in self.items.all()) - - def add_product(self, product, quantity=1): - """Add a product to the cart""" - if quantity <= 0: - return None - - cart_item, created = CartItem.objects.get_or_create( - cart=self, - product=product, - defaults={'quantity': quantity} - ) - - if not created: - cart_item.quantity += quantity - cart_item.save() - - return cart_item - - def remove_product(self, product): - """Remove a product from the cart""" - try: - cart_item = CartItem.objects.get(cart=self, product=product) - cart_item.delete() - return True - except CartItem.DoesNotExist: - return False - - def clear(self): - """Remove all items from the cart""" - self.items.all().delete() - - class Meta: - verbose_name = 'Cart' - verbose_name_plural = 'Carts' - ordering = ['-updated_at'] - -class CartItem(models.Model): - cart = models.ForeignKey(Cart, related_name='items', on_delete=models.CASCADE) - product = models.ForeignKey(Product, related_name='cart_items', on_delete=models.CASCADE) - quantity = models.PositiveIntegerField(default=1) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return f"{self.quantity} x {self.product.name} in {self.cart}" - - def get_subtotal(self): - """Calculate the subtotal for this item""" - return self.product.price * self.quantity - - class Meta: - verbose_name = 'Cart Item' - verbose_name_plural = 'Cart Items' - ordering = ['-updated_at'] - unique_together = ['cart', 'product'] - -@receiver(post_save, sender=User) -def create_user_cart(sender, instance, created, **kwargs): - """Create a cart for new users""" - if created: - Cart.objects.create(user=instance) - -class Favorite(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='favorites') - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='favorites') - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - unique_together = ['user', 'product'] - - def __str__(self): - return f"{self.user.username}'s favorite: {self.product.name}" \ No newline at end of file diff --git a/Backend/app/serializers.py b/Backend/app/serializers.py deleted file mode 100644 index e242dd2..0000000 --- a/Backend/app/serializers.py +++ /dev/null @@ -1,178 +0,0 @@ -from rest_framework import serializers -from .models import User, Category, Product, Rating, Order, OrderItem, CartItem, Cart, Favorite - -class UserSerializer(serializers.ModelSerializer): - password = serializers.CharField(write_only=True) - - class Meta: - model = User - fields = ['id', 'username', 'first_name', 'last_name', 'email', 'password', - 'phone', 'address', 'created_at'] - read_only_fields = ['id', 'created_at'] - - def create(self, validated_data): - # Use the create_user method instead of direct creation - user = User.objects.create_user( - username=validated_data['username'], - email=validated_data['email'], - password=validated_data['password'], - first_name=validated_data.get('first_name', ''), - last_name=validated_data.get('last_name', ''), - phone=validated_data.get('phone', ''), - address=validated_data.get('address', '') - ) - return user - - -class CategorySerializer(serializers.ModelSerializer): - class Meta: - model = Category - fields = ['id', 'name', 'image', 'created_at'] - read_only_fields = ['id', 'created_at'] - - -class RatingSerializer(serializers.ModelSerializer): - username = serializers.ReadOnlyField(source='user.username') - - class Meta: - model = Rating - fields = ['id', 'product', 'user', 'username', 'rating', 'comment', 'created_at'] - read_only_fields = ['id', 'created_at', 'username'] - - -class ProductSerializer(serializers.ModelSerializer): - category_name = serializers.ReadOnlyField(source='category.name') - average_rating = serializers.SerializerMethodField() - - class Meta: - model = Product - fields = ['id', 'name', 'description', 'price', 'image', 'category', 'stock','color','size', - 'category_name', 'average_rating', 'created_at'] - read_only_fields = ['id', 'created_at', 'category_name', 'average_rating'] - - def get_average_rating(self, obj): - ratings = obj.ratings.all() - if ratings: - return sum(rating.rating for rating in ratings) / len(ratings) - return 0 - - -class ProductDetailSerializer(ProductSerializer): - ratings = RatingSerializer(many=True, read_only=True) - - class Meta(ProductSerializer.Meta): - fields = ProductSerializer.Meta.fields + ['ratings'] - - -class OrderItemSerializer(serializers.ModelSerializer): - product_name = serializers.ReadOnlyField(source='product.name') - product_image = serializers.ImageField(source='product.image', read_only=True) - subtotal = serializers.SerializerMethodField() - - class Meta: - model = OrderItem - fields = ['id', 'product', 'product_name', 'product_image', 'quantity', 'price', 'subtotal'] - read_only_fields = ['id', 'product_name', 'product_image', 'subtotal'] - - def get_subtotal(self, obj): - return obj.get_subtotal() - - -class OrderItemCreateSerializer(serializers.ModelSerializer): - class Meta: - model = OrderItem - fields = ['product', 'quantity'] - - -class OrderSerializer(serializers.ModelSerializer): - user_name = serializers.ReadOnlyField(source='user.get_full_name') - order_items = OrderItemSerializer(many=True, read_only=True) - items = OrderItemCreateSerializer(many=True, write_only=True, required=False) - - class Meta: - model = Order - fields = ['id', 'user', 'user_name', 'total_price', 'status', 'shipping_address', - 'created_at', 'order_items', 'items'] - read_only_fields = ['id', 'created_at', 'user_name', 'total_price', 'order_items'] - - def create(self, validated_data): - items_data = validated_data.pop('items', []) - order = Order.objects.create(**validated_data) - - # Create order items if provided - for item_data in items_data: - product = item_data.get('product') - quantity = item_data.get('quantity', 1) - order.add_product(product, quantity) - - return order - - def update(self, instance, validated_data): - items_data = validated_data.pop('items', []) - - # Update the order fields - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() - - # Add new items if provided - for item_data in items_data: - product = item_data.get('product') - quantity = item_data.get('quantity', 1) - instance.add_product(product, quantity) - - return instance - - -class CartItemSerializer(serializers.ModelSerializer): - product_name = serializers.CharField(source='product.name', read_only=True) - product_price = serializers.SerializerMethodField() - product_image = serializers.SerializerMethodField() - subtotal = serializers.SerializerMethodField() - - class Meta: - model = CartItem - fields = ['id', 'product', 'product_name', 'product_price', 'product_image', 'quantity', 'subtotal', 'created_at', 'updated_at'] - read_only_fields = ['created_at', 'updated_at'] - - def get_subtotal(self, obj): - try: - return float(obj.product.price * obj.quantity) - except (AttributeError, TypeError): - return 0.0 - - def get_product_price(self, obj): - try: - return float(obj.product.price) - except (AttributeError, TypeError): - return 0.0 - - def get_product_image(self, obj): - if obj.product and obj.product.image: - request = self.context.get('request') - if request is not None: - return request.build_absolute_uri(obj.product.image.url) - return obj.product.image.url - return None - - -class CartSerializer(serializers.ModelSerializer): - items = CartItemSerializer(many=True, read_only=True) - total = serializers.SerializerMethodField() - - class Meta: - model = Cart - fields = ['id', 'user', 'items', 'total', 'created_at', 'updated_at'] - read_only_fields = ['created_at', 'updated_at'] - - def get_total(self, obj): - return obj.get_total() - - -class FavoriteSerializer(serializers.ModelSerializer): - product = ProductSerializer(read_only=True) - - class Meta: - model = Favorite - fields = ['id', 'product', 'created_at'] - read_only_fields = ['id', 'created_at'] \ No newline at end of file diff --git a/Backend/app/views.py b/Backend/app/views.py deleted file mode 100644 index ea21869..0000000 --- a/Backend/app/views.py +++ /dev/null @@ -1,282 +0,0 @@ -from django.conf import settings -from rest_framework import viewsets, status, filters -from rest_framework.decorators import action -from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated, IsAdminUser, AllowAny -from django.db.models import Avg -from django.shortcuts import get_object_or_404 -from django.http import HttpResponseForbidden, JsonResponse -from django.views.decorators.csrf import ensure_csrf_cookie -from django.middleware.csrf import get_token -from rest_framework.exceptions import PermissionDenied -from django.conf import settings -from django.http import FileResponse, HttpResponseForbidden -from django.views.decorators.http import require_GET -from rest_framework.authentication import TokenAuthentication -from rest_framework.decorators import authentication_classes, permission_classes -from rest_framework.permissions import IsAuthenticated -import os -from .models import User, Category, Product, Rating, Order, OrderItem, Favorite -from .serializers import ( - UserSerializer, CategorySerializer, ProductSerializer, ProductDetailSerializer, - RatingSerializer, OrderSerializer, OrderItemSerializer, FavoriteSerializer -) - -class UserViewSet(viewsets.ModelViewSet): - queryset = User.objects.all() - serializer_class = UserSerializer - filter_backends = [filters.SearchFilter] - search_fields = ['username', 'email', 'first_name', 'last_name'] - permission_classes = [AllowAny] - - def get_queryset(self): - # If user_id query param is provided, filter by that - user_id = self.request.query_params.get('user_id', None) - if user_id: - return User.objects.filter(id=user_id) - - # If no specific user_id is requested, and user is staff, show all - if self.request.user.is_authenticated and self.request.user.is_staff: - return User.objects.all() - - # For anonymous or non-staff users requesting the list endpoint, return empty queryset - if self.action == 'list': - return User.objects.none() - - # For other actions like retrieve, let Django's permission system handle it - return User.objects.all() - - @action(detail=False, methods=['get']) - def me(self, request): - """Get current user's profile""" - if not request.user.is_authenticated: - return Response({'error': 'Authentication required'}, status=status.HTTP_401_UNAUTHORIZED) - - serializer = self.get_serializer(request.user) - return Response(serializer.data) - - -class CategoryViewSet(viewsets.ModelViewSet): - queryset = Category.objects.all() - serializer_class = CategorySerializer - filter_backends = [filters.SearchFilter] - search_fields = ['name'] - permission_classes = [AllowAny] - - def get_permissions(self): - if self.action in ['create', 'update', 'partial_update', 'destroy']: - permission_classes = [IsAdminUser] - else: - permission_classes = [AllowAny] - return [permission() for permission in permission_classes] - - @action(detail=True, methods=['get']) - def products(self, request, pk=None): - category = self.get_object() - products = Product.objects.filter(category=category) - serializer = ProductSerializer(products, many=True) - return Response(serializer.data) - - -class ProductViewSet(viewsets.ModelViewSet): - queryset = Product.objects.all() - serializer_class = ProductSerializer - filter_backends = [filters.SearchFilter, filters.OrderingFilter] - search_fields = ['name', 'description', 'category__name'] - ordering_fields = ['name', 'price', 'created_at'] - permission_classes = [AllowAny] - - def get_permissions(self): - if self.action in ['create', 'update', 'partial_update', 'destroy']: - permission_classes = [IsAdminUser] - else: - permission_classes = [AllowAny] - return [permission() for permission in permission_classes] - - def get_serializer_class(self): - if self.action == 'retrieve': - return ProductDetailSerializer - return ProductSerializer - - @action(detail=True, methods=['post']) - def rate(self, request, pk=None): - # Still require authentication for rating - if not request.user.is_authenticated: - return Response({'error': 'Authentication required'}, status=status.HTTP_401_UNAUTHORIZED) - - product = self.get_object() - rating_value = request.data.get('rating') - comment = request.data.get('comment', '') - - if not rating_value: - return Response({'error': 'Rating is required'}, status=status.HTTP_400_BAD_REQUEST) - - # Update or create rating - rating, created = Rating.objects.update_or_create( - product=product, - user=request.user, - defaults={'rating': rating_value, 'comment': comment} - ) - - serializer = RatingSerializer(rating) - return Response(serializer.data) - - -class RatingViewSet(viewsets.ModelViewSet): - queryset = Rating.objects.all() - serializer_class = RatingSerializer - permission_classes = [AllowAny] - - def get_permissions(self): - if self.action in ['update', 'partial_update', 'destroy']: - permission_classes = [IsAuthenticated] - else: - permission_classes = [AllowAny] - return [permission() for permission in permission_classes] - - def perform_create(self, serializer): - # Still require authentication for creating ratings - if not self.request.user.is_authenticated: - raise PermissionDenied('Authentication required to create ratings') - serializer.save(user=self.request.user) - - -class OrderViewSet(viewsets.ModelViewSet): - queryset = Order.objects.all() - serializer_class = OrderSerializer - filter_backends = [filters.OrderingFilter] - ordering_fields = ['created_at', 'status'] - permission_classes = [AllowAny] - - def get_queryset(self): - user_id = self.request.query_params.get('user_id', None) - if user_id: - return Order.objects.filter(user_id=user_id) - - if self.request.user.is_authenticated: - if self.request.user.is_staff: - return Order.objects.all() - return Order.objects.filter(user=self.request.user) - - # For anonymous users, return empty queryset - return Order.objects.none() - - def perform_create(self, serializer): - # For creating orders, require authentication - if not self.request.user.is_authenticated: - raise PermissionDenied('Authentication required to create orders') - serializer.save(user=self.request.user) - - @action(detail=True, methods=['post']) - def cancel(self, request, pk=None): - order = self.get_object() - - # Check if order belongs to current user or user is admin - if self.request.user.is_authenticated and (order.user.id == request.user.id or request.user.is_staff): - # Check if order can be canceled - if order.status not in ['Pending', 'Shipped']: - return Response({'error': 'Cannot cancel order in current status'}, - status=status.HTTP_400_BAD_REQUEST) - - order.status = 'Cancelled' - order.save() - serializer = OrderSerializer(order) - return Response(serializer.data) - else: - return Response({'error': 'Not authorized'}, status=status.HTTP_403_FORBIDDEN) - - -class OrderItemViewSet(viewsets.ModelViewSet): - queryset = OrderItem.objects.all() - serializer_class = OrderItemSerializer - permission_classes = [AllowAny] - - def get_queryset(self): - user_id = self.request.query_params.get('user_id', None) - if user_id: - return OrderItem.objects.filter(order__user_id=user_id) - - if self.request.user.is_authenticated: - if self.request.user.is_staff: - return OrderItem.objects.all() - return OrderItem.objects.filter(order__user=self.request.user) - - # For anonymous users, return empty queryset - return OrderItem.objects.none() - -def get_csrf_token(request): - """ - This view sets the CSRF cookie and returns the token in the response. - The client can then use this token for subsequent requests. - """ - token = get_token(request) - return JsonResponse({'csrfToken': token}) -@require_GET -@authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) -def serve_protected_media(request, path): - """ - Serve media files with authentication check. - The path parameter should be the relative path within MEDIA_ROOT. - """ - # Construct the full path - file_path = os.path.join(settings.MEDIA_ROOT, path) - - # Basic security check to prevent directory traversal - if not os.path.abspath(file_path).startswith(os.path.abspath(settings.MEDIA_ROOT)): - return HttpResponseForbidden('Invalid path') - - # Check if file exists - if not os.path.exists(file_path): - return HttpResponseForbidden('File not found') - - # Serve the file - return FileResponse(open(file_path, 'rb')) - -class FavoriteViewSet(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - - def list(self, request): - """Get all favorites for the current user""" - favorites = Favorite.objects.filter(user=request.user) - serializer = FavoriteSerializer(favorites, many=True, context={'request': request}) - return Response(serializer.data) - - @action(detail=False, methods=['post']) - def toggle(self, request): - """Toggle favorite status for a product""" - product_id = request.data.get('product_id') - if not product_id: - return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) - - try: - product = Product.objects.get(id=product_id) - except Product.DoesNotExist: - return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) - - # Check if favorite exists - favorite = Favorite.objects.filter(user=request.user, product=product).first() - - if favorite: - # Remove from favorites - favorite.delete() - return Response({'message': 'Product removed from favorites'}) - else: - # Add to favorites - Favorite.objects.create(user=request.user, product=product) - return Response({'message': 'Product added to favorites'}) - - @action(detail=False, methods=['get']) - def check(self, request): - """Check if a product is favorited""" - product_id = request.query_params.get('product_id') - if not product_id: - return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) - - try: - product = Product.objects.get(id=product_id) - except Product.DoesNotExist: - return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) - - is_favorited = Favorite.objects.filter(user=request.user, product=product).exists() - return Response({'is_favorited': is_favorited}) \ No newline at end of file diff --git a/Backend/.gitignore b/backend/.gitignore similarity index 100% rename from Backend/.gitignore rename to backend/.gitignore diff --git a/Backend/.python-version b/backend/.python-version similarity index 100% rename from Backend/.python-version rename to backend/.python-version diff --git a/Backend/README.md b/backend/README.md similarity index 100% rename from Backend/README.md rename to backend/README.md diff --git a/Backend/app/__init__.py b/backend/app/__init__.py similarity index 100% rename from Backend/app/__init__.py rename to backend/app/__init__.py diff --git a/backend/app/admin.py b/backend/app/admin.py new file mode 100644 index 0000000..087174c --- /dev/null +++ b/backend/app/admin.py @@ -0,0 +1,239 @@ +from django.contrib import admin +from .models import User, Category, Product, Rating, Order, OrderItem, Cart, CartItem, PrivacyPolicy, FAQ, Contact, Slider, Coupon +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + list_display = ( + "username", + "email", + "first_name", + "last_name", + "gender", + "is_staff", + "created_at", + ) + search_fields = ("username", "email", "first_name", "last_name") + list_filter = ("is_staff", "is_active", "created_at") + readonly_fields = ("created_at", "updated_at") + fieldsets = ( + ( + "Personal Info", + { + "fields": ( + "username", + "first_name", + "last_name", + "gender", + "email", + "password", + ) + }, + ), + ("Customer Details", {"fields": ("phone", "address")}), + ( + "Permissions", + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ("Dates", {"fields": ("created_at", "updated_at", "last_login")}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ( + "username", + "email", + "first_name", + "last_name", + "password1", + "password2", + "phone", + "address", + "gender", + ), + }, + ), + ) + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ("name", "created_at") + search_fields = ("name",) + list_filter = ("created_at",) + readonly_fields = ("created_at", "updated_at") + + +class RatingInline(admin.TabularInline): + model = Rating + extra = 0 + readonly_fields = ("created_at",) + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ( + "name", + "category", + "price", + "stock", + "color", + "size", + "created_at", + "updated_at", + ) + search_fields = ("name", "description") + list_filter = ("category", "created_at") + readonly_fields = ("created_at", "updated_at") + inlines = [RatingInline] + + +@admin.register(Rating) +class RatingAdmin(admin.ModelAdmin): + list_display = ("user", "product", "rating", "created_at") + search_fields = ("user__username", "product__name", "comment") + list_filter = ("rating", "created_at") + readonly_fields = ("created_at", "updated_at") + + +class OrderItemInline(admin.TabularInline): + model = OrderItem + extra = 0 + readonly_fields = ("get_subtotal",) + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ("id", "user", "total_price", "status", "created_at") + search_fields = ("user__username", "shipping_address") + list_filter = ("status", "created_at") + readonly_fields = ("created_at", "updated_at", "total_price") + inlines = [OrderItemInline] + list_editable = ("status",) + + +class OrderItemAdmin(admin.ModelAdmin): + list_display = ("order", "product", "quantity", "price", "get_subtotal") + list_filter = ("order__status",) + search_fields = ("order__id", "product__name") + readonly_fields = ("get_subtotal",) + + def get_subtotal(self, obj): + return obj.get_subtotal() + + get_subtotal.short_description = "Subtotal" + + +admin.site.register(OrderItem, OrderItemAdmin) + + +class CartItemInline(admin.TabularInline): + model = CartItem + extra = 0 + readonly_fields = ["created_at", "updated_at"] + + +@admin.register(Cart) +class CartAdmin(admin.ModelAdmin): + list_display = ["user", "get_total", "created_at", "updated_at"] + list_filter = ["created_at", "updated_at"] + search_fields = ["user__username", "user__email"] + readonly_fields = ["created_at", "updated_at"] + inlines = [CartItemInline] + + def get_total(self, obj): + return obj.get_total() + + get_total.short_description = "Total" + + def get_subtotal(self, obj): + return obj.get_subtotal() + + get_subtotal.short_description = "Subtotal" + + +@admin.register(PrivacyPolicy) +class PrivacyPolicyAdmin(admin.ModelAdmin): + list_display = ('title', 'last_updated', 'is_active') + list_filter = ('is_active', 'last_updated') + search_fields = ('title', 'content') + readonly_fields = ('last_updated',) + fieldsets = ( + ('Policy Information', { + 'fields': ('title', 'content', 'is_active') + }), + ('Timestamps', { + 'fields': ('last_updated',), + 'classes': ('collapse',) + }), + ) + + +@admin.register(FAQ) +class FAQAdmin(admin.ModelAdmin): + list_display = ('question', 'category', 'is_active', 'created_at') + list_filter = ('category', 'is_active', 'created_at') + search_fields = ('question', 'answer') + readonly_fields = ('created_at', 'updated_at') + fieldsets = ( + ('FAQ Information', { + 'fields': ('question', 'answer', 'category', 'is_active') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +@admin.register(Contact) +class ContactAdmin(admin.ModelAdmin): + list_display = ('contact_type', 'title', 'value', 'is_active', 'created_at') + list_filter = ('contact_type', 'is_active', 'created_at') + search_fields = ('title', 'value') + readonly_fields = ('created_at', 'updated_at') + fieldsets = ( + ('Contact Information', { + 'fields': ('contact_type', 'title', 'value', 'is_active') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +@admin.register(Slider) +class SliderAdmin(admin.ModelAdmin): + list_display = ('title', 'category', 'is_active', 'created_at', 'updated_at') + list_filter = ('is_active', 'created_at', 'category') + search_fields = ('title', 'description') + readonly_fields = ('created_at', 'updated_at') + fieldsets = ( + ('Slider Information', { + 'fields': ('title', 'description', 'image', 'category', 'is_active') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +@admin.register(Coupon) +class CouponAdmin(admin.ModelAdmin): + list_display = ('code', 'discount_type', 'discount_value', 'min_purchase', 'is_active', 'start_date', 'end_date') + list_filter = ('is_active', 'discount_type') + search_fields = ('code',) + ordering = ('-created_at',) diff --git a/Backend/app/apps.py b/backend/app/apps.py similarity index 100% rename from Backend/app/apps.py rename to backend/app/apps.py diff --git a/Backend/app/auth.py b/backend/app/auth.py similarity index 100% rename from Backend/app/auth.py rename to backend/app/auth.py diff --git a/backend/app/carts.py b/backend/app/carts.py new file mode 100644 index 0000000..8a5f4cc --- /dev/null +++ b/backend/app/carts.py @@ -0,0 +1,242 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from django.shortcuts import get_object_or_404 +from django.db import transaction +import logging +from .models import Cart, CartItem, Product, Order, OrderItem +from .serializers import CartSerializer, CartItemSerializer + +logger = logging.getLogger(__name__) + +class CartViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + def get_cart(self, request): + """Get or create cart for the authenticated user""" + try: + cart, created = Cart.objects.get_or_create(user=request.user) + return cart + except Exception as e: + logger.error(f"Error getting cart: {str(e)}") + return None + + @action(detail=False, methods=['get']) + def items(self, request): + """Get all items in the cart""" + try: + cart = self.get_cart(request) + if not cart: + return Response({'error': 'Failed to get cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + serializer = CartSerializer(cart, context={'request': request}) + return Response(serializer.data) + except Exception as e: + logger.error(f"Error getting cart items: {str(e)}") + return Response({'error': 'Failed to get cart items'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['post']) + def add_item(self, request): + """Add an item to the cart""" + try: + product_id = request.data.get('product_id') + quantity = int(request.data.get('quantity', 1)) + + if not product_id: + return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + product = Product.objects.get(id=product_id) + except Product.DoesNotExist: + return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) + + # Check if product is in stock + if product.stock < quantity: + return Response({ + 'error': f'Not enough stock available. Available: {product.stock}' + }, status=status.HTTP_400_BAD_REQUEST) + + cart = self.get_cart(request) + if not cart: + return Response({'error': 'Failed to get cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + cart_item = cart.add_product(product, quantity) + + if cart_item: + serializer = CartItemSerializer(cart_item, context={'request': request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response({'error': 'Invalid quantity'}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + logger.error(f"Error adding item to cart: {str(e)}") + return Response({'error': 'Failed to add item to cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['post']) + def remove_item(self, request): + """Remove an item from the cart""" + try: + product_id = request.data.get('product_id') + if not product_id: + return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + product = Product.objects.get(id=product_id) + except Product.DoesNotExist: + return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) + + cart = self.get_cart(request) + if not cart: + return Response({'error': 'Failed to get cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + cart_item = CartItem.objects.filter(cart=cart, product=product).first() + + if cart_item: + cart_item.delete() + return Response({'message': 'Item removed from cart successfully'}) + return Response({'error': 'Item not found in cart'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"Error removing item from cart: {str(e)}") + return Response({'error': 'Failed to remove item from cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['post']) + def update_quantity(self, request): + """Update the quantity of an item in the cart""" + try: + product_id = request.data.get('product_id') + quantity = int(request.data.get('quantity', 1)) + + if not product_id: + return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) + + if quantity <= 0: + return Response({'error': 'Quantity must be greater than 0'}, status=status.HTTP_400_BAD_REQUEST) + + try: + product = Product.objects.get(id=product_id) + except Product.DoesNotExist: + return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) + + # Check if product is in stock + if product.stock < quantity: + return Response({ + 'error': f'Not enough stock available. Available: {product.stock}' + }, status=status.HTTP_400_BAD_REQUEST) + + cart = self.get_cart(request) + if not cart: + return Response({'error': 'Failed to get cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + try: + cart_item = CartItem.objects.get(cart=cart, product=product) + cart_item.quantity = quantity + cart_item.save() + serializer = CartItemSerializer(cart_item, context={'request': request}) + return Response(serializer.data) + except CartItem.DoesNotExist: + return Response({'error': 'Item not found in cart'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"Error updating cart quantity: {str(e)}") + return Response({'error': 'Failed to update cart quantity'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['post']) + def clear(self, request): + """Clear all items from the cart""" + try: + cart = self.get_cart(request) + if not cart: + return Response({'error': 'Failed to get cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + cart.items.all().delete() + return Response({'message': 'Cart cleared'}) + except Exception as e: + logger.error(f"Error clearing cart: {str(e)}") + return Response({'error': 'Failed to clear cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['delete']) + def delete(self, request): + """Delete the entire cart""" + try: + cart = self.get_cart(request) + if not cart: + return Response({'error': 'Failed to get cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + cart.delete() + return Response({'message': 'Cart deleted'}, status=status.HTTP_204_NO_CONTENT) + except Exception as e: + logger.error(f"Error deleting cart: {str(e)}") + return Response({'error': 'Failed to delete cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['post']) + def checkout(self, request): + """Convert cart to order""" + try: + cart = self.get_cart(request) + if not cart: + return Response({'error': 'Failed to get cart'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + if not cart.items.exists(): + return Response({'error': 'Cart is empty'}, status=status.HTTP_400_BAD_REQUEST) + + # Check stock availability for all items + for cart_item in cart.items.all(): + if cart_item.product.stock < cart_item.quantity: + return Response({ + 'error': f'Not enough stock for {cart_item.product.name}. Available: {cart_item.product.stock}' + }, status=status.HTTP_400_BAD_REQUEST) + + with transaction.atomic(): + # Create order from cart + order = Order.objects.create( + user=request.user, + status='Pending', + shipping_address=request.user.address or '', + total_price=cart.get_total() + ) + + # Add cart items to order + for cart_item in cart.items.all(): + OrderItem.objects.create( + order=order, + product=cart_item.product, + quantity=cart_item.quantity, + price=cart_item.product.price + ) + + # Clear the cart + cart.items.all().delete() + + return Response({ + 'message': 'Order created successfully', + 'order_id': order.id + }, status=status.HTTP_201_CREATED) + except Exception as e: + logger.error(f"Error during checkout: {str(e)}") + return Response({'error': 'Failed to process checkout'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['post']) + def merge(self, request): + """Merge another cart into the current cart""" + other_cart_id = request.data.get('cart_id') + + if not other_cart_id: + return Response({'error': 'Cart ID is required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + other_cart = Cart.objects.get(id=other_cart_id) + except Cart.DoesNotExist: + return Response({'error': 'Cart not found'}, status=status.HTTP_404_NOT_FOUND) + + if other_cart.user != request.user: + return Response({'error': 'Cannot merge carts from different users'}, + status=status.HTTP_403_FORBIDDEN) + + current_cart = self.get_cart(request) + + with transaction.atomic(): + for cart_item in other_cart.items.all(): + current_cart.add_product(cart_item.product, cart_item.quantity) + + # Delete the other cart + other_cart.delete() + + return Response({'message': 'Carts merged successfully'}) \ No newline at end of file diff --git a/Backend/app/middleware.py b/backend/app/middleware.py similarity index 100% rename from Backend/app/middleware.py rename to backend/app/middleware.py diff --git a/Backend/app/migrations/0001_initial.py b/backend/app/migrations/0001_initial.py similarity index 100% rename from Backend/app/migrations/0001_initial.py rename to backend/app/migrations/0001_initial.py diff --git a/Backend/app/migrations/0002_cart_cartitem.py b/backend/app/migrations/0002_cart_cartitem.py similarity index 100% rename from Backend/app/migrations/0002_cart_cartitem.py rename to backend/app/migrations/0002_cart_cartitem.py diff --git a/Backend/app/migrations/0003_category_description_product_average_rating_and_more.py b/backend/app/migrations/0003_category_description_product_average_rating_and_more.py similarity index 100% rename from Backend/app/migrations/0003_category_description_product_average_rating_and_more.py rename to backend/app/migrations/0003_category_description_product_average_rating_and_more.py diff --git a/backend/app/migrations/0004_user_gender.py b/backend/app/migrations/0004_user_gender.py new file mode 100644 index 0000000..3009d21 --- /dev/null +++ b/backend/app/migrations/0004_user_gender.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2 on 2025-04-23 17:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("app", "0003_category_description_product_average_rating_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="gender", + field=models.CharField( + choices=[("Male", "Male"), ("Female", "Female"), ("Other", "Other")], + default="Male", + max_length=10, + ), + ), + ] diff --git a/backend/app/migrations/0005_user_profile_picture.py b/backend/app/migrations/0005_user_profile_picture.py new file mode 100644 index 0000000..1ece3b1 --- /dev/null +++ b/backend/app/migrations/0005_user_profile_picture.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-04-23 18:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0004_user_gender'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='profile_picture', + field=models.ImageField(blank=True, null=True, upload_to='profile_pictures/'), + ), + ] diff --git a/backend/app/migrations/0006_privacypolicy.py b/backend/app/migrations/0006_privacypolicy.py new file mode 100644 index 0000000..66e14d9 --- /dev/null +++ b/backend/app/migrations/0006_privacypolicy.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2 on 2025-04-23 22:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0005_user_profile_picture'), + ] + + operations = [ + migrations.CreateModel( + name='PrivacyPolicy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('content', models.TextField()), + ('last_updated', models.DateTimeField(auto_now=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name_plural': 'Privacy Policies', + }, + ), + ] diff --git a/backend/app/migrations/0007_faq.py b/backend/app/migrations/0007_faq.py new file mode 100644 index 0000000..8ff9a60 --- /dev/null +++ b/backend/app/migrations/0007_faq.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2 on 2025-04-23 22:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0006_privacypolicy'), + ] + + operations = [ + migrations.CreateModel( + name='FAQ', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question', models.CharField(max_length=255)), + ('answer', models.TextField()), + ('category', models.CharField(choices=[('All', 'All'), ('Services', 'Services'), ('General', 'General'), ('Account', 'Account')], default='General', max_length=20)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name_plural': 'FAQs', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/app/migrations/0008_contact.py b/backend/app/migrations/0008_contact.py new file mode 100644 index 0000000..cdf6e27 --- /dev/null +++ b/backend/app/migrations/0008_contact.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2 on 2025-04-23 22:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0007_faq'), + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('contact_type', models.CharField(choices=[('customer_service', 'Customer Service'), ('whatsapp', 'WhatsApp'), ('website', 'Website'), ('facebook', 'Facebook'), ('instagram', 'Instagram'), ('twitter', 'Twitter')], max_length=20)), + ('title', models.CharField(max_length=100)), + ('value', models.CharField(max_length=200)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['contact_type'], + }, + ), + ] diff --git a/backend/app/migrations/0009_alter_contact_value.py b/backend/app/migrations/0009_alter_contact_value.py new file mode 100644 index 0000000..fec695c --- /dev/null +++ b/backend/app/migrations/0009_alter_contact_value.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-04-23 22:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0008_contact'), + ] + + operations = [ + migrations.AlterField( + model_name='contact', + name='value', + field=models.CharField(max_length=255), + ), + ] diff --git a/backend/app/migrations/0010_invitemessage.py b/backend/app/migrations/0010_invitemessage.py new file mode 100644 index 0000000..fc5bbfe --- /dev/null +++ b/backend/app/migrations/0010_invitemessage.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2 on 2025-04-23 23:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0009_alter_contact_value'), + ] + + operations = [ + migrations.CreateModel( + name='InviteMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField()), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Invite Message', + 'verbose_name_plural': 'Invite Messages', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/app/migrations/0011_invitemessage_link.py b/backend/app/migrations/0011_invitemessage_link.py new file mode 100644 index 0000000..4058ec9 --- /dev/null +++ b/backend/app/migrations/0011_invitemessage_link.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-04-23 23:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0010_invitemessage'), + ] + + operations = [ + migrations.AddField( + model_name='invitemessage', + name='link', + field=models.URLField(default='https://play.google.com/store/apps/details?id=com.example.app', help_text='App store or download link', max_length=500), + ), + ] diff --git a/backend/app/migrations/0012_alter_invitemessage_link.py b/backend/app/migrations/0012_alter_invitemessage_link.py new file mode 100644 index 0000000..6275b71 --- /dev/null +++ b/backend/app/migrations/0012_alter_invitemessage_link.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-04-23 23:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0011_invitemessage_link'), + ] + + operations = [ + migrations.AlterField( + model_name='invitemessage', + name='link', + field=models.URLField(help_text='App store or download link', max_length=500), + ), + ] diff --git a/backend/app/migrations/0013_delete_invitemessage.py b/backend/app/migrations/0013_delete_invitemessage.py new file mode 100644 index 0000000..1457457 --- /dev/null +++ b/backend/app/migrations/0013_delete_invitemessage.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2 on 2025-04-23 23:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0012_alter_invitemessage_link'), + ] + + operations = [ + migrations.DeleteModel( + name='InviteMessage', + ), + ] diff --git a/backend/app/migrations/0014_slider.py b/backend/app/migrations/0014_slider.py new file mode 100644 index 0000000..1cd35aa --- /dev/null +++ b/backend/app/migrations/0014_slider.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2 on 2025-04-24 00:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0013_delete_invitemessage'), + ] + + operations = [ + migrations.CreateModel( + name='Slider', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('description', models.TextField()), + ('image', models.ImageField(upload_to='sliders/')), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='app.category')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/app/migrations/0015_alter_slider_category.py b/backend/app/migrations/0015_alter_slider_category.py new file mode 100644 index 0000000..4f7c2cd --- /dev/null +++ b/backend/app/migrations/0015_alter_slider_category.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2 on 2025-04-24 00:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0014_slider'), + ] + + operations = [ + migrations.AlterField( + model_name='slider', + name='category', + field=models.ForeignKey(blank=True, help_text="Category to show when clicking the 'Show Now' button", null=True, on_delete=django.db.models.deletion.SET_NULL, to='app.category'), + ), + ] diff --git a/backend/app/migrations/0016_alter_slider_image.py b/backend/app/migrations/0016_alter_slider_image.py new file mode 100644 index 0000000..9eb0bb4 --- /dev/null +++ b/backend/app/migrations/0016_alter_slider_image.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-04-24 00:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0015_alter_slider_category'), + ] + + operations = [ + migrations.AlterField( + model_name='slider', + name='image', + field=models.ImageField(blank=True, null=True, upload_to='sliders/'), + ), + ] diff --git a/backend/app/migrations/0017_coupon.py b/backend/app/migrations/0017_coupon.py new file mode 100644 index 0000000..8e363d3 --- /dev/null +++ b/backend/app/migrations/0017_coupon.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2 on 2025-04-24 10:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0016_alter_slider_image'), + ] + + operations = [ + migrations.CreateModel( + name='Coupon', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=50, unique=True)), + ('discount_type', models.CharField(choices=[('percentage', 'Percentage'), ('fixed', 'Fixed Amount')], default='percentage', max_length=10)), + ('discount_value', models.DecimalField(decimal_places=2, max_digits=10)), + ('min_purchase', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('max_discount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('is_active', models.BooleanField(default=True)), + ('start_date', models.DateTimeField()), + ('end_date', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Coupon', + 'verbose_name_plural': 'Coupons', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/app/migrations/0018_alter_user_gender.py b/backend/app/migrations/0018_alter_user_gender.py new file mode 100644 index 0000000..fcb7d8d --- /dev/null +++ b/backend/app/migrations/0018_alter_user_gender.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-04-24 10:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0017_coupon'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='gender', + field=models.CharField(blank=True, choices=[('Male', 'Male'), ('Female', 'Female'), ('Other', 'Other')], default='Male', max_length=10), + ), + ] diff --git a/backend/app/migrations/0019_notification.py b/backend/app/migrations/0019_notification.py new file mode 100644 index 0000000..2d8ca9c --- /dev/null +++ b/backend/app/migrations/0019_notification.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2 on 2025-04-24 11:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0018_alter_user_gender'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('order_placed', 'Order Placed'), ('order_shipped', 'Order Shipped'), ('order_delivered', 'Order Delivered'), ('order_cancelled', 'Order Cancelled'), ('coupon', 'Coupon')], max_length=20)), + ('message', models.TextField()), + ('read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('coupon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='app.coupon')), + ('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='app.order')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/Backend/app/migrations/__init__.py b/backend/app/migrations/__init__.py similarity index 100% rename from Backend/app/migrations/__init__.py rename to backend/app/migrations/__init__.py diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..95cf3c0 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,586 @@ +from django.db import models +from django.contrib.auth.hashers import make_password, check_password +from django.dispatch import receiver +from rest_framework.authtoken.models import Token +from django.db.models.signals import post_save +from django.contrib.auth.models import ( + AbstractBaseUser, + BaseUserManager, + PermissionsMixin, +) +from django.core.validators import MinValueValidator, MaxValueValidator +import logging + +logger = logging.getLogger(__name__) + + +GENDER_CHOICES = (("Male", "Male"), ("Female", "Female"), ("Other", "Other")) + + +class UserManager(BaseUserManager): + def create_user(self, username, email, password=None, **extra_fields): + if not email: + raise ValueError("Users must have an email address") + if not username: + raise ValueError("Users must have a username") + + email = self.normalize_email(email) + user = self.model(username=username, email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, username, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + return self.create_user(username, email, password, **extra_fields) + + +class User(AbstractBaseUser, PermissionsMixin): + id = models.BigAutoField(primary_key=True) + username = models.CharField(max_length=150, unique=True) + first_name = models.CharField(max_length=30) + last_name = models.CharField(max_length=30) + email = models.EmailField(unique=True) + gender = models.CharField(max_length=10, choices=GENDER_CHOICES, default="Male", blank=True) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + phone = models.CharField(max_length=15, blank=True, null=True) + address = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + phone_number = models.CharField(max_length=15, blank=True, null=True) + profile_picture = models.ImageField(upload_to='profile_pictures/', blank=True, null=True) + + objects = UserManager() + + USERNAME_FIELD = "username" + EMAIL_FIELD = "email" + REQUIRED_FIELDS = ["email", "first_name", "last_name"] + + def __str__(self): + return self.username + + def set_password(self, raw_password): + self.password = make_password(raw_password) + + def check_password(self, raw_password): + return check_password(raw_password, self.password) + + class Meta: + verbose_name = "User" + verbose_name_plural = "Users" + ordering = ["username"] + + def get_full_name(self): + return f"{self.first_name} {self.last_name}" + + def get_short_name(self): + return self.first_name + + +@receiver(post_save, sender=User) +def create_auth_token(sender, instance=None, created=False, **kwargs): + if created: + Token.objects.create(user=instance) + + +class Category(models.Model): + id = models.BigAutoField(primary_key=True) + name = models.CharField(max_length=100, unique=True) + image = models.ImageField(upload_to="categories/", blank=True, null=True) + description = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Category" + verbose_name_plural = "Categories" + ordering = ["name"] + + @property + def image_url(self): + if self.image: + return f"/media/{self.image.name}" + return None + + +class Product(models.Model): + id = models.BigAutoField(primary_key=True) + name = models.CharField(max_length=100) + description = models.TextField() + price = models.DecimalField(max_digits=10, decimal_places=2) + image = models.ImageField(upload_to="products/", blank=True, null=True) + category = models.ForeignKey( + Category, related_name="products", on_delete=models.CASCADE + ) + size = models.CharField(max_length=10, blank=True, null=True) + color = models.CharField(max_length=20, blank=True, null=True) + stock = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + average_rating = models.DecimalField(max_digits=3, decimal_places=2, default=0) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Product" + verbose_name_plural = "Products" + ordering = ["name"] + + @property + def image_url(self): + if self.image: + return f"/media/{self.image.name}" + return None + + def update_average_rating(self): + avg_rating = self.ratings.aggregate(avg_rating=models.Avg("rating"))[ + "avg_rating" + ] + self.average_rating = avg_rating or 0 + self.save() + + +class Rating(models.Model): + id = models.BigAutoField(primary_key=True) + product = models.ForeignKey( + Product, related_name="ratings", on_delete=models.CASCADE + ) + user = models.ForeignKey(User, related_name="ratings", on_delete=models.CASCADE) + rating = models.IntegerField( + validators=[MinValueValidator(1), MaxValueValidator(5)] + ) + comment = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("product", "user") + verbose_name = "Rating" + verbose_name_plural = "Ratings" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.user.username} - {self.product.name} - {self.rating}" + + +class Order(models.Model): + id = models.AutoField(primary_key=True) + user = models.ForeignKey(User, related_name="orders", on_delete=models.CASCADE) + total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) + status = models.CharField( + max_length=20, + default="Pending", + choices=[ + ("Pending", "Pending"), + ("Shipped", "Shipped"), + ("Delivered", "Delivered"), + ("Cancelled", "Cancelled"), + ], + ) + shipping_address = models.CharField(max_length=255, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Order {self.id} by {self.user.username}" + + def update_total(self): + """Automatically calculate total price from order items""" + order_items = self.order_items.all() + if not order_items.exists(): + self.total_price = 0 + else: + self.total_price = sum(item.get_subtotal() for item in order_items) + self.save() + + def add_product(self, product, quantity=1): + """Add a product to the order""" + if product is None: + return None + + if quantity is None or quantity <= 0: + quantity = 1 + + order_item, created = OrderItem.objects.get_or_create( + order=self, + product=product, + defaults={ + "quantity": quantity, + "price": product.price if product.price else 0, + }, + ) + + if not created: + order_item.quantity += quantity + order_item.save() + + self.update_total() + return order_item + + class Meta: + verbose_name = "Order" + verbose_name_plural = "Orders" + ordering = ["-created_at"] + + +class OrderItem(models.Model): + order = models.ForeignKey( + Order, related_name="order_items", on_delete=models.CASCADE + ) + product = models.ForeignKey( + Product, related_name="order_items", on_delete=models.CASCADE + ) + quantity = models.IntegerField() + price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + + def get_subtotal(self): + """Calculate the subtotal for this item""" + if self.price is None or self.quantity is None: + return 0 + return self.price * self.quantity + + def __str__(self): + return f"{self.product.name} - {self.quantity}" + + def save(self, *args, **kwargs): + # Ensure price is set correctly if it's none or a new order item + if self.price is None or (not self.pk and not self.price): + self.price = ( + self.product.price if self.product and self.product.price else 0 + ) + + # Ensure quantity is never None + if self.quantity is None: + self.quantity = 1 + + super().save(*args, **kwargs) + # Update the order total when an item is saved + if self.order: + self.order.update_total() + + def delete(self, *args, **kwargs): + order = self.order + super().delete(*args, **kwargs) + # Update the order total when an item is deleted + if order: + order.update_total() + + class Meta: + verbose_name = "Order Item" + verbose_name_plural = "Order Items" + ordering = ["order"] + + +@receiver(post_save, sender=Order) +def update_order_status(sender, instance, created, **kwargs): + """When an order status changes to Delivered, update product stock""" + if not created and instance.status == "Delivered": + for item in instance.order_items.all(): + product = item.product + # Only update stock if we haven't already updated it + if product.stock >= item.quantity: + product.stock -= item.quantity + product.save() + else: + # Log error if stock is insufficient + logger.error(f"Insufficient stock for product {product.name} when delivering order {instance.id}") + + +class Cart(models.Model): + user = models.OneToOneField(User, related_name="cart", on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Cart for {self.user.username}" + + def get_total(self): + """Calculate total price of all items in cart""" + return sum(item.get_subtotal() for item in self.items.all()) + + def add_product(self, product, quantity=1): + """Add a product to the cart""" + if quantity <= 0: + return None + + cart_item, created = CartItem.objects.get_or_create( + cart=self, product=product, defaults={"quantity": quantity} + ) + + if not created: + cart_item.quantity += quantity + cart_item.save() + + return cart_item + + def remove_product(self, product): + """Remove a product from the cart""" + try: + cart_item = CartItem.objects.get(cart=self, product=product) + cart_item.delete() + return True + except CartItem.DoesNotExist: + return False + + def clear(self): + """Remove all items from the cart""" + self.items.all().delete() + + class Meta: + verbose_name = "Cart" + verbose_name_plural = "Carts" + ordering = ["-updated_at"] + + +class CartItem(models.Model): + cart = models.ForeignKey(Cart, related_name="items", on_delete=models.CASCADE) + product = models.ForeignKey( + Product, related_name="cart_items", on_delete=models.CASCADE + ) + quantity = models.PositiveIntegerField(default=1) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.quantity} x {self.product.name} in {self.cart}" + + def get_subtotal(self): + """Calculate the subtotal for this item""" + return self.product.price * self.quantity + + class Meta: + verbose_name = "Cart Item" + verbose_name_plural = "Cart Items" + ordering = ["-updated_at"] + unique_together = ["cart", "product"] + + +@receiver(post_save, sender=User) +def create_user_cart(sender, instance, created, **kwargs): + """Create a cart for new users""" + if created: + Cart.objects.create(user=instance) + + +class Favorite(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="favorites") + product = models.ForeignKey( + Product, on_delete=models.CASCADE, related_name="favorites" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ["user", "product"] + + def __str__(self): + return f"{self.user.username}'s favorite: {self.product.name}" + + +class PrivacyPolicy(models.Model): + title = models.CharField(max_length=200) + content = models.TextField() + last_updated = models.DateTimeField(auto_now=True) + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.title + + class Meta: + verbose_name_plural = "Privacy Policies" + + +class FAQ(models.Model): + CATEGORY_CHOICES = ( + ('All', 'All'), + ('Services', 'Services'), + ('General', 'General'), + ('Account', 'Account') + ) + + question = models.CharField(max_length=255) + answer = models.TextField() + category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='General') + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.question + + class Meta: + verbose_name_plural = "FAQs" + ordering = ['-created_at'] + + +class Contact(models.Model): + CONTACT_TYPES = [ + ('customer_service', 'Customer Service'), + ('whatsapp', 'WhatsApp'), + ('website', 'Website'), + ('facebook', 'Facebook'), + ('instagram', 'Instagram'), + ('twitter', 'Twitter'), + ] + + contact_type = models.CharField(max_length=20, choices=CONTACT_TYPES) + title = models.CharField(max_length=100) + value = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['contact_type'] + + def __str__(self): + return f"{self.get_contact_type_display()} - {self.title}" + + +class Slider(models.Model): + title = models.CharField(max_length=200) + description = models.TextField() + image = models.ImageField(upload_to='sliders/', blank=True, null=True) + category = models.ForeignKey('Category', on_delete=models.SET_NULL, null=True, blank=True, help_text="Category to show when clicking the 'Show Now' button") + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.title + + class Meta: + ordering = ['-created_at'] + + @property + def image_url(self): + if self.image: + return f"/media/{self.image.name}" + return None + + +class Coupon(models.Model): + code = models.CharField(max_length=50, unique=True) + discount_type = models.CharField( + max_length=10, + choices=[ + ('percentage', 'Percentage'), + ('fixed', 'Fixed Amount') + ], + default='percentage' + ) + discount_value = models.DecimalField(max_digits=10, decimal_places=2) + min_purchase = models.DecimalField(max_digits=10, decimal_places=2, default=0) + max_discount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + is_active = models.BooleanField(default=True) + start_date = models.DateTimeField() + end_date = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.code} - {self.discount_value}{'%' if self.discount_type == 'percentage' else '$'}" + + class Meta: + verbose_name = "Coupon" + verbose_name_plural = "Coupons" + ordering = ['-created_at'] + + def is_valid(self): + from django.utils import timezone + now = timezone.now() + return self.is_active and self.start_date <= now <= self.end_date + + def calculate_discount(self, total_amount): + if not self.is_valid() or total_amount < self.min_purchase: + return 0 + + if self.discount_type == 'percentage': + discount = (total_amount * self.discount_value) / 100 + if self.max_discount: + discount = min(discount, self.max_discount) + return discount + else: + return self.discount_value + + +class Notification(models.Model): + NOTIFICATION_TYPES = ( + ('order_placed', 'Order Placed'), + ('order_shipped', 'Order Shipped'), + ('order_delivered', 'Order Delivered'), + ('order_cancelled', 'Order Cancelled'), + ('coupon', 'Coupon'), + ) + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications') + type = models.CharField(max_length=20, choices=NOTIFICATION_TYPES) + message = models.TextField() + read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + order = models.ForeignKey('Order', on_delete=models.SET_NULL, null=True, blank=True) + coupon = models.ForeignKey('Coupon', on_delete=models.SET_NULL, null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"{self.get_type_display()} - {self.user.username}" + + +@receiver(post_save, sender=Order) +def create_order_notification(sender, instance, created, **kwargs): + """Create notifications when orders are created or updated""" + if created: + # Create notification for new order + Notification.objects.create( + user=instance.user, + type='order_placed', + message=f'Your order #{instance.id} has been placed successfully', + order=instance + ) + else: + # Create notifications for order status changes + if instance.status == 'Shipped': + Notification.objects.create( + user=instance.user, + type='order_shipped', + message=f'Your order #{instance.id} has been shipped', + order=instance + ) + elif instance.status == 'Delivered': + Notification.objects.create( + user=instance.user, + type='order_delivered', + message=f'Your order #{instance.id} has been delivered', + order=instance + ) + elif instance.status == 'Cancelled': + Notification.objects.create( + user=instance.user, + type='order_cancelled', + message=f'Your order #{instance.id} has been cancelled', + order=instance + ) + + +@receiver(post_save, sender=Coupon) +def create_coupon_notification(sender, instance, created, **kwargs): + """Create notifications when new coupons are created""" + if created and instance.is_active: + # Get all active users + users = User.objects.filter(is_active=True) + # Create notifications for all users + for user in users: + Notification.objects.create( + user=user, + type='coupon', + message=f'New coupon code {instance.code} available! Get {instance.discount_value}{"%" if instance.discount_type == "percentage" else "$"} off', + coupon=instance + ) diff --git a/Backend/app/permissions.py b/backend/app/permissions.py similarity index 100% rename from Backend/app/permissions.py rename to backend/app/permissions.py diff --git a/backend/app/serializers.py b/backend/app/serializers.py new file mode 100644 index 0000000..04bcaba --- /dev/null +++ b/backend/app/serializers.py @@ -0,0 +1,326 @@ +from rest_framework import serializers +from .models import ( + User, + Category, + Product, + Rating, + Order, + OrderItem, + CartItem, + Cart, + Favorite, + PrivacyPolicy, + FAQ, + Contact, + Slider, + Coupon, + Notification +) + + +class UserSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = [ + "id", + "username", + "first_name", + "last_name", + "gender", + "email", + "password", + "phone", + "address", + "profile_picture", + "created_at", + ] + read_only_fields = ["id", "created_at"] + extra_kwargs = { + 'gender': {'required': False}, + 'phone': {'required': False}, + 'address': {'required': False}, + } + + def create(self, validated_data): + # Use the create_user method instead of direct creation + user = User.objects.create_user( + username=validated_data["username"], + email=validated_data["email"], + password=validated_data["password"], + first_name=validated_data.get("first_name", ""), + last_name=validated_data.get("last_name", ""), + phone=validated_data.get("phone", ""), + address=validated_data.get("address", ""), + gender=validated_data.get("gender", ""), + ) + return user + + +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ["id", "name", "image", "created_at"] + read_only_fields = ["id", "created_at"] + + +class RatingSerializer(serializers.ModelSerializer): + username = serializers.ReadOnlyField(source="user.username") + + class Meta: + model = Rating + fields = [ + "id", + "product", + "user", + "username", + "rating", + "comment", + "created_at", + ] + read_only_fields = ["id", "created_at", "username"] + + +class ProductSerializer(serializers.ModelSerializer): + category_name = serializers.ReadOnlyField(source="category.name") + average_rating = serializers.SerializerMethodField() + image_url = serializers.SerializerMethodField() + + class Meta: + model = Product + fields = [ + "id", + "name", + "description", + "price", + "image", + "image_url", + "category", + "stock", + "color", + "size", + "category_name", + "average_rating", + "created_at", + ] + read_only_fields = ["id", "created_at", "category_name", "average_rating", "image_url"] + + def get_average_rating(self, obj): + try: + return float(obj.average_rating) if obj.average_rating is not None else 0.0 + except (TypeError, AttributeError): + return 0.0 + + def get_image_url(self, obj): + if obj.image: + request = self.context.get('request') + if request is not None: + return request.build_absolute_uri(obj.image.url) + return obj.image.url + return None + + +class ProductDetailSerializer(ProductSerializer): + ratings = RatingSerializer(many=True, read_only=True) + + class Meta(ProductSerializer.Meta): + fields = ProductSerializer.Meta.fields + ["ratings"] + + +class OrderItemSerializer(serializers.ModelSerializer): + product_name = serializers.ReadOnlyField(source="product.name") + product_image = serializers.ImageField(source="product.image", read_only=True) + subtotal = serializers.SerializerMethodField() + + class Meta: + model = OrderItem + fields = [ + "id", + "product", + "product_name", + "product_image", + "quantity", + "price", + "subtotal", + ] + read_only_fields = ["id", "product_name", "product_image", "subtotal"] + + def get_subtotal(self, obj): + return obj.get_subtotal() + + +class OrderItemCreateSerializer(serializers.ModelSerializer): + class Meta: + model = OrderItem + fields = ["product", "quantity"] + + +class OrderSerializer(serializers.ModelSerializer): + user_name = serializers.ReadOnlyField(source="user.get_full_name") + order_items = OrderItemSerializer(many=True, read_only=True) + items = OrderItemCreateSerializer(many=True, write_only=True, required=False) + + class Meta: + model = Order + fields = [ + "id", + "user", + "user_name", + "total_price", + "status", + "shipping_address", + "created_at", + "order_items", + "items", + ] + read_only_fields = [ + "id", + "created_at", + "user_name", + "total_price", + "order_items", + ] + + def create(self, validated_data): + items_data = validated_data.pop("items", []) + order = Order.objects.create(**validated_data) + + # Create order items if provided + for item_data in items_data: + product = item_data.get("product") + quantity = item_data.get("quantity", 1) + order.add_product(product, quantity) + + return order + + def update(self, instance, validated_data): + items_data = validated_data.pop("items", []) + + # Update the order fields + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + # Add new items if provided + for item_data in items_data: + product = item_data.get("product") + quantity = item_data.get("quantity", 1) + instance.add_product(product, quantity) + + return instance + + +class CartItemSerializer(serializers.ModelSerializer): + product_name = serializers.CharField(source="product.name", read_only=True) + product_price = serializers.SerializerMethodField() + product_image = serializers.SerializerMethodField() + subtotal = serializers.SerializerMethodField() + + class Meta: + model = CartItem + fields = [ + "id", + "product", + "product_name", + "product_price", + "product_image", + "quantity", + "subtotal", + "created_at", + "updated_at", + ] + read_only_fields = ["created_at", "updated_at"] + + def get_subtotal(self, obj): + try: + return float(obj.product.price * obj.quantity) + except (AttributeError, TypeError): + return 0.0 + + def get_product_price(self, obj): + try: + return float(obj.product.price) + except (AttributeError, TypeError): + return 0.0 + + def get_product_image(self, obj): + if obj.product and obj.product.image: + request = self.context.get("request") + if request is not None: + return request.build_absolute_uri(obj.product.image.url) + return obj.product.image.url + return None + + +class CartSerializer(serializers.ModelSerializer): + items = CartItemSerializer(many=True, read_only=True) + total = serializers.SerializerMethodField() + + class Meta: + model = Cart + fields = ["id", "user", "items", "total", "created_at", "updated_at"] + read_only_fields = ["created_at", "updated_at"] + + def get_total(self, obj): + return obj.get_total() + + +class FavoriteSerializer(serializers.ModelSerializer): + product = ProductSerializer(read_only=True) + + class Meta: + model = Favorite + fields = ["id", "product", "created_at"] + read_only_fields = ["id", "created_at"] + + +class PrivacyPolicySerializer(serializers.ModelSerializer): + class Meta: + model = PrivacyPolicy + fields = ['id', 'title', 'content', 'last_updated'] + + +class FAQSerializer(serializers.ModelSerializer): + class Meta: + model = FAQ + fields = ['id', 'question', 'answer', 'category', 'is_active', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class ContactSerializer(serializers.ModelSerializer): + class Meta: + model = Contact + fields = ['id', 'contact_type', 'title', 'value', 'is_active', 'created_at', 'updated_at'] + read_only_fields = ['created_at', 'updated_at'] + + +class SliderSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() + + class Meta: + model = Slider + fields = ('id', 'title', 'description', 'image', 'image_url', 'category', 'is_active', 'created_at', 'updated_at') + read_only_fields = ('created_at', 'updated_at') + + def get_image_url(self, obj): + if obj.image: + return self.context['request'].build_absolute_uri(obj.image_url) + return None + + +class CouponSerializer(serializers.ModelSerializer): + class Meta: + model = Coupon + fields = ['id', 'code', 'discount_type', 'discount_value', 'min_purchase', + 'max_discount', 'is_active', 'start_date', 'end_date'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class NotificationSerializer(serializers.ModelSerializer): + class Meta: + model = Notification + fields = ['id', 'type', 'message', 'read', 'created_at', 'order', 'coupon'] + read_only_fields = ['created_at'] + + diff --git a/Backend/app/tests.py b/backend/app/tests.py similarity index 100% rename from Backend/app/tests.py rename to backend/app/tests.py diff --git a/Backend/app/urls.py b/backend/app/urls.py similarity index 59% rename from Backend/app/urls.py rename to backend/app/urls.py index 96b2987..d186b90 100644 --- a/Backend/app/urls.py +++ b/backend/app/urls.py @@ -6,7 +6,9 @@ from .auth import CustomAuthToken, LogoutView from .views import ( UserViewSet, CategoryViewSet, ProductViewSet, RatingViewSet, OrderViewSet, OrderItemViewSet, get_csrf_token, serve_protected_media, - FavoriteViewSet + FavoriteViewSet, PrivacyPolicyViewSet, FAQViewSet, ContactViewSet, + SliderViewSet, CouponViewSet, ValidateCouponView, RegisterView, + NotificationViewSet ) router = DefaultRouter() @@ -17,19 +19,23 @@ router.register(r'ratings', RatingViewSet) router.register(r'orders', OrderViewSet) router.register(r'order-items', OrderItemViewSet) router.register(r'favorites', FavoriteViewSet, basename='favorite') - -# Cart endpoints -cart_router = DefaultRouter() -cart_router.register(r'cart', CartViewSet, basename='cart') +router.register(r'cart', CartViewSet, basename='cart') +router.register(r'privacy-policies', PrivacyPolicyViewSet, basename='privacy-policy') +router.register(r'faqs', FAQViewSet, basename='faq') +router.register(r'contacts', ContactViewSet, basename='contact') +router.register(r'sliders', SliderViewSet) +router.register(r'coupons', CouponViewSet) +router.register(r'notifications', NotificationViewSet, basename='notification') urlpatterns = [ path('', include(router.urls)), - path('', include(cart_router.urls)), path('auth/', include('rest_framework.urls', namespace='rest_framework')), # Token authentication endpoints path('get-csrf-token/', get_csrf_token, name='get-csrf-token'), path('token-auth/', CustomAuthToken.as_view(), name='token_auth'), path('logout/', LogoutView.as_view(), name='logout'), + path('auth/register/', RegisterView.as_view(), name='register'), path('media/', serve_protected_media, name='serve_protected_media'), + path('validate-coupon/', ValidateCouponView.as_view(), name='validate-coupon'), ] \ No newline at end of file diff --git a/backend/app/views.py b/backend/app/views.py new file mode 100644 index 0000000..095dfac --- /dev/null +++ b/backend/app/views.py @@ -0,0 +1,685 @@ +from django.conf import settings +from rest_framework import viewsets, status, filters, permissions +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated, IsAdminUser, AllowAny, IsAuthenticatedOrReadOnly +from django.db.models import Avg +from django.shortcuts import get_object_or_404 +from django.http import HttpResponseForbidden, JsonResponse +from django.views.decorators.csrf import ensure_csrf_cookie +from django.middleware.csrf import get_token +from rest_framework.exceptions import PermissionDenied +from django.conf import settings +from django.http import FileResponse, HttpResponseForbidden +from django.views.decorators.http import require_GET +from rest_framework.authentication import TokenAuthentication +from rest_framework.decorators import authentication_classes, permission_classes +from rest_framework.permissions import IsAuthenticated +import os +from .models import User, Category, Product, Rating, Order, OrderItem, Favorite, PrivacyPolicy, FAQ, Contact, Slider, Coupon, Notification +from .serializers import ( + UserSerializer, CategorySerializer, ProductSerializer, ProductDetailSerializer, + RatingSerializer, OrderSerializer, OrderItemSerializer, FavoriteSerializer, PrivacyPolicySerializer, + FAQSerializer, ContactSerializer, SliderSerializer, CouponSerializer, NotificationSerializer +) +from django.contrib.auth import authenticate +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView + +# Add RegisterView class for user registration +class RegisterView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = UserSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.save() + # Create auth token for the new user + token, created = Token.objects.get_or_create(user=user) + return Response({ + 'token': token.key, + 'user_id': user.id, + 'username': user.username, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'message': 'User registered successfully' + }, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class UserViewSet(viewsets.ModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + filter_backends = [filters.SearchFilter] + search_fields = ['username', 'email', 'first_name', 'last_name'] + permission_classes = [AllowAny] + + def get_queryset(self): + # If user_id query param is provided, filter by that + user_id = self.request.query_params.get('user_id', None) + if user_id: + return User.objects.filter(id=user_id) + + # If no specific user_id is requested, and user is staff, show all + if self.request.user.is_authenticated and self.request.user.is_staff: + return User.objects.all() + + # For anonymous or non-staff users requesting the list endpoint, return empty queryset + if self.action == 'list': + return User.objects.none() + + # For other actions like retrieve, let Django's permission system handle it + return User.objects.all() + + @action(detail=False, methods=['get']) + def me(self, request): + """Get current user's profile""" + if not request.user.is_authenticated: + return Response({'error': 'Authentication required'}, status=status.HTTP_401_UNAUTHORIZED) + + serializer = self.get_serializer(request.user) + return Response(serializer.data) + + @action(detail=False, methods=['post']) + def update_profile_picture(self, request): + """Update the current user's profile picture""" + if not request.user.is_authenticated: + return Response({'error': 'Authentication required'}, status=status.HTTP_401_UNAUTHORIZED) + + if 'profile_picture' not in request.FILES: + return Response({'error': 'No profile picture provided'}, status=status.HTTP_400_BAD_REQUEST) + + try: + user = request.user + # Save the new profile picture + user.profile_picture = request.FILES['profile_picture'] + user.save() + + # Return the updated user data + serializer = self.get_serializer(user) + response_data = serializer.data + + # Add the profile picture URL to the response + if user.profile_picture: + response_data['profile_picture'] = request.build_absolute_uri(user.profile_picture.url) + + return Response(response_data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=['post']) + def update_profile(self, request): + """Update the current user's profile details""" + if not request.user.is_authenticated: + return Response({'error': 'Authentication required'}, status=status.HTTP_401_UNAUTHORIZED) + + try: + user = request.user + # Update user details + user.username = request.data.get('username', user.username) + user.first_name = request.data.get('first_name', user.first_name) + user.last_name = request.data.get('last_name', user.last_name) + user.phone = request.data.get('phone', user.phone) + user.email = request.data.get('email', user.email) + user.gender = request.data.get('gender', user.gender) + user.address = request.data.get('address', user.address) + user.save() + + # Return the updated user data + serializer = self.get_serializer(user) + return Response(serializer.data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=['post']) + def login(self, request): + """Login user and return token""" + username = request.data.get('username') + password = request.data.get('password') + + if not username or not password: + return Response( + {'error': 'Please provide both username and password'}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = authenticate(username=username, password=password) + if not user: + return Response( + {'error': 'Invalid credentials'}, + status=status.HTTP_401_UNAUTHORIZED + ) + + token, _ = Token.objects.get_or_create(user=user) + serializer = self.get_serializer(user) + + return Response({ + 'token': token.key, + 'user': serializer.data + }) + + @action(detail=False, methods=['get']) + def gender_options(self, request): + """Get available gender options""" + options = [ + {'value': 'Male', 'label': 'Male'}, + {'value': 'Female', 'label': 'Female'}, + {'value': 'Other', 'label': 'Other'} + ] + return Response(options) + + @action(detail=False, methods=['post']) + def update_password(self, request): + """Update the current user's password""" + if not request.user.is_authenticated: + return Response({'error': 'Authentication required'}, status=status.HTTP_401_UNAUTHORIZED) + + current_password = request.data.get('current_password') + new_password = request.data.get('new_password') + + if not current_password or not new_password: + return Response({'error': 'Both current and new passwords are required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + user = request.user + # Verify current password + if not user.check_password(current_password): + return Response({'error': 'Current password is incorrect'}, status=status.HTTP_400_BAD_REQUEST) + + # Update password + user.set_password(new_password) + user.save() + + return Response({'message': 'Password updated successfully'}) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=['post']) + def delete_account(self, request): + """Delete the current user's account""" + if not request.user.is_authenticated: + return Response({'error': 'Authentication required'}, status=status.HTTP_401_UNAUTHORIZED) + + password = request.data.get('password') + + if not password: + return Response({'error': 'Password is required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + user = request.user + # Verify password + if not user.check_password(password): + return Response({'error': 'Password is incorrect'}, status=status.HTTP_400_BAD_REQUEST) + + # Delete the user + user.delete() + + return Response({'message': 'Account deleted successfully'}) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class CategoryViewSet(viewsets.ModelViewSet): + queryset = Category.objects.all() + serializer_class = CategorySerializer + filter_backends = [filters.SearchFilter] + search_fields = ['name'] + permission_classes = [AllowAny] + + def get_permissions(self): + if self.action in ['create', 'update', 'partial_update', 'destroy']: + permission_classes = [IsAdminUser] + else: + permission_classes = [AllowAny] + return [permission() for permission in permission_classes] + + @action(detail=True, methods=['get']) + def products(self, request, pk=None): + try: + # Validate that pk is a valid integer + try: + category_id = int(pk) + except (ValueError, TypeError): + return Response( + {"error": "Invalid category ID. Please provide a valid integer."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get the category + try: + category = Category.objects.get(id=category_id) + except Category.DoesNotExist: + return Response( + {"error": f"Category with ID {category_id} not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Get products with related category + products = Product.objects.filter(category=category).select_related('category') + + # Serialize the products + serializer = ProductSerializer(products, many=True, context={'request': request}) + + return Response(serializer.data) + except Exception as e: + print(f"Error in products action: {str(e)}") + print(f"Error type: {type(e)}") + import traceback + print(traceback.format_exc()) + return Response( + {"error": f"An error occurred while fetching products: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class ProductViewSet(viewsets.ModelViewSet): + queryset = Product.objects.all() + serializer_class = ProductSerializer + filter_backends = [filters.SearchFilter, filters.OrderingFilter] + search_fields = ['name', 'description', 'category__name'] + ordering_fields = ['name', 'price', 'created_at'] + permission_classes = [AllowAny] + + def get_permissions(self): + if self.action in ['create', 'update', 'partial_update', 'destroy']: + permission_classes = [IsAdminUser] + else: + permission_classes = [AllowAny] + return [permission() for permission in permission_classes] + + def get_serializer_class(self): + if self.action == 'retrieve': + return ProductDetailSerializer + return ProductSerializer + + @action(detail=True, methods=['post']) + def rate(self, request, pk=None): + # Still require authentication for rating + if not request.user.is_authenticated: + return Response({'error': 'Authentication required'}, status=status.HTTP_401_UNAUTHORIZED) + + product = self.get_object() + rating_value = request.data.get('rating') + comment = request.data.get('comment', '') + + if not rating_value: + return Response({'error': 'Rating is required'}, status=status.HTTP_400_BAD_REQUEST) + + # Update or create rating + rating, created = Rating.objects.update_or_create( + product=product, + user=request.user, + defaults={'rating': rating_value, 'comment': comment} + ) + + serializer = RatingSerializer(rating) + return Response(serializer.data) + + +class RatingViewSet(viewsets.ModelViewSet): + queryset = Rating.objects.all() + serializer_class = RatingSerializer + permission_classes = [AllowAny] + + def get_permissions(self): + if self.action in ['update', 'partial_update', 'destroy']: + permission_classes = [IsAuthenticated] + else: + permission_classes = [AllowAny] + return [permission() for permission in permission_classes] + + def perform_create(self, serializer): + # Still require authentication for creating ratings + if not self.request.user.is_authenticated: + raise PermissionDenied('Authentication required to create ratings') + serializer.save(user=self.request.user) + + +class OrderViewSet(viewsets.ModelViewSet): + queryset = Order.objects.all() + serializer_class = OrderSerializer + filter_backends = [filters.OrderingFilter] + ordering_fields = ['created_at', 'status'] + permission_classes = [AllowAny] + + def get_queryset(self): + user_id = self.request.query_params.get('user_id', None) + if user_id: + return Order.objects.filter(user_id=user_id) + + if self.request.user.is_authenticated: + if self.request.user.is_staff: + return Order.objects.all() + return Order.objects.filter(user=self.request.user) + + # For anonymous users, return empty queryset + return Order.objects.none() + + def perform_create(self, serializer): + # For creating orders, require authentication + if not self.request.user.is_authenticated: + raise PermissionDenied('Authentication required to create orders') + serializer.save(user=self.request.user) + + def update(self, request, *args, **kwargs): + """Override update to handle status changes and stock management""" + instance = self.get_object() + new_status = request.data.get('status') + + # Check if status is being updated + if new_status and new_status != instance.status: + # Validate status transition + valid_transitions = { + 'Pending': ['Shipped', 'Cancelled'], + 'Shipped': ['Delivered', 'Cancelled'], + 'Delivered': [], # No transitions allowed from Delivered + 'Cancelled': [] # No transitions allowed from Cancelled + } + + if new_status not in valid_transitions.get(instance.status, []): + return Response({ + 'error': f'Cannot transition from {instance.status} to {new_status}' + }, status=status.HTTP_400_BAD_REQUEST) + + # Check stock availability for Shipped status + if new_status == 'Shipped': + for item in instance.order_items.all(): + if item.product.stock < item.quantity: + return Response({ + 'error': f'Not enough stock for {item.product.name}. Available: {item.product.stock}' + }, status=status.HTTP_400_BAD_REQUEST) + + # Proceed with update + return super().update(request, *args, **kwargs) + + @action(detail=True, methods=['post']) + def cancel(self, request, pk=None): + order = self.get_object() + + # Check if order belongs to current user or user is admin + if self.request.user.is_authenticated and (order.user.id == request.user.id or request.user.is_staff): + # Check if order can be canceled + if order.status not in ['Pending', 'Shipped']: + return Response({'error': 'Cannot cancel order in current status'}, + status=status.HTTP_400_BAD_REQUEST) + + # Update order status + order.status = 'Cancelled' + order.save() + + # Notification will be created automatically via signal + + serializer = OrderSerializer(order) + return Response(serializer.data) + else: + return Response({'error': 'Not authorized'}, status=status.HTTP_403_FORBIDDEN) + + @action(detail=True, methods=['post']) + def ship(self, request, pk=None): + """Mark an order as shipped""" + order = self.get_object() + + # Check if user is staff + if not request.user.is_staff: + return Response({'error': 'Only staff can ship orders'}, + status=status.HTTP_403_FORBIDDEN) + + # Check if order can be shipped + if order.status != 'Pending': + return Response({'error': 'Can only ship pending orders'}, + status=status.HTTP_400_BAD_REQUEST) + + # Check stock availability + for item in order.order_items.all(): + if item.product.stock < item.quantity: + return Response({ + 'error': f'Not enough stock for {item.product.name}. Available: {item.product.stock}' + }, status=status.HTTP_400_BAD_REQUEST) + + # Update order status + order.status = 'Shipped' + order.save() + + # Notification will be created automatically via signal + + serializer = OrderSerializer(order) + return Response(serializer.data) + + @action(detail=True, methods=['post']) + def deliver(self, request, pk=None): + """Mark an order as delivered""" + order = self.get_object() + + # Check if user is staff + if not request.user.is_staff: + return Response({'error': 'Only staff can mark orders as delivered'}, + status=status.HTTP_403_FORBIDDEN) + + # Check if order can be delivered + if order.status != 'Shipped': + return Response({'error': 'Can only deliver shipped orders'}, + status=status.HTTP_400_BAD_REQUEST) + + # Update order status + order.status = 'Delivered' + order.save() + + # Notification will be created automatically via signal + + serializer = OrderSerializer(order) + return Response(serializer.data) + + +class OrderItemViewSet(viewsets.ModelViewSet): + queryset = OrderItem.objects.all() + serializer_class = OrderItemSerializer + permission_classes = [AllowAny] + + def get_queryset(self): + user_id = self.request.query_params.get('user_id', None) + if user_id: + return OrderItem.objects.filter(order__user_id=user_id) + + if self.request.user.is_authenticated: + if self.request.user.is_staff: + return OrderItem.objects.all() + return OrderItem.objects.filter(order__user=self.request.user) + + # For anonymous users, return empty queryset + return OrderItem.objects.none() + +def get_csrf_token(request): + """ + Return CSRF token for the frontend to use in subsequent requests + """ + csrf_token = get_token(request) + return JsonResponse({'csrfToken': csrf_token}) + +@require_GET +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def serve_protected_media(request, path): + """ + Serve media files with authentication check. + The path parameter should be the relative path within MEDIA_ROOT. + """ + # Construct the full path + file_path = os.path.join(settings.MEDIA_ROOT, path) + + # Basic security check to prevent directory traversal + if not os.path.abspath(file_path).startswith(os.path.abspath(settings.MEDIA_ROOT)): + return HttpResponseForbidden('Invalid path') + + # Check if file exists + if not os.path.exists(file_path): + return HttpResponseForbidden('File not found') + + # Serve the file + return FileResponse(open(file_path, 'rb')) + +class FavoriteViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + def list(self, request): + """Get all favorites for the current user""" + favorites = Favorite.objects.filter(user=request.user) + serializer = FavoriteSerializer(favorites, many=True, context={'request': request}) + return Response(serializer.data) + + @action(detail=False, methods=['post']) + def toggle(self, request): + """Toggle favorite status for a product""" + product_id = request.data.get('product_id') + if not product_id: + return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + product = Product.objects.get(id=product_id) + except Product.DoesNotExist: + return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) + + # Check if favorite exists + favorite = Favorite.objects.filter(user=request.user, product=product).first() + + if favorite: + # Remove from favorites + favorite.delete() + return Response({'message': 'Product removed from favorites'}) + else: + # Add to favorites + Favorite.objects.create(user=request.user, product=product) + return Response({'message': 'Product added to favorites'}) + + # Return updated favorites list + favorites = Favorite.objects.filter(user=request.user) + serializer = FavoriteSerializer(favorites, many=True, context={'request': request}) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def check(self, request): + """Check if a product is favorited""" + product_id = request.query_params.get('product_id') + if not product_id: + return Response({'error': 'Product ID is required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + product = Product.objects.get(id=product_id) + except Product.DoesNotExist: + return Response({'error': 'Product not found'}, status=status.HTTP_404_NOT_FOUND) + + is_favorited = Favorite.objects.filter(user=request.user, product=product).exists() + return Response({'is_favorited': is_favorited}) + +class PrivacyPolicyViewSet(viewsets.ModelViewSet): + queryset = PrivacyPolicy.objects.filter(is_active=True) + serializer_class = PrivacyPolicySerializer + permission_classes = [AllowAny] + + def get_queryset(self): + return PrivacyPolicy.objects.filter(is_active=True).order_by('-last_updated') + +class FAQViewSet(viewsets.ModelViewSet): + queryset = FAQ.objects.filter(is_active=True) + serializer_class = FAQSerializer + permission_classes = [AllowAny] + + def get_queryset(self): + category = self.request.query_params.get('category', None) + if category and category != 'All': + return FAQ.objects.filter(category=category, is_active=True) + return FAQ.objects.filter(is_active=True) + + @action(detail=False, methods=['get']) + def categories(self, request): + """Get all available FAQ categories""" + categories = FAQ.CATEGORY_CHOICES + return Response([{'value': value, 'label': label} for value, label in categories]) + +class ContactViewSet(viewsets.ModelViewSet): + queryset = Contact.objects.filter(is_active=True) + serializer_class = ContactSerializer + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + + def get_queryset(self): + return Contact.objects.filter(is_active=True).order_by('contact_type') + +class SliderViewSet(viewsets.ModelViewSet): + queryset = Slider.objects.filter(is_active=True) + serializer_class = SliderSerializer + permission_classes = [AllowAny] + + def get_queryset(self): + return Slider.objects.filter(is_active=True).order_by('-created_at') + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + +class CouponViewSet(viewsets.ModelViewSet): + queryset = Coupon.objects.all() + serializer_class = CouponSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + + def get_queryset(self): + queryset = Coupon.objects.filter(is_active=True) + code = self.request.query_params.get('code', None) + if code: + queryset = queryset.filter(code=code) + return queryset + +class ValidateCouponView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + code = request.data.get('code') + cart_total = request.data.get('cart_total', 0) + + try: + coupon = Coupon.objects.get(code=code, is_active=True) + if not coupon.is_valid(): + return Response({ + 'valid': False, + 'message': 'Coupon has expired or is not yet active' + }, status=400) + + if cart_total < coupon.min_purchase: + return Response({ + 'valid': False, + 'message': f'Minimum purchase amount of ${coupon.min_purchase} required' + }, status=400) + + discount = coupon.calculate_discount(cart_total) + return Response({ + 'valid': True, + 'discount': discount, + 'coupon': CouponSerializer(coupon).data + }) + + except Coupon.DoesNotExist: + return Response({ + 'valid': False, + 'message': 'Invalid coupon code' + }, status=400) + +class NotificationViewSet(viewsets.ModelViewSet): + serializer_class = NotificationSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Notification.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + @action(detail=True, methods=['put']) + def read(self, request, pk=None): + notification = self.get_object() + notification.read = True + notification.save() + return Response(self.get_serializer(notification).data) + + @action(detail=False, methods=['delete']) + def clear_all(self, request): + self.get_queryset().delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/Backend/main.py b/backend/main.py similarity index 100% rename from Backend/main.py rename to backend/main.py diff --git a/Backend/manage.py b/backend/manage.py similarity index 100% rename from Backend/manage.py rename to backend/manage.py diff --git a/Backend/pyproject.toml b/backend/pyproject.toml similarity index 89% rename from Backend/pyproject.toml rename to backend/pyproject.toml index 16d3dcb..eaf7a10 100644 --- a/Backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "django>=5.2", + "django-cors-headers>=4.7.0", "django-jazzmin>=3.0.1", "django-rest-framework>=0.1.0", "djangorestframework>=3.16.0", diff --git a/Backend/requirements.txt b/backend/requirements.txt similarity index 83% rename from Backend/requirements.txt rename to backend/requirements.txt index e12e731..4328ad0 100644 --- a/Backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,6 @@ asgiref==3.8.1 django==5.2 +django-cors-headers==4.7.0 django-jazzmin==3.0.1 django-rest-framework==0.1.0 djangorestframework==3.16.0 diff --git a/Backend/shoppingApp/__init__.py b/backend/shoppingApp/__init__.py similarity index 100% rename from Backend/shoppingApp/__init__.py rename to backend/shoppingApp/__init__.py diff --git a/Backend/shoppingApp/asgi.py b/backend/shoppingApp/asgi.py similarity index 100% rename from Backend/shoppingApp/asgi.py rename to backend/shoppingApp/asgi.py diff --git a/Backend/shoppingApp/settings.py b/backend/shoppingApp/settings.py similarity index 80% rename from Backend/shoppingApp/settings.py rename to backend/shoppingApp/settings.py index 352951b..52b111c 100644 --- a/Backend/shoppingApp/settings.py +++ b/backend/shoppingApp/settings.py @@ -47,6 +47,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', @@ -79,14 +80,46 @@ TEMPLATES = [ ] # CORS settings -CORS_ORIGIN_ALLOW_ALL = True -CORS_ALLOW_ALL_ORIGINS = True -CORS_ALLOW_CREDENTIALS = True - CORS_ALLOWED_ORIGINS = [ - 'http://192.168.1.75:8081', - 'http://192.168.1.56:8081', + "http://192.168.18.4:8000", + "http://192.168.18.4:19006", + "http://localhost:19006", + "http://localhost:8000", + "http://127.0.0.1:19006", + "http://127.0.0.1:8000", ] + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = True # For development only +CORS_ORIGIN_ALLOW_ALL = True # For development only + +CORS_ALLOW_METHODS = [ + 'DELETE', + 'GET', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT', +] + +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] + +# Session settings +SESSION_COOKIE_SAMESITE = 'Lax' +SESSION_COOKIE_SECURE = False # Set to True in production with HTTPS +CSRF_COOKIE_SAMESITE = 'Lax' +CSRF_COOKIE_SECURE = False # Set to True in production with HTTPS + WSGI_APPLICATION = 'shoppingApp.wsgi.application' diff --git a/Backend/shoppingApp/urls.py b/backend/shoppingApp/urls.py similarity index 100% rename from Backend/shoppingApp/urls.py rename to backend/shoppingApp/urls.py diff --git a/Backend/shoppingApp/wsgi.py b/backend/shoppingApp/wsgi.py similarity index 100% rename from Backend/shoppingApp/wsgi.py rename to backend/shoppingApp/wsgi.py diff --git a/Backend/uv.lock b/backend/uv.lock similarity index 93% rename from Backend/uv.lock rename to backend/uv.lock index 743def3..cd50dd1 100644 --- a/Backend/uv.lock +++ b/backend/uv.lock @@ -17,6 +17,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "django" }, + { name = "django-cors-headers" }, { name = "django-jazzmin" }, { name = "django-rest-framework" }, { name = "djangorestframework" }, @@ -26,6 +27,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "django", specifier = ">=5.2" }, + { name = "django-cors-headers", specifier = ">=4.7.0" }, { name = "django-jazzmin", specifier = ">=3.0.1" }, { name = "django-rest-framework", specifier = ">=0.1.0" }, { name = "djangorestframework", specifier = ">=3.16.0" }, @@ -46,6 +48,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/e0/6a5b5ea350c5bd63fe94b05e4c146c18facb51229d9dee42aa39f9fc2214/Django-5.2-py3-none-any.whl", hash = "sha256:91ceed4e3a6db5aedced65e3c8f963118ea9ba753fc620831c77074e620e7d83", size = 8301361 }, ] +[[package]] +name = "django-cors-headers" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/6c/16f6cb6064c63074fd5b2bd494eb319afd846236d9c1a6c765946df2c289/django_cors_headers-4.7.0.tar.gz", hash = "sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b", size = 21037 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/a2/7bcfff86314bd9dd698180e31ba00604001606efb518a06cca6833a54285/django_cors_headers-4.7.0-py3-none-any.whl", hash = "sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070", size = 12794 }, +] + [[package]] name = "django-jazzmin" version = "3.0.1" diff --git a/frontend/.expo/types/router.d.ts b/frontend/.expo/types/router.d.ts index 1967c85..ec588dd 100644 --- a/frontend/.expo/types/router.d.ts +++ b/frontend/.expo/types/router.d.ts @@ -6,9 +6,9 @@ export * from 'expo-router'; declare module 'expo-router' { export namespace ExpoRouter { export interface __routes { - hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/api`; params?: Router.UnknownInputParams; } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `/components/ActiveOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/CancelledOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/Category`; params?: Router.UnknownInputParams; } | { pathname: `/components/CompletedOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/ContactUsCenter`; params?: Router.UnknownInputParams; } | { pathname: `/components/FaqCenter`; params?: Router.UnknownInputParams; } | { pathname: `/components/FavoriteButton`; params?: Router.UnknownInputParams; } | { pathname: `/components/FlashSales`; params?: Router.UnknownInputParams; } | { pathname: `/components/HomeCarousel`; params?: Router.UnknownInputParams; } | { pathname: `/components/ItemCard`; params?: Router.UnknownInputParams; } | { pathname: `/components/ProductQuantityInput`; params?: Router.UnknownInputParams; } | { pathname: `/constants/clothColor`; params?: Router.UnknownInputParams; } | { pathname: `/constants/couponData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/flashSalesData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/myCartData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/paymentData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/recentData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/subShipping`; params?: Router.UnknownInputParams; } | { pathname: `/constants/usersData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/wishlistData`; params?: Router.UnknownInputParams; } | { pathname: `/context/CartContext`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AccessLocation`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AddCardScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CheckOutScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ChooseShippingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CompleteYourProfileScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ConfirmPayment`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CouponScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FashionScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FilterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FlashSalesScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/HelpCenterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/HomeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/InviteFriendsScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/JacketScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/LeaveReviewScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/LocationScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/MyCartScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/NewPasswordScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/NotificationScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/OnboardingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/OrderScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PasswordManagerScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PaymentMethodScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PaymentSuccess`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PrivacyPolicyScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProductDetails`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProductDetailsScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProfileScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/RegisterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SearchResultScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SearchScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SettingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ShippingAddressScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SignInScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/TrackOrderScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/VerifyCodeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/WellcomeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/Wishlist`; params?: Router.UnknownInputParams; } | { pathname: `/screens/WishlistScreen`; params?: Router.UnknownInputParams; } | { pathname: `/themes/BottomTabBar`; params?: Router.UnknownInputParams; } | { pathname: `/themes/TabBar`; params?: Router.UnknownInputParams; }; - hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/api`; params?: Router.UnknownOutputParams; } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; } | { pathname: `/components/ActiveOrder`; params?: Router.UnknownOutputParams; } | { pathname: `/components/CancelledOrder`; params?: Router.UnknownOutputParams; } | { pathname: `/components/Category`; params?: Router.UnknownOutputParams; } | { pathname: `/components/CompletedOrder`; params?: Router.UnknownOutputParams; } | { pathname: `/components/ContactUsCenter`; params?: Router.UnknownOutputParams; } | { pathname: `/components/FaqCenter`; params?: Router.UnknownOutputParams; } | { pathname: `/components/FavoriteButton`; params?: Router.UnknownOutputParams; } | { pathname: `/components/FlashSales`; params?: Router.UnknownOutputParams; } | { pathname: `/components/HomeCarousel`; params?: Router.UnknownOutputParams; } | { pathname: `/components/ItemCard`; params?: Router.UnknownOutputParams; } | { pathname: `/components/ProductQuantityInput`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/clothColor`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/couponData`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/flashSalesData`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/myCartData`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/paymentData`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/recentData`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/subShipping`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/usersData`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/wishlistData`; params?: Router.UnknownOutputParams; } | { pathname: `/context/CartContext`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/AccessLocation`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/AddCardScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/CheckOutScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ChooseShippingScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/CompleteYourProfileScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ConfirmPayment`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/CouponScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/FashionScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/FilterScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/FlashSalesScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/HelpCenterScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/HomeScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/InviteFriendsScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/JacketScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/LeaveReviewScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/LocationScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/MyCartScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/NewPasswordScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/NotificationScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/OnboardingScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/OrderScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/PasswordManagerScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/PaymentMethodScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/PaymentSuccess`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/PrivacyPolicyScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ProductDetails`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ProductDetailsScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ProfileScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/RegisterScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/SearchResultScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/SearchScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/SettingScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ShippingAddressScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/SignInScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/TrackOrderScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/VerifyCodeScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/WellcomeScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/Wishlist`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/WishlistScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/themes/BottomTabBar`; params?: Router.UnknownOutputParams; } | { pathname: `/themes/TabBar`; params?: Router.UnknownOutputParams; }; - href: Router.RelativePathString | Router.ExternalPathString | `/api${`?${string}` | `#${string}` | ''}` | `/${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | `/components/ActiveOrder${`?${string}` | `#${string}` | ''}` | `/components/CancelledOrder${`?${string}` | `#${string}` | ''}` | `/components/Category${`?${string}` | `#${string}` | ''}` | `/components/CompletedOrder${`?${string}` | `#${string}` | ''}` | `/components/ContactUsCenter${`?${string}` | `#${string}` | ''}` | `/components/FaqCenter${`?${string}` | `#${string}` | ''}` | `/components/FavoriteButton${`?${string}` | `#${string}` | ''}` | `/components/FlashSales${`?${string}` | `#${string}` | ''}` | `/components/HomeCarousel${`?${string}` | `#${string}` | ''}` | `/components/ItemCard${`?${string}` | `#${string}` | ''}` | `/components/ProductQuantityInput${`?${string}` | `#${string}` | ''}` | `/constants/clothColor${`?${string}` | `#${string}` | ''}` | `/constants/couponData${`?${string}` | `#${string}` | ''}` | `/constants/flashSalesData${`?${string}` | `#${string}` | ''}` | `/constants/myCartData${`?${string}` | `#${string}` | ''}` | `/constants/paymentData${`?${string}` | `#${string}` | ''}` | `/constants/recentData${`?${string}` | `#${string}` | ''}` | `/constants/subShipping${`?${string}` | `#${string}` | ''}` | `/constants/usersData${`?${string}` | `#${string}` | ''}` | `/constants/wishlistData${`?${string}` | `#${string}` | ''}` | `/context/CartContext${`?${string}` | `#${string}` | ''}` | `/screens/AccessLocation${`?${string}` | `#${string}` | ''}` | `/screens/AddCardScreen${`?${string}` | `#${string}` | ''}` | `/screens/CheckOutScreen${`?${string}` | `#${string}` | ''}` | `/screens/ChooseShippingScreen${`?${string}` | `#${string}` | ''}` | `/screens/CompleteYourProfileScreen${`?${string}` | `#${string}` | ''}` | `/screens/ConfirmPayment${`?${string}` | `#${string}` | ''}` | `/screens/CouponScreen${`?${string}` | `#${string}` | ''}` | `/screens/FashionScreen${`?${string}` | `#${string}` | ''}` | `/screens/FilterScreen${`?${string}` | `#${string}` | ''}` | `/screens/FlashSalesScreen${`?${string}` | `#${string}` | ''}` | `/screens/HelpCenterScreen${`?${string}` | `#${string}` | ''}` | `/screens/HomeScreen${`?${string}` | `#${string}` | ''}` | `/screens/InviteFriendsScreen${`?${string}` | `#${string}` | ''}` | `/screens/JacketScreen${`?${string}` | `#${string}` | ''}` | `/screens/LeaveReviewScreen${`?${string}` | `#${string}` | ''}` | `/screens/LocationScreen${`?${string}` | `#${string}` | ''}` | `/screens/MyCartScreen${`?${string}` | `#${string}` | ''}` | `/screens/NewPasswordScreen${`?${string}` | `#${string}` | ''}` | `/screens/NotificationScreen${`?${string}` | `#${string}` | ''}` | `/screens/OnboardingScreen${`?${string}` | `#${string}` | ''}` | `/screens/OrderScreen${`?${string}` | `#${string}` | ''}` | `/screens/PasswordManagerScreen${`?${string}` | `#${string}` | ''}` | `/screens/PaymentMethodScreen${`?${string}` | `#${string}` | ''}` | `/screens/PaymentSuccess${`?${string}` | `#${string}` | ''}` | `/screens/PrivacyPolicyScreen${`?${string}` | `#${string}` | ''}` | `/screens/ProductDetails${`?${string}` | `#${string}` | ''}` | `/screens/ProductDetailsScreen${`?${string}` | `#${string}` | ''}` | `/screens/ProfileScreen${`?${string}` | `#${string}` | ''}` | `/screens/RegisterScreen${`?${string}` | `#${string}` | ''}` | `/screens/SearchResultScreen${`?${string}` | `#${string}` | ''}` | `/screens/SearchScreen${`?${string}` | `#${string}` | ''}` | `/screens/SettingScreen${`?${string}` | `#${string}` | ''}` | `/screens/ShippingAddressScreen${`?${string}` | `#${string}` | ''}` | `/screens/SignInScreen${`?${string}` | `#${string}` | ''}` | `/screens/TrackOrderScreen${`?${string}` | `#${string}` | ''}` | `/screens/VerifyCodeScreen${`?${string}` | `#${string}` | ''}` | `/screens/WellcomeScreen${`?${string}` | `#${string}` | ''}` | `/screens/Wishlist${`?${string}` | `#${string}` | ''}` | `/screens/WishlistScreen${`?${string}` | `#${string}` | ''}` | `/themes/BottomTabBar${`?${string}` | `#${string}` | ''}` | `/themes/TabBar${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/api`; params?: Router.UnknownInputParams; } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `/components/ActiveOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/CancelledOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/Category`; params?: Router.UnknownInputParams; } | { pathname: `/components/CompletedOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/ContactUsCenter`; params?: Router.UnknownInputParams; } | { pathname: `/components/FaqCenter`; params?: Router.UnknownInputParams; } | { pathname: `/components/FavoriteButton`; params?: Router.UnknownInputParams; } | { pathname: `/components/FlashSales`; params?: Router.UnknownInputParams; } | { pathname: `/components/HomeCarousel`; params?: Router.UnknownInputParams; } | { pathname: `/components/ItemCard`; params?: Router.UnknownInputParams; } | { pathname: `/components/ProductQuantityInput`; params?: Router.UnknownInputParams; } | { pathname: `/constants/clothColor`; params?: Router.UnknownInputParams; } | { pathname: `/constants/couponData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/flashSalesData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/myCartData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/paymentData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/recentData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/subShipping`; params?: Router.UnknownInputParams; } | { pathname: `/constants/usersData`; params?: Router.UnknownInputParams; } | { pathname: `/constants/wishlistData`; params?: Router.UnknownInputParams; } | { pathname: `/context/CartContext`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AccessLocation`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AddCardScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CheckOutScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ChooseShippingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CompleteYourProfileScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ConfirmPayment`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CouponScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FashionScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FilterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FlashSalesScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/HelpCenterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/HomeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/InviteFriendsScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/JacketScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/LeaveReviewScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/LocationScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/MyCartScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/NewPasswordScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/NotificationScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/OnboardingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/OrderScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PasswordManagerScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PaymentMethodScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PaymentSuccess`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PrivacyPolicyScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProductDetails`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProductDetailsScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProfileScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/RegisterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SearchResultScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SearchScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SettingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ShippingAddressScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SignInScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/TrackOrderScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/VerifyCodeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/WellcomeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/Wishlist`; params?: Router.UnknownInputParams; } | { pathname: `/screens/WishlistScreen`; params?: Router.UnknownInputParams; } | { pathname: `/themes/BottomTabBar`; params?: Router.UnknownInputParams; } | { pathname: `/themes/TabBar`; params?: Router.UnknownInputParams; }; + hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/api`; params?: Router.UnknownInputParams; } | { pathname: `/config`; params?: Router.UnknownInputParams; } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/theme`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `/components/ActiveOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/CancelledOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/Category`; params?: Router.UnknownInputParams; } | { pathname: `/components/CompletedOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/ContactUsCenter`; params?: Router.UnknownInputParams; } | { pathname: `/components/FaqCenter`; params?: Router.UnknownInputParams; } | { pathname: `/components/FavoriteButton`; params?: Router.UnknownInputParams; } | { pathname: `/components/FlashSales`; params?: Router.UnknownInputParams; } | { pathname: `/components/HomeCarousel`; params?: Router.UnknownInputParams; } | { pathname: `/components/ItemCard`; params?: Router.UnknownInputParams; } | { pathname: `/components/LocationModal`; params?: Router.UnknownInputParams; } | { pathname: `/components/ProductQuantityInput`; params?: Router.UnknownInputParams; } | { pathname: `/constants/clothColor`; params?: Router.UnknownInputParams; } | { pathname: `/constants/storageKeys`; params?: Router.UnknownInputParams; } | { pathname: `/constants/subShipping`; params?: Router.UnknownInputParams; } | { pathname: `/context/AuthContext`; params?: Router.UnknownInputParams; } | { pathname: `/context/CartContext`; params?: Router.UnknownInputParams; } | { pathname: `/context/NotificationContext`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AccessLocation`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AddBankScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AddCardScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CheckOutScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ChooseShippingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CompleteYourProfileScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ConfirmPayment`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CouponScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FilterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FlashSalesScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/HelpCenterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/HomeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/InviteFriendsScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/JacketScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/LeaveReviewScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/LocationScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/MyCartScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/NewPasswordScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/NotificationScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/OnboardingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/OrderScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PasswordManagerScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PaymentMethodScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PaymentSuccess`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PrivacyPolicyScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProductDetailsScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProfileScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/RegisterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SearchResultScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SearchScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SettingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ShippingAddressScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SignInScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/TrackOrderScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/VerifyCodeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/WellcomeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/WishlistScreen`; params?: Router.UnknownInputParams; } | { pathname: `/themes/BottomTabBar`; params?: Router.UnknownInputParams; } | { pathname: `/themes/TabBar`; params?: Router.UnknownInputParams; }; + hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/api`; params?: Router.UnknownOutputParams; } | { pathname: `/config`; params?: Router.UnknownOutputParams; } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/theme`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; } | { pathname: `/components/ActiveOrder`; params?: Router.UnknownOutputParams; } | { pathname: `/components/CancelledOrder`; params?: Router.UnknownOutputParams; } | { pathname: `/components/Category`; params?: Router.UnknownOutputParams; } | { pathname: `/components/CompletedOrder`; params?: Router.UnknownOutputParams; } | { pathname: `/components/ContactUsCenter`; params?: Router.UnknownOutputParams; } | { pathname: `/components/FaqCenter`; params?: Router.UnknownOutputParams; } | { pathname: `/components/FavoriteButton`; params?: Router.UnknownOutputParams; } | { pathname: `/components/FlashSales`; params?: Router.UnknownOutputParams; } | { pathname: `/components/HomeCarousel`; params?: Router.UnknownOutputParams; } | { pathname: `/components/ItemCard`; params?: Router.UnknownOutputParams; } | { pathname: `/components/LocationModal`; params?: Router.UnknownOutputParams; } | { pathname: `/components/ProductQuantityInput`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/clothColor`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/storageKeys`; params?: Router.UnknownOutputParams; } | { pathname: `/constants/subShipping`; params?: Router.UnknownOutputParams; } | { pathname: `/context/AuthContext`; params?: Router.UnknownOutputParams; } | { pathname: `/context/CartContext`; params?: Router.UnknownOutputParams; } | { pathname: `/context/NotificationContext`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/AccessLocation`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/AddBankScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/AddCardScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/CheckOutScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ChooseShippingScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/CompleteYourProfileScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ConfirmPayment`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/CouponScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/FilterScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/FlashSalesScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/HelpCenterScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/HomeScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/InviteFriendsScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/JacketScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/LeaveReviewScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/LocationScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/MyCartScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/NewPasswordScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/NotificationScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/OnboardingScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/OrderScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/PasswordManagerScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/PaymentMethodScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/PaymentSuccess`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/PrivacyPolicyScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ProductDetailsScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ProfileScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/RegisterScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/SearchResultScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/SearchScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/SettingScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/ShippingAddressScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/SignInScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/TrackOrderScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/VerifyCodeScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/WellcomeScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/screens/WishlistScreen`; params?: Router.UnknownOutputParams; } | { pathname: `/themes/BottomTabBar`; params?: Router.UnknownOutputParams; } | { pathname: `/themes/TabBar`; params?: Router.UnknownOutputParams; }; + href: Router.RelativePathString | Router.ExternalPathString | `/api${`?${string}` | `#${string}` | ''}` | `/config${`?${string}` | `#${string}` | ''}` | `/${`?${string}` | `#${string}` | ''}` | `/theme${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | `/components/ActiveOrder${`?${string}` | `#${string}` | ''}` | `/components/CancelledOrder${`?${string}` | `#${string}` | ''}` | `/components/Category${`?${string}` | `#${string}` | ''}` | `/components/CompletedOrder${`?${string}` | `#${string}` | ''}` | `/components/ContactUsCenter${`?${string}` | `#${string}` | ''}` | `/components/FaqCenter${`?${string}` | `#${string}` | ''}` | `/components/FavoriteButton${`?${string}` | `#${string}` | ''}` | `/components/FlashSales${`?${string}` | `#${string}` | ''}` | `/components/HomeCarousel${`?${string}` | `#${string}` | ''}` | `/components/ItemCard${`?${string}` | `#${string}` | ''}` | `/components/LocationModal${`?${string}` | `#${string}` | ''}` | `/components/ProductQuantityInput${`?${string}` | `#${string}` | ''}` | `/constants/clothColor${`?${string}` | `#${string}` | ''}` | `/constants/storageKeys${`?${string}` | `#${string}` | ''}` | `/constants/subShipping${`?${string}` | `#${string}` | ''}` | `/context/AuthContext${`?${string}` | `#${string}` | ''}` | `/context/CartContext${`?${string}` | `#${string}` | ''}` | `/context/NotificationContext${`?${string}` | `#${string}` | ''}` | `/screens/AccessLocation${`?${string}` | `#${string}` | ''}` | `/screens/AddBankScreen${`?${string}` | `#${string}` | ''}` | `/screens/AddCardScreen${`?${string}` | `#${string}` | ''}` | `/screens/CheckOutScreen${`?${string}` | `#${string}` | ''}` | `/screens/ChooseShippingScreen${`?${string}` | `#${string}` | ''}` | `/screens/CompleteYourProfileScreen${`?${string}` | `#${string}` | ''}` | `/screens/ConfirmPayment${`?${string}` | `#${string}` | ''}` | `/screens/CouponScreen${`?${string}` | `#${string}` | ''}` | `/screens/FilterScreen${`?${string}` | `#${string}` | ''}` | `/screens/FlashSalesScreen${`?${string}` | `#${string}` | ''}` | `/screens/HelpCenterScreen${`?${string}` | `#${string}` | ''}` | `/screens/HomeScreen${`?${string}` | `#${string}` | ''}` | `/screens/InviteFriendsScreen${`?${string}` | `#${string}` | ''}` | `/screens/JacketScreen${`?${string}` | `#${string}` | ''}` | `/screens/LeaveReviewScreen${`?${string}` | `#${string}` | ''}` | `/screens/LocationScreen${`?${string}` | `#${string}` | ''}` | `/screens/MyCartScreen${`?${string}` | `#${string}` | ''}` | `/screens/NewPasswordScreen${`?${string}` | `#${string}` | ''}` | `/screens/NotificationScreen${`?${string}` | `#${string}` | ''}` | `/screens/OnboardingScreen${`?${string}` | `#${string}` | ''}` | `/screens/OrderScreen${`?${string}` | `#${string}` | ''}` | `/screens/PasswordManagerScreen${`?${string}` | `#${string}` | ''}` | `/screens/PaymentMethodScreen${`?${string}` | `#${string}` | ''}` | `/screens/PaymentSuccess${`?${string}` | `#${string}` | ''}` | `/screens/PrivacyPolicyScreen${`?${string}` | `#${string}` | ''}` | `/screens/ProductDetailsScreen${`?${string}` | `#${string}` | ''}` | `/screens/ProfileScreen${`?${string}` | `#${string}` | ''}` | `/screens/RegisterScreen${`?${string}` | `#${string}` | ''}` | `/screens/SearchResultScreen${`?${string}` | `#${string}` | ''}` | `/screens/SearchScreen${`?${string}` | `#${string}` | ''}` | `/screens/SettingScreen${`?${string}` | `#${string}` | ''}` | `/screens/ShippingAddressScreen${`?${string}` | `#${string}` | ''}` | `/screens/SignInScreen${`?${string}` | `#${string}` | ''}` | `/screens/TrackOrderScreen${`?${string}` | `#${string}` | ''}` | `/screens/VerifyCodeScreen${`?${string}` | `#${string}` | ''}` | `/screens/WellcomeScreen${`?${string}` | `#${string}` | ''}` | `/screens/WishlistScreen${`?${string}` | `#${string}` | ''}` | `/themes/BottomTabBar${`?${string}` | `#${string}` | ''}` | `/themes/TabBar${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/api`; params?: Router.UnknownInputParams; } | { pathname: `/config`; params?: Router.UnknownInputParams; } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/theme`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `/components/ActiveOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/CancelledOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/Category`; params?: Router.UnknownInputParams; } | { pathname: `/components/CompletedOrder`; params?: Router.UnknownInputParams; } | { pathname: `/components/ContactUsCenter`; params?: Router.UnknownInputParams; } | { pathname: `/components/FaqCenter`; params?: Router.UnknownInputParams; } | { pathname: `/components/FavoriteButton`; params?: Router.UnknownInputParams; } | { pathname: `/components/FlashSales`; params?: Router.UnknownInputParams; } | { pathname: `/components/HomeCarousel`; params?: Router.UnknownInputParams; } | { pathname: `/components/ItemCard`; params?: Router.UnknownInputParams; } | { pathname: `/components/LocationModal`; params?: Router.UnknownInputParams; } | { pathname: `/components/ProductQuantityInput`; params?: Router.UnknownInputParams; } | { pathname: `/constants/clothColor`; params?: Router.UnknownInputParams; } | { pathname: `/constants/storageKeys`; params?: Router.UnknownInputParams; } | { pathname: `/constants/subShipping`; params?: Router.UnknownInputParams; } | { pathname: `/context/AuthContext`; params?: Router.UnknownInputParams; } | { pathname: `/context/CartContext`; params?: Router.UnknownInputParams; } | { pathname: `/context/NotificationContext`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AccessLocation`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AddBankScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/AddCardScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CheckOutScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ChooseShippingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CompleteYourProfileScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ConfirmPayment`; params?: Router.UnknownInputParams; } | { pathname: `/screens/CouponScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FilterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/FlashSalesScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/HelpCenterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/HomeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/InviteFriendsScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/JacketScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/LeaveReviewScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/LocationScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/MyCartScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/NewPasswordScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/NotificationScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/OnboardingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/OrderScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PasswordManagerScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PaymentMethodScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PaymentSuccess`; params?: Router.UnknownInputParams; } | { pathname: `/screens/PrivacyPolicyScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProductDetailsScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ProfileScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/RegisterScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SearchResultScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SearchScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SettingScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/ShippingAddressScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/SignInScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/TrackOrderScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/VerifyCodeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/WellcomeScreen`; params?: Router.UnknownInputParams; } | { pathname: `/screens/WishlistScreen`; params?: Router.UnknownInputParams; } | { pathname: `/themes/BottomTabBar`; params?: Router.UnknownInputParams; } | { pathname: `/themes/TabBar`; params?: Router.UnknownInputParams; }; } } } diff --git a/frontend/.gitignore b/frontend/.gitignore index 5873d9a..31feecf 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,4 +1,3 @@ - # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb # The following patterns were generated by expo-cli diff --git a/frontend/__MACOSX/._README.md b/frontend/__MACOSX/._README.md deleted file mode 100644 index 78483a980eaae15c01b88a14fcf0444c59d283d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 276 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}@gpDx@)<>dG!eitBqRu;46I`hkR5@h z4P-h$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhB>z$r-PK$9H^8A(d-NjZ$2+jSnHtGQ!gsaFXg%h TBuYA9WMU{N=GygO)xF;USFtc1 diff --git a/frontend/__MACOSX/._babel.config.js b/frontend/__MACOSX/._babel.config.js deleted file mode 100644 index 4a3dc804bfd36be992d68d1ec5646b5382052db3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 319 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhS|3sr-PKOT)bTzM6)|M9XV%RkmcbyZ<^ep$CD&) UgG5OOj7$s##az1{th)Cb0OEx*8UO$Q diff --git a/frontend/__MACOSX/._expo-env.d.ts b/frontend/__MACOSX/._expo-env.d.ts deleted file mode 100644 index e9d8e1281726aa4d8ec38183dea8887cf260b65f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 319 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhPk&Mr-PKK9p0@1qS+m!nNoL(Ryv&6uuIJ`&!^Rx mouT2)=LHID9khDtMTPmLT-Siq5dw@%3$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhS|3sr-PJfCUc8{Xm*B%H=h?MtaZ@psTURImvUVL T5+xllGBFesbM1Pt>fUbv@fI%m diff --git a/frontend/__MACOSX/._metro.config.js b/frontend/__MACOSX/._metro.config.js deleted file mode 100644 index 5e0eff6a1730524d359cdc6afc7acd222968fe85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 319 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhS|3sr-PI|Z2Bz@qS+msj-0bD$nx-$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhPk&Mr-PJfe&kRE(d-V=OsP9XD;-X3*rn!}=hJG; m&d~7Y^8$sn4q83+qQd-Au4_Q*2mwYWhOS%ZjgR)Oc>(~5jy5g; diff --git a/frontend/__MACOSX/._package-lock.json b/frontend/__MACOSX/._package-lock.json deleted file mode 100644 index 29aaa2f98312de912e6ddf1bb43a4f73a4dac548..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 319 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhB>z$r-PK;ICD}OM6)wAy!pI9VXcE!Praxxzm)45 UkSOVZk%^(8m}}RARrh`a0GMqs)c^nh diff --git a/frontend/__MACOSX/._package.json b/frontend/__MACOSX/._package.json deleted file mode 100644 index 70c05fe1da5334de6c13a9b64d95af7b8af7012d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 319 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhB>z$r-PK8d%z_FqS+Z5-h5u5u+~AVr(RT;U&?h2 TNR)KI$iz@k%(d&ms(ZfyS=cZk diff --git a/frontend/__MACOSX/._tailwind.config.js b/frontend/__MACOSX/._tailwind.config.js deleted file mode 100644 index bf5707f7d84ff0988bfca078e11fa00a4ee54cde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 319 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhS|3sr-PKe(RG&q(d-URN6uLnWO;bbn$Vqox1Ojhs@R)|o50+1L3ClDJkFfj50X&|4`9!L`b9795aAj-fx?f}`z zXxf;8e2};R1A|m@ey(0(K|xNcUQS|hNojFvN=jl$s&YUuRL>hA?ZLpnk5HGJn4ANa zWHbR%+zbq&2)Tly{Ib-%#JuEGhB>z$r-PJPc2AN4(d-NjZ$2+jSnHtGQ!gsaFXg%h TBuYA9WMU{N=GygO)xF;UHb*bN diff --git a/frontend/__MACOSX/app/.__layout.jsx b/frontend/__MACOSX/app/.__layout.jsx deleted file mode 100644 index aa56ff929e9918e4e7ebf04b370092023934248d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 263 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}@l_xO@)_BIG!eitBqRu;46I`dkR6Ps z4P-h<+=GFEKRG{FFR`E?Csi*uF*ygK@DGsUW?&FS$Q2ajm!;+<<|U^xuroBg`Mf}3 ft%Fuiy{It1l$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}@l_xO@)_BIG!eitBqRu;46I`dkR6Ps z4P-h<+=GFEKRG{FFR`E?Csi*uF*ygK@DGsUW?&FS$Q2ajm!;+<<|U^xuroBg`Mf}3 ft%Fuiy{It1l$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K - - + + + + + ); } \ No newline at end of file diff --git a/frontend/app/api.jsx b/frontend/app/api.jsx index 5d49de8..5c539ac 100644 --- a/frontend/app/api.jsx +++ b/frontend/app/api.jsx @@ -1,6 +1,8 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import axios from 'axios'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { View, Text } from 'react-native'; +import { getApiBaseUrl } from './config'; +import { STORAGE_KEYS } from './constants/storageKeys'; // Utility function to simulate network delay const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); @@ -62,127 +64,725 @@ export default function Api() { ); } - -// Storage Keys -const STORAGE_KEYS = { - CART: 'cart', - USER_TOKEN: 'userToken', - USER_DATA: 'userData', - FAVORITES: 'favorites', - CSRF_TOKEN: 'csrfToken' -}; - -// API Configuration8000 -export const MEDIA_BASE_URL = 'http://192.168.18.3:8000'; +// API Configuration +export const MEDIA_BASE_URL = getApiBaseUrl(); export const BASE_URL = `${MEDIA_BASE_URL}/api`; -// Create axios instance with consistent configuration -const apiClient = axios.create({ +// Create axios instance with default config +const api = axios.create({ baseURL: BASE_URL, - timeout: 10000, - withCredentials: true, // Important for CSRF cookie handling + withCredentials: true, headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json' - } + 'Accept': 'application/json', + }, + // Ensure cookies are included in cross-origin requests + xsrfCookieName: 'csrftoken', + xsrfHeaderName: 'X-CSRFToken', }); -// Function to fetch CSRF token -export const fetchCSRFToken = async () => { +// Function to update base URL +export const updateBaseURL = (newURL) => { + api.defaults.baseURL = newURL; +}; + +// Function to get CSRF token +export const getCSRFToken = async () => { try { - const response = await axios.get(`${BASE_URL}/get-csrf-token/`, { - withCredentials: true, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } - }); - - if (response.data && response.data.csrfToken) { - await AsyncStorage.setItem(STORAGE_KEYS.CSRF_TOKEN, response.data.csrfToken); - // Also set it in the default headers - apiClient.defaults.headers.common['X-CSRFToken'] = response.data.csrfToken; - return response.data.csrfToken; + // First check if we already have a token stored + const storedToken = await getStoredCSRFToken(); + if (storedToken) { + return storedToken; } - console.error('No CSRF token in response:', response.data); + // If not, fetch a new token + const response = await api.get('/csrf-token/'); + + // Extract token from response + let csrfToken = null; + if (response.data && response.data.csrfToken) { + csrfToken = response.data.csrfToken; + } else if (response.headers && response.headers['x-csrf-token']) { + csrfToken = response.headers['x-csrf-token']; + } + + // Store the token if we got one + if (csrfToken) { + await AsyncStorage.setItem(STORAGE_KEYS.CSRF_TOKEN, csrfToken); + return csrfToken; + } + + console.warn('No CSRF token found in response'); return null; } catch (error) { - console.error('Failed to fetch CSRF token:', error.response || error); + console.error('Error fetching CSRF token:', error); return null; } }; -// Helper function to get stored CSRF token -const getCSRFToken = async () => { +// Function to get stored CSRF token +export const getStoredCSRFToken = async () => { try { return await AsyncStorage.getItem(STORAGE_KEYS.CSRF_TOKEN); } catch (error) { - console.error('Error getting CSRF token from storage:', error); + console.error('Error getting stored CSRF token:', error); return null; } }; -// Add token and CSRF token to requests when available -apiClient.interceptors.request.use( - async (config) => { - // Add auth token if available - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); +// Initialize API and fetch CSRF token +export const initializeAPI = async () => { + try { + // Get stored auth token + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); if (token) { - config.headers.Authorization = `Token ${token}`; + api.defaults.headers.common['Authorization'] = `Token ${token}`; } - - // Add CSRF token if available - const csrfToken = await getCSRFToken(); - if (csrfToken) { - config.headers['X-CSRFToken'] = csrfToken; + + // Get CSRF token + await getCSRFToken(); + } catch (error) { + console.error('Error initializing API:', error); + } +}; + +// Request interceptor +api.interceptors.request.use( + async (config) => { + try { + // Add auth token to headers + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (token) { + config.headers['Authorization'] = `Token ${token}`; + } + } catch (error) { + console.error('Error in request interceptor:', error); } - return config; }, - (error) => Promise.reject(error) -); - -// Handle response errors, especially auth errors -apiClient.interceptors.response.use( - response => response, - async (error) => { - // Handle 403 CSRF errors - if (error.response && error.response.status === 403) { - const errorDetail = error.response.data?.detail || ''; - if (errorDetail.includes('CSRF') || - (typeof error.response.data === 'string' && error.response.data.includes('CSRF'))) { - console.log('CSRF token missing or invalid. Fetching new token...'); - - try { - // Get a new CSRF token - await fetchCSRFToken(); - - // Retry the original request with new CSRF token - const originalRequest = error.config; - const csrfToken = await getCSRFToken(); - if (csrfToken) { - originalRequest.headers['X-CSRFToken'] = csrfToken; - return axios(originalRequest); - } - } catch (csrfError) { - console.error('Failed to refresh CSRF token:', csrfError); - } - } - } - - // Handle 401 unauthorized errors - if (error.response && error.response.status === 401) { - // Clear auth data - await AsyncStorage.removeItem(STORAGE_KEYS.USER_TOKEN); - delete apiClient.defaults.headers.common['Authorization']; - } - + (error) => { return Promise.reject(error); } ); -// Process image URLs with authentication if needed +// Response interceptor +api.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + try { + await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + await AsyncStorage.removeItem(STORAGE_KEYS.USER_DATA); + delete api.defaults.headers.common['Authorization']; + } catch (storageError) { + console.error('Error clearing auth data:', storageError); + } + } + return Promise.reject(error); + } +); + +// Auth functions +export const login = async (credentials) => { + try { + const response = await api.post('/token-auth/', credentials); + if (response.data.token) { + // Set the auth token in the API instance + api.defaults.headers.common['Authorization'] = `Token ${response.data.token}`; + // Store the token and user data + await AsyncStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, response.data.token); + if (response.data.user_id) { + // Get user data + const userResponse = await api.get(`/users/${response.data.user_id}/`); + if (userResponse.data) { + await AsyncStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(userResponse.data)); + } + } + } + return response.data; + } catch (error) { + console.error('Login error:', error); + throw error; + } +}; + +export const register = async (userData) => { + try { + // First, get a CSRF token + const csrfToken = await getCSRFToken(); + + // Make the registration request with the CSRF token in headers + const response = await api.post('/auth/register/', userData, { + headers: { + 'X-CSRFToken': csrfToken + } + }); + + // If registration is successful and we get a token, store it + if (response.data && response.data.token) { + // Set the auth token in the API instance + api.defaults.headers.common['Authorization'] = `Token ${response.data.token}`; + // Store the token + await AsyncStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, response.data.token); + + // If we have user data, store it + if (response.data.user_id) { + const userData = { + id: response.data.user_id, + username: response.data.username, + email: response.data.email, + first_name: response.data.first_name, + last_name: response.data.last_name + }; + await AsyncStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(userData)); + } + } + + return response.data; + } catch (error) { + console.error('Registration error:', error.response?.data || error); + throw error; + } +}; + +export const logout = async () => { + try { + await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + await AsyncStorage.removeItem(STORAGE_KEYS.USER_DATA); + delete api.defaults.headers.common['Authorization']; + } catch (error) { + console.error('Logout error:', error); + throw error; + } +}; + +// User functions +export const getCurrentUser = async () => { + try { + // Check if we have a token + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) return null; + + // Get user data from API + const response = await api.get('/users/me/'); + + // Store updated data + if (response.data) { + await AsyncStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(response.data)); + } + + return response.data; + } catch (error) { + console.error('Error getting current user:', error); + + // Fallback to cached data + const userData = await AsyncStorage.getItem(STORAGE_KEYS.USER_DATA); + return userData ? JSON.parse(userData) : null; + } +}; + +export const getUserData = async () => { + try { + // First try to get from API + const currentUser = await getCurrentUser(); + if (currentUser) return currentUser; + + // If API fails, try to get from storage + const userData = await AsyncStorage.getItem(STORAGE_KEYS.USER_DATA); + return userData ? JSON.parse(userData) : null; + } catch (error) { + console.error('Error getting user data:', error); + return null; + } +}; + +export const updateUserProfile = async (profileData) => { + try { + const response = await api.post('/users/update_profile/', profileData); + if (response.data) { + await AsyncStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(response.data)); + } + return response.data; + } catch (error) { + console.error('Profile update error:', error.response?.data || error); + throw error; + } +}; + +export const updateProfilePicture = async (imageUri) => { + try { + const formData = new FormData(); + formData.append('profile_picture', { + uri: imageUri, + type: 'image/jpeg', + name: 'profile_picture.jpg', + }); + + const response = await api.post('/users/update_profile_picture/', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + 'Accept': 'application/json', + }, + }); + + if (response.data && response.data.profile_picture) { + await AsyncStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(response.data)); + } + return response.data; + } catch (error) { + console.error('Profile picture update error:', error.response?.data || error); + throw error; + } +}; + +export const getGenderOptions = async () => { + try { + const response = await api.get('/users/gender_options/'); + // Transform the response to match the backend values + return response.data.map(option => ({ + value: option.value.charAt(0).toUpperCase() + option.value.slice(1), + label: option.label + })); + } catch (error) { + // Return default options if API fails + return [ + { value: 'Male', label: 'Male' }, + { value: 'Female', label: 'Female' }, + { value: 'Other', label: 'Other' } + ]; + } +}; + +// Cart functions +export const getCart = async () => { + try { + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + // If not logged in, get from local storage + const cartData = await AsyncStorage.getItem(STORAGE_KEYS.CART); + return cartData ? JSON.parse(cartData) : { items: [] }; + } + + // Set the auth token in headers + api.defaults.headers.common['Authorization'] = `Token ${token}`; + + const { data } = await api.get('/cart/items/'); + + return data; + } catch (error) { + console.error('Error getting cart:', error); + + if (error.response?.status === 401) { + // Clear auth data on 401 + await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + delete api.defaults.headers.common['Authorization']; + throw new Error('Session expired. Please login again.'); + } + + throw error; + } +}; + +// Add global refresh cart callback +let globalRefreshCartCallback = null; + +export const setGlobalRefreshCart = (callback) => { + globalRefreshCartCallback = callback; +}; + +// Update cart functions to use the callback +export const addToCart = async (productId, quantity = 1) => { + try { + const response = await api.post('/cart/add_item/', { + product_id: productId, + quantity: quantity + }); + if (globalRefreshCartCallback) { + globalRefreshCartCallback(); + } + return response.data; + } catch (error) { + console.error('Error adding to cart:', error); + if (error.response?.status === 401) { + await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + delete api.defaults.headers.common['Authorization']; + throw new Error('Session expired. Please login again.'); + } + throw error; + } +}; + +export const removeFromCart = async (cartItemId) => { + try { + // Ensure we have a valid token and set it in headers + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + // If no token, fall back to local storage cart handling + console.log('No auth token found, using local storage for cart'); + const cartData = await AsyncStorage.getItem(STORAGE_KEYS.CART); + let cart = cartData ? JSON.parse(cartData) : { items: [] }; + + // Filter out the item + cart.items = cart.items.filter(item => item.id !== cartItemId); + await AsyncStorage.setItem(STORAGE_KEYS.CART, JSON.stringify(cart)); + return cart; + } + + // First, get the cart to find the correct product_id + const cartResponse = await api.get('/cart/items/'); + const cartItems = cartResponse.data.items || []; + + // Find the cart item that matches the ID we want to delete + const cartItem = cartItems.find(item => item.id === cartItemId); + + if (!cartItem) { + throw new Error('Cart item not found'); + } + + // Use the product_id from the cart item + const productId = cartItem.product_id || cartItem.product; + console.log(`Found product_id: ${productId} for cart item: ${cartItemId}`); + + // Use the correct endpoint and method with the product_id + const response = await api.post('/cart/remove_item/', { + product_id: productId + }); + + console.log('Item removed successfully'); + if (globalRefreshCartCallback) globalRefreshCartCallback(); + return response.data; + } catch (error) { + + if (error.response?.status === 401) { + await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + delete api.defaults.headers.common['Authorization']; + throw new Error('Session expired. Please login again.'); + } + + if (error.response?.status === 403) { + throw new Error('You do not have permission to remove this item from cart.'); + } + + if (error.response?.status === 404) { + throw new Error('Cart item not found'); + } + + throw error; + } +}; + +// Make deleteCartItem directly handle the delete functionality rather than just being an alias +export const deleteCartItem = async (itemId) => { + try { + return await removeFromCart(itemId); + } catch (error) { + console.error('Error in deleteCartItem:', error); + throw error; + } +}; + +export const updateCartItemQuantity = async (cartItemId, quantity) => { + try { + // Get the auth token + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + throw new Error('Please login to update cart'); + } + + // Set the auth token in headers + api.defaults.headers.common['Authorization'] = `Token ${token}`; + + // Make the API call to update quantity + const response = await api.post('/cart/update_quantity/', { + product_id: cartItemId, + quantity: quantity + }); + + return response.data; + } catch (error) { + if (error.response?.status === 401) { + // Clear auth data on 401 + await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + delete api.defaults.headers.common['Authorization']; + throw new Error('Session expired. Please login again.'); + } + if (error.response?.status === 403) { + throw new Error('You do not have permission to update this cart item.'); + } + if (error.response?.status === 404) { + throw new Error('Cart item not found'); + } + throw error; + } +}; + +export const checkoutCart = async (shippingDetails) => { + try { + const { data } = await api.post('/cart/checkout/', shippingDetails); + return data; + } catch (error) { + console.error('Error during checkout:', error); + throw error; + } +}; + +// Product functions +export const fetchProducts = async (searchQuery = '') => { + try { + const { data } = await api.get('/products/', { + params: { + search: searchQuery + } + }); + return data; + } catch (error) { + console.error('Error fetching products:', error); + throw error; + } +}; + +export const fetchProductById = async (id) => { + try { + // First try the products endpoint + try { + const { data } = await api.get(`/products/${id}/`); + return { + ...data, + image: data.image ? await processImageUrl(data.image) : null + }; + } catch (productsError) { + // If products endpoint fails, try the posts endpoint + if (productsError.response?.status === 404) { + const { data } = await api.get(`/posts/${id}/`); + return { + ...data, + image: data.image ? await processImageUrl(data.image) : null + }; + } + throw productsError; + } + } catch (error) { + throw error; + } +}; + +// Category functions +export const fetchCategories = async () => { + try { + const { data } = await api.get('/categories/'); + return data; + } catch (error) { + throw error; + } +}; + +export const fetchCategoryById = async (id) => { + try { + const { data } = await api.get(`/categories/${id}/`); + return data; + } catch (error) { + throw error; + } +}; + +export const fetchProductsByCategory = async (categoryId) => { + try { + // Validate categoryId + if (!categoryId || categoryId === 'undefined' || isNaN(parseInt(categoryId))) { + throw new Error('Invalid category ID'); + } + + const { data } = await api.get(`/categories/${categoryId}/products/`); + + // Validate response data + if (!data) { + throw new Error('No data received from server'); + } + + if (!Array.isArray(data)) { + throw new Error('Invalid response format from server'); + } + + return data; + } catch (error) { + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + throw { + response: { + data: error.response.data, + status: error.response.status + } + }; + } else if (error.request) { + // The request was made but no response was received + throw new Error('No response from server'); + } else { + // Something happened in setting up the request that triggered an Error + throw error; + } + } +}; + +// Order functions +export const getOrders = async () => { + try { + const { data } = await api.get('/orders/'); + return data; + } catch (error) { + throw error; + } +}; + +export const getOrderById = async (orderId) => { + try { + const { data } = await api.get(`/orders/${orderId}/`); + return data; + } catch (error) { + throw error; + } +}; + +export const placeOrder = async (orderData) => { + try { + const { data } = await api.post('/orders/', orderData); + return data; + } catch (error) { + console.error('Error placing order:', error); + throw error; + } +}; + +// New order status management functions +export const updateOrderStatus = async (orderId, status) => { + try { + const { data } = await api.put(`/orders/${orderId}/`, { status }); + return data; + } catch (error) { + console.error('Error updating order status:', error); + throw error; + } +}; + +export const shipOrder = async (orderId) => { + try { + const { data } = await api.post(`/orders/${orderId}/ship/`); + return data; + } catch (error) { + console.error('Error shipping order:', error); + throw error; + } +}; + +export const deliverOrder = async (orderId) => { + try { + const { data } = await api.post(`/orders/${orderId}/deliver/`); + return data; + } catch (error) { + console.error('Error delivering order:', error); + throw error; + } +}; + +export const cancelOrder = async (orderId) => { + try { + const { data } = await api.post(`/orders/${orderId}/cancel/`); + return data; + } catch (error) { + console.error('Error cancelling order:', error); + throw error; + } +}; + +// Favorite functions +export const getFavorites = async () => { + try { + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + // If not logged in, get from local storage + const favoritesData = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES); + return favoritesData ? JSON.parse(favoritesData) : []; + } + + const { data } = await api.get('/favorites/'); + return Array.isArray(data) ? data : []; + } catch (error) { + return []; + } +}; + +export const toggleFavorite = async (product) => { + try { + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + // If not logged in, use local storage + const favoritesData = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES); + const favorites = favoritesData ? JSON.parse(favoritesData) : []; + + const index = favorites.findIndex(fav => fav.id === product.id); + if (index === -1) { + favorites.push(product); + } else { + favorites.splice(index, 1); + } + + await AsyncStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites)); + return favorites; + } + + // If logged in, use API + const response = await api.post('/favorites/toggle/', { + product_id: product.id + }); + console.log('API toggleFavorite response:', response.data); + + // The response should contain the updated favorites list + return Array.isArray(response.data) ? response.data : []; + } catch (error) { + console.error('Error toggling favorite:', error); + return []; + } +}; + +export const isProductFavorited = async (productId) => { + try { + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + // If not logged in, check local storage + const favoritesData = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES); + const favorites = favoritesData ? JSON.parse(favoritesData) : []; + return favorites.some(fav => fav.id === productId); + } + + // If logged in, check API + const { data } = await api.get(`/favorites/check/${productId}/`); + return data.is_favorited; + } catch (error) { + console.error('Error checking favorite status:', error); + return false; + } +}; + +// Rating functions +export const rateProduct = async (productId, rating, comment = '') => { + try { + const { data } = await api.post(`/products/${productId}/rate/`, { + rating, + comment + }); + return data; + } catch (error) { + console.error('Error rating product:', error); + if (error.response?.status === 401) { + await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + delete api.defaults.headers.common['Authorization']; + throw new Error('Session expired. Please login again.'); + } + throw error; + } +}; + +// Utility functions export const processImageUrl = async (url) => { if (!url) { console.log('No image URL provided, using local fallback'); @@ -212,659 +812,37 @@ export const processImageUrl = async (url) => { } }; -// CATEGORIES -export const fetchCategories = async () => { +export const updatePassword = async (currentPassword, newPassword) => { try { - const { data } = await apiClient.get('/categories/'); - const processedData = await Promise.all(data.map(async category => ({ - ...category, - image: await processImageUrl(category.image) - }))); - return processedData; - } catch (error) { - console.error('Error fetching categories:', error); - throw error; - } -}; - -export const fetchCategoryById = async (id) => { - try { - const { data } = await apiClient.get(`/categories/${id}/`); - return { - ...data, - image: await processImageUrl(data.image) - }; - } catch (error) { - console.error('Error fetching category:', error); - throw error; - } -}; - -export const fetchProductsByCategory = async (categoryId) => { - try { - const { data } = await apiClient.get(`/categories/${categoryId}/products/`); - const processedData = await Promise.all( - data.map(async (product) => ({ - ...product, - image: await processImageUrl(product.image) - })) - ); - return processedData; - } catch (error) { - console.error('Error fetching products by category:', error); - throw error; - } -}; - -// PRODUCTS -export const fetchProducts = async (searchTerm = '') => { - try { - // Ensure searchTerm is a string - const searchQuery = String(searchTerm || '').trim(); - - // Simulate authentication check - await checkAuthentication(); - - // Make API call to fetch products - const { data } = await apiClient.get('/products/', { - params: { - search: searchQuery - } + const response = await api.post('/users/update_password/', { + current_password: currentPassword, + new_password: newPassword }); - - // Process product images - const processedData = await Promise.all( - data.map(async (product) => ({ - ...product, - image: await processImageUrl(product.image) - })) - ); - - // Save to recent searches if user is logged in and search term is not empty - if (searchQuery) { - try { - await saveUserRecentSearch(searchQuery); - } catch (error) { - // Silently fail if user is not logged in - console.log('User not logged in, not saving search'); - } - } - - return processedData; - } catch (error) { - console.error('Error fetching products:', error); - // Return empty array instead of throwing error - return []; - } -}; - -export const fetchProductById = async (id) => { - try { - // First try the products endpoint - try { - const { data } = await apiClient.get(`/products/${id}/`); - return { - ...data, - image: await processImageUrl(data.image) - }; - } catch (productsError) { - // If products endpoint fails, try the posts endpoint - if (productsError.response?.status === 404) { - const { data } = await apiClient.get(`/posts/${id}/`); - return { - ...data, - image: await processImageUrl(data.image), - // Map post fields to product fields if needed - name: data.title, - price: data.price || 0, - rating: data.rating || 0 - }; - } - throw productsError; - } - } catch (error) { - console.error('Error fetching product:', error); - if (error.response?.status === 404) { - throw new Error('Product not found'); - } - throw error; - } -}; - -// CART -export const getCart = async () => { - try { - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); - if (!token) { - // If not logged in, get from local storage - const localCart = await AsyncStorage.getItem(STORAGE_KEYS.CART); - return localCart ? JSON.parse(localCart) : { items: [] }; - } - - const { data } = await apiClient.get('/cart/items/'); - - // Process product images and prices - if (data && data.items) { - const processedItems = await Promise.all( - data.items.map(async (item) => { - const price = parseFloat(item.product_price); - return { - ...item, - product_image: await processImageUrl(item.product_image), - product_price: !isNaN(price) ? price : 0, - price: !isNaN(price) ? price : 0 - }; - }) - ); - return { - ...data, - items: processedItems - }; - } - return data; - } catch (error) { - console.error('Error getting cart:', error); - if (error.response?.status === 401) { - // If unauthorized, try to get from local storage - const localCart = await AsyncStorage.getItem(STORAGE_KEYS.CART); - return localCart ? JSON.parse(localCart) : { items: [] }; - } - throw error; - } -}; - -// Global refresh function -let globalRefreshCart = null; - -export const setGlobalRefreshCart = (refreshFunction) => { - globalRefreshCart = refreshFunction; -}; - -export const addToCart = async (productId, quantity = 1) => { - try { - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); - if (!token) { - // If not logged in, use local storage - const localCart = await AsyncStorage.getItem(STORAGE_KEYS.CART); - const currentCart = localCart ? JSON.parse(localCart) : { items: [] }; - - // Check if product already exists in cart - const existingItem = currentCart.items.find(item => item.product === productId); - if (existingItem) { - existingItem.quantity += quantity; - } else { - currentCart.items.push({ - product: productId, - quantity, - product_price: 0, // Will be updated when user logs in - product_image: null - }); - } - - await AsyncStorage.setItem(STORAGE_KEYS.CART, JSON.stringify(currentCart)); - - // Trigger cart refresh if available - if (globalRefreshCart) { - globalRefreshCart(); - } - - return currentCart; - } - - const { data } = await apiClient.post('/cart/add_item/', { - product_id: productId, - quantity - }); - - // Process product images and prices - if (data && data.items) { - const processedItems = await Promise.all( - data.items.map(async (item) => { - const price = parseFloat(item.product_price); - return { - ...item, - product_image: await processImageUrl(item.product_image), - product_price: !isNaN(price) ? price : 0, - price: !isNaN(price) ? price : 0 - }; - }) - ); - - // Trigger cart refresh if available - if (globalRefreshCart) { - globalRefreshCart(); - } - - return { - ...data, - items: processedItems - }; - } - return data; - } catch (error) { - console.error('Error adding to cart:', error); - if (error.response?.status === 401) { - // If unauthorized, try to add to local storage - return await addToCart(productId, quantity); - } - throw error; - } -}; - -export const updateCartItemQuantity = async (itemId, quantity) => { - try { - const { data } = await apiClient.post('/cart/update_quantity/', { - item_id: itemId, - quantity: quantity - }); - return data; - } catch (error) { - console.error('Error updating cart quantity:', error); - if (error.response?.status === 401) { - await AsyncStorage.removeItem(STORAGE_KEYS.USER_TOKEN); - delete apiClient.defaults.headers.common['Authorization']; - throw new Error('Session expired. Please login again.'); - } - throw error; - } -}; - -export const deleteCartItem = async (itemId) => { - try { - console.log('Attempting to delete cart item with ID:', itemId); - // First get the cart item to get the product ID - const cartResponse = await apiClient.get('/cart/items/'); - const cartItem = cartResponse.data.items.find(item => item.id === itemId); - - if (!cartItem) { - throw new Error('Cart item not found'); - } - - const response = await apiClient.post('/cart/remove_item/', { - product_id: cartItem.product - }); - console.log('Delete response:', response.data); return response.data; } catch (error) { - console.error('Error removing cart item:', error); - console.error('Error response:', error.response?.data); - console.error('Error status:', error.response?.status); - if (error.response?.status === 401) { - await AsyncStorage.removeItem(STORAGE_KEYS.USER_TOKEN); - delete apiClient.defaults.headers.common['Authorization']; - throw new Error('Session expired. Please login again.'); - } + console.error('Error updating password:', error); throw error; } }; -export const checkoutCart = async (shippingDetails) => { +export const deleteAccount = async (password) => { try { - const { data } = await apiClient.post('/cart/checkout/', shippingDetails); - return data; - } catch (error) { - console.error('Error checking out:', error); - throw error; - } -}; - -// AUTH - Fixed to match Django URLs -export const loginUser = async (credentials) => { - try { - // First ensure we have a CSRF token - const csrfToken = await fetchCSRFToken(); - if (!csrfToken) { - console.error('Failed to get CSRF token before login'); - return { success: false, error: 'Failed to establish secure connection' }; - } - - // Use the correct endpoint from your Django URLs - const response = await apiClient.post('/token-auth/', credentials); - const data = response.data; - - if (data && data.token) { - // Store token - await AsyncStorage.setItem(STORAGE_KEYS.USER_TOKEN, data.token); - - // Set the token in axios defaults - apiClient.defaults.headers.common['Authorization'] = `Token ${data.token}`; - - // Store user data - if (data.user) { - await AsyncStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(data.user)); - } - - return { success: true, user: data.user }; - } - - return { success: false, error: data?.message || 'Invalid credentials' }; - } catch (error) { - console.error('Login Error:', error.response?.data || error); - - // Handle specific error cases - if (error.response) { - if (error.response.status === 401) { - return { success: false, error: 'Invalid username or password' }; - } else if (error.response.status === 403) { - // CSRF token might be invalid, try to refresh it - await fetchCSRFToken(); - return { success: false, error: 'Please try again' }; - } - return { - success: false, - error: error.response.data?.message || 'Login failed' - }; - } - - // Network or other errors - return { - success: false, - error: error.message || 'Network error. Please check your connection.' - }; - } -}; - -export const registerUser = async (userData) => { - try { - // Ensure we have a CSRF token - await fetchCSRFToken(); - - const { data } = await apiClient.post('/users/', userData); - return data; - } catch (error) { - console.error('Registration Error:', error); - throw error; - } -}; - -export const logoutUser = async () => { - try { - // Try to logout on server using the correct endpoint - try { - await apiClient.post('/logout/'); - } catch (apiError) { - console.log('API logout failed, continuing with local logout:', apiError); - } - - // Clear local storage - await AsyncStorage.removeItem(STORAGE_KEYS.USER_TOKEN); - await AsyncStorage.removeItem(STORAGE_KEYS.USER_DATA); - - // Clear API client headers - delete apiClient.defaults.headers.common['Authorization']; - - return { success: true }; - } catch (error) { - console.error('Error during logout:', error); - return { success: false, error: error.message }; - } -}; - -export const getCurrentUser = async () => { - try { - // Check if we have a token - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); - if (!token) return null; - - // Get user data from API - const { data } = await apiClient.get('/users/me/'); - - // Store updated data - await AsyncStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(data)); - - return data; - } catch (error) { - console.error('Error getting current user:', error); - - // Fallback to cached data - const userData = await AsyncStorage.getItem(STORAGE_KEYS.USER_DATA); - return userData ? JSON.parse(userData) : null; - } -}; - -// ORDERS -export const getOrders = async () => { - try { - const { data } = await apiClient.get('/orders/'); - return data; - } catch (error) { - console.error('Error fetching orders:', error); - return []; - } -}; - -export const getOrderById = async (orderId) => { - try { - const { data } = await apiClient.get(`/orders/${orderId}/`); - return data; - } catch (error) { - console.error('Error fetching order details:', error); - throw error; - } -}; - -export const placeOrder = async (orderData) => { - try { - const { data } = await apiClient.post('/orders/', orderData); - return data; - } catch (error) { - console.error('Error placing order:', error); - throw error; - } -}; - -// FAVORITES -export const getFavorites = async () => { - try { - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); - if (!token) { - // If not logged in, get from local storage - const localFavorites = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES); - return localFavorites ? JSON.parse(localFavorites) : []; - } - - const { data } = await apiClient.get('/favorites/'); - - // Process images - if (Array.isArray(data)) { - const processedData = await Promise.all( - data.map(async (favorite) => { - const product = favorite.product || favorite; - return { - ...product, - image: await processImageUrl(product.image) - }; - }) - ); - return processedData; - } - - return data || []; - } catch (error) { - console.error('Error getting favorites:', error); - return []; - } -}; - -export const toggleFavorite = async (product) => { - try { - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); - if (!token) { - // If not logged in, use local storage - const localFavorites = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES); - const currentFavorites = localFavorites ? JSON.parse(localFavorites) : []; - - // Check if product exists in favorites - const exists = currentFavorites.some(item => item.id === product.id); - let updatedFavorites; - - if (exists) { - // Remove from favorites - updatedFavorites = currentFavorites.filter(item => item.id !== product.id); - } else { - // Add to favorites - const processedProduct = { - ...product, - image: await processImageUrl(product.image) - }; - updatedFavorites = [...currentFavorites, processedProduct]; - } - - // Save to local storage - await AsyncStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(updatedFavorites)); - return updatedFavorites; - } - - // If logged in, use API - await apiClient.post('/favorites/toggle/', { - product_id: product.id + const response = await api.post('/users/delete_account/', { + password: password }); - - // Get updated favorites list - return await getFavorites(); + return response.data; } catch (error) { - console.error('Error toggling favorite:', error); - return []; - } -}; - -// RATINGS -export const rateProduct = async (productId, rating, comment = '') => { - try { - const { data } = await apiClient.post(`/products/${productId}/rate/`, { - rating, - comment - }); - return data; - } catch (error) { - console.error('Error rating product:', error); - if (error.response?.status === 401) { - await AsyncStorage.removeItem(STORAGE_KEYS.USER_TOKEN); - delete apiClient.defaults.headers.common['Authorization']; - throw new Error('Session expired. Please login again.'); - } + console.error('Error deleting account:', error); throw error; } }; -// Initialize API - Updated to handle CSRF token -export const initializeAPI = async () => { - try { - console.log('Initializing API...'); - - // First fetch CSRF token - const csrfToken = await fetchCSRFToken(); - if (!csrfToken) { - console.error('Failed to fetch CSRF token during initialization'); - return false; - } - console.log('CSRF token fetched successfully'); - - // Then check auth token - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); - if (token) { - apiClient.defaults.headers.common['Authorization'] = `Token ${token}`; - console.log('Auth token loaded'); - - // Verify token is valid - try { - const response = await apiClient.get('/users/me/'); - console.log('User authenticated:', response.data); - return true; - } catch (error) { - console.error('Token validation error:', error.response?.data || error); - if (error.response?.status === 401) { - // Token invalid, clear it - await AsyncStorage.removeItem(STORAGE_KEYS.USER_TOKEN); - delete apiClient.defaults.headers.common['Authorization']; - console.log('Invalid token cleared'); - } - } - } else { - console.log('No auth token found'); - } - return false; - } catch (error) { - console.error('Failed to initialize API:', error.response?.data || error); - return false; - } -}; - -// For backwards compatibility -export const fetchPosts = async (searchTerm) => { - try { - // Simulate authentication check - await checkAuthentication(); - - // Simulate network delay - await delay(500); - - // Filter products based on search term - const results = await Promise.all( - mockProducts - .filter(product => - product.title.toLowerCase().includes(searchTerm.toLowerCase()) || - product.description.toLowerCase().includes(searchTerm.toLowerCase()) || - product.category.toLowerCase().includes(searchTerm.toLowerCase()) - ) - .map(async product => ({ - ...product, - image: await processImageUrl(product.image) - })) - ); - - // Save to recent searches if user is logged in - try { - await saveUserRecentSearch(searchTerm); - } catch (error) { - // Silently fail if user is not logged in - console.log('User not logged in, not saving search'); - } - - return results; - } catch (error) { - console.error('Error fetching products:', error); - throw error; - } -}; - -export const fetchPostById = async (id) => { - try { - // Simulate authentication check - await checkAuthentication(); - - await delay(200); - const product = mockProducts.find(p => p.id === parseInt(id)); - if (!product) { - throw new Error('Product not found'); - } - return { - ...product, - image: await processImageUrl(product.image) - }; - } catch (error) { - throw error; - } -}; - -export const getUserData = async () => { - try { - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); - if (!token) return null; - - const { data } = await apiClient.get('/users/me/'); - return data; - } catch (error) { - console.error('Error fetching user data:', error); - return null; - } -}; - -// RECENT SEARCHES +// Recent searches functions export const getUserRecentSearches = async () => { try { - const searches = await AsyncStorage.getItem('recentSearches'); - return searches ? JSON.parse(searches) : []; + // Always use local storage for recent searches since the API endpoint doesn't exist + const recentSearchesData = await AsyncStorage.getItem(STORAGE_KEYS.RECENT_SEARCHES); + return recentSearchesData ? JSON.parse(recentSearchesData) : []; } catch (error) { console.error('Error getting recent searches:', error); return []; @@ -873,9 +851,22 @@ export const getUserRecentSearches = async () => { export const saveUserRecentSearch = async (searchTerm) => { try { - const searches = await getUserRecentSearches(); - const updatedSearches = [searchTerm, ...searches.filter(term => term !== searchTerm)].slice(0, 10); - await AsyncStorage.setItem('recentSearches', JSON.stringify(updatedSearches)); + if (!searchTerm || typeof searchTerm !== 'string') { + console.warn('Invalid search term provided to saveUserRecentSearch'); + return []; + } + + // Always use local storage for recent searches + const recentSearchesData = await AsyncStorage.getItem(STORAGE_KEYS.RECENT_SEARCHES); + const recentSearches = recentSearchesData ? JSON.parse(recentSearchesData) : []; + + // Remove if already exists (to move it to the front) + const filteredSearches = recentSearches.filter(term => term !== searchTerm); + + // Add to beginning and limit to 5 recent searches + const updatedSearches = [searchTerm, ...filteredSearches].slice(0, 5); + + await AsyncStorage.setItem(STORAGE_KEYS.RECENT_SEARCHES, JSON.stringify(updatedSearches)); return updatedSearches; } catch (error) { console.error('Error saving recent search:', error); @@ -885,9 +876,17 @@ export const saveUserRecentSearch = async (searchTerm) => { export const removeUserRecentSearch = async (searchTerm) => { try { - const searches = await getUserRecentSearches(); - const updatedSearches = searches.filter(term => term !== searchTerm); - await AsyncStorage.setItem('recentSearches', JSON.stringify(updatedSearches)); + if (!searchTerm || typeof searchTerm !== 'string') { + console.warn('Invalid search term provided to removeUserRecentSearch'); + return []; + } + + // Always use local storage for recent searches + const recentSearchesData = await AsyncStorage.getItem(STORAGE_KEYS.RECENT_SEARCHES); + const recentSearches = recentSearchesData ? JSON.parse(recentSearchesData) : []; + + const updatedSearches = recentSearches.filter(term => term !== searchTerm); + await AsyncStorage.setItem(STORAGE_KEYS.RECENT_SEARCHES, JSON.stringify(updatedSearches)); return updatedSearches; } catch (error) { console.error('Error removing recent search:', error); @@ -897,7 +896,8 @@ export const removeUserRecentSearch = async (searchTerm) => { export const clearAllUserRecentSearches = async () => { try { - await AsyncStorage.removeItem('recentSearches'); + // Always use local storage for recent searches + await AsyncStorage.removeItem(STORAGE_KEYS.RECENT_SEARCHES); return []; } catch (error) { console.error('Error clearing recent searches:', error); @@ -905,50 +905,232 @@ export const clearAllUserRecentSearches = async () => { } }; -// FAVORITES -export const isProductFavorited = async (productId) => { +export const getPrivacyPolicy = async () => { + try { + const response = await api.get('/privacy-policies/'); + return response.data; // Return all policies + } catch (error) { + console.error('Error fetching privacy policy:', error); + throw error; + } +}; + +export const getFAQs = async (category = 'All') => { + try { + const { data } = await api.get('/faqs/', { + params: { + category: category + } + }); + return data; + } catch (error) { + console.error('Error fetching FAQs:', error); + throw error; + } +}; + +export const getFAQCategories = async () => { + try { + const { data } = await api.get('/faqs/categories/'); + return data.map(category => category.value); + } catch (error) { + console.error('Error fetching FAQ categories:', error); + // Return default categories if API fails + return ['All', 'Services', 'General', 'Account']; + } +}; + +export const getContacts = async () => { try { - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); if (!token) { - // If not logged in, check local storage - const localFavorites = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES); - const favorites = localFavorites ? JSON.parse(localFavorites) : []; - return { - success: true, - isFavorited: favorites.some(item => item.id === productId) - }; + // Return default contacts if not logged in + return [ + { + id: 1, + contact_type: 'customer_service', + title: 'Customer Service', + value: '(480)555-0103', + is_active: true + }, + { + id: 2, + contact_type: 'whatsapp', + title: 'WhatsApp', + value: '(480)555-0103', + is_active: true + }, + { + id: 3, + contact_type: 'website', + title: 'Website', + value: 'www.example.com', + is_active: true + }, + { + id: 4, + contact_type: 'facebook', + title: 'Facebook', + value: '@example', + is_active: true + }, + { + id: 5, + contact_type: 'instagram', + title: 'Instagram', + value: '@example', + is_active: true + }, + { + id: 6, + contact_type: 'twitter', + title: 'Twitter', + value: '@example', + is_active: true + } + ]; } - // If logged in, check API - const { data } = await apiClient.get(`/favorites/check/${productId}/`); - return { - success: true, - isFavorited: data.is_favorited - }; + // Set the auth token in headers + api.defaults.headers.common['Authorization'] = `Token ${token}`; + + const { data } = await api.get('/contacts/'); + return data; } catch (error) { - console.error('Error checking favorite status:', error); - return { - success: false, - isFavorited: false - }; + console.error('Error fetching contacts:', error); + // Return default contacts on error + return [ + { + id: 1, + contact_type: 'customer_service', + title: 'Customer Service', + value: '(480)555-0103', + is_active: true + }, + { + id: 2, + contact_type: 'whatsapp', + title: 'WhatsApp', + value: '(480)555-0103', + is_active: true + }, + { + id: 3, + contact_type: 'website', + title: 'Website', + value: 'www.example.com', + is_active: true + }, + { + id: 4, + contact_type: 'facebook', + title: 'Facebook', + value: '@example', + is_active: true + }, + { + id: 5, + contact_type: 'instagram', + title: 'Instagram', + value: '@example', + is_active: true + }, + { + id: 6, + contact_type: 'twitter', + title: 'Twitter', + value: '@example', + is_active: true + } + ]; } }; -// Authentication check function -export const checkAuthentication = async () => { +export const getInviteMessage = async () => { + return { + message: "Hey! I've been shopping on Vendora App and it's been amazing! They have awesome deals, fast delivery, and quality products. Use my link to sign up and get a special discount on your first order!", + link: "https://play.google.com/store/apps/details?id=com.vendora.app" + }; +}; + +export const fetchSliders = async () => { try { - const token = await AsyncStorage.getItem(STORAGE_KEYS.USER_TOKEN); - if (!token) { - throw { - response: { - status: 401, - data: { detail: 'Authentication credentials were not provided.' } - } - }; - } - return token; + const { data } = await api.get('/sliders/'); + return data; } catch (error) { - console.error('Authentication check failed:', error); + console.error('Error fetching sliders:', error); throw error; } -}; \ No newline at end of file +}; + +// Coupon functions +export const getCoupons = async () => { + try { + const { data } = await api.get('/coupons/'); + return data; + } catch (error) { + console.error('Error fetching coupons:', error); + throw error; + } +}; + +export const validateCoupon = async (code, cartTotal) => { + try { + const { data } = await api.post('/validate-coupon/', { + code, + cart_total: cartTotal + }); + return data; + } catch (error) { + console.error('Error validating coupon:', error); + if (error.response?.data) { + // Return the error response data with a valid property set to false + return { + valid: false, + message: error.response.data.message || 'Invalid coupon code', + ...error.response.data + }; + } + throw error; + } +}; + +export const getNotifications = async () => { + try { + const { data } = await api.get('/notifications/'); + return data; + } catch (error) { + console.error('Error fetching notifications:', error); + throw error; + } +}; + +export const markNotificationAsRead = async (notificationId) => { + try { + const { data } = await api.put(`/notifications/${notificationId}/read/`); + return data; + } catch (error) { + console.error('Error marking notification as read:', error); + throw error; + } +}; + +export const deleteNotification = async (notificationId) => { + try { + const { data } = await api.delete(`/notifications/${notificationId}/`); + return data; + } catch (error) { + console.error('Error deleting notification:', error); + throw error; + } +}; + +export const clearAllNotifications = async () => { + try { + const { data } = await api.delete('/notifications/clear_all/'); + return data; + } catch (error) { + console.error('Error clearing notifications:', error); + throw error; + } +}; diff --git a/frontend/app/api/index.jsx b/frontend/app/api/index.jsx index 8d775fe..5c18d3e 100644 --- a/frontend/app/api/index.jsx +++ b/frontend/app/api/index.jsx @@ -1,15 +1,5 @@ // mock-api.js import AsyncStorage from '@react-native-async-storage/async-storage'; -import { View, Text } from 'react-native'; - -// Dummy component to fix Expo Router error -export default function ApiIndex() { - return ( - - API Service - This component is only to prevent Expo Router errors - - ); -} // Mock product data const mockProducts = [ @@ -98,7 +88,6 @@ const mockUsers = [ } ]; -// Add delay to simulate network request const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // Helper to simulate authentication in mock API @@ -224,13 +213,27 @@ export const deleteCartItem = async (productId) => { await checkAuthentication(); const cartData = await AsyncStorage.getItem('cart'); - let cart = cartData ? JSON.parse(cartData) : { data: [], totalPrice: 0 }; + if (!cartData) { + return { data: [], totalPrice: 0 }; + } + + let cart = JSON.parse(cartData); + + // Make sure cart has the expected structure + if (!cart.data) { + cart = { data: [], totalPrice: 0 }; + } + // Filter out the item to be deleted cart.data = cart.data.filter(item => item.id !== productId); - cart.totalPrice = cart.data.reduce((sum, item) => sum + item.totalPrice, 0); + + // Recalculate total price + cart.totalPrice = cart.data.reduce((sum, item) => sum + (item.totalPrice || 0), 0); + // Save updated cart to AsyncStorage await AsyncStorage.setItem('cart', JSON.stringify(cart)); - await delay(200); + + await delay(200); // Simulate network delay return cart; } catch (error) { console.error('Error deleting item from cart:', error); @@ -524,4 +527,66 @@ export const fetchProducts = async (searchTerm) => { console.error('Error fetching products:', error); throw error; } -}; \ No newline at end of file +}; + +export const getInviteMessage = async () => { + return { + message: "Hey! I've been shopping on this Shopping App and it's been amazing! They have awesome deals, fast delivery, and quality products. Use my link to sign up and get a special discount on your first order!", + link: "https://play.google.com/store/apps/details?id=com.vendora.app" + }; +}; + +// Mock slider data +const mockSliders = [ + { + id: 1, + title: "new collection", + description: "Discount 50% for the first transaction.", + image: "http://example.com/slider1.jpg", + is_active: true + }, + { + id: 2, + title: "kids collection", + description: "Discount 50% for the first transaction.", + image: "http://example.com/slider2.jpg", + is_active: true + }, + { + id: 3, + title: "traditional collection", + description: "Discount 50% for the first transaction.", + image: "http://example.com/slider3.jpg", + is_active: true + } +]; + +const api = { + fetchPosts, + fetchPostsById, + fetchCarts, + createCarts, + getCart, + deleteCartItem, + loginUser, + logoutUser, + getCurrentUser, + checkIsFavoriteForCurrentUser, + toggleFavoriteForCurrentUser, + getUserFavorites, + getUserRecentSearches, + saveUserRecentSearch, + removeUserRecentSearch, + clearAllUserRecentSearches, + getFavorites, + toggleFavorite, + isProductFavorited, + getUserData, + fetchProducts, + getInviteMessage +}; + +export default api; + + + diff --git a/frontend/app/components/ActiveOrder.jsx b/frontend/app/components/ActiveOrder.jsx index 6c70613..fc363c9 100644 --- a/frontend/app/components/ActiveOrder.jsx +++ b/frontend/app/components/ActiveOrder.jsx @@ -1,10 +1,12 @@ -import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native' -import React from 'react' +import { View, Text, Image, TouchableOpacity, ActivityIndicator, Alert } from 'react-native' +import React, { useState } from 'react' import { useNavigation } from '@react-navigation/native' +import { fetchProductById } from '../api' export default function ActiveOrder({ orders = [], navigation: propNavigation }) { // Use provided navigation prop if available, otherwise get from hook const navigation = propNavigation || useNavigation(); + const [loading, setLoading] = useState(false); // Safe number formatting helper const formatCurrency = (value) => { @@ -17,6 +19,48 @@ export default function ActiveOrder({ orders = [], navigation: propNavigation }) return '0.00'; }; + const handleReOrder = async (order) => { + console.log('Order data:', order); + + // Use order_items as primary source, fallback to items if order_items is empty + const orderItems = (order.order_items && order.order_items.length > 0) ? order.order_items : order.items; + console.log('Order items:', orderItems); + + if (!orderItems || orderItems.length === 0) { + Alert.alert('Error', 'No items found in this order'); + return; + } + + try { + setLoading(true); + const firstItem = orderItems[0]; + console.log('First item:', firstItem); + + const productId = firstItem.product || firstItem.product_id || firstItem.id; + console.log('Product ID:', productId); + + if (!productId) { + Alert.alert('Error', 'Could not find product ID'); + return; + } + + const product = await fetchProductById(productId); + console.log('Fetched product:', product); + + if (!product) { + Alert.alert('Error', 'Product not found'); + return; + } + + navigation.navigate('ProductDetails', { id: product.id }); + } catch (error) { + console.error('Error in handleReOrder:', error); + Alert.alert('Error', 'Failed to load product details. Please try again.'); + } finally { + setLoading(false); + } + }; + if (!orders || orders.length === 0) { return ( @@ -28,7 +72,7 @@ export default function ActiveOrder({ orders = [], navigation: propNavigation }) return ( {orders.map((order, index) => { - // Each order can have multiple items + const orderItems = (order.order_items && order.order_items.length > 0) ? order.order_items : order.items; return ( @@ -39,8 +83,8 @@ export default function ActiveOrder({ orders = [], navigation: propNavigation }) - {order.items && order.items.map((item, itemIndex) => ( - + {orderItems && orderItems.map((item, itemIndex) => ( + Total: ${formatCurrency(order.total_price || order.total_amount)} - navigation.navigate('Track Order', { orderId: order.id })} - className={`bg-primary rounded-full px-4 py-2`} - > - Track Order - + + navigation.navigate('Track Order', { orderId: order.id })} + className={`bg-primary rounded-full px-4 py-2`} + > + Track Order + + + ); diff --git a/frontend/app/components/CancelledOrder.jsx b/frontend/app/components/CancelledOrder.jsx index 511b79e..6f67277 100644 --- a/frontend/app/components/CancelledOrder.jsx +++ b/frontend/app/components/CancelledOrder.jsx @@ -1,10 +1,12 @@ -import { View, Text, Image, TouchableOpacity } from 'react-native' -import React from 'react' +import { View, Text, Image, TouchableOpacity, ActivityIndicator, Alert } from 'react-native' +import React, { useState } from 'react' import { useNavigation } from '@react-navigation/native' +import { fetchProductById } from '../api' export default function CancelledOrder({ orders = [], navigation: propNavigation }) { // Use provided navigation prop if available, otherwise get from hook const navigation = propNavigation || useNavigation(); + const [loading, setLoading] = useState(false); // Safe number formatting helper const formatCurrency = (value) => { @@ -17,6 +19,48 @@ export default function CancelledOrder({ orders = [], navigation: propNavigation return '0.00'; }; + const handleReOrder = async (order) => { + console.log('Order data:', order); + + // Use order_items as primary source, fallback to items if order_items is empty + const orderItems = (order.order_items && order.order_items.length > 0) ? order.order_items : order.items; + console.log('Order items:', orderItems); + + if (!orderItems || orderItems.length === 0) { + Alert.alert('Error', 'No items found in this order'); + return; + } + + try { + setLoading(true); + const firstItem = orderItems[0]; + console.log('First item:', firstItem); + + const productId = firstItem.product || firstItem.product_id || firstItem.id; + console.log('Product ID:', productId); + + if (!productId) { + Alert.alert('Error', 'Could not find product ID'); + return; + } + + const product = await fetchProductById(productId); + console.log('Fetched product:', product); + + if (!product) { + Alert.alert('Error', 'Product not found'); + return; + } + + navigation.navigate('ProductDetails', { id: product.id }); + } catch (error) { + console.error('Error in handleReOrder:', error); + Alert.alert('Error', 'Failed to load product details. Please try again.'); + } finally { + setLoading(false); + } + }; + if (!orders || orders.length === 0) { return ( @@ -28,7 +72,7 @@ export default function CancelledOrder({ orders = [], navigation: propNavigation return ( {orders.map((order, index) => { - // Each order can have multiple items + const orderItems = (order.order_items && order.order_items.length > 0) ? order.order_items : order.items; return ( @@ -39,8 +83,8 @@ export default function CancelledOrder({ orders = [], navigation: propNavigation - {order.items && order.items.map((item, itemIndex) => ( - + {orderItems && orderItems.map((item, itemIndex) => ( + Total: ${formatCurrency(order.total_price || order.total_amount)} navigation.navigate('Home')} + onPress={() => handleReOrder(order)} className={`bg-primary rounded-full px-4 py-2`} + disabled={loading} > - Re-Order + {loading ? ( + + ) : ( + Re-Order + )} diff --git a/frontend/app/components/Category.jsx b/frontend/app/components/Category.jsx index 2c38d0d..3c43142 100644 --- a/frontend/app/components/Category.jsx +++ b/frontend/app/components/Category.jsx @@ -34,44 +34,45 @@ export default function Category({ categories = [] }) { image: "https://cdn-icons-png.flaticon.com/512/2934/2934315.png" } ] - + + const handleCategoryPress = (category) => { + // Ensure we have a valid category ID + if (!category.id || isNaN(parseInt(category.id))) { + console.error('Invalid category ID:', category.id); + return; + } + + // Navigate to the category screen with the category ID as a number + navigation.navigate('Category', { + title: category.name, + categoryId: parseInt(category.id) + }); + }; + return ( - - - Category - See All - + - { - displayCategories.map((item, index) => { - return ( - { - navigation.navigate('Category', { - categoryId: item.id, - title: item.name - }) - }} - className={`${index + 1 == displayCategories.length ? "mr-4" : " mr-4"} flex-col items-center gap-2`} - > - - - - {item.name} - - ) - }) - } + {displayCategories.map((category, index) => ( + handleCategoryPress(category)} + className="items-center" + > + + + + {category.name} + + ))} - ) + ); } \ No newline at end of file diff --git a/frontend/app/components/CompletedOrder.jsx b/frontend/app/components/CompletedOrder.jsx index 6cd88d4..654ca8b 100644 --- a/frontend/app/components/CompletedOrder.jsx +++ b/frontend/app/components/CompletedOrder.jsx @@ -1,10 +1,12 @@ -import { View, Text, Image, TouchableOpacity } from 'react-native' -import React from 'react' +import { View, Text, Image, TouchableOpacity, ActivityIndicator, Alert } from 'react-native' +import React, { useState } from 'react' import { useNavigation } from '@react-navigation/native' +import { fetchProductById } from '../api' export default function CompletedOrder({ orders = [], navigation: propNavigation }) { // Use provided navigation prop if available, otherwise get from hook const navigation = propNavigation || useNavigation(); + const [loading, setLoading] = useState(false); // Safe number formatting helper const formatCurrency = (value) => { @@ -17,6 +19,95 @@ export default function CompletedOrder({ orders = [], navigation: propNavigation return '0.00'; }; + const handleReOrder = async (order) => { + console.log('Order data:', order); + + // Use order_items as primary source, fallback to items if order_items is empty + const orderItems = (order.order_items && order.order_items.length > 0) ? order.order_items : order.items; + console.log('Order items:', orderItems); + + if (!orderItems || orderItems.length === 0) { + Alert.alert('Error', 'No items found in this order'); + return; + } + + try { + setLoading(true); + const firstItem = orderItems[0]; + console.log('First item:', firstItem); + + const productId = firstItem.product || firstItem.product_id || firstItem.id; + console.log('Product ID:', productId); + + if (!productId) { + Alert.alert('Error', 'Could not find product ID'); + return; + } + + const product = await fetchProductById(productId); + console.log('Fetched product:', product); + + if (!product) { + Alert.alert('Error', 'Product not found'); + return; + } + + navigation.navigate('ProductDetails', { id: product.id }); + } catch (error) { + console.error('Error in handleReOrder:', error); + Alert.alert('Error', 'Failed to load product details. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleLeaveReview = async (order) => { + console.log('Order data for review:', order); + + // Use order_items as primary source, fallback to items if order_items is empty + const orderItems = (order.order_items && order.order_items.length > 0) ? order.order_items : order.items; + console.log('Order items for review:', orderItems); + + if (!orderItems || orderItems.length === 0) { + Alert.alert('Error', 'No items found in this order'); + return; + } + + try { + setLoading(true); + const firstItem = orderItems[0]; + console.log('First item for review:', firstItem); + + const productId = firstItem.product || firstItem.product_id || firstItem.id; + console.log('Product ID for review:', productId); + + if (!productId) { + Alert.alert('Error', 'Could not find product ID'); + return; + } + + const product = await fetchProductById(productId); + console.log('Fetched product for review:', product); + + if (!product) { + Alert.alert('Error', 'Product not found'); + return; + } + + navigation.navigate('Leave Review', { + productId: product.id, + productName: product.name, + productImage: product.image, + orderId: order.id + }); + } catch (error) { + console.error('Error in handleLeaveReview:', error); + Alert.alert('Error', 'Failed to load product details. Please try again.'); + } finally { + setLoading(false); + } + }; + if (!orders || orders.length === 0) { return ( @@ -28,7 +119,7 @@ export default function CompletedOrder({ orders = [], navigation: propNavigation return ( {orders.map((order, index) => { - // Each order can have multiple items + const orderItems = (order.order_items && order.order_items.length > 0) ? order.order_items : order.items; return ( @@ -39,8 +130,8 @@ export default function CompletedOrder({ orders = [], navigation: propNavigation - {order.items && order.items.map((item, itemIndex) => ( - + {orderItems && orderItems.map((item, itemIndex) => ( + Total: ${formatCurrency(order.total_price || order.total_amount)} - navigation.navigate('Leave Review')} - className={`bg-primary rounded-full px-4 py-2`} - > - Leave Review - + + handleLeaveReview(order)} + className={`bg-primary rounded-full px-4 py-2`} + disabled={loading} + > + {loading ? ( + + ) : ( + Leave Review + )} + + ); diff --git a/frontend/app/components/ContactUsCenter.jsx b/frontend/app/components/ContactUsCenter.jsx index f3e1cb5..cb7d985 100644 --- a/frontend/app/components/ContactUsCenter.jsx +++ b/frontend/app/components/ContactUsCenter.jsx @@ -1,68 +1,172 @@ -import { View, Text } from 'react-native' -import React from 'react' +import { View, Text, Animated, ActivityIndicator, Linking } from 'react-native' +import React, { useState, useRef, useEffect } from 'react' +import { TouchableOpacity } from 'react-native' import { ChevronDownIcon, ChevronUpIcon, GlobeAltIcon } from 'react-native-heroicons/outline' -import Icon from 'react-native-vector-icons/FontAwesome6'; +import Icon from 'react-native-vector-icons/FontAwesome6' +import { getContacts } from '../api' export default function ContactUsCenter() { + const [contacts, setContacts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expandedItems, setExpandedItems] = useState({}) + const animations = useRef({}) + + useEffect(() => { + fetchContacts() + }, []) + + const fetchContacts = async () => { + try { + setLoading(true) + const data = await getContacts() + setContacts(data) + + // Initialize expanded state and animations + const initialExpandedState = {} + data.forEach(contact => { + initialExpandedState[contact.id] = contact.contact_type === 'whatsapp' + animations.current[contact.id] = new Animated.Value(contact.contact_type === 'whatsapp' ? 1 : 0) + }) + setExpandedItems(initialExpandedState) + } catch (err) { + setError('Failed to load contacts') + console.error('Error fetching contacts:', err) + } finally { + setLoading(false) + } + } + + const toggleItem = (itemId) => { + const isExpanded = !expandedItems[itemId] + setExpandedItems(prev => ({ + ...prev, + [itemId]: isExpanded + })) + + Animated.timing(animations.current[itemId], { + toValue: isExpanded ? 1 : 0, + duration: 300, + useNativeDriver: false, + }).start() + } + + const handleContactPress = async (contact) => { + try { + let url = ''; + switch (contact.contact_type) { + case 'whatsapp': + // Remove any non-numeric characters from the phone number + const phoneNumber = contact.value.replace(/\D/g, ''); + url = `https://wa.me/${phoneNumber}`; + break; + case 'website': + url = contact.value.startsWith('http') ? contact.value : `https://${contact.value}`; + break; + case 'facebook': + url = `https://facebook.com/${contact.value.replace('@', '')}`; + break; + case 'instagram': + url = `https://instagram.com/${contact.value.replace('@', '')}`; + break; + case 'twitter': + url = `https://twitter.com/${contact.value.replace('@', '')}`; + break; + case 'customer_service': + // For phone numbers, use tel: protocol + const telNumber = contact.value.replace(/\D/g, ''); + url = `tel:${telNumber}`; + break; + default: + return; + } + + const supported = await Linking.canOpenURL(url); + if (supported) { + await Linking.openURL(url); + } else { + console.error(`Don't know how to open URL: ${url}`); + } + } catch (error) { + console.error('Error opening URL:', error); + } + } + + const getIcon = (contactType) => { + switch (contactType) { + case 'customer_service': + return + case 'whatsapp': + return + case 'website': + return + case 'facebook': + return + case 'instagram': + return + case 'twitter': + return + default: + return null + } + } + + if (loading) { + return ( + + + + ) + } + + if (error) { + return ( + + {error} + + ) + } + return ( - - - - - Customer Service - - - - - - - - - - WhatsApp - - - + + {contacts.map((contact) => ( + + + toggleItem(contact.id)} + className="flex-row items-center justify-between p-4"> + + {getIcon(contact.contact_type)} + {contact.title} + + {expandedItems[contact.id] ? ( + + ) : ( + + )} + + + + + handleContactPress(contact)}> + + {contact.value} + + + + - (480)555-0103 - - - - - Website - - - - - - - - - Facebook - - - - - - - - - Instagram - - - - - - - - - Twitter - - - - - + ))} ) } \ No newline at end of file diff --git a/frontend/app/components/FaqCenter.jsx b/frontend/app/components/FaqCenter.jsx index 55d69e0..9a0c662 100644 --- a/frontend/app/components/FaqCenter.jsx +++ b/frontend/app/components/FaqCenter.jsx @@ -1,68 +1,134 @@ -import { View, Text, ScrollView } from 'react-native' -import React, { useState } from 'react' +import { View, Text, ScrollView, ActivityIndicator, Animated } from 'react-native' +import React, { useState, useEffect, useRef } from 'react' import { TouchableOpacity } from 'react-native'; import { ChevronDownIcon, ChevronUpIcon } from 'react-native-heroicons/outline'; +import { getFAQs, getFAQCategories } from '../api'; export default function FaqCenter() { - const faq = ["All", "Services", "General", "Account"] - const [faqActive, setfaqActive] = useState(0); - return ( - - - { - faq.map((item, index) => { - let isActive = index == faqActive; - return ( - setfaqActive(index)} - className={` - border border-gray-200 bg-gray-200 px-5 mr-4 mb-4 rounded-full py-3 - ${isActive ? "bg-primary" : ""} - `}> - - {item} - - - ) - }) - } - - - - Can i track my order's delevery status ? - - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Ab distinctio nihil assumenda nostrum laboriosam quo, - officia eligendi obcaecati accusamus aliquam debitis, voluptatibus excepturi consequatur expedita eius quisquam velit. Rerum, officia? - - - - Is there a return policy ? - - - - Can i save my favorite item for later ? - - - - Can i share products with my friends ? - - - - How do i contact customer support ? - - - - What payment methods are accepted ? - - - - How to add review ? - - - + const [categories, setCategories] = useState(['All', 'Services', 'General', 'Account']); + const [activeCategory, setActiveCategory] = useState('All'); + const [faqs, setFaqs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedFaqs, setExpandedFaqs] = useState({}); + const animations = useRef({}); + useEffect(() => { + fetchCategories(); + }, []); + + useEffect(() => { + fetchFAQs(); + }, [activeCategory]); + + const fetchCategories = async () => { + try { + const data = await getFAQCategories(); + setCategories(data); + } catch (err) { + console.error('Error fetching categories:', err); + } + }; + + const fetchFAQs = async () => { + try { + setLoading(true); + const data = await getFAQs(activeCategory); + setFaqs(data); + const initialExpandedState = {}; + data.forEach(faq => { + initialExpandedState[faq.id] = false; + animations.current[faq.id] = new Animated.Value(0); + }); + setExpandedFaqs(initialExpandedState); + } catch (err) { + setError('Failed to load FAQs'); + console.error('Error fetching FAQs:', err); + } finally { + setLoading(false); + } + }; + + const toggleFaq = (faqId) => { + const isExpanded = !expandedFaqs[faqId]; + setExpandedFaqs(prev => ({ + ...prev, + [faqId]: isExpanded + })); + + Animated.timing(animations.current[faqId], { + toValue: isExpanded ? 1 : 0, + duration: 300, + useNativeDriver: false, + }).start(); + }; + + return ( + + + {categories.map((category, index) => { + const isActive = category === activeCategory; + return ( + setActiveCategory(category)} + className={` + border border-gray-200 bg-gray-200 px-5 mr-4 rounded-full py-3 + ${isActive ? "bg-primary" : ""} + `}> + + {category} + + + ); + })} + + + {loading ? ( + + ) : error ? ( + {error} + ) : faqs.length > 0 ? ( + faqs.map((faq) => ( + + + toggleFaq(faq.id)} + className="flex-row justify-between items-center p-4"> + {faq.question} + {expandedFaqs[faq.id] ? ( + + ) : ( + + )} + + + + + + {faq.answer} + + + + + + )) + ) : ( + + + No FAQs available for this category + + + )} - ) + ); } \ No newline at end of file diff --git a/frontend/app/components/FavoriteButton.jsx b/frontend/app/components/FavoriteButton.jsx index 6adb625..696c09f 100644 --- a/frontend/app/components/FavoriteButton.jsx +++ b/frontend/app/components/FavoriteButton.jsx @@ -1,7 +1,8 @@ -import { TouchableOpacity, ActivityIndicator } from 'react-native' -import React, { useState, useEffect } from 'react' +import { TouchableOpacity } from 'react-native' +import React from 'react' +import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import { HeartIcon } from 'react-native-heroicons/outline' -import { HeartIcon as HeartSolid } from 'react-native-heroicons/solid' +import { HeartIcon as SolidHeartIcon } from 'react-native-heroicons/solid' import { getFavorites } from '../api' /** @@ -17,15 +18,7 @@ import { getFavorites } from '../api' * @param {number} props.productId The ID of the product * @returns {React.Component} Favorite button component */ -export default function FavoriteButton({ - isFavorite = false, - onToggleFavorite, - size = 24, - activeColor = '#ff3b30', - inactiveColor = '#000000', - style = {}, - productId -}) { +const FavoriteButton = ({ isFavorite, onToggleFavorite, size = 25, style, productId }) => { const [loading, setLoading] = useState(false); const [favoriteState, setFavoriteState] = useState(isFavorite); @@ -70,16 +63,25 @@ export default function FavoriteButton({ {loading ? ( - + ) : favoriteState ? ( - + ) : ( - + )} - ) -} \ No newline at end of file + ); +} + +export default FavoriteButton; \ No newline at end of file diff --git a/frontend/app/components/FlashSales.jsx b/frontend/app/components/FlashSales.jsx index 1458687..e316cc3 100644 --- a/frontend/app/components/FlashSales.jsx +++ b/frontend/app/components/FlashSales.jsx @@ -2,23 +2,27 @@ // demoData //camel case ex:iAmAGoodBBoy // demo_data // snake case ex:i_am_a_good_boy -import { View, Text, ScrollView, TouchableOpacity, Image } from 'react-native' -import React, { useEffect, useState } from 'react' +import { View, Text, ScrollView, TouchableOpacity, Image, ActivityIndicator } from 'react-native' +import React, { useEffect, useState, forwardRef } from 'react' import MasonryList from '@react-native-seoul/masonry-list'; -import flashSalesData from '../constants/flashSalesData'; import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import { StarIcon } from 'react-native-heroicons/solid'; import { HeartIcon } from 'react-native-heroicons/outline'; +import { HeartIcon as SolidHeartIcon } from 'react-native-heroicons/solid'; import Icon from "react-native-vector-icons/FontAwesome" import { useNavigation } from '@react-navigation/native'; -import { getFavorites, toggleFavorite } from '../api'; +import { getFavorites, toggleFavorite, fetchProducts } from '../api'; +import TabBar from '../themes/TabBar' +import { ArrowLeftIcon, ChevronLeftIcon } from 'react-native-heroicons/outline' -const FlashSales = ({data}) => { +const FlashSales = forwardRef((props, ref) => { const navigation = useNavigation() const saleEnd = "2024-12-20T23:45:30"; const [endDate, setEndDate] = useState(calenderLastDate(saleEnd)) const [favorites, setFavorites] = useState([]); const [loading, setLoading] = useState(true); + const [products, setProducts] = useState([]); + const [error, setError] = useState(null); useEffect(() => { const timer = setInterval(() => { @@ -31,21 +35,28 @@ const FlashSales = ({data}) => { return () => clearInterval(timer) }, [saleEnd]) - // Load favorites when component mounts + // Load products and favorites when component mounts useEffect(() => { - const loadFavorites = async () => { + const loadData = async () => { try { setLoading(true); - const favoriteProducts = await getFavorites(); + const [productsData, favoriteProducts] = await Promise.all([ + fetchProducts(), + getFavorites() + ]); + setProducts(productsData || []); setFavorites(favoriteProducts || []); + setError(null); } catch (error) { - console.error('Error loading favorites:', error); + console.error('Error loading data:', error); + setError('Failed to load products'); + setProducts([]); setFavorites([]); } finally { setLoading(false); } }; - loadFavorites(); + loadData(); }, []); //Category toggle @@ -56,8 +67,13 @@ const FlashSales = ({data}) => { setActiveSet(item) } - const handleFavouriteToggle = async (product) => { + const handleFavouriteToggle = async (productId) => { try { + const product = products.find(p => p.id === productId); + if (!product) { + console.error('Product not found:', productId); + return; + } const updatedFavorites = await toggleFavorite(product); setFavorites(updatedFavorites || []); } catch (error) { @@ -66,20 +82,60 @@ const FlashSales = ({data}) => { }; const isProductFavorited = (productId) => { - if (!Array.isArray(favorites)) return false; return favorites.some(fav => fav.id === productId); }; + const navigateToProductDetails = (productId) => { + navigation.navigate('ProductDetails', { id: productId }); + }; + if (loading) { return ( - - Loading... + + + + ); + } + + if (error) { + return ( + + {error} + { + setLoading(true); + fetchProducts() + .then(data => { + setProducts(data); + setError(null); + }) + .catch(err => setError(err.message || 'Failed to fetch products')) + .finally(() => setLoading(false)); + }} + className="mt-4 bg-primary py-2 px-4 rounded-lg" + > + Retry + ); } return ( - + + + FlashSales { @@ -122,25 +178,31 @@ const FlashSales = ({data}) => { }) } - item.id} - numColumns={2} - showsVerticalScrollIndicator={false} - renderItem={({ item, index }) => ( - handleFavouriteToggle(item)} - isFavourite={isProductFavorited(item.id)} - navigation={navigation} - /> - )} - className="mx-4" - /> - + {products.length === 0 ? ( + + No products available + + ) : ( + item.id.toString()} + numColumns={2} + showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => ( + handleFavouriteToggle(item.id)} + isFavourite={isProductFavorited(item.id)} + onProductPress={() => navigateToProductDetails(item.id)} + /> + )} + className="mx-4" + /> + )} + ) -} +}) const calenderLastDate = (saleEnd) => { const nowDate = new Date(); @@ -161,7 +223,8 @@ const calenderLastDate = (saleEnd) => { } } -const CardItem = ({ item, index, handleFavouriteToggle, isFavourite, navigation }) => { +const CardItem = ({ item, index, handleFavouriteToggle, isFavourite, onProductPress }) => { + const navigation = useNavigation(); let isEven = item.id % 2 == 0 const getRating = () => { @@ -183,7 +246,7 @@ const CardItem = ({ item, index, handleFavouriteToggle, isFavourite, navigation return ( - { navigation.navigate('ProductDetails',{id:item.id,}) }} className="relative"> + { @@ -192,12 +255,11 @@ const CardItem = ({ item, index, handleFavouriteToggle, isFavourite, navigation }} className="absolute right-4 top-4 bg-white/50 p-2 rounded-full" > - { - isFavourite ? - - : - - } + {isFavourite ? ( + + ) : ( + + )} diff --git a/frontend/app/components/HomeCarousel.jsx b/frontend/app/components/HomeCarousel.jsx index bcbf88b..05bfe44 100644 --- a/frontend/app/components/HomeCarousel.jsx +++ b/frontend/app/components/HomeCarousel.jsx @@ -1,9 +1,43 @@ -import { View, Text, Image } from 'react-native' +import { View, Text, Image, TouchableOpacity } from 'react-native' import React, { useEffect, useState } from 'react' import PagerView from 'react-native-pager-view'; import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; +import { useNavigation } from '@react-navigation/native'; const HomeCarousel = ({ setPage, slider }) => { + const navigation = useNavigation(); + + const handleShopNow = (category) => { + // Handle both category object and direct category ID + let categoryId; + let categoryName; + + if (typeof category === 'object' && category !== null) { + // If category is an object + if (!category.id) { + console.error('Invalid category data:', category); + return; + } + categoryId = parseInt(category.id); + categoryName = category.name || 'Category'; + } else { + // If category is a direct ID + categoryId = parseInt(category); + categoryName = 'Category'; + } + + if (isNaN(categoryId)) { + console.error('Invalid category ID:', category); + return; + } + + // Navigate to the category screen with the category ID as a number + navigation.navigate('Category', { + title: categoryName, + categoryId: categoryId + }); + }; + return ( { return ( - - {item.title} - {item.description} - - - Shop Now + + {item.title} + {item.description} + handleShopNow(item.category || item.category_id)} + className={`justify-center items-start`} + > + + Shop Now - + diff --git a/frontend/app/components/ItemCard.jsx b/frontend/app/components/ItemCard.jsx index 6c469d3..8241655 100644 --- a/frontend/app/components/ItemCard.jsx +++ b/frontend/app/components/ItemCard.jsx @@ -1,30 +1,50 @@ import React from 'react'; import { View, Text, Image, StyleSheet, TouchableOpacity } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; +import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; +import { HeartIcon } from 'react-native-heroicons/outline'; +import { HeartIcon as SolidHeartIcon, StarIcon } from 'react-native-heroicons/solid'; +import { useNavigation } from '@react-navigation/native'; -const ItemCard = ({ item, onPress, onFavoritePress, isFavourite }) => ( - - - +const ItemCard = ({ item, isFavorite, onToggleFavorite }) => { + const navigation = useNavigation(); + + return ( + navigation.navigate('ProductDetails', { id: item.id })} + className="relative" + style={{ width: wp('45%') }} + > + { + e.stopPropagation(); + onToggleFavorite(item); + }} + className="absolute right-4 top-4 bg-white/50 p-2 rounded-full" > - + {isFavorite ? ( + + ) : ( + + )} + + {item.name || item.title} + + + {item.rating || 0} + + + + Rs {item.price || 0} + - {item.title} - ${item.price} - - {item.rating} - - - -); + ); +} const styles = StyleSheet.create({ card: { diff --git a/frontend/app/components/LocationModal.jsx b/frontend/app/components/LocationModal.jsx new file mode 100644 index 0000000..f3d8fb3 --- /dev/null +++ b/frontend/app/components/LocationModal.jsx @@ -0,0 +1,280 @@ +import { View, Text, TouchableOpacity, Alert, Modal, TextInput, FlatList, ActivityIndicator } from 'react-native' +import React, { useState, useEffect, useRef } from 'react' +import { ChevronLeftIcon, MagnifyingGlassIcon, XMarkIcon } from 'react-native-heroicons/outline' +import Icon from 'react-native-vector-icons/Ionicons' +import * as Location from 'expo-location'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export default function LocationModal({ visible, onClose, onLocationSelected }) { + const [isLoading, setIsLoading] = useState(false); + const [currentLocation, setCurrentLocation] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const searchTimeoutRef = useRef(null); + const isMounted = useRef(true); + + // Cleanup function to prevent memory leaks and state updates after unmount + useEffect(() => { + return () => { + isMounted.current = false; + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, []); + + // Reset state when modal becomes visible + useEffect(() => { + if (visible) { + isMounted.current = true; + setSearchQuery(''); + setSearchResults([]); + setIsSearching(false); + } + }, [visible]); + + useEffect(() => { + if (searchQuery.length > 2) { + // Clear any existing timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // Debounce the search to prevent too many API calls + searchTimeoutRef.current = setTimeout(() => { + if (isMounted.current) { + searchLocations(); + } + }, 300); + } else { + setSearchResults([]); + } + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [searchQuery]); + + const searchLocations = async () => { + if (!isMounted.current) return; + + setIsSearching(true); + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent( + searchQuery + )}&limit=10`, + { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'ShopingApp/1.0' // Replace with your app name + } + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + if (!isMounted.current) return; + + if (data && Array.isArray(data)) { + setSearchResults(data); + } else { + setSearchResults([]); + } + } catch (error) { + if (isMounted.current) { + console.error('Search error:', error); + if (error.message.includes('429')) { + Alert.alert('Error', 'Too many requests. Please wait a moment and try again.'); + } else { + Alert.alert('Error', 'Failed to search locations. Please try again.'); + } + } + } finally { + if (isMounted.current) { + setIsSearching(false); + } + } + }; + + const handleUseCurrentLocation = async () => { + if (!isMounted.current) return; + + setIsLoading(true); + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + if (isMounted.current) { + Alert.alert('Permission Denied', 'Please enable location services to use this feature.'); + } + return; + } + + const location = await Location.getCurrentPositionAsync({}); + const { latitude, longitude } = location.coords; + + // Get address from coordinates using reverse geocoding + const [address] = await Location.reverseGeocodeAsync({ + latitude, + longitude + }); + + if (!isMounted.current) return; + + if (address) { + const locationData = { + name: 'Current Location', + address: `${address.street || ''} ${address.name || ''}, ${address.city || ''}, ${address.region || ''} ${address.postalCode || ''}`.trim(), + coordinates: { latitude, longitude } + }; + + await saveLocation(locationData); + onLocationSelected(locationData); + handleClose(); + } else { + Alert.alert('Error', 'Could not determine your address. Please try again.'); + } + } catch (error) { + if (isMounted.current) { + Alert.alert('Error', 'Failed to get your location. Please try again.'); + } + } finally { + if (isMounted.current) { + setIsLoading(false); + } + } + }; + + const saveLocation = async (location) => { + try { + await AsyncStorage.setItem('userLocation', JSON.stringify(location)); + if (isMounted.current) { + setCurrentLocation(location); + } + } catch (error) { + if (isMounted.current) { + Alert.alert('Error', 'Failed to save location. Please try again.'); + } + } + }; + + const handleSelectLocation = (place) => { + const locationData = { + name: place.display_name.split(',')[0], + address: place.display_name, + coordinates: { + latitude: parseFloat(place.lat), + longitude: parseFloat(place.lon) + } + }; + + saveLocation(locationData); + onLocationSelected(locationData); + handleClose(); + }; + + const handleClose = () => { + // Reset state before closing to improve performance + setSearchQuery(''); + setSearchResults([]); + setIsSearching(false); + onClose(); + }; + + const renderSearchResult = ({ item }) => ( + handleSelectLocation(item)} + > + {item.display_name.split(',')[0]} + {item.display_name} + + ); + + return ( + + + + + + + + Select Location + + + + + {/* Search Bar */} + + + + {searchQuery.length > 0 && ( + setSearchQuery('')}> + + + )} + + + {/* Current Location Button */} + + + + {isLoading ? "Getting Location..." : "Use My Current Location"} + + + + {/* Search Results */} + {isSearching ? ( + + + Searching locations... + + ) : searchResults.length > 0 ? ( + item.place_id} + className="flex-1" + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={5} + /> + ) : searchQuery.length > 2 ? ( + + No locations found + + ) : null} + + {currentLocation && !searchQuery && ( + + Current Location: + {currentLocation.address} + + )} + + + + + ); +} \ No newline at end of file diff --git a/frontend/app/components/ProductQuantityInput.jsx b/frontend/app/components/ProductQuantityInput.jsx index 85f96c9..0b44691 100644 --- a/frontend/app/components/ProductQuantityInput.jsx +++ b/frontend/app/components/ProductQuantityInput.jsx @@ -1,5 +1,6 @@ import { View, Text, TouchableOpacity } from 'react-native' -import React from 'react' +import React, { useState } from 'react' +import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import { MinusIcon, PlusIcon } from 'react-native-heroicons/outline' /** @@ -13,34 +14,34 @@ import { MinusIcon, PlusIcon } from 'react-native-heroicons/outline' * @param {Object} props.containerStyle Additional styles for the container * @returns {React.Component} Quantity selector component */ -export default function ProductQuantityInput({ - quantity = 1, - onIncrease, - onDecrease, - label = "Quantity", - containerStyle = {} -}) { - return ( - - {label && {label}} - - - - - - {quantity} - - - - - - - ) -} \ No newline at end of file +const ProductQuantityInput = ({ initialQuantity = 1, onQuantityChange }) => { + const [quantity, setQuantity] = useState(initialQuantity); + + const handleDecrease = () => { + if (quantity > 1) { + const newQuantity = quantity - 1; + setQuantity(newQuantity); + onQuantityChange(newQuantity); + } + }; + + const handleIncrease = () => { + const newQuantity = quantity + 1; + setQuantity(newQuantity); + onQuantityChange(newQuantity); + }; + + return ( + + + + + {quantity} + + + + + ); +} + +export default ProductQuantityInput; \ No newline at end of file diff --git a/frontend/app/config.js b/frontend/app/config.js new file mode 100644 index 0000000..337216e --- /dev/null +++ b/frontend/app/config.js @@ -0,0 +1,31 @@ +// Environment configuration +const config = { + development: { + apiBaseUrl: 'http://192.168.18.4:8000', + // Add other development-specific settings here + }, + production: { + apiBaseUrl: 'https://your-production-api.com', + // Add other production-specific settings here + }, + // Add more environments as needed +}; + +// Get current environment +const getEnv = () => { + if (__DEV__) return 'development'; + return 'production'; +}; + +// Get current configuration +export const getConfig = () => { + return config[getEnv()]; +}; + +// Get API base URL +export const getApiBaseUrl = () => { + return getConfig().apiBaseUrl; +}; + +// Export all config +export default config; \ No newline at end of file diff --git a/frontend/app/constants/couponData.jsx b/frontend/app/constants/couponData.jsx deleted file mode 100644 index bbb1f2d..0000000 --- a/frontend/app/constants/couponData.jsx +++ /dev/null @@ -1,46 +0,0 @@ -const couponData=[ - { - id : 1, - couponCode: "WELLCOME200", - price: 2, - discount:"get 50% off" - - }, - { - id : 2, - couponCode: "CASHBAC12", - price: 2, - discount:"up to $12.00 cashback" - - }, - { - id : 3, - couponCode: "FEST2COST", - price: 28, - discount:"get 50% off for combo" - - }, - { - id : 4, - couponCode: "WELLCOME200", - price: 2, - discount:"get 50% off" - - }, - { - id : 5, - couponCode: "WELLCOME200", - price: 2, - discount:"get 50% off" - - }, - { - id : 6, - couponCode: "WELLCOME200", - price: 2, - discount:"get 50% off" - - }, - -] -export default couponData \ No newline at end of file diff --git a/frontend/app/constants/flashSalesData.jsx b/frontend/app/constants/flashSalesData.jsx deleted file mode 100644 index 094446d..0000000 --- a/frontend/app/constants/flashSalesData.jsx +++ /dev/null @@ -1,32 +0,0 @@ -const flashSalesData = [ - { - id: 1, - name: "Brown Jacket", - price: 300, - rating: 4.0, - image: "https://devotedstore.com/cdn/shop/files/1000074562.jpg?v=1725134987" - }, - { - id: 2, - name: "Brown Suite", - price: 400, - rating: 4.5, - image: "https://www.belmeademenswear.com/wp-content/uploads/2023/02/N292C-Ike-archer-brown_web.jpeg" - }, - { - id: 3, - name: "Brown Jacket", - price: 300, - rating: 4.5, - image: "https://devotedstore.com/cdn/shop/files/1000074562.jpg?v=1725134987" - }, - { - id: 4, - name: "Brown Suite", - price: 400, - rating: 4.5, - image: "https://www.belmeademenswear.com/wp-content/uploads/2023/02/N292C-Ike-archer-brown_web.jpeg" - }, -] - -export default flashSalesData \ No newline at end of file diff --git a/frontend/app/constants/myCartData.jsx b/frontend/app/constants/myCartData.jsx deleted file mode 100644 index 51eb39c..0000000 --- a/frontend/app/constants/myCartData.jsx +++ /dev/null @@ -1,68 +0,0 @@ -const myCartData = [ - { - id: 1, - name: "Brown Jacket", - price: 300, - rating: 4.5, - size: "S", - image: "https://devotedstore.com/cdn/shop/files/1000074562.jpg?v=1725134987" - }, - { - id: 2, - name: "Brown Suite", - price: 400, - rating: 4.5, - size: "S", - image: "https://www.belmeademenswear.com/wp-content/uploads/2023/02/N292C-Ike-archer-brown_web.jpeg" - }, - { - id: 3, - name: "Brown Jacket", - price: 300, - rating: 4.5, - size: "S", - image: "https://devotedstore.com/cdn/shop/files/1000074562.jpg?v=1725134987" - }, - { - id: 4, - name: "Brown Suite", - price: 400, - rating: 4.5, - size: "S", - image: "https://www.belmeademenswear.com/wp-content/uploads/2023/02/N292C-Ike-archer-brown_web.jpeg" - }, - { - id: 4, - name: "Brown Suite", - price: 400, - rating: 4.5, - size: "S", - image: "https://www.belmeademenswear.com/wp-content/uploads/2023/02/N292C-Ike-archer-brown_web.jpeg" - }, - { - id: 4, - name: "Brown Suite", - price: 400, - rating: 4.5, - size: "S", - image: "https://www.belmeademenswear.com/wp-content/uploads/2023/02/N292C-Ike-archer-brown_web.jpeg" - }, - { - id: 4, - name: "Brown Suite", - price: 400, - rating: 4.5, - size: "S", - image: "https://www.belmeademenswear.com/wp-content/uploads/2023/02/N292C-Ike-archer-brown_web.jpeg" - }, - { - id: 4, - name: "Brown Suite", - price: 400, - rating: 4.5, - size: "S", - image: "https://www.belmeademenswear.com/wp-content/uploads/2023/02/N292C-Ike-archer-brown_web.jpeg" - }, -] - -export default myCartData \ No newline at end of file diff --git a/frontend/app/constants/paymentData.jsx b/frontend/app/constants/paymentData.jsx deleted file mode 100644 index 8953050..0000000 --- a/frontend/app/constants/paymentData.jsx +++ /dev/null @@ -1,18 +0,0 @@ -const paymentData=[ - { - id: 1, - image: "https://cdn-icons-png.flaticon.com/512/174/174861.png", - name: "Paypal" - }, - { - id: 2, - image: "https://cdn0.iconfinder.com/data/icons/social-glyph/30/apple-480.png", - name: "Apple Pay" - }, - { - id: 3, - image: "https://user-images.githubusercontent.com/194400/70987158-4069c900-20b7-11ea-892e-8a2e1166b6b7.png", - name: "Google Pay" - } -] -export default paymentData \ No newline at end of file diff --git a/frontend/app/constants/recentData.jsx b/frontend/app/constants/recentData.jsx deleted file mode 100644 index e1ff51f..0000000 --- a/frontend/app/constants/recentData.jsx +++ /dev/null @@ -1,79 +0,0 @@ -const recentData = [ - { - id: 1, - name: "Blue jacket", - }, - { - id: 2, - name: "CosmicChic jacket", - }, - { - id: 3, - name: "Black jacket", - }, - { - id: 4, - name: "Shirt", - }, - { - id: 5, - name: "T-shirt", - }, - { - id: 6, - name: "underwear", - }, - { - id: 8, - name: "Kattu", - }, - { - id: 9, - name: "Dress", - }, - { - id: 10, - name: "Dress", - }, - { - id: 11, - name: "Dress", - }, - { - id: 12, - name: "Dress", - }, - { - id: 13, - name: "Dress", - }, - { - id: 14, - name: "Dress", - }, - { - id: 14, - name: "Dress", - }, - { - id: 14, - name: "Dress", - }, - { - id: 14, - name: "Dress", - }, - { - id: 14, - name: "Dress", - }, - { - id: 14, - name: "Dress", - }, - { - id: 14, - name: "Dress", - }, -] -export default recentData \ No newline at end of file diff --git a/frontend/app/constants/storageKeys.js b/frontend/app/constants/storageKeys.js new file mode 100644 index 0000000..2d5d77c --- /dev/null +++ b/frontend/app/constants/storageKeys.js @@ -0,0 +1,12 @@ +export const STORAGE_KEYS = { + AUTH_TOKEN: '@auth_token', + USER_DATA: '@user_data', + LOCATION: '@location', + CART: '@cart', + FAVORITES: '@favorites', + SETTINGS: '@settings', + CSRF_TOKEN: 'csrfToken', + RECENT_SEARCHES: 'recentSearches' +}; + +export default STORAGE_KEYS; \ No newline at end of file diff --git a/frontend/app/constants/usersData.jsx b/frontend/app/constants/usersData.jsx deleted file mode 100644 index d7e89eb..0000000 --- a/frontend/app/constants/usersData.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import Pic from "../../assets/images/pic.jpg" - -const usersData = [ - - { - id: 1, - image: Pic, - name: "A_1 Tharu", - num: 9762457265, - }, - { - id: 2, - image: Pic, - name: "A_1 Tharu", - num: 9762457265, - }, - { - id: 3, - image: Pic, - name: "A_1 Tharu", - num: 9762457265, - }, - { - id: 4, - image: Pic, - name: "A_1 Tharu", - num: 9762457265, - }, - { - id: 5, - image: Pic, - name: "A_1 Tharu", - num: 9762457265, - }, - { - id: 6, - image: Pic, - name: "A_1 Tharu", - num: 9762457265, - }, - { - id: 7, - image: Pic, - name: "A_1 Tharu", - num: 9762457265, - }, - { - id: 8, - image: Pic, - name: "A_1 Tharu", - num: "9762457265", - }, - { - id: 9, - image: Pic, - name: "A_1 Tharu", - num: "9762457265", - } -] -export default usersData \ No newline at end of file diff --git a/frontend/app/constants/wishlistData.jsx b/frontend/app/constants/wishlistData.jsx deleted file mode 100644 index 535d314..0000000 --- a/frontend/app/constants/wishlistData.jsx +++ /dev/null @@ -1,5 +0,0 @@ -const WishlistData = [ - {} -] - -export default WishlistData \ No newline at end of file diff --git a/frontend/app/context/AuthContext.jsx b/frontend/app/context/AuthContext.jsx new file mode 100644 index 0000000..531ccca --- /dev/null +++ b/frontend/app/context/AuthContext.jsx @@ -0,0 +1,116 @@ +import React, { createContext, useState, useContext, useEffect } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { STORAGE_KEYS } from '../constants/storageKeys'; +import api from '../api'; + +const AuthContext = createContext(); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + checkUser(); + }, []); + + const checkUser = async () => { + try { + const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + const userData = await AsyncStorage.getItem(STORAGE_KEYS.USER_DATA); + + if (token && userData) { + setUser(JSON.parse(userData)); + } + } catch (err) { + console.error('Error checking user:', err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + const login = async (email, password) => { + try { + setLoading(true); + setError(null); + + const response = await api.login(email, password); + + if (response.token) { + await AsyncStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, response.token); + await AsyncStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(response.user)); + setUser(response.user); + return true; + } + return false; + } catch (err) { + console.error('Login error:', err); + setError(err.message); + return false; + } finally { + setLoading(false); + } + }; + + const logout = async () => { + try { + setLoading(true); + await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + await AsyncStorage.removeItem(STORAGE_KEYS.USER_DATA); + setUser(null); + } catch (err) { + console.error('Logout error:', err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + const register = async (userData) => { + try { + setLoading(true); + setError(null); + + const response = await api.register(userData); + + if (response.token) { + await AsyncStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, response.token); + await AsyncStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(response.user)); + setUser(response.user); + return true; + } + return false; + } catch (err) { + console.error('Registration error:', err); + setError(err.message); + return false; + } finally { + setLoading(false); + } + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export default AuthContext; \ No newline at end of file diff --git a/frontend/app/context/CartContext.jsx b/frontend/app/context/CartContext.jsx index 0399b81..782fca2 100644 --- a/frontend/app/context/CartContext.jsx +++ b/frontend/app/context/CartContext.jsx @@ -1,23 +1,79 @@ import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { getCart } from '../api'; +import { getCart, validateCoupon } from '../api'; import { setGlobalRefreshCart } from '../api'; const CartContext = createContext(); export const CartProvider = ({ children }) => { const [cartCount, setCartCount] = useState(0); + const [cartTotal, setCartTotal] = useState(0); + const [appliedCoupon, setAppliedCoupon] = useState(null); + const [couponDiscount, setCouponDiscount] = useState(0); + const [discount, setDiscount] = useState(0); const navigation = useNavigation(); const updateCartCount = useCallback(async () => { try { const cart = await getCart(); setCartCount(cart?.items?.length || 0); + + // Calculate cart total + const total = cart?.items?.reduce((sum, item) => { + const price = parseFloat(item.product_price || item.price || 0); + const quantity = parseInt(item.quantity || 1); + return sum + (price * quantity); + }, 0) || 0; + + setCartTotal(total); + + // Calculate discount (6% of total) + const calculatedDiscount = (total * 6) / 100; + setDiscount(calculatedDiscount); + + // If there's an applied coupon, recalculate the discount + if (appliedCoupon) { + applyCoupon(appliedCoupon.code); + } } catch (error) { console.error('Error updating cart count:', error); - // Don't set cart count to 0 on error, keep previous value } - }, []); + }, [appliedCoupon]); + + const applyCoupon = async (code) => { + try { + if (!code) return; + + const result = await validateCoupon(code, cartTotal); + + if (result.valid) { + setAppliedCoupon(result.coupon); + setCouponDiscount(result.discount); + return { success: true, discount: result.discount }; + } else { + setAppliedCoupon(null); + setCouponDiscount(0); + return { success: false, message: result.message }; + } + } catch (error) { + console.error('Error applying coupon:', error); + setAppliedCoupon(null); + setCouponDiscount(0); + + // Extract the error message from the response if available + const errorMessage = error.response?.data?.message || 'Failed to apply coupon'; + return { success: false, message: errorMessage }; + } + }; + + const removeCoupon = useCallback(() => { + // Clear all coupon-related state + setAppliedCoupon(null); + setCouponDiscount(0); + + // Force a cart refresh to update totals + updateCartCount(); + }, [updateCartCount]); useEffect(() => { // Set the global refresh function @@ -38,7 +94,16 @@ export const CartProvider = ({ children }) => { }, [navigation, updateCartCount]); return ( - + {children} ); @@ -50,4 +115,6 @@ export const useCart = () => { throw new Error('useCart must be used within a CartProvider'); } return context; -}; \ No newline at end of file +}; + +export default CartContext; \ No newline at end of file diff --git a/frontend/app/context/NotificationContext.jsx b/frontend/app/context/NotificationContext.jsx new file mode 100644 index 0000000..0a6cc46 --- /dev/null +++ b/frontend/app/context/NotificationContext.jsx @@ -0,0 +1,106 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { Alert } from 'react-native'; +import { + getNotifications, + markNotificationAsRead as markNotificationAsReadApi, + deleteNotification as deleteNotificationApi, + clearAllNotifications as clearAllNotificationsApi, +} from '../api'; +import axios from 'axios'; +import { API_URL } from '../config'; + +const NotificationContext = createContext(); + +export const NotificationProvider = ({ children }) => { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchNotifications = async () => { + try { + setLoading(true); + setError(null); + const data = await getNotifications(); + setNotifications(Array.isArray(data) ? data : []); + } catch (err) { + setError('Failed to fetch notifications'); + console.error('Error fetching notifications:', err); + setNotifications([]); + } finally { + setLoading(false); + } + }; + + const markAsRead = async (notificationId) => { + try { + await markNotificationAsReadApi(notificationId); + setNotifications((prev) => + prev.map((notification) => + notification.id === notificationId + ? { ...notification, read: true } + : notification + ) + ); + } catch (err) { + console.error('Error marking notification as read:', err); + Alert.alert('Error', 'Failed to mark notification as read'); + } + }; + + const clearNotification = async (notificationId) => { + try { + await deleteNotificationApi(notificationId); + setNotifications((prev) => + prev.filter((notification) => notification.id !== notificationId) + ); + } catch (err) { + console.error('Error deleting notification:', err); + Alert.alert('Error', 'Failed to delete notification'); + } + }; + + const clearAllNotifications = async () => { + try { + await clearAllNotificationsApi(); + setNotifications([]); + } catch (err) { + console.error('Error clearing notifications:', err); + Alert.alert('Error', 'Failed to clear notifications'); + } + }; + + const getUnreadCount = () => { + return notifications.filter((notification) => !notification.read).length; + }; + + useEffect(() => { + fetchNotifications(); + }, []); + + return ( + + {children} + + ); +}; + +export const useNotifications = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotifications must be used within a NotificationProvider'); + } + return context; +}; + +export default NotificationContext; \ No newline at end of file diff --git a/frontend/app/index.jsx b/frontend/app/index.jsx index a437604..01b8892 100644 --- a/frontend/app/index.jsx +++ b/frontend/app/index.jsx @@ -24,6 +24,7 @@ import ShippingAddressScreen from './screens/ShippingAddressScreen'; import ChooseShippingScreen from './screens/ChooseShippingScreen'; import ConfirmPayment from './screens/ConfirmPayment'; import AddCardScreen from './screens/AddCardScreen'; +import AddBankScreen from './screens/AddBankScreen'; import LocationScreen from './screens/LocationScreen'; import AccessLocation from './screens/AccessLocation'; import SignInScreen from './screens/SignInScreen'; @@ -123,6 +124,7 @@ export default function Index() { + diff --git a/frontend/app/screens/AddBankScreen.jsx b/frontend/app/screens/AddBankScreen.jsx new file mode 100644 index 0000000..f6ad678 --- /dev/null +++ b/frontend/app/screens/AddBankScreen.jsx @@ -0,0 +1,142 @@ +import { View, Text, TextInput, TouchableOpacity, Alert } from 'react-native' +import React, { useState } from 'react' +import TabBar from '../themes/TabBar' +import { ChevronLeftIcon } from 'react-native-heroicons/outline' +import { useNavigation, useRoute } from '@react-navigation/native' + +export default function AddBankScreen() { + const navigation = useNavigation(); + const route = useRoute(); + const { method } = route.params; + + const [bankDetails, setBankDetails] = useState({ + bankName: '', + accountNumber: '', + accountHolderName: '', + phoneNumber: '', + email: '' + }); + const [isLoading, setIsLoading] = useState(false); + + const handleAddBank = () => { + if (!bankDetails.bankName || !bankDetails.accountNumber || !bankDetails.accountHolderName) { + Alert.alert('Error', 'Please fill in all required fields'); + return; + } + + Alert.alert( + 'Confirm Bank Details', + `You will transfer money to:\n\nBank: ${bankDetails.bankName}\nAccount Number: ${bankDetails.accountNumber}\nAccount Holder: ${bankDetails.accountHolderName}\n\nPlease make sure the details are correct.`, + [ + { + text: 'Cancel', + style: 'cancel' + }, + { + text: 'Proceed', + onPress: () => { + // Here you would typically save the bank details + Alert.alert( + 'Success', + 'Bank details added successfully. Please complete the transfer and upload the receipt.', + [{ text: 'OK' }] + ); + navigation.goBack(); + } + } + ] + ); + }; + + return ( + + } + titleStyle={{ + color: "black", + fontWeight: "bold", + fontSize: 20 + }} + prefixStyle={{ + backgroundColor: "transparent" + }} + suffixStyle={{ + backgroundColor: "white", + borderWidth: 1, + borderColor: "black" + }} + containerStyle="" + suffixAction={() => navigation.goBack()} + /> + + + Enter Bank Details + + + + Bank Name * + setBankDetails({...bankDetails, bankName: text})} + /> + + + + Account Number * + setBankDetails({...bankDetails, accountNumber: text})} + keyboardType="numeric" + /> + + + + Account Holder Name * + setBankDetails({...bankDetails, accountHolderName: text})} + /> + + + + Phone Number + setBankDetails({...bankDetails, phoneNumber: text})} + keyboardType="phone-pad" + /> + + + + Email + setBankDetails({...bankDetails, email: text})} + keyboardType="email-address" + /> + + + + Add Bank Details + + + + + ) +} \ No newline at end of file diff --git a/frontend/app/screens/AddCardScreen.jsx b/frontend/app/screens/AddCardScreen.jsx index 958103b..14417a0 100644 --- a/frontend/app/screens/AddCardScreen.jsx +++ b/frontend/app/screens/AddCardScreen.jsx @@ -1,17 +1,83 @@ -import { View, Text, TextInput, TouchableOpacity } from 'react-native' +import { View, Text, TextInput, TouchableOpacity, Alert } from 'react-native' import React, { useState } from 'react' import TabBar from '../themes/TabBar' -import { CheckIcon, ChevronLeftIcon } from 'react-native-heroicons/outline' -import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; -import Checkbox from 'expo-checkbox'; -import { useNavigation } from '@react-navigation/native'; +import { ChevronLeftIcon } from 'react-native-heroicons/outline' +import { useNavigation, useRoute } from '@react-navigation/native' +import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen' export default function AddCardScreen() { - const navigation = useNavigation() + const navigation = useNavigation(); + const route = useRoute(); + const { method } = route.params; + + const [cardNumber, setCardNumber] = useState(''); + const [expiryDate, setExpiryDate] = useState(''); + const [cvv, setCvv] = useState(''); + const [cardholderName, setCardholderName] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const formatCardNumber = (text) => { + // Remove all non-digit characters + const cleaned = text.replace(/\D/g, ''); + // Add space after every 4 digits + const formatted = cleaned.replace(/(\d{4})/g, '$1 ').trim(); + setCardNumber(formatted); + }; + + const formatExpiryDate = (text) => { + // Remove all non-digit characters + const cleaned = text.replace(/\D/g, ''); + // Add slash after 2 digits + if (cleaned.length >= 2) { + const formatted = cleaned.slice(0, 2) + '/' + cleaned.slice(2, 4); + setExpiryDate(formatted); + } else { + setExpiryDate(cleaned); + } + }; + + const handleAddCard = async () => { + try { + setIsLoading(true); + + // Basic validation + if (!cardNumber || !expiryDate || !cvv || !cardholderName) { + Alert.alert('Error', 'Please fill in all card details'); + return; + } + + if (cardNumber.replace(/\s/g, '').length !== 16) { + Alert.alert('Error', 'Please enter a valid 16-digit card number'); + return; + } + + if (cvv.length < 3) { + Alert.alert('Error', 'Please enter a valid CVV'); + return; + } + + // Here you would typically make an API call to save the card + // For now, we'll just show a success message + Alert.alert( + 'Success', + 'Card added successfully!', + [ + { + text: 'OK', + onPress: () => navigation.goBack() + } + ] + ); + } catch (error) { + console.error('Error adding card:', error); + Alert.alert('Error', 'Failed to add card. Please try again.'); + } finally { + setIsLoading(false); + } + }; - const [isChecked, setCheck] = useState(false) return ( - + navigation.goBack()} /> - - - - Card Holder Name - - - - - - Card Number - - - - - - - + + + Card Details + Enter your card information securely - - - Expiry Date - - - - CVV - - - - - + + + + Cardholder Name + - - - Save Card + + Card Number + + + + Expiry Date + + + + CVV + + + + + + + + {isLoading ? 'Adding Card...' : 'Add Card'} + + + + + + Your card details are encrypted and secure. We never store your full card number. + - - - - Add Card - - - ) } \ No newline at end of file diff --git a/frontend/app/screens/CheckOutScreen.jsx b/frontend/app/screens/CheckOutScreen.jsx index 4c7c9f5..ebb7e3b 100644 --- a/frontend/app/screens/CheckOutScreen.jsx +++ b/frontend/app/screens/CheckOutScreen.jsx @@ -7,21 +7,31 @@ import { MapPinIcon } from 'react-native-heroicons/micro'; import Icon from 'react-native-vector-icons/FontAwesome'; import { useNavigation } from '@react-navigation/native'; import { checkoutCart, getCart } from '../api'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Location from 'expo-location'; +import LocationModal from '../components/LocationModal'; +import { useCart } from '../context/CartContext'; +import ConfirmPayment from './ConfirmPayment'; export default function CheckOutScreen() { const navigation = useNavigation(); const [cart, setCart] = useState({ items: [], total_price: 0 }); - const [loading, setLoading] = useState(true); + const [loading, setIsLoading] = useState(true); const [placingOrder, setPlacingOrder] = useState(false); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); - - // Default shipping address - const shippingAddress = { + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(null); + const [shippingAddress, setShippingAddress] = useState({ type: 'Home', - address: '1901 Thornridge Cir. Shiloh, Hawaii', - zipCode: '06803' - }; + address: 'Loading address...', + zipCode: '' + }); + const [isLocationModalVisible, setIsLocationModalVisible] = useState(false); + const { appliedCoupon, couponDiscount, discount } = useCart(); + const [showPaymentSection, setShowPaymentSection] = useState(false); + + // Calculate final total + const finalTotal = cart.total_price - discount - couponDiscount + (selectedPaymentMethod?.type === 'cod' ? 150 : 0); // Default shipping method const shippingMethod = { @@ -29,6 +39,79 @@ export default function CheckOutScreen() { estimatedArrival: '25 August 2023' }; + // Fetch saved location from AsyncStorage + const fetchSavedLocation = useCallback(async () => { + try { + const savedLocation = await AsyncStorage.getItem('userLocation'); + if (savedLocation) { + const locationData = JSON.parse(savedLocation); + setShippingAddress({ + type: 'Current Location', + address: locationData.address, + zipCode: locationData.coordinates ? `${locationData.coordinates.latitude}, ${locationData.coordinates.longitude}` : '' + }); + } else { + // If no saved location, try to get current location + await getCurrentLocation(); + } + } catch (error) { + console.error('Error fetching saved location:', error); + setShippingAddress({ + type: 'Home', + address: 'Address not available', + zipCode: '' + }); + } + }, []); + + // Get current location + const getCurrentLocation = useCallback(async () => { + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Permission Denied', 'Please enable location services to use this feature.'); + return; + } + + const location = await Location.getCurrentPositionAsync({}); + const { latitude, longitude } = location.coords; + + // Get address from coordinates using reverse geocoding + const [address] = await Location.reverseGeocodeAsync({ + latitude, + longitude + }); + + if (address) { + const formattedAddress = `${address.street || ''} ${address.name || ''}, ${address.city || ''}, ${address.region || ''} ${address.postalCode || ''}`.trim(); + setShippingAddress({ + type: 'Current Location', + address: formattedAddress, + zipCode: address.postalCode || `${latitude}, ${longitude}` + }); + + // Save to AsyncStorage + await AsyncStorage.setItem('userLocation', JSON.stringify({ + name: 'Current Location', + address: formattedAddress, + coordinates: { latitude, longitude } + })); + } + } catch (error) { + console.error('Error getting current location:', error); + Alert.alert('Error', 'Failed to get your location. Please try again.'); + } + }, []); + + // Handle location selection from modal + const handleLocationSelected = (locationData) => { + setShippingAddress({ + type: 'Selected Location', + address: locationData.address, + zipCode: locationData.coordinates ? `${locationData.coordinates.latitude}, ${locationData.coordinates.longitude}` : '' + }); + }; + // Calculate item subtotal const calculateSubtotal = (item) => { // Ensure price and quantity exist and are numbers @@ -45,23 +128,26 @@ export default function CheckOutScreen() { // Process each item to ensure it has the required properties const processedItems = cartData.items.map(item => { - // Calculate subtotal if not provided by the backend - if (!item.subtotal || typeof item.subtotal !== 'number') { - item.subtotal = calculateSubtotal(item); - } + // Get price from the correct field (product_price from API) + const price = typeof item.product_price === 'number' ? item.product_price : + typeof item.price === 'number' ? item.price : + parseFloat(item.product_price || item.price || 0); + + const quantity = parseInt(item.quantity) || 1; + const subtotal = price * quantity; - // Ensure other required properties exist return { ...item, - id: item.id || Date.now(), // Fallback ID if not provided + id: item.id || Date.now(), product_name: item.product_name || item.name || item.title || 'Unknown Product', product_image: item.product_image || item.image || 'https://via.placeholder.com/150', - price: typeof item.price === 'number' ? item.price : 0, - quantity: typeof item.quantity === 'number' ? item.quantity : 1 + price: price, + quantity: quantity, + subtotal: subtotal }; }); - // Calculate total price if not provided or if we want to ensure it's correct + // Calculate total price from processed items const total_price = processedItems.reduce((total, item) => total + item.subtotal, 0); return { @@ -70,9 +156,9 @@ export default function CheckOutScreen() { }; }; - const fetchCartData = async () => { + const fetchCartData = useCallback(async () => { try { - setLoading(true); + setIsLoading(true); const cartData = await getCart(); const processedCart = processCartData(cartData); setCart(processedCart); @@ -81,19 +167,29 @@ export default function CheckOutScreen() { console.error('Error fetching cart:', error); setError('Failed to load cart data'); } finally { - setLoading(false); + setIsLoading(false); setRefreshing(false); } - }; + }, []); const onRefresh = useCallback(() => { setRefreshing(true); fetchCartData(); - }, []); + }, [fetchCartData]); + // Initialize data useEffect(() => { - fetchCartData(); - }, []); + const initializeData = async () => { + try { + await fetchCartData(); + await fetchSavedLocation(); + } catch (error) { + console.error('Error initializing data:', error); + } + }; + + initializeData(); + }, [fetchCartData, fetchSavedLocation]); const handlePlaceOrder = async () => { if (!cart.items || cart.items.length === 0) { @@ -101,28 +197,88 @@ export default function CheckOutScreen() { return; } + if (!shippingAddress.address || shippingAddress.address === 'Loading address...' || shippingAddress.address === 'Address not available') { + Alert.alert('Address Required', 'Please set a valid shipping address before proceeding.'); + return; + } + + if (!selectedPaymentMethod) { + Alert.alert('Payment Method Required', 'Please select a payment method before proceeding.'); + return; + } + try { setPlacingOrder(true); - // Prepare order data + // Calculate final total with coupon discount + const subtotal = cart.total_price; + const deliveryFee = 10; // Fixed delivery fee + const discountPercentage = 6; + const discount = (subtotal * discountPercentage) / 100; + const finalTotal = subtotal + deliveryFee - discount - (couponDiscount || 0); + const orderData = { - shipping_address: `${shippingAddress.address}, ${shippingAddress.zipCode}`, - shipping_method: shippingMethod.type, + items: cart.items.map(item => ({ + id: item.id, + quantity: item.quantity, + price: item.price + })), + shipping_address: { + address: shippingAddress.address, + latitude: shippingAddress.latitude, + longitude: shippingAddress.longitude + }, + payment_method: selectedPaymentMethod, + shipping_method: shippingMethod, + subtotal: subtotal, + delivery_fee: deliveryFee, + discount: discount, + coupon_discount: couponDiscount || 0, + coupon_code: appliedCoupon?.code || null, + total: finalTotal }; - - // Place the order using checkout endpoint + const response = await checkoutCart(orderData); - // Navigate to success screen - navigation.navigate('Payment Success', { orderId: response.id || response.order_id }); + if (response && response.order_id) { + // Clear the cart after successful order + await AsyncStorage.removeItem('cart'); + + // Navigate to Payment Success screen + navigation.navigate('Payment Success'); + } else { + throw new Error('Invalid response from server'); + } } catch (error) { console.error('Error placing order:', error); - Alert.alert('Checkout Failed', 'Failed to place your order. Please try again.'); + Alert.alert( + 'Error', + error.response?.data?.message || error.message || 'Failed to place order. Please try again.' + ); } finally { setPlacingOrder(false); } }; + // Handle payment method selection result + useEffect(() => { + const unsubscribe = navigation.addListener('focus', () => { + // Check if payment method was selected and stored + AsyncStorage.getItem('selectedPaymentMethod').then(method => { + if (method) { + setSelectedPaymentMethod(JSON.parse(method)); + } + }); + }); + + return unsubscribe; + }, [navigation]); + + const handlePaymentMethodSelect = (method) => { + setSelectedPaymentMethod(method); + setShowPaymentSection(false); + }; + if (loading) { return ( @@ -150,7 +306,7 @@ export default function CheckOutScreen() { Your cart is empty navigation.navigate('Home')} + onPress={() => navigation.navigate('Main')} className="bg-primary py-2 px-4 rounded-lg" > Continue Shopping @@ -159,22 +315,16 @@ export default function CheckOutScreen() { ); } - // Calculate costs - const deliveryFee = 10; - const discountPercentage = 6; - const discount = (cart.total_price * discountPercentage) / 100; - const totalCost = cart.total_price + deliveryFee - discount; - return ( - + } + suffix={} titleStyle={{ color: "black", fontWeight: "bold", - fontSize: 20 + fontSize: wp('5%') }} prefixStyle={{ backgroundColor: "transparent" @@ -199,36 +349,121 @@ export default function CheckOutScreen() { } > - Shipping Address + Shipping Address - - {shippingAddress.type} + + {shippingAddress.type} - - {shippingAddress.address} - {navigation.navigate('Shipping Address')}}> - CHANGE + + + + {shippingAddress.address} + + + {shippingAddress.zipCode} + + + setIsLocationModalVisible(true)} + style={{ + paddingHorizontal: wp('2%'), + paddingVertical: hp('0.5%') + }} + > + + CHANGE + - {shippingAddress.zipCode} - Choose Shipping Type + Choose Shipping Type - - {shippingMethod.type} + + {shippingMethod.type} - - Estimated arrival {shippingMethod.estimatedArrival} - {navigation.navigate('Choose Shipping')}}> - CHANGE + + + + Estimated arrival {shippingMethod.estimatedArrival} + + + {navigation.navigate('Choose Shipping')}} + style={{ + paddingHorizontal: wp('2%'), + paddingVertical: hp('0.5%') + }} + > + + CHANGE + + + Payment Method + + + {selectedPaymentMethod ? selectedPaymentMethod.name : 'Select payment method'} + + setShowPaymentSection(true)}> + + {selectedPaymentMethod ? 'CHANGE' : 'SELECT'} + + + + {selectedPaymentMethod?.type === 'cod' && ( + + Additional COD charge: Rs150 + + )} + + - Order List - {cart.items.length} item{cart.items.length !== 1 ? 's' : ''} + Order List + {cart.items.length} item{cart.items.length !== 1 ? 's' : ''} {cart.items.map((item, index) => { @@ -240,9 +475,9 @@ export default function CheckOutScreen() { - {item.product_name} - Quantity: {item.quantity} - $ {itemSubtotal.toFixed(2)} + {item.product_name} + Quantity: {item.quantity} + Rs{itemSubtotal.toFixed(2)} @@ -251,38 +486,106 @@ export default function CheckOutScreen() { - Sub-Total - ${cart.total_price.toFixed(2)} + Sub-Total + Rs{cart.total_price.toFixed(2)} - Delivery fee - ${deliveryFee.toFixed(2)} + Delivery fee + Rs{10.00} + {couponDiscount > 0 && ( + + Coupon Discount + - Rs {couponDiscount.toFixed(2)} + + )} - Discount - {discountPercentage}% (${discount.toFixed(2)}) + Discount + {6}% (Rs{discount.toFixed(2)}) + {selectedPaymentMethod?.type === 'cod' && ( + + COD Fee + Rs{150.00} + + )} - Total Cost - ${totalCost.toFixed(2)} + Total Cost + Rs{finalTotal.toFixed(2)} + {/* Add padding at the bottom to prevent content from being hidden behind the button */} + { + if (!selectedPaymentMethod) { + Alert.alert('Payment Method Required', 'Please select a payment method to continue.'); + return; + } + handlePlaceOrder(); + }} + disabled={placingOrder || !selectedPaymentMethod} + style={{ + backgroundColor: 'white', + paddingVertical: hp('1.5%'), + borderTopLeftRadius: wp('4%'), + borderTopRightRadius: wp('4%'), + borderWidth: 1, + borderColor: '#d1d5db', + position: 'absolute', + bottom: 0, + width: '100%', + shadowColor: "#000", + shadowOffset: { + width: 0, + height: -2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 5, + }} > - + {placingOrder ? ( ) : ( - Continue to Payment + + {selectedPaymentMethod ? 'Place Order' : 'Select Payment Method'} + )} + + setIsLocationModalVisible(false)} + onLocationSelected={handleLocationSelected} + /> + + {/* Payment Modal */} + {showPaymentSection && ( + + + + )} ); } \ No newline at end of file diff --git a/frontend/app/screens/CompleteYourProfileScreen.jsx b/frontend/app/screens/CompleteYourProfileScreen.jsx index e8d2856..79ad805 100644 --- a/frontend/app/screens/CompleteYourProfileScreen.jsx +++ b/frontend/app/screens/CompleteYourProfileScreen.jsx @@ -1,15 +1,151 @@ -import { View, Text, Image } from 'react-native' -import React from 'react' -import { widthPercentageToDP as wp, heightPercentageToDP as hp, heightPercentageToDP } from 'react-native-responsive-screen'; +import { View, Text, Image, TouchableOpacity, ActivityIndicator, Alert, ScrollView } from 'react-native' +import React, { useState, useEffect } from 'react' +import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import TabBar from '../themes/TabBar'; -import { ChevronDownIcon, ChevronLeftIcon, PencilSquareIcon } from 'react-native-heroicons/outline'; -import pic from "../../assets/images/pic.jpg" +import { ChevronLeftIcon, PencilSquareIcon } from 'react-native-heroicons/outline'; import { TextInput } from 'react-native'; import { useNavigation } from '@react-navigation/native'; +import { getUserData, updateUserProfile, updateProfilePicture, getGenderOptions } from '../api'; +import * as ImagePicker from 'expo-image-picker'; +import { Dropdown } from 'react-native-element-dropdown'; + +const GENDER_OPTIONS = [ + { label: 'Male', value: 'Male' }, + { label: 'Female', value: 'Female' }, + { label: 'Other', value: 'Other' } +]; export default function CompleteYourProfileScreen() { const navigation = useNavigation(); - + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [user, setUser] = useState(null); + const [genderOptions, setGenderOptions] = useState(GENDER_OPTIONS); + const [formData, setFormData] = useState({ + username: '', + first_name: '', + last_name: '', + email: '', + phone: '', + gender: '', + address: '' + }); + const [showGenderPicker, setShowGenderPicker] = useState(false); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + setLoading(true); + const [userData, genderOptions] = await Promise.all([ + getUserData(), + getGenderOptions() + ]); + + console.log('User Data:', userData); + console.log('Gender Options:', genderOptions); + console.log('User Gender:', userData?.gender); + + if (userData) { + setUser(userData); + setFormData({ + username: userData.username || '', + first_name: userData.first_name || '', + last_name: userData.last_name || '', + email: userData.email || '', + phone: userData.phone || '', + gender: userData.gender || '', + address: userData.address || '' + }); + } + + setGenderOptions(genderOptions); + } catch (error) { + console.error('Error loading data:', error); + Alert.alert('Error', 'Failed to load profile data'); + setGenderOptions(GENDER_OPTIONS); + } finally { + setLoading(false); + } + }; + + const handleUpdateProfile = async () => { + setSaving(true); + try { + const updatedUser = await updateUserProfile({ + username: formData.username, + first_name: formData.first_name, + last_name: formData.last_name, + email: formData.email, + phone: formData.phone, + gender: formData.gender, + address: formData.address + }); + setUser(updatedUser); + Alert.alert('Success', 'Profile updated successfully'); + } catch (error) { + console.error('Error updating profile:', error); + Alert.alert('Error', 'Failed to update profile'); + } finally { + setSaving(false); + } + }; + + const handleImagePick = async () => { + try { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Permission required', 'Please grant permission to access your photos'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaType.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + }); + + if (!result.canceled) { + setLoading(true); + try { + const updatedUser = await updateProfilePicture(result.assets[0].uri); + if (updatedUser) { + setUser(updatedUser); + Alert.alert('Success', 'Profile picture updated successfully'); + } + } catch (error) { + console.error('Profile picture update error:', error.response?.data || error); + Alert.alert('Error', 'Failed to update profile picture'); + } finally { + setLoading(false); + } + } + } catch (error) { + console.error('Error picking image:', error); + Alert.alert('Error', 'Failed to pick image'); + } + }; + + const handleGenderSelect = (item) => { + console.log('Selected gender:', item); + setFormData(prev => ({ + ...prev, + gender: item.value + })); + setShowGenderPicker(false); + }; + + if (loading) { + return ( + + + + ); + } + return ( navigation.goBack()} /> - - Complete Your Profile - Don't worry, only you can see your personal data. No one else will be able to see it. - - - - - - - + + + Complete Your Profile + + Don't worry, only you can see your personal data. No one else will be able to see it. + + + + + + + + + - - - Name - - - - - - Email - - - - - - Gender - - - - - - + + Username + + setFormData({...formData, username: text})} + /> + + + + First Name + + setFormData({...formData, first_name: text})} + /> + + + + Last Name + + setFormData({...formData, last_name: text})} + /> + + + + Gender + + + + + + Email + + setFormData({...formData, email: text})} + keyboardType="email-address" + autoCapitalize="none" + /> + + + + Phone + + setFormData({...formData, phone: text})} + keyboardType="phone-pad" + /> + + + + Address + + setFormData({...formData, address: text})} + keyboardType='phone-pad' + /> - - - - Complete Profile - + + {saving ? ( + + ) : ( + Complete Profile + )} + + - ) -} \ No newline at end of file + ); +} + +const styles = { + dropdown: { + height: 50, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 25, + paddingHorizontal: 16, + marginTop: 8, + backgroundColor: 'white', + }, + placeholderStyle: { + fontSize: 16, + color: 'gray', + }, + selectedTextStyle: { + fontSize: 16, + color: 'black', + }, + iconStyle: { + width: 20, + height: 20, + }, + inputSearchStyle: { + height: 40, + fontSize: 16, + }, + containerStyle: { + borderRadius: 12, + borderWidth: 1, + borderColor: '#d1d5db', + marginTop: 8, + zIndex: 1000, + elevation: 3, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + backgroundColor: 'white', + }, + itemContainerStyle: { + paddingVertical: 8, + paddingHorizontal: 16, + }, + itemTextStyle: { + fontSize: 16, + color: 'black', + }, +}; \ No newline at end of file diff --git a/frontend/app/screens/ConfirmPayment.jsx b/frontend/app/screens/ConfirmPayment.jsx index 812c6ba..ade2ff3 100644 --- a/frontend/app/screens/ConfirmPayment.jsx +++ b/frontend/app/screens/ConfirmPayment.jsx @@ -1,81 +1,309 @@ -import { View, Text, TextInput, Image } from 'react-native' -import React, { useState } from 'react' -import TabBar from '../themes/TabBar' -import { ChevronLeftIcon, ChevronRightIcon, CreditCardIcon } from 'react-native-heroicons/outline' -import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; -import paymentData from '../constants/paymentData'; -import { TouchableOpacity } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { + View, + Text, + TextInput, + Image, + TouchableOpacity, + Alert, +} from "react-native"; +import React, { useState, useEffect } from "react"; +import TabBar from "../themes/TabBar"; +import { + ChevronLeftIcon, + ChevronRightIcon, + CreditCardIcon, + BanknotesIcon, + CheckCircleIcon, +} from "react-native-heroicons/outline"; +import { + widthPercentageToDP as wp, + heightPercentageToDP as hp, +} from "react-native-responsive-screen"; +import { useNavigation } from "@react-navigation/native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +// Import local assets +const esewaLogo = require("../../assets/images/esewa.jpg"); +const khaltiLogo = require("../../assets/images/khalti.jpg"); +const imePayLogo = require("../../assets/images/imepay.png"); export default function ConfirmPayment() { - const navigation = useNavigation() + const navigation = useNavigation(); + const [paymentDataActive, setpaymentData] = useState(null); + const [linkedMethods, setLinkedMethods] = useState({ + card: false, + paypal: false, + applePay: false, + esewa: false, + khalti: false, + ime: false, + }); - const [paymentDataActive, setpaymentData] = useState(0) + // Load the selected payment method and linked methods from AsyncStorage when the component mounts + useEffect(() => { + const loadData = async () => { + try { + // Load selected payment method + const storedMethod = await AsyncStorage.getItem( + "selectedPaymentMethod", + ); + if (storedMethod) { + const method = JSON.parse(storedMethod); + setpaymentData(method.index); + } + + // Load linked payment methods + const savedMethods = await AsyncStorage.getItem("linkedPaymentMethods"); + if (savedMethods) { + setLinkedMethods(JSON.parse(savedMethods)); + } + } catch (error) { + console.error("Error loading payment data:", error); + } + }; + + loadData(); + }, []); + + const handlePaymentSelect = (index, type) => { + setpaymentData(index); + + // Create payment method object based on selection + const selectedMethod = { + index, + type, + name: getPaymentMethodName(type), + }; + + // Store the selected payment method + AsyncStorage.setItem( + "selectedPaymentMethod", + JSON.stringify(selectedMethod), + ); + }; + + const getPaymentMethodName = (type) => { + switch (type) { + case "cod": + return "Cash on Delivery"; + case "card": + return "Credit/Debit Card"; + case "paypal": + return "PayPal"; + case "applePay": + return "Apple Pay"; + case "esewa": + return "eSewa"; + case "khalti": + return "Khalti"; + case "ime": + return "IME Pay"; + default: + return "Unknown"; + } + }; + + const handleConfirmPayment = async () => { + try { + if (paymentDataActive === null) { + Alert.alert("Error", "Please select a payment method"); + return; + } + + // Get the selected payment method + const selectedMethod = { + index: paymentDataActive, + type: getPaymentMethodType(paymentDataActive), + name: getPaymentMethodName(getPaymentMethodType(paymentDataActive)), + }; + + // Store the selected payment method + await AsyncStorage.setItem( + "selectedPaymentMethod", + JSON.stringify(selectedMethod), + ); + + // Navigate to the Checkout screen + navigation.replace('Checkout'); + } catch (error) { + console.error("Error confirming payment:", error); + Alert.alert("Error", "Failed to confirm payment. Please try again."); + } + }; + + const getPaymentMethodType = (index) => { + switch (index) { + case -1: + return "cod"; + case 0: + return "card"; + case 1: + return "paypal"; + case 2: + return "applePay"; + case 3: + return "esewa"; + case 4: + return "khalti"; + case 5: + return "ime"; + default: + return "unknown"; + } + }; + + const renderPaymentOption = (index, type, icon, name, isLinked = false) => { + const isActive = index === paymentDataActive; + + return ( + handlePaymentSelect(index, type)} + className={`border rounded-2xl mb-4 ${isActive ? "border-primary bg-primary/10" : "border-gray-300"}`} + disabled={!isLinked && type !== "cod"} + > + + + {icon} + + {name} + {!isLinked && type !== "cod" && ( + Not linked + )} + {type === "cod" && ( + + Additional charge: Rs150 + + )} + + + + {isActive && ( + + )} + + + + ); + }; return ( } + suffix={} titleStyle={{ color: "black", fontWeight: "bold", - fontSize: 20 + fontSize: 20, }} prefixStyle={{ - backgroundColor: "transparent" + backgroundColor: "transparent", }} suffixStyle={{ backgroundColor: "white", borderWidth: 1, - borderColor: "black" + borderColor: "black", }} containerStyle="" - suffixAction={() => navigation.goBack()} + suffixAction={() => navigation.replace('Checkout')} /> - - Credit & Debit Card + + + + Select Payment Method + + + {/* Cash on Delivery Option */} + {renderPaymentOption( + -1, + "cod", + , + "Cash on Delivery", + )} + + {/* Credit/Debit Card */} + {renderPaymentOption( + 0, + "card", + , + "Credit/Debit Card", + linkedMethods.card, + )} + + {/* PayPal */} + {renderPaymentOption( + 1, + "paypal", + , + "PayPal", + linkedMethods.paypal, + )} + + {/* Apple Pay */} + {renderPaymentOption( + 2, + "applePay", + , + "Apple Pay", + linkedMethods.applePay, + )} + + {/* eSewa */} + {renderPaymentOption( + 3, + "esewa", + , + "eSewa", + linkedMethods.esewa, + )} + + {/* Khalti */} + {renderPaymentOption( + 4, + "khalti", + , + "Khalti", + linkedMethods.khalti, + )} + + {/* IME Pay */} + {renderPaymentOption( + 5, + "ime", + , + "IME Pay", + linkedMethods.ime, + )} - - - - - - {navigation.navigate('Add Card')}} className={`justify-center items-center`}> - + + + + + Confirm Payment + - - More Payment Options - - - - { - paymentData.map((item, index) => { - let isActive = index == paymentDataActive; - return ( - setpaymentData(index)} className={`border border-gray-300 rounded-2xl mx-4`}> - - - {item.name} - - - - - - - - ) - }) - } - - - {navigation.navigate('Payment Success')}} className={`border rounded-full bg-primary flex-row justify-center items-center `} style={{ height: hp('5%') }}> - Confirm Payment - - - - ) -} \ No newline at end of file + ); +} diff --git a/frontend/app/screens/CouponScreen.jsx b/frontend/app/screens/CouponScreen.jsx index bfba3a1..f8a7a1a 100644 --- a/frontend/app/screens/CouponScreen.jsx +++ b/frontend/app/screens/CouponScreen.jsx @@ -1,12 +1,60 @@ -import { View, Text, ScrollView } from 'react-native' -import React from 'react' +import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native' +import React, { useState, useEffect } from 'react' import { widthPercentageToDP as wp, heightPercentageToDP as hp, heightPercentageToDP } from 'react-native-responsive-screen'; import TabBar from '../themes/TabBar'; import { ChevronLeftIcon } from 'react-native-heroicons/outline'; import Icon from "react-native-vector-icons/MaterialCommunityIcons" -import couponData from '../constants/couponData'; +import { getCoupons, validateCoupon } from '../api'; +import { useNavigation } from '@react-navigation/native'; +import { useCart } from '../context/CartContext'; +import { useAuth } from '../context/AuthContext'; export default function CouponScreen() { + const [coupons, setCoupons] = useState([]); + const [loading, setLoading] = useState(true); + const navigation = useNavigation(); + const { cartTotal } = useCart(); + const { isAuthenticated } = useAuth(); + + useEffect(() => { + fetchCoupons(); + }, []); + + const fetchCoupons = async () => { + try { + setLoading(true); + const data = await getCoupons(); + setCoupons(data); + } catch (error) { + console.error('Error fetching coupons:', error); + Alert.alert('Error', 'Failed to load coupons. Please try again later.'); + } finally { + setLoading(false); + } + }; + + const handleCopyCode = async (coupon) => { + try { + if (!isAuthenticated) { + Alert.alert('Login Required', 'Please login to use coupons'); + navigation.navigate('Login'); + return; + } + + const result = await validateCoupon(coupon.code, cartTotal); + + if (result.valid) { + // Copy the code to clipboard + // You might want to use Clipboard API here + Alert.alert('Success', `Coupon code ${coupon.code} copied to clipboard!`); + } else { + Alert.alert('Invalid Coupon', result.message || 'This coupon cannot be applied to your cart.'); + } + } catch (error) { + console.error('Error validating coupon:', error); + Alert.alert('Error', 'Failed to validate coupon. Please try again later.'); + } + }; return ( @@ -34,21 +82,30 @@ export default function CouponScreen() { Best offers for you - { - couponData.map((item, index) => { + {loading ? ( + Loading coupons... + ) : coupons.length > 0 ? ( + coupons.map((item, index) => { return ( - {item.couponCode} - Add items worth ${item.price} more to unlock + {item.code} + Add items worth ${item.min_purchase} more to unlock - {item.discount} + + {item.discount_type === 'percentage' + ? `${item.discount_value}% OFF` + : `$${item.discount_value} OFF`} + - + handleCopyCode(item)} + > COPY CODE - + {/* this line of code make the semicircle i both side of container */} @@ -60,10 +117,9 @@ export default function CouponScreen() { ) }) - } - - - + ) : ( + No coupons available at the moment. + )} diff --git a/frontend/app/screens/FashionScreen.jsx b/frontend/app/screens/FashionScreen.jsx deleted file mode 100644 index d8d6b07..0000000 --- a/frontend/app/screens/FashionScreen.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { View, Text } from 'react-native' -import React from 'react' -import Icon from 'react-native-vector-icons/FontAwesome' -import { widthPercentageToDP as wp, heightPercentageToDP as hp, heightPercentageToDP } from 'react-native-responsive-screen'; - -export default function FashionScreen() { - return ( - - - - - - fashion. - - - - ) -} \ No newline at end of file diff --git a/frontend/app/screens/FilterScreen.jsx b/frontend/app/screens/FilterScreen.jsx index a70a0b5..e1f9dd2 100644 --- a/frontend/app/screens/FilterScreen.jsx +++ b/frontend/app/screens/FilterScreen.jsx @@ -1,15 +1,17 @@ import { View, Text, TouchableOpacity, ScrollView } from 'react-native' -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { ChevronLeftIcon } from 'react-native-heroicons/outline' import TabBar from '../themes/TabBar' import { StarIcon } from 'react-native-heroicons/solid' import { widthPercentageToDP as wp, heightPercentageToDP as hp, heightPercentageToDP } from 'react-native-responsive-screen'; import MultiSlider from '@ptomasroos/react-native-multi-slider' -import { useNavigation } from '@react-navigation/native' - +import { useNavigation, useRoute } from '@react-navigation/native' +import AsyncStorage from '@react-native-async-storage/async-storage'; export default function FilterScreen() { const navigation = useNavigation() + const route = useRoute() + const { activeFilters } = route.params || {}; const brand = ["all", "adidas", "nike", "puma", "gucci", "parada", "ogiy", "reebook"] const [brandActive, setbrandActive] = useState(0) @@ -28,6 +30,67 @@ export default function FilterScreen() { setRange(values) } + // Initialize filters from activeFilters if available + useEffect(() => { + if (activeFilters) { + // Set brand filter + if (activeFilters.brand) { + const brandIndex = brand.findIndex(b => b.toLowerCase() === activeFilters.brand.toLowerCase()); + if (brandIndex !== -1) setbrandActive(brandIndex); + } + + // Set gender filter + if (activeFilters.gender) { + const genderIndex = gender.findIndex(g => g.toLowerCase() === activeFilters.gender.toLowerCase()); + if (genderIndex !== -1) setgenderActive(genderIndex); + } + + // Set sort by filter + if (activeFilters.sortBy) { + const sortIndex = sortBy.findIndex(s => s === activeFilters.sortBy); + if (sortIndex !== -1) setsortByActive(sortIndex); + } + + // Set review filter + if (activeFilters.subReview) { + const reviewIndex = subReview.findIndex(r => r === activeFilters.subReview); + if (reviewIndex !== -1) setsubReview(reviewIndex); + } + + // Set price range + if (activeFilters.range) { + setRange(activeFilters.range); + } + } + }, [activeFilters]); + + const handleReset = () => { + setbrandActive(0); + setgenderActive(0); + setsortByActive(0); + setsubReview(0); + setRange([10, 2000]); + }; + + const handleApply = async () => { + // Save filter settings to AsyncStorage + const filterSettings = { + brand: brand[brandActive], + gender: gender[genderActive], + sortBy: sortBy[sortByActive], + subReview: subReview[subReviewActive] + }; + + try { + await AsyncStorage.setItem('filterSettings', JSON.stringify(filterSettings)); + // Navigate back to HomeScreen + navigation.goBack(); + } catch (error) { + console.error('Error saving filter settings:', error); + navigation.goBack(); + } + }; + return ( - - Pricing Range : Rs {range[0]} to Rs {range[1]} - - - {/* Indicator below tthe slider*/} - - { - Array.from({ length: 6 }, (_, index) => { - // Calculate the value dynamically within the range - const min = 10; - const max = range[2000]; - const step = (max - min) / 5; // 5 steps for 6 values - const value = Math.round(min + index * step); - - return ( - - Rs {value} - - ) - }) - } - - - Reveiws @@ -223,12 +237,18 @@ export default function FilterScreen() { - + Reset Filter - - + + Apply - + ) diff --git a/frontend/app/screens/FlashSalesScreen.jsx b/frontend/app/screens/FlashSalesScreen.jsx index bbbe91a..9234367 100644 --- a/frontend/app/screens/FlashSalesScreen.jsx +++ b/frontend/app/screens/FlashSalesScreen.jsx @@ -1,123 +1,183 @@ -import { View, Text, ScrollView, TouchableOpacity, Image, RefreshControl } from 'react-native' -import React, { useState, useEffect, useCallback } from 'react' -import { HeartIcon } from 'react-native-heroicons/outline' -import { HeartIcon as SolidHeartIcon } from 'react-native-heroicons/solid' -import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen' -import { useNavigation, useFocusEffect } from '@react-navigation/native' -import flashSalesData from '../constants/flashSalesData' -import { StarIcon } from 'react-native-heroicons/solid' -import { getFavorites, toggleFavorite } from '../api' - -export default function FlashSalesScreen() { - const navigation = useNavigation(); - const [favorites, setFavorites] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - - const loadFavorites = useCallback(async () => { - try { - const favoriteProducts = await getFavorites(); - setFavorites(favoriteProducts); - } catch (error) { - console.error('Error loading favorites:', error); - } finally { - setLoading(false); - setRefreshing(false); - } - }, []); - - // Load favorites when component mounts and when screen is focused - useFocusEffect( - useCallback(() => { - loadFavorites(); - }, [loadFavorites]) - ); - - const onRefresh = useCallback(() => { - setRefreshing(true); - loadFavorites(); - }, [loadFavorites]); - - const handleFavouriteToggle = async (product) => { - try { - const updatedFavorites = await toggleFavorite(product); - setFavorites(updatedFavorites); - } catch (error) { - console.error('Error toggling favorite:', error); - } - }; - - const isProductFavorited = (productId) => { - return favorites.some(fav => fav.id === productId); - }; - - const renderItem = ({ item, i }) => { - const isFavorite = isProductFavorited(item.id); - - return ( - - navigation.navigate('ProductDetails', { productId: item.id })} - > - - - { - e.stopPropagation(); - handleFavouriteToggle(item); - }} - className={`absolute top-3 right-3 z-10 bg-white rounded-full p-1`} - > - {isFavorite ? ( - - ) : ( - - )} - - - - {item.name} - - - {item.rating || 0} - - Rs {item.price} - - - - ); - }; - - if (loading && !refreshing) { - return ( - - Loading... - - ); - } - - return ( - - - } - > - - {flashSalesData.map((item, index) => renderItem({ item, i: index }))} - - - - ); -} \ No newline at end of file +import { View, Text, ScrollView, TouchableOpacity, Image, RefreshControl, ActivityIndicator } from 'react-native' +import React, { useState, useEffect, useCallback } from 'react' +import { HeartIcon } from 'react-native-heroicons/outline' +import { HeartIcon as SolidHeartIcon } from 'react-native-heroicons/solid' +import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen' +import { useNavigation, useFocusEffect } from '@react-navigation/native' +import { StarIcon } from 'react-native-heroicons/solid' +import { getFavorites, toggleFavorite, fetchProducts } from '../api' +import TabBar from '../themes/TabBar' + +export default function FlashSalesScreen() { + const navigation = useNavigation(); + const [favorites, setFavorites] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [flashSales, setFlashSales] = useState([]); + const [error, setError] = useState(null); + + const loadFlashSales = useCallback(async () => { + try { + // Fetch products from API with a 'flash_sale' filter if your API supports it + // This is an example - modify this to match your actual API + const products = await fetchProducts('flash_sale'); + setFlashSales(products); + setError(null); + } catch (error) { + console.error('Error loading flash sales:', error); + setError('Failed to load flash sales. Please try again.'); + } + }, []); + + const loadFavorites = useCallback(async () => { + try { + const favoriteProducts = await getFavorites(); + setFavorites(favoriteProducts); + } catch (error) { + console.error('Error loading favorites:', error); + } + }, []); + + const loadData = useCallback(async () => { + setLoading(true); + try { + await Promise.all([loadFlashSales(), loadFavorites()]); + } catch (error) { + console.error('Error loading data:', error); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [loadFlashSales, loadFavorites]); + + // Load data when component mounts and when screen is focused + useFocusEffect( + useCallback(() => { + loadData(); + }, [loadData]) + ); + + const onRefresh = useCallback(() => { + setRefreshing(true); + loadData(); + }, [loadData]); + + const handleFavouriteToggle = async (product) => { + try { + const updatedFavorites = await toggleFavorite(product); + setFavorites(updatedFavorites); + } catch (error) { + console.error('Error toggling favorite:', error); + } + }; + + const isProductFavorited = (productId) => { + return favorites.some(fav => fav.id === productId); + }; + + const renderItem = (item, index) => { + const isFavorite = isProductFavorited(item.id); + + return ( + + navigation.navigate('ProductDetails', { productId: item.id })} + > + + + { + e.stopPropagation(); + handleFavouriteToggle(item); + }} + className={`absolute top-3 right-3 z-10 bg-white rounded-full p-1`} + > + {isFavorite ? ( + + ) : ( + + )} + + + + {item.name || item.title} + + + {item.rating || 0} + + Rs {item.price} + + + + ); + }; + + if (loading && !refreshing) { + return ( + + + Loading... + + ); + } + + return ( + + + + } + > + {error ? ( + + {error} + + Try Again + + + ) : flashSales.length === 0 && !loading ? ( + + No flash sales available at the moment. + + ) : ( + + {flashSales.map((item, index) => renderItem(item, index))} + + )} + + + ); +} diff --git a/frontend/app/screens/HomeScreen.jsx b/frontend/app/screens/HomeScreen.jsx index c5e64c1..70df073 100644 --- a/frontend/app/screens/HomeScreen.jsx +++ b/frontend/app/screens/HomeScreen.jsx @@ -5,30 +5,65 @@ import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-nat import Category from "../components/Category"; import FlashSales from "../components/FlashSales"; import { TouchableOpacity } from "react-native"; -import { useNavigation } from "@react-navigation/native"; +import { useNavigation, useFocusEffect } from "@react-navigation/native"; import HomeCarousel from "../components/HomeCarousel"; -import { useEffect, useState, useCallback } from "react"; -import { fetchCategories, fetchProducts } from "../api"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { fetchCategories, fetchProducts, fetchSliders } from "../api"; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import LocationModal from "../components/LocationModal"; +import { useNotifications } from '../context/NotificationContext'; export default function HomeScreen() { const navigation = useNavigation() const [page, setPage] = useState(0); const [products, setProducts] = useState([]); const [categories, setCategories] = useState([]); + const [sliders, setSliders] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [location, setLocation] = useState(null); + const [isLocationModalVisible, setIsLocationModalVisible] = useState(false); + const flashSalesRef = useRef(null); + const [filteredProducts, setFilteredProducts] = useState([]); + const [activeFilters, setActiveFilters] = useState(null); + const { getUnreadCount } = useNotifications(); + const [unreadCount, setUnreadCount] = useState(0); + + // Update unread count when component mounts and when screen is focused + useEffect(() => { + updateUnreadCount(); + }, []); + + useFocusEffect( + useCallback(() => { + updateUnreadCount(); + }, []) + ); + + const updateUnreadCount = () => { + try { + const count = getUnreadCount(); + setUnreadCount(count); + } catch (error) { + console.error('Error getting unread count:', error); + setUnreadCount(0); + } + }; const loadData = async () => { try { - // Fetch both products and categories - const [productsData, categoriesData] = await Promise.all([ + // Fetch products, categories, and sliders + const [productsData, categoriesData, slidersData] = await Promise.all([ fetchProducts(), - fetchCategories() + fetchCategories(), + fetchSliders() ]); setProducts(productsData); + setFilteredProducts(productsData); setCategories(categoriesData); + setSliders(slidersData); setError(null); } catch (error) { console.error('Error fetching data:', error); @@ -39,19 +74,135 @@ export default function HomeScreen() { } }; + const loadLocation = async () => { + try { + const savedLocation = await AsyncStorage.getItem('userLocation'); + if (savedLocation) { + setLocation(JSON.parse(savedLocation)); + } + } catch (error) { + console.error('Error loading location:', error); + } + }; + useEffect(() => { loadData(); + loadLocation(); }, []); const onRefresh = useCallback(() => { setRefreshing(true); loadData(); + loadLocation(); }, []); + const handleLocationSelected = (newLocation) => { + setLocation(newLocation); + }; + + useFocusEffect( + useCallback(() => { + // Check if we should scroll to Flash Sales + const shouldScrollToFlashSales = navigation.getState().routes.find( + route => route.name === 'Main' && route.params?.scrollToFlashSales + ); + + if (shouldScrollToFlashSales && flashSalesRef.current) { + // Reset the navigation params to prevent scrolling on subsequent focuses + navigation.setParams({ scrollToFlashSales: false }); + + // Scroll to Flash Sales section + flashSalesRef.current.scrollTo({ y: 0, animated: true }); + } + }, [navigation]) + ); + + // Check for filter settings when screen is focused + useFocusEffect( + useCallback(() => { + const checkFilters = async () => { + try { + const filterSettingsStr = await AsyncStorage.getItem('filterSettings'); + if (filterSettingsStr) { + const filterSettings = JSON.parse(filterSettingsStr); + handleFilterResults(filterSettings); + // Clear the filter settings after applying them + await AsyncStorage.removeItem('filterSettings'); + } + } catch (error) { + console.error('Error checking filter settings:', error); + } + }; + + checkFilters(); + }, []) + ); + + // Handle filter results + const handleFilterResults = (filters) => { + setActiveFilters(filters); + + // Apply filters to products + let filtered = [...products]; + + // Filter by brand + if (filters.brand && filters.brand !== 'all') { + filtered = filtered.filter(product => + product.brand?.toLowerCase() === filters.brand.toLowerCase() + ); + } + + // Filter by gender + if (filters.gender && filters.gender !== 'All') { + filtered = filtered.filter(product => + product.gender?.toLowerCase() === filters.gender.toLowerCase() + ); + } + + // Filter by rating + if (filters.subReview) { + const [minRating] = filters.subReview.split(' ')[0].split('.'); + filtered = filtered.filter(product => { + const rating = parseFloat(product.rating || 0); + return rating >= parseFloat(minRating); + }); + } + + // Sort products + if (filters.sortBy) { + switch (filters.sortBy) { + case 'Most Recent': + filtered.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + break; + case 'Populor': + filtered.sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); + break; + case 'Price High': + filtered.sort((a, b) => { + const priceA = parseFloat(a.price || a.product_price || 0); + const priceB = parseFloat(b.price || b.product_price || 0); + return priceB - priceA; + }); + break; + case 'Price Low': + filtered.sort((a, b) => { + const priceA = parseFloat(a.price || a.product_price || 0); + const priceB = parseFloat(b.price || b.product_price || 0); + return priceA - priceB; + }); + break; + default: + break; + } + } + + setFilteredProducts(filtered); + }; + if(loading && !refreshing){ return ( - + ) } @@ -59,7 +210,7 @@ export default function HomeScreen() { if(error){ return ( - {error} + {error} { setLoading(true); @@ -67,108 +218,112 @@ export default function HomeScreen() { }} className="mt-4 bg-primary py-2 px-4 rounded-lg" > - Retry + Retry ) } - const slider = [ - { - title: "new collection", - description: "Discount 50% for the first transaction." - }, - { - title: "kids collection", - description: "Discount 50% for the first transaction." - }, - { - title: "traditional collection", - description: "Discount 50% for the first transaction." - }, - { - title: "traditional collection", - description: "Discount 50% for the first transaction." - }, - { - title: "traditional collection", - description: "Discount 50% for the first transaction." - }, - ] - return ( - - } - > - {/* header start */} - - - Location - + <> + + } + > + {/* header start */} + + setIsLocationModalVisible(true)} + className="flex-col gap-1" + style={{ maxWidth: wp('60%') }} + > + Location + - - - Kohalpur-11,Banke - - + + + + {location ? location.address : "Select Location"} + + + + + { navigation.navigate('Notification') }} + className="bg-gray-300 rounded-full flex justify-center items-center" + style={{ width: wp('14%'), height: wp('14%') }} + > + + {unreadCount > 0 && ( + + )} + - { navigation.navigate('Notification') }} className="bg-gray-300 h-14 w-14 rounded-full flex justify-center items-center"> - - - - - {/* header end */} - {/* search bar start */} - - navigation.navigate('Search')} - className="flex-row items-center flex-1 border border-primary px-3 h-14 rounded-full" - > + {/* header end */} + {/* search bar start */} + + - Search products - + + - { navigation.navigate('Filter') }} className="bg-primary h-14 w-14 rounded-full flex justify-center items-center"> + { navigator.navigate('Filter') }} className="bg-primary h-14 w-14 rounded-full flex justify-center items-center"> - {/* search bar start */} - {/* ads component start */} - - - - {Array.from({ length: slider.length }).map((_, index) => { - const isActive = index == page - return ( - - ) - })} - - - {/* ads component end */} - - {/* Pass categories data to the Category component */} - - - {products.length > 0 ? ( - - ) : ( - - No products available - - )} + {/* search bar end */} - + {/* ads component start */} + + + + {Array.from({ length: sliders.length }).map((_, index) => { + const isActive = index == page + return ( + + ) + })} + + + {/* ads component end */} + + {/* Pass categories data to the Category component */} + + + {/* Pass filtered products to FlashSales */} + {filteredProducts.length > 0 ? ( + + ) : ( + + No products available + + )} + + + setIsLocationModalVisible(false)} + onLocationSelected={handleLocationSelected} + /> + ); } diff --git a/frontend/app/screens/InviteFriendsScreen.jsx b/frontend/app/screens/InviteFriendsScreen.jsx index 5ade13e..5cfbee7 100644 --- a/frontend/app/screens/InviteFriendsScreen.jsx +++ b/frontend/app/screens/InviteFriendsScreen.jsx @@ -1,14 +1,111 @@ -import { View, Text, Image, TouchableOpacity } from 'react-native' -import React from 'react' +import { View, Text, Image, TouchableOpacity, ActivityIndicator, Platform } from 'react-native' +import React, { useState, useEffect } from 'react' import TabBar from '../themes/TabBar' import { ChevronLeftIcon } from 'react-native-heroicons/outline' -import { widthPercentageToDP as wp, heightPercentageToDP as hp, heightPercentageToDP } from 'react-native-responsive-screen'; -import usersData from '../constants/usersData'; -import { ScrollView } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen' +import { ScrollView } from 'react-native' +import { useNavigation } from '@react-navigation/native' +import * as Contacts from 'expo-contacts' +import { Linking } from 'react-native' +import { getInviteMessage } from '../api' export default function InviteFriendsScreen() { const navigation = useNavigation() + const [contacts, setContacts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [inviteMessage, setInviteMessage] = useState('') + const [inviteLink, setInviteLink] = useState('') + + useEffect(() => { + requestContactsPermission() + fetchInviteMessage() + }, []) + + const fetchInviteMessage = async () => { + try { + const response = await getInviteMessage() + setInviteMessage(response.message) + setInviteLink(response.link) + } catch (err) { + console.error('Error fetching invite message:', err) + setInviteMessage("Hey! I've been shopping on this Shopping App and it's been amazing! They have awesome deals, fast delivery, and quality products. Use my link to sign up and get a special discount on your first order!") + setInviteLink("https://play.google.com/store/apps/details?id=com.vendora.app") + } + } + + const requestContactsPermission = async () => { + try { + const { status } = await Contacts.requestPermissionsAsync() + if (status === 'granted') { + fetchContacts() + } else { + setError('Permission to access contacts was denied') + setLoading(false) + } + } catch (err) { + setError('Failed to request contacts permission') + setLoading(false) + } + } + + const fetchContacts = async () => { + try { + const { data } = await Contacts.getContactsAsync({ + fields: [Contacts.Fields.PhoneNumbers, Contacts.Fields.Name], + }) + + // Filter out contacts without phone numbers and sort by name + const filteredContacts = data + .filter(contact => contact.phoneNumbers && contact.phoneNumbers.length > 0) + .sort((a, b) => a.name.localeCompare(b.name)) + + setContacts(filteredContacts) + setLoading(false) + } catch (err) { + console.error('Error fetching contacts:', err) + setError('Failed to fetch contacts') + setLoading(false) + } + } + + const handleInvite = async (phoneNumber) => { + try { + const messageWithLink = `${inviteMessage}\n\n${inviteLink}` + const url = `https://wa.me/${phoneNumber.replace(/\D/g, '')}?text=${encodeURIComponent(messageWithLink)}` + + const supported = await Linking.canOpenURL(url) + if (supported) { + await Linking.openURL(url) + } else { + console.error('WhatsApp is not installed') + } + } catch (error) { + console.error('Error sending invite:', error) + } + } + + if (loading) { + return ( + + + + ) + } + + if (error) { + return ( + + {error} + + Try Again + + + ) + } return ( @@ -32,23 +129,28 @@ export default function InviteFriendsScreen() { containerStyle="" suffixAction={() => navigation.goBack()} /> - - { - usersData.map((item, index) => { - return ( - - - - {item.name} - {item.num} - - - Invite - - - ) - }) - } + + {contacts.map((contact, index) => ( + + + + {contact.name.charAt(0).toUpperCase()} + + + + {contact.name} + + {contact.phoneNumbers[0].number} + + + handleInvite(contact.phoneNumbers[0].number)} + > + Invite + + + ))} ) diff --git a/frontend/app/screens/JacketScreen.jsx b/frontend/app/screens/JacketScreen.jsx index d057193..017616f 100644 --- a/frontend/app/screens/JacketScreen.jsx +++ b/frontend/app/screens/JacketScreen.jsx @@ -2,6 +2,7 @@ import { View, Text, Image, ActivityIndicator } from 'react-native' import React, { useState, useEffect } from 'react' import TabBar from '../themes/TabBar' import { ChevronLeftIcon, HeartIcon } from 'react-native-heroicons/outline' +import { HeartIcon as SolidHeartIcon } from 'react-native-heroicons/solid' import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import MasonryList from '@react-native-seoul/masonry-list'; import { TouchableOpacity } from 'react-native'; @@ -32,12 +33,36 @@ export default function JacketScreen() { const getProducts = async () => { try { setLoading(true); - const data = await fetchProductsByCategory(categoryId); - setProducts(data); setError(null); + + // Validate categoryId + if (!categoryId) { + throw new Error('Category ID is required'); + } + + // Ensure categoryId is a number + const parsedCategoryId = parseInt(categoryId); + if (isNaN(parsedCategoryId)) { + throw new Error('Category ID must be a valid number'); + } + + console.log('Fetching products for category:', parsedCategoryId); + const data = await fetchProductsByCategory(parsedCategoryId); + + // Validate response data + if (!data) { + throw new Error('No data received from server'); + } + + if (!Array.isArray(data)) { + throw new Error('Invalid response format from server'); + } + + setProducts(data); } catch (error) { - console.error('Error fetching products by category:', error); + console.error('Error fetching products:', error); setError(error.message || 'Failed to fetch products'); + setProducts([]); } finally { setLoading(false); } @@ -48,61 +73,40 @@ export default function JacketScreen() { if (loading) { return ( - - + + + Loading products... ); } if (error) { return ( - + {error} { - setLoading(true); - fetchProductsByCategory(categoryId) - .then(data => { - setProducts(data); - setError(null); - }) - .catch(err => setError(err.message || 'Failed to fetch products')) - .finally(() => setLoading(false)); - }} - className="mt-4 bg-primary py-2 px-4 rounded-lg" + onPress={() => navigation.goBack()} + className="mt-4 bg-blue-500 px-4 py-2 rounded-lg" > - Retry + Go Back ); } return ( - - } - titleStyle={{ - color: "black", - fontWeight: "bold", - fontSize: 20 - }} - prefixStyle={{ - backgroundColor: "transparent" - }} - suffixStyle={{ - backgroundColor: "white", - borderWidth: 1, - borderColor: "black" - }} - containerStyle="" - suffixAction={() => navigation.goBack()} - /> - + + + navigation.goBack()}> + + + {title} + + + {products.length === 0 ? ( - - No products found in this category + + No products found in this category ) : ( item.id.toString()} numColumns={2} showsVerticalScrollIndicator={false} - renderItem={({ item, index }) => ( - + renderItem={({ item }) => ( + navigation.navigate('Product', { product: item })} + > + + + + {item.name} + ${item.price} + + + {item.average_rating || 0} + + + toggleFavorite(item.id)} + > + {favorites[item.id] ? ( + + ) : ( + + )} + + + )} - className="mx-4" /> )} - - ) -} - -const CardItem = ({ item, index, toggleFavorite, isFavorite, navigation }) => { - let isEven = index % 2 === 0; - - return ( - - { - navigation.navigate('ProductDetails', { id: item.id }) - }} - className="relative" - > - - toggleFavorite(item.id)} - className="absolute right-4 top-4 bg-white/50 p-2 rounded-full" - > - {isFavorite ? ( - - ) : ( - - )} - - - - - {item.name} - - - {item.average_rating || 0} - - - - Rs {item.price} - + - ) + ); } diff --git a/frontend/app/screens/LocationScreen.jsx b/frontend/app/screens/LocationScreen.jsx index aa6f938..0c36af8 100644 --- a/frontend/app/screens/LocationScreen.jsx +++ b/frontend/app/screens/LocationScreen.jsx @@ -4,18 +4,21 @@ import TabBar from '../themes/TabBar' import { ChevronLeftIcon, MagnifyingGlassIcon, XCircleIcon } from 'react-native-heroicons/outline' import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import Icon from 'react-native-vector-icons/Ionicons' +import { useNavigation } from '@react-navigation/native'; export default function LocationScreen() { + const navigation = useNavigation(); + return ( } + suffix={} titleStyle={{ color: "black", fontWeight: "bold", - fontSize: 20 + fontSize: wp('5%') }} prefixStyle={{ backgroundColor: "transparent" @@ -29,26 +32,30 @@ export default function LocationScreen() { suffixAction={() => navigation.goBack()} /> - - - - + + + + - + - - Use my current location + + Use my current location - S E A R C H R E S U L T + S E A R C H R E S U L T - - - Golden Avenue + + + Golden Avenue - 8502 Preston Rd. Ingl... + 8502 Preston Rd. Ingl... ) } \ No newline at end of file diff --git a/frontend/app/screens/MyCartScreen.jsx b/frontend/app/screens/MyCartScreen.jsx index e7453a0..3af7ff5 100644 --- a/frontend/app/screens/MyCartScreen.jsx +++ b/frontend/app/screens/MyCartScreen.jsx @@ -1,12 +1,13 @@ -import { View, Text, ScrollView, Image, TextInput, TouchableOpacity, Pressable, ActivityIndicator, Alert, RefreshControl } from 'react-native' +import { View, Text, ScrollView, Image, TextInput, TouchableOpacity, Pressable, ActivityIndicator, Alert, RefreshControl, KeyboardAvoidingView, Platform } from 'react-native' import React, { useEffect, useState, useCallback } from 'react' import TabBar from '../themes/TabBar' -import { ChevronLeftIcon, HeartIcon, MinusIcon, PlusIcon } from 'react-native-heroicons/outline' +import { ChevronLeftIcon, HeartIcon, MinusIcon, PlusIcon, XMarkIcon } from 'react-native-heroicons/outline' import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import { useNavigation, useFocusEffect } from '@react-navigation/native' import { deleteCartItem, getCart, updateCartItemQuantity, processImageUrl } from '../api' import EvIcons from "react-native-vector-icons/EvilIcons" import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useCart } from '../context/CartContext'; export default function MyCartScreen() { const navigation = useNavigation(); @@ -16,6 +17,7 @@ export default function MyCartScreen() { const [refreshing, setRefreshing] = useState(false); const [promoCode, setPromoCode] = useState(''); const [isAuthenticated, setIsAuthenticated] = useState(false); + const { cartTotal, appliedCoupon, couponDiscount, applyCoupon, removeCoupon, refreshCart } = useCart(); // Check authentication status const checkAuth = async () => { @@ -63,21 +65,33 @@ export default function MyCartScreen() { } } + // Get the price from the correct field + const price = typeof item.product_price === 'number' ? item.product_price : + typeof item.price === 'number' ? item.price : + parseFloat(item.product_price || item.price || 0); + + // Calculate subtotal based on price and quantity + const quantity = parseInt(item.quantity || 1); + const subtotal = price * quantity; + // Ensure all required properties exist return { id: item.id, product_id: item.product, product_name: item.product_name, product_image: processedImageUrl, - price: parseFloat(item.product_price || 0), - quantity: parseInt(item.quantity || 1), - subtotal: parseFloat(item.subtotal || 0) + price: price, + quantity: quantity, + subtotal: subtotal }; })); + // Calculate total from processed items + const total = processedItems.reduce((sum, item) => sum + item.subtotal, 0); + return { items: processedItems, - total: parseFloat(cartData.total || 0) + total: total }; }; @@ -112,6 +126,13 @@ export default function MyCartScreen() { const handleUpdateQuantity = async (itemId, newQuantity) => { if (newQuantity < 1) return; + // Find the cart item to get the product ID + const cartItem = cartItems.find(item => item.id === itemId); + if (!cartItem) { + Alert.alert('Error', 'Cart item not found'); + return; + } + // Optimistic update setCartItems(prevItems => prevItems.map(item => @@ -120,13 +141,43 @@ export default function MyCartScreen() { ); try { - await updateCartItemQuantity(itemId, newQuantity); + await updateCartItemQuantity(cartItem.product, newQuantity); // Refresh cart data in background fetchCartData(); } catch (error) { console.error('Error updating quantity:', error); // Revert optimistic update on error fetchCartData(); + + // Handle different types of errors + if (error.message === 'Session expired. Please login again.') { + Alert.alert( + 'Session Expired', + 'Your session has expired. Please login again.', + [ + { + text: 'OK', + onPress: () => navigation.navigate('SignIn') + } + ] + ); + } else if (error.message === 'You do not have permission to update this cart item.') { + Alert.alert( + 'Permission Error', + 'You do not have permission to update this cart item. Please try again later.' + ); + } else if (error.message === 'Cart item not found') { + Alert.alert( + 'Error', + 'This item is no longer in your cart. The cart will be refreshed.' + ); + fetchCartData(); // Refresh the cart to get the current state + } else { + Alert.alert( + 'Error', + 'Failed to update quantity. Please try again.' + ); + } } }; @@ -173,18 +224,50 @@ export default function MyCartScreen() { try { setLoading(true); - // This would normally call an API endpoint to apply the promo code - // For now, just show a message - Alert.alert('Success', 'Promo code applied successfully!'); - setPromoCode(''); + const result = await applyCoupon(promoCode); + + if (result.success) { + Alert.alert('Success', `Coupon applied! You saved ${result.discount.toFixed(2)}`); + setPromoCode(''); + } else { + // Display the specific error message from the server + Alert.alert('Invalid Coupon', result.message || 'This coupon cannot be applied to your cart.'); + } } catch (error) { console.error('Error applying promo code:', error); - Alert.alert('Error', error.message || 'Failed to apply promo code'); + // Check if the error has a response with a message + const errorMessage = error.response?.data?.message || error.message || 'Failed to apply promo code'; + Alert.alert('Error', errorMessage); } finally { setLoading(false); } }; + const handleRemoveCoupon = async () => { + try { + setLoading(true); + + // Remove the coupon from context + removeCoupon(); + + // Clear the promo code input + setPromoCode(''); + + // Fetch fresh cart data and wait for it to complete + await fetchCartData(); + + // Force a refresh of the cart context + refreshCart(); + + setLoading(false); + Alert.alert('Success', 'Coupon removed successfully'); + } catch (error) { + console.error('Error removing coupon:', error); + setLoading(false); + Alert.alert('Error', 'Failed to remove coupon. Please try again.'); + } + }; + // Error banner component for non-blocking errors const ErrorBanner = ({ message, onDismiss }) => ( @@ -195,86 +278,92 @@ export default function MyCartScreen() { ); - const CartItem = ({ item, onUpdateQuantity, onDelete }) => { - const [imageError, setImageError] = useState(false); - const [imageLoading, setImageLoading] = useState(true); - - // Use local fallback image if no image URL or error - const imageSource = !item.product_image || imageError - ? require('../../assets/images/favicon.png') - : { uri: item.product_image, cache: 'force-cache' }; + // Move CartItem outside of the main component and memoize it + const CartItem = React.memo(({ item, onUpdateQuantity, onDelete }) => { + // Calculate the price, ensuring it's a number + const price = typeof item.price === 'number' ? item.price : + typeof item.product_price === 'number' ? item.product_price : + parseFloat(item.price || item.product_price || 0); return ( - - + + { - console.error('Error loading image for product:', item.product_image); - console.error('Error details:', e.nativeEvent); - setImageError(true); - }} - onLoadStart={() => { - if (item.product_image) { - console.log('Loading image:', item.product_image); - setImageLoading(true); - } - }} - onLoadEnd={() => { - if (item.product_image) { - console.log('Image loaded successfully:', item.product_image); - setImageLoading(false); - } - }} - onLoad={() => { - if (item.product_image) { - console.log('Image loaded and rendered:', item.product_image); - setImageLoading(false); - } + width: wp('20%'), + height: wp('20%'), + resizeMode: 'cover', + borderRadius: wp('2%') }} /> - {imageLoading && item.product_image && ( - - - - )} - - {item.product_name} - - ${typeof item.price === 'number' ? item.price.toFixed(2) : '0.00'} + + {item.product_name} + + Rs {price.toFixed(2)} - + onUpdateQuantity(item.id, item.quantity - 1)} - className="bg-gray-200 p-2 rounded-full" + style={{ + backgroundColor: '#e5e7eb', + padding: wp('2%'), + borderRadius: 9999 + }} > - + - {item.quantity} + {item.quantity} onUpdateQuantity(item.id, item.quantity + 1)} - className="bg-gray-200 p-2 rounded-full" + style={{ + backgroundColor: '#e5e7eb', + padding: wp('2%'), + borderRadius: 9999 + }} > - + onDelete(item.id)} - className="ml-auto" + style={{ marginLeft: 'auto' }} > - + ); - }; + }, (prevProps, nextProps) => { + // Only re-render if these props change + return ( + prevProps.item.id === nextProps.item.id && + prevProps.item.quantity === nextProps.item.quantity && + prevProps.item.product_image === nextProps.item.product_image + ); + }); if (loading && !refreshing) { return ( @@ -313,14 +402,18 @@ export default function MyCartScreen() { return total + (isNaN(itemSubtotal) ? 0 : itemSubtotal); }, 0); const discount = (subtotal * discountPercentage) / 100; - const totalCost = subtotal + deliveryFee - discount; + const totalCost = subtotal + deliveryFee - discount - couponDiscount; return ( - + } + suffix={} titleStyle={{ color: "black", fontWeight: "bold", @@ -348,12 +441,21 @@ export default function MyCartScreen() { {!cartItems || cartItems.length === 0 ? ( - Your cart is empty - navigation.navigate('Home')} - className="mt-4 bg-primary py-2 px-4 rounded-lg" + Your cart is empty + { + // Navigate to Main (BottomTabBar) and then to Home tab + navigation.goBack(); + }} + style={{ + marginTop: hp('2%'), + backgroundColor: '#704f38', + paddingVertical: hp('1%'), + paddingHorizontal: wp('4%'), + borderRadius: wp('2%') + }} > - Continue Shopping + Continue Shopping ) : ( @@ -368,6 +470,7 @@ export default function MyCartScreen() { tintColor="#704f38" /> } + className="flex-1" > {cartItems.map((item, index) => ( ))} - + + + Promo Code + + + + Apply + + + {appliedCoupon && ( + + + + Coupon "{appliedCoupon.code}" applied! You saved {couponDiscount.toFixed(2)} + + + + Remove Coupon + + + )} + - - - + + Order Summary + + + Subtotal + Rs {subtotal.toFixed(2)} + + + + Delivery Fee + Rs {deliveryFee.toFixed(2)} + + + + Discount + - Rs {discount.toFixed(2)} + + + {couponDiscount > 0 && ( + + Coupon Discount + - Rs {couponDiscount.toFixed(2)} + + )} + + + + + Total + Rs {totalCost.toFixed(2)} + + - Apply + Proceed to Checkout - - Sub-Total - ${subtotal.toFixed(2)} - - - Delivery fee - ${deliveryFee.toFixed(2)} - - - Discount - {discountPercentage}% (${discount.toFixed(2)}) - - - - Total Cost - ${totalCost.toFixed(2)} - - - Proceed To Checkout - - + )} - + ); -} \ No newline at end of file +} diff --git a/frontend/app/screens/NotificationScreen.jsx b/frontend/app/screens/NotificationScreen.jsx index 4810a1f..783c37d 100644 --- a/frontend/app/screens/NotificationScreen.jsx +++ b/frontend/app/screens/NotificationScreen.jsx @@ -1,24 +1,159 @@ -import { View, Text } from 'react-native' -import React from 'react' +import { View, Text, ScrollView, RefreshControl, ActivityIndicator } from 'react-native' +import React, { useState, useEffect } from 'react' import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import TabBar from '../themes/TabBar'; -import { ChevronLeftIcon, FolderMinusIcon, StarIcon } from 'react-native-heroicons/outline'; -import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import { + ChevronLeftIcon, + ShoppingCartIcon, + TruckIcon, + CheckCircleIcon, + XCircleIcon, + TagIcon, + BellIcon +} from 'react-native-heroicons/outline'; import { useNavigation } from '@react-navigation/native'; - +import { useNotifications } from '../context/NotificationContext'; const NotificationScreen = () => { - const navigation = useNavigation() + const navigation = useNavigation(); + const { notifications, loading, error, fetchNotifications } = useNotifications(); + const [refreshing, setRefreshing] = useState(false); + const [groupedNotifications, setGroupedNotifications] = useState({ today: [], yesterday: [] }); + + useEffect(() => { + fetchNotifications(); + }, []); + + useEffect(() => { + groupNotifications(); + }, [notifications]); + + const groupNotifications = () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const todayNotifications = []; + const yesterdayNotifications = []; + + notifications.forEach(notification => { + const notificationDate = new Date(notification.created_at); + notificationDate.setHours(0, 0, 0, 0); + + if (notificationDate.getTime() === today.getTime()) { + todayNotifications.push(notification); + } else if (notificationDate.getTime() === yesterday.getTime()) { + yesterdayNotifications.push(notification); + } + }); + + setGroupedNotifications({ + today: todayNotifications, + yesterday: yesterdayNotifications + }); + }; + + const onRefresh = async () => { + setRefreshing(true); + try { + await fetchNotifications(); + } finally { + setRefreshing(false); + } + }; + + const getNotificationIcon = (type) => { + switch (type) { + case 'order_placed': + return ; + case 'order_shipped': + return ; + case 'order_delivered': + return ; + case 'order_cancelled': + return ; + case 'coupon': + return ; + default: + return ; + } + }; + + const formatTimeAgo = (dateString) => { + const date = new Date(dateString); + const now = new Date(); + const diffInHours = Math.floor((now - date) / (1000 * 60 * 60)); + + if (diffInHours < 1) { + return 'Just now'; + } else if (diffInHours < 24) { + return `${diffInHours}h`; + } else { + const diffInDays = Math.floor(diffInHours / 24); + return `${diffInDays}d`; + } + }; + + const renderNotificationGroup = (notifications, title) => { + if (notifications.length === 0) return null; + + return ( + <> + + {title} + + {notifications.map((notification, index) => ( + + + {getNotificationIcon(notification.type)} + + + + + {notification.type.split('_').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' ')} + + {formatTimeAgo(notification.created_at)} + + {notification.message} + + + ))} + + ); + }; + + if (loading && !refreshing) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + const unreadCount = notifications.filter(n => !n.read).length; return ( - - 2 - NEW - + unreadCount > 0 ? ( + + {unreadCount} + NEW + + ) : null } title={"Notification "} suffix={} @@ -46,89 +181,25 @@ const NotificationScreen = () => { containerStyle="" suffixAction={() => navigation.goBack()} /> - - - T O D A Y - Mark all as read - - - - + + } + > + {notifications.length === 0 ? ( + + No notifications yet - - - Order Sipped - 1h - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Eius, minus veniam. Veritatis nisi sequi tempore facere. - - - - - - - - - Flash Sale Alert - 1h - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Eius, minus veniam. Veritatis nisi sequi tempore facere. - - - - - - - - - Product Review Request - 1h - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Eius, minus veniam. Veritatis nisi sequi tempore facere. - - - - Y E S T E R D A Y - Mark all as read - - - - - - - - Order Sipped - 1d - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Eius, minus veniam. Veritatis nisi sequi tempore facere. - - - - - - - - - New Paypal Added - 1d - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Eius, minus veniam. Veritatis nisi sequi tempore facere. - - - - - - - - - Flash Sale Alert - 1d - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Eius, minus veniam. Veritatis nisi sequi tempore facere. - - - + ) : ( + <> + {renderNotificationGroup(groupedNotifications.today, "T O D A Y")} + {renderNotificationGroup(groupedNotifications.yesterday, "Y E S T E R D A Y")} + + )} + ) } -export default NotificationScreen \ No newline at end of file + +export default NotificationScreen diff --git a/frontend/app/screens/OrderScreen.jsx b/frontend/app/screens/OrderScreen.jsx index 7f78286..67f7446 100644 --- a/frontend/app/screens/OrderScreen.jsx +++ b/frontend/app/screens/OrderScreen.jsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native' +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, RefreshControl } from 'react-native' import React, { useState, useEffect } from 'react' import TabBar from '../themes/TabBar' import { ChevronLeftIcon } from 'react-native-heroicons/outline' @@ -6,7 +6,7 @@ import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-nat import ActiveOrder from '../components/ActiveOrder' import CompletedOrder from '../components/CompletedOrder' import CancelledOrder from '../components/CancelledOrder' -import { useNavigation } from '@react-navigation/native' +import { useNavigation, useFocusEffect } from '@react-navigation/native' import { getOrders } from '../api' export default function OrderScreen() { @@ -14,13 +14,10 @@ export default function OrderScreen() { const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); const orderState = ["active", "completed", "cancelled"]; const [initialValue, setValue] = useState(0); - useEffect(() => { - fetchOrders(); - }, []); - // Process order data to ensure consistent structure const processOrderData = (orderData) => { if (!Array.isArray(orderData)) { @@ -30,8 +27,8 @@ export default function OrderScreen() { return orderData.map(order => ({ ...order, - // Normalize status to lowercase to avoid case-sensitivity issues - status: (order.status || '').toLowerCase(), + // Keep status as is (case-sensitive) + status: order.status || 'Pending', // Ensure items array exists items: Array.isArray(order.items) ? order.items : [], // Ensure order has an ID @@ -54,8 +51,32 @@ export default function OrderScreen() { } }; + // Initial fetch + useEffect(() => { + fetchOrders(); + }, []); + + // Refresh when screen is focused + useFocusEffect( + React.useCallback(() => { + fetchOrders(); + }, []) + ); + + // Handle pull-to-refresh + const onRefresh = async () => { + setRefreshing(true); + try { + await fetchOrders(); + } catch (error) { + console.error('Error refreshing orders:', error); + } finally { + setRefreshing(false); + } + }; + const renderOrderState = () => { - if (loading) { + if (loading && !refreshing) { return ( @@ -79,16 +100,16 @@ export default function OrderScreen() { // Filter orders based on state const filteredOrders = orders.filter(order => { - const status = order.status.toLowerCase(); + const status = order.status; if (initialValue === 0) { - // Active orders are pending, processing - return ['pending', 'processing'].includes(status); + // Active orders are pending, processing, and shipped + return ['Pending', 'Processing', 'Shipped'].includes(status); } else if (initialValue === 1) { // Completed orders - return ['shipped', 'delivered'].includes(status); + return ['Delivered'].includes(status); } else { // Cancelled orders - return status === 'cancelled'; + return status === 'Cancelled'; } }); @@ -154,7 +175,17 @@ export default function OrderScreen() { } - + + } + > {renderOrderState()} diff --git a/frontend/app/screens/PasswordManagerScreen.jsx b/frontend/app/screens/PasswordManagerScreen.jsx index 0279612..7536030 100644 --- a/frontend/app/screens/PasswordManagerScreen.jsx +++ b/frontend/app/screens/PasswordManagerScreen.jsx @@ -1,12 +1,52 @@ -import { View, Text, TextInput, TouchableOpacity } from 'react-native' -import React from 'react' +import { View, Text, TextInput, TouchableOpacity, Alert, ActivityIndicator } from 'react-native' +import React, { useState } from 'react' import TabBar from '../themes/TabBar' -import { ChevronLeftIcon, EyeSlashIcon } from 'react-native-heroicons/outline' +import { ChevronLeftIcon, EyeSlashIcon, EyeIcon } from 'react-native-heroicons/outline' import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import { useNavigation } from '@react-navigation/native'; +import { updatePassword } from '../api'; export default function PasswordManagerScreen() { - const navigation = useNavigation() + const navigation = useNavigation(); + const [loading, setLoading] = useState(false); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [passwords, setPasswords] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '' + }); + + const handlePasswordChange = async () => { + // Validate passwords + if (!passwords.currentPassword || !passwords.newPassword || !passwords.confirmPassword) { + Alert.alert('Error', 'Please fill in all password fields'); + return; + } + + if (passwords.newPassword !== passwords.confirmPassword) { + Alert.alert('Error', 'New passwords do not match'); + return; + } + + if (passwords.newPassword.length < 8) { + Alert.alert('Error', 'New password must be at least 8 characters long'); + return; + } + + try { + setLoading(true); + await updatePassword(passwords.currentPassword, passwords.newPassword); + Alert.alert('Success', 'Password updated successfully'); + navigation.goBack(); + } catch (error) { + console.error('Password update error:', error); + Alert.alert('Error', error.response?.data?.error || 'Failed to update password'); + } finally { + setLoading(false); + } + }; return ( @@ -32,47 +72,86 @@ export default function PasswordManagerScreen() { /> - Current Password + Current Password - - + + setPasswords(prev => ({...prev, currentPassword: text}))} + /> - - - - - - Forgot Passwords ? - - - New Password - - - - - - - - - - - Conform New Password - - - - - - - - - - - - - Change Password + setShowCurrentPassword(!showCurrentPassword)}> + {showCurrentPassword ? ( + + ) : ( + + )} + + + New Password + + + + setPasswords(prev => ({...prev, newPassword: text}))} + /> + + setShowNewPassword(!showNewPassword)}> + {showNewPassword ? ( + + ) : ( + + )} + + + + + Confirm New Password + + + + setPasswords(prev => ({...prev, confirmPassword: text}))} + /> + + setShowConfirmPassword(!showConfirmPassword)}> + {showConfirmPassword ? ( + + ) : ( + + )} + + + + + + + {loading ? ( + + ) : ( + Change Password + )} + + ) } \ No newline at end of file diff --git a/frontend/app/screens/PaymentMethodScreen.jsx b/frontend/app/screens/PaymentMethodScreen.jsx index 01f9368..a9629a8 100644 --- a/frontend/app/screens/PaymentMethodScreen.jsx +++ b/frontend/app/screens/PaymentMethodScreen.jsx @@ -1,17 +1,406 @@ -import { View, Text, Image } from 'react-native' -import React from 'react' +import { View, Text, Image, TouchableOpacity, Alert, ScrollView } from 'react-native' +import React, { useState, useEffect } from 'react' import TabBar from '../themes/TabBar' -import { ChevronLeftIcon, CreditCardIcon } from 'react-native-heroicons/outline' +import { ChevronLeftIcon, CreditCardIcon, CheckCircleIcon, TrashIcon } from 'react-native-heroicons/outline' import { TextInput } from 'react-native' import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import { useNavigation } from '@react-navigation/native' +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Linking } from 'react-native'; +// Import local assets +const esewaLogo = require('../../assets/images/esewa.jpg'); +const khaltiLogo = require('../../assets/images/khalti.jpg'); +const imePayLogo = require('../../assets/images/imepay.png'); export default function PaymentMethodScreen() { - const navigation = useNavigation() + const navigator = useNavigation() + const [cardNumber, setCardNumber] = useState('') + const [cardName, setCardName] = useState('') + const [expiryDate, setExpiryDate] = useState('') + const [cvv, setCvv] = useState('') + const [paypalEmail, setPaypalEmail] = useState('') + const [paypalPassword, setPaypalPassword] = useState('') + const [esewaPhone, setEsewaPhone] = useState('') + const [esewaPin, setEsewaPin] = useState('') + const [khaltiPhone, setKhaltiPhone] = useState('') + const [khaltiPin, setKhaltiPin] = useState('') + const [imePhone, setImePhone] = useState('') + const [imePin, setImePin] = useState('') + const [showCardDetails, setShowCardDetails] = useState(false) + const [showPaypalDetails, setShowPaypalDetails] = useState(false) + const [showApplePayDetails, setShowApplePayDetails] = useState(false) + const [showEsewaDetails, setShowEsewaDetails] = useState(false) + const [showKhaltiDetails, setShowKhaltiDetails] = useState(false) + const [showImeDetails, setShowImeDetails] = useState(false) + const [linkedMethods, setLinkedMethods] = useState({ + card: false, + paypal: false, + applePay: false, + esewa: false, + khalti: false, + ime: false + }) + + // Load saved payment methods on component mount + useEffect(() => { + loadSavedPaymentMethods(); + }, []); + + const loadSavedPaymentMethods = async () => { + try { + const savedMethods = await AsyncStorage.getItem('linkedPaymentMethods'); + if (savedMethods) { + setLinkedMethods(JSON.parse(savedMethods)); + } + } catch (error) { + console.error('Error loading saved payment methods:', error); + } + }; + + const saveLinkedMethods = async (methods) => { + try { + await AsyncStorage.setItem('linkedPaymentMethods', JSON.stringify(methods)); + } catch (error) { + console.error('Error saving payment methods:', error); + } + }; + + const handleUnlink = (method) => { + Alert.alert( + 'Unlink Account', + 'Are you sure you want to unlink this account?', + [ + { + text: 'Cancel', + style: 'cancel' + }, + { + text: 'Unlink', + style: 'destructive', + onPress: () => { + const updatedMethods = { ...linkedMethods, [method]: false }; + setLinkedMethods(updatedMethods); + saveLinkedMethods(updatedMethods); + } + } + ] + ) + } + + const handleCardLink = () => { + if (!cardNumber.trim() || !cardName.trim() || !expiryDate.trim() || !cvv.trim()) { + Alert.alert('Error', 'Please fill in all card details') + return + } + // Here you would typically validate the card details + // For now, we'll just show a success message + Alert.alert('Success', 'Card linked successfully', [ + { + text: 'OK', + onPress: () => { + setShowCardDetails(false) + setCardNumber('') + setCardName('') + setExpiryDate('') + setCvv('') + const updatedMethods = { ...linkedMethods, card: true }; + setLinkedMethods(updatedMethods); + saveLinkedMethods(updatedMethods); + } + } + ]) + } + + const handlePaypalLink = () => { + if (!paypalEmail.trim() || !paypalPassword.trim()) { + Alert.alert('Error', 'Please fill in all PayPal details') + return + } + // Here you would typically validate the PayPal credentials + Alert.alert('Success', 'PayPal account linked successfully', [ + { + text: 'OK', + onPress: () => { + setShowPaypalDetails(false) + setPaypalEmail('') + setPaypalPassword('') + const updatedMethods = { ...linkedMethods, paypal: true }; + setLinkedMethods(updatedMethods); + saveLinkedMethods(updatedMethods); + } + } + ]) + } + + const handleApplePayLink = () => { + // Apple Pay typically uses the device's built-in authentication + Alert.alert('Success', 'Apple Pay account linked successfully', [ + { + text: 'OK', + onPress: () => { + setShowApplePayDetails(false) + const updatedMethods = { ...linkedMethods, applePay: true }; + setLinkedMethods(updatedMethods); + saveLinkedMethods(updatedMethods); + } + } + ]) + } + + const handleEsewaLink = () => { + if (!esewaPhone.trim() || !esewaPin.trim()) { + Alert.alert('Error', 'Please fill in all eSewa details') + return + } + // Here you would typically validate the eSewa credentials + Alert.alert('Success', 'eSewa account linked successfully', [ + { + text: 'OK', + onPress: () => { + setShowEsewaDetails(false) + setEsewaPhone('') + setEsewaPin('') + const updatedMethods = { ...linkedMethods, esewa: true }; + setLinkedMethods(updatedMethods); + saveLinkedMethods(updatedMethods); + } + } + ]) + } + + const handleKhaltiLink = () => { + if (!khaltiPhone.trim() || !khaltiPin.trim()) { + Alert.alert('Error', 'Please fill in all Khalti details') + return + } + // Here you would typically validate the Khalti credentials + Alert.alert('Success', 'Khalti account linked successfully', [ + { + text: 'OK', + onPress: () => { + setShowKhaltiDetails(false) + setKhaltiPhone('') + setKhaltiPin('') + const updatedMethods = { ...linkedMethods, khalti: true }; + setLinkedMethods(updatedMethods); + saveLinkedMethods(updatedMethods); + } + } + ]) + } + + const handleImeLink = () => { + if (!imePhone.trim() || !imePin.trim()) { + Alert.alert('Error', 'Please fill in all IME Pay details') + return + } + // Here you would typically validate the IME Pay credentials + Alert.alert('Success', 'IME Pay account linked successfully', [ + { + text: 'OK', + onPress: () => { + setShowImeDetails(false) + setImePhone('') + setImePin('') + const updatedMethods = { ...linkedMethods, ime: true }; + setLinkedMethods(updatedMethods); + saveLinkedMethods(updatedMethods); + } + } + ]) + } + + const openEsewaApp = async () => { + try { + const esewaUrl = 'esewa://'; + const supported = await Linking.canOpenURL(esewaUrl); + + if (supported) { + await Linking.openURL(esewaUrl); + } else { + Alert.alert( + 'eSewa App Not Found', + 'Please install eSewa app to continue', + [ + { + text: 'Install', + onPress: () => Linking.openURL('https://play.google.com/store/apps/details?id=com.f1soft.esewa&hl=en') + }, + { + text: 'Cancel', + style: 'cancel' + } + ] + ); + } + } catch (error) { + Alert.alert('Error', 'Could not open eSewa app'); + } + }; + + const openKhaltiApp = async () => { + try { + const khaltiUrl = 'khalti://'; + const supported = await Linking.canOpenURL(khaltiUrl); + + if (supported) { + await Linking.openURL(khaltiUrl); + } else { + Alert.alert( + 'Khalti App Not Found', + 'Please install Khalti app to continue', + [ + { + text: 'Install', + onPress: () => Linking.openURL('https://play.google.com/store/apps/details?id=com.khalti&hl=en') + }, + { + text: 'Cancel', + style: 'cancel' + } + ] + ); + } + } catch (error) { + Alert.alert('Error', 'Could not open Khalti app'); + } + }; + + const openImeApp = async () => { + try { + const imeUrl = 'imepay://'; + const supported = await Linking.canOpenURL(imeUrl); + + if (supported) { + await Linking.openURL(imeUrl); + } else { + Alert.alert( + 'IME Pay App Not Found', + 'Please install IME Pay app to continue', + [ + { + text: 'Install', + onPress: () => Linking.openURL('https://play.google.com/store/apps/details?id=com.swifttechnology.imepay&hl=en') + }, + { + text: 'Cancel', + style: 'cancel' + } + ] + ); + } + } catch (error) { + Alert.alert('Error', 'Could not open IME Pay app'); + } + }; + + const renderLinkStatus = (isLinked) => { + if (isLinked) { + return ( + + + Linked + + ) + } + return Link + } + + const renderLinkedCardDetails = () => ( + + + Card Details + handleUnlink('card')}> + + + + Card ending in 1234 + Expires: 12/25 + John Doe + + ) + + const renderLinkedPaypalDetails = () => ( + + + PayPal Account + handleUnlink('paypal')}> + + + + user@example.com + + ) + + const renderLinkedApplePayDetails = () => ( + + + Apple Pay + handleUnlink('applePay')}> + + + + Using device's Apple Pay + + ) + + const renderLinkedEsewaDetails = () => ( + + + eSewa Account + handleUnlink('esewa')}> + + + + Phone: 98XXXXXXXX + + Open eSewa App + + + ) + + const renderLinkedKhaltiDetails = () => ( + + + Khalti Account + handleUnlink('khalti')}> + + + + Phone: 98XXXXXXXX + + Open Khalti App + + + ) + + const renderLinkedImeDetails = () => ( + + + IME Pay Account + handleUnlink('ime')}> + + + + Phone: 98XXXXXXXX + + Open IME Pay App + + + ) return ( - + navigation.goBack()} + suffixAction={() => navigator.goBack()} /> Credit & Debit Card - - + setShowCardDetails(!showCardDetails)} + className={`mx-4 flex-row items-center mt-2 justify-between p-2 border border-gray-300 rounded-2xl`} + > + - + Add New Card - - Link + {renderLinkStatus(linkedMethods.card)} + + + {showCardDetails && !linkedMethods.card && ( + + + + + + + + + Link Card + - + )} + + {showCardDetails && linkedMethods.card && renderLinkedCardDetails()} + More Payment Options - + setShowPaypalDetails(!showPaypalDetails)} + className={`flex-row items-center mt-2 justify-between p-4 border-b border-gray-300 gap-4`} + > Paypal - Link + {renderLinkStatus(linkedMethods.paypal)} + + + {showPaypalDetails && !linkedMethods.paypal && ( + + + + + Link PayPal + - - + )} + + {showPaypalDetails && linkedMethods.paypal && renderLinkedPaypalDetails()} + + setShowApplePayDetails(!showApplePayDetails)} + className={`flex-row items-center justify-between p-4 border-b border-gray-300 gap-4`} + > + Apple Pay - Link + {renderLinkStatus(linkedMethods.applePay)} + + + {showApplePayDetails && !linkedMethods.applePay && ( + + + Apple Pay will use your device's built-in authentication to securely link your account. + + + Link Apple Pay + + + )} + + {showApplePayDetails && linkedMethods.applePay && renderLinkedApplePayDetails()} + + setShowEsewaDetails(!showEsewaDetails)} + className={`flex-row items-center justify-between p-4 gap-4`} + > + + eSewa + {renderLinkStatus(linkedMethods.esewa)} + + + {showEsewaDetails && !linkedMethods.esewa && ( + + + + + Link eSewa + + + Open eSewa App + + + )} + + {showEsewaDetails && linkedMethods.esewa && renderLinkedEsewaDetails()} + + setShowKhaltiDetails(!showKhaltiDetails)} + className={`flex-row items-center justify-between p-4 border-b border-gray-300 gap-4`} + > + + Khalti + {renderLinkStatus(linkedMethods.khalti)} + + + {showKhaltiDetails && !linkedMethods.khalti && ( + + + + + Link Khalti + + + Open Khalti App + + + )} + + {showKhaltiDetails && linkedMethods.khalti && renderLinkedKhaltiDetails()} + + setShowImeDetails(!showImeDetails)} + className={`flex-row items-center justify-between p-4 gap-4`} + > + + IME Pay + {renderLinkStatus(linkedMethods.ime)} + + + {showImeDetails && !linkedMethods.ime && ( + + + + + Link IME Pay + + + Open IME Pay App + + )} + + {showImeDetails && linkedMethods.ime && renderLinkedImeDetails()} - + ) -} \ No newline at end of file +} diff --git a/frontend/app/screens/PaymentSuccess.jsx b/frontend/app/screens/PaymentSuccess.jsx index 03d03be..38620ee 100644 --- a/frontend/app/screens/PaymentSuccess.jsx +++ b/frontend/app/screens/PaymentSuccess.jsx @@ -9,11 +9,20 @@ import { useNavigation } from '@react-navigation/native'; export default function PaymentSuccess() { const navigation = useNavigation() + const handleBack = () => { + // Navigate to Main screen instead of going back + navigation.goBack(); + }; + + const handleViewOrder = () => { + navigation.navigate('Order'); + }; + return ( } titleStyle={{ color: "black", @@ -29,7 +38,7 @@ export default function PaymentSuccess() { borderColor: "black" }} containerStyle="" - suffixAction={() => navigation.goBack()} + suffixAction={handleBack} /> @@ -37,21 +46,20 @@ export default function PaymentSuccess() { - Payment Successful ! - Thank you for your purchase. + Order Successful! + + Thank you for your order. Your order has been placed successfully and will be processed soon. + - - - - - {navigation.navigate('Order')}} className={` rounded-full bg-primary flex-row justify-center items-center `} style={{ height: hp('5%') }}> - View Order + + + View Order - - - View E-Reciept - + ) } \ No newline at end of file diff --git a/frontend/app/screens/PrivacyPolicyScreen.jsx b/frontend/app/screens/PrivacyPolicyScreen.jsx index e7b0f89..7ada0e8 100644 --- a/frontend/app/screens/PrivacyPolicyScreen.jsx +++ b/frontend/app/screens/PrivacyPolicyScreen.jsx @@ -1,12 +1,58 @@ -import { View, Text, ScrollView } from 'react-native' -import React from 'react' +import { View, Text, ScrollView, ActivityIndicator } from 'react-native' +import React, { useEffect, useState } from 'react' import TabBar from '../themes/TabBar' import { ChevronLeftIcon } from 'react-native-heroicons/outline' -import { widthPercentageToDP as wp, heightPercentageToDP as hp, heightPercentageToDP } from 'react-native-responsive-screen'; +import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import { useNavigation } from '@react-navigation/native'; +import { getPrivacyPolicy } from '../api'; export default function PrivacyPolicyScreen() { - const navigation = useNavigation() + const navigation = useNavigation(); + const [policies, setPolicies] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchPrivacyPolicies(); + }, []); + + const fetchPrivacyPolicies = async () => { + try { + setLoading(true); + const data = await getPrivacyPolicy(); + setPolicies(data); + } catch (err) { + setError('Failed to load privacy policies'); + console.error('Error fetching privacy policies:', err); + } finally { + setLoading(false); + } + }; + + const renderContent = (content) => { + // Split content into sections based on numbered points + const sections = content.split(/\d+\./).filter(section => section.trim()); + + return sections.map((section, index) => { + const lines = section.split('\n').filter(line => line.trim()); + return ( + + {lines.map((line, lineIndex) => { + // Check if line is a sub-point (starts with spaces) + const isSubPoint = line.trim().startsWith('-') || line.trim().startsWith('•'); + return ( + + {line.trim()} + + ); + })} + + ); + }); + }; return ( @@ -31,30 +77,25 @@ export default function PrivacyPolicyScreen() { suffixAction={() => navigation.goBack()} /> - - Cancelation Policy - Lorem ipsum dolor sit amet consectetur, adipisicing elit. Ratione, non qui facilis minima iusto maxime. Architecto eos, culpa, - soluta minus quam quis quod accusantium nam quo nisi voluptatibus? Sunt enim error, illum voluptate mollitia, ipsum porro voluptas ullam earum, doloribus obcaecati. - Illum sapiente repellat dolor ex, modi eum aliquam sed corporis delectus architecto tempore itaque dolore exercitationem odio nemo voluptate praesentium aliquid - unde soluta eaque natus illo! Qui, aperiam saepe! - Terms and Condition - Lorem ipsum, dolor sit amet consectetur adipisicing elit. Tempore quibusdam perspiciatis nemo quis? Officia eius temporibus cumque rem aut optio inventore, - fuga incidunt, deserunt, aperiam nihil quo nemo impedit reiciendis eaque facilis fugit. Consequuntur vero amet ratione illum expedita ipsum aperiam nam eveniet aut - at illo earum temporibus, voluptatem facere adipisci iure hic cum ex labore dolore deleniti doloremque quibusdam? Dicta, itaque quisquam blanditiis eum praesentium - ab deleniti cumque. Voluptate quam quis reiciendis dolor nesciunt culpa. Voluptatum temporibus aliquid quis, magni sapiente optio error quasi in quae et illo labore - cumque minima quibusdam? Blanditiis optio alias distinctio similique consequatur inventore?Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae - veniam quam architecto repellat quia ullam distinctio corporis amet, pariatur est? Atque libero nisi, magni esse corporis neque unde! Quo ipsum inventore accusantium - quas beatae animi nobis vel repellat, porro saepe debitis temporibus sunt corrupti reprehenderit doloribus molestiae odit perspiciatis eveniet dolore culpa unde distinctio - pariatur? Mollitia blanditiis minima ex repellat beatae illo non doloribus. Voluptatum qui magnam quibusdam et architecto debitis officiis adipisci provident veritatis iste. - Molestiae hic ea obcaecati nemo neque dolor sequi aliquid quos provident cumque facilis repellendus odit optio facere illo, eum blanditiis quo voluptatem necessitatibus nisi? - Fugit repellat fugiat minima totam animi natus inventore asperiores perferendis numquam cumque, tenetur magnam, sequi eveniet sed laudantium et earum quidem beatae aliquam provident - nobis! Rerum distinctio aperiam soluta nam aliquid sit, maiores vel deleniti repudiandae. Provident dicta possimus dolorem! Debitis nisi, numquam deserunt sunt nesciunt facere aperiam - optio earum assumenda commodi, repudiandae alias rerum quod magni quis, iure odio cupiditate impedit amet doloremque quas! Ipsum neque architecto vero, quidem mollitia ab omnis earum - expedita officiis atque molestias quisquam ipsam aut eligendi harum, nostrum ullam provident, eius corporis reiciendis consequuntur corrupti nemo dolores optio. Delectus omnis voluptatem - quasi expedita ea. + + {loading ? ( + + ) : error ? ( + {error} + ) : policies.length > 0 ? ( + policies.map((policy, index) => ( + + + {policy.title} + + {renderContent(policy.content)} + + )) + ) : ( + No privacy policies available + )} - - ) + ); } \ No newline at end of file diff --git a/frontend/app/screens/ProductDetails.jsx b/frontend/app/screens/ProductDetails.jsx deleted file mode 100644 index 4e8116b..0000000 --- a/frontend/app/screens/ProductDetails.jsx +++ /dev/null @@ -1,466 +0,0 @@ -import { View, Text, TouchableOpacity, Image, ScrollView, ActivityIndicator, TextInput, Alert, RefreshControl } from 'react-native' -import React, { useEffect, useState, useCallback } from 'react' -import { ArrowLeftIcon, HeartIcon, ShoppingBagIcon, StarIcon as StarOutlineIcon } from 'react-native-heroicons/outline' -// import { ImageBackground } from 'react-native' -import pic from "../../assets/images/pic.jpg" -import clothColor from "../constants/clothColor" -import { StarIcon } from 'react-native-heroicons/solid'; -import { useNavigation, useRoute } from '@react-navigation/native'; -import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; -import { addToCart, BASE_URL, fetchProductById, processImageUrl, rateProduct, getCurrentUser } from '../api' - - -export default function ProductDetails() { - const route = useRoute(); - const { id } = route.params; - const navigation = useNavigation(); - const [product, setProduct] = useState(null); - const [productImageUrl, setProductImageUrl] = useState(null); - const [ratings, setRatings] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [quantity, setQuantity] = useState(1); - const [addingToCart, setAddingToCart] = useState(false); - const [userRating, setUserRating] = useState(0); - const [ratingComment, setRatingComment] = useState(''); - const [submittingRating, setSubmittingRating] = useState(false); - const [showRatingForm, setShowRatingForm] = useState(false); - const [refreshing, setRefreshing] = useState(false); - const [editingRating, setEditingRating] = useState(null); - const [editRatingValue, setEditRatingValue] = useState(0); - const [editComment, setEditComment] = useState(''); - const [editing, setEditing] = useState(false); - const [currentUser, setCurrentUser] = useState(null); - - useEffect(() => { - fetchProductData(); - fetchCurrentUser(); - }, [id]); - - const fetchProductData = async () => { - try { - setLoading(true); - const productData = await fetchProductById(id); - setProduct(productData); - - // Process the image URL - if (productData?.image) { - const processedUrl = await processImageUrl(productData.image); - setProductImageUrl(processedUrl); - } - - // Set ratings if available - if (productData.ratings) { - setRatings(productData.ratings); - } - - setError(null); - } catch (error) { - console.error('Error fetching product details:', error); - setError(error.message || 'Failed to load product details'); - } finally { - setLoading(false); - setRefreshing(false); - } - }; - - const fetchCurrentUser = async () => { - try { - const user = await getCurrentUser(); - setCurrentUser(user); - } catch (error) { - console.error('Error fetching current user:', error); - } - }; - - const onRefresh = useCallback(() => { - setRefreshing(true); - fetchProductData(); - }, []); - - const handleAddToCart = async () => { - if (!product) return; - - try { - setAddingToCart(true); - await addToCart(product.id, quantity); - Alert.alert("Success", "Product added to cart successfully"); - navigation.navigate("MyCart"); - } catch (error) { - console.error('Error adding to cart:', error); - setError(error.message || 'Failed to add item to cart'); - Alert.alert("Error", "Failed to add product to cart"); - } finally { - setAddingToCart(false); - } - }; - - const handleSubmitRating = async () => { - if (userRating === 0) { - Alert.alert("Error", "Please select a rating"); - return; - } - - try { - setSubmittingRating(true); - await rateProduct(id, userRating, ratingComment); - Alert.alert("Success", "Your rating has been submitted"); - setUserRating(0); - setRatingComment(''); - setShowRatingForm(false); - - // Refresh product data to show updated rating - const updatedProduct = await fetchProductById(id); - setProduct(updatedProduct); - } catch (error) { - console.error('Error submitting rating:', error); - Alert.alert("Error", "Failed to submit rating. Please try again."); - } finally { - setSubmittingRating(false); - } - }; - - const [isFavourite, setIsFavourite] = useState(false) - const handleFavouriteToggle = () => { - setIsFavourite(!isFavourite) - } - - // size toggle - const subSize = ["S", "M", "L", "XL", "XXL", "XXXL"] - const [activeSet, setActiveSet] = useState("S") - - const handleSubSize = (item) => { - setActiveSet(item) - } - - const [initialColor, setColor] = useState(0) - const toggleColor = (index) => { - setColor(index) - } - - const incrementQuantity = () => setQuantity(prev => prev + 1); - const decrementQuantity = () => setQuantity(prev => Math.max(1, prev - 1)); - - const handleEditRating = async () => { - if (editRatingValue === 0) { - Alert.alert("Error", "Please select a rating"); - return; - } - - try { - setEditing(true); - await rateProduct(id, editRatingValue, editComment); - Alert.alert("Success", "Your rating has been updated"); - setEditingRating(null); - setEditRatingValue(0); - setEditComment(''); - - // Refresh product data to show updated rating - const updatedProduct = await fetchProductById(id); - setProduct(updatedProduct); - if (updatedProduct.ratings) { - setRatings(updatedProduct.ratings); - } - } catch (error) { - console.error('Error updating rating:', error); - Alert.alert("Error", "Failed to update rating. Please try again."); - } finally { - setEditing(false); - } - }; - - const startEditing = (rating) => { - setEditingRating(rating); - setEditRatingValue(rating.rating); - setEditComment(rating.comment || ''); - }; - - const cancelEditing = () => { - setEditingRating(null); - setEditRatingValue(0); - setEditComment(''); - }; - - if (loading) { - return ( - - - - ); - } - - if (error) { - return ( - - {error} - - Retry - - - ); - } - - if (!product) { - return ( - - Product not found - - ); - } - - return ( - - - } - > - - navigation.goBack()} className="bg-gray-400 rounded-full flex-row justify-center items-center size-16"> - - - - {product.name} - - - - - - - - - - - - - {product.category_name} - - - {product.average_rating || 0} - - - {product.name} - Product Details - {product.description} - - {/* Quantity Selector */} - - Quantity: - - - - - {quantity} - - + - - - - {/* Product details */} - - - Color - {product.color || 'N/A'} - - - Size - {product.size || 'N/A'} - - - Stock - {product.stock || 0} items - - - - {/* Ratings Section */} - - - Ratings & Reviews - setShowRatingForm(!showRatingForm)} - className="bg-primary px-3 py-1 rounded-full" - > - Rate this product - - - - {showRatingForm && ( - - Your Rating - - {[1, 2, 3, 4, 5].map(star => ( - setUserRating(star)} - className="mr-2" - > - {userRating >= star ? ( - - ) : ( - - )} - - ))} - - Comment (optional) - - - {submittingRating ? ( - - ) : ( - Submit Rating - )} - - - )} - - {/* Display existing ratings */} - {ratings && ratings.length > 0 ? ( - - {ratings.map((rating, index) => ( - - - - - {[1, 2, 3, 4, 5].map(star => ( - - ))} - - {rating.username} - - {new Date(rating.created_at).toLocaleDateString()} - - - {rating.username === currentUser?.username && ( - startEditing(rating)} - className="bg-primary px-3 py-1 rounded-full" - > - Edit - - )} - - {rating.comment && ( - {rating.comment} - )} - - ))} - - ) : ( - - No ratings yet. Be the first to rate this product! - - )} - - {/* Edit Rating Form */} - {editingRating && ( - - Edit Your Rating - - {[1, 2, 3, 4, 5].map(star => ( - setEditRatingValue(star)} - className="mr-2" - > - {editRatingValue >= star ? ( - - ) : ( - - )} - - ))} - - Comment (optional) - - - - Cancel - - - {editing ? ( - - ) : ( - Update Rating - )} - - - - )} - - - - - - Total Price - ${(product.price * quantity).toFixed(2)} - - - {addingToCart ? ( - - ) : ( - - )} - - {addingToCart ? 'Adding...' : 'Add to Cart'} - - - - - ); -} - diff --git a/frontend/app/screens/ProfileScreen.jsx b/frontend/app/screens/ProfileScreen.jsx index 234a096..8d42116 100644 --- a/frontend/app/screens/ProfileScreen.jsx +++ b/frontend/app/screens/ProfileScreen.jsx @@ -12,7 +12,8 @@ import { } from '@gorhom/bottom-sheet'; import { BackHandler, Alert } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import { getUserData, logoutUser } from '../api'; +import { getUserData, logout, updateProfilePicture } from '../api'; +import * as ImagePicker from 'expo-image-picker'; export default function ProfileScreen() { const navigation = useNavigation(); @@ -39,10 +40,10 @@ export default function ProfileScreen() { fetchUser(); }, []); - const logout = async () => { + const handleLogout = async () => { try { setLoading(true); - await logoutUser(); + await logout(); navigation.navigate("SignIn"); } catch (error) { Alert.alert('Logout Failed', 'Could not log out. Please try again.'); @@ -74,6 +75,60 @@ export default function ProfileScreen() { return () => backHandler.remove(); // Cleanup }, []); + const handleProfilePictureUpdate = async () => { + try { + // Request permission to access the camera roll + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + Alert.alert( + 'Permission Required', + 'Please grant permission to access your photos to update your profile picture.', + [{ text: 'OK' }] + ); + return; + } + + // Launch image picker + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + base64: false, + }); + + if (!result.canceled && result.assets && result.assets[0]) { + setLoading(true); + try { + // Update profile picture + const updatedUser = await updateProfilePicture(result.assets[0].uri); + setUserData(updatedUser); + Alert.alert( + 'Success', + 'Profile picture updated successfully', + [{ text: 'OK' }] + ); + } catch (error) { + console.error('Error updating profile picture:', error); + Alert.alert( + 'Error', + 'Failed to update profile picture. Please try again.', + [{ text: 'OK' }] + ); + } finally { + setLoading(false); + } + } + } catch (error) { + console.error('Error in image picker:', error); + Alert.alert( + 'Error', + 'Failed to access image picker. Please try again.', + [{ text: 'OK' }] + ); + } + }; + if (loading) { return ( @@ -111,7 +166,7 @@ export default function ProfileScreen() { 'User'; return ( - + - - - - + + + + + + @@ -262,7 +335,7 @@ export default function ProfileScreen() { Cancel diff --git a/frontend/app/screens/RegisterScreen.jsx b/frontend/app/screens/RegisterScreen.jsx index 5607c55..20d3dc3 100644 --- a/frontend/app/screens/RegisterScreen.jsx +++ b/frontend/app/screens/RegisterScreen.jsx @@ -1,8 +1,8 @@ -import { View, Text, TextInput, TouchableOpacity, Image, ActivityIndicator, ScrollView } from 'react-native' +import { View, Text, TextInput, TouchableOpacity, Image, ActivityIndicator, ScrollView, Modal } from 'react-native' import React, { useState } from 'react' import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; -import { EyeIcon, EyeSlashIcon } from 'react-native-heroicons/outline'; -import { registerUser } from '../api'; +import { EyeIcon, EyeSlashIcon, ChevronDownIcon } from 'react-native-heroicons/outline'; +import { register } from '../api'; import { useNavigation } from '@react-navigation/native'; export default function RegisterScreen() { @@ -16,14 +16,22 @@ export default function RegisterScreen() { first_name: "", last_name: "", phone: "", - address: "" + address: "", + gender: "" }); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [showGenderPicker, setShowGenderPicker] = useState(false); + const genderOptions = [ + { label: 'Male', value: 'Male' }, + { label: 'Female', value: 'Female' }, + { label: 'Other', value: 'Other' } + ]; + const validateForm = () => { if (!formData.username.trim()) { setError('Username is required'); @@ -60,6 +68,11 @@ export default function RegisterScreen() { setError('First and last name are required'); return false; } + + if (!formData.gender) { + setError('Gender is required'); + return false; + } return true; }; @@ -73,6 +86,14 @@ export default function RegisterScreen() { if (error) setError(null); }; + const handleGenderSelect = (gender) => { + setFormData(prev => ({ + ...prev, + gender: gender.value + })); + setShowGenderPicker(false); + }; + const handleRegister = async () => { // Clear previous errors setError(null); @@ -93,11 +114,20 @@ export default function RegisterScreen() { first_name: formData.first_name, last_name: formData.last_name, phone: formData.phone || null, - address: formData.address || "" + address: formData.address || "", + gender: formData.gender }; - const response = await registerUser(userData); - navigation.navigate("SignIn"); + const response = await register(userData); + + // If registration is successful, navigate to the appropriate screen + if (response && response.token) { + // Registration successful, navigate to sign in + navigation.navigate("SignIn"); + } else { + // Registration successful but no token, navigate to sign in + navigation.navigate("SignIn"); + } } catch (error) { // Handle different error types if (error.response) { @@ -212,6 +242,47 @@ export default function RegisterScreen() { + + Gender + setShowGenderPicker(true)} + className={`border ${error && !formData.gender ? 'border-red-500' : 'border-gray-300'} rounded-full p-2 mt-2 flex-row justify-between items-center`} + > + + {formData.gender ? genderOptions.find(g => g.value === formData.gender)?.label : 'Select gender'} + + + + + + + + + + Select Gender + setShowGenderPicker(false)}> + Cancel + + + {genderOptions.map((option) => ( + handleGenderSelect(option)} + className="py-3 border-b border-gray-200" + > + + {option.label} + + + ))} + + + + Password diff --git a/frontend/app/screens/SearchResultScreen.jsx b/frontend/app/screens/SearchResultScreen.jsx index f36c55b..d70eea3 100644 --- a/frontend/app/screens/SearchResultScreen.jsx +++ b/frontend/app/screens/SearchResultScreen.jsx @@ -102,9 +102,9 @@ const SearchResultScreen = () => { className="absolute right-4 top-4 bg-white/50 p-2 rounded-full" > {isFavorite ? ( - + ) : ( - + )} diff --git a/frontend/app/screens/SearchScreen.jsx b/frontend/app/screens/SearchScreen.jsx index 0505b9b..ee70b01 100644 --- a/frontend/app/screens/SearchScreen.jsx +++ b/frontend/app/screens/SearchScreen.jsx @@ -1,4 +1,4 @@ -import { View, Text, TextInput, ScrollView, TouchableOpacity, ActivityIndicator, Image } from 'react-native' +import { View, Text, TextInput, ScrollView, TouchableOpacity, ActivityIndicator, Image, Alert } from 'react-native' import React, { useState, useEffect } from 'react' import TabBar from '../themes/TabBar' import { ChevronLeftIcon, MagnifyingGlassIcon, XMarkIcon } from 'react-native-heroicons/outline' @@ -30,23 +30,29 @@ const SearchScreen = () => { }, []); const loadCurrentUser = async () => { - const currentUser = await getCurrentUser(); - setUser(currentUser); + try { + const currentUser = await getCurrentUser(); + setUser(currentUser); + } catch (error) { + console.error('Error loading current user:', error); + } }; const loadRecentSearches = async () => { try { const searches = await getUserRecentSearches(); - setRecentSearches(searches); + setRecentSearches(Array.isArray(searches) ? searches : []); } catch (error) { console.error('Error loading recent searches:', error); + setRecentSearches([]); } }; const handleClearRecentSearch = async (term) => { try { + if (!term) return; const updatedSearches = await removeUserRecentSearch(term); - setRecentSearches(updatedSearches); + setRecentSearches(Array.isArray(updatedSearches) ? updatedSearches : []); } catch (error) { console.error('Error clearing recent search:', error); } @@ -62,18 +68,21 @@ const SearchScreen = () => { }; const handleSearch = async (searchTerm = searchText) => { - if (!searchTerm.trim()) return; + if (!searchTerm || !searchTerm.trim()) return; setLoading(true); setErrorMsg(''); try { - const result = await fetchProducts({ search: searchTerm }); + const result = await fetchProducts(searchTerm); setProducts(result); // Save to recent searches await saveUserRecentSearch(searchTerm); + // Refresh recent searches + loadRecentSearches(); + navigation.navigate('SearchResult', { products: result, searchTerm: searchTerm @@ -87,6 +96,7 @@ const SearchScreen = () => { }; const handleRecentSearchPress = (term) => { + if (!term) return; setSearchText(term); handleSearch(term); }; @@ -122,7 +132,7 @@ const SearchScreen = () => { className="font-medium flex-1 h-full" value={searchText} onChangeText={setSearchText} - onSubmitEditing={handleSearch} + onSubmitEditing={() => handleSearch()} returnKeyType="search" autoFocus /> diff --git a/frontend/app/screens/SettingScreen.jsx b/frontend/app/screens/SettingScreen.jsx index f870187..54d2cf0 100644 --- a/frontend/app/screens/SettingScreen.jsx +++ b/frontend/app/screens/SettingScreen.jsx @@ -1,15 +1,55 @@ -import { View, Text, TouchableOpacity } from 'react-native' -import React from 'react' +import { View, Text, TouchableOpacity, Alert, TextInput, ActivityIndicator, Modal } from 'react-native' +import React, { useState } from 'react' import TabBar from '../themes/TabBar' -import { ChevronLeftIcon, ChevronRightIcon, CreditCardIcon, KeyIcon, UserCircleIcon } from 'react-native-heroicons/outline' -import { widthPercentageToDP as wp, heightPercentageToDP as hp, heightPercentageToDP } from 'react-native-responsive-screen'; +import { ChevronLeftIcon, ChevronRightIcon, CreditCardIcon, KeyIcon, UserCircleIcon, EyeIcon, EyeSlashIcon } from 'react-native-heroicons/outline' +import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import { useNavigation } from '@react-navigation/native'; +import { deleteAccount, logout } from '../api'; export default function SettingScreen() { - const navigation = useNavigation() + const navigation = useNavigation(); + const [loading, setLoading] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + const handleDeleteAccount = async () => { + if (!password) { + Alert.alert('Error', 'Please enter your password'); + return; + } + + Alert.alert( + 'Delete Account', + 'Are you sure you want to delete your account? This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + setLoading(true); + await deleteAccount(password); + await logout(); + Alert.alert('Success', 'Your account has been deleted'); + navigation.navigate('SignIn'); + } catch (error) { + console.error('Delete account error:', error); + Alert.alert('Error', error.response?.data?.error || 'Failed to delete account'); + } finally { + setLoading(false); + setShowDeleteModal(false); + setPassword(''); + } + } + } + ] + ); + }; return ( - + navigation.goBack()} /> - - - - Notification Settings - - + + + + + + Notification Settings + - - - + + - {navigation.navigate('Password Manager')}} className={`mx-4 flex-row items-center justify-between`}> - - - Password Manager - - + navigation.navigate('Password Manager')} + className="flex-row items-center justify-between py-3" + > + + + Password Manager + - - - + + - - - - Delete Account - - + setShowDeleteModal(true)} + className="flex-row items-center justify-between py-3" + > + + + Delete Account + - + + - + + {/* Delete Account Modal */} + setShowDeleteModal(false)} + > + + + Delete Account + + This action cannot be undone. Please enter your password to confirm. + + + + + setShowPassword(!showPassword)}> + {showPassword ? ( + + ) : ( + + )} + + + + + { + setShowDeleteModal(false); + setPassword(''); + }} + className="flex-1 bg-gray-200 py-3 rounded-xl" + > + Cancel + + + {loading ? ( + + ) : ( + Delete + )} + + + + + ) } \ No newline at end of file diff --git a/frontend/app/screens/SignInScreen.jsx b/frontend/app/screens/SignInScreen.jsx index df9aeba..7f7bd80 100644 --- a/frontend/app/screens/SignInScreen.jsx +++ b/frontend/app/screens/SignInScreen.jsx @@ -2,8 +2,10 @@ import { View, Text, TextInput, TouchableOpacity, Image, ActivityIndicator } fro import React, { useState } from 'react' import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import { EyeIcon, EyeSlashIcon } from 'react-native-heroicons/outline'; -import { loginUser } from '../api'; +import { login } from '../api'; import { useNavigation } from '@react-navigation/native'; +import { STORAGE_KEYS } from '../constants/storageKeys'; +import AsyncStorage from '@react-native-async-storage/async-storage'; export default function SignInScreen() { const navigation = useNavigation(); @@ -45,10 +47,10 @@ export default function SignInScreen() { setLoading(true); try { - // Call loginUser with credentials - const result = await loginUser({ username, password }); + // Call login with credentials + const result = await login({ username, password }); - if (result && result.success) { + if (result && result.token) { console.log('Login successful'); navigation.navigate("Main"); } else { diff --git a/frontend/app/screens/TrackOrderScreen.jsx b/frontend/app/screens/TrackOrderScreen.jsx index efb723e..86eb6da 100644 --- a/frontend/app/screens/TrackOrderScreen.jsx +++ b/frontend/app/screens/TrackOrderScreen.jsx @@ -1,13 +1,226 @@ -import { View, Text } from 'react-native' -import React from 'react' +import { View, Text, ActivityIndicator, RefreshControl, FlatList, TouchableOpacity } from 'react-native' +import React, { useEffect, useState } from 'react' import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; import TabBar from '../themes/TabBar'; import { ChevronLeftIcon } from 'react-native-heroicons/outline'; import { Image } from 'react-native'; import Timeline from 'react-native-timeline-flatlist' import { Box, FileText, Package, Truck } from 'lucide-react'; +import { useNavigation, useRoute } from '@react-navigation/native'; +import { getOrderById, updateOrderStatus, shipOrder, deliverOrder, cancelOrder } from '../api'; + +const getStatusColor = (timelineStatus, orderStatus) => { + const statusOrder = ['Pending', 'Shipped', 'Delivered']; + const timelineIndex = statusOrder.indexOf(timelineStatus); + const orderIndex = statusOrder.indexOf(orderStatus); + + // If order is pending, only show first dot as active + if (orderStatus === 'Pending') { + return timelineStatus === 'Pending' ? '#22c55e' : '#d1d5db'; + } + + if (timelineIndex < orderIndex) { + return '#22c55e'; // Completed status - green-500 + } else if (timelineIndex === orderIndex) { + return '#22c55e'; // Current status - green-500 + } else { + return '#d1d5db'; // Pending status - gray-300 + } +}; export default function TrackOrderScreen() { + const navigation = useNavigation(); + const route = useRoute(); + const { orderId } = route.params || {}; + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [updating, setUpdating] = useState(false); + const [lastUpdated, setLastUpdated] = useState(null); + + const fetchOrder = async () => { + try { + setLoading(true); + const orderData = await getOrderById(orderId); + + // Check if the order status has changed + if (order && orderData.status !== order.status) { + setLastUpdated(new Date()); + } + + setOrder(orderData); + } catch (err) { + setError('Failed to fetch order details'); + console.error('Error fetching order:', err); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + if (orderId) { + fetchOrder(); + + // Set up polling interval to check for status updates every 30 seconds + const intervalId = setInterval(() => { + fetchOrder(); + }, 30000); + + // Clean up interval on component unmount + return () => clearInterval(intervalId); + } + }, [orderId]); + + const onRefresh = React.useCallback(() => { + setRefreshing(true); + fetchOrder(); + }, [orderId]); + + const handleBack = () => { + navigation.goBack(); + }; + + const handleStatusUpdate = async (newStatus) => { + try { + setUpdating(true); + let updatedOrder; + + switch (newStatus) { + case 'shipped': + updatedOrder = await shipOrder(orderId); + break; + case 'delivered': + updatedOrder = await deliverOrder(orderId); + break; + case 'cancelled': + updatedOrder = await cancelOrder(orderId); + break; + default: + updatedOrder = await updateOrderStatus(orderId, newStatus.charAt(0).toUpperCase() + newStatus.slice(1)); + } + + setOrder(updatedOrder); + } catch (err) { + setError(err.response?.data?.error || 'Failed to update order status'); + console.error('Error updating order status:', err); + } finally { + setUpdating(false); + } + }; + + if (loading && !refreshing) { + return ( + + + + ); + } + + if (error || !order) { + return ( + + {error || 'Order not found'} + + ); + } + + const orderItems = (order.order_items && order.order_items.length > 0) ? order.order_items : order.items; + const firstItem = orderItems?.[0]; + + const formatCurrency = (value) => { + if (value === undefined || value === null) return '0.00'; + if (typeof value === 'number') return value.toFixed(2); + if (typeof value === 'string') { + const num = parseFloat(value); + return isNaN(num) ? '0.00' : num.toFixed(2); + } + return '0.00'; + }; + + const renderOrderDetails = () => { + return ( + <> + + + + + {firstItem?.product_name || firstItem?.product?.title || 'Product'} + Qty: {firstItem?.quantity || 1} + + ${formatCurrency(firstItem?.price || firstItem?.subtotal)} + + + + + + Order Details + + Expected Delivery Date + {new Date(order.expected_delivery_date || new Date().setDate(new Date().getDate() + 7)).toLocaleDateString()} + + + Tracking ID + {order.tracking_id || orderId || 'N/A'} + + {lastUpdated && ( + + Status Updated + {lastUpdated.toLocaleTimeString()} + + )} + + + Order Status + , + status: 'Pending' + }, + { + time: new Date(order.shipped_at || new Date(order.created_at).setDate(new Date(order.created_at).getDate() + 2)).toLocaleString(), + title: 'Shipped', + description: 'Your order is on the way', + icon: , + status: 'Shipped' + }, + { + time: new Date(order.delivered_at || new Date(order.created_at).setDate(new Date(order.created_at).getDate() + 5)).toLocaleString(), + title: 'Delivered', + description: 'Your order has been delivered', + icon: , + status: 'Delivered' + } + ].map(item => ({ + ...item, + circleColor: getStatusColor(item.status, order.status), + lineColor: getStatusColor(item.status, order.status), + titleStyle: { color: getStatusColor(item.status, order.status) } + }))} + circleSize={20} + timeContainerStyle={{ minWidth: 52 }} + descriptionStyle={{ color: 'gray' }} + /> + + {updating && ( + + + + )} + + + ); + }; + return ( navigation.goBack()} + suffixAction={handleBack} /> - - - - - Brown Jacket - Size: XXL| Qty: 10pcs - - Rs:3000 - - - + + renderOrderDetails()} + refreshControl={ + + } + contentContainerStyle={{ flexGrow: 1 }} + /> - - Order Details - - Expected Delivery Date - 03 Sep 2023 - - - Tracking ID - TRK38438783 - - - {/* Order Status */} - - Order Status - - - {/* Timeline Items */} - {/* - } - title="Order Placed" - date="23 Aug 2023, 04:25 PM" - isCompleted={true} - isFirst={true} - /> - } - title="In Progress" - date="23 Aug 2023, 03:54 PM" - isCompleted={true} - /> - } - title="Shipped" - date="Expected 02 Sep 2023" - isCompleted={false} - /> - } - title="Delivered" - date="23 Aug 2023, 2023" - isCompleted={false} - isLast={true} - /> - */} - - ) } diff --git a/frontend/app/screens/Wishlist.jsx b/frontend/app/screens/Wishlist.jsx deleted file mode 100644 index d38c40b..0000000 --- a/frontend/app/screens/Wishlist.jsx +++ /dev/null @@ -1,124 +0,0 @@ -import { View, Text, ScrollView, Image } from 'react-native' -import React, { useState } from 'react' -import { ArrowLeftIcon, ChevronLeftIcon, HeartIcon } from 'react-native-heroicons/outline' -import { TouchableOpacity } from 'react-native' -import TabBar from '../themes/TabBar' -import MasonryList from '@react-native-seoul/masonry-list'; -import flashSalesData from '../constants/flashSalesData' -import { widthPercentageToDP as wp, heightPercentageToDP as hp, heightPercentageToDP } from 'react-native-responsive-screen'; -import { StarIcon } from 'react-native-heroicons/solid' -import Icon from 'react-native-vector-icons/FontAwesome' -import { useNavigation } from '@react-navigation/native' - -const Wishlist = () => { - const navigation = useNavigation() - const [favorites, setFavorites] = useState({}) - - const subWishlist = ["All", "Shirt", "Paint", "T-shirt", "Jacket", "Suite", "Shoes"] - const [activeSet, setActiveSet] = useState("All") - - const handleSubWishlist = (item) => { - setActiveSet(item) - } - const handleFavouriteToggle = (productId) => { - setFavorites(prev => ({ - ...prev, - [productId]: !prev[productId] - })) - } - - return ( - - } - titleStyle={{ - color: "black", - fontWeight: "bold", - fontSize: 20 - }} - prefixStyle={{ - backgroundColor: "transparent" - }} - suffixStyle={{ - backgroundColor: "white", - borderWidth: 1, - borderColor: "black" - }} - containerStyle="" - suffixAction={() => navigation.goBack()} - /> - - - { - subWishlist.map((item, index) => { - let isActive = item == activeSet; - - return ( - handleSubWishlist(item)} - key={index} - className={` - border border-gray-500 px-4 ml-4 mb-4 rounded-full py-2 - ${isActive ? "bg-primary" : ""} - `}> - - {item} - - - - ) - }) - } - - item.id} - numColumns={2} - showsVerticalScrollIndicator={false} - renderItem={({ item, index }) => } - className="mx-4" - /> - - - - - ) -} -const CardItem = ({ item, index, handleFavouriteToggle, isFavourite, navigation }) => { - let isEven = item.id % 2 == 0 - return ( - - { navigation.navigate('ProductDetails',{title:item.name}) }} className="relative"> - - handleFavouriteToggle(item.id)} className="absolute right-4 top-4 bg-white/50 p-2 rounded-full"> - { - isFavourite ? - - : - - } - - - - - - {item.name} - - - {item.rating} - - - - Rs {item.price} - - - - ) -} - -export default Wishlist \ No newline at end of file diff --git a/frontend/app/screens/WishlistScreen.jsx b/frontend/app/screens/WishlistScreen.jsx index ce9585c..9e57848 100644 --- a/frontend/app/screens/WishlistScreen.jsx +++ b/frontend/app/screens/WishlistScreen.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { View, Text, StyleSheet, ScrollView, RefreshControl, TouchableOpacity, Image } from 'react-native'; +import { View, Text, StyleSheet, ScrollView, RefreshControl, TouchableOpacity, Image, ActivityIndicator } from 'react-native'; import { getFavorites, toggleFavorite } from '../api'; import { useFocusEffect } from '@react-navigation/native'; import { ChevronLeftIcon, HeartIcon } from 'react-native-heroicons/outline'; @@ -18,9 +18,10 @@ const WishlistScreen = ({ navigation }) => { const loadFavorites = async () => { try { const data = await getFavorites(); - setFavorites(data); + setFavorites(data || []); } catch (error) { console.error('Error loading favorites:', error); + setFavorites([]); } finally { setLoading(false); setRefreshing(false); @@ -40,21 +41,25 @@ const WishlistScreen = ({ navigation }) => { const handleFavoriteToggle = async (product) => { try { - const updatedFavorites = await toggleFavorite(product); - setFavorites(updatedFavorites); + await toggleFavorite(product); + // Reload favorites after toggling + loadFavorites(); } catch (error) { console.error('Error toggling favorite:', error); } }; + // Ensure favorites is always an array + const safeFavorites = Array.isArray(favorites) ? favorites : []; + const filteredFavorites = activeCategory === "All" - ? favorites - : favorites.filter(item => item.category === activeCategory); + ? safeFavorites + : safeFavorites.filter(item => item.product && item.product.category_name === activeCategory); if (loading && !refreshing) { return ( - - Loading... + + ); } @@ -129,17 +134,17 @@ const WishlistScreen = ({ navigation }) => { ]} > navigation.navigate('ProductDetails', { product: item })} + onPress={() => navigation.navigate('ProductDetails', { id: item.product.id })} style={styles.card} > handleFavoriteToggle(item)} + onPress={() => handleFavoriteToggle(item.product)} > { - {item.title} + {item.product.name} - ${item.price} + Rs {item.product.price} - {item.rating} + {item.product.average_rating || 0} diff --git a/frontend/app/theme.js b/frontend/app/theme.js new file mode 100644 index 0000000..07314ef --- /dev/null +++ b/frontend/app/theme.js @@ -0,0 +1,98 @@ +export const colors = { + primary: '#007AFF', + secondary: '#5856D6', + success: '#34C759', + danger: '#FF3B30', + warning: '#FF9500', + info: '#5AC8FA', + background: '#FFFFFF', + text: '#000000', + textSecondary: '#8E8E93', + border: '#E5E5EA', + card: '#F2F2F7', + placeholder: '#C7C7CC', + disabled: '#D1D1D6', + overlay: 'rgba(0, 0, 0, 0.5)', +}; + +export const spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 40, +}; + +export const typography = { + h1: { + fontSize: 32, + fontWeight: 'bold', + }, + h2: { + fontSize: 24, + fontWeight: 'bold', + }, + h3: { + fontSize: 20, + fontWeight: '600', + }, + body: { + fontSize: 16, + }, + caption: { + fontSize: 14, + color: colors.textSecondary, + }, +}; + +export const shadows = { + small: { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 2, + }, + medium: { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.30, + shadowRadius: 4.65, + elevation: 4, + }, + large: { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 6, + }, + shadowOpacity: 0.37, + shadowRadius: 7.49, + elevation: 6, + }, +}; + +export const borderRadius = { + sm: 4, + md: 8, + lg: 12, + xl: 16, + round: 999, +}; + +const theme = { + colors, + spacing, + typography, + shadows, + borderRadius, +}; + +export default theme; \ No newline at end of file diff --git a/frontend/assets/images/esewa.jpg b/frontend/assets/images/esewa.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a476550a1d7d70e5bc2ab531bc9dc55b81d29eb2 GIT binary patch literal 20377 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%0DZ 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
wUa3OQKpWqW%%1+C%e8Rf5`G&+bPyi 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 KKd9_hDOFFre@|A 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�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 navigation in React Native files - -echo "Starting navigation fix script..." - -# List of all files to check and fix -find ./app/screens -name "*.jsx" -type f | while read file; do - echo "Processing $file..." - - # Check if the file uses expo-router navigation - if grep -q "import { useNavigation } from 'expo-router'" "$file"; then - echo " Fixing expo-router import in $file" - sed -i "s/import { useNavigation } from 'expo-router'/import { useNavigation } from '@react-navigation\/native'/" "$file" - fi - - # Check if file has navigator variable - if grep -q "const navigator = useNavigation()" "$file"; then - echo " Fixing navigator variable in $file" - sed -i "s/const navigator = useNavigation()/const navigation = useNavigation()/" "$file" - fi - - # Check and fix suffixAction with navigator.goBack - if grep -q "suffixAction={() => navigator.goBack()}" "$file"; then - echo " Fixing navigator.goBack in suffixAction in $file" - sed -i "s/suffixAction={() => navigator.goBack()}/suffixAction={() => navigation.goBack()}/" "$file" - fi - - # Check and fix other direct navigator.goBack calls - if grep -q "onPress={() => navigator.goBack()}" "$file"; then - echo " Fixing navigator.goBack in onPress in $file" - sed -i "s/onPress={() => navigator.goBack()}/onPress={() => navigation.goBack()}/" "$file" - fi - - # Fix navigator.navigate calls - if grep -q "navigator.navigate" "$file"; then - echo " Fixing navigator.navigate calls in $file" - sed -i "s/navigator.navigate/navigation.navigate/g" "$file" - fi - - # Fix props passing - from navigator to navigation - if grep -q "navigator={navigator}" "$file"; then - echo " Fixing navigator props in $file" - sed -i "s/navigator={navigator}/navigation={navigation}/g" "$file" - fi - - # Fix function params that use navigator - if grep -q "({ .*, navigator" "$file"; then - echo " Fixing function parameters using navigator in $file" - sed -i "s/({ \(.*\), navigator/({ \1, navigation/g" "$file" - fi -done - -echo "Done processing files. Please check the changes before committing." \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9474b8b..735a3ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,8 @@ "@ptomasroos/react-native-multi-slider": "^2.2.2", "@react-native-async-storage/async-storage": "^2.1.2", "@react-native-community/blur": "^4.4.1", + "@react-native-community/geolocation": "^3.4.0", + "@react-native-picker/picker": "^2.11.0", "@react-native-seoul/masonry-list": "^1.4.2", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", @@ -22,9 +24,12 @@ "expo-blur": "~14.0.1", "expo-checkbox": "~4.0.1", "expo-constants": "~17.0.3", + "expo-contacts": "^14.0.5", "expo-font": "~13.0.1", "expo-haptics": "~14.0.0", + "expo-image-picker": "^16.0.6", "expo-linking": "~7.0.3", + "expo-location": "^18.0.10", "expo-router": "4.0.11", "expo-splash-screen": "~0.29.18", "expo-status-bar": "~2.0.0", @@ -38,6 +43,7 @@ "react-icons": "^5.4.0", "react-native": "0.76.5", "react-native-check-box": "^2.1.7", + "react-native-element-dropdown": "^2.12.4", "react-native-gesture-handler": "~2.20.2", "react-native-heroicons": "^4.0.0", "react-native-pager-view": "6.5.1", @@ -2372,15 +2378,15 @@ } }, "node_modules/@expo/config": { - "version": "10.0.6", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-10.0.6.tgz", - "integrity": "sha512-xXkfPElrtxznkOZxFASJ7OPa6E9IHSjcZwj5BQ6XUF2dz5M7AFa2h5sXM8AalSaDU5tEBSgoUOjTh5957TlR8g==", + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-10.0.11.tgz", + "integrity": "sha512-nociJ4zr/NmbVfMNe9j/+zRlt7wz/siISu7PjdWE4WE+elEGxWWxsGzltdJG0llzrM+khx8qUiFK5aiVcdMBww==", "license": "MIT", "dependencies": { "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~9.0.10", - "@expo/config-types": "^52.0.0", - "@expo/json-file": "^9.0.0", + "@expo/config-plugins": "~9.0.17", + "@expo/config-types": "^52.0.5", + "@expo/json-file": "^9.0.2", "deepmerge": "^4.3.1", "getenv": "^1.0.0", "glob": "^10.4.2", @@ -2393,14 +2399,14 @@ } }, "node_modules/@expo/config-plugins": { - "version": "9.0.12", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-9.0.12.tgz", - "integrity": "sha512-/Ko/NM+GzvJyRkq8PITm8ms0KY5v0wmN1OQFYRMkcJqOi3PjlhndW+G6bHpJI9mkQXBaUnHwAiGLqIC3+MQ5Wg==", + "version": "9.0.17", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-9.0.17.tgz", + "integrity": "sha512-m24F1COquwOm7PBl5wRbkT9P9DviCXe0D7S7nQsolfbhdCWuvMkfXeoWmgjtdhy7sDlOyIgBrAdnB6MfsWKqIg==", "license": "MIT", "dependencies": { - "@expo/config-types": "^52.0.0", - "@expo/json-file": "~9.0.0", - "@expo/plist": "^0.2.0", + "@expo/config-types": "^52.0.5", + "@expo/json-file": "~9.0.2", + "@expo/plist": "^0.2.2", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -2427,9 +2433,9 @@ } }, "node_modules/@expo/config-types": { - "version": "52.0.1", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-52.0.1.tgz", - "integrity": "sha512-vD8ZetyKV7U29lR6+NJohYeoLYTH+eNYXJeNiSOrWCz0witJYY11meMmEnpEaVbN89EfC6uauSUOa6wihtbyPQ==", + "version": "52.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-52.0.5.tgz", + "integrity": "sha512-AMDeuDLHXXqd8W+0zSjIt7f37vUd/BP8p43k68NHpyAvQO+z8mbQZm3cNQVAMySeayK2XoPigAFB1JF2NFajaA==", "license": "MIT" }, "node_modules/@expo/config/node_modules/@babel/code-frame": { @@ -2483,9 +2489,9 @@ } }, "node_modules/@expo/env": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-0.4.0.tgz", - "integrity": "sha512-g2JYFqck3xKIwJyK+8LxZ2ENZPWtRgjFWpeht9abnKgzXVXBeSNECFBkg+WQjQocSIdxXhEWM6hz4ZAe7Tc4ng==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-0.4.2.tgz", + "integrity": "sha512-TgbCgvSk0Kq0e2fLoqHwEBL4M0ztFjnBEz0YCDm5boc1nvkV1VMuIMteVdeBwnTh8Z0oPJTwHCD49vhMEt1I6A==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -2604,9 +2610,9 @@ } }, "node_modules/@expo/json-file": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.0.0.tgz", - "integrity": "sha512-M+55xFVrFzDcgMDf+52lPDLjKB5xwRfStWlv/b/Vu2OLgxGZLWpxoPYjlRoHqxjPbCQIi2ZCbobK+0KuNhsELg==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.0.2.tgz", + "integrity": "sha512-yAznIUrybOIWp3Uax7yRflB0xsEpvIwIEqIjao9SGi2Gaa+N0OamWfe0fnXBSWF+2zzF4VvqwT4W5zwelchfgw==", "license": "MIT", "dependencies": { "@babel/code-frame": "~7.10.4", @@ -2734,9 +2740,9 @@ "license": "MIT" }, "node_modules/@expo/plist": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.2.0.tgz", - "integrity": "sha512-F/IZJQaf8OIVnVA6XWUeMPC3OH6MV00Wxf0WC0JhTQht2QgjyHUa3U5Gs3vRtDq8tXNsZneOQRDVwpaOnd4zTQ==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.2.2.tgz", + "integrity": "sha512-ZZGvTO6vEWq02UAPs3LIdja+HRO18+LRI5QuDl6Hs3Ps7KX7xU6Y6kjahWKY37Rx2YjNpX07dGpBFzzC+vKa2g==", "license": "MIT", "dependencies": { "@xmldom/xmldom": "~0.7.7", @@ -3998,6 +4004,32 @@ "optional": true, "peer": true }, + "node_modules/@react-native-community/geolocation": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@react-native-community/geolocation/-/geolocation-3.4.0.tgz", + "integrity": "sha512-bzZH89/cwmpkPMKKveoC72C4JH0yF4St5Ceg/ZM9pA1SqX9MlRIrIrrOGZ/+yi++xAvFDiYfihtn9TvXWU9/rA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@react-native-picker/picker": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.0.tgz", + "integrity": "sha512-QuZU6gbxmOID5zZgd/H90NgBnbJ3VV6qVzp6c7/dDrmWdX8S0X5YFYgDcQFjE3dRen9wB9FWnj2VVdPU64adSg==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native-seoul/masonry-list": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@react-native-seoul/masonry-list/-/masonry-list-1.4.2.tgz", @@ -7714,19 +7746,29 @@ } }, "node_modules/expo-constants": { - "version": "17.0.3", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.3.tgz", - "integrity": "sha512-lnbcX2sAu8SucHXEXxSkhiEpqH+jGrf+TF+MO6sHWIESjwOUVVYlT8qYdjR9xbxWmqFtrI4KV44FkeJf2DaFjQ==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz", + "integrity": "sha512-XfWRyQAf1yUNgWZ1TnE8pFBMqGmFP5Gb+SFSgszxDdOoheB/NI5D4p7q86kI2fvGyfTrxAe+D+74nZkfsGvUlg==", "license": "MIT", "dependencies": { - "@expo/config": "~10.0.4", - "@expo/env": "~0.4.0" + "@expo/config": "~10.0.11", + "@expo/env": "~0.4.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, + "node_modules/expo-contacts": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/expo-contacts/-/expo-contacts-14.0.5.tgz", + "integrity": "sha512-PjuwJmXgQLQ5CnuhFvgP+jRdYFFh23ARP3urln9GXwMm1uqR5Y0ZdQC44PgJsKxGlJYVzPQlDxer03mGsoXN8g==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-file-system": { "version": "18.0.5", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.5.tgz", @@ -7762,6 +7804,27 @@ "expo": "*" } }, + "node_modules/expo-image-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz", + "integrity": "sha512-Eg+5FHtyzv3Jjw9dHwu2pWy4xjf8fu3V0Asyy42kO+t/FbvW/vjUixpTjPtgKQLQh+2/9Nk4JjFDV6FwCnF2ZA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "16.0.6", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-16.0.6.tgz", + "integrity": "sha512-HN4xZirFjsFDIsWFb12AZh19fRzuvZjj2ll17cGr19VNRP06S/VPQU3Tdccn5vwUzQhOBlLu704CnNm278boiQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~5.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.1.tgz", @@ -7786,6 +7849,15 @@ "react-native": "*" } }, + "node_modules/expo-location": { + "version": "18.0.10", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-18.0.10.tgz", + "integrity": "sha512-R0Iioz0UZ9Ts8TACPngh8uDFbajJhVa5/igLqWB8Pq/gp8UHuwj7PC8XbZV7avsFoShYjaxrOhf4U7IONeKLgg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.0.4.tgz", @@ -13211,6 +13283,22 @@ "node": ">=10" } }, + "node_modules/react-native-element-dropdown": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/react-native-element-dropdown/-/react-native-element-dropdown-2.12.4.tgz", + "integrity": "sha512-abZc5SVji9FIt7fjojRYrbuvp03CoeZJrgvezQoDoSOrpiTqkX69ix5m+j06W2AVncA0VWvbT+vCMam8SoVadw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-gesture-handler": { "version": "2.20.2", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a9ebb30..b94369b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,8 @@ "@ptomasroos/react-native-multi-slider": "^2.2.2", "@react-native-async-storage/async-storage": "^2.1.2", "@react-native-community/blur": "^4.4.1", + "@react-native-community/geolocation": "^3.4.0", + "@react-native-picker/picker": "^2.11.0", "@react-native-seoul/masonry-list": "^1.4.2", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", @@ -29,9 +31,12 @@ "expo-blur": "~14.0.1", "expo-checkbox": "~4.0.1", "expo-constants": "~17.0.3", + "expo-contacts": "^14.0.5", "expo-font": "~13.0.1", "expo-haptics": "~14.0.0", + "expo-image-picker": "^16.0.6", "expo-linking": "~7.0.3", + "expo-location": "^18.0.10", "expo-router": "4.0.11", "expo-splash-screen": "~0.29.18", "expo-status-bar": "~2.0.0", @@ -45,6 +50,7 @@ "react-icons": "^5.4.0", "react-native": "0.76.5", "react-native-check-box": "^2.1.7", + "react-native-element-dropdown": "^2.12.4", "react-native-gesture-handler": "~2.20.2", "react-native-heroicons": "^4.0.0", "react-native-pager-view": "6.5.1", diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2f35dd4..d44d4fb 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -11,8 +11,8 @@ "include": [ "**/*.ts", "**/*.tsx", + "nativewind-env.d.ts", ".expo/types/**/*.ts", - "expo-env.d.ts", - "nativewind-env.d.ts" + "expo-env.d.ts" ] -} \ No newline at end of file +}