You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							633 lines
						
					
					
						
							17 KiB
						
					
					
				
			
		
		
		
			
			
			
				
					
				
				
					
				
			
		
		
	
	
							633 lines
						
					
					
						
							17 KiB
						
					
					
				| <template> | |
|   <QuickNav /> | |
|   <TopMessage /> | |
| 
 | |
|   <!-- CONTENT --> | |
|   <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> | |
|     <!-- Back --> | |
|     <div | |
|       v-if="!hideBackButton" | |
|       class="text-lg text-center font-light relative px-7" | |
|     > | |
|       <h1 | |
|         class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" | |
|         @click="cancelBack()" | |
|       > | |
|         <fa icon="chevron-left" class="fa-fw"></fa> | |
|       </h1> | |
|     </div> | |
|  | |
|     <!-- Heading --> | |
|     <h1 class="text-4xl text-center font-light px-4 mb-4">What Is Offered</h1> | |
|  | |
|     <h1 class="text-xl font-bold text-center mb-4"> | |
|       <span> | |
|         Offer to | |
|         {{ | |
|           offeredToProject | |
|             ? projectName | |
|             : offeredToRecipient | |
|               ? recipientName | |
|               : "someone unidentified" | |
|         }}</span | |
|       > | |
|     </h1> | |
|     <textarea | |
|       class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" | |
|       placeholder="What is offered" | |
|       v-model="descriptionOfItem" | |
|       data-testId="itemDescription" | |
|     /> | |
|     <div class="flex flex-row justify-center"> | |
|       <span | |
|         class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20" | |
|         @click="changeUnitCode()" | |
|       > | |
|         {{ libsUtil.UNIT_SHORT[unitCode] || unitCode }} | |
|       </span> | |
|       <div | |
|         class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2" | |
|         @click="amountInput === '0' ? null : decrement()" | |
|       > | |
|         <fa icon="chevron-left" /> | |
|       </div> | |
|       <input | |
|         type="number" | |
|         class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20" | |
|         v-model="amountInput" | |
|         data-testId="inputOfferAmount" | |
|       /> | |
|       <div | |
|         class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2" | |
|         @click="increment()" | |
|       > | |
|         <fa icon="chevron-right" /> | |
|       </div> | |
|     </div> | |
|  | |
|     <div class="flex flex-row mt-2"> | |
|       <span | |
|         class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2" | |
|       > | |
|         Conditions | |
|       </span> | |
|       <textarea | |
|         class="w-full border border-slate-400 px-3 py-2 rounded-r" | |
|         placeholder="Prerequisites, other people to include, etc." | |
|         v-model="descriptionOfCondition" | |
|       /> | |
|     </div> | |
|  | |
|     <div class="flex flex-row mt-2"> | |
|       <span | |
|         class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2" | |
|       > | |
|         {{ validThroughDateInput ? "" : "No" }} Expiration | |
|       </span> | |
|       <input | |
|         v-model="validThroughDateInput" | |
|         type="date" | |
|         class="w-full rounded border border-slate-400 px-3 py-2 rounded-r" | |
|       /> | |
|     </div> | |
|  | |
|     <div class="h-7 mt-4 flex"> | |
|       <input | |
|         v-if="projectId && !offeredToRecipient" | |
|         type="checkbox" | |
|         class="h-6 w-6 mr-2" | |
|         v-model="offeredToProject" | |
|       /> | |
|       <fa | |
|         v-else | |
|         icon="square" | |
|         class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded" | |
|         @click="notifyUserOfProject()" | |
|       /> | |
|       <label class="text-sm mt-1"> | |
|         {{ | |
|           projectId | |
|             ? "This is offered to " + projectName | |
|             : "No project was chosen" | |
|         }} | |
|       </label> | |
|     </div> | |
|  | |
|     <div class="h-7 mt-4 flex"> | |
|       <input | |
|         v-if="recipientDid && !offeredToProject" | |
|         type="checkbox" | |
|         class="h-6 w-6 mr-2" | |
|         v-model="offeredToRecipient" | |
|       /> | |
|       <fa | |
|         v-else | |
|         icon="square" | |
|         class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded" | |
|         @click="notifyUserOfRecipient()" | |
|       /> | |
|       <label class="text-sm mt-1"> | |
|         {{ | |
|           recipientDid | |
|             ? "This is offered to " + recipientName | |
|             : "No recipient was chosen." | |
|         }} | |
|       </label> | |
|     </div> | |
|  | |
|     <div v-if="showGeneralAdvanced" class="mt-4 flex"> | |
|       <router-link | |
|         :to="{ | |
|           name: 'claim-add-raw', | |
|           query: { | |
|             claim: constructOfferParam(), | |
|           }, | |
|         }" | |
|         class="text-blue-500" | |
|       > | |
|         Edit & Submit Raw | |
|       </router-link> | |
|     </div> | |
|  | |
|     <p class="text-center mb-2 mt-6 italic"> | |
|       Sign & Send to publish to the world | |
|       <fa | |
|         icon="circle-info" | |
|         class="pl-2 text-blue-500 cursor-pointer" | |
|         @click="explainData()" | |
|       /> | |
|     </p> | |
|     <div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> | |
|       <button | |
|         class="block w-full text-center text-lg font-bold uppercase 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" | |
|         @click="confirm" | |
|       > | |
|         Sign & Send | |
|       </button> | |
|       <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="cancel" | |
|       > | |
|         Cancel | |
|       </button> | |
|     </div> | |
|   </section> | |
| </template> | |
|  | |
| <script lang="ts"> | |
| import { Component, Vue } from "vue-facing-decorator"; | |
| import { Router } from "vue-router"; | |
| 
 | |
| import QuickNav from "@/components/QuickNav.vue"; | |
| import TopMessage from "@/components/TopMessage.vue"; | |
| import { NotificationIface } from "@/constants/app"; | |
| import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; | |
| import { | |
|   createAndSubmitOffer, | |
|   didInfo, | |
|   editAndSubmitOffer, | |
|   GenericCredWrapper, | |
|   getPlanFromCache, | |
|   hydrateOffer, | |
|   OfferVerifiableCredential, | |
| } from "@/libs/endorserServer"; | |
| import * as libsUtil from "@/libs/util"; | |
| import { Contact } from "@/db/tables/contacts"; | |
| 
 | |
| @Component({ | |
|   components: { | |
|     QuickNav, | |
|     TopMessage, | |
|   }, | |
| }) | |
| export default class OfferDetailsView extends Vue { | |
|   $notify!: (notification: NotificationIface, timeout?: number) => void; | |
| 
 | |
|   activeDid = ""; | |
|   apiServer = ""; | |
| 
 | |
|   amountInput = "0"; | |
|   descriptionOfCondition = ""; | |
|   descriptionOfItem = ""; | |
|   destinationPathAfter = ""; | |
|   hideBackButton = false; | |
|   message = ""; | |
|   offeredToProject = false; | |
|   offeredToRecipient = false; | |
|   offererDid: string | undefined; | |
|   offerId = ""; | |
|   prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>; | |
|   projectId = ""; | |
|   projectName = "a project"; | |
|   recipientDid = ""; | |
|   recipientName = ""; | |
|   showGeneralAdvanced = false; | |
|   unitCode = "HUR"; | |
|   validThroughDateInput = ""; | |
| 
 | |
|   libsUtil = libsUtil; | |
| 
 | |
|   async mounted() { | |
|     try { | |
|       this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"] | |
|         ? (JSON.parse( | |
|             (this.$route as Router).query["prevCredToEdit"], | |
|           ) as GenericCredWrapper<OfferVerifiableCredential>) | |
|         : undefined; | |
|     } catch (error) { | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "danger", | |
|           title: "Retrieval Error", | |
|           text: "The previous record isn't available for editing. If you submit, you'll create a new record.", | |
|         }, | |
|         6000, | |
|       ); | |
|     } | |
| 
 | |
|     const prevAmount = | |
|       this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood; | |
|     this.amountInput = | |
|       (this.$route as Router).query["amountInput"] || | |
|       (prevAmount ? String(prevAmount) : "") || | |
|       this.amountInput; | |
|     this.unitCode = ((this.$route as Router).query["unitCode"] || | |
|       this.prevCredToEdit?.claim?.includesObject?.unitCode || | |
|       this.unitCode) as string; | |
| 
 | |
|     this.descriptionOfCondition = | |
|       this.prevCredToEdit?.claim?.description || this.descriptionOfCondition; | |
|     this.descriptionOfItem = | |
|       (this.$route as Router).query["description"] || | |
|       this.prevCredToEdit?.claim?.itemOffered?.description || | |
|       this.descriptionOfItem; | |
|     this.destinationPathAfter = (this.$route as Router).query[ | |
|       "destinationPathAfter" | |
|     ]; | |
|     this.offererDid = ((this.$route as Router).query["offererDid"] || | |
|       this.prevCredToEdit?.claim?.agent?.identifier || | |
|       this.offererDid) as string; | |
|     this.hideBackButton = | |
|       (this.$route as Router).query["hideBackButton"] === "true"; | |
|     this.message = ((this.$route as Router).query["message"] as string) || ""; | |
| 
 | |
|     // find any project ID | |
|     let project; | |
|     if ( | |
|       this.prevCredToEdit?.claim?.itemOffered?.isPartOf?.["@type"] === | |
|       "PlanAction" | |
|     ) { | |
|       project = this.prevCredToEdit?.claim?.itemOffered?.isPartOf; | |
|     } | |
|     this.projectId = ((this.$route as Router).query["projectId"] || | |
|       project?.identifier || | |
|       this.projectId) as string; | |
|     this.projectName = ((this.$route as Router).query["projectName"] || | |
|       project?.name || | |
|       this.projectName) as string; | |
| 
 | |
|     this.recipientDid = ((this.$route as Router).query["recipientDid"] || | |
|       this.prevCredToEdit?.claim?.recipient?.identifier) as string; | |
|     this.recipientName = | |
|       ((this.$route as Router).query["recipientName"] as string) || ""; | |
| 
 | |
|     this.validThroughDateInput = | |
|       this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput; | |
| 
 | |
|     try { | |
|       const settings = await retrieveSettingsForActiveAccount(); | |
|       this.apiServer = settings.apiServer ?? ""; | |
|       this.activeDid = settings.activeDid ?? ""; | |
|       this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false; | |
| 
 | |
|       let allContacts: Contact[] = []; | |
|       let allMyDids: string[] = []; | |
|       if (this.recipientDid && !this.recipientName) { | |
|         allContacts = await db.contacts.toArray(); | |
| 
 | |
|         await accountsDB.open(); | |
|         const allAccounts = await accountsDB.accounts.toArray(); | |
|         allMyDids = allAccounts.map((acc) => acc.did); | |
|         this.recipientName = didInfo( | |
|           this.recipientDid, | |
|           this.activeDid, | |
|           allMyDids, | |
|           allContacts, | |
|         ); | |
|       } | |
|       // these should be functions but something's wrong with the syntax in the <> conditional | |
|       this.offeredToProject = !!this.projectId; | |
|       this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid; | |
| 
 | |
|       // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
|     } catch (err: any) { | |
|       console.error("Error retrieving settings from database:", err); | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "danger", | |
|           title: "Error", | |
|           text: err.message || "There was an error retrieving your settings.", | |
|         }, | |
|         -1, | |
|       ); | |
|     } | |
| 
 | |
|     if (this.projectId && !this.projectName) { | |
|       // console.log("Getting project name from cache", this.projectId); | |
|       const project = await getPlanFromCache( | |
|         this.projectId, | |
|         this.axios, | |
|         this.apiServer, | |
|         this.activeDid, | |
|       ); | |
|       this.projectName = project?.name | |
|         ? "the project: " + project.name | |
|         : "a project"; | |
|     } | |
|   } | |
| 
 | |
|   changeUnitCode() { | |
|     const units = Object.keys(this.libsUtil.UNIT_SHORT); | |
|     const index = units.indexOf(this.unitCode); | |
|     this.unitCode = units[(index + 1) % units.length]; | |
|   } | |
| 
 | |
|   increment() { | |
|     this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`; | |
|   } | |
| 
 | |
|   decrement() { | |
|     this.amountInput = `${Math.max( | |
|       0, | |
|       (parseFloat(this.amountInput) || 1) - 1, | |
|     )}`; | |
|   } | |
| 
 | |
|   cancel() { | |
|     if (this.destinationPathAfter) { | |
|       (this.$router as Router).push({ path: this.destinationPathAfter }); | |
|     } else { | |
|       (this.$router as Router).back(); | |
|     } | |
|   } | |
| 
 | |
|   cancelBack() { | |
|     (this.$router as Router).back(); | |
|   } | |
| 
 | |
|   async confirm() { | |
|     if (!this.activeDid) { | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "danger", | |
|           title: "Error", | |
|           text: "You must select an identifier before you can record a offer.", | |
|         }, | |
|         2000, | |
|       ); | |
|       return; | |
|     } | |
|     if (parseFloat(this.amountInput) < 0) { | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "danger", | |
|           text: "You may not send a negative number.", | |
|           title: "", | |
|         }, | |
|         2000, | |
|       ); | |
|       return; | |
|     } | |
|     if (!this.descriptionOfItem && !parseFloat(this.amountInput)) { | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "danger", | |
|           title: "Error", | |
|           text: `You must enter a description or some number of ${ | |
|             this.libsUtil.UNIT_LONG[this.unitCode] | |
|           }.`, | |
|         }, | |
|         2000, | |
|       ); | |
|       return; | |
|     } | |
| 
 | |
|     this.$notify( | |
|       { | |
|         group: "alert", | |
|         type: "toast", | |
|         text: "Recording the offer...", | |
|         title: "", | |
|       }, | |
|       1000, | |
|     ); | |
| 
 | |
|     // this is asynchronous, but we don't need to wait for it to complete | |
|     await this.recordOffer(); | |
|   } | |
| 
 | |
|   notifyUserOfProject() { | |
|     if (!this.projectId) { | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "warning", | |
|           title: "Error", | |
|           text: "To assign to a project, you must open this page through a project.", | |
|         }, | |
|         3000, | |
|       ); | |
|     } else { | |
|       // must be because offeredToRecipient is true | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "warning", | |
|           title: "Error", | |
|           text: "You cannot assign both to a project and to a recipient.", | |
|         }, | |
|         3000, | |
|       ); | |
|     } | |
|   } | |
| 
 | |
|   notifyUserOfRecipient() { | |
|     if (!this.recipientDid) { | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "warning", | |
|           title: "Error", | |
|           text: "To assign to a recipient, you must open this page from a contact.", | |
|         }, | |
|         3000, | |
|       ); | |
|     } else { | |
|       // must be because offeredToProject is true | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "warning", | |
|           title: "Error", | |
|           text: "You cannot assign both to a recipient and to a project.", | |
|         }, | |
|         3000, | |
|       ); | |
|     } | |
|   } | |
| 
 | |
|   /** | |
|    * | |
|    * @param offererDid may be null | |
|    * @param description may be an empty string | |
|    * @param amountInput may be 0 | |
|    * @param unitCode may be omitted, defaults to "HUR" | |
|    */ | |
|   public async recordOffer() { | |
|     try { | |
|       const recipientDid = this.offeredToRecipient | |
|         ? this.recipientDid | |
|         : undefined; | |
|       const projectId = this.offeredToProject ? this.projectId : undefined; | |
|       let result; | |
|       if (this.prevCredToEdit) { | |
|         // don't create from a blank one in case some properties were set from a different interface | |
|         result = await editAndSubmitOffer( | |
|           this.axios, | |
|           this.apiServer, | |
|           this.prevCredToEdit, | |
|           this.activeDid, | |
|           this.descriptionOfItem, | |
|           parseFloat(this.amountInput), | |
|           this.unitCode, | |
|           this.descriptionOfCondition, | |
|           this.validThroughDateInput, | |
|           recipientDid, | |
|           projectId, | |
|         ); | |
|       } else { | |
|         result = await createAndSubmitOffer( | |
|           this.axios, | |
|           this.apiServer, | |
|           this.activeDid, | |
|           this.descriptionOfItem, | |
|           parseFloat(this.amountInput), | |
|           this.unitCode, | |
|           this.descriptionOfCondition, | |
|           this.validThroughDateInput, | |
|           recipientDid, | |
|           projectId, | |
|         ); | |
|       } | |
| 
 | |
|       if (result.type === "error" || this.isCreationError(result.response)) { | |
|         const errorMessage = this.getCreationErrorMessage(result); | |
|         console.error("Error with offer creation result:", result); | |
|         this.$notify( | |
|           { | |
|             group: "alert", | |
|             type: "danger", | |
|             title: "Error", | |
|             text: errorMessage || "There was an error creating the offer.", | |
|           }, | |
|           -1, | |
|         ); | |
|       } else { | |
|         this.$notify( | |
|           { | |
|             group: "alert", | |
|             type: "success", | |
|             title: "Success", | |
|             text: `That offer was recorded.`, | |
|           }, | |
|           5000, | |
|         ); | |
|         localStorage.removeItem("imageUrl"); | |
|         if (this.destinationPathAfter) { | |
|           (this.$router as Router).push({ path: this.destinationPathAfter }); | |
|         } else { | |
|           (this.$router as Router).back(); | |
|         } | |
|       } | |
|       // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
|     } catch (error: any) { | |
|       console.error("Error with offer recordation caught:", error); | |
|       const errorMessage = | |
|         error.userMessage || | |
|         error.response?.data?.error?.message || | |
|         "There was an error recording the offer."; | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "danger", | |
|           title: "Error", | |
|           text: errorMessage, | |
|         }, | |
|         -1, | |
|       ); | |
|     } | |
|   } | |
| 
 | |
|   constructOfferParam() { | |
|     const recipientDid = this.offeredToRecipient | |
|       ? this.recipientDid | |
|       : undefined; | |
|     const projectId = this.offeredToProject ? this.projectId : undefined; | |
|     const offerClaim = hydrateOffer( | |
|       this.prevCredToEdit?.claim as OfferVerifiableCredential, | |
|       this.activeDid, | |
|       recipientDid, | |
|       this.descriptionOfItem, | |
|       parseFloat(this.amountInput), | |
|       this.unitCode, | |
|       this.descriptionOfCondition, | |
|       projectId, | |
|       this.validThroughDateInput, | |
|       this.prevCredToEdit?.id as string, | |
|     ); | |
|     const claimStr = JSON.stringify(offerClaim); | |
|     return claimStr; | |
|   } | |
| 
 | |
|   // Helper functions for readability | |
|  | |
|   /** | |
|    * @param result response "data" from the server | |
|    * @returns true if the result indicates an error | |
|    */ | |
|   // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
|   isCreationError(result: any) { | |
|     return result.status !== 201 || result.data?.error; | |
|   } | |
| 
 | |
|   /** | |
|    * @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data") | |
|    * @returns best guess at an error message | |
|    */ | |
|   // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
|   getCreationErrorMessage(result: any) { | |
|     return ( | |
|       result.error?.userMessage || | |
|       result.error?.error || | |
|       result.response?.data?.error?.message | |
|     ); | |
|   } | |
| 
 | |
|   explainData() { | |
|     this.$notify( | |
|       { | |
|         group: "alert", | |
|         type: "success", | |
|         title: "Data Sharing", | |
|         text: libsUtil.PRIVACY_MESSAGE, | |
|       }, | |
|       -1, | |
|     ); | |
|   } | |
| } | |
| </script>
 | |
| 
 |