diff --git a/frontend/nextjs/emt.config.ts b/frontend/nextjs/emt.config.ts index 64b7c7e..b06ba74 100644 --- a/frontend/nextjs/emt.config.ts +++ b/frontend/nextjs/emt.config.ts @@ -1,44 +1,54 @@ - -import {connectorsForWallets} from "@rainbow-me/rainbowkit"; +import { connectorsForWallets } from "@rainbow-me/rainbowkit"; import { injectedWallet, metaMaskWallet, walletConnectWallet, -} from '@rainbow-me/rainbowkit/wallets'; - import { configureChains, createConfig } from 'wagmi'; -import { publicProvider } from 'wagmi/providers/public'; +} from "@rainbow-me/rainbowkit/wallets"; +import { configureChains, createConfig } from "wagmi"; +import { publicProvider } from "wagmi/providers/public"; import { collection } from "firebase/firestore"; import { firestore } from "@/lib/firebase"; -import {chain, envChains} from './contracts' +import { chain, envChains } from "./contracts"; import { HOME_PAGE } from "@/app/(with wallet)/_components/page-links"; - const { chains, publicClient } = configureChains( - envChains, - [ - publicProvider() - ] - ); +const { chains, publicClient } = configureChains(envChains, [publicProvider()]); - const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID as string +const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID as string; - const customMetamaskWallet = metaMaskWallet({ projectId, chains }) - customMetamaskWallet.createConnector = () => { - const old = metaMaskWallet({ projectId, chains }).createConnector() - const oldMobileGetUri = old.mobile!.getUri; - old.mobile!.getUri = async () => { - if(window?.ethereum){ - return oldMobileGetUri as any - } - return 'https://metamask.app.link/dapp/'+location.origin+HOME_PAGE +/** + * Creates a custom metamask connector that opens the app in the metamask app if the user is on mobile. + * + */ +const customMetamaskWallet = metaMaskWallet({ projectId, chains }); +/** + * modify the createConnector function to return a custom connector + */ +customMetamaskWallet.createConnector = () => { + const newConnector = metaMaskWallet({ projectId, chains }).createConnector(); + const oldMobileGetUri = newConnector.mobile!.getUri; + /** + * modify the getUri function to return a custom uri + */ + newConnector.mobile!.getUri = async () => { + /** + * if the user is not in the matamask browser, return the default uri + */ + if (window?.ethereum) { + return oldMobileGetUri as any; } - return old - } - - const connectors = connectorsForWallets([ + /** + * if the user is in the metamask browser, return a custom uri + */ + return "https://metamask.app.link/dapp/" + location.origin + HOME_PAGE; + }; + return newConnector; +}; + +const connectors = connectorsForWallets([ { - groupName: 'Recommended', + groupName: "Recommended", wallets: [ injectedWallet({ chains }), customMetamaskWallet, @@ -46,24 +56,63 @@ import { HOME_PAGE } from "@/app/(with wallet)/_components/page-links"; ], }, ]); - - const wagmiConfig = createConfig({ - autoConnect: true, - connectors, - publicClient - }) - export const emtChains = chains - export const emtWagmiConfig = wagmiConfig - export {chain} +const wagmiConfig = createConfig({ + autoConnect: true, + connectors, + publicClient, +}); + +export const emtChains = chains; +export const emtWagmiConfig = wagmiConfig; +export { chain }; +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 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 exptLevelKeys = [1, 2, 3] +export const exptLevelKeys = [1, 2, 3]; diff --git a/frontend/nextjs/src/app/(with wallet)/dapp/bookings/_components/book-expert-dialogue.tsx b/frontend/nextjs/src/app/(with wallet)/dapp/bookings/_components/book-expert-dialogue.tsx index 0c10301..e880899 100644 --- a/frontend/nextjs/src/app/(with wallet)/dapp/bookings/_components/book-expert-dialogue.tsx +++ b/frontend/nextjs/src/app/(with wallet)/dapp/bookings/_components/book-expert-dialogue.tsx @@ -16,21 +16,11 @@ import useBackend from '@/lib/hooks/useBackend' import { BookingCalendarForm } from './book-expert' import { ExptListing, ExptListingWithAuthorProfile } from '@/lib/types' -export default function BookExpertDialogue({data}: {data: ExptListingWithAuthorProfile}){ - const {fetchProfile} = useBackend() - - const {data: author, isLoading}= useQuery({ - queryKey: ["author", data.author], - queryFn: ()=>fetchProfile(data.author) - - }); - - if(!author && isLoading) return - if(!author) return - +export default function BookExpertDialogue({data}: {data: {listing: ExptListingWithAuthorProfile, id: string, remainingSessions: number}}){ + const {listing, id, remainingSessions} = data; return - + @@ -38,22 +28,26 @@ return
- Book a Session with {author.displayName} + Book a Session with {listing.authorProfile.displayName} - + +
+
Token Id
+
{id}
+
Session Duration
-
{data.sessionCount} session(s) x {data.sessionDuration} minutes
+
{listing.sessionDuration} session(s) x {listing.sessionCount} minutes
Description
-
{data.description}
+
{listing.description}
- +
diff --git a/frontend/nextjs/src/app/(with wallet)/dapp/bookings/_components/book-expert.tsx b/frontend/nextjs/src/app/(with wallet)/dapp/bookings/_components/book-expert.tsx index d6e10de..23b01a0 100644 --- a/frontend/nextjs/src/app/(with wallet)/dapp/bookings/_components/book-expert.tsx +++ b/frontend/nextjs/src/app/(with wallet)/dapp/bookings/_components/book-expert.tsx @@ -1,6 +1,6 @@ "use client"; import React from "react"; -import { ExpertTicket, ExptListing } from "@/lib/types"; +import { Booking, ExpertTicket, ExptListing } from "@/lib/types"; import { ScrollArea } from "@/components/ui/scroll-area"; import { zodResolver } from "@hookform/resolvers/zod"; import { CalendarIcon } from "@radix-ui/react-icons"; @@ -28,28 +28,47 @@ import { import { toast } from "@/components/ui/use-toast"; import { Globe2Icon } from "lucide-react"; import { Textarea } from "@/components/ui/textarea"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import DataLoading from "@/components/ui/data-loading"; import NoData from "@/components/ui/no-data"; import BookExpertDialogue from "./book-expert-dialogue"; import useBackend from "@/lib/hooks/useBackend"; import InfiniteScroll from "@/components/ui/infinite-scroller"; import { useUser } from "@/lib/hooks/user"; +import { Timestamp } from "firebase/firestore"; +import { Input } from "@/components/ui/input"; -const FormSchema = z.object({ - availableDate: z.date({ - required_error: "A date is required.", - }), - message: z.string().optional(), -}); -export function BookingCalendarForm() { +export function BookingCalendarForm({exptListing, exptTokenId, remainingSessions}:{exptListing: ExptListing, exptTokenId: string, remainingSessions: number}) { + const { bookExpert } = useBackend(); + const queryClient = useQueryClient(); + const FormSchema = z.object({ + sessionTimestamp: z.date({ + required_error: "A date is required.", + }), + message: z.string().optional(), + sessionCount: z.coerce.number().max(remainingSessions, { + message: `You can only book ${exptListing.sessionCount} sessions`, + }) + }); const form = useForm>({ resolver: zodResolver(FormSchema), }); const currentTimezone = "UTC Time (10:00)"; + const { data, mutateAsync} = useMutation({ + mutationFn: bookExpert, + onSuccess: (data) => { + queryClient.setQueryData(['bookings'], (oldData: Booking[])=>{ + return [ + ...oldData, + data + ] + }) + }, + }) + function onSubmit(data: z.infer) { toast({ title: "You submitted the following values:", @@ -59,6 +78,9 @@ export function BookingCalendarForm() { // {/* */} ), }); + + mutateAsync({...data, exptListingId: exptListing.id, sessionTimestamp: Timestamp.fromDate(data.sessionTimestamp), mentor: exptListing.author, exptTokenId}) + } return ( @@ -66,7 +88,7 @@ export function BookingCalendarForm() {
( Select Date & Time @@ -131,6 +153,24 @@ export function BookingCalendarForm() { )} /> + ( + + Number of Sessions ({remainingSessions} remaining) + + + + + + )} + />
Time Zone
@@ -155,18 +195,18 @@ export function BookingCalendarForm() { const BookExpert = () => { - const { fetchExptListings } = useBackend(); + const { fetchExpts } = useBackend(); const {user} = useUser(); return ( { - return lastPage[lastPage.length - 1]?.timestamp; + return lastPage[lastPage.length - 1]; }} - queryKey={["ownedExpt"]} - filters={{mentee: user?.uid, key: 'djsj'}} - fetcher={fetchExptListings} + queryKey={["menteeExpts"]} + filters={{mentee: user?.uid}} + fetcher={fetchExpts} className="w-full flex flex-wrap gap-4 flex-grow" /> ); diff --git a/frontend/nextjs/src/app/(with wallet)/dapp/bookings/page.tsx b/frontend/nextjs/src/app/(with wallet)/dapp/bookings/page.tsx index 6c6d9d9..f8bd2ca 100644 --- a/frontend/nextjs/src/app/(with wallet)/dapp/bookings/page.tsx +++ b/frontend/nextjs/src/app/(with wallet)/dapp/bookings/page.tsx @@ -2,7 +2,7 @@ 'use client'; import React from 'react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Booking, BookingFilters, Content, ExpertTicket, ExptListing, ReviewItem as ReviewItemProps, UserProfile } from '@/lib/types'; +import { Booking, BookingFilters, Content, ExpertTicket, ExptFilters, ExptListing, ReviewItem as ReviewItemProps, UserProfile } from '@/lib/types'; import ExpertHubCard from '@/components/ui/expert-hub-card'; import SessionReviewForm from './_components/session-review-form'; import BookExpert from './_components/book-expert'; @@ -31,7 +31,7 @@ const {user} = useUser(); <>
diff --git a/frontend/nextjs/src/app/(with wallet)/dapp/expert-hub/page.tsx b/frontend/nextjs/src/app/(with wallet)/dapp/expert-hub/page.tsx index 141f778..f5911ff 100644 --- a/frontend/nextjs/src/app/(with wallet)/dapp/expert-hub/page.tsx +++ b/frontend/nextjs/src/app/(with wallet)/dapp/expert-hub/page.tsx @@ -29,9 +29,6 @@ const ExpertHub = () => { fetcher={fetchExptListings} size={3} ItemComponent={ExpertHubCard} - getNextPageParam={(lastPage) => { - return lastPage[lastPage.length - 1]?.timestamp; - }} queryKey={["exptListings"]} noDataMessage="No EXPT listings found. Please try later" /> diff --git a/frontend/nextjs/src/app/(with wallet)/dapp/notifications/page.tsx b/frontend/nextjs/src/app/(with wallet)/dapp/notifications/page.tsx index df26528..657ef78 100644 --- a/frontend/nextjs/src/app/(with wallet)/dapp/notifications/page.tsx +++ b/frontend/nextjs/src/app/(with wallet)/dapp/notifications/page.tsx @@ -34,8 +34,8 @@ const Notifications = () => { { - return fetchNotifications(lastDocTimeStamp); + fetcher={(lastDoc) => { + return fetchNotifications(lastDoc?.timestamp); }} className='space-y-5' enabled={!!user} diff --git a/frontend/nextjs/src/app/(with wallet)/dapp/profile/[uid]/_components/claim-expt-card.tsx b/frontend/nextjs/src/app/(with wallet)/dapp/profile/[uid]/_components/claim-expt-card.tsx index 8719072..057f894 100644 --- a/frontend/nextjs/src/app/(with wallet)/dapp/profile/[uid]/_components/claim-expt-card.tsx +++ b/frontend/nextjs/src/app/(with wallet)/dapp/profile/[uid]/_components/claim-expt-card.tsx @@ -25,7 +25,7 @@ import { toast } from "@/components/ui/use-toast" import { Textarea } from '@/components/ui/textarea' import { cn, isValidFileType } from "@/lib/utils" -import { NewExptListing, UserProfile } from '@/lib/types'; +import { ClaimHistoryItem, NewExptListing, UserProfile } from '@/lib/types'; import { Input } from '@/components/ui/input' import { Select, @@ -48,20 +48,6 @@ const PLACEHOLDER_COVER_PHOTO = require('@/assets/default-photo.png') const SESSION_COUNT = ["1", "2", "3", "4", "5"] as const; const SESSION_DURATIONS = ["15", "30"] as const; -const FormSchema = z.object({ - coverImage: z - .custom((v) => v instanceof File, { - message: 'Only images allowed e.g JPG, JPEG or PNG are allowed.', - }), - collectionSize: z.coerce.number(), // the tokenIds that are meant to be minted - collectionName: z.string(), - price: z.coerce.number().gte(1, { - message: "Price is required", - }).positive(), - sessionCount: z.enum(SESSION_COUNT), - sessionDuration: z.enum(SESSION_DURATIONS), - description: z.string().optional() -}); // Component @@ -69,6 +55,22 @@ const ClaimExptCard = ({profile}: {profile: UserProfile}) => { const {user}= useUser() const {listExpts} = useBackend(); const router = useRouter(); + const FormSchema = z.object({ + coverImage: z + .custom((v) => v instanceof File, { + message: 'Only images allowed e.g JPG, JPEG or PNG are allowed.', + }), + //TODO: @od41 Error meesage doesn't show up in form + collectionSize: z.coerce.number().max(profile.ownedExptIds?.length || 0, "Number exceeds owned expts"), // the tokenIds that are meant to be minted + collectionName: z.string(), + price: z.coerce.number().gte(1, { + message: "Price is required", + }).positive(), + sessionCount: z.enum(SESSION_COUNT), + sessionDuration: z.enum(SESSION_DURATIONS), + description: z.string().optional() + }); + const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { @@ -86,7 +88,7 @@ const [coverPhotoPreview, setCoverPhotoPreview] = useState(null); async function onSubmit(data: z.infer) { setIsListExptLoading(true) - const {coverImage, collectionName, collectionSize, price, description, sessionCount, sessionDuration} = data + const {coverImage, collectionName, collectionSize, price, description, sessionCount, sessionDuration} = data const listing: NewExptListing = { collectionName: collectionName, collectionSize: collectionSize, @@ -97,7 +99,7 @@ const [coverPhotoPreview, setCoverPhotoPreview] = useState(null); sessionDuration: Number(sessionDuration), timestamp: serverTimestamp(), coverImage: coverImage, - tokenIds: profile.ownedExptIds! + tokenIds: profile.ownedExptIds?.slice(0, collectionSize) || [], } const res = await listExpts(listing) @@ -128,23 +130,23 @@ const [coverPhotoPreview, setCoverPhotoPreview] = useState(null); const {mutateAsync: handleClaimExpt} = useMutation({ mutationFn: claimExpt, - onSuccess: () => { + onSuccess: (data) => { queryClient.setQueryData(["unclaimedExpt", uid], ()=>{ return 0; - }) - toast({ - title: 'Claimed', - description: 'You have claimed your Expt', - variant: 'success', - loadProgress: 100, - }) + }); + queryClient.setQueryData(['claimHistory', user?.uid], (oldData: ClaimHistoryItem[])=>{ + return [ + { + ...data?.claimHistoryItem + }, ...oldData] + }) + }, onMutate:()=>{ toast({ title: 'Claiming..', description: 'Mining Transaction', - duration: Infinity, - loadProgress: 10, + duration: 100, }) }, @@ -182,7 +184,7 @@ const [coverPhotoPreview, setCoverPhotoPreview] = useState(null); collectionSize: form.watch().collectionSize, price: form.watch().price, paymentCurrency: "USDT", - tokenIds: [1, 2, 3, 4, 5], + tokenIds: profile.ownedExptIds || [], remainingTokenIds: [], imageURL: coverPhotoPreview ? coverPhotoPreview : String(PLACEHOLDER_COVER_PHOTO.default.src), description: form.watch().description!, @@ -254,9 +256,9 @@ const [coverPhotoPreview, setCoverPhotoPreview] = useState(null); name="collectionSize" render={({ field }) => ( - Collection Size + Collection Size (Expts Owned : {profile.ownedExptIds?.length}) - + diff --git a/frontend/nextjs/src/app/(with wallet)/dapp/profile/[uid]/_components/set-availability-status.tsx b/frontend/nextjs/src/app/(with wallet)/dapp/profile/[uid]/_components/set-availability-status.tsx index 07d38aa..8d62227 100644 --- a/frontend/nextjs/src/app/(with wallet)/dapp/profile/[uid]/_components/set-availability-status.tsx +++ b/frontend/nextjs/src/app/(with wallet)/dapp/profile/[uid]/_components/set-availability-status.tsx @@ -65,9 +65,6 @@ const FormSchema = z.object({ // Component const SetAvailabilityStatus = ({ profile }: any) => { - const { user } = useUser() - const { listExpts } = useBackend(); - const router = useRouter(); const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { @@ -77,7 +74,6 @@ const SetAvailabilityStatus = ({ profile }: any) => { }) const [isListExptLoading, setIsListExptLoading] = useState(false) - const [coverPhotoPreview, setCoverPhotoPreview] = useState(null); async function onSubmit(data: z.infer) { setIsListExptLoading(true) @@ -92,47 +88,6 @@ const SetAvailabilityStatus = ({ profile }: any) => { setIsListExptLoading(false) form.reset() } - - const { claimExpt, profileReady, fetchUnclaimedExpt } = useBackend(); - const { uid } = useParams(); - const queryClient = useQueryClient(); - - - const { data: unclaimedExpt } = useQuery({ - queryKey: ["unclaimedExpt", uid], - queryFn: () => fetchUnclaimedExpt(), - enabled: profileReady - }); - - const { mutateAsync: handleClaimExpt } = useMutation({ - mutationFn: claimExpt, - onSuccess: () => { - queryClient.setQueryData(["unclaimedExpt", uid], () => { - return 0; - }) - toast({ - title: 'Claimed', - description: 'You have claimed your Expt', - variant: 'success', - loadProgress: 100, - }) - }, - onMutate: () => { - toast({ - title: 'Claiming..', - description: 'Mining Transaction', - duration: Infinity, - loadProgress: 10, - }) - - }, - onError: (e: any) => { - // Handle error state here - console.error("oops!", e.message) - }, - - }) - return (
diff --git a/frontend/nextjs/src/components/ui/infinite-scroller.tsx b/frontend/nextjs/src/components/ui/infinite-scroller.tsx index 25c3377..129a696 100644 --- a/frontend/nextjs/src/components/ui/infinite-scroller.tsx +++ b/frontend/nextjs/src/components/ui/infinite-scroller.tsx @@ -71,7 +71,7 @@ export default function InfiniteScroll({ getNextPageParam || ((lastPage, allPages) => { if (max && allPages.length >= max) return undefined; - return lastPage[lastPage.length - 1]?.timestamp; + return lastPage[lastPage.length - 1] }), select: (data) => { return { diff --git a/frontend/nextjs/src/components/ui/use-toast.ts b/frontend/nextjs/src/components/ui/use-toast.ts index d251748..17ee01c 100644 --- a/frontend/nextjs/src/components/ui/use-toast.ts +++ b/frontend/nextjs/src/components/ui/use-toast.ts @@ -23,7 +23,6 @@ type ToasterToast = ToastProps & { title?: React.ReactNode description?: React.ReactNode action?: ToastActionElement - loadProgress?: number } const actionTypes = { diff --git a/frontend/nextjs/src/lib/hooks/useBackend.tsx b/frontend/nextjs/src/lib/hooks/useBackend.tsx index 16c38a5..c1dc0b9 100644 --- a/frontend/nextjs/src/lib/hooks/useBackend.tsx +++ b/frontend/nextjs/src/lib/hooks/useBackend.tsx @@ -162,10 +162,12 @@ export default function useBackend() { const { data: exptLevels } = useQuery({ queryKey: ["exptlevels"], queryFn: async () => { - const levelsPromises = exptLevelKeys.map((key) => - emtMarketplace.exptLevels(key) - ); + const levelsPromises = exptLevelKeys.map(async (key) => { + const l = await emtMarketplace.exptLevels(key); + return { requiredMent: Number(l[0]), receivableExpt: Number(l[1]) }; + }); const levels = await Promise.all(levelsPromises); + console.log("exptLevels", levels); return levels; }, throwOnError: (error) => { @@ -196,9 +198,12 @@ export default function useBackend() { const contract = stableCoin.attach( token.address! ) as typeof stableCoin; - const balance = await contract.balanceOf(currentUserId); + const balance = Number(await contract.balanceOf(currentUserId)); return { - [token.name]: parseFloat(ethers.formatUnits(balance, 6)).toFixed(2), + [token.name]: + token.name === "USDT" + ? parseFloat(ethers.formatUnits(balance, 6)).toFixed(2) + : balance, }; }, enabled: !wrongChain && !!user?.uid, @@ -277,19 +282,28 @@ export default function useBackend() { } } async function claimMent() { - function getMentClaimed(receipt: ContractTransactionReceipt) { - const filter = emtMarketplace.filters.MentClaimed().fragment; - let mentClaimed = 0; + function getTokenIdsClaimed(receipt: ContractTransactionReceipt) { + const fragment = expertToken.filters.Transfer().fragment; // console.log(receipt?.logs) - receipt?.logs.some((log) => { - const d = emtMarketplace.interface.decodeEventLog(filter, log.data); - console.log("l", d); - mentClaimed = Number(d[1]); - return false; + const tokenIds: number[] = []; + receipt?.logs.forEach((log, i) => { + try { + const d = emtMarketplace.interface.decodeEventLog( + fragment, + log.data, + log.topics + ); + // console.log("log" + i, d); + // mentClaimed = Number(d[2]); + tokenIds.push(Number(d[2])); + } catch (e) { + console.log("log" + i, log, e); + } }); - console.log("mentClaimed", mentClaimed); - return mentClaimed; + console.log("tokenIds", tokenIds); + return tokenIds; } + if (!user?.uid) { throw new Error("User not logged in"); } @@ -297,17 +311,22 @@ export default function useBackend() { const tx = await emtMarketplace.claimMent(); const receipt = await tx!.wait(); console.log("claimed ment"); - // @ts-ignore - const mentClaimed = getMentClaimed(receipt); + const tokenIds = getTokenIdsClaimed(receipt!); const historyItem: Omit = { type: "ment", - amount: mentClaimed, + amount: tokenIds.length, + tokenIds, uid: user.uid, }; const claimHistoryItem = await saveClaimHistoryItemToFirestore(historyItem); const newMent = await updateUserMentInFirestore(); - return { mentClaimed, newMent, claimHistoryItem }; + return { + mentClaimed: tokenIds.length, + tokenIds, + newMent, + claimHistoryItem, + }; } catch (err: any) { console.log(err); throw new Error("Error claiming ment. Message: " + err.message); @@ -316,62 +335,86 @@ export default function useBackend() { async function fetchMentAndLevel(uid = user?.uid): Promise<[number, number]> { console.log("fetchMentAndLevel", uid); - let ment = 0; + let ment = balances.MENT; if (uid !== user?.uid) { ment = await fetchMent(uid); } console.log("ment2222: ", ment); - const level = exptLevels - ? Object.entries(exptLevels).find( - ([key, level]) => (ment || 0) > level.requiredMent - )?.[0] || 0 + let userlevel = 0; + exptLevels + ? exptLevels.some((level, index) => { + if ((ment || 0) >= level.requiredMent) { + userlevel = index + 1; + return false; + } else return true; + }) : 0; - console.log("level: ", level); - return [Number(ment), Number(level)]; + console.log("level: ", userlevel); + return [Number(ment), Number(userlevel)]; } async function claimExpt() { - function getExptClaimed(receipt: ContractTransactionReceipt) { + function extractExptClaimedFromTxReceipt( + receipt: ContractTransactionReceipt + ) { const filter = emtMarketplace.filters.ExptClaimed().fragment; let exptClaimed = 0; // console.log(receipt?.logs) receipt?.logs.some((log) => { - const d = emtMarketplace.interface.decodeEventLog(filter, log.data); - console.log("l", d); - exptClaimed = Number(d[1]); - return false; + try { + const d = emtMarketplace.interface.decodeEventLog(filter, log.data); + console.log("l", d); + exptClaimed = Number(d[1]); + return true; + } catch (err) { + return false; + } }); console.log("exptClaimed", exptClaimed); return exptClaimed; } if (!user?.uid) { + toast({ + title: "Login", + description: "Please login to claim expt", + }); throw new Error("User not logged in"); } - try { - const [_, level] = await fetchMentAndLevel(); - - if (!level) { - throw new Error("Not qualified for expt"); + for (const i of exptLevelKeys) { + const level = i + 1; + const t = loadingToast("Claiming expt", 1); + try { + const tx = await emtMarketplace.claimExpt(level); + t("mining transaction", 50); + const receipt = await tx!.wait(); + const exptClaimed = extractExptClaimedFromTxReceipt(receipt!); + const historyItem: Omit = { + type: "expt", + amount: exptClaimed, + level: Number(level), + uid: user.uid, + }; + t("almost there", 80); + const val = await expertToken.balanceOf(user.uid); + const newExptBalance = Number(val); + const claimHistoryItem = + await saveClaimHistoryItemToFirestore(historyItem); + console.log("claimed expt"); + t(exptClaimed + " Expts claimed for level " + level, 100); + return { newExptBalance, claimHistoryItem }; + } catch (err: any) { + if ( + err.message.includes("Not qualified for level") || + err.message.includes("Level has already been claimed") || + err.message.includes("Expt Level does not exists") + ) { + continue; + } else { + console.log(err); + t("Error claiming expt at level " + level, 0, true); + throw new Error("Error claiming expt. Message: " + err.message); + } } - const tx = await emtMarketplace.claimExpt(level); - const receipt = await tx!.wait(); - const val = await expertToken.balanceOf(user.uid); - const newExptBalance = Number(val); - const exptClaimed = getExptClaimed(receipt!); - - const historyItem: Omit = { - type: "expt", - amount: exptClaimed, - level: level, - uid: user.uid, - }; - const claimHistoryItem = - await saveClaimHistoryItemToFirestore(historyItem); - console.log("claimed expt. New expt balance: ", newExptBalance); - return { newExptBalance, claimHistoryItem }; - } catch (err: any) { - console.log(err); - throw new Error("Error claiming ment. Message: " + err.message); } } @@ -494,7 +537,7 @@ export default function useBackend() { const userDocRef = doc(USERS_COLLECTION, owner); const userDoc = await getDoc(userDocRef); const author = userDoc.data() as Content["author"]; - console.log('usebackend author: ', userDoc.data()) + console.log("usebackend author: ", userDoc.data()); const votes = await fetchPostVotes(id); return { @@ -637,9 +680,17 @@ export default function useBackend() { } try { console.log("fetching unclaimed expt"); - const [_, level] = await fetchMentAndLevel(); - const val = await emtMarketplace.unclaimedExpt(user.uid, level || 1); - const unclaimedExpt = Number(val); + let unclaimedExpt = 0; + for (let i = 0; i < exptLevelKeys.length; i++) { + try { + const val = await emtMarketplace.unclaimedExpt(user.uid, i + 1); + unclaimedExpt += Number(val); + } catch (err) { + console.log( + "Error fetching unclaimed expt for level " + (i + 1) + err + ); + } + } console.log("unclaimed expt:", unclaimedExpt); return unclaimedExpt; } catch (err: any) { @@ -710,7 +761,7 @@ export default function useBackend() { * @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) { + async function getUserFollowingsIds(uid = user?.uid, size = 10) { try { const q = query( collectionGroup(firestore, "followers"), @@ -719,7 +770,7 @@ export default function useBackend() { ); console.log("isfollowing"); const snap = await getDocs(q); - console.log(snap) + console.log(snap); const uids = snap.docs.map((d) => d.ref.path.split("/")[1]); return uids; } catch (e) { @@ -756,8 +807,8 @@ export default function useBackend() { } if (filters?.isFollowing) { const uidsOfFollowings = await getUserFollowingsIds(); - console.log('pppp, fetching posts following', uidsOfFollowings) - q = query(q, where('owner', "in", uidsOfFollowings)); + console.log("pppp, fetching posts following", uidsOfFollowings); + q = query(q, where("owner", "in", uidsOfFollowings)); } const querySnapshot = await getDocs(q); @@ -1034,7 +1085,10 @@ 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(), uid: user.uid }); + await setDoc(userFollowersRef, { + timestamp: serverTimestamp(), + uid: user.uid, + }); createNotification({ type: "follow", recipients: [id] }); @@ -1119,22 +1173,34 @@ export default function useBackend() { } } if (!user?.uid) { + toast({ + title: "Login", + description: "Please login to list expts", + }); throw new Error("User not logged in"); } + const t = loadingToast("Listing Expts", 1); try { - await expertToken.setApprovalForAll(emtMarketplace.target, true); - console.log("listing expts in contract"); + t("seeking approval to transfer expts", 10); + const approvalTxn = await expertToken.setApprovalForAll(emtMarketplace.target, true); + t("mining transaction", 20); + await approvalTxn!.wait(); + t("listing expts in contract", 40); const tx = await emtMarketplace.offerExpts( listing.tokenIds, stableCoin.target, listing.price ); + t("mining transaction", 50); await tx!.wait(); console.log("listed expts in contract"); + t("Finalising Listing", 70); const id = await saveExptListingToFirestore(listing); + t("Expts listed successfully", 100); return id; } catch (err: any) { console.log(err); + t("Error listing expts", 0, true); throw new Error("Error listing expts. Message: " + err.message); } } @@ -1147,58 +1213,119 @@ export default function useBackend() { * @returns A promise that resolves to an array of expt listings with author profile. */ async function fetchExptListings( - lastDocTimeStamp?: Timestamp, + lastDoc?: ExptListingWithAuthorProfile, 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) - ); + //fetch expts that the user bought from another + try { - 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 (filters?.mentee) { + const ownedExpts = await fetchOwnedExptIds(filters.mentee); + filters.tokenIds = ownedExpts; } - if (tokenIds.length > 0) { - q = query(q, where("tokenIds", "array-contains-any", tokenIds)); + // fetch expts that the user listed + if (filters?.mentor) { + const ownedExpts = await fetchOwnedExptIds(filters.mentor); + filters.tokenIds = ownedExpts; } - - const querySnapshot = await getDocs(q); - if (querySnapshot.empty) return []; - + // Split the tokenIds array into chunks of 30 because of firebase array-contains limit + const tokenIdsChunks = filters?.tokenIds + ? chunkArray(filters.tokenIds, 30) + : [[]]; + + const listingPromises = tokenIdsChunks.map(async (tokenIds) => { + // when fetching the listings for the mentee we use an inequality operator in the author field + // which requires us to order by the author field and start after the last doc's author + // this is because firebase does not allow inequality operators on multiple fields + // https://firebase.google.com/docs/firestore/query-data/order-limit-data#limitations + // so we check if the mentee filter is NOT present and if so we order by and starty after the timestamp + let q = query( + EXPT_LISTINGS_COLLECTION, + limit(size) + ); + if (!(filters?.mentee) ) { + q = query(q, orderBy("timestamp", "desc"), startAfter(lastDoc?.timestamp || '')); + } + if (filters?.mentee) { + q = query(q, orderBy("author"), where("author", "!=", filters.mentee), startAfter(lastDoc?.author || '')); + } + // fetch expts that the user listed + if (filters?.mentor) { + q = query(q, where("author", "==", filters.mentor)); + } + 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)); + } + console.log('query', ) + + 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); + listing.tokensOfCurrentUser = listing.tokenIds.filter( + (tokenId) => filters?.tokenIds?.includes(tokenId) + ); 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; + } catch (err: any) { + console.log("Error fetching expt listings. Details: " + err); + throw new Error(err); + } + + } - const listingsArrays = await Promise.all(listingPromises); + async function fetchExpts( + lastDoc?: { id: string; listing: ExptListingWithAuthorProfile }, + size = 1, + filters?: ExptFilters + ) { + try { + const listings = await fetchExptListings(lastDoc?.listing, size, filters); + const exptsPromises = listings.map((listing) => listing.tokensOfCurrentUser!.map( async (tokenId) => { + const remainingSessions = await getRemainingSessions(listing, tokenId); + return { + id: tokenId, + listing, + remainingSessions, + } + } + ) + ).flat() + return await Promise.all(exptsPromises); + } catch (err: any) { + console.log("Error fetching expts. Details: " + err); + throw new Error(err); + } - // Flatten the array of arrays into a single array - const listings = listingsArrays.flat(); + async function getRemainingSessions(listing: ExptListingWithAuthorProfile, tokenId: number) { + const q = query(BOOKINGS_COLLECTION, where('exptTokenId', '==', tokenId)) + const bookings = (await getDocs(q)).docs.map(d=> d.data()) as Booking[]; + const bookedSessions = bookings.reduce((acc, booking) => { + return acc + booking.sessionCount; + }, 0) - return listings; + return listing.sessionCount - bookedSessions; } +} + /** * Fetches the list of bookings based on the provided filters. @@ -1208,7 +1335,7 @@ export default function useBackend() { * @returns A promise that resolves to an array of bookings. */ async function fetchBookings( - lastDocTimeStamp?: Timestamp, + lastDoc? : Booking, size = 1, filters?: BookingFilters ): Promise { @@ -1218,8 +1345,8 @@ export default function useBackend() { limit(size) ); - if (lastDocTimeStamp) { - q = query(q, startAfter(lastDocTimeStamp)); + if (lastDoc) { + q = query(q, startAfter(lastDoc.timestamp)); } if (filters?.tags) { q = query(q, where("tags", "array-contains-any", filters.tags)); @@ -1249,6 +1376,40 @@ export default function useBackend() { return bookings; } + async function bookExpert( + data: Omit< + Booking, + "id" | "timestamp" | "mentee" | "isCompleted" | "exptListing" + > + ) { + if (!user?.uid) { + toast({ + title: "Login", + description: "Please login to book an expert", + }); + throw new Error("User not logged in"); + } + const t = loadingToast("Booking Expert", 1); + try { + const docRef = doc(BOOKINGS_COLLECTION); + const booking: Omit = { + ...data, + isCompleted: false, + mentee: user?.uid!, + timestamp: serverTimestamp(), + }; + + await setDoc(docRef, booking); + console.log("saved booking to firestore"); + t("Expert Booked successfully", 100); + return { ...booking, id: docRef.id } satisfies Booking; + } catch (err: any) { + console.log(err); + t("Error booking expert", 0, true); + throw new Error("Error booking expert. Message: " + err.message); + } + } + // Helper function to split an array into chunks function chunkArray(array: T[], chunkSize: number): T[][] { return Array(Math.ceil(array.length / chunkSize)) @@ -1329,7 +1490,7 @@ export default function useBackend() { user?.uid! ); const userFollowersSnap = await getDoc(userFollowersRef); - return !!userFollowersSnap.data()?.uid + return !!userFollowersSnap.data()?.uid; } catch (err: any) { console.log("error checking following", err.message, id, user); } @@ -1371,6 +1532,7 @@ export default function useBackend() { if (!user?.uid) { throw new Error("User not logged in"); } + const t = loadingToast("Buying Expts", 1); async function updateListingInFireStore(boughtTokenId: number) { try { updateDoc(doc(EXPT_LISTINGS_COLLECTION, listing.id), { @@ -1382,34 +1544,45 @@ export default function useBackend() { } } try { + t("seeking approval to transfer stablecoin", 10); console.log("approving stableCoin transfer in contract"); const tx = await stableCoin.approve( emtMarketplace.target, listing.price * 10 ** 6 ); + t("mining transaction", 20); const receipt = await tx.wait(); console.log(receipt); console.log("buying expts in contract"); + t("buying expts in contract", 40); 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]; + let loadingPercent = 40 + ((listing.remainingTokenIds.length - exptToBuyIndex) / listing.remainingTokenIds.length) * 30; try { console.log("tokenToBuyId", tokenToBuyId, listing); const tx = await emtMarketplace.buyExpt(tokenToBuyId); await tx!.wait(); - console.log("bought expts in contract"); + console.log("bought expt in contract"); + t("Finalising Purchase", 80); await updateListingInFireStore(tokenToBuyId); + t("Expt bought successfully", 100); 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); + t("buying expts in contract", loadingPercent); + } else { + t("Error buying expts", 0, true); + throw new Error(err);} } + t("this listing has been sold out", undefined, true); } + } catch (err: any) { console.log("Error buying expts. Message: " + err.message); return false; @@ -1435,6 +1608,56 @@ export default function useBackend() { } } + async function fetchOwnedExptIdsOfMentee(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 []; + } + } + + async function fetchMentorExptIds(uid = user?.uid) { + if (!uid) return []; + console.log("fetching mentor expts ids"); + try { + const q = query( + CLAIM_HISTORY_COLLECTION, + where("uid", "==", uid), + where("type", "==", "expt") + ); + const querySnapshot = await getDocs(q); + const tokenIds = querySnapshot.docs.reduce((acc, doc) => { + const data = doc.data(); + if (data.tokenId) acc.push(...data.tokenIds); + return acc; + }, [] as number[]); + return tokenIds; + } catch (err: any) { + console.log("error fetching mentor expts ids ", err); + return []; + } + } + + async function fetchMenteeExptIds(uid = user?.uid) { + if (!uid) return []; + console.log("fetching mentee expts ids"); + try { + const q = query( + EXPT_LISTINGS_COLLECTION, + where(`buyers.${uid}`, ">", "") + ); + } catch (err: any) { + console.log("error fetching mentee expts ids ", err); + return []; + } + } + /** * Fetches the profiles based on the provided filters. * @param lastdocParam - The last document parameter. @@ -1451,7 +1674,11 @@ export default function useBackend() { if (filters?.ment) { console.log("filters.ment", filters); - q = query(q, orderBy("ment", filters.ment), startAfter(lastdoc?.ment || "")); + q = query( + q, + orderBy("ment", filters.ment), + startAfter(lastdoc?.ment || "") + ); } if (filters?.level) { q = query( @@ -1464,12 +1691,16 @@ export default function useBackend() { } if (filters?.numFollowers) { //TODO @Jovells update follow function to store count in firestore - q = query(q, orderBy("numFollowers", filters.numFollowers), startAfter(lastdoc?.numFollowers || "")); + q = query( + q, + orderBy("numFollowers", filters.numFollowers), + startAfter(lastdoc?.numFollowers || "") + ); } if (filters?.usernames) { q = query( q, - orderBy('username'), + orderBy("username"), startAfter(lastdoc?.username || ""), where( "usernameLowercase", @@ -1479,44 +1710,47 @@ export default function useBackend() { ); } if (filters?.isFollowing) { - console.log('fetching profiles following') + 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 || "")); + 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) + 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 [] + 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 (profile.uid !== user?.uid && !isFollowing) { + profilesNotFollowing.push(profile); } } if (profilesNotFollowing.length < size) { const moreProfiles = await fetchProfiles( - profiles[ - profiles.length - 1 - ], + profiles[profiles.length - 1], size - profilesNotFollowing.length, filters ); profilesNotFollowing.push(...moreProfiles); - } + } return profilesNotFollowing; } - + const profiles = await doFetch(); - return profiles - + return profiles; + async function doFetch() { const querySnapshot = await getDocs(q); const profiles = querySnapshot.docs.map((doc) => { @@ -1614,5 +1848,7 @@ export default function useBackend() { fetchSinglePost, fetchProfiles, fetchPrivacyPolicy, + bookExpert, + fetchExpts, }; } diff --git a/frontend/nextjs/src/lib/types.ts b/frontend/nextjs/src/lib/types.ts index 40544fb..9eda398 100644 --- a/frontend/nextjs/src/lib/types.ts +++ b/frontend/nextjs/src/lib/types.ts @@ -109,6 +109,7 @@ export type ExptFilters = { tags?: string[], author?: string, mentee?: string, + mentor?: string, tokenIds?: number[], } export type Coin = { @@ -136,6 +137,7 @@ export type ClaimHistoryItem ={ timestamp: Timestamp, amount: number, level?: number, + tokenIds?: number[], uid: string, } @@ -166,7 +168,7 @@ export type Booking ={ message?: string; mentor: string; mentee: string; - sessionTimestamp: string; + sessionTimestamp: Timestamp; sessionCount: number; exptListing?: ExptListingWithAuthorProfile; exptListingId: string;