<template> <QuickNav selected="Projects" /> <TopMessage /> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Heading --> <h1 id="ViewHeading" class="text-4xl text-center font-light">Your Ideas</h1> <OnboardingDialog ref="onboardingDialog" /> <!-- Result Tabs --> <div class="text-center text-slate-500 border-b border-slate-300 mt-8"> <ul class="flex flex-wrap justify-center gap-4 -mb-px"> <li> <a href="#" @click=" offers = []; projects = []; showOffers = true; showProjects = false; loadOffers(); " v-bind:class="computedOfferTabClassNames()" > Offers </a> </li> <li> <a href="#" @click=" offers = []; projects = []; showOffers = false; showProjects = true; loadProjects(); " v-bind:class="computedProjectTabClassNames()" > Projects </a> </li> </ul> </div> <!-- Quick Search --> <!-- <div id="QuickSearch" class="mb-4 flex"> <input type="text" placeholder="Search…" class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2" /> <button class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400" > <fa icon="magnifying-glass" class="fa-fw"></fa> </button> </div> --> <!-- New Project --> <button v-if="isRegistered && showProjects" class="fixed right-6 top-24 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full" @click="onClickNewProject()" > <fa icon="plus" class="fa-fw"></fa> </button> <!-- Loading Animation --> <div class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full" v-if="isLoading" > <fa icon="spinner" class="fa-spin-pulse"></fa> </div> <!-- Offer Results List --> <InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData"> <div v-if="offers.length === 0" class="text-center py-4"> You have not offered anything. <br /> <router-link to="/discover" class="text-blue-600"> Look for projects worth some of your time. </router-link> </div> <ul id="listOffers" class="border-t border-slate-300"> <li class="border-b border-slate-300" v-for="offer in offers" :key="offer.handleId" > <div class="block py-4 flex gap-4"> <div v-if="offer.fulfillsPlanHandleId" class="flex-none"> <ProjectIcon :entityId="offer.fulfillsPlanHandleId" :iconSize="48" class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12" /> </div> <div v-if="offer.recipientDid" class="flex-none w-12"> <EntityIcon :entityId="offer.recipientDid" :iconSize="48" class="inline-block align-middle border border-slate-300 rounded-md" /> </div> <div> <div> To {{ offer.fulfillsPlanHandleId ? projectNameFromHandleId[offer.fulfillsPlanHandleId] : didInfo( offer.recipientDid, activeDid, allMyDids, allContacts, ) }} </div> <div> {{ offer.objectDescription }} </div> <span class="text-sm"> <span v-if="offer.amount"> <fa :icon="libsUtil.iconForUnitCode(offer.unit)" class="fa-fw text-slate-400" /> <span v-if="offer.amountGiven >= offer.amount"> <fa icon="check-circle" class="fa-fw text-green-500" /> All {{ offer.amount }} given </span> <span v-else> <fa icon="triangle-exclamation" class="fa-fw text-yellow-500" /> {{ offer.amountGiven ? "" : "All" }} {{ offer.amount - (offer.amountGiven || 0) }} remaining </span> <span v-if="offer.amountGiven > 0"> <span class="text-sm text-slate-400"> ({{ offer.amountGiven }} given, <span v-if="offer.amountGivenConfirmed >= offer.amountGiven" > <!-- There's no need for a green icon: it's unnecessary if there's already a green, and confusing if there's a yellow. --> all </span> <span v-else> <!-- only show icon if there's not already a warning --> <fa v-if="offer.amountGiven >= offer.amount" icon="triangle-exclamation" class="fa-fw text-yellow-300" /> {{ offer.amountGivenConfirmed || 0 }} </span> of that is confirmed) </span> </span> </span> <span v-else> <!-- Non-amount offer --> <span v-if="offer.nonAmountGivenConfirmed"> <fa icon="check-circle" class="fa-fw text-green-500" /> {{ offer.nonAmountGivenConfirmed }} {{ offer.nonAmountGivenConfirmed == 1 ? "give" : "gives" }} are confirmed. </span> <span v-else> <fa icon="triangle-exclamation" class="fa-fw text-yellow-500" /> <span class="text-sm">Not confirmed by anyone</span> </span> </span> <a @click="onClickLoadClaim(offer.jwtId)"> <fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" ></fa> </a> </span> </div> </div> </li> </ul> </InfiniteScroll> <!-- Project Results List --> <InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData"> <div v-if="projects.length === 0" class="text-center py-4"> You have not announced any projects. <div v-if="isRegistered"> Hit the big <fa icon="plus" class="bg-green-600 text-white px-1.5 py-1 rounded-full" /> button. You'll never know until you try. </div> <div v-else> <button @click="showNameThenIdDialog()" class="text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md" > Get someone to onboard you. </button> <UserNameDialog ref="userNameDialog" /> </div> </div> <ul id="listProjects" class="border-t border-slate-300"> <li class="border-b border-slate-300" v-for="project in projects" :key="project.handleId" > <a @click="onClickLoadProject(project.handleId)" class="block py-4 flex gap-4" > <div class="flex-none"> <ProjectIcon :entityId="project.handleId" :iconSize="48" :imageUrl="project.image" class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12" /> </div> <div class="grow overflow-hidden"> <h2 class="text-base font-semibold">{{ project.name }}</h2> <div class="text-sm truncate"> {{ project.description }} </div> </div> </a> </li> </ul> </InfiniteScroll> </section> </template> <script lang="ts"> import { AxiosRequestConfig } from "axios"; import { Component, Vue } from "vue-facing-decorator"; import { Router } from "vue-router"; import { NotificationIface } from "@/constants/app"; import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; import * as libsUtil from "@/libs/util"; import EntityIcon from "@/components/EntityIcon.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue"; import QuickNav from "@/components/QuickNav.vue"; import OnboardingDialog from "@/components/OnboardingDialog.vue"; import ProjectIcon from "@/components/ProjectIcon.vue"; import TopMessage from "@/components/TopMessage.vue"; import UserNameDialog from "@/components/UserNameDialog.vue"; import { Contact } from "@/db/tables/contacts"; import { didInfo, getHeaders, getPlanFromCache, OfferSummaryRecord, PlanData, } from "@/libs/endorserServer"; import { OnboardPage } from "@/libs/util"; @Component({ components: { EntityIcon, InfiniteScroll, QuickNav, OnboardingDialog, ProjectIcon, TopMessage, UserNameDialog, }, }) export default class ProjectsView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; errNote(message) { this.$notify( { group: "alert", type: "danger", title: "Error", text: message }, 5000, ); } activeDid = ""; allContacts: Array<Contact> = []; allMyDids: Array<string> = []; apiServer = ""; givenName = ""; isLoading = false; isRegistered = false; offers: OfferSummaryRecord[] = []; projectNameFromHandleId: Record<string, string> = {}; // mapping from handleId to description projects: PlanData[] = []; showOffers = false; showProjects = true; libsUtil = libsUtil; didInfo = didInfo; async mounted() { try { const settings = await retrieveSettingsForActiveAccount(); this.activeDid = settings.activeDid || ""; this.apiServer = settings.apiServer || ""; this.isRegistered = !!settings.isRegistered; this.givenName = settings.firstName || ""; this.allContacts = await db.contacts.toArray(); await accountsDB.open(); const allAccounts = await accountsDB.accounts.toArray(); this.allMyDids = allAccounts.map((acc) => acc.did); if (!settings.finishedOnboarding) { (this.$refs.onboardingDialog as OnboardingDialog).open( OnboardPage.Create, ); } if (allAccounts.length === 0) { console.error("No accounts found."); this.errNote("You need an identifier to load your projects."); } else { await this.loadOffers(); } } catch (err) { console.error("Error initializing:", err); this.errNote("Something went wrong loading your projects."); } } /** * Core project data loader * @param url the url used to fetch the data * @param token Authorization token **/ async projectDataLoader(url: string) { try { const headers = await getHeaders(this.activeDid); this.isLoading = true; const resp = await this.axios.get(url, { headers } as AxiosRequestConfig); if (resp.status === 200 && resp.data.data) { const plans: PlanData[] = resp.data.data; for (const plan of plans) { const { name, description, handleId, image, issuerDid, rowid } = plan; this.projects.push({ name, description, image, handleId, issuerDid, rowid, }); } } else { console.error( "Bad server response & data for plans:", resp.status, resp.data, ); this.errNote("Failed to get projects from the server."); } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { console.error("Got error loading plans:", error.message || error); this.errNote("Got an error loading projects."); } finally { this.isLoading = false; } } /** * Data loader used by infinite scroller * @param payload is the flag from the InfiniteScroll indicating if it should load **/ async loadMoreProjectData(payload: boolean) { if (this.projects.length > 0 && payload) { const latestProject = this.projects[this.projects.length - 1]; await this.loadProjects(`beforeId=${latestProject.rowid}`); } } /** * Load projects initially * @param issuerDid of the user * @param urlExtra additional url parameters in a string **/ async loadProjects(urlExtra: string = "") { const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`; await this.projectDataLoader(url); } /** * Handle clicking on a project entry found in the list * @param id of the project **/ onClickLoadProject(id: string) { const route = { path: "/project/" + encodeURIComponent(id), }; (this.$router as Router).push(route); } /** * Handling clicking on the new project button **/ onClickNewProject(): void { const route = { name: "new-edit-project", }; (this.$router as Router).push(route); } onClickLoadClaim(jwtId: string) { const route = { path: "/claim/" + encodeURIComponent(jwtId), }; (this.$router as Router).push(route); } /** * Core offer data loader * @param url the url used to fetch the data * @param token Authorization token **/ async offerDataLoader(url: string) { const headers = await getHeaders(this.activeDid); try { this.isLoading = true; const resp = await this.axios.get(url, { headers } as AxiosRequestConfig); if (resp.status === 200 && resp.data.data) { // add one-by-one as they retrieve project names, potentially from the server for (const offer of resp.data.data) { if (offer.fulfillsPlanHandleId) { const project = await getPlanFromCache( offer.fulfillsPlanHandleId, this.axios, this.apiServer, this.activeDid, ); const projectName = project?.name as string; this.projectNameFromHandleId[offer.fulfillsPlanHandleId] = projectName; } this.offers = this.offers.concat([offer]); } } else { console.error( "Bad server response & data for offers:", resp.status, resp.data, ); this.$notify( { group: "alert", type: "danger", title: "Error", text: "Failed to get offers from the server. Try again later.", }, -1, ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { console.error("Got error loading offers:", error.message || error); this.$notify( { group: "alert", type: "danger", title: "Error", text: "Got an error loading offers.", }, -1, ); } finally { this.isLoading = false; } } /** * Data loader used by infinite scroller * @param payload is the flag from the InfiniteScroll indicating if it should load **/ async loadMoreOfferData(payload: boolean) { if (this.offers.length > 0 && payload) { const latestOffer = this.offers[this.offers.length - 1]; await this.loadOffers(`&beforeId=${latestOffer.jwtId}`); } } /** * Load offers initially * @param issuerDid of the user * @param urlExtra additional url parameters in a string **/ async loadOffers(urlExtra: string = "") { const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${this.activeDid}${urlExtra}`; await this.offerDataLoader(url); } showNameThenIdDialog() { if (!this.givenName) { (this.$refs.userNameDialog as UserNameDialog).open(() => { this.promptForShareMethod(); }); } else { this.promptForShareMethod(); } } promptForShareMethod() { this.$notify( { group: "modal", type: "confirm", title: "Are you nearby with cameras?", text: "If so, we'll use those with QR codes to share.", onCancel: async () => {}, onNo: async () => { (this.$router as Router).push({ name: "share-my-contact-info" }); }, onYes: async () => { (this.$router as Router).push({ name: "contact-qr" }); }, noText: "we will share another way", yesText: "we are nearby with cameras", }, -1, ); } public computedOfferTabClassNames() { return { "inline-block": true, "py-3": true, "rounded-t-lg": true, "border-b-2": true, active: this.showOffers, "text-black": this.showOffers, "border-black": this.showOffers, "font-semibold": this.showOffers, "text-blue-600": !this.showOffers, "border-transparent": !this.showOffers, "hover:border-slate-400": !this.showOffers, }; } public computedProjectTabClassNames() { return { "inline-block": true, "py-3": true, "rounded-t-lg": true, "border-b-2": true, active: this.showProjects, "text-black": this.showProjects, "border-black": this.showProjects, "font-semibold": this.showProjects, "text-blue-600": !this.showProjects, "border-transparent": !this.showProjects, "hover:border-slate-400": !this.showProjects, }; } } </script>