- Add comprehensive route validation with zod schema - Create type-safe DeepLinkRoute enum for all valid routes - Add structured error handling for invalid routes - Redirect to error page with detailed feedback - Add better timeout handling in deeplink tests The changes improve robustness by: 1. Validating route paths before navigation 2. Providing detailed error messages for invalid links 3. Redirecting users to dedicated error pages 4. Adding parameter validation with specific feedback 5. Improving type safety across deeplink handling
1508 lines
46 KiB
Vue
1508 lines
46 KiB
Vue
<template>
|
|
<QuickNav />
|
|
<TopMessage />
|
|
|
|
<!-- CONTENT -->
|
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
|
<!-- Breadcrumb -->
|
|
<div id="ViewBreadcrumb">
|
|
<div>
|
|
<h1 class="text-center text-lg font-light relative px-7">
|
|
<!-- Back -->
|
|
<button
|
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
|
@click="$router.back()"
|
|
>
|
|
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
|
|
</button>
|
|
Project Idea
|
|
</h1>
|
|
<h2 class="text-center text-xl font-semibold">
|
|
{{ name }}
|
|
<button
|
|
v-if="activeDid === issuer || activeDid === agentDid"
|
|
title="Edit"
|
|
data-testId="editClaimButton"
|
|
@click="onEditClick()"
|
|
>
|
|
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
|
</button>
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Project Details -->
|
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
|
|
<div>
|
|
<div class="pb-4 flex gap-4">
|
|
<div class="pt-1">
|
|
<ProjectIcon
|
|
:entity-id="projectId"
|
|
:icon-size="64"
|
|
:image-url="imageUrl"
|
|
:link-to-full="true"
|
|
class="block border border-slate-300 rounded-md max-h-16 max-w-16"
|
|
/>
|
|
</div>
|
|
|
|
<div class="overflow-hidden">
|
|
<div class="text-sm mb-3">
|
|
<div class="truncate">
|
|
<font-awesome
|
|
icon="user"
|
|
class="fa-fw text-slate-400"
|
|
></font-awesome>
|
|
{{ issuerInfoObject?.displayName }}
|
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
|
|
<a
|
|
:href="`/did/${issuer}`"
|
|
target="_blank"
|
|
class="text-blue-500"
|
|
>
|
|
<font-awesome
|
|
icon="arrow-up-right-from-square"
|
|
class="fa-fw"
|
|
/>
|
|
</a>
|
|
</span>
|
|
<span v-else-if="serverUtil.isHiddenDid(issuer)">
|
|
<font-awesome
|
|
icon="info-circle"
|
|
class="fa-fw text-blue-500 cursor-pointer"
|
|
@click="openHiddenDidDialog()"
|
|
/>
|
|
</span>
|
|
</div>
|
|
<div v-if="startTime">
|
|
<font-awesome
|
|
icon="calendar"
|
|
class="fa-fw text-slate-400"
|
|
></font-awesome>
|
|
Starts {{ startTime }}
|
|
</div>
|
|
<div v-if="endTime">
|
|
<font-awesome
|
|
icon="calendar"
|
|
class="fa-fw text-slate-400"
|
|
></font-awesome>
|
|
Ends {{ endTime }}
|
|
</div>
|
|
<div v-if="latitude || longitude">
|
|
<font-awesome
|
|
icon="location-dot"
|
|
class="fa-fw text-slate-400"
|
|
></font-awesome>
|
|
<a
|
|
:href="getOpenStreetMapUrl()"
|
|
target="_blank"
|
|
class="underline text-blue-500"
|
|
>Map View
|
|
<font-awesome
|
|
icon="arrow-up-right-from-square"
|
|
class="fa-fw text-blue-500"
|
|
/>
|
|
</a>
|
|
</div>
|
|
<div v-if="url">
|
|
<font-awesome
|
|
icon="globe"
|
|
class="fa-fw text-slate-400"
|
|
></font-awesome>
|
|
<a
|
|
:href="addScheme(url)"
|
|
target="_blank"
|
|
class="underline text-blue-500"
|
|
>
|
|
{{ domainForWebsite(url) }}
|
|
<font-awesome
|
|
icon="arrow-up-right-from-square"
|
|
class="fa-fw"
|
|
/>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-sm text-slate-500">
|
|
<div v-if="!expanded">
|
|
{{ truncatedDesc }}
|
|
<a
|
|
v-if="description.length >= truncateLength"
|
|
class="uppercase text-xs font-semibold text-slate-700"
|
|
@click="expandText"
|
|
>... Read More</a
|
|
>
|
|
</div>
|
|
<div v-else>
|
|
{{ description }}
|
|
<a
|
|
class="uppercase text-xs font-semibold text-slate-700"
|
|
@click="collapseText"
|
|
>- Read Less</a
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<a class="cursor-pointer" @click="onClickLoadClaim(projectId)">
|
|
<font-awesome icon="file-lines" class="pl-2 pt-1 text-blue-500" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid items-start grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
|
<div>
|
|
<div
|
|
v-if="fulfillersToThis.length > 0"
|
|
class="bg-slate-100 px-4 py-3 rounded-md"
|
|
>
|
|
<h3 class="text-sm uppercase font-semibold mt-3">
|
|
Projects That Contribute To This
|
|
</h3>
|
|
<!--
|
|
centering because long, wrapped project names didn't left align with blank
|
|
or "text-left"
|
|
-->
|
|
<div class="text-center">
|
|
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
|
|
<button
|
|
class="text-blue-500"
|
|
@click="onClickLoadProject(plan.handleId)"
|
|
>
|
|
{{ plan.name }}
|
|
</button>
|
|
</div>
|
|
<div v-if="fulfillersToHitLimit" class="text-center">
|
|
<button @click="loadPlanFulfillersTo()">Load More</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
|
|
<h3 class="text-sm uppercase font-semibold mb-3">
|
|
Projects Getting Contributions From This
|
|
</h3>
|
|
<!--
|
|
centering because long, wrapped project names didn't left align with blank
|
|
or "text-left"
|
|
-->
|
|
<div class="text-center">
|
|
<button
|
|
class="text-blue-500"
|
|
@click="onClickLoadProject(fulfilledByThis.handleId)"
|
|
>
|
|
{{ fulfilledByThis.name }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="activeDid && isRegistered">
|
|
<div class="text-center">
|
|
<p class="mt-2 mt-4 text-center">Record a contribution from:</p>
|
|
</div>
|
|
<ul
|
|
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5 mt-2"
|
|
>
|
|
<li @click="openGiftDialogToProject({ name: 'you', did: activeDid })">
|
|
<font-awesome
|
|
icon="hand"
|
|
class="fa-fw text-blue-500 text-5xl cursor-pointer"
|
|
/>
|
|
<h3
|
|
class="mt-5 text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
|
>
|
|
You
|
|
</h3>
|
|
</li>
|
|
<li @click="openGiftDialogToProject()">
|
|
<img
|
|
src="../assets/blank-square.svg"
|
|
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
|
|
/>
|
|
<h3
|
|
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
|
>
|
|
Unnamed/Unknown
|
|
</h3>
|
|
</li>
|
|
<li
|
|
v-for="contact in allContacts.slice(0, 5)"
|
|
:key="contact.did"
|
|
@click="openGiftDialogToProject(contact)"
|
|
>
|
|
<EntityIcon
|
|
:contact="contact"
|
|
:icon-size="64"
|
|
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
|
|
/>
|
|
<h3
|
|
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
|
>
|
|
{{ contact.name || "(no name)" }}
|
|
</h3>
|
|
</li>
|
|
<li>
|
|
<span
|
|
v-if="allContacts.length >= 5"
|
|
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
|
|
@click="onClickAllContactsGifting()"
|
|
>
|
|
... or someone else...
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
<GiftedDialog ref="giveDialogToThis" :to-project-id="projectId" />
|
|
</div>
|
|
|
|
<!-- Offers & Gifts to & from this -->
|
|
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
|
<!-- First, offers on the left-->
|
|
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
|
<div v-if="activeDid && isRegistered">
|
|
<div class="text-center">
|
|
<button
|
|
data-testId="offerButton"
|
|
class="block w-full 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-1 rounded-md"
|
|
@click="openOfferDialog()"
|
|
>
|
|
Offer to this (maybe with conditions)...
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<OfferDialog
|
|
ref="customOfferDialog"
|
|
:project-id="projectId"
|
|
:project-name="name"
|
|
/>
|
|
|
|
<h3 class="text-lg font-bold mb-3 mt-4">Offered To This Idea</h3>
|
|
|
|
<div v-if="offersToThis.length === 0">
|
|
(None yet. Wanna
|
|
<span class="cursor-pointer text-blue-500" @click="openOfferDialog()"
|
|
>offer something... especially if others join you</span
|
|
>?)
|
|
</div>
|
|
|
|
<ul v-else class="text-sm border-t border-slate-300">
|
|
<li
|
|
v-for="offer in offersToThis"
|
|
:key="offer.jwtId"
|
|
class="py-1.5 border-b border-slate-300"
|
|
>
|
|
<div class="flex justify-between gap-4">
|
|
<span>
|
|
<font-awesome
|
|
icon="user"
|
|
class="fa-fw text-slate-400"
|
|
></font-awesome>
|
|
{{
|
|
serverUtil.didInfo(
|
|
offer.offeredByDid,
|
|
activeDid,
|
|
allMyDids,
|
|
allContacts,
|
|
)
|
|
}}
|
|
</span>
|
|
<span v-if="offer.amount" class="whitespace-nowrap">
|
|
<font-awesome
|
|
:icon="libsUtil.iconForUnitCode(offer.unit)"
|
|
class="fa-fw text-slate-400"
|
|
/>{{ offer.amount }}
|
|
</span>
|
|
</div>
|
|
<div v-if="offer.objectDescription" class="text-slate-500">
|
|
<font-awesome icon="comment" class="fa-fw text-slate-400" />
|
|
{{ offer.objectDescription }}
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<a
|
|
class="cursor-pointer"
|
|
@click="onClickLoadClaim(offer.jwtId as string)"
|
|
>
|
|
<font-awesome
|
|
icon="file-lines"
|
|
class="pl-2 pt-1 text-blue-500"
|
|
/>
|
|
</a>
|
|
<a
|
|
v-if="checkIsFulfillable(offer)"
|
|
@click="onClickFulfillGiveToOffer(offer)"
|
|
>
|
|
<font-awesome
|
|
icon="hand-holding-heart"
|
|
class="text-blue-500 cursor-pointer"
|
|
/>
|
|
</a>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<div v-if="offersHitLimit" class="text-center text-blue-500">
|
|
<button @click="loadOffers()">Load More</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Now, gives TO this project in the middle -->
|
|
<!-- (similar to "FROM" gift display below) -->
|
|
<div class="bg-slate-100 px-4 py-3 rounded-md" data-testId="gives-to">
|
|
<div v-if="activeDid && isRegistered">
|
|
<div class="text-center">
|
|
<button
|
|
class="block w-full 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-1rounded-md"
|
|
@click="openGiftDialogToProject()"
|
|
>
|
|
Given To This...
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="text-lg font-bold mt-4">Given To This Idea</h3>
|
|
|
|
<div v-if="givesToThis.length === 0" class="text-sm">
|
|
(None yet. If you've seen something, say something by clicking a
|
|
contact above.)
|
|
</div>
|
|
|
|
<div v-else class="mt-1 text-sm">
|
|
<!-- Totals section -->
|
|
<div class="mt-1 flex items-center min-h-[1.5rem]">
|
|
<div v-if="loadingTotals" class="flex-1">
|
|
<font-awesome
|
|
icon="spinner"
|
|
class="fa-spin-pulse text-blue-500"
|
|
/>
|
|
</div>
|
|
<div v-else-if="givesTotalsByUnit.length > 0" class="flex-1">
|
|
<span class="font-semibold mr-2 shrink-0">Totals</span>
|
|
<span class="whitespace-nowrap overflow-hidden text-ellipsis">
|
|
<a
|
|
class="cursor-pointer text-blue-500"
|
|
@click="totalsExpanded = !totalsExpanded"
|
|
>
|
|
<!-- just show the hours, or alternatively whatever is first -->
|
|
<span v-if="givenTotalHours() > 0">
|
|
{{ givenTotalHours() }} {{ libsUtil.UNIT_SHORT["HUR"] }}
|
|
</span>
|
|
<span v-else>
|
|
{{ givesTotalsByUnit[0].amount }}
|
|
{{ libsUtil.UNIT_SHORT[givesTotalsByUnit[0].unit] }}
|
|
</span>
|
|
<span v-if="givesTotalsByUnit.length > 1">...</span>
|
|
<span>
|
|
<font-awesome
|
|
:icon="totalsExpanded ? 'chevron-up' : 'chevron-right'"
|
|
class="fa-fw text-xs ml-1"
|
|
/>
|
|
</span>
|
|
</a>
|
|
<!-- show the full list when expanded -->
|
|
<div v-if="totalsExpanded">
|
|
<div
|
|
v-for="total in givesTotalsByUnit"
|
|
:key="total.unit"
|
|
class="ml-2"
|
|
>
|
|
<font-awesome
|
|
:icon="libsUtil.iconForUnitCode(total.unit)"
|
|
class="fa-fw text-slate-400 mr-1"
|
|
/>
|
|
{{ total.amount }} {{ libsUtil.UNIT_LONG[total.unit] }}
|
|
</div>
|
|
</div>
|
|
</span>
|
|
</div>
|
|
<div v-else>
|
|
<span class="font-semibold mr-2 shrink-0">
|
|
{{ givesToThis.length }}{{ givesHitLimit ? "+" : "" }} record{{
|
|
givesToThis.length === 1 ? "" : "s"
|
|
}}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- List of gives -->
|
|
<ul class="mt-2 text-sm border-t border-slate-300">
|
|
<li
|
|
v-for="give in givesToThis"
|
|
:key="give.id"
|
|
class="py-1.5 border-b border-slate-300"
|
|
>
|
|
<div class="flex justify-between gap-4">
|
|
<span>
|
|
<font-awesome icon="user" class="fa-fw text-slate-400" />
|
|
{{
|
|
serverUtil.didInfo(
|
|
give.agentDid,
|
|
activeDid,
|
|
allMyDids,
|
|
allContacts,
|
|
)
|
|
}}
|
|
</span>
|
|
<span v-if="give.amount" class="whitespace-nowrap">
|
|
<font-awesome
|
|
:icon="libsUtil.iconForUnitCode(give.unit)"
|
|
class="fa-fw text-slate-400"
|
|
/>{{ give.amount }}
|
|
</span>
|
|
</div>
|
|
<div class="text-slate-500">
|
|
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
|
|
{{ give.issuedAt?.substring(0, 10) }}
|
|
</div>
|
|
<div v-if="give.description" class="text-slate-500">
|
|
<font-awesome icon="comment" class="fa-fw text-slate-400" />
|
|
{{ give.description }}
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<a @click="onClickLoadClaim(give.jwtId)">
|
|
<font-awesome
|
|
icon="file-lines"
|
|
class="text-blue-500 cursor-pointer"
|
|
/>
|
|
</a>
|
|
|
|
<a
|
|
v-if="
|
|
checkIsConfirmable(give) &&
|
|
!recentlyCheckedAndUnconfirmableJwts.includes(give.jwtId)
|
|
"
|
|
@click="deepCheckConfirmable(give)"
|
|
>
|
|
<font-awesome
|
|
icon="circle-check"
|
|
class="text-blue-500 cursor-pointer"
|
|
/>
|
|
</a>
|
|
<a v-else-if="checkingConfirmationForJwtId === give.jwtId">
|
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
|
</a>
|
|
<a v-else @click="shallowNotifyWhyCannotConfirm(give)">
|
|
<font-awesome
|
|
icon="circle-check"
|
|
class="text-slate-500 cursor-pointer"
|
|
/>
|
|
</a>
|
|
</div>
|
|
<div v-if="give.fullClaim.image" class="flex justify-center">
|
|
<a :href="give.fullClaim.image" target="_blank">
|
|
<img
|
|
:src="give.fullClaim.image"
|
|
class="h-24 mt-2 rounded-xl"
|
|
/>
|
|
</a>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div v-if="givesHitLimit" class="text-center text-blue-500">
|
|
<button @click="loadGives()">Load More</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Finally, gives FROM this project on the right -->
|
|
<!-- (similar to "TO" gift display above) -->
|
|
<div class="bg-slate-100 px-4 py-3 rounded-md" data-testId="gives-from">
|
|
<div v-if="activeDid && isRegistered">
|
|
<div class="text-center">
|
|
<button
|
|
class="block w-full 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-1 rounded-md"
|
|
@click="openGiftDialogFromProject()"
|
|
>
|
|
Given By This...
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<GiftedDialog ref="giveDialogFromThis" :from-project-id="projectId" />
|
|
|
|
<h3 class="text-lg font-bold mb-3 mt-4">
|
|
Benefitted From This Project
|
|
</h3>
|
|
|
|
<div v-if="givesProvidedByThis.length === 0">(None yet.)</div>
|
|
|
|
<ul v-else class="text-sm border-t border-slate-300">
|
|
<li
|
|
v-for="give in givesProvidedByThis"
|
|
:key="give.jwtId"
|
|
class="py-1.5 border-b border-slate-300"
|
|
>
|
|
<div class="flex justify-between gap-4">
|
|
<span>
|
|
{{
|
|
serverUtil.didInfo(
|
|
give.recipientDid,
|
|
activeDid,
|
|
allMyDids,
|
|
allContacts,
|
|
)
|
|
}}
|
|
</span>
|
|
<span v-if="give.amount" class="whitespace-nowrap">
|
|
<font-awesome
|
|
:icon="libsUtil.iconForUnitCode(give.unit)"
|
|
class="fa-fw text-slate-400"
|
|
/>{{ give.amount }}
|
|
</span>
|
|
</div>
|
|
<div class="text-slate-500">
|
|
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
|
|
{{ give.issuedAt?.substring(0, 10) }}
|
|
</div>
|
|
<div v-if="give.description" class="text-slate-500">
|
|
<font-awesome icon="comment" class="fa-fw text-slate-400" />
|
|
{{ give.description }}
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<a @click="onClickLoadClaim(give.jwtId)">
|
|
<font-awesome
|
|
icon="file-lines"
|
|
class="text-blue-500 cursor-pointer"
|
|
/>
|
|
</a>
|
|
|
|
<a
|
|
v-if="
|
|
checkIsConfirmable(give) &&
|
|
!recentlyCheckedAndUnconfirmableJwts.includes(give.jwtId)
|
|
"
|
|
@click="deepCheckConfirmable(give)"
|
|
>
|
|
<font-awesome
|
|
icon="circle-check"
|
|
class="text-blue-500 cursor-pointer"
|
|
/>
|
|
</a>
|
|
<a v-else-if="checkingConfirmationForJwtId === give.jwtId">
|
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
|
</a>
|
|
<a v-else @click="shallowNotifyWhyCannotConfirm(give)">
|
|
<font-awesome
|
|
icon="circle-check"
|
|
class="text-slate-500 cursor-pointer"
|
|
/>
|
|
</a>
|
|
</div>
|
|
<div v-if="give.fullClaim.image" class="flex justify-center">
|
|
<a :href="give.fullClaim.image" target="_blank">
|
|
<img :src="give.fullClaim.image" class="h-24 mt-2 rounded-xl" />
|
|
</a>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<div v-if="givesProvidedByHitLimit" class="text-center">
|
|
<button @click="loadGivesProvidedBy()">Load More</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<HiddenDidDialog ref="hiddenDidDialog" />
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { AxiosError } from "axios";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { Router } from "vue-router";
|
|
import {
|
|
GenericVerifiableCredential,
|
|
GenericCredWrapper,
|
|
GiveSummaryRecord,
|
|
GiveVerifiableCredential,
|
|
OfferSummaryRecord,
|
|
OfferVerifiableCredential,
|
|
PlanSummaryRecord,
|
|
} from "../interfaces";
|
|
import GiftedDialog from "../components/GiftedDialog.vue";
|
|
import OfferDialog from "../components/OfferDialog.vue";
|
|
import TopMessage from "../components/TopMessage.vue";
|
|
import QuickNav from "../components/QuickNav.vue";
|
|
import EntityIcon from "../components/EntityIcon.vue";
|
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
|
import { NotificationIface } from "../constants/app";
|
|
import {
|
|
db,
|
|
logConsoleAndDb,
|
|
retrieveSettingsForActiveAccount,
|
|
} from "../db/index";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import * as libsUtil from "../libs/util";
|
|
import * as serverUtil from "../libs/endorserServer";
|
|
import { retrieveAccountDids } from "../libs/util";
|
|
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
|
|
import { logger } from "../utils/logger";
|
|
/**
|
|
* Project View Component
|
|
* @author Matthew Raymer
|
|
*
|
|
* This component displays and manages detailed project information. It handles:
|
|
* - Project loading and display from URL-encoded project handles
|
|
* - Project metadata (name, description, dates, location)
|
|
* - Issuer information and verification
|
|
* - Project contributions and fulfillments
|
|
* - Offers and gifts tracking
|
|
* - Contact interactions
|
|
*
|
|
* Data Flow:
|
|
* 1. Component loads with project ID from route
|
|
* 2. Fetches project data, contacts, and account settings
|
|
* 3. Loads related data (offers, gifts, fulfillments)
|
|
* 4. Updates UI with paginated results
|
|
*
|
|
* Security Features:
|
|
* - DID visibility controls
|
|
* - JWT validation for imports
|
|
* - Permission checks for actions
|
|
*
|
|
* State Management:
|
|
* - Maintains separate loading states for different data types
|
|
* - Handles pagination limits
|
|
* - Tracks confirmation states
|
|
*
|
|
* @see GiftedDialog for gift creation
|
|
* @see OfferDialog for offer creation
|
|
* @see HiddenDidDialog for DID privacy explanations
|
|
*/
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
GiftedDialog,
|
|
HiddenDidDialog,
|
|
OfferDialog,
|
|
ProjectIcon,
|
|
QuickNav,
|
|
TopMessage,
|
|
},
|
|
})
|
|
export default class ProjectViewView extends Vue {
|
|
/** Notification function injected by Vue */
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
/** Router instance for navigation */
|
|
$router!: Router;
|
|
|
|
// Account and Settings State
|
|
/** Currently active DID */
|
|
activeDid = "";
|
|
/** Project agent DID */
|
|
agentDid = "";
|
|
/** DIDs that can see the agent DID */
|
|
agentDidVisibleToDids: Array<string> = [];
|
|
/** All DIDs associated with current account */
|
|
allMyDids: Array<string> = [];
|
|
/** All known contacts */
|
|
allContacts: Array<Contact> = [];
|
|
/** API server endpoint */
|
|
apiServer = "";
|
|
/** Registration status of current user */
|
|
isRegistered = false;
|
|
|
|
// Project Data
|
|
/** Project description */
|
|
description = "";
|
|
/** Project end time */
|
|
endTime = "";
|
|
/** Text expansion state */
|
|
expanded = false;
|
|
/** Project fulfilled by this project */
|
|
fulfilledByThis: PlanSummaryRecord | null = null;
|
|
/** Projects fulfilling this project */
|
|
fulfillersToThis: Array<PlanSummaryRecord> = [];
|
|
/** Flag for fulfiller pagination */
|
|
fulfillersToHitLimit = false;
|
|
/** Gifts to this project */
|
|
givesToThis: Array<GiveSummaryRecord> = [];
|
|
givesHitLimit = false;
|
|
givesProvidedByThis: Array<GiveSummaryRecord> = [];
|
|
givesProvidedByHitLimit = false;
|
|
givesTotalsByUnit: Array<{ unit: string; amount: number }> = [];
|
|
imageUrl = "";
|
|
/** Project issuer DID */
|
|
issuer = "";
|
|
/** Cached issuer information */
|
|
issuerInfoObject: {
|
|
known: boolean;
|
|
displayName: string;
|
|
profileImageUrl?: string;
|
|
} | null = null;
|
|
/** DIDs that can see issuer information */
|
|
issuerVisibleToDids: Array<string> = [];
|
|
/** Project location data */
|
|
latitude = 0;
|
|
loadingTotals = false;
|
|
longitude = 0;
|
|
/** Project name */
|
|
name = "";
|
|
/** Project ID (handle) */
|
|
projectId = "";
|
|
/** Project start time */
|
|
startTime = "";
|
|
/** Project URL */
|
|
url = "";
|
|
|
|
// Interaction Data
|
|
/** Gifts to this project */
|
|
offersToThis: Array<OfferSummaryRecord> = [];
|
|
/** Flag for offers pagination */
|
|
offersHitLimit = false;
|
|
|
|
// UI State
|
|
/** JWT being checked for confirmation */
|
|
checkingConfirmationForJwtId = "";
|
|
/** Recently checked unconfirmable JWTs */
|
|
recentlyCheckedAndUnconfirmableJwts: string[] = [];
|
|
|
|
totalsExpanded = false;
|
|
truncatedDesc = "";
|
|
/** Truncation length */
|
|
truncateLength = 40;
|
|
|
|
// Utility References
|
|
libsUtil = libsUtil;
|
|
serverUtil = serverUtil;
|
|
|
|
/**
|
|
* Component lifecycle hook that initializes the project view
|
|
*
|
|
* Workflow:
|
|
* 1. Loads account settings and contacts
|
|
* 2. Retrieves all account DIDs
|
|
* 3. Extracts project ID from URL
|
|
* 4. Initializes project data loading
|
|
*
|
|
* @throws Logs errors but continues loading
|
|
* @emits Notification on profile loading errors
|
|
*/
|
|
async created() {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.activeDid = settings.activeDid || "";
|
|
this.apiServer = settings.apiServer || "";
|
|
this.allContacts = await db.contacts.toArray();
|
|
this.isRegistered = !!settings.isRegistered;
|
|
|
|
try {
|
|
this.allMyDids = await retrieveAccountDids();
|
|
} catch (error) {
|
|
// continue because we want to see claims, even anonymously
|
|
logConsoleAndDb(
|
|
"Error retrieving all account DIDs on home page:" + error,
|
|
true,
|
|
);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error Loading Profile",
|
|
text: "See the Help page to fix problems with your personal data.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
|
|
const pathParam = window.location.pathname.substring("/project/".length);
|
|
if (pathParam) {
|
|
this.projectId = decodeURIComponent(pathParam);
|
|
}
|
|
this.loadProject(this.projectId, this.activeDid);
|
|
this.loadTotals();
|
|
}
|
|
|
|
/**
|
|
* Navigates to project edit view with current project ID
|
|
*/
|
|
onEditClick(): void {
|
|
this.$router.push({
|
|
name: "new-edit-project",
|
|
query: { projectId: this.projectId },
|
|
});
|
|
}
|
|
|
|
// Isn't there a better way to make this available to the template?
|
|
expandText() {
|
|
this.expanded = true;
|
|
}
|
|
|
|
collapseText() {
|
|
this.expanded = false;
|
|
}
|
|
|
|
async loadProject(projectId: string, userDid: string) {
|
|
this.projectId = projectId;
|
|
|
|
const url =
|
|
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
|
|
const headers = await serverUtil.getHeaders(userDid);
|
|
|
|
try {
|
|
const resp = await this.axios.get(url, { headers });
|
|
if (resp.status === 200) {
|
|
const startTime = resp.data.claim?.startTime;
|
|
if (startTime != null) {
|
|
const startDateTime = new Date(startTime);
|
|
this.startTime =
|
|
startDateTime.toLocaleDateString() +
|
|
" " +
|
|
startDateTime.toLocaleTimeString();
|
|
}
|
|
const endTime = resp.data.claim?.endTime;
|
|
if (endTime != null) {
|
|
const endDateTime = new Date(endTime);
|
|
this.endTime =
|
|
endDateTime.toLocaleDateString() +
|
|
" " +
|
|
endDateTime.toLocaleTimeString();
|
|
}
|
|
this.agentDid = resp.data.claim?.agent?.identifier;
|
|
this.agentDidVisibleToDids =
|
|
resp.data.claim?.agent?.identifierVisibleToDids || [];
|
|
this.imageUrl = resp.data.claim?.image;
|
|
this.issuer = resp.data.issuer;
|
|
this.issuerInfoObject = serverUtil.didInfoObject(
|
|
this.issuer,
|
|
this.activeDid,
|
|
this.allMyDids,
|
|
this.allContacts,
|
|
);
|
|
this.issuerVisibleToDids = resp.data.issuerVisibleToDids || [];
|
|
this.name = resp.data.claim?.name || "(no name)";
|
|
this.description = resp.data.claim?.description || "(no description)";
|
|
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
|
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
|
|
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
|
|
this.url = resp.data.claim?.url || "";
|
|
} else {
|
|
// actually, axios throws an error on 404 so we probably never get here
|
|
logger.error("Error getting project:", resp);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "There was a problem getting that project.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
logger.error("Error retrieving project:", error);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving that project.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
|
|
this.givesToThis = [];
|
|
this.loadGives();
|
|
|
|
this.givesProvidedByThis = [];
|
|
this.loadGivesProvidedBy();
|
|
|
|
this.offersToThis = [];
|
|
this.loadOffers();
|
|
|
|
this.fulfillersToThis = [];
|
|
this.loadPlanFulfillersTo();
|
|
|
|
this.fulfilledByThis = null;
|
|
this.loadPlanFulfilledBy();
|
|
}
|
|
|
|
/**
|
|
* Loads gifts made to this project
|
|
*
|
|
* Handles pagination and updates component state with results.
|
|
* Uses beforeId for pagination based on last loaded gift.
|
|
*
|
|
* @throws Logs errors and notifies user
|
|
* @emits Notification on loading errors
|
|
*/
|
|
async loadGives() {
|
|
const givesUrl =
|
|
this.apiServer +
|
|
"/api/v2/report/givesToPlans?planIds=" +
|
|
encodeURIComponent(JSON.stringify([this.projectId]));
|
|
let postfix = "";
|
|
if (this.givesToThis.length > 0) {
|
|
postfix =
|
|
"&beforeId=" + this.givesToThis[this.givesToThis.length - 1].jwtId;
|
|
}
|
|
const givesInUrl = givesUrl + postfix;
|
|
|
|
const headers = await serverUtil.getHeaders(this.activeDid);
|
|
try {
|
|
const resp = await this.axios.get(givesInUrl, { headers });
|
|
if (resp.status === 200 && resp.data.data) {
|
|
this.givesToThis = this.givesToThis.concat(resp.data.data);
|
|
this.givesHitLimit = resp.data.hitLimit;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to retrieve more gives to this project.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const serverError = error as AxiosError;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving more gives to this project.",
|
|
},
|
|
5000,
|
|
);
|
|
logger.error(
|
|
"Something went wrong retrieving more gives to this project:",
|
|
serverError.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads gifts provided by this project
|
|
*
|
|
* Similar to loadGives but for outgoing gifts.
|
|
* Maintains separate pagination state.
|
|
*
|
|
* @throws Logs errors and notifies user
|
|
* @emits Notification on loading errors
|
|
*/
|
|
async loadGivesProvidedBy() {
|
|
const providedByUrl =
|
|
this.apiServer +
|
|
"/api/v2/report/givesProvidedBy?providerId=" +
|
|
encodeURIComponent(this.projectId);
|
|
let postfix = "";
|
|
if (this.givesProvidedByThis.length > 0) {
|
|
postfix =
|
|
"&beforeId=" +
|
|
this.givesProvidedByThis[this.givesProvidedByThis.length - 1].jwtId;
|
|
}
|
|
const providedByFullUrl = providedByUrl + postfix;
|
|
|
|
const headers = await serverUtil.getHeaders(this.activeDid);
|
|
try {
|
|
const resp = await this.axios.get(providedByFullUrl, { headers });
|
|
if (resp.status === 200) {
|
|
this.givesProvidedByThis = this.givesProvidedByThis.concat(
|
|
resp.data.data,
|
|
);
|
|
this.givesProvidedByHitLimit = resp.data.hitLimit;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to retrieve gives that were provided by this project.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const serverError = error as AxiosError;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving gives that were provided by this project.",
|
|
},
|
|
5000,
|
|
);
|
|
logger.error(
|
|
"Something went wrong retrieving gives that were provided by this project:",
|
|
serverError.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads offers made to this project
|
|
*
|
|
* Handles pagination and filtering of valid offers.
|
|
* Updates component state with results.
|
|
*
|
|
* @throws Logs errors and notifies user
|
|
* @emits Notification on loading errors
|
|
*/
|
|
async loadOffers() {
|
|
const offersUrl =
|
|
this.apiServer +
|
|
"/api/v2/report/offersToPlans?planIds=" +
|
|
encodeURIComponent(JSON.stringify([this.projectId]));
|
|
let postfix = "";
|
|
if (this.offersToThis.length > 0) {
|
|
postfix =
|
|
"&beforeId=" + this.offersToThis[this.offersToThis.length - 1].jwtId;
|
|
}
|
|
const offersInUrl = offersUrl + postfix;
|
|
|
|
const headers = await serverUtil.getHeaders(this.activeDid);
|
|
try {
|
|
const resp = await this.axios.get(offersInUrl, { headers });
|
|
if (resp.status === 200 && resp.data.data) {
|
|
this.offersToThis = this.offersToThis.concat(resp.data.data);
|
|
this.offersHitLimit = resp.data.hitLimit;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to retrieve more offers to this project.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const serverError = error as AxiosError;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving more offers to this project.",
|
|
},
|
|
5000,
|
|
);
|
|
logger.error(
|
|
"Something went wrong retrieving more offers to this project:",
|
|
serverError.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads projects that fulfill this project
|
|
*
|
|
* Manages pagination state and updates component with results.
|
|
*
|
|
* @throws Logs errors and notifies user
|
|
* @emits Notification on loading errors
|
|
*/
|
|
async loadPlanFulfillersTo() {
|
|
const fulfillsUrl =
|
|
this.apiServer +
|
|
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
|
|
encodeURIComponent(this.projectId);
|
|
let postfix = "";
|
|
if (this.fulfillersToThis.length > 0) {
|
|
postfix =
|
|
"&beforeId=" +
|
|
this.fulfillersToThis[this.fulfillersToThis.length - 1].jwtId;
|
|
}
|
|
const fulfillsInUrl = fulfillsUrl + postfix;
|
|
|
|
const headers = await serverUtil.getHeaders(this.activeDid);
|
|
try {
|
|
const resp = await this.axios.get(fulfillsInUrl, { headers });
|
|
if (resp.status === 200) {
|
|
this.fulfillersToThis = this.fulfillersToThis.concat(resp.data.data);
|
|
this.fulfillersToHitLimit = resp.data.hitLimit;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to retrieve more plans that fullfill this project.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const serverError = error as AxiosError;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving more plans that fulfull this project.",
|
|
},
|
|
5000,
|
|
);
|
|
logger.error(
|
|
"Something went wrong retrieving more plans that fulfill this project:",
|
|
serverError.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads project that this project fulfills
|
|
*
|
|
* Updates fulfilledByThis state with result.
|
|
*
|
|
* @throws Logs errors and notifies user
|
|
* @emits Notification on loading errors
|
|
*/
|
|
async loadPlanFulfilledBy() {
|
|
const fulfilledByUrl =
|
|
this.apiServer +
|
|
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
|
|
encodeURIComponent(this.projectId);
|
|
const headers = await serverUtil.getHeaders(this.activeDid);
|
|
try {
|
|
const resp = await this.axios.get(fulfilledByUrl, { headers });
|
|
if (resp.status === 200) {
|
|
this.fulfilledByThis = resp.data.data;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to retrieve plans fulfilled by this project.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const serverError = error as AxiosError;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving plans fulfilled by this project.",
|
|
},
|
|
5000,
|
|
);
|
|
logger.error(
|
|
"Error retrieving plans fulfilled by this project:",
|
|
serverError.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle clicking on a project entry found in the list
|
|
* @param id of the project
|
|
**/
|
|
async onClickLoadProject(projectId: string) {
|
|
const route = {
|
|
path: "/project/" + encodeURIComponent(projectId),
|
|
};
|
|
this.$router.push(route);
|
|
this.loadProject(projectId, this.activeDid);
|
|
}
|
|
|
|
getOpenStreetMapUrl() {
|
|
// Google URL is https://maps.google.com/?q=LAT,LONG
|
|
return (
|
|
"https://www.openstreetmap.org/?mlat=" +
|
|
this.latitude +
|
|
"&mlon=" +
|
|
this.longitude +
|
|
"#map=15/" +
|
|
this.latitude +
|
|
"/" +
|
|
this.longitude
|
|
);
|
|
}
|
|
|
|
openGiftDialogToProject(contact?: libsUtil.GiverReceiverInputInfo) {
|
|
(this.$refs.giveDialogToThis as GiftedDialog).open(
|
|
contact,
|
|
undefined,
|
|
undefined,
|
|
(contact?.name || "Someone not named") + ` gave to this project`,
|
|
);
|
|
}
|
|
|
|
openGiftDialogFromProject() {
|
|
(this.$refs.giveDialogFromThis as GiftedDialog).open(
|
|
undefined,
|
|
{ did: this.activeDid, name: "You" },
|
|
undefined,
|
|
`This project gave to you`,
|
|
);
|
|
}
|
|
|
|
openOfferDialog() {
|
|
(this.$refs.customOfferDialog as OfferDialog).open();
|
|
}
|
|
|
|
onClickAllContactsGifting() {
|
|
const route = {
|
|
name: "contact-gift",
|
|
query: {
|
|
projectId: this.projectId,
|
|
},
|
|
};
|
|
this.$router.push(route);
|
|
}
|
|
|
|
onClickLoadClaim(jwtId: string) {
|
|
const route = {
|
|
path: "/claim/" + encodeURIComponent(jwtId),
|
|
};
|
|
this.$router.push(route);
|
|
}
|
|
|
|
checkIsFulfillable(offer: OfferSummaryRecord) {
|
|
const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = {
|
|
...serverUtil.BLANK_GENERIC_SERVER_RECORD,
|
|
claim: offer.fullClaim,
|
|
claimType: "Offer",
|
|
issuer: offer.offeredByDid,
|
|
};
|
|
return libsUtil.canFulfillOffer(offerRecord);
|
|
}
|
|
|
|
onClickFulfillGiveToOffer(offer: OfferSummaryRecord) {
|
|
const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = {
|
|
...serverUtil.BLANK_GENERIC_SERVER_RECORD,
|
|
claim: offer.fullClaim,
|
|
issuer: offer.offeredByDid,
|
|
};
|
|
const giver: libsUtil.GiverReceiverInputInfo = {
|
|
did: libsUtil.offerGiverDid(offerRecord),
|
|
};
|
|
(this.$refs.giveDialogToThis as GiftedDialog).open(
|
|
giver,
|
|
undefined,
|
|
offer.handleId,
|
|
"Given by " + (giver?.name || "someone not named"),
|
|
);
|
|
}
|
|
|
|
// return an HTTPS URL if it's not a global URL
|
|
addScheme(url: string) {
|
|
if (!libsUtil.isGlobalUri(url)) {
|
|
return "https://" + url;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
// return just the domain for display, if possible
|
|
domainForWebsite(url: string) {
|
|
try {
|
|
const hostname = new URL(url).hostname;
|
|
if (!hostname) {
|
|
// happens for non-http URLs
|
|
return url;
|
|
} else if (url.endsWith(hostname)) {
|
|
// it's just the domain
|
|
return hostname;
|
|
} else {
|
|
// there's more, but don't bother displaying the whole thing
|
|
return hostname + "...";
|
|
}
|
|
} catch (error: unknown) {
|
|
// must not be a valid URL
|
|
return url;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param confirmerIdList optional list of DIDs who confirmed; if missing, doesn't do a full server check
|
|
*/
|
|
checkIsConfirmable(give: GiveSummaryRecord, confirmerIdList?: string[]) {
|
|
const giveDetails: GenericCredWrapper<GiveVerifiableCredential> = {
|
|
...serverUtil.BLANK_GENERIC_SERVER_RECORD,
|
|
claim: give.fullClaim,
|
|
claimType: "GiveAction",
|
|
issuer: give.issuerDid,
|
|
};
|
|
return libsUtil.isGiveRecordTheUserCanConfirm(
|
|
this.isRegistered,
|
|
giveDetails,
|
|
this.activeDid,
|
|
confirmerIdList,
|
|
);
|
|
}
|
|
|
|
shallowNotifyWhyCannotConfirm(give: GiveSummaryRecord) {
|
|
const confirmerIds = this.recentlyCheckedAndUnconfirmableJwts.includes(
|
|
give.jwtId,
|
|
)
|
|
? [this.activeDid]
|
|
: [];
|
|
libsUtil.notifyWhyCannotConfirm(
|
|
this.$notify,
|
|
this.isRegistered,
|
|
"GiveAction",
|
|
give,
|
|
this.activeDid,
|
|
confirmerIds,
|
|
);
|
|
}
|
|
|
|
async deepCheckConfirmable(give: GiveSummaryRecord) {
|
|
this.checkingConfirmationForJwtId = give.jwtId;
|
|
const confirmerInfo: libsUtil.ConfirmerData | undefined =
|
|
await libsUtil.retrieveConfirmerIdList(
|
|
this.apiServer,
|
|
give.jwtId,
|
|
give.issuerDid,
|
|
this.activeDid,
|
|
);
|
|
if (
|
|
this.checkIsConfirmable(give, confirmerInfo?.confirmerIdList as string[])
|
|
) {
|
|
this.confirmConfirmClaim(give);
|
|
} else {
|
|
this.recentlyCheckedAndUnconfirmableJwts = [
|
|
...this.recentlyCheckedAndUnconfirmableJwts,
|
|
give.jwtId,
|
|
];
|
|
libsUtil.notifyWhyCannotConfirm(
|
|
this.$notify,
|
|
this.isRegistered,
|
|
"GiveAction",
|
|
give,
|
|
this.activeDid,
|
|
confirmerInfo?.confirmerIdList as string[],
|
|
);
|
|
}
|
|
this.checkingConfirmationForJwtId = "";
|
|
}
|
|
|
|
confirmConfirmClaim(give: GiveSummaryRecord) {
|
|
this.$notify(
|
|
{
|
|
group: "modal",
|
|
type: "confirm",
|
|
title: "Confirm",
|
|
text: "Do you personally confirm that this is true?",
|
|
onYes: async () => {
|
|
await this.confirmClaim(give);
|
|
},
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
|
|
// similar code is found in ClaimView
|
|
async confirmClaim(give: GiveSummaryRecord) {
|
|
// similar logic is found in endorser-mobile
|
|
const goodClaim = serverUtil.removeSchemaContext(
|
|
serverUtil.removeVisibleToDids(
|
|
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
|
give.fullClaim,
|
|
give.jwtId,
|
|
give.handleId,
|
|
),
|
|
),
|
|
);
|
|
const confirmationClaim: GenericVerifiableCredential = {
|
|
"@context": "https://schema.org",
|
|
"@type": "AgreeAction",
|
|
object: goodClaim,
|
|
};
|
|
const result = await serverUtil.createAndSubmitClaim(
|
|
confirmationClaim,
|
|
this.activeDid,
|
|
this.apiServer,
|
|
this.axios,
|
|
);
|
|
if (result.type === "success") {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "success",
|
|
title: "Success",
|
|
text: "Confirmation submitted.",
|
|
},
|
|
5000,
|
|
);
|
|
this.recentlyCheckedAndUnconfirmableJwts = [
|
|
...this.recentlyCheckedAndUnconfirmableJwts,
|
|
give.jwtId,
|
|
];
|
|
} else {
|
|
logger.error("Got error submitting the confirmation:", result);
|
|
const message =
|
|
(result.error?.error as string) ||
|
|
"There was a problem submitting the confirmation.";
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: message,
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
|
|
openHiddenDidDialog() {
|
|
(this.$refs.hiddenDidDialog as HiddenDidDialog).open(
|
|
"creator",
|
|
this.issuerVisibleToDids,
|
|
this.allContacts,
|
|
this.activeDid,
|
|
this.allMyDids,
|
|
);
|
|
}
|
|
|
|
async loadTotals() {
|
|
this.loadingTotals = true;
|
|
const url =
|
|
this.apiServer +
|
|
"/api/v2/report/givesToPlans?planIds=" +
|
|
encodeURIComponent(JSON.stringify([this.projectId]));
|
|
const headers = await serverUtil.getHeaders(this.activeDid);
|
|
|
|
try {
|
|
const resp = await this.axios.get(url, { headers });
|
|
if (resp.status === 200 && resp.data.data) {
|
|
// Calculate totals by unit
|
|
const totals: { [key: string]: number } = {};
|
|
resp.data.data.forEach((give: GiveSummaryRecord) => {
|
|
const amount = give.fullClaim.object?.amountOfThisGood;
|
|
const unit = give.fullClaim.object?.unitCode;
|
|
if (amount && unit) {
|
|
totals[unit] = (totals[unit] || 0) + amount;
|
|
}
|
|
});
|
|
|
|
// Convert totals object to array format
|
|
this.givesTotalsByUnit = Object.entries(totals).map(
|
|
([unit, amount]) => ({
|
|
unit,
|
|
amount,
|
|
}),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error("Error loading totals:", error);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to load totals for this project.",
|
|
},
|
|
5000,
|
|
);
|
|
} finally {
|
|
this.loadingTotals = false;
|
|
}
|
|
}
|
|
|
|
givenTotalHours(): number {
|
|
return (
|
|
this.givesTotalsByUnit.find((total) => total.unit === "HUR")?.amount || 0
|
|
);
|
|
}
|
|
}
|
|
</script>
|