<template> <div v-if="visible" class="dialog-overlay"> <div class="dialog"> <!-- Step 1: Giver --> <div id="sectionGiftedGiver" v-show="currentStep === 1"> <label class="block font-bold mb-4"> {{ showProjects ? 'Choose a project to benefit from:' : 'Choose a person to receive from:' }} </label> <!-- Unified Quick-pick grid for People and Projects --> <ul :class="showProjects ? 'grid grid-cols-3 md:grid-cols-4 gap-x-2 gap-y-4 text-center mb-4' : 'grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-2 gap-y-4 text-center mb-4'"> <template v-if="showProjects"> <li v-for="project in projects.slice(0, 7)" :key="project.handleId" @click="selectProject(project)" class="cursor-pointer" > <div class="relative w-fit mx-auto"> <ProjectIcon :entity-id="project.handleId" :icon-size="48" :image-url="project.image" class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1" /> </div> <h3 class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"> {{ project.name }} </h3> <div class="text-xs text-slate-500 truncate"> <font-awesome icon="user" class="fa-fw text-slate-400" /> {{ didInfo(project.issuerDid, activeDid, allMyDids, allContacts) }} </div> </li> <li v-if="projects.length === 0" class="text-xs text-slate-500 italic col-span-full"> (No projects found.) </li> <li v-if="projects.length > 0"> <router-link :to="{ name: 'discover' }" class="cursor-pointer" > <font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" /> <h3 class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"> Show All </h3> </router-link> </li> </template> <template v-else> <li v-if="isFromProjectView && activeDid" @click="selectGiver({ did: activeDid, name: 'You' })" class="cursor-pointer" > <font-awesome icon="hand" class="text-blue-500 text-5xl mb-1" /> <h3 class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden"> You </h3> </li> <li @click="selectGiver()" class="cursor-pointer" > <font-awesome icon="circle-question" class="text-slate-400 text-5xl mb-1" /> <h3 class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"> Unnamed </h3> </li> <li v-if="allContacts.length === 0" class="text-xs text-slate-500 italic col-span-full"> (Add friends to see more people worthy of recognition.) </li> <li v-for="contact in allContacts.slice(0, 10)" :key="contact.did" @click="selectGiver(contact)" class="cursor-pointer" > <div class="relative w-fit mx-auto"> <EntityIcon :contact="contact" class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1" /> <div class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"> <font-awesome icon="clock" class="block text-white text-xs w-[1em]" /> </div> </div> <h3 class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"> {{ contact.name || contact.did }} </h3> </li> <li v-if="allContacts.length > 0"> <router-link :to="{ name: 'contact-gift', query: { recipientProjectId: toProjectId, recipientProjectName: giver?.name, recipientProjectImage: giver?.image, recipientProjectHandleId: giver?.handleId } }" class="cursor-pointer" > <font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" /> <h3 class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"> Show All </h3> </router-link> </li> </template> </ul> <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-lg" @click="cancel" > Cancel </button> </div> <!-- Step 2: Gift --> <div id="sectionGiftedGift" v-show="currentStep === 2"> <button v-if="!fromProjectId" class="w-full flex items-center gap-2 bg-slate-100 border border-slate-300 rounded-md p-2 mb-4" @click="goBackToStep1" > <div> <template v-if="showProjects"> <ProjectIcon v-if="giver?.handleId" :entity-id="giver.handleId" :icon-size="32" :image-url="giver.image" class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover" /> </template> <template v-else> <EntityIcon v-if="giver?.did" :contact="giver" class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover" /> <font-awesome v-else icon="circle-question" class="text-slate-400 text-3xl" /> </template> </div> <div class="text-start min-w-0"> <p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">{{ showProjects ? 'Benefitted from:' : 'Received from:' }}</p> <h3 class="font-semibold truncate">{{ giver?.name || 'Unnamed' }}</h3> </div> <p class="ms-auto text-sm uppercase font-medium pe-2">Change</p> </button> <div v-else class="w-full flex items-center gap-2 bg-slate-100 border border-slate-300 rounded-md p-2 mb-4"> <div> <template v-if="showProjects"> <ProjectIcon v-if="giver?.handleId" :entity-id="giver.handleId" :icon-size="32" :image-url="giver.image" class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover" /> </template> <template v-else> <EntityIcon v-if="giver?.did" :contact="giver" class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover" /> <font-awesome v-else icon="circle-question" class="text-slate-400 text-3xl" /> </template> </div> <div class="text-start min-w-0"> <p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">{{ showProjects ? 'Benefitted from:' : 'Received from:' }}</p> <h3 class="font-semibold truncate">{{ giver?.name || 'Unnamed' }}</h3> </div> </div> <input v-model="description" type="text" class="block w-full rounded border border-slate-400 px-3 py-2 mb-4 placeholder:italic" :placeholder="prompt || 'What was given?'" /> <div class="flex mb-4"> <button class="rounded-s border border-e-0 border-slate-400 bg-slate-200 px-4 py-2" @click="amountInput === '0' ? null : decrement()" > <font-awesome icon="chevron-left" /> </button> <input id="inputGivenAmount" v-model="amountInput" type="number" class="flex-1 border border-e-0 border-slate-400 px-2 py-2 text-center w-[1px]" /> <button class="rounded-e border border-slate-400 bg-slate-200 px-4 py-2" @click="increment()" > <font-awesome icon="chevron-right" /> </button> <select v-model="unitCode" class="flex-1 rounded border border-slate-400 ms-2 px-3 py-2"> <option value="HUR">Hours</option> <option value="USD">US $</option> <option value="BTC">BTC</option> <option value="BX">BX</option> <option value="ETH">ETH</option> </select> </div> <router-link :to="{ name: 'gifted-details', query: { amountInput, description, giverDid: giver?.did, giverName: giver?.name, offerId, fulfillsProjectId: toProjectId, providerProjectId: fromProjectId, recipientDid: receiver?.did, recipientName: receiver?.name, unitCode, }, }" 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-lg mb-4" > Photo & more options… </router-link> <p class="text-center mb-4"> <b class="font-medium">Sign & Send</b> to publish to the world <font-awesome icon="circle-info" class="fa-fw text-blue-500 text-lg 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-md uppercase 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-1.5 py-2 rounded-lg" @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-lg" @click="cancel" > Cancel </button> </div> </div> </div> </div> </template> <script lang="ts"> import { Vue, Component, Prop } from "vue-facing-decorator"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { createAndSubmitGive, didInfo, serverMessageForUser, getHeaders, } from "../libs/endorserServer"; import * as libsUtil from "../libs/util"; import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { Contact } from "../db/tables/contacts"; import * as databaseUtil from "../db/databaseUtil"; import { retrieveAccountDids } from "../libs/util"; import { logger } from "../utils/logger"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import EntityIcon from "../components/EntityIcon.vue"; import ProjectIcon from "../components/ProjectIcon.vue"; import { PlanData } from "../interfaces/records"; @Component({ components: { EntityIcon, ProjectIcon, }, }) export default class GiftedDialog extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; @Prop() fromProjectId = ""; @Prop() toProjectId = ""; @Prop({ default: false }) showProjects = false; @Prop() isFromProjectView = false; activeDid = ""; allContacts: Array<Contact> = []; allMyDids: Array<string> = []; apiServer = ""; amountInput = "0"; callbackOnSuccess?: (amount: number) => void = () => {}; customTitle?: string; description = ""; giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent offerId = ""; prompt = ""; receiver?: libsUtil.GiverReceiverInputInfo; unitCode = "HUR"; visible = false; currentStep = 1; libsUtil = libsUtil; projects: PlanData[] = []; didInfo = didInfo; async open( giver?: libsUtil.GiverReceiverInputInfo, receiver?: libsUtil.GiverReceiverInputInfo, offerId?: string, customTitle?: string, prompt?: string, callbackOnSuccess: (amount: number) => void = () => {}, ) { this.customTitle = customTitle; this.giver = giver; this.prompt = prompt || ""; this.receiver = receiver; this.amountInput = "0"; this.callbackOnSuccess = callbackOnSuccess; this.offerId = offerId || ""; this.currentStep = giver ? 2 : 1; try { let settings = await databaseUtil.retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) { settings = await retrieveSettingsForActiveAccount(); } this.apiServer = settings.apiServer || ""; this.activeDid = settings.activeDid || ""; const platformService = PlatformServiceFactory.getInstance(); const result = await platformService.dbQuery(`SELECT * FROM contacts`); if (result) { this.allContacts = databaseUtil.mapQueryResultToValues( result, ) as unknown as Contact[]; } if (USE_DEXIE_DB) { this.allContacts = await db.contacts.toArray(); } this.allMyDids = await retrieveAccountDids(); if (this.giver && !this.giver.name) { this.giver.name = didInfo( this.giver.did, this.activeDid, this.allMyDids, this.allContacts, ); } if (this.showProjects) { await this.loadProjects(); } } catch (err: any) { logger.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, ); } this.visible = true; } close() { // close the dialog but don't change values (since it might be submitting info) this.visible = false; } 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() { this.close(); this.eraseValues(); } eraseValues() { this.description = ""; this.giver = undefined; this.amountInput = "0"; this.prompt = ""; this.unitCode = "HUR"; this.currentStep = 1; } async confirm() { if (!this.activeDid) { this.$notify( { group: "alert", type: "danger", title: "Error", text: "You must select an identifier before you can record a give.", }, 3000, ); 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.description && !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.close(); this.$notify( { group: "alert", type: "toast", text: "Recording the give...", title: "", }, 1000, ); // this is asynchronous, but we don't need to wait for it to complete await this.recordGive( (this.giver?.did as string) || null, (this.receiver?.did as string) || null, this.description, parseFloat(this.amountInput), this.unitCode, ).then(() => { this.eraseValues(); }); } /** * * @param giverDid may be null * @param recipientDid may be null * @param description may be an empty string * @param amount may be 0 * @param unitCode may be omitted, defaults to "HUR" */ async recordGive( giverDid: string | null, recipientDid: string | null, description: string, amount: number, unitCode: string = "HUR", ) { try { const result = await createAndSubmitGive( this.axios, this.apiServer, this.activeDid, this.showProjects ? undefined : giverDid as string, this.showProjects && this.isFromProjectView ? this.giver?.handleId : recipientDid as string, description, amount, unitCode, this.showProjects && this.isFromProjectView ? this.giver?.handleId : this.toProjectId, this.offerId, false, undefined, this.showProjects && !this.isFromProjectView ? this.giver?.handleId : undefined, ); if (!result.success) { const errorMessage = this.getGiveCreationErrorMessage(result); logger.error("Error with give creation result:", result); this.$notify( { group: "alert", type: "danger", title: "Error", text: errorMessage || "There was an error creating the give.", }, -1, ); } else { this.$notify( { group: "alert", type: "success", title: "Success", text: `That gift was recorded.`, }, 7000, ); if (this.callbackOnSuccess) { this.callbackOnSuccess(amount); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { logger.error("Error with give recordation caught:", error); const errorMessage = error.userMessage || serverMessageForUser(error) || "There was an error recording the give."; this.$notify( { group: "alert", type: "danger", title: "Error", text: errorMessage, }, -1, ); } } // Helper functions for readability /** * @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 getGiveCreationErrorMessage(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, ); } selectGiver(contact?: Contact) { if (contact) { this.giver = { did: contact.did, name: contact.name || contact.did }; } else { this.giver = { did: '', name: 'Unnamed' }; } this.currentStep = 2; } goBackToStep1() { this.currentStep = 1; } async loadProjects() { try { const response = await fetch(this.apiServer + "/api/v2/report/plans", { method: "GET", headers: await getHeaders(this.activeDid), }); if (response.status !== 200) { throw new Error("Failed to load projects"); } const results = await response.json(); if (results.data) { this.projects = results.data; } } catch (error) { logger.error("Error loading projects:", error); this.$notify( { group: "alert", type: "danger", title: "Error", text: "Failed to load projects", }, 3000, ); } } selectProject(project: PlanData) { this.giver = { did: project.handleId, name: project.name, image: project.image, handleId: project.handleId }; this.receiver = { did: this.activeDid, name: "You" }; this.currentStep = 2; } } </script> <style> .dialog-overlay { z-index: 50; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; padding: 1.5rem; } .dialog { background-color: white; padding: 1rem; border-radius: 0.5rem; width: 100%; max-width: 500px; } </style>