From d0b0c84a7c672aca22356c7d5fe9ff6c70e9ede0 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Tue, 15 Jul 2025 11:32:35 +0700 Subject: [PATCH 1/4] feat: update .gitignore and restructure imports in __root.tsx --- packages/CourtBooking/.gitignore | 9 +++++++-- packages/CourtBooking/src/routes/__root.tsx | 4 +++- packages/CourtBooking/src/utils/fomater.ts | 6 ++++++ packages/CourtBooking/vite.config.ts | 10 +++++----- 4 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 packages/CourtBooking/src/utils/fomater.ts diff --git a/packages/CourtBooking/.gitignore b/packages/CourtBooking/.gitignore index f3a692bd..4642c7d3 100644 --- a/packages/CourtBooking/.gitignore +++ b/packages/CourtBooking/.gitignore @@ -36,8 +36,13 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store -# Ignore TanStack Router temp -.tanstack/tmp/ +# Nitro +.output/ +.nitro/ + +# TanStack Start +.tanstack/ + # Ignore Vite cache .vite/ diff --git a/packages/CourtBooking/src/routes/__root.tsx b/packages/CourtBooking/src/routes/__root.tsx index b8334dea..6e7a73cd 100644 --- a/packages/CourtBooking/src/routes/__root.tsx +++ b/packages/CourtBooking/src/routes/__root.tsx @@ -6,7 +6,9 @@ import { HeadContent, Scripts, } from '@tanstack/react-router'; -import appCss from '../styles/app.css?url'; +// import '../styles/app.css'; + +import appCss from '../../styles/app.css?url'; import type { ReactNode } from 'react'; export const Route = createRootRoute({ diff --git a/packages/CourtBooking/src/utils/fomater.ts b/packages/CourtBooking/src/utils/fomater.ts new file mode 100644 index 00000000..d39b5292 --- /dev/null +++ b/packages/CourtBooking/src/utils/fomater.ts @@ -0,0 +1,6 @@ +export const fomatter = (number: number) => { + return Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + }).format(number); +}; diff --git a/packages/CourtBooking/vite.config.ts b/packages/CourtBooking/vite.config.ts index e956a80f..f8bb4b87 100644 --- a/packages/CourtBooking/vite.config.ts +++ b/packages/CourtBooking/vite.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; +// import react from '@vitejs/plugin-react'; import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import tailwindcss from '@tailwindcss/vite'; @@ -22,10 +22,10 @@ export default defineConfig({ target: 'react', autoCodeSplitting: true, }), - react({ - jsxRuntime: 'automatic', - babel: {}, - }), + // react({ + // jsxRuntime: 'automatic', + // babel: {}, + // }), tsConfigPaths({ projects: ['./tsconfig.json'], }), From 805bae5b49433e3238b7b73c752dd6b0684db4f1 Mon Sep 17 00:00:00 2001 From: LeThanhNhan91 Date: Tue, 15 Jul 2025 15:13:54 +0700 Subject: [PATCH 2/4] migrating FE: user UI --- .../shared/Validations/Validations.js | 31 + .../shared/Validations/bookingValidation.js | 48 + .../shared/Validations/formValidation.js | 134 ++ .../shared/Validations/reviewValidation.js | 13 + .../src/components/shared/map/DisplayMap.js | 83 ++ .../components/shared/map/GeocoderLocation.js | 26 + .../src/components/shared/map/Geolocation.js | 11 + .../components/shared/map/RoutingMachine.js | 36 + .../shared/requestUserBooking/index.js | 30 + .../shared/requestUserBooking/style.css | 85 ++ .../shared/requestUserLogin/index.jsx | 17 + .../shared/requestUserLogin/style.css | 62 + .../components/shared/theme/footer/index.js | 74 + .../components/shared/theme/footer/style.scss | 120 ++ .../components/shared/theme/header/index.js | 187 +++ .../components/shared/theme/header/style.scss | 108 ++ .../shared/theme/header/styleHeader.scss | 157 ++ .../shared/theme/masterLayout/index.js | 15 + .../src/features/auth/AuthContext.js | 36 + .../auth/components/forgetPass/index.jsx | 109 ++ .../auth/components/forgetPass/style.scss | 130 ++ .../features/auth/components/login/index.js | 529 +++++++ .../auth/components/login/loginTest.scss | 258 ++++ .../auth/components/resetPass/index.jsx | 124 ++ .../src/features/auth/firebase.js | 30 + .../components/Payment/FailurePage.css | 61 + .../components/Payment/LoadingPage.jsx | 25 + .../Payment/PaymentConfirmation.jsx | 152 ++ .../components/Payment/PaymentDetailFixed.jsx | 289 ++++ .../components/Payment/PaymentDetails.jsx | 654 +++++++++ .../components/Payment/PaymentFailed.js | 19 + .../components/Payment/PaymentSuccessful.js | 23 + .../components/Payment/SuccessPage.css | 60 + .../booking/components/Payment/VNPayStep.jsx | 19 + .../booking/components/bookedPage/index.js | 587 ++++++++ .../booking/components/bookedPage/style.scss | 354 +++++ .../booking/components/bookingByDay/index.js | 1299 +++++++++++++++++ .../components/bookingByDay/style.scss | 466 ++++++ .../components/bookingByDay/styles.scss | 15 + .../components/bookingFixDay/CalendarView.jsx | 314 ++++ .../booking/components/bookingFixDay/Fix.jsx | 908 ++++++++++++ .../components/bookingFlex/Flexible.jsx | 303 ++++ .../bookingFlex/FlexibleBooking.jsx | 978 +++++++++++++ .../booking/components/schedulePage/index.js | 7 + .../components/schedulePage/style.scss | 0 .../homePage/SlideShow/SlideShow.js | 46 + .../home/components/homePage/index.js | 489 +++++++ .../home/components/homePage/style.scss | 356 +++++ .../news/components/newsPage/News.css | 44 + .../news/components/newsPage/index.js | 89 ++ .../features/news/components/newsPage/news.js | 19 + .../news/components/newsPage/style.scss | 133 ++ .../components/profilePage/editStyle.scss | 198 +++ .../profile/components/profilePage/index.js | 481 ++++++ .../profile/components/profilePage/style.scss | 123 ++ .../assets/users/images/banner/background.jpg | Bin 0 -> 27870 bytes .../assets/users/images/byday/green_tick.png | Bin 0 -> 81328 bytes .../assets/users/images/byday/pic1.webp | Bin 0 -> 828548 bytes .../assets/users/images/byday/pic2.webp | Bin 0 -> 749698 bytes .../assets/users/images/byday/pic3.webp | Bin 0 -> 714756 bytes .../assets/users/images/byday/pic4.webp | Bin 0 -> 333288 bytes .../assets/users/images/byday/pic5.webp | Bin 0 -> 643032 bytes .../assets/users/images/byday/red_cross.png | Bin 0 -> 3389 bytes .../assets/users/images/categories/cat-1.png | Bin 0 -> 6774 bytes .../assets/users/images/categories/cat-2.png | Bin 0 -> 4766 bytes .../assets/users/images/categories/cat-3.png | Bin 0 -> 1781 bytes .../assets/users/images/categories/cat-4.png | Bin 0 -> 6217 bytes .../assets/users/images/categories/image.png | Bin 0 -> 92366 bytes .../assets/users/images/featured/images.jpg | Bin 0 -> 8987 bytes .../assets/users/images/featured/news.jpg | Bin 0 -> 99585 bytes .../assets/users/images/hero/banner.jpg | Bin 0 -> 40126 bytes .../assets/users/images/hero/banner1.jpg | Bin 0 -> 36382 bytes .../public/assets/users/images/hero/qr.png | Bin 0 -> 14786 bytes .../public/assets/users/images/hero/user.png | Bin 0 -> 12875 bytes 74 files changed, 10964 insertions(+) create mode 100644 packages/CourtBooking/src/components/shared/Validations/Validations.js create mode 100644 packages/CourtBooking/src/components/shared/Validations/bookingValidation.js create mode 100644 packages/CourtBooking/src/components/shared/Validations/formValidation.js create mode 100644 packages/CourtBooking/src/components/shared/Validations/reviewValidation.js create mode 100644 packages/CourtBooking/src/components/shared/map/DisplayMap.js create mode 100644 packages/CourtBooking/src/components/shared/map/GeocoderLocation.js create mode 100644 packages/CourtBooking/src/components/shared/map/Geolocation.js create mode 100644 packages/CourtBooking/src/components/shared/map/RoutingMachine.js create mode 100644 packages/CourtBooking/src/components/shared/requestUserBooking/index.js create mode 100644 packages/CourtBooking/src/components/shared/requestUserBooking/style.css create mode 100644 packages/CourtBooking/src/components/shared/requestUserLogin/index.jsx create mode 100644 packages/CourtBooking/src/components/shared/requestUserLogin/style.css create mode 100644 packages/CourtBooking/src/components/shared/theme/footer/index.js create mode 100644 packages/CourtBooking/src/components/shared/theme/footer/style.scss create mode 100644 packages/CourtBooking/src/components/shared/theme/header/index.js create mode 100644 packages/CourtBooking/src/components/shared/theme/header/style.scss create mode 100644 packages/CourtBooking/src/components/shared/theme/header/styleHeader.scss create mode 100644 packages/CourtBooking/src/components/shared/theme/masterLayout/index.js create mode 100644 packages/CourtBooking/src/features/auth/AuthContext.js create mode 100644 packages/CourtBooking/src/features/auth/components/forgetPass/index.jsx create mode 100644 packages/CourtBooking/src/features/auth/components/forgetPass/style.scss create mode 100644 packages/CourtBooking/src/features/auth/components/login/index.js create mode 100644 packages/CourtBooking/src/features/auth/components/login/loginTest.scss create mode 100644 packages/CourtBooking/src/features/auth/components/resetPass/index.jsx create mode 100644 packages/CourtBooking/src/features/auth/firebase.js create mode 100644 packages/CourtBooking/src/features/booking/components/Payment/FailurePage.css create mode 100644 packages/CourtBooking/src/features/booking/components/Payment/LoadingPage.jsx create mode 100644 packages/CourtBooking/src/features/booking/components/Payment/PaymentConfirmation.jsx create mode 100644 packages/CourtBooking/src/features/booking/components/Payment/PaymentDetailFixed.jsx create mode 100644 packages/CourtBooking/src/features/booking/components/Payment/PaymentDetails.jsx create mode 100644 packages/CourtBooking/src/features/booking/components/Payment/PaymentFailed.js create mode 100644 packages/CourtBooking/src/features/booking/components/Payment/PaymentSuccessful.js create mode 100644 packages/CourtBooking/src/features/booking/components/Payment/SuccessPage.css create mode 100644 packages/CourtBooking/src/features/booking/components/Payment/VNPayStep.jsx create mode 100644 packages/CourtBooking/src/features/booking/components/bookedPage/index.js create mode 100644 packages/CourtBooking/src/features/booking/components/bookedPage/style.scss create mode 100644 packages/CourtBooking/src/features/booking/components/bookingByDay/index.js create mode 100644 packages/CourtBooking/src/features/booking/components/bookingByDay/style.scss create mode 100644 packages/CourtBooking/src/features/booking/components/bookingByDay/styles.scss create mode 100644 packages/CourtBooking/src/features/booking/components/bookingFixDay/CalendarView.jsx create mode 100644 packages/CourtBooking/src/features/booking/components/bookingFixDay/Fix.jsx create mode 100644 packages/CourtBooking/src/features/booking/components/bookingFlex/Flexible.jsx create mode 100644 packages/CourtBooking/src/features/booking/components/bookingFlex/FlexibleBooking.jsx create mode 100644 packages/CourtBooking/src/features/booking/components/schedulePage/index.js create mode 100644 packages/CourtBooking/src/features/booking/components/schedulePage/style.scss create mode 100644 packages/CourtBooking/src/features/home/components/homePage/SlideShow/SlideShow.js create mode 100644 packages/CourtBooking/src/features/home/components/homePage/index.js create mode 100644 packages/CourtBooking/src/features/home/components/homePage/style.scss create mode 100644 packages/CourtBooking/src/features/news/components/newsPage/News.css create mode 100644 packages/CourtBooking/src/features/news/components/newsPage/index.js create mode 100644 packages/CourtBooking/src/features/news/components/newsPage/news.js create mode 100644 packages/CourtBooking/src/features/news/components/newsPage/style.scss create mode 100644 packages/CourtBooking/src/features/profile/components/profilePage/editStyle.scss create mode 100644 packages/CourtBooking/src/features/profile/components/profilePage/index.js create mode 100644 packages/CourtBooking/src/features/profile/components/profilePage/style.scss create mode 100644 packages/CourtBooking/src/public/assets/users/images/banner/background.jpg create mode 100644 packages/CourtBooking/src/public/assets/users/images/byday/green_tick.png create mode 100644 packages/CourtBooking/src/public/assets/users/images/byday/pic1.webp create mode 100644 packages/CourtBooking/src/public/assets/users/images/byday/pic2.webp create mode 100644 packages/CourtBooking/src/public/assets/users/images/byday/pic3.webp create mode 100644 packages/CourtBooking/src/public/assets/users/images/byday/pic4.webp create mode 100644 packages/CourtBooking/src/public/assets/users/images/byday/pic5.webp create mode 100644 packages/CourtBooking/src/public/assets/users/images/byday/red_cross.png create mode 100644 packages/CourtBooking/src/public/assets/users/images/categories/cat-1.png create mode 100644 packages/CourtBooking/src/public/assets/users/images/categories/cat-2.png create mode 100644 packages/CourtBooking/src/public/assets/users/images/categories/cat-3.png create mode 100644 packages/CourtBooking/src/public/assets/users/images/categories/cat-4.png create mode 100644 packages/CourtBooking/src/public/assets/users/images/categories/image.png create mode 100644 packages/CourtBooking/src/public/assets/users/images/featured/images.jpg create mode 100644 packages/CourtBooking/src/public/assets/users/images/featured/news.jpg create mode 100644 packages/CourtBooking/src/public/assets/users/images/hero/banner.jpg create mode 100644 packages/CourtBooking/src/public/assets/users/images/hero/banner1.jpg create mode 100644 packages/CourtBooking/src/public/assets/users/images/hero/qr.png create mode 100644 packages/CourtBooking/src/public/assets/users/images/hero/user.png diff --git a/packages/CourtBooking/src/components/shared/Validations/Validations.js b/packages/CourtBooking/src/components/shared/Validations/Validations.js new file mode 100644 index 00000000..5398e469 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/Validations/Validations.js @@ -0,0 +1,31 @@ +const API_URL = + "https://courtcaller.azurewebsites.net/api/Users?pageNumber=1&pageSize=100"; + +export const validateEmail = async (email) => { + const emailRegex = /[A-Z0-9._%+-]+@[A-Z0-9-]+.+.[A-Z]{2,4}/gim; + + if (!emailRegex.test(email)) { + return { isValid: false, message: "Wrong Email format! (abc@gmail.com)" }; + } + try { + const response = await fetch(API_URL); + if (!response.ok) { + throw new Error("Failed to fetch user detail"); + } + + const users = await response.json(); + const resetEmail = users.data.map((user) => user.email.toLowerCase()); + + if (!resetEmail.includes(email.toLowerCase())) { + return { + isValid: false, + message: "THERE IS NO ACCOUNT WITH THIS EMAIL!", + }; + } + + return { isValid: true, message: "" }; + } catch (error) { + console.error("Error fetching email:", error); + return { isValid: false, message: "Error validating email" }; + } +}; diff --git a/packages/CourtBooking/src/components/shared/Validations/bookingValidation.js b/packages/CourtBooking/src/components/shared/Validations/bookingValidation.js new file mode 100644 index 00000000..8ad8b164 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/Validations/bookingValidation.js @@ -0,0 +1,48 @@ +export const flexValidation = (slots) => { + const slotNumber = Number(slots); + + if (isNaN(slotNumber)) { + return { isValid: false, message: 'You must input a number' }; + } + + if (slotNumber > 10) { + return { isValid: false, message: 'You can only book a maximum of 10 slots each time' }; + } + + return { isValid: true, message: '' }; +}; + +export const fixMonthValidation = (months) => { + if (months > 3) { + return { isValid: false, message: 'You can only book a maximum of 3 months each time' }; + } + + return { isValid: true, message: '' }; +}; + +export const fixStartTimeValidation = (startTime) => { + const timeFormat = /^\d{2}:00:00$/; + + if (!timeFormat.test(startTime)) { + return { isValid: false, message: 'Following the format hh:00:00' }; + } + + return { isValid: true, message: '' }; +}; + +export const fixEndTimeValidation = (startTime, endTime) => { + const timeFormat = /^\d{2}:00:00$/; + + if (!timeFormat.test(endTime)) { + return { isValid: false, message: 'Following the format hh:00:00' }; + } + + const startHour = parseInt(startTime.split(':')[0], 10); + const endHour = parseInt(endTime.split(':')[0], 10); + + if (endHour - startHour !== 1) { + return { isValid: false, message: 'End Time is 1 hour later than Start Time' }; + } + + return { isValid: true, message: '' }; +}; diff --git a/packages/CourtBooking/src/components/shared/Validations/formValidation.js b/packages/CourtBooking/src/components/shared/Validations/formValidation.js new file mode 100644 index 00000000..aa19f131 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/Validations/formValidation.js @@ -0,0 +1,134 @@ +const API_URL = + "https://courtcaller.azurewebsites.net/api/Users?pageNumber=1&pageSize=100"; + +export const validateFullName = (fullName) => { + if (fullName.length >= 6) return { isValid: true, message: "" }; + return { isValid: false, message: "More than 6 characters!" }; +}; + +export const validateUserName = async (userName) => { + if (userName.length < 6) + return { isValid: false, message: "More than 6 character!" }; + + try { + const response = await fetch(API_URL); + if (!response.ok) { + throw new Error("Failed to fetch registered emails"); + } + + const users = await response.json(); + const duplicateUserName = users.data.map((user) => + user.userName.toLowerCase() + ); + + if (duplicateUserName.includes(userName.toLowerCase())) { + return { + isValid: false, + message: "User Name is already exist!", + }; + } + return { isValid: true, message: "" }; + } catch (error) { + console.error("Error fetching userName:", error); + return { isValid: false, message: "Error validating userName" }; + } +}; + +export const validateAddress = (address) => { + if (address.length >= 15) return { isValid: true, message: "" }; + return { isValid: false, message: "More than 15 characters!" }; +}; + +export const validateYob = (yob) => { + const currentYear = new Date().getFullYear(); + + if (yob > currentYear) { + return { + isValid: false, + message: "Year of birth must be less than current year!", + }; + } + + if (yob < currentYear - 100) { + return { isValid: false, message: "Invalid year of birth!" }; + } + + return { isValid: true, message: "" }; +}; + +export const validateEmail = async (email) => { + const emailRegex = /[A-Z0-9._%+-]+@[A-Z0-9-]+.+.[A-Z]{2,4}/gim; + + if (!emailRegex.test(email)) { + return { isValid: false, message: "Wrong Email format! (abc@gmail.com)" }; + } + + // Fetch registered emails from the API + try { + const response = await fetch(API_URL); + if (!response.ok) { + throw new Error("Failed to fetch registered emails"); + } + + const users = await response.json(); + const registeredEmails = users.data.map((user) => user.email.toLowerCase()); + + if (registeredEmails.includes(email.toLowerCase())) { + return { isValid: false, message: "Email is already existed" }; + } + + return { isValid: true, message: "" }; + } catch (error) { + console.error("Error fetching emails:", error); + return { + isValid: false, + message: "Error validating email. Please try again later.", + }; + } +}; + +export const validatePassword = (password) => { + const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\W).{6,}$/; + + if (!passwordRegex.test(password)) { + return { + isValid: false, + message: 'At least 6 characters, include "A, a, @,.."', + }; + } + + return { isValid: true, message: "" }; +}; + +export const validateConfirmPassword = (password, confirmPassword) => { + if (password === confirmPassword) return { isValid: true, message: "" }; + return { isValid: false, message: "Does not match with Password!" }; +}; + +export const validatePhone = (phone) => { + const phoneRegex = /^\d{10,11}$/; + if (phoneRegex.test(phone)) { + return { isValid: true, message: "" }; + } + return { + isValid: false, + message: "Phone must be a number with 10 to 11 digits", + }; +}; + +export const validateTime = (time) => { + const timeRegex = /^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/; + if (timeRegex.test(time)) return { isValid: true, message: "" }; + return { isValid: false, message: "Invalid time format! (hh:mm:ss)" }; +}; + +export const validateRequired = (value) => { + if (value.trim() !== "") return { isValid: true, message: "" }; + return { isValid: false, message: "This field is required" }; +}; + +export const validateNumber = (value) => { + if (!isNaN(value) && value.trim() !== "") + return { isValid: true, message: "" }; + return { isValid: false, message: "Must be a number" }; +}; diff --git a/packages/CourtBooking/src/components/shared/Validations/reviewValidation.js b/packages/CourtBooking/src/components/shared/Validations/reviewValidation.js new file mode 100644 index 00000000..66966d58 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/Validations/reviewValidation.js @@ -0,0 +1,13 @@ +export const reviewTextValidation = (reviewText) => { + if(reviewText.length < 8){ + return {isValid: false, message: 'You need to full fill this box. At least 8 characters!'} + } + return{isValid: true, message: ''} +} + +export const valueValidation = (value) => { + if(value < 1){ + return {isValid: false, message: 'Choose number of stars !'} + } + return{isValid: true, message: ''} +} \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/map/DisplayMap.js b/packages/CourtBooking/src/components/shared/map/DisplayMap.js new file mode 100644 index 00000000..2cd3ee39 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/map/DisplayMap.js @@ -0,0 +1,83 @@ +import React, { useEffect, useState } from 'react'; +import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; +import L from 'leaflet'; +import "leaflet-control-geocoder/dist/Control.Geocoder.css"; +import "leaflet-control-geocoder/dist/Control.Geocoder.js"; +import RoutingMachine from './RoutingMachine'; +import { getGeocodeFromAddress } from './GeocoderLocation'; + +function DisplayMap({ address }) { + // Initialize userPosition and destination as null + const [userPosition, setUserPosition] = useState(null); + const [destination, setDestination] = useState(null); + + useEffect(() => { + if (address) { + getGeocodeFromAddress(address) + .then(result => setDestination(result)) + .catch(error => console.error(error)); + } + }, [address]); + + useEffect(() => { + navigator.geolocation.getCurrentPosition( + (position) => { + setUserPosition([position.coords.latitude, position.coords.longitude]); + }, + () => { + console.error("User location permission denied"); + } + ); + }, []); + + // Default center for the map to prevent white screen when positions are null + const defaultCenter = [10.875376656860935, 106.80076631184579]; + + return ( + + + {userPosition && ( + + Your Location + + )} + {destination && ( + + Branch Location + + )} + + {destination && } + {/* Conditional rendering of RoutingMachine */} + {userPosition && destination && } + + ); +} + +function UpdateMapView({ position }) { + const map = useMap(); + if (position) { + map.flyTo(position, 14); + } + return null; +} + +const destinationIcon = L.icon({ + iconUrl: 'https://cdn0.iconfinder.com/data/icons/small-n-flat/24/678111-map-marker-512.png', + iconSize: [35, 35], + iconAnchor: [17, 35], + popupAnchor: [0, -35] +}); + +const originIcon = L.icon({ + iconUrl: 'https://cdn3.iconfinder.com/data/icons/map-14/144/Map-10-512.png', + iconSize: [60, 60], + iconAnchor: [17, 35], + popupAnchor: [0, -35] +}); + + +export default DisplayMap; diff --git a/packages/CourtBooking/src/components/shared/map/GeocoderLocation.js b/packages/CourtBooking/src/components/shared/map/GeocoderLocation.js new file mode 100644 index 00000000..e9285afd --- /dev/null +++ b/packages/CourtBooking/src/components/shared/map/GeocoderLocation.js @@ -0,0 +1,26 @@ +import L from 'leaflet'; +import 'leaflet-control-geocoder'; // Ensure you have this or a similar plugin + +const getGeocodeFromAddress = async (address) => { + return new Promise((resolve, reject) => { + if (!address) { + reject("Address is required"); + } + + const geocoder = L.Control.Geocoder.nominatim(); + geocoder.geocode(address, (results) => { + if (results.length > 0 && results[0].center) { + const { center } = results[0]; + if (center && 'lat' in center && 'lng' in center) { + resolve(center); // Resolve with the geocode + } else { + reject("Invalid center object"); + } + } else { + reject("Address not found"); + } + }); + }); +}; + +export { getGeocodeFromAddress }; \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/map/Geolocation.js b/packages/CourtBooking/src/components/shared/map/Geolocation.js new file mode 100644 index 00000000..ed74454b --- /dev/null +++ b/packages/CourtBooking/src/components/shared/map/Geolocation.js @@ -0,0 +1,11 @@ +const getUserLocation = () => { + return new Promise((resolve, reject) => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(resolve, reject); + } else { + reject(new Error('Geolocation is not supported by this browser.')); + } + }); +}; + +export default getUserLocation; \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/map/RoutingMachine.js b/packages/CourtBooking/src/components/shared/map/RoutingMachine.js new file mode 100644 index 00000000..a107d2a7 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/map/RoutingMachine.js @@ -0,0 +1,36 @@ +import React, { useEffect } from 'react' +import { useMap } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet-routing-machine'; +import 'leaflet-routing-machine/dist/leaflet-routing-machine.css'; + +function RoutingMachine({ userPosition, branchPosition }) { + const map = useMap(); + + useEffect(() => { + + L.Routing.control({ + waypoints: [L.latLng(userPosition), L.latLng(branchPosition)], + lineOptions:{ + styles:[ + { + color: "blue", + weight: 6, + opacity: 0.7, + }, + ], + }, + routeWhileDragging: false, + geocoder: L.Control.Geocoder.nominatim(), + addWaypoints: false, + fitSelectedRoutes: true, + showAlternatives: true, + draggableWaypoints: false, + createMarker: function() { return null; } // Prevent automatic marker creation + + }).addTo(map); + }, []); + return null; +} + +export default RoutingMachine; \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/requestUserBooking/index.js b/packages/CourtBooking/src/components/shared/requestUserBooking/index.js new file mode 100644 index 00000000..ced9110a --- /dev/null +++ b/packages/CourtBooking/src/components/shared/requestUserBooking/index.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import './style.css'; + +const RequestBooking = () => { + return( +
+
+
+
+ +
+
+

Request Booking! +

+

You need to book first or already have booking(s) in this branch in order to make a review.

+
+
+ +
+
+
+
+
+ ); +}; + +export default RequestBooking; \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/requestUserBooking/style.css b/packages/CourtBooking/src/components/shared/requestUserBooking/style.css new file mode 100644 index 00000000..a5f9e87e --- /dev/null +++ b/packages/CourtBooking/src/components/shared/requestUserBooking/style.css @@ -0,0 +1,85 @@ +.notifications-container { + width: 320px; + height: auto; + font-size: 0.875rem; + line-height: 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; + } + + .flex { + display: flex; + } + + .flex-shrink-0 { + flex-shrink: 0; + } + + .success { + padding: 1rem; + border-radius: 0.375rem; + background-color: #FDEDEC; + } + + .succes-svg { + color: red; + width: 1.25rem; + height: 1.25rem; + } + + .success-prompt-wrap { + margin-left: 0.75rem; + } + + .success-prompt-heading { + font-weight: bold; + color: #A93226; + margin: 0; + } + + .success-prompt-prompt { + margin-top: 0.5rem; + color: #C0392B; + } + + .success-button-container { + display: flex; + margin-top: 0.875rem; + margin-bottom: -0.375rem; + margin-left: -0.5rem; + margin-right: -0.5rem; + } + + .success-button-main { + padding-top: 0.375rem; + padding-bottom: 0.375rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + background-color: #FDEDEC; + color: #A93226; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: bold; + border-radius: 0.375rem; + border: none + } + + .success-button-main:hover { + background-color: #F5B7B1; + } + + .success-button-secondary { + padding-top: 0.375rem; + padding-bottom: 0.375rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + margin-left: 0.75rem; + background-color: #FDEDEC; + color: #A93226; + font-size: 0.875rem; + line-height: 1.25rem; + border-radius: 0.375rem; + border: none; + } + \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/requestUserLogin/index.jsx b/packages/CourtBooking/src/components/shared/requestUserLogin/index.jsx new file mode 100644 index 00000000..167b5a45 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/requestUserLogin/index.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import './style.css'; + +const RequestLogin = () => { + return ( +
+

Login Request.

+

+ You haven't signed in yet. Please log in to fully experience our application. Thanks a lot. +

+ +
+ ); +}; + +export default RequestLogin; diff --git a/packages/CourtBooking/src/components/shared/requestUserLogin/style.css b/packages/CourtBooking/src/components/shared/requestUserLogin/style.css new file mode 100644 index 00000000..cf3dee53 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/requestUserLogin/style.css @@ -0,0 +1,62 @@ +.requestCard { + width: 280px; + height: 250px; + background: linear-gradient(to right,rgb(137, 104, 255),rgb(175, 152, 255)); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 20px; + padding: 0 20px; + border-radius: 20px; + position: relative; + overflow: hidden; + } + + .requestCard::before { + width: 150px; + height: 150px; + content: ""; + background: linear-gradient(to right,rgb(142, 110, 255),rgb(208, 195, 255)); + position: absolute; + z-index: 1; + border-radius: 50%; + right: -25%; + top: -25%; + } + + .requestHeading { + margin: 0; + font-size: 1.9em; + font-weight: 600; + color: rgb(241, 241, 241); + z-index: 2; + } + + .requestDescription { + margin: 0; + font-size: 1.2em; + color: rgb(241, 241, 241); + z-index: 2; + } + + .requestDescription a { + color: rgb(241, 241, 241); + } + + .acceptButton { + padding: 11px 20px; + border-radius: 30px; + background-color: #7b57ff; + transition-duration: .2s; + border: none; + color: rgb(241, 241, 241); + cursor: pointer; + font-weight: 600; + z-index: 2; + } + + .acceptButton:hover { + background-color: #714aff; + transition-duration: .2s; + } \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/theme/footer/index.js b/packages/CourtBooking/src/components/shared/theme/footer/index.js new file mode 100644 index 00000000..6b341618 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/theme/footer/index.js @@ -0,0 +1,74 @@ +import { memo } from "react" +import './style.scss' +import { AiOutlineFacebook, AiOutlineInstagram, AiOutlinePhone } from "react-icons/ai"; +import { FiYoutube, FiTwitter } from "react-icons/fi"; +import { Email } from "@mui/icons-material"; + +const Footer = () => { + return ( +
+
+
+
+
+

Court Caller

+ +
+
+
+
+

About us

+
    +
  • Home Page
  • +
  • Schedule Booking
  • +
  • News
  • +
+ +
    +
  • Booked
  • +
  • Introduction
  • +
  • Service
  • +
+
+
+
+
+

Contact Media

+
+
+ +
+
+

123.465.463

+ Support 24/7 +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ ); +}; + +export default memo(Footer); \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/theme/footer/style.scss b/packages/CourtBooking/src/components/shared/theme/footer/style.scss new file mode 100644 index 00000000..3f3402a9 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/theme/footer/style.scss @@ -0,0 +1,120 @@ +@use "sass:map"; +@import "style/pages/theme"; +@import "style/pages/_all"; + +.footer { +width: 100%; +background: #d0d2d3; +padding-top: 70px; +padding-bottom: 0; + + .footer_about { + margin-bottom: 30px; + + .footer_about_logo { + margin-bottom: 15px; + display: inline-block; + } + + ul li { + font-size: 16px; + color: map.get($theme-colors, "normal-text"); + line-height: 36px; + list-style: none; + } + } + + .footer_widget{ + margin-bottom: 30px; + overflow: hidden; + + h1{ + color: map.get($theme-colors, "normal-text"); + margin-bottom: 10px; + font-weight: 600; + } + + ul{ + float: left; + width: 50%; + li { + font-size: 16px; + color: map.get($theme-colors, "normal-text"); + line-height: 36px; + list-style: none; + } + } + + p { + font-size: 14px; + color: map.get($theme-colors, "normal-text"); + margin-bottom: 30px; + } + + .select_bar_phone{ + display: flex; + align-items: center; + + &_icon { + font-size: 25px; + color: map.get($theme-colors, "main"); + + :hover { + background: map.get($theme-colors, "main"); + color: #ffffff; + border-color: #ffffff; + } + + svg{ + font-size: 20px; + padding: 10px; + color: #404040; + border: 1px solid #ededed; + border-radius: 50%; + transition: all, 0.3s; + background: #ffffff; + } + } + + &_text { + margin-left: 10px; + + p { + margin: 0; + color: map.get($theme-colors, "bold-text"); + font-weight: bold; + } + + span { + font-size: 14px; + color: map.get($theme-colors, "normal-text"); + } + } + } + + .footer_widget_social{ + display: flex; + align-items: center; + column-gap: 10px; + margin-top: 10px; + + div { + :hover { + background: map.get($theme-colors, "main"); + color: #ffffff; + border-color: #ffffff; + } + + svg{ + font-size: 20px; + padding: 10px; + color: #404040; + border: 1px solid #ededed; + border-radius: 50%; + transition: all, 0.3s; + background: #ffffff; + } + } + } +} +} \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/theme/header/index.js b/packages/CourtBooking/src/components/shared/theme/header/index.js new file mode 100644 index 00000000..f2b3ab0f --- /dev/null +++ b/packages/CourtBooking/src/components/shared/theme/header/index.js @@ -0,0 +1,187 @@ +import { memo, useState, useEffect } from "react"; +import "./style.scss"; +import "./styleHeader.scss" +import { AiOutlineUser } from "react-icons/ai"; +import { MdOutlineGridView } from "react-icons/md"; +import { LuHeartHandshake } from "react-icons/lu"; +import { RiLogoutBoxRFill } from "react-icons/ri"; +import { Link } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { ROUTERS } from "utils/router"; +import { Slide, ToastContainer, toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { useAuth } from 'AuthContext'; +import axios from "axios"; +import { jwtDecode } from "jwt-decode"; + +const Header = () => { + const navigate = useNavigate(); + const { logout } = useAuth(); + const [showProfilePopup, setShowProfilePopup] = useState(false); + const [userData, setUserData] = useState(null); + const [user, setUser] = useState(null); + const [userId, setUserId] = useState(null); + const [userName, setUserName] = useState(''); + const [userPic, setUserPic] = useState('') + // console.log("userData", userData) + // console.log("user", user) + + useEffect(() => { + const token = localStorage.getItem("token"); + + if (token) { + const decoded = jwtDecode(token); + setUserName(decoded.name) + setUserPic(decoded.picture) + + const fetchUserData = async (id, isGoogle) => { + try { + if (isGoogle) { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/GetUserDetailByUserEmail/${id}` + ); + setUserData(response.data); + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/GetUserDetailByUserEmail/${id}?searchValue=${id}` + ); + setUser(userResponse.data); + } else { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/${id}` + ); + setUserData(response.data); + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/${id}` + ); + setUser(userResponse.data); + } + } catch (error) { + console.error("Error fetching user data:", error); + } + }; + + if (decoded.iss !== "https://accounts.google.com") { + const userId = decoded.Id; + setUserId(userId); + fetchUserData(userId, false); + } else { + const userId = decoded.email; + setUserId(userId); + fetchUserData(userId, true); + } + } + }, []); + + const [menus] = useState([ + { + name: "Home", + path: ROUTERS.USER.HOME, + }, + // { + // name: "Schedule Booking", + // path: ROUTERS.USER.SCHEDULEPAGE, + // }, + { + name: "News", + path: ROUTERS.USER.NEWS, + }, + { + name: "Booked", + path: ROUTERS.USER.BOOKED, + } + ]); + + const handleLogout = () => { + logout(); + navigate(ROUTERS.USER.LOGIN); + }; + + const toggleProfilePopup = () => { + setShowProfilePopup(!showProfilePopup); + }; + + return ( + <> +
+
+
+
+

Court Caller

+
+
+
+ +
+ +
+
+
    + {user ? ( +
  • +
    + +
    +
    +
    +
    + Avatar +
    +

    {user.userName}

    +

    Balance: {userData.balance}

    +
    +
      +
    • + + View profile +
    • + +
    • + + Log out +
    • +
    +
    +
  • + ) : ( +
  • + + + + Log In + +
  • + )} +
+
+
+
+
+ + ); +}; + +export default memo(Header); diff --git a/packages/CourtBooking/src/components/shared/theme/header/style.scss b/packages/CourtBooking/src/components/shared/theme/header/style.scss new file mode 100644 index 00000000..e1505c90 --- /dev/null +++ b/packages/CourtBooking/src/components/shared/theme/header/style.scss @@ -0,0 +1,108 @@ +@use "sass:map"; +@import "style/pages/theme"; + + +$bannerImg: "/assets/users/images/hero/banner.jpg"; + + +.container{ + .header_logo { + padding: 15px 0; + } + + .header_menu { + padding: 24px 0; + + ul { + display: flex; + justify-content: space-evenly; + + li { + list-style: none; + position: relative; + + a { + text-decoration: none; + font-size: 14px; + color: map.get($theme-colors, "bold-text"); + text-transform: uppercase; + font-weight: bold; + letter-spacing: 2px; + transition: all 0.3s; + padding: 5px 0; + display: block; + } + a:hover{ + color: #008874; + } + } + + li:hover .header_menu_dropdown { + opacity: 1; + visibility: visible; + } + + .active a { + color: map.get($theme-colors, "main"); + } + + .header_menu_dropdown { + background: #222222; + display: block; + position: absolute; + width: 180px; + z-index: 9; + padding: 5px 0; + transition: all, 0.3s; + opacity: 0; + visibility: hidden; + + li { + a { + text-transform: capitalize; + color: #ffffff; + font-weight: 400; + padding: 5px 15px; + } + + :hover { + color: map.get($theme-colors, "main"); + } + } + } + } + } + + .header_login { + display: flex; + justify-content: end; // can giua + align-items: center; //vi tri tren duoi + padding: 24px 0; + + ul { + display: flex; + column-gap: 5px; + list-style: none; + + li { + display: flex; + align-items: center; + + a, span { + text-decoration: none; + font-size: 20px; + margin-left: 10px; + cursor: pointer; + } + + a { + display: inline-block; + color: map.get($theme-colors, "normal-text"); + } + } + } + + } +} + + diff --git a/packages/CourtBooking/src/components/shared/theme/header/styleHeader.scss b/packages/CourtBooking/src/components/shared/theme/header/styleHeader.scss new file mode 100644 index 00000000..3fd53fae --- /dev/null +++ b/packages/CourtBooking/src/components/shared/theme/header/styleHeader.scss @@ -0,0 +1,157 @@ +.profile-section { + position: relative; + + .profile-button { + display: flex; + align-items: center; + cursor: pointer; + + img { + height: 40px; + width: 40px; + border-radius: 50%; + } + + span { + margin-left: 10px; + font-size: 20px; + } + .button2 { + display: inline-block; + transition: all 0.2s ease-in; + position: relative; + overflow: hidden; + z-index: 1; + color: #fff; + padding: 0.7em 1.7em; + cursor: pointer; + font-size: 18px; + border-radius: 0.5em; + background: #008874; + border: 1px solid #e8e8e8; + //box-shadow: 6px 6px 12px #a3ffaa, -6px -6px 12px #c4f9c5; + } + + .button2:active { + color: #666; + box-shadow: inset 4px 4px 12px #c5c5c5, inset -4px -4px 12px #ffffff; + } + + .button2:before { + content: ""; + position: absolute; + left: 50%; + transform: translateX(-50%) scaleY(1) scaleX(1.25); + top: 100%; + width: 140%; + height: 180%; + background-color: rgba(0, 0, 0, 0.05); + border-radius: 50%; + display: block; + transition: all 0.5s 0.1s cubic-bezier(0.55, 0, 0.1, 1); + z-index: -1; + } + + .button2:after { + content: ""; + position: absolute; + left: 55%; + transform: translateX(-50%) scaleY(1) scaleX(1.45); + top: 180%; + width: 160%; + height: 190%; + background-color: #04f86a; + border-radius: 50%; + display: block; + transition: all 0.5s 0.1s cubic-bezier(0.55, 0, 0.1, 1); + z-index: -1; + } + + .button2:hover { + color: #ffffff; + border: 1px solid #04f86a; + box-shadow: 6px 6px 12px #a3ffaa, -6px -6px 12px #c4f9c5; + } + + .button2:hover:before { + top: -35%; + background-color: #04f86a; + transform: translateX(-50%) scaleY(1.3) scaleX(0.8); + } + + .button2:hover:after { + top: -45%; + background-color: #04f86a; + transform: translateX(-50%) scaleY(1.3) scaleX(0.8); + } + + } + + .profile-popup { + position: absolute; + top: 50px; + right: 0; + width: 300px; + background: white; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + border-radius: 10px; + padding: 10px; + z-index: 1000; + display: none; + + &.active { + display: block; + } + + .profile-info { + background-color: #008874; + border-radius: 10px; + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + + .profile-pic { + height: 50px; + width: 50px; + border-radius: 50%; + margin-bottom: 10px; + + img{ + height: 50px; + border: solid #2EF516; + border-radius: 50%; + width: 50px; + margin-top: 5px; + } + } + + p { + font-size: 14px; + color: #fff; + } + } + + .profile-actions { + display: block; + list-style: none; + padding: 0; + margin: 10px 0; + + li { + padding: 10px 0; + cursor: pointer; + + &:hover { + background: #f5f5f5; + } + + a { + text-decoration: none; + color: #333; + } + } + } + } +} \ No newline at end of file diff --git a/packages/CourtBooking/src/components/shared/theme/masterLayout/index.js b/packages/CourtBooking/src/components/shared/theme/masterLayout/index.js new file mode 100644 index 00000000..29d8f76e --- /dev/null +++ b/packages/CourtBooking/src/components/shared/theme/masterLayout/index.js @@ -0,0 +1,15 @@ +import { memo } from "react" +import Header from "../header"; +import Footer from "../footer"; + +const masterLayout = ({ children, ...props }) =>{ + return ( +
+
+ {children} +
+
+ ); +}; + +export default memo(masterLayout); \ No newline at end of file diff --git a/packages/CourtBooking/src/features/auth/AuthContext.js b/packages/CourtBooking/src/features/auth/AuthContext.js new file mode 100644 index 00000000..c440b396 --- /dev/null +++ b/packages/CourtBooking/src/features/auth/AuthContext.js @@ -0,0 +1,36 @@ +// src/AuthContext.js +import React, { createContext, useContext, useState, useEffect } from 'react'; + +const AuthContext = createContext(); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + + useEffect(() => { + // Lấy thông tin người dùng từ localStorage nếu có + const storedUser = JSON.parse(localStorage.getItem('user')); + if (storedUser) { + setUser(storedUser); + } + }, []); + + const login = (userData) => { + localStorage.setItem('user', JSON.stringify(userData)); + setUser(userData); + }; + + const logout = () => { + localStorage.removeItem('user'); + localStorage.removeItem('token') + localStorage.removeItem('userRole'); + setUser(null); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); diff --git a/packages/CourtBooking/src/features/auth/components/forgetPass/index.jsx b/packages/CourtBooking/src/features/auth/components/forgetPass/index.jsx new file mode 100644 index 00000000..90fafba6 --- /dev/null +++ b/packages/CourtBooking/src/features/auth/components/forgetPass/index.jsx @@ -0,0 +1,109 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import axios from "axios"; +import { forgetPassword } from "api/userApi"; +import "./style.scss"; +import ClipLoader from "react-spinners/ClipLoader"; +import { validateEmail } from "../Validations/Validations"; + +const ForgetPassword = () => { + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [message, setMessage] = useState(""); + const [messageType, setMessageType] = useState(""); + const [loading, setLoading] = useState(false); + + const [emailValidation, setEmailValidation] = useState({ + isValid: true, + message: "", + }); + + const handleSubmit = async (event) => { + event.preventDefault(); + + const emailValidation = await validateEmail(email); + + setEmailValidation(emailValidation); + + if (!emailValidation.isValid) { + setMessage("Please try again"); + setMessageType("error"); + return; + } + + setLoading(true); + + try { + const response = await forgetPassword(email); + if (response.success) { + setSuccess(response.message); + setError(""); + } else { + setError(response.message); + setSuccess(""); + } + } catch (error) { + console.log("error in forget password", error); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
Forget Password
+ {error &&

{error}

} + {success && ( +

+ {success} +

+ )} +
+
+

Email

+ setEmail(e.target.value)} + required="Please enter your email" + /> + {emailValidation.message && ( +

{emailValidation.message}

+ )} +
+ +
+ + <> +

+ Don't have an account? + + + {" "} + Sign up now + + +

+ +
+
+
+ ); +}; + +export default ForgetPassword; diff --git a/packages/CourtBooking/src/features/auth/components/forgetPass/style.scss b/packages/CourtBooking/src/features/auth/components/forgetPass/style.scss new file mode 100644 index 00000000..dd09178e --- /dev/null +++ b/packages/CourtBooking/src/features/auth/components/forgetPass/style.scss @@ -0,0 +1,130 @@ +.forgot-box { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + // background: linear-gradient(to right, #ececec, #ABEBC6); + background-image: url("../../../assets/users/images/banner/background.jpg"); + background-position: center; + background-repeat: no-repeat; + background-size: cover; +} + +.forgot-container { + max-width: 350px; + width: 100%; + background-color: #fff; + padding: 32px 24px; + font-size: 14px; + font-family: inherit; + color: #212121; + display: flex; + flex-direction: column; + gap: 20px; + box-sizing: border-box; + border-radius: 10px; + box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.084), 0px 2px 3px rgba(0, 0, 0, 0.168); +} + +.forgot-container button:active { + scale: 0.95; +} + +.forgot-container .forgot-title { + text-align: center; + font-weight: 600; + font-size: 27px; + margin: 20px 0 50px; +} + +.forgot-container .forgot-form { + display: flex; + flex-direction: column; +} + +.forgot-container .form-group { + display: contents; +} + +.forgot-container .form-group label { + display: block; + margin-bottom: 5px; +} + +.forgot-container .form-group .forgot-input { + padding: 12px 16px; + margin-bottom: 20px; + border-radius: 6px; + font-family: inherit; + border: 1px solid #ccc; +} + +.forgot-container .form-group .forgot-input::placeholder { + opacity: 0.5; +} + +.forgot-container .form-group .forgot-input:focus { + outline: none; + border-color: #1778f2; +} + +.forgot-container .forgot-submit-btn { + display: flex; + justify-content: center; + align-items: center; + font-family: inherit; + color: #fff; + background-color: #28b463; + border: none; + width: 100%; + padding: 12px 16px; + font-size: inherit; + gap: 8px; + margin: 0 0 30px; + cursor: pointer; + border-radius: 6px; + box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.084), 0px 2px 3px rgba(0, 0, 0, 0.168); +} + +.forgot-container .forgot-submit-btn:hover { + background-color: #04f86a; +} + +.forgot-container .link { + color: #1778f2; + text-decoration: none; +} + +.forgot-container .signup-link { + align-self: center; + font-weight: 500; +} + +.forgot-container .signup-link .link { + font-weight: 400; +} + +.forgot-container .link:hover { + text-decoration: underline; +} + +.error-input { + box-shadow: 1px 1px 5px 0 red; + padding: 12px 16px; + margin-bottom: 10px; + border-radius: 6px; + font-family: inherit; + border: none; +} + +.errorVal { + color: red; + font-size: 12px; + font-weight: 600; + margin: 10px 0; + text-transform: none; +} diff --git a/packages/CourtBooking/src/features/auth/components/login/index.js b/packages/CourtBooking/src/features/auth/components/login/index.js new file mode 100644 index 00000000..d106e02e --- /dev/null +++ b/packages/CourtBooking/src/features/auth/components/login/index.js @@ -0,0 +1,529 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import "./loginTest.scss"; +import { FaGoogle, FaFacebookF } from "react-icons/fa"; +import { loginApi } from "api/usersApi"; +import { + validateFullName, + validateEmail, + validatePassword, + validateConfirmPassword, +} from "../Validations/formValidation"; +import axios from "axios"; +import { toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { GoogleLogin } from "@react-oauth/google"; +import ClipLoader from "react-spinners/ClipLoader"; +import { ROUTERS } from "utils/router"; +import { FacebookAuthProvider, signInWithPopup } from "firebase/auth"; +import { auth } from "firebase.js"; +import { useAuth } from "AuthContext.js"; +import { jwtDecode } from "jwt-decode"; +import { fetchUserById, fetchRoleByUserId } from "api/userApi"; + +const Login = () => { + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [fullName, setFullName] = useState(""); + const [email, setEmail] = useState(""); + const [isLogin, setIsLogin] = useState(true); + const [message, setMessage] = useState(""); + const [messageType, setMessageType] = useState(""); + const [loading, setLoading] = useState(false); + const [profile, setProfile] = useState(null); + + const [fullNameValidation, setFullNameValidation] = useState({ + isValid: true, + message: "", + }); + const [emailValidation, setEmailValidation] = useState({ + isValid: true, + message: "", + }); + const [passwordValidation, setPasswordValidation] = useState({ + isValid: true, + message: "", + }); + const [confirmPasswordValidation, setConfirmPasswordValidation] = useState({ + isValid: true, + message: "", + }); + + const navigate = useNavigate(); + const { login } = useAuth(); + + useEffect(() => { + const container = document.getElementById("container"); + const registerBtn = document.getElementById("register"); + const loginBtn = document.getElementById("login"); + + const addActiveClass = () => container.classList.add("active"); + const removeActiveClass = () => container.classList.remove("active"); + + if (registerBtn && loginBtn) { + registerBtn.addEventListener("click", addActiveClass); + loginBtn.addEventListener("click", removeActiveClass); + } + + return () => { + if (registerBtn && loginBtn) { + registerBtn.removeEventListener("click", addActiveClass); + loginBtn.removeEventListener("click", removeActiveClass); + } + }; + }, []); + + const handleLogin = async (e) => { + e.preventDefault(); + + if (!email || !password) { + toast.error("Email/Password is required!"); + return; + } + + setLoading(true); + try { + const res = await loginApi(email, password); + if (res && res.token) { + localStorage.setItem("token", res.token); + console.log("token: ", res.token); + var decode = jwtDecode(res.token); + localStorage.setItem("userRole", decode.role); + const userData = { + email: decode.email, + role: decode.role, + }; + login(userData); // Lưu thông tin người dùng vào context + navigate(ROUTERS.USER.HOME); + } else if (res && res.status === 401) { + setMessage("Login failed!"); + setMessageType("error"); + } else if ( + res && + res.data.status === "Error" && + res.data.message == "User is banned!" + ) { + toast.error("This account is banned!", { + position: "top-right", + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + theme: "colored", + }); + return; + } + } catch (error) { + setMessage("Login failed!"); + setMessageType("error"); + } finally { + setLoading(false); + } + }; + + const handleRegister = async (e) => { + e.preventDefault(); + + const fullNameValidation = validateFullName(fullName); + const emailValidation = await validateEmail(email); + const passwordValidation = validatePassword(password); + const confirmPasswordValidation = validateConfirmPassword( + password, + confirmPassword + ); + + setFullNameValidation(fullNameValidation); + setEmailValidation(emailValidation); + setPasswordValidation(passwordValidation); + setConfirmPasswordValidation(confirmPasswordValidation); + + if ( + !fullNameValidation.isValid || + !emailValidation.isValid || + !passwordValidation.isValid || + !confirmPasswordValidation.isValid + ) { + setMessage("Please try again"); + setMessageType("error"); + return; + } + + setLoading(true); + + try { + const response = await axios.post( + "https://courtcaller.azurewebsites.net/api/authentication/register", + { + fullName: fullName, + email: email, + password: password, + confirmPassword: confirmPassword, + } + ); + toast.success("Registration successful!"); + setMessage("SIGN UP SUCCESSFULLY - LOG IN NOW"); + setMessageType("success"); + setIsLogin(true); + } catch (error) { + if (error.response) { + toast.error(error.response.data.message || "Registration failed"); + } else if (error.request) { + toast.error("No response from server"); + } else { + toast.error(error.message); + } + } finally { + setLoading(false); + } + }; + + const loginGoogle = async (response) => { + var token = response.credential; + console.log("Google Token:", token); + + try { + const res = await fetch( + "https://courtcaller.azurewebsites.net/api/authentication/google-login?token=" + + token, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + } + ); + + const data = await res.json(); + + if (res.ok) { + localStorage.setItem("token", data.token); + localStorage.setItem("ggToken", token); + var decode = jwtDecode(data.token); + localStorage.setItem("userRole", decode.role); + const userData = { + email: decode.email, + role: decode.role, + }; + login(userData); + navigate(ROUTERS.USER.HOME); + } else { + console.error("Backend error:", data); + toast.error("Login Failed"); + throw new Error(data.message || "Google login failed"); + } + } catch (error) { + console.error("Error during login:", error); + toast.error("Login Failed"); + } + }; + + const loginFacebook = async (response) => { + try { + const provider = new FacebookAuthProvider(); + const result = await signInWithPopup(auth, provider); + + const accessToken = result.user.stsTokenManager.accessToken; + + console.log("Login successfully", result.user); + console.log("Access Token:", accessToken); + + const res = await fetch( + "https://courtcaller.azurewebsites.net/api/authentication/facebook-login?token=" + + accessToken, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + } + ); + + const data = await res.json(); + + if (res.ok) { + console.log("Login successful:", data); + localStorage.setItem("token", accessToken); + var decode = jwtDecode(accessToken); + + const userData = { + email: decode.email, + }; + login(userData); + navigate(ROUTERS.USER.HOME); + } else { + console.error("Backend error:", data); + toast.error("Login Failed"); + } + } catch (error) { + console.error("Error:", error.message); + toast.error("Facebook login failed"); + } + }; + + return ( +
+
+ {/* Sign In Form */} +
+ {isLogin && ( +
+
+

LOG IN

+ +
+ { + console.log("Login Failed"); + }} + /> + +
+ + + or use your account for login + + + setEmail(e.target.value)} + required + className={`w-full bg-gray-100 border-none rounded-lg px-4 py-3 text-sm mb-2 outline-none ${ + !emailValidation.isValid ? "shadow-red-500 shadow-md" : "" + }`} + /> + {emailValidation.message && ( +

+ {emailValidation.message} +

+ )} + + setPassword(e.target.value)} + required + className={`w-full bg-gray-100 border-none rounded-lg px-4 py-3 text-sm mb-2 outline-none ${ + !passwordValidation.isValid + ? "shadow-red-500 shadow-md" + : "" + }`} + /> + {passwordValidation.message && ( +

+ {passwordValidation.message} +

+ )} + +
+ Forgot Password? +
+ + + + {message && ( +

+ {message} +

+ )} +
+
+ )} +
+ + {/* Sign Up Form */} +
+ {!isLogin && ( +
+
+

Create Account

+ + + or use your email for registration + + + setFullName(e.target.value)} + required + className={`w-full bg-gray-100 border-none rounded-lg px-4 py-3 text-sm mb-2 outline-none ${ + !fullNameValidation.isValid + ? "shadow-red-500 shadow-md" + : "" + }`} + /> + {fullNameValidation.message && ( +

+ {fullNameValidation.message} +

+ )} + + setEmail(e.target.value)} + required + className={`w-full bg-gray-100 border-none rounded-lg px-4 py-3 text-sm mb-2 outline-none ${ + !emailValidation.isValid ? "shadow-red-500 shadow-md" : "" + }`} + /> + {emailValidation.message && ( +

+ {emailValidation.message} +

+ )} + + setPassword(e.target.value)} + required + className={`w-full bg-gray-100 border-none rounded-lg px-4 py-3 text-sm mb-2 outline-none ${ + !passwordValidation.isValid + ? "shadow-red-500 shadow-md" + : "" + }`} + /> + {passwordValidation.message && ( +

+ {passwordValidation.message} +

+ )} + + setConfirmPassword(e.target.value)} + required + className={`w-full bg-gray-100 border-none rounded-lg px-4 py-3 text-sm mb-2 outline-none ${ + !confirmPasswordValidation.isValid + ? "shadow-red-500 shadow-md" + : "" + }`} + /> + {confirmPasswordValidation.message && ( +

+ {confirmPasswordValidation.message} +

+ )} + + + + {message && ( +

+ {message} +

+ )} +
+
+ )} +
+ + {/* Toggle Container */} +
+
+ {/* Toggle Left Panel */} +
+

+ badminton is joy +

+

+ Enter your username & password to schedule now!! +

+ +
+ + {/* Toggle Right Panel */} +
+

+ badminton is life +

+

+ Register with your personal details to use all of the site + features!! +

+ +
+
+
+
+
+ ); +}; + +export default Login; diff --git a/packages/CourtBooking/src/features/auth/components/login/loginTest.scss b/packages/CourtBooking/src/features/auth/components/login/loginTest.scss new file mode 100644 index 00000000..a83e2912 --- /dev/null +++ b/packages/CourtBooking/src/features/auth/components/login/loginTest.scss @@ -0,0 +1,258 @@ + @import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap'); + + .login-component { + * { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Montserrat', sans-serif; + } + + + background: linear-gradient(to right, #ececec, #ABEBC6); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + height: 100vh; + + + .container { + background-color: #fff; + border-radius: 30px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.35); + position: relative; + overflow: hidden; + width: 800px; + max-width: 80%; + min-height: 520px; + } + + .toggle p { + font-size: 14px; + line-height: 20px; + letter-spacing: 0.3px; + margin: 20px 0; + } + + .container span { + font-size: 13px; + } + + .container a { + color: #777777; + font-size: 13px; + text-decoration: none; + margin: 15px 0 10px; + } + + .container a:hover{ + color: blue; + text-decoration: underline; + } + + .signInBtn { + background-color: #28B463; + } + + .signUpBtn { + background-color: #3498DB; + } + + .container button { + color: #fff; + font-size: 12px; + padding: 10px 45px; + border: 1px solid transparent; + border-radius: 8px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + margin-top: 10px; + cursor: pointer; + } + + .container button.hidden { + background-color: transparent; + border-color: #fff; + } + + .container form { + background-color: #fff; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 0 40px; + height: 100%; + } + + .container input { + background-color: #eee; + border: none; + margin: 8px 0; + padding: 10px 15px; + font-size: 13px; + border-radius: 8px; + width: 100%; + outline: none; + } + + .form-container { + position: absolute; + top: 0; + height: 100%; + transition: all 0.6s ease-in-out; + } + + .sign-in { + left: 0; + width: 50%; + z-index: 2; + } + + .container.active .sign-in { + transform: translateX(100%); + } + + .sign-up { + left: 0; + width: 50%; + opacity: 0; + z-index: 1; + } + + .container.active .sign-up { + transform: translateX(100%); + opacity: 1; + z-index: 5; + animation: move 0.6s; + } + + @keyframes move { + 0%, 49.99% { + opacity: 0; + z-index: 1; + } + 50%, 100% { + opacity: 1; + z-index: 5; + } + } + + .social-icons { + margin: 20px 0; + } + + .social-icons a { + background-color: #2067f5; + border: 1px solid #ccc; + border-radius: 5px; + display: inline-flex; + justify-content: flex-start; + align-items: center; + margin: 10px 3px; + padding: 5px; + width: 260px; + height: 40px; + cursor: pointer; + } + + .toggle-container { + position: absolute; + top: 0; + left: 50%; + width: 50%; + height: 100%; + overflow: hidden; + transition: all 0.6s ease-in-out; + border-radius: 150px 0 0 100px; + z-index: 1000; + } + + .toggle-container h1, p{ + text-transform: uppercase; + } + + .container.active .toggle-container { + transform: translateX(-100%); + border-radius: 0 150px 100px 0; + } + + .toggle { + background-color: #008874; + height: 100%; + background: linear-gradient(to right, #5DADE2, #2ECC71); + color: #fff; + position: relative; + left: -100%; + height: 100%; + width: 200%; + transform: translateX(0); + transition: all 0.6s ease-in-out; + } + + .container.active .toggle { + transform: translateX(50%); + } + + .toggle-panel { + position: absolute; + width: 50%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 0 30px; + text-align: center; + top: 0; + transform: translateX(0); + transition: all 0.6s ease-in-out; + } + + .toggle-left { + transform: translateX(-200%); + } + + .container.active .toggle-left { + transform: translateX(0); + } + + .toggle-right { + right: 0; + transform: translateX(0); + } + + .container.active .toggle-right { + transform: translateX(200%); + } + + .error { + color: red; + text-align: center; + margin-top: 20px; + } + + .success { + color: green; + text-align: center; + margin-top: 20px; + } + + .error-input { + box-shadow: 1px 1px 5px 0 red; + } + + .container .errorVal { + color: red; + font-size: 10px; + font-weight: 600; + margin: 0; + text-transform: none; + } + + .success-input { + box-shadow: 1px 1px 5px 0 #58D68D; + } + } \ No newline at end of file diff --git a/packages/CourtBooking/src/features/auth/components/resetPass/index.jsx b/packages/CourtBooking/src/features/auth/components/resetPass/index.jsx new file mode 100644 index 00000000..541416f1 --- /dev/null +++ b/packages/CourtBooking/src/features/auth/components/resetPass/index.jsx @@ -0,0 +1,124 @@ +import React, { useState } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import ClipLoader from "react-spinners/ClipLoader"; +import { toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { resetPassword } from "api/userApi"; +import { + validatePassword, + validateConfirmPassword, +} from "../Validations/formValidation"; + +const ResetPassword = () => { + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [message, setMessage] = useState(""); + const [messageType, setMessageType] = useState(""); + const [loading, setLoading] = useState(false); + + const navigate = useNavigate(); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const token = queryParams.get("token"); + const email = queryParams.get("email"); + + const [passwordValidation, setPasswordValidation] = useState({ + isValid: true, + message: "", + }); + const [confirmPasswordValidation, setConfirmPasswordValidation] = useState({ + isValid: true, + message: "", + }); + + const handleResetPassword = async (e) => { + e.preventDefault(); + + const passwordValidation = validatePassword(password); + const confirmPasswordValidation = validateConfirmPassword(confirmPassword); + + setPasswordValidation(passwordValidation); + setConfirmPasswordValidation(confirmPasswordValidation); + + if (!passwordValidation.isValid || !confirmPasswordValidation.isValid) { + setMessage("Please try again"); + setMessageType("error"); + return; + } + + setLoading(true); + + try { + const response = await resetPassword( + email, + token, + password, + confirmPassword + ); + if (!response.success) { + throw new Error(response.message || "Something went wrong"); + } + + toast.success(response.message || "Password reset successfully!"); + navigate("/login"); // Chuyển hướng người dùng đến trang đăng nhập sau khi reset password thành công + } catch (error) { + toast.error(error.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
RESET PASSWORD
+
+

+ Enter your new password +

+ setPassword(e.target.value)} + required + /> + {passwordValidation.message && ( +

{passwordValidation.message}

+ )} + setConfirmPassword(e.target.value)} + required + /> + {confirmPasswordValidation.message && ( +

{confirmPasswordValidation.message}

+ )} +
+ +
+
+
+
+ ); +}; + +export default ResetPassword; diff --git a/packages/CourtBooking/src/features/auth/firebase.js b/packages/CourtBooking/src/features/auth/firebase.js new file mode 100644 index 00000000..22409ac6 --- /dev/null +++ b/packages/CourtBooking/src/features/auth/firebase.js @@ -0,0 +1,30 @@ +// Import the functions you need from the SDKs you need +import { getStorage } from "firebase/storage"; +import firebase from 'firebase/compat/app'; +import 'firebase/compat/auth'; +import 'firebase/compat/firestore'; + +// TODO: Add SDKs for Firebase products that you want to use +// https://firebase.google.com/docs/web/setup#available-libraries + +// Your web app's Firebase configuration +// For Firebase JS SDK v7.20.0 and later, measurementId is optional +const firebaseConfig = { + apiKey: "AIzaSyDAPz7GMnppP018Kpm20stKsAdbHOcJHc4", + authDomain: "court-callers.firebaseapp.com", + projectId: "court-callers", + storageBucket: "court-callers.appspot.com", + messagingSenderId: "48866245430", + appId: "1:48866245430:web:a9eb7ef7e76077765c5197", + measurementId: "G-KEMR387K1Q" +}; + +// Use this to initialize the firebase App +const firebaseApp = firebase.initializeApp(firebaseConfig); +// Use these for db & auth +const db = firebaseApp.firestore(); +const auth = firebase.auth(); + +const storageDb = getStorage(firebaseApp) + +export {firebaseApp, auth, storageDb} \ No newline at end of file diff --git a/packages/CourtBooking/src/features/booking/components/Payment/FailurePage.css b/packages/CourtBooking/src/features/booking/components/Payment/FailurePage.css new file mode 100644 index 00000000..1aebd738 --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/Payment/FailurePage.css @@ -0,0 +1,61 @@ +*{ + padding: 0; + margin: 0; +} +.failed-container{ + width: 100%; + height: 100vh; + background: linear-gradient(to right, #dedddd, #f3b1ab); + display: flex; + align-items: center; + justify-content: center; +} + +.failed-box{ + width: 301px; + background-color: #fff; + border-radius: 6px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + padding: 0 30px 30px; + color: #333; +} + +.failed-box img{ + width: 90px; + margin-top: -50px; + border-radius: 50%; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +.failed-box h2{ + font-size: 38px; + font-weight: 500; + margin: 30px 0 10px; +} + +.failed-box button{ + width: 100%; + padding: 10px 0; + background: #f44f3d; + color: #fff; + border: 0; + outline: none; + font-size: 18px; + border-radius: 4px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} +.failed-box button:hover{ + background-color: #d24838; +} + +.email-contact{ + text-decoration: none; +} +.email-contact:hover{ + text-decoration: underline; +} \ No newline at end of file diff --git a/packages/CourtBooking/src/features/booking/components/Payment/LoadingPage.jsx b/packages/CourtBooking/src/features/booking/components/Payment/LoadingPage.jsx new file mode 100644 index 00000000..0f70f62a --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/Payment/LoadingPage.jsx @@ -0,0 +1,25 @@ +// src/staff/Payment/LoadingPage.jsx +import React from 'react'; +import { Box, CircularProgress, Typography } from '@mui/material'; + +const LoadingPage = () => { + return ( + + + + Processing your payment, please wait... + + + ); +}; + +export default LoadingPage; diff --git a/packages/CourtBooking/src/features/booking/components/Payment/PaymentConfirmation.jsx b/packages/CourtBooking/src/features/booking/components/Payment/PaymentConfirmation.jsx new file mode 100644 index 00000000..441e528a --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/Payment/PaymentConfirmation.jsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Typography, Paper, Grid } from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { fetchBookingById } from 'api/bookingApi'; +import moment from 'moment'; +import { fetchTimeSlotByBookingId } from 'api/timeSlotApi'; +import CancelIcon from '@mui/icons-material/Cancel'; + +export const PaymentConfirmed = ({ + userInfo = {}, branchId = 'N/A', courtId = 'N/A', +}) => { + const [booking, setBooking] = useState(null); + const [loading, setLoading] = useState(true); + const [timeSlots, setTimeSlot] = useState(null); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const bookingId = params.get('vnp_TxnRef'); + + if (bookingId) { + const fetchBookingAndTimeSlot = async () => { + try { + const bookingData = await fetchBookingById(bookingId); + + setBooking(bookingData); + + const timeSlotData = await fetchTimeSlotByBookingId(bookingId); + + setTimeSlot(timeSlotData); + } catch (error) { + console.error('Error fetching booking or time slot:', error); + } finally { + setLoading(false); + } + }; + + fetchBookingAndTimeSlot(); + } else { + setLoading(false); + } + }, []); + + if (loading) { + return Loading...; + } + + + + return ( + + + + + Payment confirmed + + + Thank you, your payment has been successful and your booking is now confirmed. A confirmation email has been sent to {userInfo.email || 'N/A'}. + + {booking && ( + + + Order summary + + + + + + Email: + + + + + {userInfo.email || 'N/A'} + + + + + Branch ID: + + + + + {branchId} + + + + + Court ID: + + + + + {courtId} + + + + + Time Slot: + + + + + {timeSlots ? ( timeSlots.map ((slot,index) => ( +
+ { `${slot.slotStartTime} - ${slot.slotEndTime} ` } +
))): 'N/A'} +
+
+ + + Payment Date: + + + + + {moment(booking.bookingDate).format('DD/MM/YYYY')} + + + + + Total Price: + + + + + {booking.totalPrice} USD + + +
+
+
+ )} +
+
+ ); +}; + +export const PaymentRejected = () => { + return ( + + + + + Payment rejected + + + Your payment was declined. Please try again or use a different payment method. + + + + ); +}; diff --git a/packages/CourtBooking/src/features/booking/components/Payment/PaymentDetailFixed.jsx b/packages/CourtBooking/src/features/booking/components/Payment/PaymentDetailFixed.jsx new file mode 100644 index 00000000..0cf484c3 --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/Payment/PaymentDetailFixed.jsx @@ -0,0 +1,289 @@ +import React, { useState, useEffect } from 'react'; +import { Box, FormControl, FormLabel, RadioGroup, FormControlLabel, Radio, Button, Stepper, Step, StepLabel, Typography, Divider, Grid, TextField } from '@mui/material'; +import { useLocation, useNavigate } from 'react-router-dom'; +import axios from 'axios'; +import { jwtDecode } from 'jwt-decode'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import PaymentIcon from '@mui/icons-material/Payment'; +import { generatePaymentToken, processPayment } from 'api/paymentApi'; +import { createFixedBooking } from 'api/bookingApi'; +import LoadingPage from './LoadingPage'; +import { processBalancePayment } from 'api/paymentApi'; + +const theme = createTheme({ + components: { + MuiRadio: { + styleOverrides: { + root: { + color: 'black', + '&.Mui-checked': { + color: 'black', + }, + }, + }, + }, + }, +}); + +const steps = ['Payment Details', 'Payment Confirmation']; + +const formatDate = (dateString) => { + const date = new Date(dateString); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const year = date.getFullYear(); + return `${month}/${day}/${year}`; +}; + +const PaymentDetailFixed = () => { + const location = useLocation(); + const navigate = useNavigate(); + const { branchId, bookingRequests, totalPrice, numberOfMonths, daysOfWeek, startDate, slotStartTime, slotEndTime } = location.state || {}; + const [userId, setUserId] = useState(''); + const [user, setUser] = useState(null); + const [userData, setUserData] = useState(null); + const [userName, setUserName] = useState(''); + const [userEmail, setUserEmail] = useState(''); + const [activeStep, setActiveStep] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(''); + + console.log('bookdata: ', branchId, bookingRequests, totalPrice, numberOfMonths, daysOfWeek, startDate, bookingRequests[0].slotDate, slotStartTime, slotEndTime) + + useEffect(() => { + const token = localStorage.getItem("token"); + + if (token) { + const decoded = jwtDecode(token); + setUserEmail(decoded.email) + + const fetchUserData = async (id, isGoogle) => { + try { + if (isGoogle) { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/GetUserDetailByUserEmail/${id}` + ); + setUserData(response.data); + setUserName(response.data.fullName) + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/GetUserDetailByUserEmail/${id}?searchValue=${id}` + ); + setUser(userResponse.data); + + } else { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/${id}` + ); + setUserData(response.data); + setUserName(response.data.fullName) + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/${id}` + ); + setUser(userResponse.data); + } + } catch (error) { + console.error("Error fetching user data:", error); + } + }; + + if (decoded.iss !== "https://accounts.google.com") { + const userId = decoded.Id; + setUserId(userId); + fetchUserData(userId, false); + } else { + const userId = decoded.email; + setUserId(userId); + fetchUserData(userId, true); + } + } + }, []); + + const handleNext = async (paymentMethod) => { + if (activeStep === 0) { + setIsLoading(true); + try { + const formattedStartDate = formatDate(startDate); + + const response = await createFixedBooking( + numberOfMonths, + daysOfWeek, + formattedStartDate, + userData.userId, + branchId, + bookingRequests[0].slotDate, + slotStartTime, + slotEndTime + ); + console.log('res', response) + + const bookingId = response.bookingId; + const tokenResponse = await generatePaymentToken(bookingId); + const token = tokenResponse.token; + if (paymentMethod === "Balance") { + try { + await processBalancePayment(token); + navigate("/confirm"); + } catch (error) { + console.error("Balance payment failed:", error); + navigate("/reject"); + } + } else { + const paymentResponse = await processPayment(token); + const paymentUrl = paymentResponse; + window.location.href = paymentUrl; + return; + } + + } catch (error) { + console.error('Error processing payment:', error); + setErrorMessage('Error processing payment. Please try again.'); + setIsLoading(false); + } + } else { + setActiveStep((prevActiveStep) => prevActiveStep + 1); + } + }; + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + const handlePaymentMethodChange = (event) => { + setSelectedPaymentMethod(event.target.value); + }; + + const getStepContent = (step) => { + switch (step) { + case 0: + return ( + <> + + + Customer Information + + + {userName} + + + {userEmail} + + + + + + + + + Payment Method + + + Choose Payment Method + + } + label="Credit Card" + sx={{ color: "black" }} + /> + } + label="Balance" + sx={{ color: "black" }} + /> + + + + + + + + Invoice + + + Branch ID: {branchId} + + + Number of Months: {numberOfMonths} + + + Days of Week: {daysOfWeek.join(', ')} + + + Start Date: {startDate} + + + Slot Start Time: {slotStartTime} + + + Slot End Time: {slotEndTime} + + + + Total Price: {totalPrice} VND + + + + + + + ); + case 1: + return ; + default: + return 'Unknown step'; + } + }; + + return ( + + + + Payment Details + + + {steps.map((label) => ( + + {label} + + ))} + + {isLoading ? : getStepContent(activeStep)} + + + + + + + + ); +}; + +export default PaymentDetailFixed; diff --git a/packages/CourtBooking/src/features/booking/components/Payment/PaymentDetails.jsx b/packages/CourtBooking/src/features/booking/components/Payment/PaymentDetails.jsx new file mode 100644 index 00000000..54db47c7 --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/Payment/PaymentDetails.jsx @@ -0,0 +1,654 @@ +import React, { useState, useEffect } from "react"; +import { Box, FormControl, FormLabel, RadioGroup, FormControlLabel, Radio, Button, TextField, Stepper, Step, StepLabel, Typography, Divider, Card, CardContent, CardHeader, Grid,} from "@mui/material"; +import { useLocation, useNavigate } from "react-router-dom"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; +import PaymentIcon from "@mui/icons-material/Payment"; +import { generatePaymentToken, processPayment, processBalancePayment } from "api/paymentApi"; +import LoadingPage from "./LoadingPage"; +import { reserveSlots, createBookingFlex, deleteBookingInFlex, } from "api/bookingApi"; +import { addTimeSlotIfExistBooking } from "api/timeSlotApi"; +import axios from "axios"; +import { jwtDecode } from "jwt-decode"; +import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr"; +import * as signalR from "@microsoft/signalr"; + +const theme = createTheme({ + components: { + MuiRadio: { + styleOverrides: { + root: { + color: "black", + "&.Mui-checked": { + color: "black", + }, + }, + }, + }, + }, +}); + +const steps = ["Payment Details", "Payment Confirmation"]; + +const PaymentDetail = () => { + const location = useLocation(); + const navigate = useNavigate(); + const { + branchId, + bookingRequests, + totalPrice, + type, + availableSlot, + bookingId, + numberOfSlot, + } = location.state || {}; + const sortedBookingRequests = bookingRequests + ? [...bookingRequests].sort((a, b) => { + const dateA = new Date(`${a.slotDate}T${a.timeSlot.slotStartTime}`); + const dateB = new Date(`${b.slotDate}T${b.timeSlot.slotStartTime}`); + return dateA - dateB; + }) + : []; + const [activeStep, setActiveStep] = useState(0); + + const [email, setEmail] = useState(""); + const [userEmail, setUserEmail] = useState(""); + const [userId, setUserId] = useState(""); + const [user, setUser] = useState(null); + const [userData, setUserData] = useState(null); + const [errorMessage, setErrorMessage] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [userName, setUserName] = useState(""); + const [connection, setConnection] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(''); + const [showFlexPayment, setShowFlexPayment] = useState(false); + + useEffect(() => { + const token = localStorage.getItem("token"); + + if (token) { + const decoded = jwtDecode(token); + console.log(decoded); + setUserEmail(decoded.email); + + const fetchUserData = async (id, isGoogle) => { + try { + if (isGoogle) { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/GetUserDetailByUserEmail/${id}` + ); + setUserData(response.data); + setUserName(response.data.fullName); + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/GetUserDetailByUserEmail/${id}?searchValue=${id}` + ); + setUser(userResponse.data); + } else { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/${id}` + ); + setUserData(response.data); + setUserName(response.data.fullName); + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/${id}` + ); + console.log("userResponse", userResponse.data); + setUser(userResponse.data); + } + } catch (error) { + console.error("Error fetching user data:", error); + } + }; + + if (decoded.iss !== "https://accounts.google.com") { + const userId = decoded.Id; + setUserId(userId); + fetchUserData(userId, false); + } else { + const userId = decoded.email; + setUserId(userId); + fetchUserData(userId, true); + } + } + }, []); + + //đấm nhau với signalR + useEffect(() => { + const newConnection = new HubConnectionBuilder() + .withUrl("https://courtcaller.azurewebsites.net/timeslothub", { + skipNegotiation: true, + transport: signalR.HttpTransportType.WebSockets, + }) + .withAutomaticReconnect() + .configureLogging(LogLevel.Information) + .build(); + + newConnection.onreconnecting((error) => { + console.log(`Connection lost due to error "${error}". Reconnecting.`); + setIsConnected(false); + }); + + newConnection.onreconnected((connectionId) => { + console.log( + `Connection reestablished. Connected with connectionId "${connectionId}".` + ); + setIsConnected(true); + }); + + newConnection.onclose((error) => { + console.log( + `Connection closed due to error "${error}". Try refreshing this page to restart the connection.` + ); + setIsConnected(false); + }); + + console.log("Initializing connection..."); + setConnection(newConnection); + }, []); + + useEffect(() => { + if (connection) { + const startConnection = async () => { + try { + await connection.start(); + console.log("SignalR Connected."); + setIsConnected(true); + } catch (error) { + console.log("Error starting connection:", error); + setIsConnected(false); + setTimeout(startConnection, 5000); + } + }; + startConnection(); + } + }, [connection]); + + useEffect(() => { + if(type === "flexible" && availableSlot !== 0){ + setShowFlexPayment(true); + }else{ + setShowFlexPayment(false); + } + }, []) + + // gửi slot để backend signalr nó check + const sendUnavailableSlotCheck = async () => { + if (connection) { + const lastRequest = bookingRequests[bookingRequests.length - 1]; + const slotCheckModel = { + branchId: branchId, + slotDate: lastRequest.slotDate, + timeSlot: { + slotDate: lastRequest.slotDate, + slotStartTime: lastRequest.timeSlot.slotStartTime, + slotEndTime: lastRequest.timeSlot.slotEndTime, + }, + }; + console.log("SlotCheckModel:", slotCheckModel); + try { + await connection.send("DisableSlot", slotCheckModel); + console.log("Data sent to server:", slotCheckModel); + } catch (e) { + console.log("Error sending data to server:", e); + } + } else { + alert("No connection to server yet."); + } + }; + + const handleNext = async (paymentMethod) => { + try { + await sendUnavailableSlotCheck(); + + if (type === "flexible" && availableSlot !== 0 && bookingId) { + const bookingForm = bookingRequests.map((request) => ({ + courtId: null, + branchId: branchId, + slotDate: request.slotDate, + timeSlot: { + slotDate: request.slotDate, + slotStartTime: request.timeSlot.slotStartTime, + slotEndTime: request.timeSlot.slotEndTime, + }, + })); + const booking = await addTimeSlotIfExistBooking(bookingForm, bookingId); + navigate("/confirm", { + state: { + bookingId: bookingId, + bookingForm: bookingForm, + }, + }); + return; + } + + else if (type === "flexible" && availableSlot === 0) { + let id = null; + try { + setIsLoading(true); + const bookingForm = bookingRequests.map((request) => ({ + courtId: null, + branchId: branchId, + slotDate: request.slotDate, + timeSlot: { + slotDate: request.slotDate, + slotStartTime: request.timeSlot.slotStartTime, + slotEndTime: request.timeSlot.slotEndTime, + }, + })); + + const createBookingTypeFlex = await createBookingFlex( + userData.userId, + numberOfSlot, + branchId + ); + + id = createBookingTypeFlex.bookingId; + const booking = await reserveSlots(userData.userId, bookingForm); + setActiveStep((prevActiveStep) => prevActiveStep + 1); + const tokenResponse = await generatePaymentToken(booking.bookingId); + const token = tokenResponse.token; + if (paymentMethod === "Balance") { + try { + await processBalancePayment(token); + navigate("/confirm"); + } catch (error) { + console.error("Balance payment failed:", error); + navigate("/reject"); + } + } else { + const paymentResponse = await processPayment(token); + const paymentUrl = paymentResponse; + window.location.href = paymentUrl; + return; + } + } catch (error) { + console.error("Error processing payment:", error); + setErrorMessage("Error processing payment. Please try again."); + if (id) { + try { + await deleteBookingInFlex(id); + console.log("Booking rolled back successfully"); + return; + } catch (deleteError) { + console.error("Error rolling back booking:", deleteError); + } + } + setIsLoading(false); + } + } + + if (activeStep === 0) { + setIsLoading(true); + try { + const bookingForm = bookingRequests.map((request) => { + return { + courtId: null, + branchId: branchId, + slotDate: request.slotDate, + timeSlot: { + slotStartTime: request.timeSlot.slotStartTime, + slotEndTime: request.timeSlot.slotEndTime, + }, + }; + }); + + const booking = await reserveSlots(userData.userId, bookingForm); + setActiveStep((prevActiveStep) => prevActiveStep + 1); + + const tokenResponse = await generatePaymentToken(booking.bookingId); + const token = tokenResponse.token; + + if (paymentMethod === "Balance") { + try { + await processBalancePayment(token); + navigate("/confirm"); + } catch (error) { + console.error("Balance payment failed:", error); + navigate("/reject"); + } + } else { + const paymentResponse = await processPayment(token); + const paymentUrl = paymentResponse; + window.location.href = paymentUrl; + return; + } + } catch (error) { + console.error("Error processing payment:", error); + setErrorMessage("Error processing payment. Please try again."); + } + setIsLoading(false); + } + } catch (error) { + console.error("Error sending unavailable slot check:", error); + } + }; + + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + const handlePaymentMethodChange = (event) => { + setSelectedPaymentMethod(event.target.value); + }; + + const getStepContent = (step) => { + switch (step) { + case 0: + return ( + <> + + + Customer Information + + + {userName} + + + {userEmail} + + + + {/* box này là thông tin payment method */} + + + {showFlexPayment ? ( + + + Payment Method + + + + You don't need to select payment method because you have {availableSlot} slot(s) now ! + + {/* + } + label="Credit Card" + sx={{ color: "black" }} + /> + } + label="Balance" + sx={{ color: "black" }} + /> + */} + + + + ) : ( + + + + Payment Method + + + + Select Payment Method + + + } + label="Credit Card" + sx={{ color: "black" }} + /> + } + label="Balance" + sx={{ color: "black" }} + /> + + + + + )} + + + + + Bill + + + Branch ID: {branchId} + + + Time Slot: + + {bookingRequests && + sortedBookingRequests.map((request, index) => ( + + + Date: {request.slotDate} + + + Start Time:{" "} + {request.timeSlot.slotStartTime} + + + End Time:{" "} + {request.timeSlot.slotEndTime} + + + Price: {request.price} USD + + + ))} + + + Total Price: {totalPrice} USD + + + + + + + ); + case 1: + return ; // Show loading page + + // ----------------------------------------------------------------------------------- + { + /* xử lý vnPay xong thì đưa ra cái này !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // đổi case 1 thành paymentconfimationstep hoặc paymentrejectedstep dựa trên kết quả trả về từ vnpay + case 1: + //thành công + // return ; + + //thất bại + return ; + */ + } + // ----------------------------------------------------------------------------------- + + default: + return "Unknown step"; + } + }; + + return ( + <> + {showFlexPayment ? ( + + + Payment Details + + + {steps.map((label) => ( + + {label} + + ))} + + {isLoading ? : getStepContent(activeStep)} + + + {/* */} + + + + + ) : ( + + + + Payment Details + + + {steps.map((label) => ( + + {label} + + ))} + + {isLoading ? : getStepContent(activeStep)} + + + + + + + + )} + + ); +}; + +export default PaymentDetail; + +//hãy chỉnh cho tôi nếu \ No newline at end of file diff --git a/packages/CourtBooking/src/features/booking/components/Payment/PaymentFailed.js b/packages/CourtBooking/src/features/booking/components/Payment/PaymentFailed.js new file mode 100644 index 00000000..0df9102c --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/Payment/PaymentFailed.js @@ -0,0 +1,19 @@ +import React from 'react'; +import './FailurePage.css'; +import { Link } from "react-router-dom"; +import noImg from "assets/users/images/byday/red_cross.png" + +function PaymentFailed() { + return ( +
+
+ Payment Failed +

OOPS !!!

+

Something went wrong! Please try again on booking or contact with Us: courtcallers@gmail.com

+ +
+
+ ); +} + +export default PaymentFailed; diff --git a/packages/CourtBooking/src/features/booking/components/Payment/PaymentSuccessful.js b/packages/CourtBooking/src/features/booking/components/Payment/PaymentSuccessful.js new file mode 100644 index 00000000..8bae656e --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/Payment/PaymentSuccessful.js @@ -0,0 +1,23 @@ +import React from 'react'; +import './SuccessPage.css'; +import { Link } from "react-router-dom"; +import { FaHeart } from "react-icons/fa"; +import yesImg from "assets/users/images/byday/green_tick.png" + +function PaymentSuccessful() { + return ( +
+
+ Payment Successful +

Thank You!

+

Your booking has been successfully submitted. Thanks for choosing our service

+
+ + +
+
+
+ ); +} + +export default PaymentSuccessful; diff --git a/packages/CourtBooking/src/features/booking/components/Payment/SuccessPage.css b/packages/CourtBooking/src/features/booking/components/Payment/SuccessPage.css new file mode 100644 index 00000000..7f37ad40 --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/Payment/SuccessPage.css @@ -0,0 +1,60 @@ +*{ + margin: 0; + padding: 0; +} + +.payment-container{ + width: 100%; + height: 100vh; + background: linear-gradient(to right, #dedddd, #ABEBC6); + display: flex; + align-items: center; + justify-content: center; +} + +.success-box{ + width: 301px; + background-color: #fff; + border-radius: 6px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + padding: 0 30px 30px; + color: #333; +} + +.success-box img{ + width: 90px; + margin-top: -50px; + border-radius: 50%; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +.success-box h2{ + font-size: 38px; + font-weight: 500; + margin: 30px 0 10px; +} + +.success-box button{ + width: 100%; + margin-top: 20px; + padding: 10px 0; + background: #6fd649; + color: #fff; + border: 0; + outline: none; + font-size: 18px; + border-radius: 4px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} +.success-box button:hover{ + background-color: #51b12e; +} + +.contact-email{ + text-decoration: none; +} \ No newline at end of file diff --git a/packages/CourtBooking/src/features/booking/components/Payment/VNPayStep.jsx b/packages/CourtBooking/src/features/booking/components/Payment/VNPayStep.jsx new file mode 100644 index 00000000..89d37748 --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/Payment/VNPayStep.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import VpnKeyIcon from '@mui/icons-material/VpnKey'; + +const VNPayStep = () => { + return ( + + + VNPay + + + Please enter your VNPay details to proceed with the payment. + + {/* Add VNPay form fields here */} + + ); +}; + +export default VNPayStep; diff --git a/packages/CourtBooking/src/features/booking/components/bookedPage/index.js b/packages/CourtBooking/src/features/booking/components/bookedPage/index.js new file mode 100644 index 00000000..01b6e50a --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/bookedPage/index.js @@ -0,0 +1,587 @@ +import { memo, useState, useEffect } from "react"; +import { jwtDecode } from "jwt-decode"; +import axios from "axios"; +import { FaRegCalendarCheck } from "react-icons/fa"; +import { GiShuttlecock } from "react-icons/gi"; +import { fetchQrcode } from "api/bookingApi"; +import BeatLoader from "react-spinners/BeatLoader"; +import "./style.scss"; + + +const BookedPage = () => { + const [bookings, setBookings] = useState([]); + const [overdueBookings, setOverdueBookings] = useState([]); + const [canceledBookings, setCanceledBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedBooking, setSelectedBooking] = useState(null); + const [slotInfo, setSlotInfo] = useState([]); + const [branchInfo, setBranchInfo] = useState(null); + const [showModal, setShowModal] = useState(false); + const [showCancelModal, setShowCancelModal] = useState(false); + const [bookingIdToCancel, setBookingIdToCancel] = useState(null); + const [qrcode, setQrcode] = useState(null); + const [userId, setUserId] = useState(null); + const [userData, setUserData] = useState(null); + const [user, setUser] = useState(null); + + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + const decodedToken = jwtDecode(token); + setUserId(decodedToken.Id); + + const fetchUserData = async (id, isGoogle) => { + try { + if (isGoogle) { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/GetUserDetailByUserEmail/${id}` + ); + setUserData(response.data); + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/GetUserDetailByUserEmail/${id}?searchValue=${id}` + ); + setUser(userResponse.data); + } else { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/${id}` + ); + setUserData(response.data); + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/${id}` + ); + setUser(userResponse.data); + } + } catch (error) { + console.error("Error fetching user data:", error); + } + }; + + if (decodedToken.iss !== "https://accounts.google.com") { + const userId = decodedToken.Id; + setUserId(userId); + fetchUserData(userId, false); + } else { + const userId = decodedToken.email; + setUserId(userId); + fetchUserData(userId, true); + } + } + }, []); + + useEffect(() => { + const fetchBookings = async () => { + if (!userData || !userData.userId) return; + + try { + const bookingsResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Bookings/userId/${userData.userId}` + ); + const allBookings = bookingsResponse.data; + + const filteredBookings = await Promise.all( + allBookings.map(async (booking) => { + try { + const paymentResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Payments/bookingid/${booking.bookingId}` + ); + if (paymentResponse.data.paymentMessage === "Complete") + return booking; + } catch (paymentError) { + console.error( + `Error fetching payment data for booking ID ${booking.bookingId}:`, + paymentError + ); + } + return null; + }) + ); + + const validBookings = filteredBookings.filter( + (booking) => booking !== null + ); + + const currentDate = new Date(); + const scheduledBookings = []; + const overdueBookingsList = []; + const canceledBookingsList = []; + + await Promise.all( + validBookings.map(async (booking) => { + const slotResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/TimeSlots/bookingId/${booking.bookingId}` + ); + const slots = slotResponse.data; + + const isOverdue = slots.every( + (slot) => new Date(slot.slotDate) < currentDate + ); + + if (booking.status === "Canceled") { + canceledBookingsList.push(booking); + } else if (isOverdue) { + overdueBookingsList.push(booking); + } else { + scheduledBookings.push(booking); + } + }) + ); + + setBookings(scheduledBookings); + setOverdueBookings(overdueBookingsList); + setCanceledBookings(canceledBookingsList); + } catch (error) { + console.error("Error fetching bookings data:", error); + } finally { + setLoading(false); + } + }; + + fetchBookings(); + }, [userData]); + + const formatDate = (dateString) => { + const date = new Date(dateString); + const day = date.getDate(); + const month = date.getMonth() + 1; + const year = date.getFullYear(); + return `${day < 10 ? `0${day}` : day}/${ + month < 10 ? `0${month}` : month + }/${year}`; + }; + + const handleCancelBooking = async () => { + try { + await axios.delete( + `https://courtcaller.azurewebsites.net/api/Bookings/cancelBooking/${bookingIdToCancel}` + ); + setBookings((prevBookings) => + prevBookings.map((booking) => + booking.bookingId === bookingIdToCancel + ? { ...booking, status: "Canceled" } + : booking + ) + ); + setShowCancelModal(false); + } catch (error) { + console.error(`Error canceling booking ID ${bookingIdToCancel}:`, error); + } + }; + + const handleViewBooking = async (booking) => { + setSelectedBooking(booking); + setShowModal(true); + + try { + setQrcode(await fetchQrcode(booking.bookingId)); + const slotResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/TimeSlots/bookingId/${booking.bookingId}` + ); + console.log("Slot Response:", slotResponse.data); + setSlotInfo( + slotResponse.data.sort((a, b) => { + const dateA = new Date(a.slotDate); + const dateB = new Date(b.slotDate); + if (dateA < dateB) return -1; + if (dateA > dateB) return 1; + const timeA = new Date(`1970-01-01T${a.slotStartTime}`); + const timeB = new Date(`1970-01-01T${b.slotStartTime}`); + return timeA - timeB; + }) + ); + + const branchResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Branches/${booking.branchId}` + ); + console.log("Branch Response:", branchResponse.data); + setBranchInfo(branchResponse.data); + } catch (error) { + console.error("Error fetching slot or branch data:", error); + } + }; + + console.log("slotinfo", slotInfo); + + const closeModal = () => { + setShowModal(false); + setSelectedBooking(null); + setSlotInfo([]); + setBranchInfo(null); + }; + + const handleCancelClick = (bookingId) => { + setBookingIdToCancel(bookingId); + setShowCancelModal(true); + }; + + const closeCancelModal = () => { + setShowCancelModal(false); + setBookingIdToCancel(null); + }; + + return ( +
+
+
+
+
+ WELCOME TO +

+ COURT CALLER +
+ HAVE FUN +

+
+
+
+
+
+

BOOKED PAGE

+
+ {loading ? ( + + ) : ( +
+
+

+ Upcoming Schedule +

+
+ + + + + + + + + + + + + + {bookings.length > 0 ? ( + bookings.map((booked, index) => ( + + + + + + + + + + + + + )) + ) : ( + + + + + + )} +
BookingIDDateNumber of slotsBooking TypePriceStatusDetailsCancel
{booked.bookingId}{formatDate(booked.bookingDate)}{booked.numberOfSlot}{booked.bookingType}{booked.totalPrice} VND{booked.status} + {booked.status !== "Canceled" && ( + + )} + + {booked.status !== "Canceled" && ( + + )} +
+ No upcoming bookings found +
+
+

+ Overdue Bookings +

+
+ + + + + + + + + + + + + {overdueBookings.length > 0 ? ( + overdueBookings.map((booked, index) => ( + + + + + + + + + + + + )) + ) : ( + + + + + + )} +
BookingIDDateNumber of slotsBooking TypePriceStatusDetails
{booked.bookingId}{formatDate(booked.bookingDate)}{booked.numberOfSlot}{booked.bookingType}{booked.totalPrice} VND{booked.status} + {booked.status !== "Canceled" && ( + + )} +
+ No overdue bookings found +
+
+

+ Canceled Bookings +

+
+ + + + + + + + + + + + {canceledBookings.length > 0 ? ( + canceledBookings.map((booked, index) => ( + + + + + + + + + + + )) + ) : ( + + + + + + )} +
BookingIDDateNumber of slotsBooking TypePriceStatus
{booked.bookingId}{formatDate(booked.bookingDate)}{booked.numberOfSlot}{booked.bookingType}{booked.totalPrice} VND{booked.status}
+ No canceled bookings found +
+
+
+
+ )} + + {showModal && selectedBooking && ( +
+
+ + × + +
+
+

+ Slot Information{" "} + +

+
+ + + + + + + + + + {slotInfo.length > 0 ? ( + slotInfo.map((slot, index) => ( + + + + + + + + + )) + ) : ( + + + + + + )} +
CourtIDSlot DateStart TimeEnd Time
{slot.courtId}{formatDate(slot.slotDate)}{slot.slotStartTime}{slot.slotEndTime}
+ No slot information available +
+
+
+
+

+ Branch Information{" "} + +

+ {branchInfo ? ( + <> +
+

Name:

+

{branchInfo.branchName}

+
+
+

Address:

+

{branchInfo.branchAddress}

+
+
+

Phone:

+

{branchInfo.branchPhone}

+
+
+

Status:

+

{branchInfo.status}

+
+ + ) : ( +

No branch information available

+ )} +
+
+ +
+
+
+ QR Code +
+

QR Code for Checking In

+

Checked in

+
+
+
+
+ )} + + {showCancelModal && ( +
+
+
+
+ +
+
+ Cancel Booking +

+ Are you sure you want to cancel your booking? Your balance + will only be refunded half. However, your membership points + remain the same. This action cannot be undone. +

+
+
+ + +
+
+
+
+ )} +
+ ); +}; + +export default memo(BookedPage); diff --git a/packages/CourtBooking/src/features/booking/components/bookedPage/style.scss b/packages/CourtBooking/src/features/booking/components/bookedPage/style.scss new file mode 100644 index 00000000..c61b0e10 --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/bookedPage/style.scss @@ -0,0 +1,354 @@ +$bannerImg: "/assets/users/images/hero/banner.jpg"; + +.hero_banner_container { + .hero_banner { + background-image: url($bannerImg); + background-image: no-repeat; + background-size: cover; + height: 600px; + width: 100%; + display: flex; + align-items: center; + margin-bottom: 20px; + + .hero_text { + padding-left: 75px; + + span { + font-size: 14px; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 4px; + color: #008874; + } + + h2 { + font-size: 45px; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 4px; + line-height: 52px; + color: #000; + margin: 10px 0; + } + } + } +} + +.booked-title{ + display: flex; + justify-content: center; + +} + +main { + padding: 20px; + background-color: #fff; + margin: 20px 20px 0 20px; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +h2 { + margin-top: 0; + margin-bottom: 10px; +} + +table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; +} + +table th, +table td { + border-bottom: 1px solid #ddd; +} + +table th, +table td { + padding: 10px; + text-align: center; +} + +th { + background-color: #2ecc71; + color: #fff; + border: none; + position: sticky; + top: 0; +} + +td { + border-left: none; + border-right: none; +} + +.overdue-booking{ + th { + background-color: red; + color: #fff; + border: none; + position: sticky; + top: 0; + } +} + + +.canceled-booking{ + th { + background-color: #DC7633; + color: #fff; + border: none; + position: sticky; + top: 0; + } +} + +.cancel-button { + width: 80px; + height: 30px; + border: 3px solid red; + border-radius: 5px; + transition: all 0.3s; + cursor: pointer; + background: white; + font-size: 0.9em; + font-weight: 550; + font-family: 'Montserrat', sans-serif; +} +.cancel-button:hover { + background: red; + color: white; + font-size: 1.1em; +} + +.view-button{ + width: 80px; + height: 30px; + border: 3px solid #28B463; + border-radius: 5px; + transition: all 0.3s; + cursor: pointer; + background: white; + font-size: 0.9em; + font-weight: 550; + font-family: 'Montserrat', sans-serif; +} +.view-button:hover{ + background: #28B463; + color: white; + font-size: 1.1em; +} + +/* Modal styles */ +.modal-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + display: flex; + align-items: center; + background-color: white; + padding: 20px; + border: 1px solid #888; + width: 100%; + height: 540px; + max-width: 830px; + border-radius: 10px; + position: relative; +} + +.slot-view, +.branch-view { + width: 400px; + height: 235px; + padding: 10px; + border-radius: 10px; + box-shadow: inset 2px 2px 2px 0px rgba(255, 255, 255, 0.5), + 7px 7px 20px 0px rgba(0, 0, 0, 0.2), 4px 4px 5px 0px rgba(0, 0, 0, 0.2); +} +.user-qr-checking { + width: 360px; + height: 400px; + padding: 10px; + border-radius: 10px; + box-shadow: inset 2px 2px 2px 0px rgba(255, 255, 255, 0.5), + 7px 7px 20px 0px rgba(0, 0, 0, 0.2), 4px 4px 5px 0px rgba(0, 0, 0, 0.2); +} + +.branch-view { + margin-top: 20px; +} + +.branch-detail { + display: flex; + justify-content: space-between; + border-bottom: solid #b2b2b2; + border-bottom-width: thin; + + .branch-field { + font-weight: 600; + padding: 20px 0 6px 0; + } + .branch-info { + padding: 20px 0 6px 0; + } +} + +.qr-placeholder { + display: flex; + align-items: center; + justify-content: center; + margin-top: 50px; + + .qr { + width: 250px; + height: 250px; + } +} + +.user-qr p { + display: flex; + align-items: center; + justify-content: center; + margin-top: 20px; + font-size: large; +} + +.close { + color: #aaa; + position: absolute; + top: 0px; + right: 10px; + font-size: 50px; + font-weight: bold; + cursor: pointer; +} + +.close:hover, +.close:focus { + color: red; + text-decoration: none; +} + +// Cancel styling +.cancel-confirm-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.card { + overflow: hidden; + position: relative; + background-color: #ffffff; + text-align: left; + border-radius: 0.5rem; + max-width: 291px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.cancel-header { + padding: 1.25rem 1rem 1rem 1rem; + background-color: #ffffff; +} + +.cancel-image { + display: flex; + margin-left: auto; + margin-right: auto; + background-color: #fee2e2; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 5rem; + height: 5rem; + border-radius: 9999px; +} + +.cancel-image svg { + color: #dc2626; + width: 2.5rem; + height: 2.5rem; +} + +.cancel-content { + margin-top: 0.75rem; + text-align: center; +} + +.cancel-title { + color: #111827; + font-size: 1.3rem; + font-weight: 600; + line-height: 1.5rem; +} + +.cancel-message { + margin-top: 0.5rem; + color: #6b7280; + font-size: 0.875rem; + line-height: 1.25rem; +} + +.cancel-actions { + margin: 0.75rem 1rem; + background-color: #f9fafb; +} + +.cancel-desactivate { + display: inline-flex; + padding: 0.5rem 1rem; + background-color: #dc2626; + color: #ffffff; + font-size: 1rem; + line-height: 1.5rem; + font-weight: 500; + justify-content: center; + width: 100%; + border-radius: 0.375rem; + border-width: 1px; + border-color: transparent; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} +.cancel-desactivate:hover{ + background-color: #fd1616; + box-shadow: 0px 15px 20px rgba(207, 63, 63, 0.4); +} + +.cancel-button2 { + display: inline-flex; + margin-top: 0.75rem; + padding: 0.5rem 1rem; + background-color: #ffffff; + color: #374151; + font-size: 1rem; + line-height: 1.5rem; + font-weight: 500; + justify-content: center; + width: 100%; + border-radius: 0.375rem; + border: 1px solid #d1d5db; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} +.cancel-button2:hover{ + box-shadow: 0px 15px 20px rgba(74, 71, 71, 0.4); +} \ No newline at end of file diff --git a/packages/CourtBooking/src/features/booking/components/bookingByDay/index.js b/packages/CourtBooking/src/features/booking/components/bookingByDay/index.js new file mode 100644 index 00000000..07c72d78 --- /dev/null +++ b/packages/CourtBooking/src/features/booking/components/bookingByDay/index.js @@ -0,0 +1,1299 @@ +import { memo, useState, useEffect, useRef } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { FaWifi, FaMotorcycle, FaBowlFood } from "react-icons/fa6"; +import { FaCar, FaStar } from "react-icons/fa"; +import { RiDrinks2Fill } from "react-icons/ri"; +import axios from "axios"; +import { jwtDecode } from "jwt-decode"; +import { MdOutlineLocalDrink } from "react-icons/md"; +import pic1 from "assets/users/images/byday/pic1.webp"; +import pic2 from "assets/users/images/byday/pic2.webp"; +import pic3 from "assets/users/images/byday/pic3.webp"; +import { IoLocationOutline } from "react-icons/io5"; +import { + Box, + Button, + Grid, + Typography, + Select, + MenuItem, + FormControl, + IconButton, +} from "@mui/material"; +import { fetchBranches, fetchBranchById } from "api/branchApi"; +import { fetchBookingByUserId } from "api/bookingApi"; +import { fetchPrice } from "api/priceApi"; +import dayjs from "dayjs"; +import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; +import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos"; +import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; +import { CiEdit } from "react-icons/ci"; +import "./styles.scss"; +import "react-multi-carousel/lib/styles.css"; +import "./style.scss"; +import DisplayMap from "map/DisplayMap"; +import * as signalR from "@microsoft/signalr"; +import { + HubConnectionBuilder, + HttpTransportType, + LogLevel, +} from "@microsoft/signalr"; +import { fetchUnavailableSlots } from "../../../api/timeSlotApi"; +import RequestLogin from "../requestUserLogin"; +import RequestBooking from "../requestUserBooking"; +import {fetchPercentRatingByBranch, fetchEachPercentRatingByBranch} from "../../../api/reviewApi"; + +dayjs.extend(isSameOrBefore); + +// quy ước các ngày trong tuần thành số +const dayToNumber = { + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, + Sunday: 7, +}; + +//trả về mảng 2 cái ngày bắt đầu và kết thúc dạng số +const parseOpenDay = (openDay) => { + if (!openDay || typeof openDay !== "string") { + console.error("Invalid openDay:", openDay); + return [0, 0]; + } + const days = openDay.split(" to "); + if (days.length !== 2) { + console.error("Invalid openDay format:", openDay); + return [0, 0]; + } + const [startDay, endDay] = days; + return [dayToNumber[startDay], dayToNumber[endDay]]; +}; + +// tạo ra mảng các ngày trong tuần +const getDaysOfWeek = (startOfWeek, openDay) => { + let days = []; + const [startDay, endDay] = parseOpenDay(openDay); + if (startDay === 0 || endDay === 0) { + console.error("Invalid days parsed:", { startDay, endDay }); + return days; + } + + for (var i = startDay; i <= endDay; i++) { + days.push(dayjs(startOfWeek).add(i, "day")); + } + + return days; +}; + +// hàm generate các slot từ openTime đến closeTime +const generateTimeSlots = (openTime, closeTime) => { + let slots = []; + for (let hour = openTime; hour < closeTime; hour++) { + const start = formatTime(hour); + const end = formatTime(hour + 1); + slots.push(`${start} - ${end}`); + } + return slots; +}; + +const formatTime = (time) => { + const hours = Math.floor(time); + const minutes = Math.round((time - hours) * 60); + const formattedHours = hours < 10 ? `0${hours}` : hours; + const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes; + return `${formattedHours}:${formattedMinutes}`; +}; + +const timeStringToDecimal = (timeString) => { + const date = new Date(`1970-01-01T${timeString}Z`); + const hours = date.getUTCHours(); + const minutes = date.getUTCMinutes(); + const seconds = date.getUTCSeconds(); + return hours + minutes / 60 + seconds / 3600; +}; + +const BookByDay = () => { + const location = useLocation(); + const { branch } = location.state; + + const [selectedBranch, setSelectedBranch] = useState(branch.branchId); + const [showAfternoon, setShowAfternoon] = useState(false); + const [startOfWeek, setStartOfWeek] = useState(dayjs().startOf("week")); + const [weekdayPrice, setWeekdayPrice] = useState(0); + const [weekendPrice, setWeekendPrice] = useState(0); + const [numberOfCourt, setNumberOfCourts] = useState(''); + const [selectedSlots, setSelectedSlots] = useState([]); + const [openTime, setOpentime] = useState(branch.openTime); + const [closeTime, setClosetime] = useState(branch.closeTime); + const [openDay, setOpenDay] = useState(branch.openDay); + const [weekDays, setWeekDays] = useState([]); + const [morningTimeSlots, setMorningTimeSlots] = useState([]); + const [afternoonTimeSlots, setAfternoonTimeSlots] = useState([]); + const navigate = useNavigate(); + const currentDate = dayjs(); + const [highlightedStars, setHighlightedStars] = useState(0); + const [reviewText, setReviewText] = useState(""); + const [reviewFormVisible, setReviewFormVisible] = useState(false); + const [reviews, setReviews] = useState([]); + const [reviewsVisible, setReviewsVisible] = useState(false); + const [editingReview, setEditingReview] = useState(null); + const [userId, setUserId] = useState(null); + const [isUserVip, setUserVip] = useState(false); + const [userData, setUserData] = useState(null); + const [user, setUser] = useState(null); + const [connection, setConnection] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [AverageRating, setAverageRating] = useState(null); + const [listRating, setListRating] = useState([]); + const [unavailableSlots, setUnavailableSlot] = useState([]); + //dùng cái này để thay thế startOfWeek vì startOfWeek chỉ set về ngày hiện tại + const [newWeekStart, setNewWeekStart] = useState(dayjs().startOf("week")); + const selectBranchRef = useRef(selectedBranch); + const [showLogin, setShowLogin] = useState(false); // State to manage visibility of RequestLogin component + const [showRequestBooking, setShowRequestBooking] = useState(false); + useEffect(() => { + selectBranchRef.current = selectedBranch; + }, [selectedBranch]); + const newWeekStartRef = useRef(newWeekStart); + useEffect(() => { + newWeekStartRef.current = newWeekStart; + }, [newWeekStart]); + + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + const decodedToken = jwtDecode(token); + setUserId(decodedToken.Id); + + const fetchUserData = async (id, isGoogle) => { + try { + if (isGoogle) { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/GetUserDetailByUserEmail/${id}` + ); + setUserData(response.data); + + + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/GetUserDetailByUserEmail/${id}?searchValue=${id}` + ); + setUser(userResponse.data); + + } else { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/${id}` + ); + setUserData(response.data); + console.log('response nè:', response.data.isVip); + setUserVip(response.data.isVip); + const userResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/Users/${id}` + ); + setUser(userResponse.data); + } + } catch (error) { + console.error("Error fetching user data:", error); + } + }; + + if (decodedToken.iss !== "https://accounts.google.com") { + const userId = decodedToken.Id; + setUserId(userId); + fetchUserData(userId, false); + } else { + const userId = decodedToken.email; + setUserId(userId); + fetchUserData(userId, true); + } + } + }, []); + + //signalR + useEffect(() => { + const newConnection = new HubConnectionBuilder() + .withUrl("https://courtcaller.azurewebsites.net/timeslothub") + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Information) + .build(); + + newConnection.onreconnecting((error) => { + console.log(`Connection lost due to error "${error}". Reconnecting.`); + setIsConnected(false); + }); + + newConnection.onreconnected((connectionId) => { + console.log( + `Connection reestablished. Connected with connectionId "${connectionId}".` + ); + setIsConnected(true); + }); + + newConnection.onclose((error) => { + console.log( + `Connection closed due to error "${error}". Try refreshing this page to restart the connection.` + ); + setIsConnected(false); + }); + + newConnection.on("DisableSlot", (slotCheckModel) => { + console.log("Received DisableSlot:", slotCheckModel); + + //check nếu mà slot trả về có branch và date trùng với branch và date mà mình đang chọn thì set lại unavailable slot + const startOfWeekDayjs = dayjs(newWeekStartRef.current); //lấy ra đúng cái ngày đầu tiên của tuần user chọn + console.log("startOfWeekDayjs:", startOfWeekDayjs.format("YYYY-MM-DD")); + + const fromDate = startOfWeekDayjs.add(1, "day").startOf("day"); + const toDate = startOfWeekDayjs.add(7, "day").endOf("day"); + const slotDate = dayjs(slotCheckModel.slotDate, "YYYY-MM-DD"); + + console.log( + "fromDate :", + fromDate.format("YYYY-MM-DD"), + "toDate ", + toDate.format("YYYY-MM-DD"), + "slotDate:", + slotDate.format("YYYY-MM-DD") + ); + + //check lẻ dkien + const isBranchMatch = slotCheckModel.branchId === selectBranchRef.current; + console.log( + "branch của signalR:", + slotCheckModel.branchId, + "branch mình chọn:", + selectBranchRef.current, + "check thử cái này ", + selectBranchRef + ); + const isDateMatch = slotDate.isBetween(fromDate, toDate, "day", "[]"); + console.log("isBranchMatch:", isBranchMatch, "isDateMatch:", isDateMatch); + if (isBranchMatch && isDateMatch) { + console.log("điều kiện là true"); + const { + slotDate, + timeSlot: { slotStartTime, slotEndTime }, + } = slotCheckModel; + const newSlot = { slotDate, slotStartTime, slotEndTime }; + + setUnavailableSlot((prev) => [...prev, newSlot]); + } + }); + + setConnection(newConnection); + }, []); + //check unavailable slot + useEffect(() => { + console.log("UnavailableSlot:", unavailableSlots); + }, [unavailableSlots]); + + useEffect(() => { + if (connection) { + const startConnection = async () => { + try { + await connection.start(); + console.log("SignalR Connected."); + setIsConnected(true); + } catch (error) { + console.error("SignalR Connection Error:", error); + setIsConnected(false); + setTimeout(startConnection, 5000); + } + }; + startConnection(); + } + }, [connection]); + + const handleStarClick = (value) => { + setHighlightedStars(value); + }; + + const handleReviewTextChange = (event) => { + setReviewText(event.target.value); + }; + + const handleSubmitReview = async () => { + const token = localStorage.getItem('token'); + if(!token){ + setShowLogin(true); + return; + } + + if (!userData || !userData.userId) { + console.error("User data is not available"); + return; + } + + const checkBooking = await fetchBookingByUserId(userData.userId); + const listBranchId = checkBooking.map((booking) => booking.branchId); + if(!listBranchId.includes(selectedBranch)){ + setShowRequestBooking(true); + return; + } + + if(checkBooking.length == 0){ + setShowRequestBooking(true); + return; + } + + try { + if (!token) { + throw new Error("No token found"); + } + + const reviewData = { + reviewText, + rating: highlightedStars, + userId: userData.userId, + branchId: branch.branchId, // Đảm bảo rằng branchId đang được cung cấp ở đây nếu cần + }; + + try { + await axios.post( + "https://courtcaller.azurewebsites.net/api/Reviews", + reviewData + ); + setReviewFormVisible(false); + // Xử lý sau khi gửi đánh giá thành công (ví dụ: thông báo cho người dùng, cập nhật danh sách đánh giá, v.v.) + } catch (error) { + console.error("Error submitting review", error); + // Xử lý lỗi khi gửi đánh giá + } + } catch (error) { + navigate("/login"); + } + }; + + const handleViewReviews = async () => { + try { + const response = await axios.get( + `https://courtcaller.azurewebsites.net/api/Reviews/GetReviewsByBranch/${selectedBranch}`, + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + const reviewsWithDetails = await Promise.all( + response.data.map(async (review) => { + console.log("review", review.id); + let userFullName = "Unknown User"; + if (review.id) { + try { + const userDetailsResponse = await axios.get( + `https://courtcaller.azurewebsites.net/api/UserDetails/${review.id}`, + { + headers: { + "Content-Type": "application/json", + }, + } + ); + userFullName = userDetailsResponse.data.fullName; + } catch (userDetailsError) { + console.error("Error fetching user details:", userDetailsError); + } + } + return { ...review, userFullName }; + }) + ); + + setReviews(reviewsWithDetails); + setReviewsVisible(true); + } catch (error) { + console.error("Error fetching reviews:", error); + } + }; + + const handleEditReview = (review) => { + setEditingReview(review); + setReviewText(review.reviewText); + setHighlightedStars(review.rating); + }; + + const handleUpdateReview = async () => { + try { + const token = localStorage.getItem("token"); + if (!token) { + throw new Error("No token found"); + } + + const updatedReviewData = { + reviewText, + rating: highlightedStars, + userId: editingReview.id, + branchId: editingReview.branchId, + }; + + console.log("editingReview", editingReview); + + const response = await axios.put( + `https://courtcaller.azurewebsites.net/api/Reviews/${editingReview.reviewId}`, + updatedReviewData, + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + const updatedReviews = reviews.map((review) => + review.id === editingReview.id ? response.data : review + ); + setReviews(updatedReviews); + setEditingReview(null); + setReviewText(""); + setHighlightedStars(0); + } catch (error) { + console.error("Error updating review:", error); + } + }; + + // fetch giá theo branch đã chọn + useEffect(() => { + const fetchPrices = async () => { + try { + console.log("isUserVip:", isUserVip); + const prices = await fetchPrice(isUserVip,selectedBranch); + setWeekdayPrice(prices.weekdayPrice); + setWeekendPrice(prices.weekendPrice); + console.log("prices:", prices); + } catch (error) { + console.error("Error fetching prices", error); + } + }; + + fetchPrices(); + }, [selectedBranch,isUserVip]); + + useEffect(() => { + const fetchNumberOfCourts = async () => { + try { + const response = await fetch(`https://courtcaller.azurewebsites.net/numberOfCourt/${selectedBranch}`); + const data = await response.json(); + setNumberOfCourts(data); + } catch (err) { + console.error(`Failed to fetch number of courts for branch ${selectedBranch}`); + } + }; + fetchNumberOfCourts(); + }, [selectedBranch]); + + // Parse openDay và lấy ngày trong tuần + useEffect(() => { + if (openDay) { + const days = getDaysOfWeek(startOfWeek, openDay); + setWeekDays(days); + //console.log('Computed weekDays:', days); + } + }, [openDay, startOfWeek]); + + // tạo ra các slot nhỏ sáng từ opentime đến 14h + useEffect(() => { + if (openTime && "14:00:00") { + const decimalOpenTime = timeStringToDecimal(openTime); + const decimalCloseTime = timeStringToDecimal("14:00:00"); + const timeSlots = generateTimeSlots(decimalOpenTime, decimalCloseTime); + setMorningTimeSlots(timeSlots); + + } + }, [openTime]); + + // tạo ra các slot nhỏ chiều từ 14h đến closeTime + useEffect(() => { + if (closeTime && "14:00:00") { + const decimalOpenTime = timeStringToDecimal("14:00:00"); + const decimalCloseTime = timeStringToDecimal(closeTime); + //console.log('decimalOpenTime:', decimalOpenTime); + //console.log('decimalCloseTime:', decimalCloseTime); + const timeSlots = generateTimeSlots(decimalOpenTime, decimalCloseTime); + setAfternoonTimeSlots(timeSlots); + //console.log('generate timeSlots:', timeSlots); + } + }, [closeTime]); + + // xử lý khi click vào slot + const handleSlotClick = (slot, day, price) => { + const slotId = `${day.format("YYYY-MM-DD")}_${slot}_${price}`; + + // Tìm tất cả các slot cùng thời gian đã được chọn + const sameTimeSlots = selectedSlots.filter((selectedSlot) => + selectedSlot.slotId.startsWith(`${day.format("YYYY-MM-DD")}_${slot}`) + ); + + // Nếu slot đã chọn tồn tại và đã chọn đủ 2 slot cùng thời gian, hủy chọn slot đầu tiên + if (sameTimeSlots.length >= 2) { + const firstSlotId = sameTimeSlots[0].slotId; + setSelectedSlots( + selectedSlots.filter( + (selectedSlot) => selectedSlot.slotId !== firstSlotId + ) + ); + } else { + // Nếu tổng số slot đã chọn nhỏ hơn 3, thêm slot mới + if (selectedSlots.length < 3) { + setSelectedSlots([...selectedSlots, { slotId, slot, day, price }]); + } else { + alert("You can select up to 3 slots only"); + } + } + }; + + const handleRemoveSlot = (slotId) => { + setSelectedSlots( + selectedSlots.filter((selectedSlot) => selectedSlot.slotId !== slotId) + ); + }; + + // xử lý nút sáng chiều + const handleToggleMorning = () => { + setShowAfternoon(false); + }; + + const handleToggleAfternoon = () => { + setShowAfternoon(true); + }; + + // xử lý chỉ hiện 1 tuần trước và các tuần sau + const handlePreviousWeek = async () => { + const currentWeekStart = dayjs().startOf("week"); + const oneWeekBeforeCurrentWeek = dayjs() + .startOf("week") + .subtract(1, "week"); + const oneWeekBeforeStartOfWeek = dayjs(startOfWeek).subtract(1, "week"); + // Không cho phép quay về tuần trước tuần hiện tại + if (oneWeekBeforeStartOfWeek.isBefore(currentWeekStart, "week")) { + return; + } + if ( + !dayjs(startOfWeek).isSame(oneWeekBeforeCurrentWeek, "week") && + oneWeekBeforeStartOfWeek.isAfter(oneWeekBeforeCurrentWeek) + ) { + setStartOfWeek(oneWeekBeforeStartOfWeek); + } else if (dayjs(startOfWeek).isSame(oneWeekBeforeCurrentWeek, "week")) { + setStartOfWeek(oneWeekBeforeCurrentWeek); + } + + const newWeekStart = oneWeekBeforeStartOfWeek.format("YYYY-MM-DD"); + setNewWeekStart(newWeekStart); + const unavailableSlot = await fetchUnavailableSlots( + newWeekStart, + selectedBranch + ); + const slots = Array.isArray(unavailableSlot) ? unavailableSlot : []; + setUnavailableSlot(slots); + }; + + const handleNextWeek = async () => { + const newWeekStart = dayjs(startOfWeek).add(1, "week").format("YYYY-MM-DD"); + setNewWeekStart(newWeekStart); + setStartOfWeek(dayjs(startOfWeek).add(1, "week")); + console.log("startOfWeek là dcm:", startOfWeek.format("YYYY-MM-DD")); + const unavailableSlot = await fetchUnavailableSlots( + newWeekStart, + selectedBranch + ); + const slots = Array.isArray(unavailableSlot) ? unavailableSlot : []; + setUnavailableSlot(slots); + }; + + // xử lý khi click vào nút continue qua trang tiếp theo + const handleContinue = async () => { + const token = localStorage.getItem('token'); + if(!token) { + setShowLogin(true); + return; + } + + if (!selectedBranch) { + alert("Please select a branch first"); + return; + } + + const bookingRequests = selectedSlots.map((slot) => { + const { day, slot: timeSlot, price } = slot; + const [slotStartTime, slotEndTime] = timeSlot.split(" - "); + + return { + slotDate: day.format("YYYY-MM-DD"), + timeSlot: { + slotStartTime: `${slotStartTime}:00`, + slotEndTime: `${slotEndTime}:00`, + }, + price: parseFloat(price), + }; + }); + + navigate("/payment-detail", { + state: { + branchId: selectedBranch, + bookingRequests, + totalPrice: bookingRequests.reduce( + (totalprice, object) => totalprice + parseFloat(object.price), + 0 + ), + }, + }); + }; + const isSlotUnavailable = (day, slot) => { + const formattedDay = day.format("YYYY-MM-DD"); + const slotStartTime = slot.split(" - ")[0]; + return unavailableSlots.some((unavailableSlot) => { + return ( + unavailableSlot.slotDate === formattedDay && + unavailableSlot.slotStartTime === `${slotStartTime}:00` + ); + }); + }; + const getSlotColor = (day, slot, isSelected) => { + const isPastSlot = + day.isBefore(currentDate, "day") || + (day.isSame(currentDate, "day") && + timeStringToDecimal(currentDate.format("HH:mm:ss")) > + timeStringToDecimal(slot.split(" - ")[0]) + 0.25) || + isSlotUnavailable(day, slot); + + if (isSelected) return "#1976d2"; + if (isPastSlot) return "#E0E0E0"; + return "#D9E9FF"; + }; + useEffect(() => { + const fetchInitialUnavailableSlots = async () => { + const currentWeekStart = dayjs(startOfWeek).format("YYYY-MM-DD"); + const unavailableSlot = await fetchUnavailableSlots( + currentWeekStart, + selectedBranch + ); + const slots = Array.isArray(unavailableSlot) ? unavailableSlot : []; + setUnavailableSlot(slots); + }; + + if (selectedBranch) { + fetchInitialUnavailableSlots(); + } + }, [selectedBranch, startOfWeek]); + + //fetch rating tổng xong rồi đưa vào averageRating + useEffect(() => { + const fetchRating = async () => { + try { + console.log('selectedBranch của rating:', selectedBranch); + const data = await fetchPercentRatingByBranch(selectedBranch); + setAverageRating(data); + } catch (error) { + console.error('Error fetching rating', error); + } + }; + + fetchRating(); +}, [selectedBranch]); + + //fetch rating nhỏ +useEffect(() => { + const fetchEachPercentRating = async () => { + try { + const data = await fetchEachPercentRatingByBranch(selectedBranch); + console.log('data:', data); + setListRating(data); + } catch (error) { + console.error('Error fetching rating', error); + } + }; + fetchEachPercentRating(); +}, [selectedBranch]); + + + + + const days = weekDays; + const pictures = JSON.parse(branch.branchPicture).slice(0, 5); + + return ( + <> +
+
+
+

{branch.branchName}

+

+ {" "} + {branch.branchAddress} +

+

{branch.description}

+
+ +
+
+
+ {pictures.map((picture, index) => ( +
+ img-fluid +
+ ))} +
+ img-fluid +
+
+ img-fluid +
+
+ img-fluid +
+
+
+ +
+
Branch Information
+
+
+ Open Time: + {branch.openTime} +
+
+ Close Time: + {branch.closeTime} +
+
+ Number of courts: + {numberOfCourt} +
+
+ Weekday Price: + {weekdayPrice} VND +
+
+ Weekend Price: + {weekendPrice} VND +
+
+ Phone: + {branch.branchPhone} +
+
+
+
Convenient Service
+
+ + Wifi + + + Motorbike Parking + + + Car Parking + + + Food + + + Drinks + + + Free Ice Tea + +
+
+
+
+
+ + + + + + {selectedBranch} + + + + {/* Khung ngày */} + <> + {branch.branchId && ( + + + + + + From {dayjs(startOfWeek).add(1, "day").format("D/M")} To{" "} + {dayjs(startOfWeek).add(7, "day").format("D/M")} + + + + + + )} + + <> + {branch.branchId && ( + + + + + )} + + + + {days.map((day, dayIndex) => ( + + + + + {day.format("ddd")} + + + {day.format("D/M")} + + + + + {(showAfternoon ? afternoonTimeSlots : morningTimeSlots).map( + (slot, slotIndex) => { + const price = + day.day() >= 1 && day.day() <= 5 + ? weekdayPrice + : weekendPrice; // Monday to Friday for weekdays, Saturday to Sunday for weekends + const slotId = `${day.format("YYYY-MM-DD")}_${slot}_${price}`; + const isSelected = selectedSlots.some( + (selectedSlot) => selectedSlot.slotId === slotId + ); + const slotCount = selectedSlots.filter( + (selectedSlot) => selectedSlot.slotId === slotId + ).length; + + return ( + + + + ); + } + )} + + ))} + <> + {branch.branchId && ( + + + + )} + + + {/* Map */} +
+
+
+

Branch Location

+

{branch.branchAddress}

+
+
+
+
+
+
+ Branch Location +
+
+ +
+
+
+
+ {/* Rating form */} +
+
+

Rating this Branch

+
+
+ {AverageRating} + +
+
+ + +
+
+
+
+ ★★★★★ +
+
+
+ {/* làm tròn đơn vị math round được chưa nhân*/} + {Math.round(listRating[4])}% +
+
+ ★★★★☆ +
+
+
+ {Math.round(listRating[3])}% +
+
+ ★★★☆☆ +
+
+
+ {Math.round(listRating[2])}% +
+
+ ★★☆☆☆ +
+
+
+ {Math.round(listRating[1])}% +
+
+ ★☆☆☆☆ +
+
+
+ {Math.round(listRating[0])}% +
+
+ {reviewFormVisible && ( +
+

Tell us your experience

+
+ {[1, 2, 3, 4, 5].map((value) => ( + = value ? "highlight" : "" + }`} + data-value={value} + onClick={() => handleStarClick(value)} + > + ★ + + ))} +
+
+ +
+ +
+ )} + {reviewsVisible && ( + <> +
setReviewsVisible(false)} + >
+
+
+

All Reviews

+
+
+ +
+
+ {reviews.map((review, index) => ( +
+
+
+ + {review.userFullName} + + + {review.rating} + + +
+ {userData && review.id === userData.userId && ( + handleEditReview(review)} + /> + )} +
+ {editingReview?.id === review.id ? ( +
+
+ {[1, 2, 3, 4, 5].map((value) => ( + = value ? "highlight" : "" + }`} + data-value={value} + onClick={() => handleStarClick(value)} + > + ★ + + ))} +
+ +
+ +
+ )} + {reviewsVisible && ( + <> +
setReviewsVisible(false)} + >
+
+
+

All Reviews

+
+
+ +
+
+ {reviews.map((review, index) => ( +
+
+
+ + {review.userFullName} + + + {review.rating} + + +
+ {review.id === userData.userId && ( + handleEditReview(review)} + /> + )} +
+ {editingReview?.id === review.id ? ( +
+
+ {[1, 2, 3, 4, 5].map((value) => ( + = value ? "highlight" : "" + }`} + data-value={value} + onClick={() => handleStarClick(value)} + > + ★ + + ))} +
+ +
+ +
+ )} + {reviewsVisible && ( + <> +
setReviewsVisible(false)} + >
+
+
+

All Reviews

+
+
+ +
+
+ {reviews.map((review, index) => ( +
+
+
+ + {review.userFullName} + + + {review.rating} + + +
+ {review.id === userData.userId && ( + handleEditReview(review)} + /> + )} +
+ {editingReview?.id === review.id ? ( +
+
+ {[1, 2, 3, 4, 5].map((value) => ( + = value ? "highlight" : "" + }`} + data-value={value} + onClick={() => handleStarClick(value)} + > + ★ + + ))} +
+