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
-
+ )}
>
)}
@@ -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 }) => {
+
+
+
+
{/* 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