diff --git a/frontend/nextjs/emt.config.ts b/frontend/nextjs/emt.config.ts index aae2b48..36bf7e1 100644 --- a/frontend/nextjs/emt.config.ts +++ b/frontend/nextjs/emt.config.ts @@ -45,11 +45,11 @@ import {chain, envChains} from './contracts' export const USERS_COLLECTION = collection(firestore, 'users'); + export const ADMIN_COLLECTION = collection(firestore, 'admin'); export const NOTIFICATIONS_COLLECTION = process.env.NODE_ENV === "production"? collection(firestore, 'notifications') : collection(firestore, 'dev', String(process.env.NEXT_PUBLIC_DEV!) + chain.id , 'notifications'); export const CLAIM_HISTORY_COLLECTION = process.env.NODE_ENV === "production"? collection(firestore, 'claimHistory') : collection(firestore, 'dev', String(process.env.NEXT_PUBLIC_DEV!) + chain.id , 'claimHistory'); export const CONTENTS_COLLECTION = process.env.NODE_ENV === "production"? collection(firestore, 'contents') : collection(firestore, 'dev', String(process.env.NEXT_PUBLIC_DEV!) + chain.id, 'contents'); export const EXPT_LISTINGS_COLLECTION = process.env.NODE_ENV === "production"? collection(firestore, 'exptListings') : collection(firestore, 'dev', String(process.env.NEXT_PUBLIC_DEV!) + chain.id, 'exptListings'); export const BOOKINGS_COLLECTION = process.env.NODE_ENV === "production"? collection(firestore, 'bookings') : collection(firestore, 'dev', String(process.env.NEXT_PUBLIC_DEV!) + chain.id, 'bookings'); - export const ADMIN_COLLECTION = process.env.NODE_ENV === "production"? collection(firestore, 'admin') : collection(firestore, 'dev', String(process.env.NEXT_PUBLIC_DEV!) + chain.id, 'admin'); export const exptLevelKeys = [1, 2, 3] diff --git a/frontend/nextjs/package-lock.json b/frontend/nextjs/package-lock.json index 937305f..34360a7 100644 --- a/frontend/nextjs/package-lock.json +++ b/frontend/nextjs/package-lock.json @@ -50,6 +50,7 @@ "react-hook-form": "^7.48.2", "react-icons": "^4.11.0", "react-quill": "^2.0.0", + "react-share": "^5.0.3", "siwe": "^2.1.4", "tailwind-merge": "^2.0.0", "tailwindcss-animate": "^1.0.7", @@ -5886,6 +5887,11 @@ "url": "https://joebell.co.uk" } }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -9036,6 +9042,27 @@ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, + "node_modules/jsonp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/jsonp/-/jsonp-0.2.1.tgz", + "integrity": "sha512-pfog5gdDxPdV4eP7Kg87M8/bHgshlZ5pybl+yKxAnCZ5O7lCIn7Ixydj03wOlnDQesky2BPyA91SQ+5Y/mNwzw==", + "dependencies": { + "debug": "^2.1.3" + } + }, + "node_modules/jsonp/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/jsonp/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -11045,6 +11072,18 @@ } } }, + "node_modules/react-share": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-share/-/react-share-5.0.3.tgz", + "integrity": "sha512-8BFkzfd8zNrO6JMP4Dwrt2sTSrRGQ9bNrU3K0riAwRJe4U/Z8/ryjKuhP2jLoHsfxj38MsPuyEQiVlIHVIICvw==", + "dependencies": { + "classnames": "^2.3.2", + "jsonp": "^0.2.1" + }, + "peerDependencies": { + "react": "^17 || ^18" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", diff --git a/frontend/nextjs/package.json b/frontend/nextjs/package.json index 12d11ff..af20ba3 100644 --- a/frontend/nextjs/package.json +++ b/frontend/nextjs/package.json @@ -52,6 +52,7 @@ "react-hook-form": "^7.48.2", "react-icons": "^4.11.0", "react-quill": "^2.0.0", + "react-share": "^5.0.3", "siwe": "^2.1.4", "tailwind-merge": "^2.0.0", "tailwindcss-animate": "^1.0.7", diff --git a/frontend/nextjs/src/app/(with wallet)/_components/providers.tsx b/frontend/nextjs/src/app/(with wallet)/_components/providers.tsx index 7a178dc..50b21b9 100644 --- a/frontend/nextjs/src/app/(with wallet)/_components/providers.tsx +++ b/frontend/nextjs/src/app/(with wallet)/_components/providers.tsx @@ -30,7 +30,7 @@ export default function DappProviders({ }: { children: React.ReactNode }) { - // TODO: @Jovells persist Signupdata lo loaclstorage + // TODO: @Jovells persist Signupdata lo localstorage return ( diff --git a/frontend/nextjs/src/app/(with wallet)/_components/user-List.tsx b/frontend/nextjs/src/app/(with wallet)/_components/user-List.tsx index 66bda24..f6700e7 100644 --- a/frontend/nextjs/src/app/(with wallet)/_components/user-List.tsx +++ b/frontend/nextjs/src/app/(with wallet)/_components/user-List.tsx @@ -7,9 +7,14 @@ import Link from "next/link"; import { PROFILE_PAGE } from "./page-links"; import { HiCheckBadge, HiOutlineFire } from "react-icons/hi2"; import { Badge } from "@/components/ui/badge"; +import { useUser } from "@/lib/hooks/user"; + +export default function UserList({ filters, max = 5 }: { filters?: ProfileFilters, max?: number }) { + const { fetchProfiles} = useBackend(); + const {user} = useUser(); + if (filters?.isNotFollowing && !user) + return
Sign in To get Suggestions
-export default function UserList({ filters }: { filters?: ProfileFilters }) { - const { fetchProfiles } = useBackend(); return ( data.uid} filters={filters} noDataMessage="No profiles found. Please try later" + getNextPageParam={(lastPage) => { + if(max && max<= lastPage.length) return undefined + return lastPage[lastPage.length - 1] + }} ItemComponent={({ data }: { data: UserProfile }) => ( { @@ -140,7 +141,7 @@ const PostTemplate = ({ post, isLoading, isFollowingUser, toggleFollowing }: Pos
- + {post.author?.displayName} {post.author?.isExpert === true && } @@ -200,9 +201,7 @@ const PostTemplate = ({ post, isLoading, isFollowingUser, toggleFollowing }: Pos - + diff --git a/frontend/nextjs/src/app/(with wallet)/dapp/p/create/_components/create-post-form.tsx b/frontend/nextjs/src/app/(with wallet)/dapp/p/create/_components/create-post-form.tsx index c41510d..9efd949 100644 --- a/frontend/nextjs/src/app/(with wallet)/dapp/p/create/_components/create-post-form.tsx +++ b/frontend/nextjs/src/app/(with wallet)/dapp/p/create/_components/create-post-form.tsx @@ -146,7 +146,7 @@ const CreatePostForm = () => { /> {/* Only display the post type if you're answering a question */} - {form.watch().postType == POST_TYPE[2] && ( + {form.watch().postType == POST_TYPE[2] ? ( { )} /> - )} + ):( + (form.getFieldState('questionPostURL').isDirty&&form.resetField('questionPostURL')), "") + } + @@ -274,7 +277,7 @@ const CreatePostForm = () => { + ) diff --git a/frontend/nextjs/src/components/ui/posts.tsx b/frontend/nextjs/src/components/ui/posts.tsx index 02e0d06..d71e2c3 100644 --- a/frontend/nextjs/src/components/ui/posts.tsx +++ b/frontend/nextjs/src/components/ui/posts.tsx @@ -7,14 +7,16 @@ import InfiniteScroll from "./infinite-scroller"; type Props = { filters?: { owner?: string; tags?: string[]; isFollowing?: true }; + sizePerFetch?: number; }; -export default function Posts({ filters }: Props) { +export default function Posts({ filters, sizePerFetch }: Props) { const { fetchPosts } = useBackend(); return ( data.metadata.id} + size={sizePerFetch || 5} ItemComponent={(props) => ( <> diff --git a/frontend/nextjs/src/components/ui/profile-search-card.tsx b/frontend/nextjs/src/components/ui/profile-search-card.tsx index 87888dd..e7ca2f5 100644 --- a/frontend/nextjs/src/components/ui/profile-search-card.tsx +++ b/frontend/nextjs/src/components/ui/profile-search-card.tsx @@ -23,7 +23,7 @@ const ProfileSearchCard = ({ data }: { data: Content }) => { return ( - +
diff --git a/frontend/nextjs/src/components/ui/share-button.tsx b/frontend/nextjs/src/components/ui/share-button.tsx new file mode 100644 index 0000000..3787fd7 --- /dev/null +++ b/frontend/nextjs/src/components/ui/share-button.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import {Button} from './button'; +import {Popover} from './popover'; +import {EmailShareButton, FacebookShareButton, TwitterShareButton, LinkedinShareButton, EmailIcon, FacebookIcon, LinkedinIcon, TwitterIcon, XIcon} from 'react-share' +import { toast } from './use-toast'; +import { HiOutlineShare } from 'react-icons/hi2'; +import { PopoverContent, PopoverTrigger } from '@radix-ui/react-popover'; + +export default function ShareButton({title, path} : {title: string, path: string}) { + const [isOpen, setIsOpen] = useState(false); + + const handleButtonClick = () => { + setIsOpen(!isOpen); + }; + + const handleCopyLink = () => { + navigator.clipboard.writeText(url); + toast({ + title: 'Link copied to clipboard', + variant: 'success', + }) + setIsOpen(false); + }; + const url = location.origin + path + + return ( +
+ + + + + + setIsOpen(false)} sideOffset={12} alignOffset={0} align="end" className=" shadow-lg p-2 rounded flex space-x-2 bg-accent-shade"> + + + + + + + + + +
+ ); +}; + diff --git a/frontend/nextjs/src/components/ui/use-toast.ts b/frontend/nextjs/src/components/ui/use-toast.ts index 600c14b..d251748 100644 --- a/frontend/nextjs/src/components/ui/use-toast.ts +++ b/frontend/nextjs/src/components/ui/use-toast.ts @@ -6,6 +6,15 @@ import type { ToastProps, } from "@/components/ui/toast" + // INFO: if you want to create a toast with a progress bar, use the snippet below + // toast({ + // title: "Profile updated!", + // description:
+ // + //
, + // duration: Infinity + // }); + const TOAST_LIMIT = 1 const TOAST_REMOVE_DELAY = 1000000 diff --git a/frontend/nextjs/src/components/ui/voter.tsx b/frontend/nextjs/src/components/ui/voter.tsx index c6dfc1d..f51adf6 100644 --- a/frontend/nextjs/src/components/ui/voter.tsx +++ b/frontend/nextjs/src/components/ui/voter.tsx @@ -63,10 +63,11 @@ export default function Voter({ post }: { post: Content }) { e: React.MouseEvent ) { const voteType = e.currentTarget.name as "upvote" | "downvote"; + console.log('voting', voteType, post ) const res = await mutateAsync({ id: post.metadata.id, voteType, - owner: post.author?.uid, + owner: post.post.owner, }); console.log("res", res); } diff --git a/frontend/nextjs/src/lib/hooks/useBackend.tsx b/frontend/nextjs/src/lib/hooks/useBackend.tsx index 4079a94..16c38a5 100644 --- a/frontend/nextjs/src/lib/hooks/useBackend.tsx +++ b/frontend/nextjs/src/lib/hooks/useBackend.tsx @@ -79,7 +79,6 @@ import { } from "@rainbow-me/rainbowkit"; import { Progress } from "@/components/ui/progress"; - function loadingToast( message: string, stage?: number | undefined, @@ -523,7 +522,7 @@ export default function useBackend() { } /** * Fetches a single listing from the database. - * + * * @param id - The ID of the listing to fetch. * @returns A promise that resolves to the fetched listing. * @throws An error if the listing does not exist. @@ -543,7 +542,7 @@ export default function useBackend() { /** * Fetches the number of followers for a user. - * + * * @param id - The ID of the user. * @returns The number of followers. */ @@ -557,7 +556,7 @@ export default function useBackend() { /** * Fetches the number of followers for a given user ID. - * + * * @param id - The ID of the user. * @returns The number of followers. */ @@ -606,7 +605,7 @@ export default function useBackend() { } /** * Fetches the number of unclaimed ment for the logged-in user. - * + * * @returns The number of unclaimed ment. * @throws If the user is not logged in. */ @@ -628,7 +627,7 @@ export default function useBackend() { /** * Fetches the unclaimed experience points (expt) for the logged-in user. - * + * * @returns The number of unclaimed experience points. * @throws {Error} If the user is not logged in or if there is an error fetching the experience points. */ @@ -658,7 +657,7 @@ export default function useBackend() { /** * Fetches the profile of a user. - * + * * @param uid - The user ID. * @param exclude - An optional object specifying which parts of the profile to exclude. * - followers: Set to true to exclude the number of followers. @@ -706,6 +705,29 @@ export default function useBackend() { } } + /** + * Retrieves the IDs of the users that a user is following. + * @param uid - The user ID. If not provided, the current user's ID will be used. + * @returns A promise that resolves to an array of followings IDs. + */ + async function getUserFollowingsIds(uid = user?.uid, size=10) { + try { + const q = query( + collectionGroup(firestore, "followers"), + where("uid", "==", uid), + limit(size) + ); + console.log("isfollowing"); + const snap = await getDocs(q); + console.log(snap) + const uids = snap.docs.map((d) => d.ref.path.split("/")[1]); + return uids; + } catch (e) { + console.log("error fetching uids of followings", e); + return []; + } + } + /** * Fetches posts from the database. * @param lastDocTimeStamp - The timestamp of the last document. @@ -733,7 +755,9 @@ export default function useBackend() { q = query(q, where("owner", "==", filters.owner)); } if (filters?.isFollowing) { - q = query(q, where("owner", "in", filters.isFollowing)); + const uidsOfFollowings = await getUserFollowingsIds(); + console.log('pppp, fetching posts following', uidsOfFollowings) + q = query(q, where('owner', "in", uidsOfFollowings)); } const querySnapshot = await getDocs(q); @@ -741,7 +765,6 @@ export default function useBackend() { const promises = querySnapshot.docs.map(async (doc) => { const post = doc.data() as Content["post"]; const { author, metadata } = await fetchPostMetadata(post.owner, doc.id); - author.uid = post.owner return { post, author, metadata }; }); const posts: Content[] = await Promise.all(promises); @@ -750,17 +773,17 @@ export default function useBackend() { /** * Fetches privacy policy from the database. - * @param + * @param * @returns An the latest privacy policy & timestamp. */ async function fetchPrivacyPolicy(): Promise { - console.log('calling fetch...') + console.log("calling fetch..."); - const policyDocRef = doc(ADMIN_COLLECTION, 'privacy-policy'); + const policyDocRef = doc(ADMIN_COLLECTION, "privacy-policy"); const policyDoc = await getDoc(policyDocRef); if (policyDoc.exists()) { const data = policyDoc.data() as PolicyDoc; - return data + return data; } else { throw new Error("Policy document not found!"); } @@ -800,7 +823,7 @@ export default function useBackend() { } catch (err: any) { console.log(err); t("Error creating post", 0, true); - throw new Error(err) + throw new Error(err); } async function uploadPostImage(id: string) { if (!post.image) return ""; @@ -831,7 +854,10 @@ export default function useBackend() { } } - async function savePostToFirestore(imageURL: string, docRef: DocumentReference) { + async function savePostToFirestore( + imageURL: string, + docRef: DocumentReference + ) { try { console.log("writing to database"); await setDoc(docRef, { @@ -870,7 +896,7 @@ export default function useBackend() { const t = loadingToast("Submitting your request", 1); try { // TODO @od41 error: missing permissions - const docRef = doc(ADMIN_COLLECTION); ; + const docRef = doc(ADMIN_COLLECTION); await saveRequestToFirestore(docRef); console.log("Document written with ID: ", docRef.id); t("Request submitted successfully", 100); @@ -878,7 +904,7 @@ export default function useBackend() { } catch (err: any) { console.log(err); t("Error submitting request", 0, true); - throw new Error(err) + throw new Error(err); } async function saveRequestToFirestore(docRef: DocumentReference) { @@ -940,6 +966,12 @@ export default function useBackend() { let tx: ContractTransactionResponse; const isUpvote = voteType === "upvote"; try { + console.log( + "voting in contract. firebases Id:", + id, + "contentId:", + contentId + ); if (isUpvote) { tx = await emtMarketplace.upVoteContent(contentId); } else if (voteType === "downvote") { @@ -958,7 +990,7 @@ export default function useBackend() { createNotification({ type: voteType, contentId: id, - recipients: [user.uid], + recipients: [owner], }); t("Vote SuccessFul", 100); return { @@ -982,7 +1014,7 @@ export default function useBackend() { /** * Follows a user by adding the current user's ID to the followers list of the specified user. - * + * * @param id - The ID of the user to follow. * @returns A boolean indicating whether the user was successfully followed. * @throws An error if the user is not logged in or if there is an error following the user. @@ -1002,7 +1034,7 @@ export default function useBackend() { //TODO: @Jovells enforce this at rules level and remove this check to avoid extra roundrtip to db if (await checkFollowing(id)) return false; - await setDoc(userFollowersRef, { timestamp: serverTimestamp() }); + await setDoc(userFollowersRef, { timestamp: serverTimestamp(), uid: user.uid }); createNotification({ type: "follow", recipients: [id] }); @@ -1014,7 +1046,7 @@ export default function useBackend() { /** * Unfollows a user by removing the current user's ID from the followers list of the specified user. - * + * * @param id - The ID of the user to unfollow. * @returns A boolean indicating whether the user was successfully unfollowed. * @throws An error if there is an error unfollowing the user. @@ -1037,7 +1069,7 @@ export default function useBackend() { throw new Error("Error unfollowing user. Message: " + err.message); } } - + /** * Lists the expts and performs necessary operations such as uploading cover photo, * saving to Firestore, and listing in the contract. @@ -1107,82 +1139,30 @@ export default function useBackend() { } } - - /** - * Fetches the list of expt listings populated with author profile based on the provided filters. - * @param lastDocTimeStamp - The timestamp of the last document. - * @param size - The number of listings to fetch. - * @param filters - The filters to apply. - * @returns A promise that resolves to an array of expt listings with author profile. - */ - async function fetchExptListings( - lastDocTimeStamp?: Timestamp, - size = 1, - filters?: ExptFilters - ): Promise { - // Split the tokenIds array into chunks of 30 because of firebase array-contains limit - if (filters?.mentee) { - const ownedExpts = await fetchOwnedExptIds(filters.mentee); - filters.tokenIds = ownedExpts; - } - const tokenIdsChunks = filters?.tokenIds - ? chunkArray(filters.tokenIds, 30) - : [[]]; - - const listingPromises = tokenIdsChunks.map(async (tokenIds) => { - let q = query( - EXPT_LISTINGS_COLLECTION, - orderBy("timestamp", "desc"), - limit(size) - ); - - if (lastDocTimeStamp) { - q = query(q, startAfter(lastDocTimeStamp)); - } - if (filters?.tags) { - q = query(q, where("tags", "array-contains-any", filters.tags)); - } - if (filters?.author) { - q = query(q, where("owner", "==", filters.author)); - } - if (tokenIds.length > 0) { - q = query(q, where("tokenIds", "array-contains-any", tokenIds)); - } - - const querySnapshot = await getDocs(q); - if (querySnapshot.empty) return []; - - const withAuthorPromises = querySnapshot.docs.map(async (doc) => { - const listing = doc.data() as ExptListingWithAuthorProfile; - listing.id = doc.id; - listing.authorProfile = await fetchProfile(listing.author); - return listing; - }); - return await Promise.all(withAuthorPromises); - }); - - const listingsArrays = await Promise.all(listingPromises); - - // Flatten the array of arrays into a single array - const listings = listingsArrays.flat(); - - return listings; + /** + * Fetches the list of expt listings populated with author profile based on the provided filters. + * @param lastDocTimeStamp - The timestamp of the last document. + * @param size - The number of listings to fetch. + * @param filters - The filters to apply. + * @returns A promise that resolves to an array of expt listings with author profile. + */ + async function fetchExptListings( + lastDocTimeStamp?: Timestamp, + size = 1, + filters?: ExptFilters + ): Promise { + // Split the tokenIds array into chunks of 30 because of firebase array-contains limit + if (filters?.mentee) { + const ownedExpts = await fetchOwnedExptIds(filters.mentee); + filters.tokenIds = ownedExpts; } + const tokenIdsChunks = filters?.tokenIds + ? chunkArray(filters.tokenIds, 30) + : [[]]; - /** - * Fetches the list of bookings based on the provided filters. - * @param lastDocTimeStamp - The timestamp of the last document. - * @param size - The number of bookings to fetch. - * @param filters - The filters to apply. - * @returns A promise that resolves to an array of bookings. - */ - async function fetchBookings( - lastDocTimeStamp?: Timestamp, - size = 1, - filters?: BookingFilters - ): Promise { + const listingPromises = tokenIdsChunks.map(async (tokenIds) => { let q = query( - BOOKINGS_COLLECTION, + EXPT_LISTINGS_COLLECTION, orderBy("timestamp", "desc"), limit(size) ); @@ -1193,82 +1173,134 @@ export default function useBackend() { if (filters?.tags) { q = query(q, where("tags", "array-contains-any", filters.tags)); } - if (filters?.mentee) { - q = query(q, where("mentee", "==", filters.mentee)); - } - if (filters?.mentor) { - q = query(q, where("mentor", "==", filters.mentor)); + if (filters?.author) { + q = query(q, where("owner", "==", filters.author)); } - if (filters?.isPast) { - q = query(q, where("timestamp", "<", serverTimestamp())); - } - if (filters?.isUpcoming) { - q = query(q, where("timestamp", ">", serverTimestamp())); + if (tokenIds.length > 0) { + q = query(q, where("tokenIds", "array-contains-any", tokenIds)); } const querySnapshot = await getDocs(q); + if (querySnapshot.empty) return []; - const bookingPromises = querySnapshot.docs.map(async (doc) => { - const booking = doc.data() as Booking; - booking.id = doc.id; - booking.exptListing = await fetchSingleListing(booking.exptListingId); - return booking; + const withAuthorPromises = querySnapshot.docs.map(async (doc) => { + const listing = doc.data() as ExptListingWithAuthorProfile; + listing.id = doc.id; + listing.authorProfile = await fetchProfile(listing.author); + return listing; + }); + return await Promise.all(withAuthorPromises); }); - const bookings = await Promise.all(bookingPromises); - return bookings; - } - // Helper function to split an array into chunks - function chunkArray(array: T[], chunkSize: number): T[][] { - return Array(Math.ceil(array.length / chunkSize)) - .fill(null) - .map((_, index) => index * chunkSize) - .map((begin) => array.slice(begin, begin + chunkSize)); + const listingsArrays = await Promise.all(listingPromises); + + // Flatten the array of arrays into a single array + const listings = listingsArrays.flat(); + + return listings; + } + + /** + * Fetches the list of bookings based on the provided filters. + * @param lastDocTimeStamp - The timestamp of the last document. + * @param size - The number of bookings to fetch. + * @param filters - The filters to apply. + * @returns A promise that resolves to an array of bookings. + */ + async function fetchBookings( + lastDocTimeStamp?: Timestamp, + size = 1, + filters?: BookingFilters + ): Promise { + let q = query( + BOOKINGS_COLLECTION, + orderBy("timestamp", "desc"), + limit(size) + ); + + if (lastDocTimeStamp) { + q = query(q, startAfter(lastDocTimeStamp)); + } + if (filters?.tags) { + q = query(q, where("tags", "array-contains-any", filters.tags)); + } + if (filters?.mentee) { + q = query(q, where("mentee", "==", filters.mentee)); + } + if (filters?.mentor) { + q = query(q, where("mentor", "==", filters.mentor)); + } + if (filters?.isPast) { + q = query(q, where("timestamp", "<", serverTimestamp())); + } + if (filters?.isUpcoming) { + q = query(q, where("timestamp", ">", serverTimestamp())); } + const querySnapshot = await getDocs(q); - /** - * Updates the user profile with the provided updates. - * Also validates the updates before updating the user. - * @param updates - The profile updates. - * @throws Error if there is an error updating the user profile. - * @returns A promise that resolves to the updated profile. - */ - async function updateProfile(updates: { - displayName?: string; - profilePicture?: File; - about?: string; - username?: string; - tags?: string; - }) { - const _updates: { [key: string]: string | boolean | File } = { ...updates }; - if (updates.profilePicture) { - try { - const imageURL = await uploadImage( - updates.profilePicture, - user?.uid!, - "profilePictures" - ); - _updates.photoURL = imageURL; - delete _updates.profilePicture; - } catch (err: any) { - throw new Error("Error uploading image. Details: " + err.message); - } - } + const bookingPromises = querySnapshot.docs.map(async (doc) => { + const booking = doc.data() as Booking; + booking.id = doc.id; + booking.exptListing = await fetchSingleListing(booking.exptListingId); + return booking; + }); + const bookings = await Promise.all(bookingPromises); + return bookings; + } + + // Helper function to split an array into chunks + function chunkArray(array: T[], chunkSize: number): T[][] { + return Array(Math.ceil(array.length / chunkSize)) + .fill(null) + .map((_, index) => index * chunkSize) + .map((begin) => array.slice(begin, begin + chunkSize)); + } + /** + * Updates the user profile with the provided updates. + * Also validates the updates before updating the user. + * @param updates - The profile updates. + * @throws Error if there is an error updating the user profile. + * @returns A promise that resolves to the updated profile. + */ + async function updateProfile(updates: { + displayName?: string; + profilePicture?: File; + about?: string; + username?: string; + tags?: string; + }) { + const _updates: { [key: string]: string | boolean | File } = { ...updates }; + if (updates.profilePicture) { try { - return await sendUserProfileUpdates(_updates); + const imageURL = await uploadImage( + updates.profilePicture, + user?.uid!, + "profilePictures" + ); + _updates.photoURL = imageURL; + delete _updates.profilePicture; } catch (err: any) { - throw new Error("Error updating user profile. Details: " + err.message); + throw new Error("Error uploading image. Details: " + err.message); } - /** + } + + try { + return await sendUserProfileUpdates(_updates); + } catch (err: any) { + throw new Error("Error updating user profile. Details: " + err.message); + } + /** * Sends the provided updates to the database. * Validation is done here * @param updates - The updates to apply to the user. * @returns A promise that resolves to the updated user. * @returns A promise that resolves to an object containing the validation error if there is a validation error. */ - async function sendUserProfileUpdates(updates: Omit, "email">) { + async function sendUserProfileUpdates( + updates: Omit, "email"> + ) { const updateResult = (await update({ updates })) as unknown as { updateValidationError: { code: string; @@ -1281,275 +1313,306 @@ export default function useBackend() { } return updates; } + } + /** + * Checks if the current user is following the specified user. + * @param id - The ID of the user to check. + * @returns A promise that resolves to a boolean indicating if the current user is following the specified user. + */ + async function checkFollowing(id: string) { + try { + const userFollowersRef = doc( + USERS_COLLECTION, + id, + "followers", + user?.uid! + ); + const userFollowersSnap = await getDoc(userFollowersRef); + return !!userFollowersSnap.data()?.uid + } catch (err: any) { + console.log("error checking following", err.message, id, user); } + return false; + } - /** - * Checks if the current user is following the specified user. - * @param id - The ID of the user to check. - * @returns A promise that resolves to a boolean indicating if the current user is following the specified user. - */ - async function checkFollowing(id: string) { - try { - const userFollowersRef = doc( - USERS_COLLECTION, - id, - "followers", - user?.uid! - ); - const userFollowersSnap = await getDoc(userFollowersRef); - return !!userFollowersSnap.exists(); - } catch (err: any) { - console.log("err", err.message); - } - return false; + /** + * Fetches the claim history of the specified user. + * @param uid - The ID of the user. Defaults to the current user's ID. + * @returns A promise that resolves to an array of claim history items. + */ + async function fetchClaimHistory(uid = user?.uid) { + try { + const historySnap = await getDocs( + query( + CLAIM_HISTORY_COLLECTION, + where("uid", "==", uid), + orderBy("timestamp", "desc") + ) + ); + const history = historySnap.docs.map((doc) => { + const data = doc.data(); + data.id = doc.id; + return data; + }); + return history as ClaimHistoryItem[]; + } catch (err) { + console.log("error fetching claim history. ", err); } + } - /** - * Fetches the claim history of the specified user. - * @param uid - The ID of the user. Defaults to the current user's ID. - * @returns A promise that resolves to an array of claim history items. - */ - async function fetchClaimHistory(uid = user?.uid) { + /** + * Buys an expt listing. + * @param listing - The expt listing to buy. + * @returns A promise that resolves to a boolean indicating if the purchase was successful. + * @throws Error if the user is not logged in or there is an error buying the expt. + */ + async function buyExpt(listing: ExptListing) { + if (!user?.uid) { + throw new Error("User not logged in"); + } + async function updateListingInFireStore(boughtTokenId: number) { try { - const historySnap = await getDocs( - query( - CLAIM_HISTORY_COLLECTION, - where("uid", "==", uid), - orderBy("timestamp", "desc") - ) - ); - const history = historySnap.docs.map((doc) => { - const data = doc.data(); - data.id = doc.id; - return data; + updateDoc(doc(EXPT_LISTINGS_COLLECTION, listing.id), { + remainingTokenIds: arrayRemove(boughtTokenId), + collectionSize: increment(-1), }); - return history as ClaimHistoryItem[]; - } catch (err) { - console.log("error fetching claim history. ", err); + } catch (error) { + console.log("error Updating Token listing ", error); } } - - /** - * Buys an expt listing. - * @param listing - The expt listing to buy. - * @returns A promise that resolves to a boolean indicating if the purchase was successful. - * @throws Error if the user is not logged in or there is an error buying the expt. - */ - async function buyExpt(listing: ExptListing) { - if (!user?.uid) { - throw new Error("User not logged in"); - } - async function updateListingInFireStore(boughtTokenId: number) { + try { + console.log("approving stableCoin transfer in contract"); + const tx = await stableCoin.approve( + emtMarketplace.target, + listing.price * 10 ** 6 + ); + const receipt = await tx.wait(); + console.log(receipt); + console.log("buying expts in contract"); + let exptToBuyIndex = listing.remainingTokenIds.length - 1; + + //this loop is here because the chosen expt to buy + // might have been bought already before this user completes the purchase + while (exptToBuyIndex >= 0) { + const tokenToBuyId = listing.remainingTokenIds[exptToBuyIndex]; try { - updateDoc(doc(EXPT_LISTINGS_COLLECTION, listing.id), { - remainingTokenIds: arrayRemove(boughtTokenId), - collectionSize: increment(-1), - }); - } catch (error) { - console.log("error Updating Token listing ", error); - } - } - try { - console.log("approving stableCoin transfer in contract"); - const tx = await stableCoin.approve( - emtMarketplace.target, - listing.price * 10 ** 6 - ); - const receipt = await tx.wait(); - console.log(receipt); - console.log("buying expts in contract"); - let exptToBuyIndex = listing.remainingTokenIds.length - 1; - - //this loop is here because the chosen expt to buy - // might have been bought already before this user completes the purchase - while (exptToBuyIndex >= 0) { - const tokenToBuyId = listing.remainingTokenIds[exptToBuyIndex]; - try { - console.log("tokenToBuyId", tokenToBuyId, listing); - const tx = await emtMarketplace.buyExpt(tokenToBuyId); - await tx!.wait(); - console.log("bought expts in contract"); - await updateListingInFireStore(tokenToBuyId); - return true; - } catch (err: any) { - if (err.message.includes("No deposit yet for token id")) { - console.log("this expt has probably been bought. Trying the next"); - exptToBuyIndex = exptToBuyIndex - 1; - } else throw new Error(err); - } + console.log("tokenToBuyId", tokenToBuyId, listing); + const tx = await emtMarketplace.buyExpt(tokenToBuyId); + await tx!.wait(); + console.log("bought expts in contract"); + await updateListingInFireStore(tokenToBuyId); + return true; + } catch (err: any) { + if (err.message.includes("No deposit yet for token id")) { + console.log("this expt has probably been bought. Trying the next"); + exptToBuyIndex = exptToBuyIndex - 1; + } else throw new Error(err); } - } catch (err: any) { - console.log("Error buying expts. Message: " + err.message); - return false; } + } catch (err: any) { + console.log("Error buying expts. Message: " + err.message); + return false; } + } - /** - * Fetches the IDs of the expts owned by the specified user. - * @param uid - The ID of the user. Defaults to the current user's ID. - * @returns A promise that resolves to an array of expt IDs. - */ - async function fetchOwnedExptIds(uid = user?.uid) { - if (!uid) return []; - try { - console.log("fetching tokens of user"); - const val = await expertToken.tokensOfOwner(uid); - const tokenIds = val.map((id) => Number(id)); - console.log("tokens of owner", tokenIds); - return tokenIds; - } catch (err: any) { - console.log("error fetching owned expts ids ", err); - return []; - } + /** + * Fetches the IDs of the expts owned by the specified user. + * @param uid - The ID of the user. Defaults to the current user's ID. + * @returns A promise that resolves to an array of expt IDs. + */ + async function fetchOwnedExptIds(uid = user?.uid) { + if (!uid) return []; + try { + console.log("fetching tokens of user"); + const val = await expertToken.tokensOfOwner(uid); + const tokenIds = val.map((id) => Number(id)); + console.log("tokens of owner", tokenIds); + return tokenIds; + } catch (err: any) { + console.log("error fetching owned expts ids ", err); + return []; } + } - /** - * Fetches the profiles based on the provided filters. - * @param lastdocParam - The last document parameter. - * @param size - The number of profiles to fetch. - * @param filters - The filters to apply. - * @returns A promise that resolves to an array of profiles. - */ - async function fetchProfiles( - lastdocParam?: any, - size = 5, - filters?: ProfileFilters - ) { - let q = query(USERS_COLLECTION, limit(size)); - if (lastdocParam) { - q = query(q, startAfter(lastdocParam)); - } - if (filters?.ment) { - q = query(q, orderBy("ment", filters.ment)); - } - if (filters?.level) { - q = query( - q, - where("ment", ">=", exptLevels![filters.level].requiredMent) - ); - } - if (filters?.tags) { - q = query(q, where("tags", "array-contains-any", filters.tags)); - } - if (filters?.numFollowers) { - //TODO @Jovells update follow function to store count in firestore - q = query(q, orderBy("numFollowers", filters.numFollowers)); + /** + * Fetches the profiles based on the provided filters. + * @param lastdocParam - The last document parameter. + * @param size - The number of profiles to fetch. + * @param filters - The filters to apply. + * @returns A promise that resolves to an array of profiles. + */ + async function fetchProfiles( + lastdoc?: UserProfile, + size = 5, + filters?: ProfileFilters + ) { + let q = query(USERS_COLLECTION, limit(size)); + + if (filters?.ment) { + console.log("filters.ment", filters); + q = query(q, orderBy("ment", filters.ment), startAfter(lastdoc?.ment || "")); + } + if (filters?.level) { + q = query( + q, + where("ment", ">=", exptLevels![filters.level].requiredMent) + ); + } + if (filters?.tags) { + q = query(q, where("tags", "array-contains-any", filters.tags)); + } + if (filters?.numFollowers) { + //TODO @Jovells update follow function to store count in firestore + q = query(q, orderBy("numFollowers", filters.numFollowers), startAfter(lastdoc?.numFollowers || "")); + } + if (filters?.usernames) { + q = query( + q, + orderBy('username'), + startAfter(lastdoc?.username || ""), + where( + "usernameLowercase", + "in", + filters?.usernames.map((u) => u.toLowerCase()) + ) + ); + } + if (filters?.isFollowing) { + console.log('fetching profiles following') + const uidsOfFollowings = await getUserFollowingsIds(); + filters.uids = filters.uids + ? filters.uids.filter((value) => uidsOfFollowings.includes(value)) + : uidsOfFollowings; + } + if (filters?.uids) { + q = query(q, where(documentId(), "in", filters.uids), orderBy(documentId()), startAfter(lastdoc?.uid || "")); + } + if (filters?.isNotFollowing) { + q= query(q, orderBy(documentId()), startAfter(lastdoc?.uid || " ")) + console.trace('1. fetching profiles not following', size) + + const profiles = await doFetch(); + const profilesNotFollowing: UserProfile[] = []; + if(profiles.length === 0) return [] + for (let profile of profiles) { + const isFollowing = await checkFollowing(profile.uid); + if (profile.uid !== user?.uid && !isFollowing ) { + profilesNotFollowing.push(profile) + } } - if (filters?.usernames) { - q = query( - q, - where( - "usernameLowercase", - "in", - filters?.usernames.map((u) => u.toLowerCase()) - ) + if (profilesNotFollowing.length < size) { + const moreProfiles = await fetchProfiles( + profiles[ + profiles.length - 1 + ], + size - profilesNotFollowing.length, + filters ); - } - if (filters?.uids) { - q = query(q, where(documentId(), "in", filters.uids)); - } - if (filters?.isFollowing) { - //TODO @Jovells - } - if (filters?.isNotFollowing) { - //TODO @Jovells - } - + profilesNotFollowing.push(...moreProfiles); + } + return profilesNotFollowing; + } + + const profiles = await doFetch(); + return profiles + + async function doFetch() { const querySnapshot = await getDocs(q); const profiles = querySnapshot.docs.map((doc) => { const data = doc.data(); data.uid = doc.id; - return data; + return data as UserProfile; }); return profiles; } + } - /** - * Fetches the member votes for the specified content. - * @param id - The ID of the content. - * @returns A promise that resolves to an array of member votes. - * @throws Error if the user is not logged in or there is an error fetching the member votes. - */ - async function fetchMemberVotes(id: string) { - if (!user?.uid) throw new Error("User not logged in"); - console.log("fetchingMemberVotes", id); - try { - const contentId = ethers.encodeBytes32String(id); - const [upvoted, downvoted] = await emtMarketplace.memberVotes( - contentId, - user.uid - ); - return [upvoted, downvoted]; - } catch (err: any) { - console.log("error fetching member votes", err); - throw new Error(err); - } + /** + * Fetches the member votes for the specified content. + * @param id - The ID of the content. + * @returns A promise that resolves to an array of member votes. + * @throws Error if the user is not logged in or there is an error fetching the member votes. + */ + async function fetchMemberVotes(id: string) { + if (!user?.uid) throw new Error("User not logged in"); + console.log("fetchingMemberVotes", id); + try { + const contentId = ethers.encodeBytes32String(id); + const [upvoted, downvoted] = await emtMarketplace.memberVotes( + contentId, + user.uid + ); + return [upvoted, downvoted]; + } catch (err: any) { + console.log("error fetching member votes", err); + throw new Error(err); } + } - /** - * Fetches the votes for the specified post. - * @param id - The ID of the post. - * @returns A promise that resolves to the post votes. - */ - async function fetchPostVotes(id: string): Promise { - console.log("fetchPostVotes", id); - try { - const contentId = ethers.encodeBytes32String(id); - const [_upvotes, _downvotes, diffrence] = - await emtMarketplace.contentVotes(contentId); - const [userUpvoted, userDownvoted] = user?.uid - ? await fetchMemberVotes(id) - : [undefined, undefined]; - return { - upvotes: Number(_upvotes), - downvotes: Number(_downvotes), - userUpvoted, - userDownvoted, - }; - } catch (e: any) { - console.log("error fetching post votes", e); - return { - upvotes: 0, - downvotes: 0, - userUpvoted: undefined, - userDownvoted: undefined, - }; - } + /** + * Fetches the votes for the specified post. + * @param id - The ID of the post. + * @returns A promise that resolves to the post votes. + */ + async function fetchPostVotes(id: string): Promise { + console.log("fetchPostVotes", id); + try { + const contentId = ethers.encodeBytes32String(id); + const [_upvotes, _downvotes, diffrence] = + await emtMarketplace.contentVotes(contentId); + const [userUpvoted, userDownvoted] = user?.uid + ? await fetchMemberVotes(id) + : [undefined, undefined]; + return { + upvotes: Number(_upvotes), + downvotes: Number(_downvotes), + userUpvoted, + userDownvoted, + }; + } catch (e: any) { + console.log("error fetching post votes", e); + return { + upvotes: 0, + downvotes: 0, + userUpvoted: undefined, + userDownvoted: undefined, + }; } + } - const profileReady = exptLevels !== undefined; - - return { - balances, - refetchBalances, - isFetchingBalances, - fetchPostVotes, - createPost, - submitRequest, - fetchClaimHistory, - fetchBookings, - buyExpt, - fetchExptListings, - fetchSingleListing, - listExpts, - profileReady, - updateProfile, - fetchUnclaimedExpt, - fetchUnclaimedMent, - claimMent, - claimExpt, - fetchNotifications, - fetchUserPosts, - uploadImage, - followUser, - unfollowUser, - fetchPosts, - fetchProfile, - checkFollowing, - voteOnPost, - fetchSinglePost, - fetchProfiles, - fetchPrivacyPolicy - } + const profileReady = exptLevels !== undefined; + + return { + balances, + refetchBalances, + isFetchingBalances, + fetchPostVotes, + createPost, + submitRequest, + fetchClaimHistory, + fetchBookings, + buyExpt, + fetchExptListings, + fetchSingleListing, + listExpts, + profileReady, + updateProfile, + fetchUnclaimedExpt, + fetchUnclaimedMent, + claimMent, + claimExpt, + fetchNotifications, + fetchUserPosts, + uploadImage, + followUser, + unfollowUser, + fetchPosts, + fetchProfile, + checkFollowing, + voteOnPost, + fetchSinglePost, + fetchProfiles, + fetchPrivacyPolicy, }; +} diff --git a/frontend/nextjs/src/lib/hooks/user.tsx b/frontend/nextjs/src/lib/hooks/user.tsx index 1147588..852f475 100644 --- a/frontend/nextjs/src/lib/hooks/user.tsx +++ b/frontend/nextjs/src/lib/hooks/user.tsx @@ -36,7 +36,7 @@ export function useUser(): UserContext { export function UserProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const { data: session, status, update }: { data: UserSession | null} & ReturnType = useSession(); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const signUpDataRef = useRef({}); const signUpData = signUpDataRef.current; @@ -79,7 +79,6 @@ export function UserProvider({ children }: { children: React.ReactNode }) { } const signIn = useMemo(() => async function (options?:{redirect?: boolean }) { - console.error('signing in', session?.firebaseToken?.substring(0, 4)) try { setIsLoading(true); if (session?.firebaseToken) { @@ -105,6 +104,9 @@ export function UserProvider({ children }: { children: React.ReactNode }) { }, [session?.firebaseToken]) useLayoutEffect(()=>{ + if(isLoading){ + return + } if (PROTECTED_ROUTES.some( route=> pathname.startsWith(route)) && !user ){ router.push(HOME_PAGE) } @@ -131,6 +133,10 @@ export function UserProvider({ children }: { children: React.ReactNode }) { //logged in to next auth if (session?.firebaseToken) { signIn({redirect: false}) + + } + else{ + setIsLoading(false) } }, [session?.firebaseToken, user?.uid]) diff --git a/frontend/nextjs/src/lib/types.ts b/frontend/nextjs/src/lib/types.ts index 135ad55..40544fb 100644 --- a/frontend/nextjs/src/lib/types.ts +++ b/frontend/nextjs/src/lib/types.ts @@ -51,7 +51,7 @@ export interface Content { metadata: PostVotes & { id: string; }, - author: UserProfile + author: Omit } export interface ExpertTicket {