forked from jsnbuchanan/crowd-funder-for-time-pwa
Changes: - Move v-model directives before other attributes - Move v-bind directives before event handlers - Reorder attributes for better readability - Fix template attribute ordering across components - Improve eslint rules - add default vite config for testing (handles nostr error too) This follows Vue.js style guide recommendations for attribute ordering and improves template consistency.
629 lines
17 KiB
Vue
629 lines
17 KiB
Vue
<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()"
|
|
>
|
|
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
|
|
</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 not named"
|
|
}}</span
|
|
>
|
|
</h1>
|
|
<textarea
|
|
v-model="descriptionOfItem"
|
|
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
|
placeholder="What is offered"
|
|
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()"
|
|
>
|
|
<font-awesome icon="chevron-left" />
|
|
</div>
|
|
<input
|
|
v-model="amountInput"
|
|
type="number"
|
|
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
|
data-testId="inputOfferAmount"
|
|
/>
|
|
<div
|
|
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
|
@click="increment()"
|
|
>
|
|
<font-awesome 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
|
|
v-model="descriptionOfCondition"
|
|
class="w-full border border-slate-400 px-3 py-2 rounded-r"
|
|
placeholder="Prerequisites, other people to include, etc."
|
|
/>
|
|
</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"
|
|
v-model="offeredToProject"
|
|
type="checkbox"
|
|
class="h-6 w-6 mr-2"
|
|
/>
|
|
<font-awesome
|
|
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"
|
|
v-model="offeredToRecipient"
|
|
type="checkbox"
|
|
class="h-6 w-6 mr-2"
|
|
/>
|
|
<font-awesome
|
|
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
|
|
<font-awesome
|
|
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 { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|
|
|
import QuickNav from "../components/QuickNav.vue";
|
|
import TopMessage from "../components/TopMessage.vue";
|
|
import { NotificationIface } from "../constants/app";
|
|
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
|
import { GenericCredWrapper, OfferVerifiableCredential } from "../interfaces";
|
|
import {
|
|
createAndSubmitOffer,
|
|
didInfo,
|
|
editAndSubmitOffer,
|
|
getPlanFromCache,
|
|
hydrateOffer,
|
|
} from "../libs/endorserServer";
|
|
import * as libsUtil from "../libs/util";
|
|
import { retrieveAccountDids } from "../libs/util";
|
|
|
|
@Component({
|
|
components: {
|
|
QuickNav,
|
|
TopMessage,
|
|
},
|
|
})
|
|
export default class OfferDetailsView extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
$route!: RouteLocationNormalizedLoaded;
|
|
$router!: Router;
|
|
|
|
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.query["prevCredToEdit"] as string)
|
|
? (JSON.parse(
|
|
this.$route.query["prevCredToEdit"] as string,
|
|
) 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.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
|
|
const prevAmount =
|
|
this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood;
|
|
this.amountInput =
|
|
(this.$route.query["amountInput"] as string) ||
|
|
(prevAmount ? String(prevAmount) : "") ||
|
|
this.amountInput;
|
|
this.unitCode = ((this.$route.query["unitCode"] as string) ||
|
|
this.prevCredToEdit?.claim?.includesObject?.unitCode ||
|
|
this.unitCode) as string;
|
|
|
|
this.descriptionOfCondition =
|
|
this.prevCredToEdit?.claim?.description || this.descriptionOfCondition;
|
|
this.descriptionOfItem =
|
|
(this.$route.query["description"] as string) ||
|
|
this.prevCredToEdit?.claim?.itemOffered?.description ||
|
|
this.descriptionOfItem;
|
|
this.destinationPathAfter =
|
|
(this.$route.query["destinationPathAfter"] as string) || "";
|
|
this.offererDid = ((this.$route.query["offererDid"] as string) ||
|
|
(this.prevCredToEdit?.claim?.agent as unknown as { identifier: string })
|
|
?.identifier ||
|
|
this.offererDid) as string;
|
|
this.hideBackButton =
|
|
(this.$route.query["hideBackButton"] as string) === "true";
|
|
this.message = (this.$route.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.query["projectId"] as string) ||
|
|
project?.identifier ||
|
|
this.projectId) as string;
|
|
this.projectName = ((this.$route.query["projectName"] as string) ||
|
|
project?.name ||
|
|
this.projectName) as string;
|
|
|
|
this.recipientDid = ((this.$route.query["recipientDid"] as string) ||
|
|
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
|
|
this.recipientName = (this.$route.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;
|
|
|
|
if (this.recipientDid && !this.recipientName) {
|
|
const allContacts = await db.contacts.toArray();
|
|
const allMyDids = await retrieveAccountDids();
|
|
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.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
|
|
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.",
|
|
},
|
|
5000,
|
|
);
|
|
} 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,
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
7000,
|
|
);
|
|
}
|
|
}
|
|
</script>
|