diff --git a/package-lock.json b/package-lock.json index 95a99ed..bcd834b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@testing-library/user-event": "^12.8.3", "bootstrap": "^5.2.0-beta1", "bootstrap-icons": "^1.8.3", + "fast-fuzzy": "^1.11.2", "lodash": "^4.17.21", "moment": "^2.29.3", "msw": "^0.43.1", @@ -8154,6 +8155,14 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-fuzzy": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/fast-fuzzy/-/fast-fuzzy-1.11.2.tgz", + "integrity": "sha512-H1ct10Pzx+pSO4h7F1uBXET91ay2hy67J1aQZFKL23EXsOoanpwjPNQQoc+NhClKJMmlGGN+0bXhIdFJX70BJw==", + "dependencies": { + "graphemesplit": "^2.4.1" + } + }, "node_modules/fast-glob": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", @@ -9038,6 +9047,15 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, + "node_modules/graphemesplit": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/graphemesplit/-/graphemesplit-2.4.4.tgz", + "integrity": "sha512-lKrpp1mk1NH26USxC/Asw4OHbhSQf5XfrWZ+CDv/dFVvd1j17kFgMotdJvOesmHkbFX9P9sBfpH8VogxOWLg8w==", + "dependencies": { + "js-base64": "^3.6.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/graphql": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.5.0.tgz", @@ -10933,6 +10951,11 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-base64": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz", + "integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==" + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -12479,6 +12502,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -16618,6 +16646,11 @@ "resolved": "https://registry.npmjs.org/timer2/-/timer2-1.0.0.tgz", "integrity": "sha512-UOZql+P2ET0da+B7V3/RImN3IhC5ghb+9cpecfUhmYGIm0z73dDr3A781nBLnFYmRzeT1AmoT4w9Lgr8n7n7xg==" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -16886,6 +16919,15 @@ "node": ">=4" } }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -23959,6 +24001,14 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "fast-fuzzy": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/fast-fuzzy/-/fast-fuzzy-1.11.2.tgz", + "integrity": "sha512-H1ct10Pzx+pSO4h7F1uBXET91ay2hy67J1aQZFKL23EXsOoanpwjPNQQoc+NhClKJMmlGGN+0bXhIdFJX70BJw==", + "requires": { + "graphemesplit": "^2.4.1" + } + }, "fast-glob": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", @@ -24617,6 +24667,15 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, + "graphemesplit": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/graphemesplit/-/graphemesplit-2.4.4.tgz", + "integrity": "sha512-lKrpp1mk1NH26USxC/Asw4OHbhSQf5XfrWZ+CDv/dFVvd1j17kFgMotdJvOesmHkbFX9P9sBfpH8VogxOWLg8w==", + "requires": { + "js-base64": "^3.6.0", + "unicode-trie": "^2.0.0" + } + }, "graphql": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.5.0.tgz", @@ -26005,6 +26064,11 @@ } } }, + "js-base64": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz", + "integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==" + }, "js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -27179,6 +27243,11 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, "param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -30102,6 +30171,11 @@ "resolved": "https://registry.npmjs.org/timer2/-/timer2-1.0.0.tgz", "integrity": "sha512-UOZql+P2ET0da+B7V3/RImN3IhC5ghb+9cpecfUhmYGIm0z73dDr3A781nBLnFYmRzeT1AmoT4w9Lgr8n7n7xg==" }, + "tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -30303,6 +30377,15 @@ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz", "integrity": "sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==" }, + "unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "requires": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", diff --git a/package.json b/package.json index 894c2ff..e1a57da 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@testing-library/user-event": "^12.8.3", "bootstrap": "^5.2.0-beta1", "bootstrap-icons": "^1.8.3", + "fast-fuzzy": "^1.11.2", "lodash": "^4.17.21", "moment": "^2.29.3", "msw": "^0.43.1", diff --git a/src/Routes.js b/src/Routes.js index 82c4388..e9bd35c 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -43,20 +43,28 @@ const ProjectRoutes = ({ setToastOptions, option, setOption }) => { } + styles={{ + overlay: (base) => ({ + ...base, + background: "gray", + }), + content: (base) => ({ + ...base, + position: "fixed", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + }), + }} > - {authLoading ? ( -
- ) : ( - - )} +
); @@ -68,14 +76,11 @@ const AnimatedRoutes = ({ isBanned, showSimpleToast, setToastOptions, - authLoading, option, setOption, }) => { // Add TransitionGroup and CSSTransition for animations - return authLoading ? ( -
- ) : !isBanned ? ( + return !isBanned ? ( } /> } /> diff --git a/src/components/ListingCard/listingCard.js b/src/components/ListingCard/listingCard.js index f63be6e..4ffd58b 100644 --- a/src/components/ListingCard/listingCard.js +++ b/src/components/ListingCard/listingCard.js @@ -14,6 +14,7 @@ const ListingCard = ({ setModalState, creator_id, listing_id, + avg_rating, }) => { const levelParams = { primary: "Primary", @@ -34,6 +35,7 @@ const ListingCard = ({ fields, creator_id, listing_id, + avg_rating, }); }; return ( diff --git a/src/components/ListingModal/listingModal.js b/src/components/ListingModal/listingModal.js index 921749d..b278c51 100644 --- a/src/components/ListingModal/listingModal.js +++ b/src/components/ListingModal/listingModal.js @@ -10,6 +10,7 @@ import FieldTag from "components/FieldTag/fieldTag"; import { supabaseClient as supabase } from "config/supabase-client"; import { useNavigate } from "react-router-dom"; import Spinner from "react-bootstrap/Spinner"; +import Rating from "components/Rating/Rating"; const ListingModal = (props) => { const [deleteState, setDeleteState] = useState(0); @@ -23,6 +24,7 @@ const ListingModal = (props) => { fields, creator_id, listing_id, + avg_rating, } = data; const isOwnListing = supabase.auth.user() ? creator_id === supabase.auth.user().id @@ -126,22 +128,24 @@ const ListingModal = (props) => { > View Profile - + }) + } + > + Chat + + )} )} @@ -156,10 +160,19 @@ const ListingModal = (props) => { alt="Avatar" className={listingModalStyles.avatar} /> - - {username} - - {/* Add rating here! (stars) */} +
+ + {username} + +
+ +

+ {avg_rating + ? `${avg_rating?.toFixed(1)} / 5.0` + : "No ratings yet!"} +

+
+
diff --git a/src/components/Rating/Rating.js b/src/components/Rating/Rating.js index 0bb2862..a8a4d47 100644 --- a/src/components/Rating/Rating.js +++ b/src/components/Rating/Rating.js @@ -7,8 +7,14 @@ const colors = { grey: "#a9a9a9", }; -const Rating = ({ setReviews, ratingHover, index: index1, className }) => { - const [currentValue, setCurrentValue] = setReviews; +const Rating = ({ + setReviews, + ratingHover, + index: index1, + className, + size, +}) => { + const [currentValue, setCurrentValue] = setReviews || []; const [hoverValue, setHoverValue] = useState(0); const handleClick = (value) => { setCurrentValue(value); @@ -29,7 +35,7 @@ const Rating = ({ setReviews, ratingHover, index: index1, className }) => { return ( handleClick(index + 1)} onMouseOver={() => handleMouseOver(index + 1)} onMouseLeave={handleMouseLeave} @@ -47,8 +53,8 @@ const Rating = ({ setReviews, ratingHover, index: index1, className }) => { return ( index ? colors.orange : colors.grey} + size={size || 24} + color={Math.round(index1) > index ? colors.orange : colors.grey} /> ); } diff --git a/src/pages/ChatPage/chatPage.js b/src/pages/ChatPage/chatPage.js index 054d0d3..ff2dfeb 100644 --- a/src/pages/ChatPage/chatPage.js +++ b/src/pages/ChatPage/chatPage.js @@ -74,7 +74,7 @@ const ChatPageBody = ({ startChatData, setModalState, unusedModalState, - blockedArray + blockedArray, }) => { // Whether to show the Sidebar or not. Applicable when window width is under 768px. const [showSidebar, setShowSidebar] = useState(window.innerWidth < 768); @@ -119,7 +119,7 @@ const ChatPageBody = ({ const [searchQuery, setSearchQuery] = useState(""); const navigate = useNavigate(); - const uid = supabase.auth.user().id; + const uid = supabase.auth.user()?.id; // Helper functions to add/remove classes from elements const removeClass = (elem, name) => { @@ -800,6 +800,9 @@ const ChatPageBody = ({ // Run only once, at the start. // Currently only supports direct messages. Group chats might be added (TBC) useEffect(() => { + // First check if user is signed in! Guests should not be able to see this page. + if (!uid) navigate("/loginpage"); + const fetchConvos = async () => { try { setLoadingConvos(true); diff --git a/src/pages/CreateListingPage/createListingPage.js b/src/pages/CreateListingPage/createListingPage.js index d74297f..364d62d 100644 --- a/src/pages/CreateListingPage/createListingPage.js +++ b/src/pages/CreateListingPage/createListingPage.js @@ -157,10 +157,12 @@ const CreateListingBody = ({ return; } const fields = [ - ...sFieldInputs.map((sFieldInput) => ({ - category: sFieldInput.requirement, - value: sFieldInput.input().value, - })), + ...sFieldInputs + .filter((sFieldInput) => sFieldInput.value) + .map((sFieldInput) => ({ + category: sFieldInput.requirement, + value: sFieldInput.input().value, + })), ]; const image_urls = imageURLs.map((img) => img.publicURL); diff --git a/src/pages/ListingsPage/__test__/listingsPage.test.js b/src/pages/ListingsPage/__test__/listingsPage.test.js index 97a0276..f8938fb 100644 --- a/src/pages/ListingsPage/__test__/listingsPage.test.js +++ b/src/pages/ListingsPage/__test__/listingsPage.test.js @@ -18,6 +18,7 @@ const wrapPage = (authData = { logged_in: false }) => { subscribe: jest.fn(), })), })); + supabaseClient.removeSubscription = jest.fn(); render( @@ -38,7 +39,6 @@ describe("Tutor/Tutee toggle", () => { it("should be present in the document", () => { wrapPage(); expect(getToggle()).toBeInTheDocument(); - supabaseClient.from.mockClear(); }); // Planned: displays tutor/tutee based on user preferences (AuthContext) diff --git a/src/pages/ListingsPage/listingsPage.js b/src/pages/ListingsPage/listingsPage.js index f08f5d6..292b6d6 100644 --- a/src/pages/ListingsPage/listingsPage.js +++ b/src/pages/ListingsPage/listingsPage.js @@ -6,6 +6,7 @@ import ListingCard from "components/ListingCard/listingCard"; import { supabaseClient as supabase } from "config/supabase-client"; import { CloseButton } from "react-bootstrap"; import { debounce } from "lodash"; +import { components } from "react-select"; import FieldTag from "components/FieldTag/fieldTag"; import Container from "react-bootstrap/Container"; import Row from "react-bootstrap/Row"; @@ -13,6 +14,10 @@ import Col from "react-bootstrap/Col"; import Spinner from "react-bootstrap/Spinner"; import ListingModal from "components/ListingModal/listingModal"; import listingsPageStyles from "./listingsPage.module.css"; +import Select from "react-select"; +import CreatableSelect from "react-select/creatable"; +import Badge from "react-bootstrap/Badge"; +import { fuzzy } from "fast-fuzzy"; const ListingsPage = () => { const { authData } = useContext(AuthContext); @@ -48,6 +53,48 @@ const ListingsPage = () => { export default ListingsPage; const ListingPageBody = ({ setModalState, blockedArray }) => { + // Options for sort field + const sortOptions = [ + { value: "created_at desc", label: "Newest to Oldest" }, + { value: "created_at asc", label: "Oldest to Newest" }, + { value: "avg_rating desc", label: "Highest to Lowest Rating" }, + { value: "avg_rating asc", label: "Lowest to Highest Rating" }, + { value: "num_reviews desc", label: "Most to Least Reviews" }, + { value: "num_reviews asc", label: "Least to Most Reviews" }, + ]; + + // Options for the targeted education level filter + const levelOptions = [ + { value: "primary", label: "Primary" }, + { value: "secondary", label: "Secondary" }, + { value: "tertiary", label: "Tertiary" }, + { value: "undergrad", label: "Undergraduate" }, + { value: "grad", label: "Graduate" }, + { value: "others", label: "Others" }, + ]; + + // Options for the Subject filter + const subjectOptions = [ + { value: "math", label: "Math" }, + { value: "english", label: "English" }, + { value: "science", label: "Science" }, + { value: "literature", label: "Literature" }, + { value: "geography", label: "Geography" }, + ]; + + // Options for the Qualifications Filter + const qualificationsOptions = [ + { value: "phd", label: "PhD" }, + { value: "masters", label: "Masters" }, + { value: "moe teacher", label: "MOE Teacher" }, + { value: "degree", label: "Degree" }, + { value: "undergraduate", label: "Undergraduate" }, + { value: "a levels", label: "A Levels" }, + { value: "diploma", label: "Diploma" }, + { value: "o levels", label: "O Levels" }, + { value: "psle", label: "PSLE" }, + ]; + // Stores the text of the tutor/tutee toggle // Loads previously saved tutor/tutee, if applicable const [tutorTutee, setTutorTutee] = useState( @@ -55,11 +102,43 @@ const ListingPageBody = ({ setModalState, blockedArray }) => { ); // Stores the text entered into the search bar const [query, setQuery] = useState(""); + const [filters, setFilters] = useState([]); + const [sortBy, setSortBy] = useState( + localStorage.getItem("sortBy") || "created_at desc" + ); const searchHandler = () => { setQuery(document.getElementById("search-input").value); }; + const createFilterHandler = (filterName, isMulti, setCreatableState) => { + if (!isMulti) + return (option, action) => { + if (action.action === "clear") + setFilters((old) => old.filter(({ name }) => name !== filterName)); + else + setFilters((old) => [ + ...old, + { name: filterName, value: option.value }, + ]); + }; + else + return (option, action) => { + // Check if new option is being created + if (action.action === "create-option") { + setCreatableState((old) => [...old, action.option]); + } + + if (option.length === 0) + setFilters((old) => old.filter(({ name }) => name !== filterName)); + else + setFilters((old) => [ + ...old.filter(({ name }) => name !== filterName), + { name: filterName, value: option }, + ]); + }; + }; + return ( {/* Header containing "I'm looking for a ..." with the Tutor/Tutee toggle */} @@ -84,6 +163,9 @@ const ListingPageBody = ({ setModalState, blockedArray }) => { { + if (event.key === "Enter") searchHandler(); + }} /> { @@ -101,9 +183,128 @@ const ListingPageBody = ({ setModalState, blockedArray }) => { + + + ({ + ...props, + borderRadius: "20px", + backgroundColor: + filters.filter(({ name }) => name === "level").length === 0 + ? "white" + : "#d4e9e4", + }), + menu: (props) => ({ ...props, width: "12em" }), + clearIndicator: (props) => ({ + ...props, + paddingLeft: 0, + }), + valueContainer: (props) => ({ ...props, paddingRight: "0px" }), + dropdownIndicator: (props, state) => ({ + ...props, + paddingLeft: "0px", + display: state.getValue().length > 0 ? "none" : "flex", + }), + option: (props, state) => ({ + ...props, + backgroundColor: state.isSelected ? "#F0F0F0" : "white", + color: "black", + }), + }} + components={{ + IndicatorSeparator: () => null, + MultiValueRemove: () => null, + MultiValue: (state) => { + const numSelected = state.getValue().length; + if (numSelected > 1 && state.index > 0) return <>; + return ( +

+ {numSelected > 1 ? "Level" : state.data.label} + {numSelected > 1 && {numSelected}} +

+ ); + }, + Option: (props) => CheckboxOption(props, true), + }} + onChange={createFilterHandler("level", true)} + isSearchable={false} + closeMenuOnSelect={false} + hideSelectedOptions={false} + isClearable + isMulti + /> + + + + + + + +
+ {/* Legend for the listing field badge colors */}
@@ -121,6 +322,8 @@ const ListingPageBody = ({ setModalState, blockedArray }) => { query={query} setModalState={setModalState} blockedArray={blockedArray} + filters={filters} + sortBy={sortBy} /> ); @@ -152,81 +355,61 @@ const TutorTuteeToggle = ({ tutorTutee, setTutorTutee }) => { ); }; -const Listings = ({ tutorTutee, query, setModalState, blockedArray }) => { +const Listings = ({ + tutorTutee, + query, + setModalState, + blockedArray, + filters, + sortBy, +}) => { const [listingData, setListingData] = useState([]); + const [filteredListings, setFilteredListings] = useState([]); // Set to true when data is being fetched from Supabase const [loading, setLoading] = useState(false); // Array of objects containing the data of each listing - const parseListing = async ({ - creator_id, - level, - rates, - fields, - image_urls, - listing_id, - }) => { - let { - data: { avatar_url: avatarTitle, username }, - error: avatarError, - status: avatarStatus, - } = await supabase - .from("profiles") - .select("username, avatar_url") - .eq("id", creator_id) - .single(); - if (avatarError && avatarStatus !== 406) throw avatarError; - - const { publicURL: avatarUrl, error: urlError } = - avatarTitle === "" - ? {} - : supabase.storage.from("avatars").getPublicUrl(avatarTitle); - if (urlError) throw urlError; - - return { - avatarUrl, - username, - level, - rates, - fields, - image_urls, - listing_id, - creator_id, - }; - }; + // const filterListing = ({ level, rates, fields }) => + // `${level} ${rates} ${Object.keys(fields).reduce( + // (acc, key) => `${acc} ${fields[key].value}`, + // "" + // )}` + // .toLowerCase() + // .includes(query.toLowerCase()); const filterListing = ({ level, rates, fields }) => - `${level} ${rates} ${Object.keys(fields).reduce( - (acc, key) => `${acc} ${fields[key].value}`, - "" - )}` - .toLowerCase() - .includes(query.toLowerCase()); + fuzzy( + query, + `${level} ${rates} ${Object.keys(fields).reduce( + (acc, key) => `${acc} ${fields[key].value}`, + "" + )}` + ) > 0.8; + + const createComparator = (sortBy) => { + if (!sortBy) return () => 0; + const [field, order] = sortBy.split(" "); + if (order === "asc") return (a, b) => a[field] - b[field]; + else return (a, b) => b[field] - a[field]; + }; // Fetch listings from Supabase and display using ListingCards useEffect(() => { const getListings = async () => { + const [sortField, sortOrder] = sortBy.split(" "); try { setLoading(true); - // Fetch data from the 'listings' table - let { - data: listingDb, - listingError, - listingStatus, - } = await supabase - .from("listings") - .select("creator_id, level, rates, fields, image_urls, listing_id") - .eq("seeking_for", tutorTutee); - if (listingError && listingStatus !== 406) throw listingError; - - // Filter using the entered query (set to "" by default/on clearing the textbox) - listingDb = listingDb.filter(filterListing); - - // Map each of the fetched rows into an async call to obtain each listing creators' - // avatar URL. After all asynchronous calls have been resolved, set the result to - // the listingData state/hook. - const newListingData = await Promise.all(listingDb.map(parseListing)); + // Fetch data using `get_listings()` RPC call + let { data: listingDb, error: listingError } = await supabase + .rpc("get_listings") + .eq("seeking_for", tutorTutee) + .order(sortField, { ascending: sortOrder === "asc" }); + if (listingError) throw listingError; + + // Filter using the selected criteria + const newListingData = listingDb.filter(filterListing); + // listingDb = listingDb.filter(filterListing); //if no blocked user, it will return all the listings if (blockedArray === null) { @@ -252,44 +435,83 @@ const Listings = ({ tutorTutee, query, setModalState, blockedArray }) => { // (because we are actively mutating `listingData` on each call!) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tutorTutee, query]); + }, [tutorTutee, query, sortBy]); - // Listen for live changes to listings - useState(() => { + useEffect(() => { + // Setup listener const listingSub = supabase .from("listings") .on("INSERT", async (payload) => { - const newListingData = await parseListing(payload.new); - if (filterListing(newListingData)) { - setListingData((oldListingData) => [ - ...oldListingData, - newListingData, - ]); + try { + const { data: newListingData, error } = await supabase.rpc( + "get_listings" + ); + if (error) throw error; + + setListingData(newListingData.sort(createComparator(sortBy))); + } catch (error) { + alert(error.message); } }) .on("DELETE", (payload) => { - setListingData((oldListingData) => - oldListingData.filter( + setListingData((oldListingData) => { + return oldListingData.filter( (data) => data.listing_id !== payload.old.listing_id - ) - ); + ); + }); }) .subscribe(); return () => supabase.removeSubscription(listingSub); + + // We are disabling the dependency warning as we only wish to + // run this block once, at the start. + // + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Filter listings, if necessary + useEffect(() => { + const createFilter = ({ name, value }) => { + if (!name && !value) return true; + return (listing) => { + const valueArray = value.map(({ value }) => value); + if (name === "level") { + if (valueArray.includes(listing.level)) return true; + } else { + const relevantFields = listing.fields.filter( + ({ category }) => category === name + ); + for (let filterVal of value) + for (let field of relevantFields) + if (fuzzy(filterVal.value, field.value) > 0.8) return true; + } + return false; + }; + }; + if (filters.length === 0) setFilteredListings(listingData); + else + setFilteredListings( + listingData.filter((listing) => + filters.reduce( + (acc, filterData) => acc && createFilter(filterData)(listing), + true + ) + ) + ); + }, [filters, listingData]); + return (
{loading ? ( - ) : listingData.length === 0 ? ( + ) : filteredListings.length === 0 ? (

Nothing here!

) : ( - {listingData.map( + {filteredListings.map( ({ - avatarUrl, + avatar_url, username, level, rates, @@ -297,10 +519,15 @@ const Listings = ({ tutorTutee, query, setModalState, blockedArray }) => { image_urls, listing_id, creator_id, + avg_rating, }) => { return ( { fields={fields} setModalState={setModalState} creator_id={creator_id} + avg_rating={avg_rating} /> ); } @@ -319,3 +547,114 @@ const Listings = ({ tutorTutee, query, setModalState, blockedArray }) => {
); }; + +const MultiBadge = ({ children }) => ( + + {children} + +); + +const TagFilter = ({ + tagType, + tagLabel, + defaultOptions, + filterState: [filters, setFilters], + createFilterHandler, +}) => { + const [options, setOptions] = useState(defaultOptions); + + const removeOption = (props, event) => { + const toRemove = props.value; + if (!props.hasValue) event.stopPropagation(); + setOptions((old) => old.filter(({ value }) => value !== toRemove)); + }; + + return ( + ({ + ...props, + borderRadius: "20px", + backgroundColor: + filters.filter(({ name }) => name === tagType).length === 0 + ? "white" + : "#d4e9e4", + }), + menu: (props) => ({ ...props, width: "12em" }), + clearIndicator: (props) => ({ + ...props, + paddingLeft: 0, + }), + valueContainer: (props) => ({ ...props, paddingRight: "0px" }), + dropdownIndicator: (props, state) => ({ + ...props, + paddingLeft: "0px", + display: state.getValue().length > 0 ? "none" : "flex", + }), + option: (props, state) => ({ + ...props, + backgroundColor: state.isSelected ? "#F0F0F0" : "white", + color: "black", + }), + }} + components={{ + IndicatorSeparator: () => null, + MultiValueRemove: () => null, + MultiValue: (state) => { + const numSelected = state.getValue().length; + if (numSelected > 1 && state.index > 0) return <>; + return ( +

+ {numSelected > 1 ? tagLabel : state.data.label} + {numSelected > 1 && {numSelected}} +

+ ); + }, + Option: (props) => + CheckboxOption( + props, + defaultOptions.filter(({ value }) => value === props.value).length > + 0, + removeOption + ), + }} + onChange={createFilterHandler(tagType, true, setOptions)} + closeMenuOnSelect={false} + hideSelectedOptions={false} + isClearable + isMulti + /> + ); +}; + +const CheckboxOption = (props, isDefault, removeOption) => ( +
+ + + + {isDefault || props.data.label.includes('Create "') || ( + removeOption(props, event)} + /> + )} + +
+); diff --git a/src/pages/ListingsPage/listingsPage.module.css b/src/pages/ListingsPage/listingsPage.module.css index 3118fcc..fe25eb3 100644 --- a/src/pages/ListingsPage/listingsPage.module.css +++ b/src/pages/ListingsPage/listingsPage.module.css @@ -6,7 +6,7 @@ box-shadow: 0px 4px 4px #00000040; display: flex; flex-direction: column; - overflow: hidden; + overflow: visible; padding: 20px; margin: 40px 0px; max-width: 90%; @@ -136,8 +136,12 @@ flex-wrap: wrap; border-radius: 12px; display: flex; - overflow: hidden; + overflow: visible; padding: 9px 0; margin: 0px 30px; width: 90%; } + +:global(.carousel-control-next), :global(.carousel-control-prev) { + z-index: 0 !important +} \ No newline at end of file