Browse Source
Reviewed-on: https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/pulls/123
trentlarson
3 months ago
11 changed files with 891 additions and 97 deletions
@ -0,0 +1,633 @@ |
|||
<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 Was 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 was offered" |
|||
v-model="itemDescription" |
|||
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="conditionDescription" |
|||
/> |
|||
</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 was given 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 was given to " + recipientName |
|||
: "No recipient was chosen." |
|||
}} |
|||
</label> |
|||
</div> |
|||
|
|||
<div 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 } from "@/db/index"; |
|||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; |
|||
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"; |
|||
conditionDescription = ""; |
|||
itemDescription = ""; |
|||
destinationPathAfter = ""; |
|||
offeredToProject = false; |
|||
offeredToRecipient = false; |
|||
offererDid: string | undefined; |
|||
hideBackButton = false; |
|||
message = ""; |
|||
offerId = ""; |
|||
prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>; |
|||
projectId = ""; |
|||
projectName = "a project"; |
|||
recipientDid = ""; |
|||
recipientName = ""; |
|||
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.conditionDescription = |
|||
this.prevCredToEdit?.claim?.description || this.conditionDescription; |
|||
this.itemDescription = |
|||
(this.$route as Router).query["description"] || |
|||
this.prevCredToEdit?.claim?.itemOffered?.description || |
|||
this.itemDescription; |
|||
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 { |
|||
await db.open(); |
|||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; |
|||
this.apiServer = settings?.apiServer || ""; |
|||
this.activeDid = settings?.activeDid || ""; |
|||
|
|||
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.itemDescription && !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 give...", |
|||
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.itemDescription, |
|||
parseFloat(this.amountInput), |
|||
this.unitCode, |
|||
this.conditionDescription, |
|||
this.validThroughDateInput, |
|||
recipientDid, |
|||
projectId, |
|||
); |
|||
} else { |
|||
result = await createAndSubmitOffer( |
|||
this.axios, |
|||
this.apiServer, |
|||
this.activeDid, |
|||
this.itemDescription, |
|||
parseFloat(this.amountInput), |
|||
this.unitCode, |
|||
this.conditionDescription, |
|||
this.validThroughDateInput, |
|||
recipientDid, |
|||
projectId, |
|||
); |
|||
} |
|||
|
|||
if (result.type === "error" || this.isCreationError(result.response)) { |
|||
const errorMessage = this.getCreationErrorMessage(result); |
|||
console.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 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 give recordation caught:", error); |
|||
const errorMessage = |
|||
error.userMessage || |
|||
error.response?.data?.error?.message || |
|||
"There was an error recording the give."; |
|||
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 giveClaim = hydrateOffer( |
|||
this.prevCredToEdit?.claim as OfferVerifiableCredential, |
|||
this.activeDid, |
|||
recipientDid, |
|||
this.itemDescription, |
|||
parseFloat(this.amountInput), |
|||
this.unitCode, |
|||
this.conditionDescription, |
|||
projectId, |
|||
this.validThroughDateInput, |
|||
this.prevCredToEdit?.id as string, |
|||
); |
|||
const claimStr = JSON.stringify(giveClaim); |
|||
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> |
Loading…
Reference in new issue