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

<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" }}&nbsp;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 &amp; 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>