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.
683 lines
21 KiB
683 lines
21 KiB
<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>
|
|
|