|
|
@ -8,8 +8,44 @@ |
|
|
|
Your Ideas |
|
|
|
</h1> |
|
|
|
|
|
|
|
<!-- Quick Search --> |
|
|
|
<!-- Result Tabs --> |
|
|
|
<div class="text-center text-slate-500 border-b border-slate-300"> |
|
|
|
<ul class="flex flex-wrap justify-center gap-4 -mb-px"> |
|
|
|
<li> |
|
|
|
<a |
|
|
|
href="#" |
|
|
|
@click=" |
|
|
|
offers = []; |
|
|
|
projects = []; |
|
|
|
showOffers = true; |
|
|
|
showProjects = false; |
|
|
|
loadOffers(); |
|
|
|
" |
|
|
|
v-bind:class="computedOfferTabClassNames()" |
|
|
|
> |
|
|
|
Offers |
|
|
|
</a> |
|
|
|
</li> |
|
|
|
<li> |
|
|
|
<a |
|
|
|
href="#" |
|
|
|
@click=" |
|
|
|
offers = []; |
|
|
|
projects = []; |
|
|
|
showOffers = false; |
|
|
|
showProjects = true; |
|
|
|
loadProjects(); |
|
|
|
" |
|
|
|
v-bind:class="computedProjectTabClassNames()" |
|
|
|
> |
|
|
|
Projects |
|
|
|
</a> |
|
|
|
</li> |
|
|
|
</ul> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- Quick Search --> |
|
|
|
<!-- |
|
|
|
<div id="QuickSearch" class="mb-4 flex"> |
|
|
|
<input |
|
|
|
type="text" |
|
|
@ -22,9 +58,11 @@ |
|
|
|
<fa icon="magnifying-glass" class="fa-fw"></fa> |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
--> |
|
|
|
|
|
|
|
<!-- New Project --> |
|
|
|
<button |
|
|
|
v-if="showProjects" |
|
|
|
class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full" |
|
|
|
@click="onClickNewProject()" |
|
|
|
> |
|
|
@ -39,8 +77,108 @@ |
|
|
|
<fa icon="spinner" class="fa-spin-pulse"></fa> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- Results List --> |
|
|
|
<InfiniteScroll @reached-bottom="loadMoreData"> |
|
|
|
<!-- Offer Results List --> |
|
|
|
<InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData"> |
|
|
|
<ul class="border-t border-slate-300"> |
|
|
|
<li |
|
|
|
class="border-b border-slate-300" |
|
|
|
v-for="offer in offers" |
|
|
|
:key="offer.handleId" |
|
|
|
> |
|
|
|
<div class="block py-4 flex gap-4"> |
|
|
|
<div v-if="offer.fulfillsPlanHandleId" class="flex-none w-12"> |
|
|
|
<ProjectIcon |
|
|
|
:entityId="offer.fulfillsPlanHandleId" |
|
|
|
:iconSize="48" |
|
|
|
class="inline-block align-middle border border-slate-300 rounded-md" |
|
|
|
></ProjectIcon> |
|
|
|
</div> |
|
|
|
<div v-if="offer.recipientDid" class="flex-none w-12"> |
|
|
|
<EntityIcon |
|
|
|
:entityId="offer.recipientDid" |
|
|
|
:iconSize="48" |
|
|
|
class="inline-block align-middle border border-slate-300 rounded-md" |
|
|
|
></EntityIcon> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div> |
|
|
|
<div> |
|
|
|
{{ offer.objectDescription }} |
|
|
|
</div> |
|
|
|
|
|
|
|
<span class="text-sm"> |
|
|
|
<span v-if="offer.amount"> |
|
|
|
<fa |
|
|
|
:icon="libsUtil.iconForUnitCode(offer.unit)" |
|
|
|
class="fa-fw text-slate-400" |
|
|
|
/> |
|
|
|
|
|
|
|
<span v-if="offer.amountGiven >= offer.amount"> |
|
|
|
<fa icon="check-circle" class="fa-fw text-green-500" /> |
|
|
|
All {{ offer.amount }} given |
|
|
|
</span> |
|
|
|
<span v-else> |
|
|
|
<fa |
|
|
|
icon="triangle-exclamation" |
|
|
|
class="fa-fw text-yellow-500" |
|
|
|
/> |
|
|
|
{{ offer.amountGiven ? "" : "All" }} |
|
|
|
{{ offer.amount - (offer.amountGiven || 0) }} remaining |
|
|
|
</span> |
|
|
|
|
|
|
|
<span v-if="offer.amountGiven > 0"> |
|
|
|
<span class="text-sm text-slate-400"> |
|
|
|
({{ offer.amountGiven }} given, |
|
|
|
<span |
|
|
|
v-if="offer.amountGivenConfirmed >= offer.amountGiven" |
|
|
|
> |
|
|
|
<!-- no need for green icon; unnecessary if there's already a green, confusing if there's a yellow --> |
|
|
|
all |
|
|
|
</span> |
|
|
|
<span v-else> |
|
|
|
<!-- only show icon if there's not already a warning --> |
|
|
|
<fa |
|
|
|
v-if="offer.amountGiven >= offer.amount" |
|
|
|
icon="triangle-exclamation" |
|
|
|
class="fa-fw text-yellow-300" |
|
|
|
/> |
|
|
|
{{ offer.amountGivenConfirmed || 0 }} |
|
|
|
</span> |
|
|
|
of that is confirmed) |
|
|
|
</span> |
|
|
|
</span> |
|
|
|
</span> |
|
|
|
<span v-else> |
|
|
|
<!-- Non-amount offer --> |
|
|
|
<span v-if="offer.nonAmountGivenConfirmed"> |
|
|
|
<fa icon="check-circle" class="fa-fw text-green-500" /> |
|
|
|
{{ offer.nonAmountGivenConfirmed }} |
|
|
|
{{ offer.nonAmountGivenConfirmed == 1 ? "give" : "gives" }} |
|
|
|
are confirmed. |
|
|
|
</span> |
|
|
|
<span v-else> |
|
|
|
<fa |
|
|
|
icon="triangle-exclamation" |
|
|
|
class="fa-fw text-yellow-500" |
|
|
|
/> |
|
|
|
<span class="text-sm">Not confirmed by anyone</span> |
|
|
|
</span> |
|
|
|
</span> |
|
|
|
|
|
|
|
<a @click="onClickLoadClaim(offer.jwtId)"> |
|
|
|
<fa |
|
|
|
icon="circle-info" |
|
|
|
class="pl-2 text-blue-500 cursor-pointer" |
|
|
|
></fa> |
|
|
|
</a> |
|
|
|
</span> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</li> |
|
|
|
</ul> |
|
|
|
</InfiniteScroll> |
|
|
|
<!-- Project Results List --> |
|
|
|
<InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData"> |
|
|
|
<ul class="border-t border-slate-300"> |
|
|
|
<li |
|
|
|
class="border-b border-slate-300" |
|
|
@ -74,15 +212,18 @@ |
|
|
|
|
|
|
|
<script lang="ts"> |
|
|
|
import { Component, Vue } from "vue-facing-decorator"; |
|
|
|
|
|
|
|
import { accountsDB, db } from "@/db/index"; |
|
|
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
|
|
|
import { accessToken } from "@/libs/crypto"; |
|
|
|
import * as libsUtil from "@/libs/util"; |
|
|
|
import { IIdentifier } from "@veramo/core"; |
|
|
|
import InfiniteScroll from "@/components/InfiniteScroll.vue"; |
|
|
|
import QuickNav from "@/components/QuickNav.vue"; |
|
|
|
import ProjectIcon from "@/components/ProjectIcon.vue"; |
|
|
|
import TopMessage from "@/components/TopMessage.vue"; |
|
|
|
import { ProjectData } from "@/libs/endorserServer"; |
|
|
|
import { OfferServerRecord, PlanData } from "@/libs/endorserServer"; |
|
|
|
import EntityIcon from "@/components/EntityIcon.vue"; |
|
|
|
|
|
|
|
interface Notification { |
|
|
|
group: string; |
|
|
@ -92,16 +233,21 @@ interface Notification { |
|
|
|
} |
|
|
|
|
|
|
|
@Component({ |
|
|
|
components: { InfiniteScroll, QuickNav, ProjectIcon, TopMessage }, |
|
|
|
components: { EntityIcon, InfiniteScroll, QuickNav, ProjectIcon, TopMessage }, |
|
|
|
}) |
|
|
|
export default class ProjectsView extends Vue { |
|
|
|
$notify!: (notification: Notification, timeout?: number) => void; |
|
|
|
|
|
|
|
apiServer = ""; |
|
|
|
projects: ProjectData[] = []; |
|
|
|
current: IIdentifier; |
|
|
|
projects: PlanData[] = []; |
|
|
|
currentIid: IIdentifier; |
|
|
|
isLoading = false; |
|
|
|
numAccounts = 0; |
|
|
|
offers: OfferServerRecord[] = []; |
|
|
|
showOffers = true; |
|
|
|
showProjects = false; |
|
|
|
|
|
|
|
libsUtil = libsUtil; |
|
|
|
|
|
|
|
/** |
|
|
|
* 'created' hook runs when the Vue instance is first created |
|
|
@ -110,8 +256,8 @@ export default class ProjectsView extends Vue { |
|
|
|
try { |
|
|
|
await db.open(); |
|
|
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY); |
|
|
|
const activeDid = settings?.activeDid || ""; |
|
|
|
this.apiServer = settings?.apiServer || ""; |
|
|
|
const activeDid: string = (settings?.activeDid as string) || ""; |
|
|
|
this.apiServer = (settings?.apiServer as string) || ""; |
|
|
|
|
|
|
|
await accountsDB.open(); |
|
|
|
this.numAccounts = await accountsDB.accounts.count(); |
|
|
@ -127,12 +273,11 @@ export default class ProjectsView extends Vue { |
|
|
|
-1, |
|
|
|
); |
|
|
|
} else { |
|
|
|
const identity = await this.getIdentity(activeDid); |
|
|
|
this.current = identity; |
|
|
|
this.loadProjects(identity); |
|
|
|
this.currentIid = await this.getIdentity(activeDid); |
|
|
|
await this.loadOffers(); |
|
|
|
} |
|
|
|
} catch (err) { |
|
|
|
console.log("Error initializing:", err); |
|
|
|
console.error("Error initializing:", err); |
|
|
|
this.$notify( |
|
|
|
{ |
|
|
|
group: "alert", |
|
|
@ -150,7 +295,7 @@ export default class ProjectsView extends Vue { |
|
|
|
* @param url the url used to fetch the data |
|
|
|
* @param token Authorization token |
|
|
|
**/ |
|
|
|
async dataLoader(url: string, token: string) { |
|
|
|
async projectDataLoader(url: string, token: string) { |
|
|
|
const headers: { [key: string]: string } = { |
|
|
|
"Content-Type": "application/json", |
|
|
|
Authorization: `Bearer ${token}`, |
|
|
@ -160,13 +305,17 @@ export default class ProjectsView extends Vue { |
|
|
|
this.isLoading = true; |
|
|
|
const resp = await this.axios.get(url, { headers }); |
|
|
|
if (resp.status === 200 || !resp.data.data) { |
|
|
|
const plans: ProjectData[] = resp.data.data; |
|
|
|
const plans: PlanData[] = resp.data.data; |
|
|
|
for (const plan of plans) { |
|
|
|
const { name, description, handleId, issuerDid, rowid } = plan; |
|
|
|
this.projects.push({ name, description, handleId, issuerDid, rowid }); |
|
|
|
} |
|
|
|
} else { |
|
|
|
console.log("Bad server response & data:", resp.status, resp.data); |
|
|
|
console.error( |
|
|
|
"Bad server response & data for plans:", |
|
|
|
resp.status, |
|
|
|
resp.data, |
|
|
|
); |
|
|
|
this.$notify( |
|
|
|
{ |
|
|
|
group: "alert", |
|
|
@ -179,7 +328,7 @@ export default class ProjectsView extends Vue { |
|
|
|
} |
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
|
|
|
} catch (error: any) { |
|
|
|
console.error("Got error loading projects:", error.message || error); |
|
|
|
console.error("Got error loading plans:", error.message || error); |
|
|
|
this.$notify( |
|
|
|
{ |
|
|
|
group: "alert", |
|
|
@ -198,44 +347,35 @@ export default class ProjectsView extends Vue { |
|
|
|
* Data loader used by infinite scroller |
|
|
|
* @param payload is the flag from the InfiniteScroll indicating if it should load |
|
|
|
**/ |
|
|
|
async loadMoreData(payload: boolean) { |
|
|
|
async loadMoreProjectData(payload: boolean) { |
|
|
|
if (this.projects.length > 0 && payload) { |
|
|
|
const latestProject = this.projects[this.projects.length - 1]; |
|
|
|
const url = `${this.apiServer}/api/v2/report/plansByIssuer?beforeId=${latestProject.rowid}`; |
|
|
|
const token = await accessToken(this.current); |
|
|
|
await this.dataLoader(url, token); |
|
|
|
await this.loadProjects( |
|
|
|
this.currentIid, |
|
|
|
`beforeId=${latestProject.rowid}`, |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Handle clicking on a project entry found in the list |
|
|
|
* @param id of the project |
|
|
|
**/ |
|
|
|
onClickLoadProject(id: string) { |
|
|
|
localStorage.setItem("projectId", id); |
|
|
|
const route = { |
|
|
|
path: "/project/" + encodeURIComponent(id), |
|
|
|
}; |
|
|
|
this.$router.push(route); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Load projects initially |
|
|
|
* @param identity of the user |
|
|
|
* @param identifier of the user |
|
|
|
* @param urlExtra additional url parameters in a string |
|
|
|
**/ |
|
|
|
async loadProjects(identity: IIdentifier) { |
|
|
|
const url = `${this.apiServer}/api/v2/report/plansByIssuer`; |
|
|
|
async loadProjects(identifier?: IIdentifier, urlExtra: string = "") { |
|
|
|
const identity = identifier || this.currentIid; |
|
|
|
const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`; |
|
|
|
const token: string = await accessToken(identity); |
|
|
|
await this.dataLoader(url, token); |
|
|
|
await this.projectDataLoader(url, token); |
|
|
|
} |
|
|
|
|
|
|
|
public async getIdentity(activeDid: string) { |
|
|
|
public async getIdentity(activeDid: string): Promise<IIdentifier> { |
|
|
|
await accountsDB.open(); |
|
|
|
const account = await accountsDB.accounts |
|
|
|
.where("did") |
|
|
|
.equals(activeDid) |
|
|
|
.first(); |
|
|
|
const identity = JSON.parse(account?.identity || "null"); |
|
|
|
const identity = JSON.parse((account?.identity as string) || "null"); |
|
|
|
|
|
|
|
if (!identity) { |
|
|
|
throw new Error( |
|
|
@ -245,6 +385,18 @@ export default class ProjectsView extends Vue { |
|
|
|
return identity; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Handle clicking on a project entry found in the list |
|
|
|
* @param id of the project |
|
|
|
**/ |
|
|
|
onClickLoadProject(id: string) { |
|
|
|
localStorage.setItem("projectId", id); |
|
|
|
const route = { |
|
|
|
path: "/project/" + encodeURIComponent(id), |
|
|
|
}; |
|
|
|
this.$router.push(route); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Handling clicking on the new project button |
|
|
|
**/ |
|
|
@ -255,5 +407,120 @@ export default class ProjectsView extends Vue { |
|
|
|
}; |
|
|
|
this.$router.push(route); |
|
|
|
} |
|
|
|
|
|
|
|
onClickLoadClaim(jwtId: string) { |
|
|
|
const route = { |
|
|
|
path: "/claim/" + encodeURIComponent(jwtId), |
|
|
|
}; |
|
|
|
this.$router.push(route); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Core offer data loader |
|
|
|
* @param url the url used to fetch the data |
|
|
|
* @param token Authorization token |
|
|
|
**/ |
|
|
|
async offerDataLoader(url: string, token: string) { |
|
|
|
const headers: { [key: string]: string } = { |
|
|
|
"Content-Type": "application/json", |
|
|
|
Authorization: `Bearer ${token}`, |
|
|
|
}; |
|
|
|
|
|
|
|
try { |
|
|
|
this.isLoading = true; |
|
|
|
const resp = await this.axios.get(url, { headers }); |
|
|
|
if (resp.status === 200 || !resp.data.data) { |
|
|
|
this.offers = this.offers.concat(resp.data.data); |
|
|
|
} else { |
|
|
|
console.error( |
|
|
|
"Bad server response & data for offers:", |
|
|
|
resp.status, |
|
|
|
resp.data, |
|
|
|
); |
|
|
|
this.$notify( |
|
|
|
{ |
|
|
|
group: "alert", |
|
|
|
type: "danger", |
|
|
|
title: "Error", |
|
|
|
text: "Failed to get offers from the server. Try again later.", |
|
|
|
}, |
|
|
|
-1, |
|
|
|
); |
|
|
|
} |
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
|
|
|
} catch (error: any) { |
|
|
|
console.error("Got error loading offers:", error.message || error); |
|
|
|
this.$notify( |
|
|
|
{ |
|
|
|
group: "alert", |
|
|
|
type: "danger", |
|
|
|
title: "Error", |
|
|
|
text: "Got an error loading offers.", |
|
|
|
}, |
|
|
|
-1, |
|
|
|
); |
|
|
|
} finally { |
|
|
|
this.isLoading = false; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Data loader used by infinite scroller |
|
|
|
* @param payload is the flag from the InfiniteScroll indicating if it should load |
|
|
|
**/ |
|
|
|
async loadMoreOfferData(payload: boolean) { |
|
|
|
if (this.offers.length > 0 && payload) { |
|
|
|
const latestOffer = this.offers[this.offers.length - 1]; |
|
|
|
await this.loadOffers(this.currentIid, `&beforeId=${latestOffer.jwtId}`); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Load offers initially |
|
|
|
* @param identifier of the user |
|
|
|
* @param urlExtra additional url parameters in a string |
|
|
|
**/ |
|
|
|
async loadOffers(identifier?: IIdentifier, urlExtra: string = "") { |
|
|
|
const identity = identifier || this.currentIid; |
|
|
|
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${identity.did}${urlExtra}`; |
|
|
|
const token: string = await accessToken(identity); |
|
|
|
await this.offerDataLoader(url, token); |
|
|
|
} |
|
|
|
|
|
|
|
public computedOfferTabClassNames() { |
|
|
|
return { |
|
|
|
"inline-block": true, |
|
|
|
"py-3": true, |
|
|
|
"rounded-t-lg": true, |
|
|
|
"border-b-2": true, |
|
|
|
|
|
|
|
active: this.showOffers, |
|
|
|
"text-black": this.showOffers, |
|
|
|
"border-black": this.showOffers, |
|
|
|
"font-semibold": this.showOffers, |
|
|
|
|
|
|
|
"text-blue-600": !this.showOffers, |
|
|
|
"border-transparent": !this.showOffers, |
|
|
|
"hover:border-slate-400": !this.showOffers, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
public computedProjectTabClassNames() { |
|
|
|
return { |
|
|
|
"inline-block": true, |
|
|
|
"py-3": true, |
|
|
|
"rounded-t-lg": true, |
|
|
|
"border-b-2": true, |
|
|
|
|
|
|
|
active: this.showProjects, |
|
|
|
"text-black": this.showProjects, |
|
|
|
"border-black": this.showProjects, |
|
|
|
"font-semibold": this.showProjects, |
|
|
|
|
|
|
|
"text-blue-600": !this.showProjects, |
|
|
|
"border-transparent": !this.showProjects, |
|
|
|
"hover:border-slate-400": !this.showProjects, |
|
|
|
}; |
|
|
|
} |
|
|
|
} |
|
|
|
</script> |
|
|
|