diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/README.md b/examples/apps/ecommerce/web/react-vite-redux-tailwind/README.md index 29513a90..dd4a3fcc 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/README.md +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/README.md @@ -1,5 +1,7 @@ ## ecommerce frontend application +Check out the application [here](https://ecommerce-client-tsy6.onrender.com/) + This is an ecommerce client app which is made by consuming freeapi: https://github.com/hiteshchoudhary/apihub. ### Steps to run the code locally @@ -28,6 +30,27 @@ This is an ecommerce client app which is made by consuming freeapi: https://gith * API for countries & states: https://countriesnow.space/ * Design Inspiration: https://www.figma.com/community/file/1219312065205187851 + + +### Implemented Features +* Allows user to register by email or Google SSO. +* View Categories, View Products In a Category, View All Products, View Product Details, Search for a product. +* Add Products to Cart, Update Product Quantity In Cart, Delete Product from Cart. +* Add address, Update Address, Delete an address. +* View eligible coupon codes. +* Apply Eligible Coupon Codes. +* Pay via PayPal. +* Update profile, Change Password. +* View Orders. +* As an admin + * View Categories, Add a category, Update a category, Delete a category. + * View Products, Add a product, Update a product, Delete a product. + * View Coupons, Add a coupon, Update a coupon, Delete a coupon. + * View Orders, Update Order Status +* Supports Multilingualism, along with right to left languages. + + + ### Dependencies * [React](https://github.com/facebook/react) : v18.2.0 * [Vite](https://vitejs.dev/) : v5.0.12 @@ -48,5 +71,7 @@ This is an ecommerce client app which is made by consuming freeapi: https://gith * [react-i18next](https://github.com/i18next/react-i18next) : v6.20.1 * Form Handling * [React Hook Form](https://github.com/react-hook-form/react-hook-form) : v7.49.2 +* Admin Module Grid + * [AG Grid React](https://www.ag-grid.com/react-data-grid/getting-started/): v 31.2.1 diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/package-lock.json b/examples/apps/ecommerce/web/react-vite-redux-tailwind/package-lock.json index 9fce410a..ce2cd5be 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/package-lock.json +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@paypal/react-paypal-js": "^8.1.3", "@reduxjs/toolkit": "^2.0.1", + "ag-grid-community": "^31.2.1", + "ag-grid-react": "^31.2.1", "axios": "^1.6.2", "date-fns": "^3.6.0", "i18next": "^23.7.11", @@ -1591,6 +1593,24 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ag-grid-community": { + "version": "31.2.1", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-31.2.1.tgz", + "integrity": "sha512-D+gnUQ4dHZ/EQJmupQnDqcEKiCEeuK5ZxlsIpdPKgHg/23dmW+aEdivtB9nLpSc2IEK0RUpchcSxeUT37Boo5A==" + }, + "node_modules/ag-grid-react": { + "version": "31.2.1", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-31.2.1.tgz", + "integrity": "sha512-9UH3xxXRwZfW97oz58KboyCJl4t+zdetopieeHVcttsXX1DvGFDUIEz7A1sQaG8e1DAXLMf3IxoIPrfWheH4XA==", + "dependencies": { + "ag-grid-community": "31.2.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.3.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3117,7 +3137,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3452,6 +3471,16 @@ "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==" }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3559,6 +3588,11 @@ } } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/react-redux": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.0.4.tgz", diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/package.json b/examples/apps/ecommerce/web/react-vite-redux-tailwind/package.json index bfeea1b1..f7592b67 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/package.json +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/package.json @@ -13,6 +13,8 @@ "dependencies": { "@paypal/react-paypal-js": "^8.1.3", "@reduxjs/toolkit": "^2.0.1", + "ag-grid-community": "^31.2.1", + "ag-grid-react": "^31.2.1", "axios": "^1.6.2", "date-fns": "^3.6.0", "i18next": "^23.7.11", diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/ar/translation.json b/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/ar/translation.json index 02c7f2fc..0765246d 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/ar/translation.json +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/ar/translation.json @@ -159,5 +159,69 @@ "cancelled": "ألغيت", "delivered": "تم التوصيل", "pleaseSelectAOrderStatus": "يرجى تحديد حالة الطلب", - "ourStory": "قصتنا" + "ourStory": "قصتنا", + "admin": "مسؤل", + "addCategory": "إضافة فئة", + "categoryName": "اسم الفئة", + "editCategory": "تحرير الفئة", + "updateCategory": "تحديث الفئة", + "categoryNameIsRequired": "اسم الفئة مطلوب", + "categoryAddedSuccessfully": "تمت إضافة الفئة بنجاح", + "categoryUpdatedSuccessfully": "تم تحديث الفئة بنجاح", + "categoryDeletedSuccessfully": "تم حذف الفئة بنجاح", + "deleteCategory": "حذف الفئة", + "areYouSureYouWantToDeleteTheCategory": "هل أنت متأكد أنك تريد حذف الفئة؟", + "addProduct": "إضافة منتج", + "mainImage": "الصورة الرئيسية", + "subImages": "الصور الفرعية", + "productName": "اسم المنتج", + "description": "الوصف", + "price": "السعر", + "stock": "المخزون", + "category": "الفئة", + "updateProduct": "تحديث المنتج", + "nameIsRequired": "الاسم مطلوب", + "descriptionIsRequired": "الوصف مطلوب", + "priceIsRequired": "السعر مطلوب", + "stockIsRequired": "المخزون مطلوب", + "categoryIsRequired": "الفئة مطلوبة", + "mainImageIsRequired": "الصورة الرئيسية مطلوبة", + "productAddedSuccessfully": "تمت إضافة المنتج بنجاح", + "productUpdatedSuccessfully": "تم تحديث المنتج بنجاح", + "nothingToUpdate": "لا يوجد شيء للتحديث", + "productDeletedSuccessfully": "تم حذف المنتج بنجاح", + "deleteProduct": "حذف المنتج", + "areYouSureYouWantToDeleteTheProduct": "هل أنت متأكد أنك تريد حذف المنتج؟", + "products": "المنتجات", + "orders": "الطلبات", + "coupons": "الكوبونات", + "name": "الاسم", + "addCoupon": "إضافة كوبون", + "changeCouponStatus": "تغيير حالة القسيمة", + "areYouSureYouWantToChangeCouponStatusTo": "هل أنت متأكد أنك تريد تغيير حالة القسيمة إلى", + "active": "نشط", + "inactive": "غير نشط", + "statusChangedSuccessfully": "تم تغيير الحالة بنجاح", + "updateCoupon": "تحديث القسيمة", + "couponCode": "رمز القسيمة", + "discountValue": "قيمة الخصم", + "minimumCartValue": "الحد الأدنى لقيمة السلة", + "startDate": "تاريخ البدء", + "expiryDate": "تاريخ الانتهاء", + "selectDateTime": "اختر التاريخ والوقت", + "couponCodeIsRequired": "رمز القسيمة مطلوب", + "discountValueIsRequired": "قيمة الخصم مطلوبة", + "minimumCartValueIsRequired": "الحد الأدنى لقيمة السلة مطلوب", + "startDateIsRequired": "تاريخ البدء مطلوب", + "expiryDateIsRequired": "تاريخ الانتهاء مطلوب", + "expiryDateCannotBeBeforeStartDate": "تاريخ الانتهاء لا يمكن أن يكون قبل تاريخ البدء", + "couponUpdatedSuccessfully": "تم تحديث القسيمة بنجاح", + "couponAddedSuccessfully": "تمت إضافة القسيمة بنجاح", + "couponDeletedSuccessfully": "تم حذف القسيمة بنجاح", + "deleteCoupon": "حذف القسيمة", + "areYouSureYouWantToDeleteTheCoupon": "هل أنت متأكد أنك تريد حذف القسيمة: ", + "couponCodeMustBeFourCharactersLong": "يجب أن يتكون رمز القسيمة من أربعة أحرف على الأقل", + "orderStatusUpdatedSuccessfully": "تم تحديث حالة الطلب من المتجر بنجاح", + "updateOrderStatus": "تحديث حالة الطلب", + "pleaseWaitFetchingDetails": "يرجى الانتظار، جاري جلب التفاصيل" } diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/en/translation.json b/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/en/translation.json index b246516c..dea10d24 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/en/translation.json +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/en/translation.json @@ -159,5 +159,69 @@ "cancelled": "cancelled", "delivered": "delivered", "pleaseSelectAOrderStatus": "please select a order status", - "ourStory": "our story" + "ourStory": "our story", + "admin": "admin", + "addCategory": "add category", + "categoryName": "category name", + "editCategory": "edit category", + "updateCategory": "update category", + "categoryNameIsRequired": "category name is required", + "categoryAddedSuccessfully": "category added successfully", + "categoryUpdatedSuccessfully": "category updated successfully", + "categoryDeletedSuccessfully": "category deleted successfully", + "deleteCategory": "delete category", + "areYouSureYouWantToDeleteTheCategory": "are you sure you want to delete the category", + "addProduct": "add product", + "mainImage": "main image", + "subImages": "sub images", + "productName": "product name", + "description": "description", + "price": "price", + "stock": "stock", + "category": "category", + "updateProduct": "update product", + "nameIsRequired": "name is required", + "descriptionIsRequired": "description is required", + "priceIsRequired": "price is required", + "stockIsRequired": "stock is required", + "categoryIsRequired": "category is required", + "mainImageIsRequired": "main image is required", + "productAddedSuccessfully": "product added successfully", + "productUpdatedSuccessfully": "product updated successfully", + "nothingToUpdate": "nothing to update", + "productDeletedSuccessfully": "product deleted successfully", + "deleteProduct": "delete product", + "areYouSureYouWantToDeleteTheProduct": "are you sure you want to delete the product", + "products": "products", + "orders": "orders", + "coupons": "coupons", + "name": "name", + "addCoupon": "add coupon", + "changeCouponStatus": "change coupon status", + "areYouSureYouWantToChangeCouponStatusTo": "are you sure you want to change the coupon status to", + "active": "active", + "inactive": "inactive", + "statusChangedSuccessfully": "status changed successfully", + "updateCoupon": "update coupon", + "couponCode": "coupon code", + "discountValue": "discount value", + "minimumCartValue": "minimum cart value", + "startDate": "start date", + "expiryDate": "expiry date", + "selectDateTime": "select date and time", + "couponCodeIsRequired": "coupon code is required", + "discountValueIsRequired": "discount value is required", + "minimumCartValueIsRequired": "minimum cart value is required", + "startDateIsRequired": "start date is required", + "expiryDateIsRequired": "expiry date is required", + "expiryDateCannotBeBeforeStartDate": "expiry date cannot be before start date", + "couponUpdatedSuccessfully": "coupon updated successfully", + "couponAddedSuccessfully": "coupon added successfully", + "couponDeletedSuccessfully": "coupon deleted successfully", + "deleteCoupon": "delete coupon", + "areYouSureYouWantToDeleteTheCoupon": "are you sure you want to delete the coupon: ", + "couponCodeMustBeFourCharactersLong": "coupon code must be at least four characters long", + "orderStatusUpdatedSuccessfully": "order status updated successfully", + "updateOrderStatus": "update order status", + "pleaseWaitFetchingDetails": "please wait, fetching details" } diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/hn/translation.json b/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/hn/translation.json index 1a94ad6c..118b78dc 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/hn/translation.json +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/public/locales/hn/translation.json @@ -159,5 +159,69 @@ "cancelled": "रद्द", "delivered": "वितरित", "pleaseSelectAOrderStatus": "कृपया एक आदेश की स्थिति चुनें", - "ourStory": "हमारी कहानी" + "ourStory": "हमारी कहानी", + "admin": "व्यवस्थापक", + "addCategory": "श्रेणी जोड़ें", + "categoryName": "श्रेणी का नाम", + "editCategory": "श्रेणी संपादित करें", + "updateCategory": "श्रेणी अपडेट करें", + "categoryNameIsRequired": "श्रेणी का नाम आवश्यक है", + "categoryAddedSuccessfully": "श्रेणी सफलतापूर्वक जोड़ी गई", + "categoryUpdatedSuccessfully": "श्रेणी सफलतापूर्वक अपडेट की गई", + "categoryDeletedSuccessfully": "श्रेणी सफलतापूर्वक हटाई गई", + "deleteCategory": "श्रेणी हटाएं", + "areYouSureYouWantToDeleteTheCategory": "क्या आप वाकई इस श्रेणी को हटाना चाहते हैं", + "addProduct": "उत्पाद जोड़ें", + "mainImage": "मुख्य छवि", + "subImages": "उप छवियाँ", + "productName": "उत्पाद का नाम", + "description": "विवरण", + "price": "मूल्य", + "stock": "स्टॉक", + "category": "श्रेणी", + "updateProduct": "उत्पाद अपडेट", + "nameIsRequired": "नाम आवश्यक है", + "descriptionIsRequired": "विवरण आवश्यक है", + "priceIsRequired": "मूल्य आवश्यक है", + "stockIsRequired": "स्टॉक आवश्यक है", + "categoryIsRequired": "श्रेणी आवश्यक है", + "mainImageIsRequired": "मुख्य छवि आवश्यक है", + "productAddedSuccessfully": "उत्पाद सफलतापूर्वक जोड़ा गया", + "productUpdatedSuccessfully": "उत्पाद सफलतापूर्वक अपडेट किया गया", + "nothingToUpdate": "कुछ अपडेट करने योग्य नहीं है", + "productDeletedSuccessfully": "उत्पाद सफलतापूर्वक हटा दिया गया", + "deleteProduct": "उत्पाद हटाएं", + "areYouSureYouWantToDeleteTheProduct": "क्या आप वाकई उत्पाद को हटाना चाहते हैं?", + "products": "उत्पाद", + "orders": "आदेश", + "coupons": "कूपन", + "name": "नाम", + "addCoupon": "कूपन जोड़ें", + "changeCouponStatus": "कूपन की स्थिति बदलें", + "areYouSureYouWantToChangeCouponStatusTo": "क्या आप सुनिश्चित हैं कि आप कूपन की स्थिति को बदलना चाहते हैं", + "active": "सक्रिय", + "inactive": "निष्क्रिय", + "statusChangedSuccessfully": "स्थिति सफलतापूर्वक बदल दी गई है", + "updateCoupon": "कूपन अपडेट करें", + "couponCode": "कूपन कोड", + "discountValue": "छूट मूल्य", + "minimumCartValue": "न्यूनतम कार्ट मूल्य", + "startDate": "प्रारंभ तिथि", + "expiryDate": "समाप्ति तिथि", + "selectDateTime": "तारीख और समय का चयन करें", + "couponCodeIsRequired": "कूपन कोड आवश्यक है", + "discountValueIsRequired": "डिस्काउंट मूल्य आवश्यक है", + "minimumCartValueIsRequired": "न्यूनतम कार्ट मूल्य आवश्यक है", + "startDateIsRequired": "प्रारंभ तिथि आवश्यक है", + "expiryDateIsRequired": "समाप्ति तिथि आवश्यक है", + "expiryDateCannotBeBeforeStartDate": "समाप्ति तिथि प्रारंभ तिथि से पहले नहीं हो सकती", + "couponUpdatedSuccessfully": "कूपन सफलतापूर्वक अपडेट किया गया", + "couponAddedSuccessfully": "कूपन सफलतापूर्वक जोड़ा गया", + "couponDeletedSuccessfully": "कूपन सफलतापूर्वक हटा दिया गया", + "deleteCoupon": "कूपन हटाएं", + "areYouSureYouWantToDeleteTheCoupon": "क्या आप सुनिश्चित हैं कि आप कूपन हटाना चाहते हैं: ", + "couponCodeMustBeFourCharactersLong": "कूपन कोड कम से कम चार अक्षरों का होना चाहिए", + "orderStatusUpdatedSuccessfully": "आदेश की स्थिति सफलतापूर्वक अपडेट की गई", + "updateOrderStatus": "आदेश की स्थिति अपडेट करें", + "pleaseWaitFetchingDetails": "कृपया प्रतीक्षा करें, विवरण प्राप्त किए जा रहे हैं" } diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/RoutePaths.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/RoutePaths.tsx index e0622086..7d42608a 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/RoutePaths.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/RoutePaths.tsx @@ -1,22 +1,24 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; -import PageLayout from "./layouts/PageLayout"; -import HomePageContainer from "./pages/home/container/HomePageContainer"; -import ProductsPageContainer from "./pages/products/container/ProductsPageContainer"; import { ROUTE_PATHS } from "./constants"; -import ProductDetailPageContainer from "./pages/productdetail/container/ProductDetailPageContainer"; -import LoginPageContainer from "./pages/login/container/LoginPageContainer"; -import SignupPageContainer from "./pages/signup/container/SignupPageContainer"; +import PageLayout from "./layouts/PageLayout"; +import AboutPageContainer from "./pages/about/container/AboutPageContainer"; +import AdminPageContainer from "./pages/admin/container/AdminPageContainer"; import CartPageContainer from "./pages/cart/container/CartPageContainer"; -import ForLoggedInUsers from "./protectedroutes/ForLoggedInUsers"; import CheckoutPageContainer from "./pages/checkout/container/CheckoutPageContainer"; -import PaymentFeedbackPageContainer from "./pages/paymentfeedback/container/PaymentFeedbackPageContainer"; +import HomePageContainer from "./pages/home/container/HomePageContainer"; +import LoginPageContainer from "./pages/login/container/LoginPageContainer"; import ManageAccountPageContainer from "./pages/manageaccount/container/ManageAccountPageContainer"; +import OrderDetailPageContainer from "./pages/orderdetail/container/OrderDetailPageContainer"; import OrdersPageContainer from "./pages/orders/container/OrdersPageContainer"; +import PageNotFoundPageContainer from "./pages/pagenotfound/container/PageNotFoundPageContainer"; +import PaymentFeedbackPageContainer from "./pages/paymentfeedback/container/PaymentFeedbackPageContainer"; +import ProductDetailPageContainer from "./pages/productdetail/container/ProductDetailPageContainer"; +import ProductsPageContainer from "./pages/products/container/ProductsPageContainer"; import ProductSearchPageContainer from "./pages/productsearch/container/ProductSearchPageContainer"; import ResetForgottenPasswordPageContainer from "./pages/resetforgottenpassword/container/ResetForgottenPasswordPageContainer"; -import PageNotFoundPageContainer from "./pages/pagenotfound/container/PageNotFoundPageContainer"; -import OrderDetailPageContainer from "./pages/orderdetail/container/OrderDetailPageContainer"; -import AboutPageContainer from "./pages/about/container/AboutPageContainer"; +import SignupPageContainer from "./pages/signup/container/SignupPageContainer"; +import ForAdminUsers from "./protectedroutes/ForAdminUsers"; +import ForLoggedInUsers from "./protectedroutes/ForLoggedInUsers"; /* All Routes */ const RoutePaths = () => { @@ -31,6 +33,7 @@ const RoutePaths = () => { } /> } /> } /> + } /> }> } /> } /> @@ -39,7 +42,13 @@ const RoutePaths = () => { } /> } /> - } /> + }> + } /> + } /> + } /> + } /> + + } /> } /> diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/DateTimePicker.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/DateTimePicker.tsx new file mode 100644 index 00000000..0b0f3183 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/DateTimePicker.tsx @@ -0,0 +1,212 @@ +import { ar } from "date-fns/locale/ar"; +import { enIN } from "date-fns/locale/en-IN"; +import React, { + ChangeEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { DayPicker, Matcher } from "react-day-picker"; +import "react-day-picker/dist/style.css"; +import { useTranslation } from "react-i18next"; +import useOutsideClick from "../../hooks/useOutsideClick"; +import { useAppSelector } from "../../store"; +import "../../styles/DateRangePicker.css"; +import { zeroFormattedNumber } from "../../utils/commonHelper"; +import ErrorMessage from "./ErrorMessage"; + +export interface DateTimePickerActionsRef { + forceSetSelectedDateTime(range: Date): void; +} +interface DateTimePickerProps { + onChange(range: Date): void; + errorMessage?: string; + actionsRef?: DateTimePickerActionsRef; + inputClassName?: string; + containerClassName?: string; + openOnTop?: boolean; + placeholder?: string; + disabledProperties?: Matcher | Matcher[] +} +const DateTimePicker = React.forwardRef( + (props: DateTimePickerProps, _: React.ForwardedRef) => { + const { + onChange, + errorMessage = "", + actionsRef, + containerClassName = "", + inputClassName = "", + openOnTop = false, + placeholder = "", + disabledProperties + } = props; + + const { t } = useTranslation(); + + const isRTL = useAppSelector((state) => state.language.isRTL); + + /* Visibility of date range picker */ + const [isPickerShown, setIsPickerShown] = useState(false); + + /* To store the selected date and time */ + const [selectedDateTime, setSelectedDateTime] = useState(); + + const [selectedTime, setSelectedTime] = useState(""); + + /* Reference to the input element and div element which wraps around date time picker */ + const inputRef = useRef(null); + const dateTimePickerRef = useRef(null); + + /* To know whether a click has occurred outside the date time picker */ + const [clickedOutsidePicker] = useOutsideClick(dateTimePickerRef); + + /* Toggle visibility to date time picker calendar */ + const togglePicker = () => { + setIsPickerShown((prev) => !prev); + }; + + /* As month index starts from 0, adding 1 */ + const getMonthValue = (month?: number): string => { + if (typeof month === "number" && !isNaN(month)) { + return `${zeroFormattedNumber(month + 1)}`; + } else { + return ""; + } + }; + + const timeChangeHandler = (event: ChangeEvent) => { + /* Time as string hh:mm */ + const time = event.target.value; + + /* If selectedDateTime does not exist */ + if (!selectedDateTime) { + /* Set the selected time and return */ + setSelectedTime(time); + return; + } + + /* Time as hh:mm being split into an array of 2 elements */ + const timeElementsList = time.split(":"); + const hour = Number(timeElementsList[0]); /* Hour */ + const minutes = Number(timeElementsList[1]); /* Minutes */ + + /* Setting the time in selectedDateTime */ + setSelectedDateTime((prev) => { + return new Date( + prev!.getFullYear(), + prev!.getMonth(), + prev!.getDate(), + hour, + minutes + ); + }); + /* Setting the selected time */ + setSelectedTime(time); + }; + + const dateChangeHandler = (selectedDate: Date | undefined) => { + /* If selectedTime is undefined (Date is selected first) or the selected date is undefined */ + if (!selectedTime || !selectedDate) { + setSelectedDateTime(selectedDate); + return; + } + + /* Time as hh:mm being split into an array of 2 elements */ + const timeElementsList = selectedTime.split(":"); + const hour = Number(timeElementsList[0]); /* Hour */ + const minutes = Number(timeElementsList[1]); /* Minutes */ + + /* Setting the date in selected date time, Time is already selected here*/ + setSelectedDateTime((_) => { + return new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + selectedDate.getDate(), + hour, + minutes + ); + }); + }; + + /* Value shown on the input */ + const inputValue = useMemo(() => { + if (selectedDateTime) { + const date = zeroFormattedNumber(selectedDateTime.getDate()); + const month = getMonthValue(selectedDateTime?.getMonth()); + const year = selectedDateTime.getFullYear(); + const hours = zeroFormattedNumber(selectedDateTime.getHours()); + const minutes = zeroFormattedNumber(selectedDateTime.getMinutes()); + return `${date}/${month}/${year} - ${hours}:${minutes}`; + } + return ""; + }, [selectedDateTime]); + + /* If clicked outside, toggle picker */ + useEffect(() => { + if (clickedOutsidePicker) { + togglePicker(); + } + }, [clickedOutsidePicker]); + + useEffect(() => { + /* On change of selected date time call parent onChange function */ + if (selectedDateTime) { + onChange(selectedDateTime); + } + }, [selectedDateTime, onChange]); + + /* To allow parent to forcefully set selected date time: For operations like reset */ + useEffect(() => { + if (actionsRef) { + actionsRef.forceSetSelectedDateTime = (dateTime) => { + setSelectedDateTime(dateTime); + setSelectedTime(`${dateTime.getHours()}:${dateTime.getMinutes()}`); + }; + } + }, [actionsRef]); + + return ( +
+ + {errorMessage && ( + + )} + {isPickerShown && ( +
+ + + + } + /> +
+ )} +
+ ); + } +); + +export default DateTimePicker; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Dropdown.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Dropdown.tsx index f45d4cd7..4426b1e2 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Dropdown.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Dropdown.tsx @@ -189,6 +189,7 @@ const Dropdown = forwardRef( {isDropdownMenuShown && (
{/* Selected Item Comes First */} {selectedItem && ( diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/FullPageLoadingSpinner.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/FullPageLoadingSpinner.tsx index a3c35ffd..08212aaf 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/FullPageLoadingSpinner.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/FullPageLoadingSpinner.tsx @@ -1,14 +1,20 @@ import LoadingSpinner from "../icons/LoadingSpinner"; import { createPortal } from "react-dom"; +import Text from "./Text"; -const FullPageLoadingSpinner = () => { +interface FullPageLoadingSpinnerProps { + message?: string; +} +const FullPageLoadingSpinner = (props: FullPageLoadingSpinnerProps) => { + const { message = "" } = props; return ( <> {createPortal( -
+
+ {message && {message}}
, - document.getElementById('root') || document.body + document.getElementById("root") || document.body )} ); diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Grid.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Grid.tsx new file mode 100644 index 00000000..65301f5e --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Grid.tsx @@ -0,0 +1,129 @@ +import { + ColDef, + SizeColumnsToContentStrategy, + SizeColumnsToFitGridStrategy, + SizeColumnsToFitProvidedWidthStrategy, +} from "ag-grid-community"; +import "ag-grid-community/styles/ag-grid.css"; +import "ag-grid-community/styles/ag-theme-quartz.css"; +import { AgGridReact } from "ag-grid-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAppSelector } from "../../store"; +import "../../styles/Grid.css"; + +interface GridProps { + rowData: RowType[] | null; + columnDefination: ColDef[]; + rowSelection?: "single" | "multiple" | undefined; + onRowSelectionChanged?(selectedRows: Array): void; + autoSizeAllColumnsAfterDataUpdate?: boolean; + autoSizeStrategy?: + | SizeColumnsToFitGridStrategy + | SizeColumnsToFitProvidedWidthStrategy + | SizeColumnsToContentStrategy; +} + +const Grid = (props: GridProps) => { + const { + rowData, + columnDefination, + autoSizeStrategy, + rowSelection, + onRowSelectionChanged, + autoSizeAllColumnsAfterDataUpdate = false, + } = props; + + const isRTL = useAppSelector((state) => state.language.isRTL); + + /* Grid ready flag: To know if grid is ready to be customized by API calls */ + const [isGridReady, setIsGridReady] = useState(false); + + const gridRef = useRef(null); + + /* On grid ready, update isGridReady state */ + const gridReadyHandler = () => { + setIsGridReady(true); + }; + + const windowResizeHandler = useCallback(() => { + /* If the grid is ready */ + if (isGridReady) { + /* Calling gridApi methods according to the autoSizeStrategy */ + if (autoSizeStrategy?.type === "fitGridWidth") { + gridRef.current!.api.sizeColumnsToFit(); + } else if (autoSizeStrategy?.type === "fitCellContents") { + gridRef.current!.api.autoSizeAllColumns(); + } + } + }, [isGridReady, autoSizeStrategy]); + + /* Once row selection has changed */ + const onSelectionChanged = useCallback(() => { + if (onRowSelectionChanged) { + /* Pass the selected rows to the parent */ + const selectedRows = gridRef.current!.api.getSelectedRows(); + onRowSelectionChanged(selectedRows); + } + }, [onRowSelectionChanged]); + + /* On row data updated */ + const onRowDataUpdatedHandler = () => { + /* If autoSizeAllColumnsAfterDataUpdate is true*/ + if (autoSizeAllColumnsAfterDataUpdate && isGridReady) { + /** + * Auto size all columns after .5 seconds, and this event doesn't coincide with + * data rendered by AG Grid. + */ + setTimeout(() => { + gridRef.current?.api.autoSizeAllColumns(); + }, 500); + } + }; + + useEffect(() => { + /* Resize grid according to the passed autoSizeStrategy, resize on window resize as well */ + windowResizeHandler(); + window.addEventListener("resize", windowResizeHandler); + + /* Removing even listener */ + return () => { + window.removeEventListener("resize", windowResizeHandler); + }; + }, [windowResizeHandler]); + + /* Show loading over lay, if null is passed */ + useEffect(() => { + if(isGridReady){ + if(rowData === null){ + gridRef.current?.api.showLoadingOverlay(); + } + else{ + gridRef.current?.api.hideOverlay(); + } + } + }, [rowData, isGridReady]) + + return ( + <> +
+ +
+ + ); +}; + +export default Grid; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Image.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Image.tsx index cfb0860f..d0857d68 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Image.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/Image.tsx @@ -1,23 +1,50 @@ -import { SyntheticEvent } from "react"; +import { SyntheticEvent, useState } from "react"; +import LoadingSpinner from "../icons/LoadingSpinner"; interface ImageProps { - src: string, - alt: string, - backupImageSrc: string, - className?: string + src: string; + alt: string; + backupImageSrc: string; + className?: string; } const Image = (props: ImageProps) => { + const { src, alt, backupImageSrc, className = "" } = props; - const {src, alt, backupImageSrc, className = ''} = props + const [isLoading, setIsLoading] = useState(true); - const imgLoadErrorHandler = (event: SyntheticEvent) => { - const target = event.currentTarget; - target.onerror = null; - target.src = backupImageSrc; - } - return ( - {alt} - ) -} + /* On error loading the image, show the backupImage */ + const imgLoadErrorHandler = ( + event: SyntheticEvent + ) => { + const target = event.currentTarget; + /* On error of backupImageSrc, set loading to false */ + target.onerror = () => {setIsLoading(false)}; + target.src = backupImageSrc; + }; + + /* Once the image is loaded hide the loading spinner */ + const onImageLoaded = () => { + setIsLoading(false); + }; + + return ( + <> + {isLoading && ( +
+
+ +
+
+ )} + {alt} + + ); +}; -export default Image; \ No newline at end of file +export default Image; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/ImagePicker.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/ImagePicker.tsx new file mode 100644 index 00000000..dbaad8f0 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/ImagePicker.tsx @@ -0,0 +1,110 @@ +import React, { ChangeEvent, useEffect, useMemo, useState } from "react"; +import { ForwardedRef, useId } from "react"; +import AddIcon from "../icons/AddIcon"; +import Image from "./Image"; +import ErrorMessage from "./ErrorMessage"; +import DeleteIcon from "../icons/DeleteIcon"; +import { useAppSelector } from "../../store"; +import Button from "./Button"; + + +export interface ImagePickerActions { + forceSetSelectedImage(file: File): void; +} +interface ImagePickerProps { + className?: string; + altText: string; + onChange(selectedImage?: File): void; + errorMessage?: string; + actionsRef?: ImagePickerActions; +} +const ImagePicker = React.forwardRef( + (props: ImagePickerProps, ref: ForwardedRef) => { + const { + className = "", + altText = "", + onChange, + errorMessage = "", + actionsRef + } = props; + const id = useId(); + + const isRTL = useAppSelector((state) => state.language.isRTL); + + /* Selected Image File */ + const [selectedImage, setSelectedImage] = useState(); + + /* Image Change Handler */ + const imageChangeHandler = (event: ChangeEvent) => { + /* Setting state and passing data back to parent */ + if (event?.target?.files?.[0]) { + setSelectedImage(event.target.files[0]); + onChange(event.target.files[0]); + } + }; + + /* Remove Image Handler setting selected image to undefined, and passing it to parent */ + const removeImageHandler = () => { + setSelectedImage(undefined); + onChange(undefined); + } + + + /* URL for previewing the image */ + const previewSource = useMemo(() => { + if (selectedImage) { + return URL.createObjectURL(selectedImage); + } + return null; + }, [selectedImage]); + + useEffect(() => { + if(actionsRef){ + actionsRef.forceSetSelectedImage = (file: File) => { + setSelectedImage(file); + } + } + }, [actionsRef]) + + return ( + <> + + {errorMessage && ( + + )} + + + ); + } +); + +export default ImagePicker; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/NavList.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/NavList.tsx index 7df2ab02..03100ff0 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/NavList.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/NavList.tsx @@ -13,7 +13,7 @@ const NavList = ({ navList, className = "" }: NavListProps) => { return (
diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/RadioButtons.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/RadioButtons.tsx index a1420156..c9c20d53 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/RadioButtons.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/basic/RadioButtons.tsx @@ -7,6 +7,7 @@ interface RadioButtonsProps { items: Array>; containerClassName?: string; radioButtonContainerClassName?: string; + radioButtonClassName?: string; onChange(selectedItem: T): void; errorMessage?: string; } @@ -19,6 +20,7 @@ const RadioButtons = React.forwardRef( items, containerClassName = "", radioButtonContainerClassName = "", + radioButtonClassName = "", onChange, errorMessage = "", } = props; @@ -46,7 +48,7 @@ const RadioButtons = React.forwardRef( defaultChecked={item.isDefaultSelected} ref={ref} className={`appearance-none cursor-pointer w-4 h-4 bg-white rounded-full outline outline-2 outline-black - checked:border-[3px] checked:border-white checked:bg-black + checked:border-[3px] checked:border-white checked:bg-black ${radioButtonClassName} ${isRTL ? "ml-2" : "mr-2"}`} onChange={() => { onChange(item.data); diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcategorymodal/container/AddEditCategoryModalContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcategorymodal/container/AddEditCategoryModalContainer.tsx new file mode 100644 index 00000000..7e9e1933 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcategorymodal/container/AddEditCategoryModalContainer.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { AddCategoryFields, EditCategoryFields } from "../../../../constants"; +import { Category } from "../../../../services/category/CategoryTypes"; +import AddEditCategoryModal from "../presentation/AddEditCategoryModal"; +import CategoryService from "../../../../services/category/CategoryService"; +import ApiError from "../../../../services/ApiError"; +import FeedbackModal from "../../feedbackmodal/presentation/FeedbackModal"; +import { useTranslation } from "react-i18next"; + +interface AddEditCategoryModalContainer { + category?: Category; + hideModal(): void; + onCategoryAddedOrUpdatedHandler(category: Category): void; +} +const AddEditCategoryModalContainer = ( + props: AddEditCategoryModalContainer +) => { + const { category, hideModal, onCategoryAddedOrUpdatedHandler } = props; + + const { t } = useTranslation(); + + /* API Call in progress flag */ + const [isLoading, setIsLoading] = useState(false); + + /* Error from API */ + const [apiErrorMessage, setApiErrorMessage] = useState(""); + + /* State to show success dialog once add or edit operation is complete */ + const [isAddOrEditComplete, setIsAddOrEditComplete] = useState(false); + + const addCategoryHandler = async (fields: AddCategoryFields) => { + setIsLoading(true); + setApiErrorMessage(""); + + const response = await CategoryService.addCategory(fields.categoryName); + + setIsLoading(false); + + if (!(response instanceof ApiError)) { + setIsAddOrEditComplete(true); + onCategoryAddedOrUpdatedHandler(response); + } else { + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + } + }; + + const editCategoryHandler = async (fields: EditCategoryFields) => { + setIsLoading(true); + setApiErrorMessage(""); + + const response = await CategoryService.editCategory(fields); + + setIsLoading(false); + + if (!(response instanceof ApiError)) { + setIsAddOrEditComplete(true); + onCategoryAddedOrUpdatedHandler(response); + } else { + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + } + }; + return ( + <> + {isAddOrEditComplete ? ( + + ) : ( + + )} + + ); +}; + +export default AddEditCategoryModalContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcategorymodal/presentation/AddEditCategoryModal.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcategorymodal/presentation/AddEditCategoryModal.tsx new file mode 100644 index 00000000..afbd233c --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcategorymodal/presentation/AddEditCategoryModal.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from "react-i18next"; +import Modal from "../../../basic/Modal"; +import Input from "../../../basic/Input"; +import Button from "../../../basic/Button"; +import { + AddCategoryFields, + ButtonTypes, + EditCategoryFields, +} from "../../../../constants"; +import Text from "../../../basic/Text"; +import { Category } from "../../../../services/category/CategoryTypes"; +import { useEffect, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import ErrorMessage from "../../../basic/ErrorMessage"; + +interface AddEditCategoryModalProps { + category?: Category; + addCategoryHandler(fields: AddCategoryFields): void; + editCategoryHandler(fields: EditCategoryFields): void; + isLoading?: boolean; + apiErrorMessage?: string; + cancelButtonHandler(): void; +} +const AddEditCategoryModal = (props: AddEditCategoryModalProps) => { + const { + category, + addCategoryHandler, + editCategoryHandler, + isLoading = false, + apiErrorMessage = "", + cancelButtonHandler, + } = props; + + /* If a category is passed, then edit mode is true else it's an add category request */ + const isInEditMode = useMemo(() => { + return category ? true : false; + }, [category]); + + const submitHandler = (fields: AddCategoryFields) => { + if (isInEditMode && category) { + editCategoryHandler({ category, newCategoryName: fields.categoryName }); + } else { + addCategoryHandler(fields); + } + }; + + const { t } = useTranslation(); + + const { + register, + setValue, + handleSubmit, + formState: { errors }, + } = useForm(); + + /* If a category is passed set it's value in the input */ + useEffect(() => { + if (category) { + setValue("categoryName", category.name); + } + }, [category, setValue]); + + return ( + +
+ + {isInEditMode ? t("updateCategory") : t("addCategory")} + +
+ {apiErrorMessage && ( + + )} + +
+ + + {!isLoading && ( + + )} +
+
+
+
+ ); +}; + +export default AddEditCategoryModal; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcouponmodal/container/AddEditCouponModalContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcouponmodal/container/AddEditCouponModalContainer.tsx new file mode 100644 index 00000000..a8a05747 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcouponmodal/container/AddEditCouponModalContainer.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { CouponClass } from "../../../../services/coupon/CouponTypes"; +import { useTranslation } from "react-i18next"; +import AddEditCouponModal from "../presentation/AddEditCouponModal"; +import { AddEditCouponFields } from "../../../../constants"; +import FeedbackModal from "../../feedbackmodal/presentation/FeedbackModal"; +import CouponService from "../../../../services/coupon/CouponService"; +import ApiError from "../../../../services/ApiError"; + +interface AddEditCouponModalContainerProps { + hideModal(): void; + onCouponAddedOrEdited(coupon: CouponClass): void; + coupon?: CouponClass; +} +const AddEditCouponModalContainer = ( + props: AddEditCouponModalContainerProps +) => { + const { hideModal, onCouponAddedOrEdited, coupon } = props; + + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + + const [apiErrorMessage, setApiErrorMessage] = useState(""); + + const [isAddOrEditComplete, setIsAddOrEditComplete] = useState(false); + + const addCouponHandler = async (fields: AddEditCouponFields) => { + setIsLoading(true); + setApiErrorMessage(""); + + const response = await CouponService.addCoupon(fields); + + setIsLoading(false); + + /* Success */ + if (!(response instanceof ApiError)) { + setIsAddOrEditComplete(true); + onCouponAddedOrEdited(response); + } else { + /* Error */ + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + } + }; + const editCouponHandler = async (fields: AddEditCouponFields) => { + if (coupon) { + setIsLoading(true); + setApiErrorMessage(""); + + const response = await CouponService.editCoupon(fields, coupon?._id); + + setIsLoading(false); + + /* Success */ + if (!(response instanceof ApiError)) { + setIsAddOrEditComplete(true); + onCouponAddedOrEdited(response); + } else { + /* Error */ + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + } + } + }; + + return ( + <> + {isAddOrEditComplete ? ( + + ) : ( + + )} + + ); +}; + +export default AddEditCouponModalContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcouponmodal/presentation/AddEditCouponModal.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcouponmodal/presentation/AddEditCouponModal.tsx new file mode 100644 index 00000000..4e2b15bf --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditcouponmodal/presentation/AddEditCouponModal.tsx @@ -0,0 +1,216 @@ +import moment from "moment"; +import { useEffect, useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { AddEditCouponFields, ButtonTypes } from "../../../../constants"; +import { CouponClass } from "../../../../services/coupon/CouponTypes"; +import Button from "../../../basic/Button"; +import DateTimePicker from "../../../basic/DateTimePicker"; +import ErrorMessage from "../../../basic/ErrorMessage"; +import Input from "../../../basic/Input"; +import Modal from "../../../basic/Modal"; +import { COUPON_CODE_MINIMUM_LENGTH } from "../../../../data/applicationData"; + +interface AddEditCouponModalProps { + hideModal(): void; + coupon?: CouponClass; + addCouponHandler(fields: AddEditCouponFields): void; + editCouponHandler(fields: AddEditCouponFields): void; + isLoading?: boolean; + apiErrorMessage?: string; +} +const AddEditCouponModal = (props: AddEditCouponModalProps) => { + const { + hideModal, + coupon, + addCouponHandler, + editCouponHandler, + isLoading = false, + apiErrorMessage = "", + } = props; + + const { t } = useTranslation(); + + /* If coupon is provided, isInEditMode will be true else false */ + const isInEditMode = useMemo(() => { + if (coupon) { + return true; + } + return false; + }, [coupon]); + + /* Submit button handler */ + const submitHandler = (fields: AddEditCouponFields) => { + if (isInEditMode) { + editCouponHandler(fields); + } else { + addCouponHandler(fields); + } + }; + + const { + register, + control, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm(); + + /* Default Values, for editing a coupon */ + useEffect(() => { + if (coupon) { + setValue("name", coupon.name); + setValue("couponCode", coupon.couponCode); + setValue("discountValue", coupon.discountValue); + } + }, [coupon, setValue]); + + return ( + +
+ {apiErrorMessage && ( + + )} + { + if (!value.trim()) { + return t("invalidValue"); + } + }, + })} + /> + + { + if (!value.trim()) { + return t("invalidValue"); + } + if (value.length < COUPON_CODE_MINIMUM_LENGTH) { + return t("couponCodeMustBeFourCharactersLong"); + } + }, + })} + /> + + { + if (isNaN(Number(value)) || Number(value) <= 0) { + return t("invalidValue"); + } + }, + })} + /> + + {!isInEditMode && ( + <> + { + if (isNaN(Number(value)) || Number(value) < 0) { + return t("invalidValue"); + } + }, + })} + /> + ( + + )} + /> + { + if ( + moment(value).isSameOrBefore(moment(watch("startDate"))) + ) { + return t("expiryDateCannotBeBeforeStartDate"); + } + }, + }} + render={({ field }) => ( + + )} + /> + + )} +
+ + {!isLoading && ( + + )} +
+ +
+ ); +}; + +export default AddEditCouponModal; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditproductmodal/container/AddEditProductModalContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditproductmodal/container/AddEditProductModalContainer.tsx new file mode 100644 index 00000000..5fc67716 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditproductmodal/container/AddEditProductModalContainer.tsx @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AddEditProductFieldsForService, + DropdownItem, + EditProductFieldsForService +} from "../../../../constants"; +import ApiError from "../../../../services/ApiError"; +import CategoryService from "../../../../services/category/CategoryService"; +import ProductService from "../../../../services/product/ProductService"; +import { Product } from "../../../../services/product/ProductTypes"; +import FeedbackModal from "../../feedbackmodal/presentation/FeedbackModal"; +import AddEditProductModal from "../presentation/AddEditProductModal"; + +interface AddEditProductModalContainerProps { + hideModal(): void; + product?: Product; + onProductAddedOrUpdated(newProduct: Product): void; +} +const AddEditProductModalContainer = ( + props: AddEditProductModalContainerProps +) => { + const { hideModal, product, onProductAddedOrUpdated } = props; + + const { t } = useTranslation(); + + /* Categories Dropdown */ + const [categoriesDropdown, setCategoriesDropdown] = useState( + [] + ); + + /* Loader flag */ + const [isLoading, setIsLoading] = useState(false); + + /* Error message when editing or adding a product */ + const [apiErrorMessage, setApiErrorMessage] = useState(""); + + /* Once add or edit is complete to show the final success modal */ + const [isAddOrEditOperationComplete, setIsAddOrEditOperationComplete] = + useState(false); + + /* Default category selected in case of edit product */ + const [defaultSelectedCategory, setDefaultSelectedCategory] = + useState(); + + /* Fetch all categories for dropdown menu */ + const fetchAllCategories = useCallback(() => { + CategoryService.getAllCategoriesAsync((data, _, error) => { + if (!error) { + /* Set Categories Dropdown Items */ + setCategoriesDropdown((prev) => { + const formatted = data.map((category) => { + /* Formatting the object to include id as category id, and text as category name */ + const formattedCategory = { id: category._id, text: category.name }; + + /* If a product is passed and it's id is same as the current category id, set it as the default selected category */ + if (product && category._id === product.category) { + setDefaultSelectedCategory(formattedCategory); + } + + /* Return formatted category from the map function */ + return formattedCategory; + }); + + /* Adding to the list of dropdown items for categories */ + return [...prev, ...formatted]; + }); + } else { + console.error("Error -- fetchAllCategories()", error); + } + }); + }, [product]); + + const editProductHandler = async (fields: AddEditProductFieldsForService) => { + if (product) { + setIsLoading(true); + setApiErrorMessage(""); + + /* Object which will consist of all the fields to update */ + const fieldsToUpdate: EditProductFieldsForService = { ...fields }; + + let isFieldsChanged = false; + + let key: keyof typeof fieldsToUpdate; + + /* Iterating through each key to only keep the fields which have been updated */ + for (key in fieldsToUpdate) { + const value = fieldsToUpdate[key]; + + /* Main Image */ + if (value instanceof File) { + /* If the mainImage has not changed delete it from the fieldsToUpdate */ + if (product.mainImage._id === value.name) { + delete fieldsToUpdate.mainImage; + } + else{ + isFieldsChanged = true; + } + } + /* SubImages */ + else if (Array.isArray(value)) { + /* For each existing sub image of the product */ + for (const existingSubImage of product.subImages) { + /* Checking if image exists in the updated subImages list */ + const imageIndex = value.findIndex((subImage) => { + if (subImage.name === existingSubImage._id) { + return true; + } + }); + /* If the image does not exist */ + if (imageIndex === -1) { + /* Remove the image */ + const response = await ProductService.removeSubImageOfProduct( + existingSubImage._id, + product._id + ); + /* Error removing the image, return an ApiError */ + if (response instanceof ApiError) { + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + setIsLoading(false); + console.error("-- Error removing Image", response); + return; + } + isFieldsChanged = true; + } else { + /* Removing from the list of sub images in fields to update as the image already exists */ + value.splice(imageIndex, 1); + } + } + } + else if(typeof value === "object" && "id" in value){ + if(product.category !== value.id){ + isFieldsChanged = true; + } + } + else { + if(value === product[key]){ + delete fieldsToUpdate[key] + } + else{ + isFieldsChanged = true; + } + } + } + + if(!isFieldsChanged){ + setApiErrorMessage(t("nothingToUpdate")); + setIsLoading(false); + return; + } + + const response = await ProductService.editProduct(fieldsToUpdate, product._id); + + setIsLoading(false); + + if(!(response instanceof ApiError)){ + /* Success */ + setIsAddOrEditOperationComplete(true); + onProductAddedOrUpdated(response); + } + else{ + /* Error */ + setApiErrorMessage(response.errorResponse?.message || response.errorMessage) + } + + } + }; + + const addProductHandler = async (fields: AddEditProductFieldsForService) => { + setIsLoading(true); + setApiErrorMessage(""); + const response = await ProductService.addProduct(fields); + setIsLoading(false); + + if (!(response instanceof ApiError)) { + setIsAddOrEditOperationComplete(true); + onProductAddedOrUpdated(response); + } else { + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + } + }; + + useEffect(() => { + fetchAllCategories(); + }, [fetchAllCategories]); + + return ( + <> + {isAddOrEditOperationComplete ? ( + + ) : ( + + )} + + ); +}; + +export default AddEditProductModalContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditproductmodal/presentation/AddEditProductModal.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditproductmodal/presentation/AddEditProductModal.tsx new file mode 100644 index 00000000..4928e14e --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/addeditproductmodal/presentation/AddEditProductModal.tsx @@ -0,0 +1,329 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { + AddEditProductFields, + AddEditProductFieldsForService, + ButtonTypes, + DropdownItem, + DropdownTypes, +} from "../../../../constants"; +import { MAX_SUBIMAGES_PER_PRODUCT } from "../../../../data/applicationData"; +import UtilServices from "../../../../services/UtilServices"; +import { Product } from "../../../../services/product/ProductTypes"; +import { useAppSelector } from "../../../../store"; +import Button from "../../../basic/Button"; +import Dropdown from "../../../basic/Dropdown"; +import ErrorMessage from "../../../basic/ErrorMessage"; +import FullPageLoadingSpinner from "../../../basic/FullPageLoadingSpinner"; +import ImagePicker, { ImagePickerActions } from "../../../basic/ImagePicker"; +import Input from "../../../basic/Input"; +import Modal from "../../../basic/Modal"; +import Text from "../../../basic/Text"; + +interface AddEditProductModalProps { + hideModal(): void; + categories: DropdownItem[]; + defaultSelectedCategory?: DropdownItem; + product?: Product; + addProductHandler(fields: AddEditProductFieldsForService): void; + editProductHandler(fields: AddEditProductFieldsForService): void; + isLoading?: boolean; + apiErrorMessage?: string; +} +const AddEditProductModal = (props: AddEditProductModalProps) => { + const { + hideModal, + categories, + defaultSelectedCategory, + product, + addProductHandler, + editProductHandler, + isLoading = false, + apiErrorMessage = "", + } = props; + + const { t } = useTranslation(); + + const isRTL = useAppSelector((state) => state.language.isRTL); + + /* For Iterating through sub images to avoid repetition */ + const subImagesListForIteration = useMemo( + () => new Array(MAX_SUBIMAGES_PER_PRODUCT).fill(true), + [] + ); + + /* To show a loading spinner until the default values are set when the modal is in edit mode. */ + const [isPreparingModalForEdit, setIsPreparingModalForEdit] = useState(false); + + /* Main Image Ref for setting the inital image in case of edit operation */ + const mainImageActionsRef = useRef({ + forceSetSelectedImage(_) {}, + }); + + /* Sub Image Actions Ref as an array for setting the inital image in case of edit operation */ + const subImagesActionsRef = useRef([]); + subImagesListForIteration.forEach((_) => { + subImagesActionsRef.current.push({ forceSetSelectedImage(_) {} }); + }); + + /* If product exists: edit mode is true */ + const isInEditMode = useMemo(() => { + return product ? true : false; + }, [product]); + + const { + register, + setValue, + handleSubmit, + control, + formState: { errors }, + } = useForm(); + + /* Submit Handler */ + const submitHandler = (fields: AddEditProductFields) => { + /* Change type, appending all subImages into a single list */ + const fieldsForApiCall: AddEditProductFieldsForService = { + ...fields, + subImages: [], + }; + + /* For each subImage adding it to a single array in fieldsForApiCall */ + subImagesListForIteration.forEach((_, index) => { + const subImage = + fields[`subImage${index + 1}` as keyof AddEditProductFields]; + if (subImage && subImage instanceof File) { + fieldsForApiCall.subImages.push(subImage); + } + /* Deleting subImage1, 2, etc.. as they got added when the object was initialized */ + delete fieldsForApiCall[ + `subImage${index + 1}` as keyof AddEditProductFieldsForService + ]; + }); + + if (isInEditMode) { + editProductHandler(fieldsForApiCall); + } else { + addProductHandler(fieldsForApiCall); + } + }; + + /* Setting the default values: If the modal is for update */ + useEffect(() => { + const setDefaultProductValues = async () => { + if (product) { + setIsPreparingModalForEdit(true); + + /* Setting name, description, price and stock */ + setValue("name", product?.name); + setValue("description", product.description); + setValue("price", product.price); + setValue("stock", product.stock); + + /* Setting the category of the product */ + if (defaultSelectedCategory) { + setValue("category", defaultSelectedCategory); + } + + /* Getting a main image as a blob*/ + const mainImageFile = await UtilServices.getImageUrlAsFileObject( + product.mainImage.url, + product.mainImage._id + ); + + /* Setting the mainImage, forceSetting it in the Image Picker component */ + setValue("mainImage", mainImageFile); + mainImageActionsRef.current.forceSetSelectedImage(mainImageFile); + + /* Performing the same action for each subImage */ + for (let index = 0; index < subImagesListForIteration.length; index++) { + if (product?.subImages?.[index]) { + const subImageFile = await UtilServices.getImageUrlAsFileObject( + product.subImages[index].url, + product.subImages[index]._id + ); + setValue( + `subImage${index + 1}` as keyof AddEditProductFields, + subImageFile + ); + subImagesActionsRef.current[index].forceSetSelectedImage( + subImageFile + ); + } + } + + setIsPreparingModalForEdit(false); + } + }; + setDefaultProductValues(); + }, [product, defaultSelectedCategory, setValue, subImagesListForIteration]); + + return ( + +
+ {isPreparingModalForEdit && } + {apiErrorMessage && ( + + )} +
+ { + if (!value.trim()) { + return t("invalidValue"); + } + }, + })} + /> + { + if (!value.trim()) { + return t("invalidValue"); + } + }, + })} + /> + + { + if (isNaN(Number(value)) || Number(value) < 0) { + return t("invalidValue"); + } + }, + })} + /> + + { + if (isNaN(Number(value)) || Number(value) < 0) { + return t("invalidValue"); + } + }, + })} + /> + + ( + + )} + /> +
+
+ {t("mainImage")} + ( + + )} + /> +
+
+ {t("subImages")} +
+ {subImagesListForIteration.map((_, index) => ( + ( + + )} + /> + ))} +
+
+ + + {!isLoading && ( + + )} + +
+ ); +}; + +export default AddEditProductModal; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/changecouponstatusmodal/container/ChangeCouponStatusModalContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/changecouponstatusmodal/container/ChangeCouponStatusModalContainer.tsx new file mode 100644 index 00000000..a6a394e2 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/changecouponstatusmodal/container/ChangeCouponStatusModalContainer.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import ApiError from "../../../../services/ApiError"; +import CouponService from "../../../../services/coupon/CouponService"; +import { CouponClass } from "../../../../services/coupon/CouponTypes"; +import FeedbackModal from "../../feedbackmodal/presentation/FeedbackModal"; +import ChangeCouponStatusModal from "../presentation/ChangeCouponStatusModal"; + +interface ChangeCouponStatusModalContainerProps { + hideModal(): void; + coupon: CouponClass; + resetCouponData(coupon: CouponClass): void; +} +const ChangeCouponStatusModalContainer = ( + props: ChangeCouponStatusModalContainerProps +) => { + const { hideModal, coupon, resetCouponData } = props; + + const { t } = useTranslation(); + + /* Loading flag */ + const [isLoading, setIsLoading] = useState(false); + + /* API Error message */ + const [apiErrorMessage, setApiErrorMessage] = useState(""); + + /* Status change completed flag */ + const [isStatusChangeComplete, setIsStatusChangeComplete] = useState(false); + + /* Change status handler */ + const changeCouponStatusHandler = async () => { + setIsLoading(true); + setApiErrorMessage(""); + const response = await CouponService.updateCouponActiveStatus( + coupon._id, + coupon.isActive + ); + + setIsLoading(false); + + if (!(response instanceof ApiError)) { + /* Success: show feedback modal */ + setIsStatusChangeComplete(true); + } else { + /* Error: Show feedback modal and reset coupon data to the previous isActive flag */ + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + resetCouponData({ ...coupon, isActive: !coupon.isActive }); + } + }; + + /* On click of cancel, reset data and hide modal */ + const hideModalAndResetCouponData = () => { + resetCouponData({...coupon, isActive: !coupon.isActive}) + hideModal(); + } + + return ( + <> + {isStatusChangeComplete || apiErrorMessage ? ( + + ) : ( + + )} + + ); +}; + +export default ChangeCouponStatusModalContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/changecouponstatusmodal/presentation/ChangeCouponStatusModal.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/changecouponstatusmodal/presentation/ChangeCouponStatusModal.tsx new file mode 100644 index 00000000..61a196d4 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/changecouponstatusmodal/presentation/ChangeCouponStatusModal.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; +import { CouponClass } from "../../../../services/coupon/CouponTypes"; +import Modal from "../../../basic/Modal"; +import Text from "../../../basic/Text"; + +interface ChangeCouponStatusModalProps { + hideModal(): void; + coupon: CouponClass; + changeStatusHandler(): void; + isLoading?: boolean; +} +const ChangeCouponStatusModal = (props: ChangeCouponStatusModalProps) => { + const { hideModal, coupon, changeStatusHandler, isLoading = false } = props; + + const { t } = useTranslation(); + + return ( + +
+ + {t("areYouSureYouWantToChangeCouponStatusTo")} + + + {coupon.isActive ? t("active") : t("inactive")} + +
+
+ ); +}; + +export default ChangeCouponStatusModal; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecategorymodal/container/DeleteCategoryModalContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecategorymodal/container/DeleteCategoryModalContainer.tsx new file mode 100644 index 00000000..144cc233 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecategorymodal/container/DeleteCategoryModalContainer.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { Category } from "../../../../services/category/CategoryTypes"; +import DeleteCategoryModal from "../presentation/DeleteCategoryModal"; +import { useTranslation } from "react-i18next"; +import CategoryService from "../../../../services/category/CategoryService"; +import ApiError from "../../../../services/ApiError"; +import FeedbackModal from "../../feedbackmodal/presentation/FeedbackModal"; + +interface DeleteCategoryModalContainerProps { + hideModal(): void; + category: Category; + onCategoryDeleted(deletedCategory: Category): void; +} +const DeleteCategoryModalContainer = ( + props: DeleteCategoryModalContainerProps +) => { + const { hideModal, category, onCategoryDeleted } = props; + + const { t } = useTranslation(); + + /* API Call in progress flag */ + const [isLoading, setIsLoading] = useState(false); + + /* Error from API */ + const [apiErrorMessage, setApiErrorMessage] = useState(""); + + /* State to show success dialog once delete operation is complete */ + const [isDeletionComplete, setIsDeletionComplete] = useState(false); + + const deleteCategoryHandler = async () => { + setIsLoading(true); + setApiErrorMessage(""); + + const response = await CategoryService.deleteCategory(category); + + setIsLoading(false); + + if (!(response instanceof ApiError)) { + /* Success */ + setIsDeletionComplete(true); + onCategoryDeleted(response.data.deletedCategory); + } else { + /* Error */ + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + } + }; + + return ( + <> + {isDeletionComplete || apiErrorMessage ? ( + + ) : ( + + )} + + ); +}; + +export default DeleteCategoryModalContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecategorymodal/presentation/DeleteCategoryModal.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecategorymodal/presentation/DeleteCategoryModal.tsx new file mode 100644 index 00000000..87e418be --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecategorymodal/presentation/DeleteCategoryModal.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; +import { Category } from "../../../../services/category/CategoryTypes"; +import Modal from "../../../basic/Modal"; +import Text from "../../../basic/Text"; + +interface DeleteCategoryModalProps { + category: Category; + deleteCategoryHandler(): void; + isLoading?: boolean; + cancelButtonHandler(): void; + } +const DeleteCategoryModal = (props: DeleteCategoryModalProps) => { + const {category, deleteCategoryHandler, isLoading, cancelButtonHandler} = props; + + const {t} = useTranslation(); + return ( + +
+ + {t("areYouSureYouWantToDeleteTheCategory")} + + + {category.name} + +
+ +
+ ) +} + +export default DeleteCategoryModal; \ No newline at end of file diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecouponmodal/container/DeleteCouponModalContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecouponmodal/container/DeleteCouponModalContainer.tsx new file mode 100644 index 00000000..b42eab12 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecouponmodal/container/DeleteCouponModalContainer.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import ApiError from "../../../../services/ApiError"; +import CouponService from "../../../../services/coupon/CouponService"; +import { CouponClass } from "../../../../services/coupon/CouponTypes"; +import FeedbackModal from "../../feedbackmodal/presentation/FeedbackModal"; +import DeleteCouponModal from "../presentation/DeleteCouponModal"; + +interface DeleteCouponModalContainerProps { + hideModal(): void; + coupon: CouponClass; + onCouponDeleted(deletedCoupon: CouponClass): void; +} +const DeleteCouponModalContainer = (props: DeleteCouponModalContainerProps) => { + const { hideModal, coupon, onCouponDeleted } = props; + + const { t } = useTranslation(); + + /* API Call in progress flag */ + const [isLoading, setIsLoading] = useState(false); + + /* Error from API */ + const [apiErrorMessage, setApiErrorMessage] = useState(""); + + /* State to show success dialog once delete operation is complete */ + const [isDeletionComplete, setIsDeletionComplete] = useState(false); + + const deleteCouponHandler = async () => { + setIsLoading(true); + setApiErrorMessage(""); + + const response = await CouponService.deleteCoupon(coupon._id); + + setIsLoading(false); + + if (!(response instanceof ApiError)) { + /* Success */ + setIsDeletionComplete(true); + onCouponDeleted(response); + } else { + /* Error */ + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + } + }; + + return ( + <> + {isDeletionComplete || apiErrorMessage ? ( + + ) : ( + + )} + + ); +}; + +export default DeleteCouponModalContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecouponmodal/presentation/DeleteCouponModal.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecouponmodal/presentation/DeleteCouponModal.tsx new file mode 100644 index 00000000..62ab9d82 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deletecouponmodal/presentation/DeleteCouponModal.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; +import { CouponClass } from "../../../../services/coupon/CouponTypes"; +import Modal from "../../../basic/Modal"; +import Text from "../../../basic/Text"; + +interface DeleteCouponModalProps { + coupon: CouponClass; + deleteCouponHandler(): void; + isLoading?: boolean; + cancelButtonHandler(): void; +} +const DeleteCouponModal = (props: DeleteCouponModalProps) => { + const { coupon, deleteCouponHandler, isLoading, cancelButtonHandler } = props; + + const { t } = useTranslation(); + return ( + +
+ + {t("areYouSureYouWantToDeleteTheCoupon")} + + + {coupon.name} + +
+
+ ); +}; + +export default DeleteCouponModal; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deleteproductmodal/container/DeleteProductModalContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deleteproductmodal/container/DeleteProductModalContainer.tsx new file mode 100644 index 00000000..6ecbdec3 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deleteproductmodal/container/DeleteProductModalContainer.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import ApiError from "../../../../services/ApiError"; +import ProductService from "../../../../services/product/ProductService"; +import { Product } from "../../../../services/product/ProductTypes"; +import FeedbackModal from "../../feedbackmodal/presentation/FeedbackModal"; +import DeleteProductModal from "../presentation/DeleteProductModal"; + +interface DeleteProductModalContainerProps { + hideModal(): void; + product: Product; + onProductDeleted(deletedProduct: Product): void; +} +const DeleteProductModalContainer = ( + props: DeleteProductModalContainerProps +) => { + const { hideModal, product, onProductDeleted } = props; + + const { t } = useTranslation(); + + /* API Call in progress flag */ + const [isLoading, setIsLoading] = useState(false); + + /* Error from API */ + const [apiErrorMessage, setApiErrorMessage] = useState(""); + + /* State to show success dialog once delete operation is complete */ + const [isDeletionComplete, setIsDeletionComplete] = useState(false); + + const deleteProductHandler = async () => { + setIsLoading(true); + setApiErrorMessage(""); + + const response = await ProductService.deleteProduct(product._id); + + setIsLoading(false); + + if (!(response instanceof ApiError)) { + /* Success */ + setIsDeletionComplete(true); + onProductDeleted(response); + } else { + /* Error */ + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + } + }; + + return ( + <> + {isDeletionComplete || apiErrorMessage ? ( + + ) : ( + + )} + + ); +}; + +export default DeleteProductModalContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deleteproductmodal/presentation/DeleteProductModal.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deleteproductmodal/presentation/DeleteProductModal.tsx new file mode 100644 index 00000000..1908ce9a --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/deleteproductmodal/presentation/DeleteProductModal.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; +import { Product } from "../../../../services/product/ProductTypes"; +import Modal from "../../../basic/Modal"; +import Text from "../../../basic/Text"; + +interface DeleteProductModalProps { + product: Product; + deleteProductHandler(): void; + isLoading?: boolean; + cancelButtonHandler(): void; +} +const DeleteProductModal = (props: DeleteProductModalProps) => { + const { product, deleteProductHandler, isLoading, cancelButtonHandler } = + props; + + const { t } = useTranslation(); + return ( + +
+ + {t("areYouSureYouWantToDeleteTheProduct")} + + + {product.name} + +
+
+ ); +}; + +export default DeleteProductModal; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/editorderstatusmodal/container/EditOrderStatusModalContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/editorderstatusmodal/container/EditOrderStatusModalContainer.tsx new file mode 100644 index 00000000..058d9861 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/editorderstatusmodal/container/EditOrderStatusModalContainer.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import { ORDER_STATUS } from "../../../../data/applicationData"; +import ApiError from "../../../../services/ApiError"; +import OrderService from "../../../../services/order/OrderService"; +import { OrderClass } from "../../../../services/order/OrderTypes"; +import FeedbackModal from "../../feedbackmodal/presentation/FeedbackModal"; +import EditOrderStatusModal from "../presentation/EditOrderStatusModal"; +import { useState } from "react"; + +interface EditOrderStatusModalContainerProps { + hideModal(): void; + order: OrderClass; + onOrderStatusUpdatedHandler(orderId: string, status: ORDER_STATUS): void; +} +const EditOrderStatusModalContainer = ( + props: EditOrderStatusModalContainerProps +) => { + const { hideModal, onOrderStatusUpdatedHandler, order } = props; + + const { t } = useTranslation(); + + /* API Call in progress flag */ + const [isLoading, setIsLoading] = useState(false); + + /* Error from API */ + const [apiErrorMessage, setApiErrorMessage] = useState(""); + + /* State to show success dialog once edit operation is completed*/ + const [isEditComplete, setIsEditComplete] = useState(false); + + /* Edit Status Handler */ + const editStatusHandler = async (status: ORDER_STATUS) => { + setIsLoading(true); + setApiErrorMessage(""); + + const response = await OrderService.editOrderStatus(status, order._id); + + setIsLoading(false); + + /* Success */ + if (!(response instanceof ApiError)) { + setIsEditComplete(true); + onOrderStatusUpdatedHandler(order._id, status); + } else { + /* Error */ + setApiErrorMessage( + response.errorResponse?.message || response.errorMessage + ); + } + }; + return ( + <> + {apiErrorMessage || isEditComplete ? ( + + ) : ( + + )} + + ); +}; + +export default EditOrderStatusModalContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/editorderstatusmodal/presentation/EditOrderStatusModal.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/editorderstatusmodal/presentation/EditOrderStatusModal.tsx new file mode 100644 index 00000000..1766cd99 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/editorderstatusmodal/presentation/EditOrderStatusModal.tsx @@ -0,0 +1,139 @@ +import { useTranslation } from "react-i18next"; +import { + ButtonTypes, + DropdownItem, + DropdownTypes, +} from "../../../../constants"; +import { ORDER_STATUS } from "../../../../data/applicationData"; +import { OrderClass } from "../../../../services/order/OrderTypes"; +import Button from "../../../basic/Button"; +import Dropdown, { DropdownActions } from "../../../basic/Dropdown"; +import Modal from "../../../basic/Modal"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; + +interface EditOrderStatusModalProps { + hideModal(): void; + order: OrderClass; + isLoading?: boolean; + editStatusHandler(status: ORDER_STATUS): void; +} +const EditOrderStatusModal = (props: EditOrderStatusModalProps) => { + const { hideModal, order, isLoading, editStatusHandler } = props; + + const { t } = useTranslation(); + + /* Dropdown Items */ + const orderStatusDropdownItems: Array = useMemo(() => { + /* Array of Order Statuses with id and textKey both as the status */ + return Object.keys(ORDER_STATUS).map((status) => { + return { + id: status, + textKey: status.toLowerCase(), + }; + }); + }, []); + + /* Submit button handler */ + const submitButtonHandler = (fields: { status: DropdownItem }) => { + /* Call the container edit status function passind the status selected */ + editStatusHandler(fields.status.id as ORDER_STATUS); + }; + + /* For setting selected dropdown item value */ + const statusDropdownActionsRef = useRef({ + forceSetSelectedItem(_) {}, + }); + + const { control, handleSubmit, watch, setValue } = useForm<{ + status: DropdownItem; + }>(); + + /* Update button enabled flag */ + const [isUpdateButtonEnabled, setIsUpdateButtonEnabled] = useState(false); + + /* Watching for changed in the form */ + useEffect(() => { + const subscription = watch((value) => { + /* + If the selected status is not equal to the order status, enable the update button, + else disable it + */ + if(value.status?.id && order.status !== value.status.id){ + setIsUpdateButtonEnabled(true); + } + else{ + setIsUpdateButtonEnabled(false); + } + }); + return () => { + subscription.unsubscribe(); + }; + }, [watch, order]); + + /* On change of order */ + useEffect(() => { + if (order) { + /* Default selected dropdown item based on the order status */ + const selectedDropdownItem = orderStatusDropdownItems.find( + (item) => item.id === order.status + ); + + /* Setting the default status dropdown value */ + if (selectedDropdownItem) { + setValue("status", selectedDropdownItem); + statusDropdownActionsRef.current.forceSetSelectedItem( + selectedDropdownItem + ); + } + } + }, [order, orderStatusDropdownItems, setValue]); + + return ( + +
+ ( + + )} + /> + + + {!isLoading && ( + + )} + +
+ ); +}; + +export default EditOrderStatusModal; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/logoutmodal/container/LogoutModalContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/logoutmodal/container/LogoutModalContainer.tsx index 2d9001e8..304ffa75 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/logoutmodal/container/LogoutModalContainer.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/modals/logoutmodal/container/LogoutModalContainer.tsx @@ -30,6 +30,7 @@ const LogoutModalContainer = (props: LogoutModalContainerProps) => { //Error setErrorMessage(response.errorResponse?.message || response.errorMessage) } else { + hideModal(); dispatch(logOut()); navigate("/"); } diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/categoriestable/container/CategoriesTableContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/categoriestable/container/CategoriesTableContainer.tsx new file mode 100644 index 00000000..d0a7a7d9 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/categoriestable/container/CategoriesTableContainer.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from "react"; +import CategoryService from "../../../../services/category/CategoryService"; +import CategoriesTable from "../presentation/CategoriesTable"; +import { Category } from "../../../../services/category/CategoryTypes"; +import { + convertUTCToLocalTime, + formatDateTime, +} from "../../../../utils/dateTimeHelper"; +import { DATE_TIME_FORMATS } from "../../../../constants"; + +const CategoriesTableContainer = () => { + + /* + Flag for whether categories are being fetched + (To show loading spinner until the first response come) + */ + const [isFetchingCategories, setIsFetchingCategories] = useState(true); + + /* Key value categories object, key is id and value is the Category */ + const [categories, setCategories] = useState<{ [key: string]: Category }>({}); + + /* To know if an error has occurred when fetching categories */ + const [isError, setIsError] = useState(false); + + const formatCategory = (category: Category) => { + /* Cloning the object, so the original object from container doesn't get updated */ + category = { ...category }; + + /* Converting all times to local and formatting them */ + category.createdAt = formatDateTime( + convertUTCToLocalTime( + category.createdAt, + DATE_TIME_FORMATS.standardDateWithTime + ), + DATE_TIME_FORMATS.standardDateWithTime, + DATE_TIME_FORMATS.displayedDateWithTime + ); + + category.updatedAt = formatDateTime( + convertUTCToLocalTime( + category.updatedAt, + DATE_TIME_FORMATS.standardDateWithTime + ), + DATE_TIME_FORMATS.standardDateWithTime, + DATE_TIME_FORMATS.displayedDateWithTime + ); + return category; + }; + + /* Fetch All Categories Asynchronously */ + const fetchAllCategories = useCallback(() => { + setIsFetchingCategories(true); + CategoryService.getAllCategoriesAsync((data, _, error) => { + if (!error) { + setCategories((prev) => { + data.map((category) => { + /* Formatting category */ + category = formatCategory(category); + + /* At key: Category ID, value is the category object */ + prev[category._id] = category; + }); + return {...prev}; + }); + + setIsFetchingCategories(false); + + } else { + setIsFetchingCategories(false); + console.error("Error -- fetchAllCategories()", error); + setIsError(true); + } + }); + }, []); + + /* Once a category has been updated or added (In order to avoid another apiCall) */ + const onCategoryAddedOrUpdatedHandler = ( + newCategory: Category, + ) => { + /* Update category object at the categoryId */ + setCategories((prev) => { + prev[newCategory._id] = formatCategory(newCategory); + return {...prev}; + }); + }; + + const onCategoryDeletedHandler = (deletedCategory: Category) => { + /* Removing the key value pair of the deletedCategory */ + setCategories((prev) => { + delete prev[deletedCategory._id] + return {...prev}; + }); + }; + + /* Initial Render */ + useEffect(() => { + fetchAllCategories(); + }, [fetchAllCategories]); + + return ( + <> + + + ); +}; + +export default CategoriesTableContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/categoriestable/presentation/CategoriesTable.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/categoriestable/presentation/CategoriesTable.tsx new file mode 100644 index 00000000..10db51ba --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/categoriestable/presentation/CategoriesTable.tsx @@ -0,0 +1,199 @@ +import { + ColDef, + SizeColumnsToContentStrategy, + SizeColumnsToFitGridStrategy, + SizeColumnsToFitProvidedWidthStrategy, +} from "ag-grid-community"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BREAKPOINTS, ButtonTypes } from "../../../../constants"; +import useBreakpointCheck from "../../../../hooks/useBreakpointCheck"; +import { Category } from "../../../../services/category/CategoryTypes"; +import { useAppSelector } from "../../../../store"; +import { + gridDateFilterComparator, + gridDateSortComparator, +} from "../../../../utils/dateTimeHelper"; +import Button from "../../../basic/Button"; +import ErrorMessage from "../../../basic/ErrorMessage"; +import Grid from "../../../basic/Grid"; +import AddEditCategoryModalContainer from "../../../modals/addeditcategorymodal/container/AddEditCategoryModalContainer"; +import DeleteCategoryModalContainer from "../../../modals/deletecategorymodal/container/DeleteCategoryModalContainer"; +import CategoryOptionsCell from "./CategoryOptionsCell"; + +interface CategoriesTableProps { + categories: Category[] | null; + isError: boolean; + onCategoryAddedOrUpdatedHandler(newCategory: Category): void; + onCategoryDeletedHandler(deletedCategory: Category): void; +} +const CategoriesTable = (props: CategoriesTableProps) => { + const { + categories, + onCategoryAddedOrUpdatedHandler, + onCategoryDeletedHandler, + isError, + } = props; + + const { t } = useTranslation(); + + const isRTL = useAppSelector((state) => state.language.isRTL); + + /* Is large screen */ + const isLG = useBreakpointCheck(BREAKPOINTS.lg); + + /* Column Defination for the grid */ + const CATEGORIES_TABLE_COL_DEFS: ColDef[] = [ + { + field: "name", + sortable: false, + filter: "agTextColumnFilter", + filterParams: { + maxNumConditions: 1, + filterOptions: ["contains"], + }, + }, + { + field: "createdAt", + unSortIcon: true, + comparator: gridDateSortComparator, + filter: "agDateColumnFilter", + filterParams: { + suppressAndOrCondition: true, + filterOptions: ["equals"], + comparator: gridDateFilterComparator, + }, + }, + { + field: "updatedAt", + unSortIcon: true, + comparator: gridDateSortComparator, + filter: "agDateColumnFilter", + filterParams: { + suppressAndOrCondition: true, + filterOptions: ["equals"], + comparator: gridDateFilterComparator, + }, + }, + { + field: "", + maxWidth: 100, + resizable: false, + cellRenderer: CategoryOptionsCell, + cellRendererParams: { + onEditOrDeleteClickHandler: toggleEditOrDeleteCategoryModal, + }, + pinned: !isLG ? (isRTL ? "left" : "right") : false, + }, + ]; + + /* Visibility of AddEdit Category dialog */ + const [isAddEditCategoryModalShown, setIsAddEditCategoryModalShown] = + useState(false); + + /* Visibility of Delete Category dialog */ + const [isDeleteCategoryModalShown, setIsDeleteCategoryModalShown] = + useState(false); + + /* Selected category for edit & delete options on a category */ + const [selectedCategory, setSelectedCategory] = useState(); + + /* Toggle Add Category Dialog */ + const toggleAddCategoryModal = () => { + /* Reset selected category */ + setSelectedCategory(undefined); + setIsAddEditCategoryModalShown((prev) => !prev); + }; + + /* Toggle Edit or Delete Category Dialog */ + function toggleEditOrDeleteCategoryModal( + category: Category, + type: "EDIT" | "DELETE" + ) { + /* If there is no selected category: Toggle is to show the dialog */ + if (!selectedCategory && category) { + /* Set selected category */ + setSelectedCategory(category); + } else { + /* Set category to undefined */ + setSelectedCategory(undefined); + } + /* Toggle the dialog */ + if (type === "EDIT") { + setIsAddEditCategoryModalShown((prev) => !prev); + } else { + setIsDeleteCategoryModalShown((prev) => !prev); + } + } + + /* + On Large screens fit the entire width, on smaller screens fit the contents of a cell + */ + const autoSizeStrategy: + | SizeColumnsToFitGridStrategy + | SizeColumnsToFitProvidedWidthStrategy + | SizeColumnsToContentStrategy = useMemo(() => { + if (isLG) { + return { + type: "fitGridWidth", + }; + } + return { + type: "fitCellContents", + }; + }, [isLG]); + + return ( + <> + {isError ? ( + + ) : ( + <> + {isAddEditCategoryModalShown && ( + + onCategoryAddedOrUpdatedHandler(category) + } + /> + )} + {isDeleteCategoryModalShown && selectedCategory && ( + + toggleEditOrDeleteCategoryModal(selectedCategory, "DELETE") + } + category={selectedCategory} + onCategoryDeleted={(deletedCategory) => { + onCategoryDeletedHandler(deletedCategory); + }} + /> + )} +
+ + +
+ + )} + + ); +}; + +export default CategoriesTable; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/categoriestable/presentation/CategoryOptionsCell.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/categoriestable/presentation/CategoryOptionsCell.tsx new file mode 100644 index 00000000..3809e76d --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/categoriestable/presentation/CategoryOptionsCell.tsx @@ -0,0 +1,34 @@ +import { CustomCellRendererProps } from "ag-grid-react"; +import { Category } from "../../../../services/category/CategoryTypes"; +import Button from "../../../basic/Button"; +import DeleteIcon from "../../../icons/DeleteIcon"; +import EditIcon from "../../../icons/EditIcon"; + +interface CategoryOptionsCellProps extends CustomCellRendererProps { + onEditOrDeleteClickHandler(category: Category, type: "DELETE" | "EDIT"): void; +} +/* For options cell for a particular category in the Category Table */ +const CategoryOptionsCell = (props: CategoryOptionsCellProps) => { + return ( + <> +
+ + +
+ + ); +}; + +export default CategoryOptionsCell; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/couponstable/container/CouponsTableContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/couponstable/container/CouponsTableContainer.tsx new file mode 100644 index 00000000..9a177e6f --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/couponstable/container/CouponsTableContainer.tsx @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useState } from "react"; +import { DATE_TIME_FORMATS } from "../../../../constants"; +import CouponService from "../../../../services/coupon/CouponService"; +import { CouponClass } from "../../../../services/coupon/CouponTypes"; +import { + convertUTCToLocalTime, + formatDateTime, +} from "../../../../utils/dateTimeHelper"; +import CouponsTable from "../presentation/CouponsTable"; + +const CouponsTableContainer = () => { + + /* + Flag for whether coupons are being fetched + (To show loading spinner until the first response come) + */ + const [isFetchingCoupons, setIsFetchingCoupons] = useState(true); + + /* Key value coupons object, key is id and value is the Coupon */ + const [coupons, setCoupons] = useState<{ [key: string]: CouponClass }>({}); + + /* To know if an error has occurred when fetching coupons */ + const [isError, setIsError] = useState(false); + + const formatCoupon = (coupon: CouponClass) => { + /* Cloning the object, so the original object doesn't get updated */ + coupon = { ...coupon }; + + /* Converting expiryDate and startDate to local and formatting them, + as expiry date will be visible */ + coupon.expiryDate = formatDateTime( + convertUTCToLocalTime( + coupon.expiryDate, + DATE_TIME_FORMATS.standardDateWithTime + ), + DATE_TIME_FORMATS.standardDateWithTime, + DATE_TIME_FORMATS.displayedDateWithTime + ); + coupon.startDate = formatDateTime( + convertUTCToLocalTime( + coupon.startDate, + DATE_TIME_FORMATS.standardDateWithTime + ), + DATE_TIME_FORMATS.standardDateWithTime, + DATE_TIME_FORMATS.displayedDateWithTime + ); + + return coupon; + }; + + /* Fetch All Coupons Asynchronously */ + const fetchAllCoupons = useCallback(() => { + setIsFetchingCoupons(true); + CouponService.getAllCouponsAsync((data, _, error) => { + if (!error) { + setCoupons((prev) => { + data.map((coupon) => { + /* Formatting coupon */ + coupon = formatCoupon(coupon); + + /* At key: Coupon ID, value is the coupon object */ + prev[coupon._id] = coupon; + }); + return { ...prev }; + }); + setIsFetchingCoupons(false); + } else { + console.error("Error -- fetchAllCoupons() Admin", error); + setIsError(true); + setIsFetchingCoupons(false); + } + }); + }, []); + + /* Once a coupon has been updated or added (In order to avoid another apiCall) */ + const onCouponAddedOrUpdatedHandler = (newCoupon: CouponClass) => { + /* Update coupon object at the couponId */ + setCoupons((prev) => { + prev[newCoupon._id] = formatCoupon(newCoupon); + return { ...prev }; + }); + }; + + const onCouponDeletedHandler = (deletedCoupon: CouponClass) => { + /* Removing the key value pair of the deletedCoupon */ + setCoupons((prev) => { + delete prev[deletedCoupon._id]; + return { ...prev }; + }); + }; + + const resetCouponStatusHandler = (coupon: CouponClass) => { + setCoupons((prev) => { + prev[coupon._id] = coupon; + return { ...prev }; + }); + }; + + /* Initial Render */ + useEffect(() => { + fetchAllCoupons(); + }, [fetchAllCoupons]); + + return ( + <> + + + ); +}; + +export default CouponsTableContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/couponstable/presentation/CouponsOptionsCell.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/couponstable/presentation/CouponsOptionsCell.tsx new file mode 100644 index 00000000..712d7ef2 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/couponstable/presentation/CouponsOptionsCell.tsx @@ -0,0 +1,34 @@ +import { CustomCellRendererProps } from "ag-grid-react"; +import { CouponClass } from "../../../../services/coupon/CouponTypes"; +import Button from "../../../basic/Button"; +import DeleteIcon from "../../../icons/DeleteIcon"; +import EditIcon from "../../../icons/EditIcon"; + +interface CouponsOptionsCellProps extends CustomCellRendererProps { + onEditOrDeleteClickHandler(coupon: CouponClass, type: "DELETE" | "EDIT"): void; +} +/* For options cell for a particular coupon */ +const CouponsOptionsCell = (props: CouponsOptionsCellProps) => { + return ( + <> +
+ + +
+ + ); +}; + +export default CouponsOptionsCell; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/couponstable/presentation/CouponsTable.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/couponstable/presentation/CouponsTable.tsx new file mode 100644 index 00000000..74087e84 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/couponstable/presentation/CouponsTable.tsx @@ -0,0 +1,231 @@ +import { + ColDef +} from "ag-grid-community"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ButtonTypes } from "../../../../constants"; +import { CouponClass } from "../../../../services/coupon/CouponTypes"; +import { useAppSelector } from "../../../../store"; +import { + gridDateFilterComparator, + gridDateSortComparator, +} from "../../../../utils/dateTimeHelper"; +import Button from "../../../basic/Button"; +import ErrorMessage from "../../../basic/ErrorMessage"; +import Grid from "../../../basic/Grid"; +import AddEditCouponModalContainer from "../../../modals/addeditcouponmodal/container/AddEditCouponModalContainer"; +import ChangeCouponStatusModalContainer from "../../../modals/changecouponstatusmodal/container/ChangeCouponStatusModalContainer"; +import DeleteCouponModalContainer from "../../../modals/deletecouponmodal/container/DeleteCouponModalContainer"; +import CouponsOptionsCell from "./CouponsOptionsCell"; + +interface CouponsTableProps { + coupons: CouponClass[] | null; + isError: boolean; + onCouponAddedOrUpdatedHandler(newCoupon: CouponClass): void; + onCouponDeletedHandler(deletedCoupon: CouponClass): void; + resetCouponStatusHandler(resetCoupon: CouponClass): void; +} +const CouponsTable = (props: CouponsTableProps) => { + const { + coupons, + onCouponAddedOrUpdatedHandler, + onCouponDeletedHandler, + resetCouponStatusHandler, + isError, + } = props; + + const { t } = useTranslation(); + + const isRTL = useAppSelector((state) => state.language.isRTL); + + const [couponStatusPropertiesState, setCouponStatusPropertiesState] = + useState<{ isModalShown: boolean; coupon?: CouponClass }>({ + isModalShown: false, + coupon: undefined, + }); + + /* Column Defination for the grid */ + const COUPONS_TABLE_COL_DEFS: ColDef[] = [ + { + field: "name", + sortable: false, + filter: "agTextColumnFilter", + filterParams: { + maxNumConditions: 1, + filterOptions: ["contains"], + }, + }, + { + field: "couponCode", + sortable: false, + filter: "agTextColumnFilter", + filterParams: { + maxNumConditions: 1, + filterOptions: ["contains"], + }, + }, + { + field: "isActive", + sortable: true, + editable: true, + onCellValueChanged: (event) => { + setCouponStatusPropertiesState({ + isModalShown: true, + coupon: event.data, + }); + }, + }, + { + field: "expiryDate", + unSortIcon: true, + comparator: gridDateSortComparator, + filter: "agDateColumnFilter", + filterParams: { + suppressAndOrCondition: true, + filterOptions: ["equals"], + comparator: gridDateFilterComparator, + }, + }, + { + field: "minimumCartValue", + sortable: true, + }, + { + field: "discountValue", + sortable: true, + }, + { + field: "type", + sortable: false, + }, + { + field: "startDate", + unSortIcon: true, + comparator: gridDateSortComparator, + filter: "agDateColumnFilter", + filterParams: { + suppressAndOrCondition: true, + filterOptions: ["equals"], + comparator: gridDateFilterComparator, + }, + }, + { + field: "", + maxWidth: 100, + resizable: false, + cellRenderer: CouponsOptionsCell, + cellRendererParams: { + onEditOrDeleteClickHandler: toggleEditOrDeleteCouponModal, + }, + pinned: isRTL ? 'left' : 'right' + }, + ]; + + /* Visibility of AddEdit Coupon dialog */ + const [isAddEditCouponModalShown, setIsAddEditCouponModalShown] = + useState(false); + + /* Visibility of Delete Coupon dialog */ + const [isDeleteCouponModalShown, setIsDeleteCouponModalShown] = + useState(false); + + /* Selected coupon for edit & delete options */ + const [selectedCoupon, setSelectedCoupon] = useState(); + + const toggleCouponStatusChangeModal = () => { + setCouponStatusPropertiesState((prev) => { + if (prev.isModalShown) { + return { isModalShown: false, coupon: undefined }; + } + return prev; + }); + }; + + /* Toggle Add Coupon Dialog */ + const toggleAddCouponModal = () => { + /* Reset selected coupon */ + setSelectedCoupon(undefined); + setIsAddEditCouponModalShown((prev) => !prev); + }; + + /* Toggle Edit or Delete Coupon Dialog */ + function toggleEditOrDeleteCouponModal( + coupon: CouponClass, + type: "EDIT" | "DELETE" + ) { + /* If there is no selected coupon: Toggle is to show the dialog */ + if (!selectedCoupon && coupon) { + /* Set selected coupon */ + setSelectedCoupon(coupon); + } else { + /* Set coupon to undefined */ + setSelectedCoupon(undefined); + } + /* Toggle the dialog */ + if (type === "EDIT") { + setIsAddEditCouponModalShown((prev) => !prev); + } else { + setIsDeleteCouponModalShown((prev) => !prev); + } + } + + return ( + <> + {isError ? ( + + ) : ( + <> + {isAddEditCouponModalShown && ( + + )} + {isDeleteCouponModalShown && selectedCoupon && ( + + toggleEditOrDeleteCouponModal(selectedCoupon, "DELETE") + } + onCouponDeleted={onCouponDeletedHandler} + /> + )} + {couponStatusPropertiesState.isModalShown && + couponStatusPropertiesState.coupon && ( + + )} +
+ + +
+ + )} + + ); +}; + +export default CouponsTable; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/header/container/HeaderContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/header/container/HeaderContainer.tsx index 8035c480..0122c841 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/header/container/HeaderContainer.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/header/container/HeaderContainer.tsx @@ -12,19 +12,28 @@ import { NavigationOption, ROUTE_PATHS, QUERY_PARAMS, + USER_ROLES, } from "../../../../constants"; -import { getNavigationItemList } from "../../../../data/applicationData"; +import { + ADMIN_NAVIGATION_ITEMS, + getNavigationItemList, +} from "../../../../data/applicationData"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { getUserCartThunk, resetCartSlice } from "../../../../store/CartSlice"; import { createSearchParams } from "react-router-dom"; +interface HeaderContainerProps { + isAdminPageLayoutShown: boolean; +} const HeaderContainer = React.forwardRef(function HeaderContainer( - _, + props: HeaderContainerProps, ref: ForwardedRef ) { + const { isAdminPageLayoutShown } = props; const navigate = useCustomNavigate(); const dispatch = useAppDispatch(); + const userDetails = useAppSelector((state) => state.auth.userDetails); const isLoggedIn = useAppSelector((state) => state.auth.isLoggedIn); const userCart = useAppSelector((state) => state.cart.userCart); @@ -86,8 +95,14 @@ const HeaderContainer = React.forwardRef(function HeaderContainer( /* Get Navigation Item List based on isLoggedIn flag */ useEffect(() => { - setNavigationList(getNavigationItemList(isLoggedIn)); - }, [isLoggedIn]); + if (isAdminPageLayoutShown) { + setNavigationList(ADMIN_NAVIGATION_ITEMS); + } else { + setNavigationList( + getNavigationItemList(isLoggedIn, userDetails?.role || USER_ROLES.user) + ); + } + }, [isLoggedIn, userDetails?.role, isAdminPageLayoutShown]); return (
); }); diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/header/presentation/Header.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/header/presentation/Header.tsx index 7459983d..0a8f159b 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/header/presentation/Header.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/header/presentation/Header.tsx @@ -1,5 +1,9 @@ import { useTranslation } from "react-i18next"; -import { BREAKPOINTS, NavigationOption } from "../../../../constants"; +import { + BREAKPOINTS, + ButtonTypes, + NavigationOption, +} from "../../../../constants"; import useBreakpointCheck from "../../../../hooks/useBreakpointCheck"; import Hamburger from "../../../basic/Hamburger"; import SearchInput from "../../../basic/SearchInput"; @@ -8,7 +12,8 @@ import CartIcon from "../../../icons/CartIcon"; import { useAppSelector } from "../../../../store"; import NavList from "../../../basic/NavList"; import InfoHeaderContainer from "../../infoheader/container/InfoHeaderContainer"; -import { ForwardedRef, forwardRef } from "react"; +import { ForwardedRef, forwardRef, useState } from "react"; +import LogoutModalContainer from "../../../modals/logoutmodal/container/LogoutModalContainer"; interface HeaderProps { logoClickHandler(): void; @@ -16,16 +21,32 @@ interface HeaderProps { itemsInCart: number; cartClickHandler(): void; searchHandler(inputText: string): void; + isAdminPageLayoutShown: boolean; } -const Header = forwardRef(function Header(props: HeaderProps, ref: ForwardedRef) { - - const {logoClickHandler, navItemList, itemsInCart = 0, cartClickHandler, searchHandler} = props; +const Header = forwardRef(function Header( + props: HeaderProps, + ref: ForwardedRef +) { + const { + logoClickHandler, + navItemList, + itemsInCart = 0, + cartClickHandler, + searchHandler, + isAdminPageLayoutShown, + } = props; const { t } = useTranslation(); const isRTL = useAppSelector((state) => state.language.isRTL); const isLG = useBreakpointCheck(BREAKPOINTS.lg); + const [isLogoutModalShown, setIsLogoutModalShown] = useState(false); + + const toggleLogoutModal = () => { + setIsLogoutModalShown((prev) => !prev); + }; + /* Mobile & Tablet */ if (!isLG) { return ( @@ -33,12 +54,9 @@ const Header = forwardRef(function Header(props: HeaderProps, ref: ForwardedRef<
- +
-
+ {!isAdminPageLayoutShown && ( +
+ <> + + + +
+ )} + {isAdminPageLayoutShown && ( + <> + {isLogoutModalShown && ( + + )} + + + )}
); diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/container/OrdersTableContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/container/OrdersTableContainer.tsx new file mode 100644 index 00000000..ac5bfefe --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/container/OrdersTableContainer.tsx @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useState } from "react"; +import { DATE_TIME_FORMATS } from "../../../../constants"; +import { ORDER_STATUS } from "../../../../data/applicationData"; +import OrderService from "../../../../services/order/OrderService"; +import { OrderClass } from "../../../../services/order/OrderTypes"; +import { + convertUTCToLocalTime, + formatDateTime, +} from "../../../../utils/dateTimeHelper"; +import OrdersTable from "../presentation/OrdersTable"; + +const OrdersTableContainer = () => { + + /* + Flag for whether orders are being fetched + (To show loading spinner until the first response come) + */ + const [isFetchingOrders, setIsFetchingOrders] = useState(true); + + /* Key value orders object, key is id and value is the order */ + const [orders, setOrders] = useState<{ [key: string]: OrderClass }>({}); + + /* To know if an error has occurred when fetching orders */ + const [isError, setIsError] = useState(false); + + const formatOrder = (order: OrderClass) => { + /* Cloning the object, so the original object doesn't get updated */ + order = { ...order }; + + /* Converting createdAt and updatedAt to local and formatting them */ + order.createdAt = formatDateTime( + convertUTCToLocalTime( + order.createdAt, + DATE_TIME_FORMATS.standardDateWithTime + ), + DATE_TIME_FORMATS.standardDateWithTime, + DATE_TIME_FORMATS.displayedDateWithTime + ); + order.updatedAt = formatDateTime( + convertUTCToLocalTime( + order.updatedAt, + DATE_TIME_FORMATS.standardDateWithTime + ), + DATE_TIME_FORMATS.standardDateWithTime, + DATE_TIME_FORMATS.displayedDateWithTime + ); + + return order; + }; + + /* Fetch Orders Asynchronously */ + const fetchOrders = useCallback((status: ORDER_STATUS) => { + setOrders({}); + setIsFetchingOrders(true); + OrderService.getOrdersAsync(status, (data, _, error) => { + if (!error) { + setOrders((prev) => { + data.map((order) => { + /* Formatting order */ + order = formatOrder(order); + + /* At key: Order ID, value is the order object */ + prev[order._id] = order; + }); + return { ...prev }; + }); + setIsFetchingOrders(false); + } else { + console.error("Error -- fetchOrders() Admin", error); + setIsFetchingOrders(false); + setIsError(true); + } + }); + }, []); + + + /* Once a order status has been updated */ + const onOrderStatusUpdatedHandler = ( + orderId: string, + _: ORDER_STATUS + ) => { + /* Update order object at the orderId */ + setOrders((prev) => { + delete prev[orderId] + return { ...prev }; + }); + }; + + /* Initial Render */ + useEffect(() => { + fetchOrders(ORDER_STATUS.PENDING); + }, [fetchOrders]); + + return ( + <> + + + ); +}; + +export default OrdersTableContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/presentation/OrderLinkCell.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/presentation/OrderLinkCell.tsx new file mode 100644 index 00000000..8543f51d --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/presentation/OrderLinkCell.tsx @@ -0,0 +1,30 @@ +import { CustomCellRendererProps } from "ag-grid-react"; +import Link from "../../../basic/Link"; +import { LinkTypes, QUERY_PARAMS, ROUTE_PATHS } from "../../../../constants"; +import useCustomNavigate from "../../../../hooks/useCustomNavigate"; +import { createSearchParams } from "react-router-dom"; + +const OrderLinkCell = (props: CustomCellRendererProps) => { + const navigate = useCustomNavigate(); + + /* Navigate to the order detail page of the particular order */ + const orderIdClickHandler = () => { + navigate({ + pathname: ROUTE_PATHS.orderDetail, + search: createSearchParams({ + [QUERY_PARAMS.orderId]: props.value, + }).toString(), + }); + }; + return ( + <> + + + ); +}; + +export default OrderLinkCell; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/presentation/OrdersOptionsCell.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/presentation/OrdersOptionsCell.tsx new file mode 100644 index 00000000..95d4a9e9 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/presentation/OrdersOptionsCell.tsx @@ -0,0 +1,26 @@ +import { CustomCellRendererProps } from "ag-grid-react"; +import { OrderClass } from "../../../../services/order/OrderTypes"; +import Button from "../../../basic/Button"; +import EditIcon from "../../../icons/EditIcon"; + +interface OrdersOptionsCellProps extends CustomCellRendererProps { + onEditClickHandler(order: OrderClass): void; +} +/* For options cell for a particular order */ +const OrdersOptionsCell = (props: OrdersOptionsCellProps) => { + return ( + <> +
+ +
+ + ); +}; + +export default OrdersOptionsCell; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/presentation/OrdersTable.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/presentation/OrdersTable.tsx new file mode 100644 index 00000000..d4aa1b8f --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/orderstable/presentation/OrdersTable.tsx @@ -0,0 +1,216 @@ +import { ColDef } from "ag-grid-community"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + RADIO_BUTTON_TYPE +} from "../../../../constants"; +import { ORDER_STATUS } from "../../../../data/applicationData"; +import { OrderClass } from "../../../../services/order/OrderTypes"; +import { useAppSelector } from "../../../../store"; +import { + gridDateFilterComparator, + gridDateSortComparator, +} from "../../../../utils/dateTimeHelper"; +import ErrorMessage from "../../../basic/ErrorMessage"; +import Grid from "../../../basic/Grid"; +import RadioButtons from "../../../basic/RadioButtons"; +import EditOrderStatusModalContainer from "../../../modals/editorderstatusmodal/container/EditOrderStatusModalContainer"; +import OrderLinkCell from "./OrderLinkCell"; +import OrdersOptionsCell from "./OrdersOptionsCell"; + +interface OrdersTableProps { + orders: OrderClass[] | null; + isError: boolean; + onOrderStatusUpdatedHandler(orderId: string, status: ORDER_STATUS): void; + fetchOrders(status: ORDER_STATUS): void; +} +const OrdersTable = (props: OrdersTableProps) => { + const { orders, onOrderStatusUpdatedHandler, fetchOrders, isError } = props; + + const { t } = useTranslation(); + + const isRTL = useAppSelector((state) => state.language.isRTL); + + /* Visibility of Edit Order dialog */ + const [isEditOrderModalShown, setIsEditOrderModalShown] = useState(false); + + /* Order status of the orders which are visibile */ + const [selectedOrderStatus, setSelectedOrderStatus] = useState( + ORDER_STATUS.PENDING + ); + + /* Selected order for edit operation*/ + const [selectedOrder, setSelectedOrder] = useState(); + + /* Column Defination for the grid */ + const ORDERS_TABLE_COL_DEFS: ColDef[] = [ + { + field: "_id", + sortable: false, + filter: "agTextColumnFilter", + cellRenderer: OrderLinkCell, + filterParams: { + maxNumConditions: 1, + filterOptions: ["contains"], + }, + }, + { + field: "customer.username", + sortable: false, + filter: "agTextColumnFilter", + filterParams: { + maxNumConditions: 1, + filterOptions: ["contains"], + }, + }, + { + field: "customer.email", + sortable: false, + filter: "agTextColumnFilter", + filterParams: { + maxNumConditions: 1, + filterOptions: ["contains"], + }, + }, + { + field: "createdAt", + unSortIcon: true, + comparator: gridDateSortComparator, + filter: "agDateColumnFilter", + filterParams: { + suppressAndOrCondition: true, + filterOptions: ["equals"], + comparator: gridDateFilterComparator, + }, + }, + { + field: "updatedAt", + unSortIcon: true, + comparator: gridDateSortComparator, + filter: "agDateColumnFilter", + filterParams: { + suppressAndOrCondition: true, + filterOptions: ["equals"], + comparator: gridDateFilterComparator, + }, + }, + { + field: "discountedOrderPrice", + sortable: true, + }, + { + field: "paymentId", + sortable: false, + filter: "agTextColumnFilter", + filterParams: { + maxNumConditions: 1, + filterOptions: ["contains"], + }, + }, + { + field: "", + maxWidth: 100, + resizable: false, + hide: selectedOrderStatus !== ORDER_STATUS.PENDING, + cellRenderer: OrdersOptionsCell, + cellRendererParams: { + onEditClickHandler: toggleEditOrderModal, + }, + pinned: isRTL ? "left" : "right", + }, + ]; + + const orderStatusRadioButtons: Array> = + useMemo(() => { + return [ + { + label: t("pending"), + isDefaultSelected: true, + data: ORDER_STATUS.PENDING, + id: ORDER_STATUS.PENDING, + }, + { + label: t("cancelled"), + isDefaultSelected: false, + data: ORDER_STATUS.CANCELLED, + id: ORDER_STATUS.CANCELLED, + }, + { + label: t("delivered"), + isDefaultSelected: false, + data: ORDER_STATUS.DELIVERED, + id: ORDER_STATUS.DELIVERED, + }, + ]; + }, [t]); + + /* On order type changed */ + const onOrderTypeChangeHandler = (status: ORDER_STATUS) => { + /* If the previous status is not equal to the new status */ + if (status !== selectedOrderStatus) { + /* Set selected order status */ + setSelectedOrderStatus(status); + + /* Fetch orders of the particular status */ + fetchOrders(status); + } + }; + + /* Toggle Edit Order Dialog */ + function toggleEditOrderModal(order: OrderClass) { + /* If there is no selected order: Toggle is to show the dialog */ + if (!selectedOrder && order) { + /* Set selected order */ + setSelectedOrder(order); + } else { + /* Set order to undefined */ + setSelectedOrder(undefined); + } + /* Toggle the dialog */ + setIsEditOrderModalShown((prev) => !prev); + } + + return ( + <> + {isError ? ( + + ) : ( + <> + {isEditOrderModalShown && selectedOrder && ( + toggleEditOrderModal(selectedOrder)} + order={selectedOrder} + onOrderStatusUpdatedHandler={onOrderStatusUpdatedHandler} + /> + )} +
+ + +
+ + )} + + ); +}; + +export default OrdersTable; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/productstable/container/ProductsTableContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/productstable/container/ProductsTableContainer.tsx new file mode 100644 index 00000000..0cabd14f --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/productstable/container/ProductsTableContainer.tsx @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Product } from "../../../../services/product/ProductTypes"; +import ProductService from "../../../../services/product/ProductService"; +import ProductsTable from "../presentation/ProductsTable"; +import CategoryService from "../../../../services/category/CategoryService"; +import ApiError from "../../../../services/ApiError"; + +export interface ProductWithCategoryName extends Product { + categoryName: string; +} +const ProductsTableContainer = () => { + + + /* + Flag for whether products are being fetched + (To show loading spinner until the first response come) + */ + const [isFetchingProducts, setIsFetchingProducts] = useState(true); + + /* Key value products object, key is product id and value is the Product */ + const [products, setProducts] = useState<{ + [key: string]: ProductWithCategoryName; + }>({}); + + /* To store category names as object, id is categoryId and value is categoryName */ + const categoryNames = useRef<{ [key: string]: string }>({}); + + /* To know if an error has occurred when fetching products */ + const [isError, setIsError] = useState(false); + + /* Get category name by categoryId */ + const getCategoryName = async (categoryId: string): Promise => { + /* Checking for category name locally */ + const categoryName = categoryNames.current?.[categoryId]; + + /* If the category name is not found */ + if (!categoryName) { + /* Fetch category by id */ + const response = await CategoryService.getCategoryById(categoryId); + + /* Success return category name */ + if (!(response instanceof ApiError)) { + /* Save it in memory */ + categoryNames.current[categoryId] = response.name; + + return response.name; + } else { + /* Return empty string */ + return ""; + } + } + return categoryName; + }; + + const fetchProducts = useCallback(async () => { + + setIsFetchingProducts(true); + /* Hiding error, Displaying loading spinner, Resetting products list state */ + setIsError(false); + + /* Get all products asynchronously */ + ProductService.getAllProductsAsync(async (data, _, error) => { + /* Success */ + if (!error) { + const tempProducts: typeof products = {}; + + /* Iterating through fetched products */ + for (const product of data) { + /* Category Name*/ + const categoryName = await getCategoryName(product.category) + + /* Set products state */ + tempProducts[product._id] = { ...product, categoryName }; + } + + /* Set products */ + setProducts((prev) => { + return { ...prev, ...tempProducts }; + }); + setIsFetchingProducts(false); + } else { + + setIsFetchingProducts(false); + /* API Error */ + setIsError(true); + } + }); + }, []); + + /* Once a product has been updated or added (In order to avoid another apiCall) */ + const onProductAddedOrUpdatedHandler = async (newProduct: Product) => { + /* Getting the category name */ + const categoryName = await getCategoryName(newProduct.category); + + /* Update products object at the productId */ + setProducts((prev) => { + prev[newProduct._id] = { + ...newProduct, + categoryName + }; + + return { ...prev }; + }); + }; + + const onProductDeletedHandler = (deletedProduct: Product) => { + /* Removing the key value pair of the deletedProduct */ + setProducts((prev) => { + delete prev[deletedProduct._id]; + return { ...prev }; + }); + }; + + useEffect(() => { + fetchProducts(); + }, [fetchProducts]); + + return ( + <> + + + ); +}; + +export default ProductsTableContainer; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/productstable/presentation/ProductOptionsCell.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/productstable/presentation/ProductOptionsCell.tsx new file mode 100644 index 00000000..83d36f19 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/productstable/presentation/ProductOptionsCell.tsx @@ -0,0 +1,34 @@ +import { CustomCellRendererProps } from "ag-grid-react"; +import { Product } from "../../../../services/product/ProductTypes"; +import Button from "../../../basic/Button"; +import DeleteIcon from "../../../icons/DeleteIcon"; +import EditIcon from "../../../icons/EditIcon"; + +interface ProductOptionsCellProps extends CustomCellRendererProps { + onEditOrDeleteClickHandler(product: Product, type: "DELETE" | "EDIT"): void; +} +/* For options cell for a particular product in the Products Table */ +const ProductOptionsCell = (props: ProductOptionsCellProps) => { + return ( + <> +
+ + +
+ + ); +}; + +export default ProductOptionsCell; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/productstable/presentation/ProductsTable.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/productstable/presentation/ProductsTable.tsx new file mode 100644 index 00000000..929f8f94 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/components/widgets/productstable/presentation/ProductsTable.tsx @@ -0,0 +1,194 @@ +import { + ColDef, + SizeColumnsToContentStrategy, + SizeColumnsToFitGridStrategy, + SizeColumnsToFitProvidedWidthStrategy, +} from "ag-grid-community"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BREAKPOINTS, ButtonTypes } from "../../../../constants"; +import useBreakpointCheck from "../../../../hooks/useBreakpointCheck"; +import { Product } from "../../../../services/product/ProductTypes"; +import { useAppSelector } from "../../../../store"; +import Button from "../../../basic/Button"; +import ErrorMessage from "../../../basic/ErrorMessage"; +import Grid from "../../../basic/Grid"; +import { ProductWithCategoryName } from "../container/ProductsTableContainer"; +import ProductOptionsCell from "./ProductOptionsCell"; +import AddEditProductModalContainer from "../../../modals/addeditproductmodal/container/AddEditProductModalContainer"; +import DeleteProductModalContainer from "../../../modals/deleteproductmodal/container/DeleteProductModalContainer"; + +interface ProductsTableProps { + products: ProductWithCategoryName[] | null; + isError: boolean; + onProductAddedOrUpdated(newProduct: Product): void; + onProductDeletedHandler(deletedProduct: Product): void; +} +const ProductsTable = (props: ProductsTableProps) => { + const { + products, + onProductAddedOrUpdated, + onProductDeletedHandler, + isError, + } = props; + + const { t } = useTranslation(); + + const isRTL = useAppSelector((state) => state.language.isRTL); + + + /* Is large screen */ + const isLG = useBreakpointCheck(BREAKPOINTS.lg); + + /* Column Defination for the grid */ + const PRODUCTS_TABLE_COL_DEFS: ColDef[] = [ + { + field: "name", + sortable: false, + filter: "agTextColumnFilter", + filterParams: { + maxNumConditions: 1, + filterOptions: ["contains"], + }, + }, + { + field: "categoryName", + sortable: true, + filter: "agTextColumnFilter", + filterParams: { + maxNumConditions: 1, + filterOptions: ["contains"], + }, + }, + { + field: "description", + sortable: false, + tooltipField: "description", + }, + { + field: "price", + sortable: true, + }, + { + field: "stock", + sortable: true, + }, + { + field: "", + maxWidth: 100, + resizable: false, + cellRenderer: ProductOptionsCell, + cellRendererParams: { + onEditOrDeleteClickHandler: toggleEditOrDeleteProductModal, + }, + pinned: !isLG ? (isRTL ? 'left' : 'right') : false + }, + ]; + + + /* Visibility of AddEdit Product dialog */ + const [isAddEditProdutModalShown, setIsAddEditProductModalShown] = + useState(false); + + /* Visibility of Delete Product dialog */ + const [isDeleteProductModalShown, setIsDeleteProductModalShown] = + useState(false); + + /* Selected product for edit & delete options */ + const [selectedProduct, setSelectedProduct] = useState(); + + /* Toggle Add Product Dialog */ + const toggleAddProductModal = () => { + /* Reset selected product */ + setSelectedProduct(undefined); + setIsAddEditProductModalShown((prev) => !prev); + }; + + /* Toggle Edit or Delete Product Dialog */ + function toggleEditOrDeleteProductModal( + product: Product, + type: "EDIT" | "DELETE" + ) { + /* If there is no product: Toggle is to show the dialog */ + if (!selectedProduct && product) { + /* Set selected product */ + setSelectedProduct(product); + } else { + /* Set product to undefined */ + setSelectedProduct(undefined); + } + /* Toggle the dialog */ + if (type === "EDIT") { + setIsAddEditProductModalShown((prev) => !prev); + } else { + setIsDeleteProductModalShown((prev) => !prev); + } + } + + /* + On Large screens fit the entire width, on smaller screens fit the contents of a cell + */ + const autoSizeStrategy: + | SizeColumnsToFitGridStrategy + | SizeColumnsToFitProvidedWidthStrategy + | SizeColumnsToContentStrategy = useMemo(() => { + if (isLG) { + return { + type: "fitGridWidth", + }; + } + return { + type: "fitCellContents", + }; + }, [isLG]); + + return ( + <> + {isError ? ( + + ) : ( + <> + {isAddEditProdutModalShown && ( + + )} + {isDeleteProductModalShown && selectedProduct && ( + + toggleEditOrDeleteProductModal(selectedProduct, "DELETE") + } + onProductDeleted={onProductDeletedHandler} + product={selectedProduct} + /> + )} +
+ + +
+ + )} + + ); +}; + +export default ProductsTable; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/constants.ts b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/constants.ts index 1a766d19..db71c71e 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/constants.ts +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/constants.ts @@ -1,6 +1,8 @@ import React from "react"; import { AddressClass } from "./services/address/AddressTypes"; import { DateRange } from "react-day-picker"; +import { Category } from "./services/category/CategoryTypes"; +import { COUPON_TYPES } from "./services/coupon/CouponTypes"; /* DROPDOWN */ export enum DropdownTypes { @@ -139,7 +141,17 @@ export enum ROUTE_PATHS { orderDetail = "/order", resetForgottenPassword = "/forgot-password/:token", pageNotFound = "/page-not-found", - about = "/about" + about = "/about", + admin = "/admin", + adminCategories = "/admin/categories", + adminProducts = "/admin/products", + adminOrders = "/admin/orders", + adminCoupons = "/admin/coupons" +} + +export enum USER_ROLES { + admin = "ADMIN", + user = "USER" } @@ -269,4 +281,57 @@ export interface ProductFilterFields { export interface OrderListFilterFields { dateRange: DateRange, checkedStatus: Array> -} \ No newline at end of file +} + +export interface AddCategoryFields { + categoryName: string; +} + +export interface EditCategoryFields { + category: Category; + newCategoryName: string; +} + +export interface AddEditProductFields { + name: string, + description: string, + category: DropdownItem, + price: number, + stock: number, + mainImage: File, + subImage1: File, + subImage2: File, + subImage3: File, + subImage4: File +} + +export interface AddEditProductFieldsForService { + name: string, + description: string, + category: DropdownItem, + price: number, + stock: number, + mainImage: File, + subImages: File[] +} + +/* For api call */ +export interface EditProductFieldsForService { + name?: string, + description?: string, + category?: DropdownItem, + price?: number, + stock?: number, + mainImage?: File, + subImages?: File[] +} + +export interface AddEditCouponFields { + name: string, + couponCode: string, + type: COUPON_TYPES, + discountValue: number, + minimumCartValue: number, + startDate: Date, + expiryDate: Date +} diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/data/applicationData.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/data/applicationData.tsx index a59bf600..61a6b362 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/data/applicationData.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/data/applicationData.tsx @@ -11,8 +11,10 @@ import { CategoryIcon, DropdownItem, NavigationOption, + ROUTE_PATHS, SelectionMenuItem, TabItemConfig, + USER_ROLES, } from "../constants"; import i18n from "../i18n"; @@ -25,40 +27,72 @@ export const DRAWER_ITEMS: Array = [ { id: 2, textKey: "about", - navigateTo: "/about", + navigateTo: ROUTE_PATHS.about, }, { id: 3, textKey: "login", - navigateTo: "/login", + navigateTo: ROUTE_PATHS.login, }, ]; -export const getNavigationItemList = (isLoggedIn: boolean) => { +export const ADMIN_NAVIGATION_ITEMS: Array = [ + { + id: 1, + textKey: "categories", + navigateTo: ROUTE_PATHS.adminCategories, + }, + { + id: 2, + textKey: "products", + navigateTo: ROUTE_PATHS.adminProducts, + }, + { + id: 3, + textKey: "orders", + navigateTo: ROUTE_PATHS.adminOrders, + }, + { + id: 4, + textKey: "coupons", + navigateTo: ROUTE_PATHS.adminCoupons, + } +]; + +export const getNavigationItemList = ( + isLoggedIn: boolean, + role: USER_ROLES +) => { const tempDrawerItems = [...DRAWER_ITEMS]; if (isLoggedIn) { tempDrawerItems.pop(); + if (role === USER_ROLES.admin) { + tempDrawerItems.push({ + id: 4, + textKey: "admin", + navigateTo: ROUTE_PATHS.adminCategories, + }); + } tempDrawerItems.push({ - id: 4, + id: 5, textKey: "myAccount", navigateTo: "my-account", - customComponent: + customComponent: , }); } return tempDrawerItems; }; - export const MY_ACCOUNT_OPTIONS: Array = [ { id: 1, textKey: "manageAccount", - icon: , + icon: , }, { id: 2, textKey: "myOrders", - icon: + icon: , }, { id: 3, @@ -75,7 +109,12 @@ export const DEFAULT_CURRENCY = "INR"; export const DEFAULT_COUNTRY = "United Arab Emirates"; -export const COUNTRIES_DROPDOWN_LIST: DropdownItem[] = [{id: 1, text: DEFAULT_COUNTRY}, {id: 2, text: "India"}] +export const COUPON_CODE_MINIMUM_LENGTH = 4; + +export const COUNTRIES_DROPDOWN_LIST: DropdownItem[] = [ + { id: 1, text: DEFAULT_COUNTRY }, + { id: 2, text: "India" }, +]; export const COMPANY_GURANTEE_LIST: COMPANY_GURANTEE[] = [ { id: 1, @@ -100,47 +139,50 @@ export const COMPANY_GURANTEE_LIST: COMPANY_GURANTEE[] = [ export const EXPLORE_PRODUCTS_COUNT = 8; export const FEATURED_PRODUCTS_COUNT = 4; export const RELATED_PRODUCTS_COUNT = 4; +export const MAX_SUBIMAGES_PER_PRODUCT = 4; export enum PAYMENT_TYPES { - PAYPAL = "PAYPAL" + PAYPAL = "PAYPAL", } export const MANAGE_ACCOUNT_TABS: Array = [ { - id: 1, - tabHeadingKey: "editProfile" + id: 1, + tabHeadingKey: "editProfile", }, { - id: 2, - tabHeadingKey: "myAddresses" - } -] + id: 2, + tabHeadingKey: "myAddresses", + }, +]; export enum ORDER_STATUS { - PENDING= "PENDING", - CANCELLED= "CANCELLED", - DELIVERED= "DELIVERED", + PENDING = "PENDING", + CANCELLED = "CANCELLED", + DELIVERED = "DELIVERED", } export const ORDER_STATUS_FILTERS_CHECKBOX: Array> = [ { id: ORDER_STATUS.PENDING, - data: null, + data: null, isDefaultSelected: true, isLabelKey: true, - label: i18n.t("pending") + label: i18n.t("pending"), }, { id: ORDER_STATUS.CANCELLED, - data: null, + data: null, isDefaultSelected: true, label: i18n.t("cancelled"), isLabelKey: true, }, { id: ORDER_STATUS.DELIVERED, - data: null, + data: null, isDefaultSelected: true, label: i18n.t("delivered"), isLabelKey: true, - } -] + }, +]; + + diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/layouts/PageLayout.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/layouts/PageLayout.tsx index 874470ab..a60aae95 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/layouts/PageLayout.tsx +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/layouts/PageLayout.tsx @@ -3,20 +3,35 @@ import { useDispatch } from "react-redux"; import { updateBreakpoint } from "../store/BreakpointSlice"; import { getCurrentBreakpoint } from "../utils/breakpointsHelper"; -import { Outlet } from "react-router-dom"; +import { Outlet, useLocation } from "react-router-dom"; import ArrowButton from "../components/basic/ArrowButton"; import ToastMessage from "../components/basic/ToastMessage"; import FooterContainer from "../components/widgets/footer/container/FooterContainer"; import HeaderContainer from "../components/widgets/header/container/HeaderContainer"; -import { ARROW_BUTTONS } from "../constants"; +import { ARROW_BUTTONS, ROUTE_PATHS, USER_ROLES } from "../constants"; import { useAppSelector } from "../store"; +import { updateHeaderHeight } from "../store/UIinfoSlice"; const PageLayout = () => { const dispatch = useDispatch(); + /* Page Location */ + const location = useLocation(); + /* isRTL language */ const isRTL = useAppSelector((state) => state.language.isRTL); + /* Logged in user details */ + const userDetails = useAppSelector((state) => state.auth.userDetails); + + /* + Shown admin page layout: if the logged in user is admin, + and the location path includes is /admin + */ + const isAdminPageLayoutShown = + userDetails?.role === USER_ROLES.admin && + location.pathname.includes(ROUTE_PATHS.admin); + /* Header height in pixels */ const [headerHeight, setHeaderHeight] = useState("0px"); @@ -44,7 +59,8 @@ const PageLayout = () => { /* Updating header height */ useEffect(() => { setHeaderHeight(`${headerContainerRef.current?.clientHeight}px`); - }, [headerContainerRef]); + dispatch(updateHeaderHeight(headerContainerRef?.current?.clientHeight)); + }, [headerContainerRef, dispatch]); /* Scroll to top smoothly */ const scrollToTop = () => { @@ -55,20 +71,22 @@ const PageLayout = () => {
- +
-
- - -
+ {!isAdminPageLayoutShown && ( +
+ + +
+ )}
); }; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/pages/admin/container/AdminPageContainer.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/pages/admin/container/AdminPageContainer.tsx new file mode 100644 index 00000000..16cc67a9 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/pages/admin/container/AdminPageContainer.tsx @@ -0,0 +1,12 @@ +import AdminCategoriesPage from "../presentation/AdminPage"; + + +const AdminPageContainer = () => { + return ( + <> + + + ) +} + +export default AdminPageContainer; \ No newline at end of file diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/pages/admin/presentation/AdminPage.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/pages/admin/presentation/AdminPage.tsx new file mode 100644 index 00000000..445cb9a6 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/pages/admin/presentation/AdminPage.tsx @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import CategoriesTableContainer from "../../../components/widgets/categoriestable/container/CategoriesTableContainer"; +import { useAppSelector } from "../../../store"; +import { useLocation } from "react-router-dom"; +import { ROUTE_PATHS } from "../../../constants"; +import ProductsTableContainer from "../../../components/widgets/productstable/container/ProductsTableContainer"; +import CouponsTableContainer from "../../../components/widgets/couponstable/container/CouponsTableContainer"; +import OrdersTableContainer from "../../../components/widgets/orderstable/container/OrdersTableContainer"; + +const AdminPage = () => { + const location = useLocation(); + + const headerHeight = useAppSelector((state) => state.uiInfo.headerHeight); + + /* Setting height as total screen height - headerHeight, for AG grid */ + const pageContentHeight = useMemo(() => { + return `${window.innerHeight - headerHeight}px`; + }, [headerHeight]); + + return ( +
+ {location.pathname === ROUTE_PATHS.adminCategories ? ( + + ) : location.pathname === ROUTE_PATHS.adminProducts ? ( + + ) : location.pathname === ROUTE_PATHS.adminCoupons ? ( + + ) : location.pathname === ROUTE_PATHS.adminOrders ? ( + + ) : ( + <> + )} +
+ ); +}; + +export default AdminPage; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/protectedroutes/ForAdminUsers.tsx b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/protectedroutes/ForAdminUsers.tsx new file mode 100644 index 00000000..26e7c11c --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/protectedroutes/ForAdminUsers.tsx @@ -0,0 +1,27 @@ +import { Outlet } from "react-router-dom"; +import { USER_ROLES } from "../services/auth/AuthTypes"; +import { useAppSelector } from "../store"; +import { useEffect } from "react"; +import useCustomNavigate from "../hooks/useCustomNavigate"; + + +const ForAdminUsers = () => { + const user = useAppSelector((state) => state.auth.userDetails); + const isLogInCheckDone = useAppSelector((state) => state.auth.isLogInCheckDone); + + const navigate = useCustomNavigate(); + + useEffect(() => { + if(isLogInCheckDone && user?.role !== USER_ROLES.admin){ + navigate("/", true); + } + }, [isLogInCheckDone, user?.role, navigate]) + + if(isLogInCheckDone && user && user.role == USER_ROLES.admin){ + return ; + } + + return <> +} + +export default ForAdminUsers; \ No newline at end of file diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/UtilServices.ts b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/UtilServices.ts new file mode 100644 index 00000000..01a57d29 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/UtilServices.ts @@ -0,0 +1,19 @@ +import axios from "axios"; + + +class UtilServices { + + private axiosInstance = axios.create({withCredentials: false}); + + async getImageUrlAsFileObject(url: string, fileName: string){ + try{ + const response = await this.axiosInstance.get(url, {responseType: 'blob'}); + return new File([response.data], fileName); + } + catch(error){ + return new File([], ''); + } + } +} + +export default new UtilServices(); \ No newline at end of file diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/category/CategoryService.ts b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/category/CategoryService.ts index 8f1ed60d..2b49dad3 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/category/CategoryService.ts +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/category/CategoryService.ts @@ -1,3 +1,4 @@ +import { EditCategoryFields } from "../../constants"; import ApiError from "../ApiError"; import ApiRequest from "../ApiRequest"; import ApiResponse from "../ApiResponse"; @@ -10,7 +11,11 @@ class CategoryService { /* Get All Categories Asynchronously: As the requests keep fulfilling response will be sent as callback */ async getAllCategoriesAsync( - callback: (data: Category[], isDone: boolean, errorMessage?: ApiError) => void + callback: ( + data: Category[], + isDone: boolean, + errorMessage?: ApiError + ) => void ) { /* API Request */ const apiRequest = new ApiRequest(this.BASE_URL); @@ -74,6 +79,68 @@ class CategoryService { : callback([], true, new ApiError(firstResponse.message)); } } + + async addCategory(categoryName: string): Promise { + const apiRequest = new ApiRequest(this.BASE_URL); + + const response = await apiRequest.postRequest({ + name: categoryName, + }); + + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } + + async editCategory(fields: EditCategoryFields): Promise { + const apiRequest = new ApiRequest( + `${this.BASE_URL}/${fields.category._id}` + ); + + const response = await apiRequest.patchRequest({ + name: fields.newCategoryName, + }); + + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } + + async deleteCategory( + category: Category + ): Promise | ApiError> { + const apiRequest = new ApiRequest(`${this.BASE_URL}/${category._id}`); + + const response = await apiRequest.deleteRequest<{ + deletedCategory: Category; + }>(); + + if (response instanceof ApiResponse && response.success) { + return response; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } + + async getCategoryById(categoryId: string): Promise { + const apiRequest = new ApiRequest(`${this.BASE_URL}/${categoryId}`); + + const response = await apiRequest.getRequest(); + + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } } export default new CategoryService(); diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/coupon/CouponService.ts b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/coupon/CouponService.ts index e2f2d2c6..45fab486 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/coupon/CouponService.ts +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/coupon/CouponService.ts @@ -1,14 +1,87 @@ +import { AddEditCouponFields, DATE_TIME_FORMATS } from "../../constants"; +import { getMomentObjectFromDateObject } from "../../utils/dateTimeHelper"; import ApiError from "../ApiError"; import ApiRequest from "../ApiRequest"; import ApiResponse from "../ApiResponse"; import { UserCart } from "../cart/CartTypes"; -import { CouponClass, CouponListClass } from "./CouponTypes"; +import { COUPON_TYPES, CouponClass, CouponListClass } from "./CouponTypes"; class CouponService { BASE_URL = "/api/v1/ecommerce/coupons"; defaultPageLimit = 50; defaultPageNumber = 1; + /* Get All Coupons Asynchronously: As the requests keep fulfilling response will be sent as callback */ + async getAllCouponsAsync( + callback: ( + data: CouponClass[], + isDone: boolean, + errorMessage?: ApiError + ) => void + ) { + /* API Request */ + const apiRequest = new ApiRequest(this.BASE_URL); + + /* Initializing Page Number Counter */ + let pageNumberCounter = this.defaultPageNumber; + + /* First Request to know the total pages */ + const firstResponse = await apiRequest.getRequest({ + page: pageNumberCounter, + limit: this.defaultPageLimit, + }); + + /* If the first request is successful */ + if (firstResponse instanceof ApiResponse && firstResponse.success) { + /* Total Number of Pages */ + const totalPages = firstResponse.data.totalPages; + + /* Incrementing page counter */ + pageNumberCounter++; + + /* Number of requests to be made */ + let requestsPending = totalPages - pageNumberCounter + 1; + + /* If first request is the last request: return */ + if (!requestsPending) { + return callback(firstResponse.data.coupons, true); + } else { + callback(firstResponse.data.coupons, false); + } + + /* Remaining requests made in parallel */ + for (let counter = pageNumberCounter; counter <= totalPages; counter++) { + apiRequest + .getRequest({ + page: counter, + limit: this.defaultPageLimit, + }) + .then((res) => { + /* Decrementing pending requests count */ + requestsPending--; + + /* Error in request: Return */ + if (!(res instanceof ApiResponse && res.success)) { + return res instanceof ApiError + ? callback([], true, res) + : callback([], true, new ApiError(res.message)); + } else if (!requestsPending) { + /* All Requests are done */ + return callback(res.data.coupons, true); + } else { + /* Sending the data of an in between request */ + callback(res.data.coupons, false); + } + }); + } + } else { + // Error + return firstResponse instanceof ApiError + ? callback([], true, firstResponse) + : callback([], true, new ApiError(firstResponse.message)); + } + } + /* Get All Coupons Available to User Asynchronously: As the requests keep fulfilling response will be sent as callback */ async getAllCouponsAvailableToUserAsync( callback: ( @@ -105,6 +178,84 @@ class CouponService { return response; } } + + async updateCouponActiveStatus( + couponId: string, + isActive: boolean + ): Promise { + const apiRequest = new ApiRequest(`${this.BASE_URL}/status/${couponId}`); + + const response = await apiRequest.patchRequest({ isActive }); + + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } + + async addCoupon( + fields: AddEditCouponFields + ): Promise { + const apiRequest = new ApiRequest(this.BASE_URL); + + const response = await apiRequest.postRequest({ + name: fields.name, + couponCode: fields.couponCode, + type: COUPON_TYPES.FLAT, + discountValue: fields.discountValue, + minimumCartValue: fields.minimumCartValue, + expiryDate: getMomentObjectFromDateObject(fields.expiryDate).format( + DATE_TIME_FORMATS.standardDateWithTime + ), + startDate: getMomentObjectFromDateObject(fields.startDate).format( + DATE_TIME_FORMATS.standardDateWithTime + ), + }); + + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } + + async editCoupon( + fields: AddEditCouponFields, + couponId: string + ): Promise { + const apiRequest = new ApiRequest(`${this.BASE_URL}/${couponId}`); + + const response = await apiRequest.patchRequest({ + name: fields.name, + couponCode: fields.couponCode, + discountValue: fields.discountValue, + }); + + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } + + async deleteCoupon(couponId: string): Promise { + const apiRequest = new ApiRequest(`${this.BASE_URL}/${couponId}`); + + const response = await apiRequest.deleteRequest<{ + deletedCoupon: CouponClass; + }>(); + + if (response instanceof ApiResponse && response.success) { + return response.data.deletedCoupon; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } } export default new CouponService(); diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/order/OrderService.ts b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/order/OrderService.ts index 746e2df6..b92bcd76 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/order/OrderService.ts +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/order/OrderService.ts @@ -4,12 +4,17 @@ import ApiRequest from "../ApiRequest"; import ApiResponse from "../ApiResponse"; import { GeneratePayPalOrderResponseClass, - OrderDetailClass + OrderClass, + OrderDetailClass, + OrderListClass, } from "./OrderTypes"; +import { ORDER_STATUS } from "../../data/applicationData"; class OrderService { BASE_ORDERS_URL = "/api/v1/ecommerce/orders"; BASE_URL = "/api/v1/ecommerce/orders/provider"; + defaultPageNumber = 1; + defaultPageLimit = 50; async generatePayPalOrder( addressId: string @@ -54,6 +59,83 @@ class OrderService { } return response; } + + async getOrdersAsync( + status: ORDER_STATUS | undefined, + callback: (data: OrderClass[], isDone: boolean, error?: ApiError) => void + ) { + const apiRequest = new ApiRequest(`${this.BASE_ORDERS_URL}/list/admin`); + + let page = this.defaultPageNumber; + + const firstResponse = await apiRequest.getRequest({ + page, + limit: this.defaultPageLimit, + status: status, + }); + + if (firstResponse instanceof ApiResponse && firstResponse.success) { + const totalPages = firstResponse.data.totalPages; + + page++; + let requestsPending = totalPages - page + 1; + + if (!requestsPending) { + return callback(firstResponse.data.orders, true); + } else { + callback(firstResponse.data.orders, false); + } + + for (page; page <= totalPages; page++) { + apiRequest + .getRequest({ + page, + limit: this.defaultPageLimit, + status: status, + }) + .then((res) => { + requestsPending--; + + /* Error in request: Return */ + if (!(res instanceof ApiResponse && res.success)) { + return res instanceof ApiError + ? callback([], true, res) + : callback([], true, new ApiError(res.message)); + } else if (!requestsPending) { + /* All Requests are done */ + return callback(res.data.orders, true); + } else { + /* Sending the data of an in between request */ + callback(res.data.orders, false); + } + }); + } + } else if (firstResponse instanceof ApiResponse) { + return callback([], false, new ApiError(firstResponse.message)); + } else { + return callback([], false, firstResponse); + } + } + + async editOrderStatus( + status: ORDER_STATUS, + orderId: string + ): Promise<{ status: ORDER_STATUS } | ApiError> { + const apiRequest = new ApiRequest( + `${this.BASE_ORDERS_URL}/status/${orderId}` + ); + + const response = await apiRequest.patchRequest<{ status: ORDER_STATUS }>({ + status, + }); + + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } } export default new OrderService(); diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/product/ProductService.ts b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/product/ProductService.ts index 254657f6..8bbc6a97 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/product/ProductService.ts +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/services/product/ProductService.ts @@ -1,3 +1,7 @@ +import { + AddEditProductFieldsForService, + EditProductFieldsForService, +} from "../../constants"; import { generateRandomNumber } from "../../utils/commonHelper"; import ApiError from "../ApiError"; import ApiRequest from "../ApiRequest"; @@ -115,7 +119,12 @@ class ProductService { } async getAllProductsAsync( - callback: (data: Array, isDone: boolean, error?: ApiError, categoryInfo?: {_id: string, name: string}) => void, + callback: ( + data: Array, + isDone: boolean, + error?: ApiError, + categoryInfo?: { _id: string; name: string } + ) => void, categoryId?: string ) { /* @@ -146,9 +155,19 @@ class ProductService { /* If no requests are pending return else send intermediate response */ if (!requestsPending) { - return callback(firstResponse.data.products, true, undefined, firstResponse.data.category); + return callback( + firstResponse.data.products, + true, + undefined, + firstResponse.data.category + ); } else { - callback(firstResponse.data.products, false, undefined, firstResponse.data.category); + callback( + firstResponse.data.products, + false, + undefined, + firstResponse.data.category + ); } for (let counter = page; counter <= totalPages; counter++) { apiRequest @@ -164,10 +183,20 @@ class ProductService { : callback([], true, new ApiError(response.message)); } else if (!requestsPending) { /* All Requests are done */ - return callback(response.data.products, true, undefined, response.data.category); + return callback( + response.data.products, + true, + undefined, + response.data.category + ); } else { /* Sending the data of an in between request */ - callback(response.data.products, false, undefined, response.data.category); + callback( + response.data.products, + false, + undefined, + response.data.category + ); } }); } @@ -218,6 +247,127 @@ class ProductService { return response; } } + + async addProduct( + fields: AddEditProductFieldsForService + ): Promise { + const apiRequest = new ApiRequest(this.BASE_URL); + + /* Form data as body */ + const productFormData = new FormData(); + productFormData.append("name", fields.name); + productFormData.append("description", fields.description); + productFormData.append("price", fields.price.toString()); + productFormData.append("stock", fields.stock.toString()); + productFormData.append("category", fields.category.id as string); + productFormData.append("mainImage", fields.mainImage); + + fields.subImages.forEach((subImage) => { + productFormData.append("subImages", subImage); + }); + + /* API Call */ + const response = await apiRequest.postRequest(productFormData); + + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } + + /* Edit Product */ + async editProduct( + fields: EditProductFieldsForService, + productId: string + ): Promise { + const apiRequest = new ApiRequest(`${this.BASE_URL}/${productId}`); + + /* Form data */ + const formData = new FormData(); + + let key: keyof typeof fields; + for (key in fields) { + const value = fields[key]; + if (value !== undefined) { + /* For string types: name, description */ + + if (typeof value === "string") { + formData.append(key, value); + } + else if (typeof value === "number") { + /* Number types: price, stock */ + + formData.append(key, value.toString()); + } + else if ( + + /* Category */ + "id" in value && + "text" in value && + typeof value.id === "string" + ) + { + /* Dropdown Item */ + formData.append(key, value.id); + } + else if (value instanceof File) { + /* mainImage */ + formData.append(key, value); + } + else if (Array.isArray(value)) { + /* Sub Images */ + value.forEach((subImage) => { + formData.append(key, subImage); + }); + } + } + } + + const response = await apiRequest.patchRequest(formData); + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + + return response; + } + + /* Remove a sub image of the product */ + async removeSubImageOfProduct( + subImageId: string, + productId: string + ): Promise { + const apiRequest = new ApiRequest( + `${this.BASE_URL}/remove/subimage/${productId}/${subImageId}` + ); + + const response = await apiRequest.patchRequest({}); + + if (response instanceof ApiResponse && response.success) { + return response.data; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } + + async deleteProduct(productId: string): Promise { + const apiRequest = new ApiRequest(`${this.BASE_URL}/${productId}`); + + const response = await apiRequest.deleteRequest<{ + deletedProduct: Product; + }>(); + + if (response instanceof ApiResponse && response.success) { + return response.data.deletedProduct; + } else if (response instanceof ApiResponse) { + return new ApiError(response.message); + } + return response; + } } export default new ProductService(); diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/store/UIinfoSlice.ts b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/store/UIinfoSlice.ts new file mode 100644 index 00000000..42b65064 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/store/UIinfoSlice.ts @@ -0,0 +1,17 @@ +import { createSlice } from "@reduxjs/toolkit"; + +const initialState = { + headerHeight: 0, +}; +const UIinfoSlice = createSlice({ + name: "UIinfoSlic", + initialState, + reducers: { + updateHeaderHeight(state, { payload }) { + state.headerHeight = payload; + }, + }, +}); + +export const { updateHeaderHeight } = UIinfoSlice.actions; +export default UIinfoSlice; diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/store/index.ts b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/store/index.ts index 54e3f48e..61a23966 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/store/index.ts +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/store/index.ts @@ -5,6 +5,7 @@ import BreakpointSlice from "./BreakpointSlice"; import AuthSlice from "./AuthSlice"; import ToastMessageSlice from "./ToastMessageSlice"; import CartSlice from "./CartSlice"; +import UIinfoSlice from "./UIinfoSlice"; const store = configureStore({ reducer: { @@ -12,7 +13,8 @@ const store = configureStore({ breakpoint: BreakpointSlice.reducer, auth: AuthSlice.reducer, toastMessage: ToastMessageSlice.reducer, - cart: CartSlice.reducer + cart: CartSlice.reducer, + uiInfo: UIinfoSlice.reducer } }) /* useAppSelector and useAppDispatch for typescript */ diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/styles/Grid.css b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/styles/Grid.css new file mode 100644 index 00000000..b30bd7e7 --- /dev/null +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/styles/Grid.css @@ -0,0 +1,4 @@ +/* Hiding dropdown in the column filter */ +.ag-filter .ag-picker-field { + display: none; +} \ No newline at end of file diff --git a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/utils/dateTimeHelper.ts b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/utils/dateTimeHelper.ts index 4131ea9c..3bad8c98 100644 --- a/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/utils/dateTimeHelper.ts +++ b/examples/apps/ecommerce/web/react-vite-redux-tailwind/src/utils/dateTimeHelper.ts @@ -73,3 +73,38 @@ export const checkIfDateIsInRange = ( } return false; }; + + +/* Grid Date Comparator for filter */ +export const gridDateFilterComparator = (filterLocalDateAtMidnight: Date, cellValue: DATE_TIME_FORMATS.displayedDateWithTime) => { + + const filterValueMoment = moment(filterLocalDateAtMidnight); + const cellValueMoment = moment(cellValue, DATE_TIME_FORMATS.displayedDateWithTime); + if(filterValueMoment.isSame(cellValueMoment, 'date')){ + return 0; + } + else if(filterValueMoment.isAfter(cellValueMoment, 'date')){ + return -1; + } + + return 1; + +} +/* Grid Date Comparator for sorting */ +export const gridDateSortComparator = (dateA: DATE_TIME_FORMATS.displayedDateWithTime, dateB: DATE_TIME_FORMATS.displayedDateWithTime) => { + + const dateAMoment = moment(dateA, DATE_TIME_FORMATS.displayedDateWithTime); + const dateBMoment = moment(dateB, DATE_TIME_FORMATS.displayedDateWithTime); + + if(dateAMoment.isSame(dateBMoment)){ + return 0; + } + else if(dateAMoment.isAfter(dateBMoment)){ + return -1; + } + return 1; +} + +export const getMomentObjectFromDateObject = (date: Date) => { + return moment(date); +} \ No newline at end of file