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