<template> <QuickNav /> <TopMessage /> <!-- CONTENT --> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Breadcrumb --> <div id="ViewBreadcrumb"> <h1 class="text-lg text-center font-light relative px-7"> <!-- Back --> <button @click="$router.go(-1)" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" > <fa icon="chevron-left" class="fa-fw"></fa> </button> Idea <h2 class="text-xl font-semibold">{{ name }}</h2> </h1> </div> <!-- Project Details --> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4"> <div> <div class="block pb-4 flex gap-4"> <div class="flex-none w-16 pt-1"> <ProjectIcon :entityId="projectId" :iconSize="64" class="block border border-slate-300 rounded-md" ></ProjectIcon> </div> <div class="overflow-hidden"> <div class="text-sm mb-3"> <div class="truncate"> <fa icon="user" class="fa-fw text-slate-400"></fa> {{ serverUtil.didInfo(issuer, activeDid, allMyDids, allContacts) }} <span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)"> <button @click=" libsUtil.doCopyTwoSecRedo( issuer, () => (showDidCopy = !showDidCopy), ) " class="ml-2 mr-2" > <fa icon="copy" class="text-slate-400 fa-fw"></fa> </button> <span v-show="showDidCopy">Copied DID</span> </span> </div> <div v-if="startTime"> <fa icon="calendar" class="fa-fw text-slate-400"></fa> {{ startTime }} </div> <div v-if="latitude || longitude"> <fa icon="location-dot" class="fa-fw text-slate-400"></fa> <a :href="getOpenStreetMapUrl()" target="_blank" class="underline" >Map View <fa icon="arrow-up-right-from-square" class="fa-fw" /> </a> </div> <div v-if="url"> <fa icon="globe" class="fa-fw text-slate-400"></fa> <a :href="addScheme(url)" target="_blank" class="underline"> {{ domainForWebsite(this.url) }} <fa icon="arrow-up-right-from-square" class="fa-fw" /> </a> </div> </div> </div> </div> <div class="text-sm text-slate-500"> <div v-if="!expanded"> {{ truncatedDesc }} <a v-if="description.length >= truncateLength" @click="expandText" class="uppercase text-xs font-semibold text-slate-700" >... Read More</a > </div> <div v-else> {{ description }} <a @click="collapseText" class="uppercase text-xs font-semibold text-slate-700" >- Read Less</a > </div> </div> <a @click="onClickLoadClaim(projectId)" class="cursor-pointer"> <fa icon="file-lines" class="pl-2 pt-1 text-blue-500" /> </a> </div> <button v-if="activeDid === issuer || activeDid === agentDid" type="button" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" @click="onEditClick()" > Edit </button> </div> <div v-if="activeDid" class="mt-4"> <div class="text-center"> <button @click="openOfferDialog()" class="block w-full text-lg 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 px-2 py-3 rounded-md" > Offer (maybe with conditions)... </button> </div> </div> <OfferDialog ref="customOfferDialog" :projectId="this.projectId" /> <div v-if="activeDid"> <div class="text-center"> <p class="mt-2 mt-4 text-center">Record a contribution from:</p> </div> <ul class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5" > <li @click="openGiftDialog({ name: 'you', did: activeDid })"> <fa icon="hand" class="fa-fw text-slate-400 text-5xl" /> <h3 class="mt-5 text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden" > You </h3> </li> <li @click="openGiftDialog()"> <img src="../assets/blank-square.svg" class="mx-auto border border-slate-300 rounded-md mb-1" /> <h3 class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden" > Unnamed/Unknown </h3> </li> <li v-for="contact in allContacts.slice(0, 6)" :key="contact.did" @click="openGiftDialog(contact)" > <EntityIcon :contact="contact" :iconSize="64" class="mx-auto border border-slate-300 rounded-md mb-1" /> <h3 class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden" > {{ contact.name || "(no name)" }} </h3> </li> </ul> <!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) --> <a v-if="allContacts.length >= 7" @click="onClickAllContactsGifting()" class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" > Show More Contacts… </a> <GiftedDialog ref="customGiveDialog" :projectId="this.projectId" /> </div> <!-- Offers & Gifts to & from this --> <div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4"> <div class="bg-slate-100 px-4 py-3 rounded-md"> <h3 class="text-sm font-semibold mb-3">Offered To This Idea</h3> <div v-if="offersToThis.length === 0"> (None yet. Wanna <span @click="openOfferDialog()" class="cursor-pointer text-blue-500" >offer something... especially if others join you</span >?) </div> <ul v-else class="text-sm border-t border-slate-300"> <li v-for="offer in offersToThis" :key="offer.id" class="py-1.5 border-b border-slate-300" > <div class="flex justify-between gap-4"> <span> <fa icon="user" class="fa-fw text-slate-400"></fa> {{ serverUtil.didInfo( offer.offeredByDid, activeDid, allMyDids, allContacts, ) }} </span> <span v-if="offer.amount" class="whitespace-nowrap"> <fa :icon="libsUtil.iconForUnitCode(offer.unit)" class="fa-fw text-slate-400" />{{ offer.amount }} </span> </div> <div v-if="offer.objectDescription" class="text-slate-500"> <fa icon="comment" class="fa-fw text-slate-400" /> {{ offer.objectDescription }} </div> <div class="flex justify-between"> <a @click="onClickLoadClaim(offer.jwtId as string)" class="cursor-pointer" > <fa icon="file-lines" class="pl-2 pt-1 text-blue-500" /> </a> <a v-if="checkIsFulfillable(offer)" @click="onClickFulfillGiveToOffer(offer)" > <fa icon="hand-holding-heart" class="text-blue-500 cursor-pointer" /> </a> </div> </li> </ul> <div v-if="offersHitLimit" class="text-center text-blue-500"> <button @click="loadOffers()">Load More</button> </div> </div> <div class="bg-slate-100 px-4 py-3 rounded-md"> <h3 class="text-sm font-semibold mb-3">Given To This Idea</h3> <div v-if="givesToThis.length === 0"> (None yet. If you've seen something, say something by clicking a contact above.) </div> <ul v-else class="text-sm border-t border-slate-300"> <li v-for="give in givesToThis" :key="give.id" class="py-1.5 border-b border-slate-300" > <div class="flex justify-between gap-4"> <span ><fa icon="user" class="fa-fw text-slate-400"></fa> {{ serverUtil.didInfo( give.agentDid, activeDid, allMyDids, allContacts, ) }} </span> <span v-if="give.amount" class="whitespace-nowrap"> <fa :icon="libsUtil.iconForUnitCode(give.unit)" class="fa-fw text-slate-400" />{{ give.amount }} </span> </div> <div class="text-slate-500"> <fa icon="calendar" class="fa-fw text-slate-400" /> {{ give.issuedAt?.substring(0, 10) }} </div> <div v-if="give.description" class="text-slate-500"> <fa icon="comment" class="fa-fw text-slate-400" /> {{ give.description }} </div> <div class="flex justify-between"> <a @click="onClickLoadClaim(give.jwtId)"> <fa icon="file-lines" class="text-blue-500 cursor-pointer" /> </a> <a v-if="checkIsConfirmable(give)" @click="confirmConfirmClaim(give)" > <fa icon="circle-check" class="text-blue-500 cursor-pointer" /> </a> </div> </li> </ul> <div v-if="givesHitLimit" class="text-center text-blue-500"> <button @click="loadGives()">Load More</button> </div> </div> <div class="grid items-start grid-cols-1 gap-4"> <div v-if="fulfillersToThis.length > 0" class="bg-slate-100 px-4 py-3 rounded-md" > <h3 class="text-sm uppercase font-semibold mb-3"> Contributions To This Idea </h3> <!-- centering because long, wrapped project names didn't left align with blank or "text-left" --> <div class="text-center"> <div v-for="plan in fulfillersToThis" :key="plan.handleId"> <button @click="onClickLoadProject(plan.handleId)" class="text-blue-500" > {{ plan.name }} </button> </div> <div v-if="fulfillersToHitLimit" class="text-center">Load More</div> </div> </div> <div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md"> <h3 class="text-sm uppercase font-semibold mb-3"> Contributions From This Idea </h3> <!-- centering because long, wrapped project names didn't left align with blank or "text-left" --> <div class="text-center"> <button @click="onClickLoadProject(fulfilledByThis.handleId)" class="text-blue-500" > {{ fulfilledByThis.name }} </button> </div> </div> </div> </div> </section> </template> <script lang="ts"> import { AxiosError, RawAxiosRequestHeaders } from "axios"; import { IIdentifier } from "@veramo/core"; import { Component, Vue } from "vue-facing-decorator"; import GiftedDialog from "@/components/GiftedDialog.vue"; import OfferDialog from "@/components/OfferDialog.vue"; import TopMessage from "@/components/TopMessage.vue"; import QuickNav from "@/components/QuickNav.vue"; import EntityIcon from "@/components/EntityIcon.vue"; import ProjectIcon from "@/components/ProjectIcon.vue"; import { NotificationIface } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; import { Account } from "@/db/tables/accounts"; import { Contact } from "@/db/tables/contacts"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { accessToken } from "@/libs/crypto"; import * as libsUtil from "@/libs/util"; import { BLANK_GENERIC_SERVER_RECORD, GenericCredWrapper, GiverReceiverInputInfo, GiveSummaryRecord, OfferSummaryRecord, PlanSummaryRecord, } from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer"; @Component({ components: { EntityIcon, GiftedDialog, OfferDialog, ProjectIcon, QuickNav, TopMessage, }, }) export default class ProjectViewView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; activeDid = ""; agentDid = ""; allMyDids: Array<string> = []; allContacts: Array<Contact> = []; apiServer = ""; description = ""; expanded = false; fulfilledByThis: PlanSummaryRecord | null = null; fulfillersToThis: Array<PlanSummaryRecord> = []; fulfillersToHitLimit = false; givesToThis: Array<GiveSummaryRecord> = []; givesHitLimit = false; issuer = ""; latitude = 0; longitude = 0; name = ""; offersToThis: Array<OfferSummaryRecord> = []; offersHitLimit = false; projectId = localStorage.getItem("projectId") || ""; // handle ID showDidCopy = false; startTime = ""; truncatedDesc = ""; truncateLength = 40; url = ""; libsUtil = libsUtil; serverUtil = serverUtil; async created() { await db.open(); const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; this.activeDid = settings?.activeDid || ""; this.apiServer = settings?.apiServer || ""; this.allContacts = await db.contacts.toArray(); await accountsDB.open(); const accounts = accountsDB.accounts; const accountsArr: Account[] = await accounts?.toArray(); this.allMyDids = accountsArr.map((acc) => acc.did); const account = accountsArr.find((acc) => acc.did === this.activeDid); const identity = JSON.parse(account?.identity || "null"); const pathParam = window.location.pathname.substring("/project/".length); if (pathParam) { this.projectId = decodeURIComponent(pathParam); } this.loadProject(this.projectId, identity); } public async getIdentity(activeDid: string) { await accountsDB.open(); const account = (await accountsDB.accounts .where("did") .equals(activeDid) .first()) as Account; const identity = JSON.parse(account?.identity || "null"); return identity; } onEditClick() { localStorage.setItem("projectId", this.projectId as string); const route = { name: "new-edit-project", }; this.$router.push(route); } // Isn't there a better way to make this available to the template? expandText() { this.expanded = true; } collapseText() { this.expanded = false; } async loadProject(projectId: string, identity: IIdentifier) { this.projectId = projectId; const url = this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId); const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json", }; if (identity) { const token = await accessToken(identity); headers["Authorization"] = "Bearer " + token; } try { const resp = await this.axios.get(url, { headers }); if (resp.status === 200) { const startTime = resp.data.claim?.startTime; if (startTime != null) { const startDateTime = new Date(startTime); this.startTime = startDateTime.toLocaleDateString() + " " + startDateTime.toLocaleTimeString(); } this.agentDid = resp.data.claim?.agent?.identifier; this.issuer = resp.data.issuer; this.name = resp.data.claim?.name || "(no name)"; this.description = resp.data.claim?.description || "(no description)"; this.truncatedDesc = this.description.slice(0, this.truncateLength); this.latitude = resp.data.claim?.location?.geo?.latitude || 0; this.longitude = resp.data.claim?.location?.geo?.longitude || 0; this.url = resp.data.claim?.url || ""; } else { // actually, axios throws an error on 404 so we probably never get here console.error("Error getting project:", resp); this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was a problem getting that project. See logs for more info.", }, 5000, ); } } catch (error: unknown) { console.error("Error retrieving project:", error); const serverError = error as AxiosError; if (serverError.response?.status === 404) { this.$notify( { group: "alert", type: "danger", title: "Error", text: "That project does not exist.", }, 5000, ); } else { this.$notify( { group: "alert", type: "danger", title: "Error", text: "Something went wrong retrieving that project. See logs for more info.", }, 5000, ); } } this.loadGives(); this.loadOffers(); this.loadFulfillersTo(); // now load fulfilled-by, a single project if (identity) { const token = await accessToken(identity); headers["Authorization"] = "Bearer " + token; } const fulfilledByUrl = this.apiServer + "/api/v2/report/planFulfilledByPlan?planHandleId=" + encodeURIComponent(projectId); try { const resp = await this.axios.get(fulfilledByUrl, { headers }); if (resp.status === 200) { this.fulfilledByThis = resp.data.data; } else { this.$notify( { group: "alert", type: "danger", title: "Error", text: "Failed to retrieve plans fulfilled by this project.", }, 5000, ); } } catch (error: unknown) { const serverError = error as AxiosError; this.$notify( { group: "alert", type: "danger", title: "Error", text: "Something went wrong retrieving plans fulfilled by this project.", }, 5000, ); console.error( "Error retrieving plans fulfilled by this project:", serverError.message, ); } } async loadGives() { const givesUrl = this.apiServer + "/api/v2/report/givesToPlans?planIds=" + encodeURIComponent(JSON.stringify([this.projectId])); let postfix = ""; if (this.givesToThis.length > 0) { postfix = "&beforeId=" + this.givesToThis[this.givesToThis.length - 1].jwtId; } const givesInUrl = givesUrl + postfix; const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json", }; const identity = await this.getIdentity(this.activeDid); if (identity) { const token = await accessToken(identity); headers["Authorization"] = "Bearer " + token; } try { const resp = await this.axios.get(givesInUrl, { headers }); if (resp.status === 200 && resp.data.data) { this.givesToThis = this.givesToThis.concat(resp.data.data); this.givesHitLimit = resp.data.hitLimit; } else { this.$notify( { group: "alert", type: "danger", title: "Error", text: "Failed to retrieve more gives to this project.", }, 5000, ); } } catch (error: unknown) { const serverError = error as AxiosError; this.$notify( { group: "alert", type: "danger", title: "Error", text: "Something went wrong retrieving more gives to this project.", }, 5000, ); console.error( "Something went wrong retrieving more gives to this project:", serverError.message, ); } } async loadOffers() { const offersUrl = this.apiServer + "/api/v2/report/offersToPlans?planIds=" + encodeURIComponent(JSON.stringify([this.projectId])); let postfix = ""; if (this.offersToThis.length > 0) { postfix = "&beforeId=" + this.offersToThis[this.offersToThis.length - 1].jwtId; } const offersInUrl = offersUrl + postfix; const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json", }; const identity = await this.getIdentity(this.activeDid); if (identity) { const token = await accessToken(identity); headers["Authorization"] = "Bearer " + token; } try { const resp = await this.axios.get(offersInUrl, { headers }); if (resp.status === 200 && resp.data.data) { this.offersToThis = this.offersToThis.concat(resp.data.data); this.offersHitLimit = resp.data.hitLimit; } else { this.$notify( { group: "alert", type: "danger", title: "Error", text: "Failed to retrieve more offers to this project.", }, 5000, ); } } catch (error: unknown) { const serverError = error as AxiosError; this.$notify( { group: "alert", type: "danger", title: "Error", text: "Something went wrong retrieving more offers to this project.", }, 5000, ); console.error( "Something went wrong retrieving more offers to this project:", serverError.message, ); } } async loadFulfillersTo() { const fulfillsUrl = this.apiServer + "/api/v2/report/planFulfillersToPlan?planHandleId=" + encodeURIComponent(this.projectId); let postfix = ""; if (this.fulfillersToThis.length > 0) { postfix = "&beforeId=" + this.fulfillersToThis[this.fulfillersToThis.length - 1].jwtId; } const fulfillsInUrl = fulfillsUrl + postfix; const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json", }; const identity = await this.getIdentity(this.activeDid); if (identity) { const token = await accessToken(identity); headers["Authorization"] = "Bearer " + token; } try { const resp = await this.axios.get(fulfillsInUrl, { headers }); if (resp.status === 200) { this.fulfillersToThis = this.fulfillersToThis.concat(resp.data.data); this.fulfillersToHitLimit = resp.data.hitLimit; } else { this.$notify( { group: "alert", type: "danger", title: "Error", text: "Failed to retrieve more plans that fullfill this project.", }, 5000, ); } } catch (error: unknown) { const serverError = error as AxiosError; this.$notify( { group: "alert", type: "danger", title: "Error", text: "Something went wrong retrieving more plans that fulfull this project.", }, 5000, ); console.error( "Something went wrong retrieving more plans that fulfill this project:", serverError.message, ); } } /** * Handle clicking on a project entry found in the list * @param id of the project **/ async onClickLoadProject(projectId: string) { localStorage.setItem("projectId", projectId); const route = { path: "/project/" + encodeURIComponent(projectId), }; this.$router.push(route); this.loadProject(projectId, await this.getIdentity(this.activeDid)); } getOpenStreetMapUrl() { // Google URL is https://maps.google.com/?q=LAT,LONG return ( "https://www.openstreetmap.org/?mlat=" + this.latitude + "&mlon=" + this.longitude + "#map=15/" + this.latitude + "/" + this.longitude ); } openGiftDialog(contact?: GiverReceiverInputInfo) { (this.$refs.customGiveDialog as GiftedDialog).open( contact, undefined, undefined, "Given by " + (contact?.name || "someone not named"), ); } openOfferDialog() { (this.$refs.customOfferDialog as OfferDialog).open(); } onClickAllContactsGifting() { localStorage.setItem("projectId", this.projectId); const route = { name: "contact-gives", }; this.$router.push(route); } onClickLoadClaim(jwtId: string) { const route = { path: "/claim/" + encodeURIComponent(jwtId), }; this.$router.push(route); } checkIsFulfillable(offer: OfferSummaryRecord) { const offerRecord: GenericCredWrapper = { ...BLANK_GENERIC_SERVER_RECORD, claim: offer.fullClaim, claimType: "Offer", issuer: offer.offeredByDid, }; return libsUtil.canFulfillOffer(offerRecord); } onClickFulfillGiveToOffer(offer: OfferSummaryRecord) { const offerRecord: GenericCredWrapper = { ...BLANK_GENERIC_SERVER_RECORD, claim: offer.fullClaim, issuer: offer.offeredByDid, }; const giver: GiverReceiverInputInfo = { did: libsUtil.offerGiverDid(offerRecord), }; (this.$refs.customGiveDialog as GiftedDialog).open( giver, undefined, offer.handleId, "Given by " + (giver?.name || "someone not named"), ); } // return an HTTPS URL if it's not a global URL addScheme(url: string) { if (!libsUtil.isGlobalUri(url)) { return "https://" + url; } return url; } // return just the domain for display, if possible domainForWebsite(url: string) { try { const hostname = new URL(url).hostname; if (!hostname) { // happens for non-http URLs return url; } else if (url.endsWith(hostname)) { // it's just the domain return hostname; } else { // there's more, but don't bother displaying the whole thing return hostname + "..."; } } catch (error: unknown) { // must not be a valid URL return url; } } checkIsConfirmable(give: GiveSummaryRecord) { const giveDetails: GenericCredWrapper = { ...BLANK_GENERIC_SERVER_RECORD, claim: give.fullClaim, claimType: "GiveAction", issuer: give.agentDid, }; return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid); } confirmConfirmClaim(give: GiveSummaryRecord) { this.$notify( { group: "modal", type: "confirm", title: "Confirm", text: "Do you personally confirm that this is true?", onYes: async () => { await this.confirmClaim(give); }, }, -1, ); } // similar code is found in ClaimView async confirmClaim(give: GiveSummaryRecord) { // similar logic is found in endorser-mobile const goodClaim = serverUtil.removeSchemaContext( serverUtil.removeVisibleToDids( serverUtil.addLastClaimOrHandleAsIdIfMissing( give.fullClaim, give.jwtId, give.handleId, ), ), ); const confirmationClaim: serverUtil.GenericVerifiableCredential = { "@context": "https://schema.org", "@type": "AgreeAction", object: goodClaim, }; const result = await serverUtil.createAndSubmitClaim( confirmationClaim, await this.getIdentity(this.activeDid), this.apiServer, this.axios, ); if (result.type === "success") { this.$notify( { group: "alert", type: "success", title: "Success", text: "Confirmation submitted.", }, 5000, ); } else { console.error("Got error submitting the confirmation:", result); const message = (result.error?.error as string) || "There was a problem submitting the confirmation. See logs for more info."; this.$notify( { group: "alert", type: "danger", title: "Error", text: message, }, 5000, ); } } } </script>