You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
411 lines
13 KiB
411 lines
13 KiB
<template>
|
|
<QuickNav selected="Contacts" />
|
|
|
|
<section class="p-6 pb-24 max-w-3xl mx-auto">
|
|
<!-- Header -->
|
|
<div class="mb-8">
|
|
<router-link
|
|
:to="{ name: 'contacts' }"
|
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
|
>
|
|
<font-awesome icon="chevron-left" class="fa-fw" />
|
|
</router-link>
|
|
|
|
<h1 class="text-4xl text-center font-light pt-4">
|
|
Transferred with {{ contact?.name }}
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Info Messages -->
|
|
<div class="text-center text-sm text-slate-600 mb-6 space-y-1">
|
|
<p>(Only 50 most recent)</p>
|
|
<p>
|
|
(This does not include claims by them if they're not visible to you.)
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Transfer History Table -->
|
|
<table
|
|
class="table-auto w-full border-t border-slate-300 text-sm sm:text-base text-center"
|
|
>
|
|
<thead class="bg-slate-100">
|
|
<tr class="border-b border-slate-300">
|
|
<th class="px-1 py-2">Date</th>
|
|
<th class="px-1 py-2">From Them</th>
|
|
<th></th>
|
|
<th class="px-1 py-2">To Them</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="record in giveRecords"
|
|
:key="record.jwtId"
|
|
class="border-b border-slate-300"
|
|
>
|
|
<!-- Date -->
|
|
<td class="p-1 text-xs sm:text-sm text-left text-slate-500">
|
|
{{ new Date(record.issuedAt).toLocaleString() }}
|
|
</td>
|
|
|
|
<!-- From Them -->
|
|
<td class="p-1">
|
|
<div v-if="record.agentDid === contact?.did">
|
|
<div class="font-bold">
|
|
{{ displayAmount(record.unit, record.amount) }}
|
|
<font-awesome
|
|
v-if="record.amountConfirmed"
|
|
icon="circle-check"
|
|
class="text-green-600 fa-fw"
|
|
title="Confirmed"
|
|
/>
|
|
<button v-else title="Unconfirmed" @click="confirm(record)">
|
|
<font-awesome icon="circle" class="text-blue-600 fa-fw" />
|
|
</button>
|
|
</div>
|
|
<div class="italic text-xs sm:text-sm text-slate-500">
|
|
{{ record.description }}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Direction Arrow -->
|
|
<td class="p-1">
|
|
<font-awesome
|
|
:icon="
|
|
record.agentDid === contact?.did ? 'arrow-left' : 'arrow-right'
|
|
"
|
|
class="text-slate-400 fa-fw"
|
|
/>
|
|
</td>
|
|
|
|
<!-- To Them -->
|
|
<td class="p-1">
|
|
<div v-if="record.agentDid !== contact?.did">
|
|
<div class="font-bold">
|
|
{{ displayAmount(record.unit, record.amount) }}
|
|
<font-awesome
|
|
v-if="record.amountConfirmed"
|
|
icon="circle-check"
|
|
class="text-green-600 fa-fw"
|
|
title="Confirmed"
|
|
/>
|
|
<button
|
|
v-else
|
|
title="Unconfirmed"
|
|
@click="cannotConfirmMessage()"
|
|
>
|
|
<font-awesome icon="circle" class="text-slate-600 fa-fw" />
|
|
</button>
|
|
</div>
|
|
<div class="italic text-xs sm:text-sm text-slate-500">
|
|
{{ record.description }}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { AxiosError, AxiosRequestHeaders } from "axios";
|
|
import * as R from "ramda";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|
|
|
import QuickNav from "../components/QuickNav.vue";
|
|
import { NotificationIface } from "../constants/app";
|
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
|
import { createNotifyHelpers, TIMEOUTS } from "../utils/notify";
|
|
import {
|
|
NOTIFY_SETTINGS_RETRIEVAL_ERROR,
|
|
NOTIFY_SERVER_RETRIEVAL_ERROR,
|
|
NOTIFY_CONFIRMATION_RESTRICTION,
|
|
} from "../constants/notifications";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
|
import { GiveSummaryRecord, GiveActionClaim } from "../interfaces";
|
|
import { AgreeActionClaim } from "../interfaces/claims";
|
|
import {
|
|
createEndorserJwtVcFromClaim,
|
|
displayAmount,
|
|
getHeaders,
|
|
SCHEMA_ORG_CONTEXT,
|
|
} from "../libs/endorserServer";
|
|
import { retrieveAccountCount } from "../libs/util";
|
|
|
|
/**
|
|
* Contact Amounts View Component
|
|
* @author Matthew Raymer
|
|
*
|
|
* This component displays the transfer history between the current user and a specific contact.
|
|
* It shows both incoming and outgoing transfers with confirmation status and allows users
|
|
* to confirm unconfirmed transfers.
|
|
*
|
|
* Features:
|
|
* - Displays transfer history in a table format
|
|
* - Shows confirmation status for each transfer
|
|
* - Allows confirmation of unconfirmed transfers
|
|
* - Handles server communication for transfer data
|
|
* - Provides error handling and user feedback
|
|
*
|
|
* Workflow:
|
|
* 1. Component loads with contact DID from route query
|
|
* 2. Fetches contact information from database via PlatformServiceMixin
|
|
* 3. Loads transfer history from server API
|
|
* 4. Displays transfers in chronological order
|
|
* 5. Allows user to confirm unconfirmed transfers
|
|
*
|
|
* Transfer Types:
|
|
* - From Them: Transfers received from the contact
|
|
* - To Them: Transfers sent to the contact
|
|
*
|
|
* Confirmation Status:
|
|
* - Confirmed: Transfer has been acknowledged by recipient
|
|
* - Unconfirmed: Transfer pending confirmation
|
|
* - Cannot Confirm: User is not the recipient of the transfer
|
|
*/
|
|
@Component({
|
|
components: { QuickNav },
|
|
mixins: [PlatformServiceMixin],
|
|
})
|
|
export default class ContactAmountssView extends Vue {
|
|
/** Notification function injected by Vue */
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
/** Current route instance */
|
|
$route!: RouteLocationNormalizedLoaded;
|
|
/** Router instance for navigation */
|
|
$router!: Router;
|
|
|
|
/** Notification helpers */
|
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
|
|
|
/** Active user DID */
|
|
activeDid = "";
|
|
/** API server URL */
|
|
apiServer = "";
|
|
/** Current contact data */
|
|
contact: Contact | null = null;
|
|
/** Array of transfer records */
|
|
giveRecords: Array<GiveSummaryRecord> = [];
|
|
/** Number of user accounts */
|
|
numAccounts = 0;
|
|
|
|
/** Display amount utility function */
|
|
displayAmount = displayAmount;
|
|
|
|
/**
|
|
* Component lifecycle hook that initializes account count
|
|
*/
|
|
async beforeCreate() {
|
|
this.numAccounts = await retrieveAccountCount();
|
|
}
|
|
|
|
/**
|
|
* Component lifecycle hook that initializes the contact amounts view
|
|
*
|
|
* Workflow:
|
|
* 1. Extracts contact DID from route query parameters
|
|
* 2. Queries database for contact information via PlatformServiceMixin
|
|
* 3. Retrieves user settings for active DID and API server
|
|
* 4. Loads transfer history if both active DID and contact are available
|
|
* 5. Handles errors with appropriate user notifications
|
|
*
|
|
* @throws Will not throw but notifies on errors
|
|
* @emits Notification on database or settings errors
|
|
*/
|
|
async created() {
|
|
this.notify = createNotifyHelpers(this.$notify);
|
|
|
|
try {
|
|
const contactDid = this.$route.query["contactDid"] as string;
|
|
const contact = await this.$getContact(contactDid);
|
|
this.contact = contact;
|
|
|
|
const settings = await this.$getSettings(MASTER_SETTINGS_KEY);
|
|
this.activeDid = settings?.activeDid || "";
|
|
this.apiServer = settings?.apiServer || "";
|
|
|
|
if (this.activeDid && this.contact) {
|
|
this.loadGives(this.activeDid, this.contact);
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
await this.$logError("Error retrieving settings or gives.");
|
|
this.notify.error(
|
|
err.userMessage || NOTIFY_SETTINGS_RETRIEVAL_ERROR.message,
|
|
TIMEOUTS.LONG,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads transfer history from the server API
|
|
*
|
|
* Fetches both incoming and outgoing transfers between the active user and contact,
|
|
* then sorts them by date and updates the component state.
|
|
*
|
|
* @param activeDid The active user's DID
|
|
* @param contact The contact to load transfers with
|
|
* @throws Will not throw but notifies on server errors
|
|
* @emits Notification on server communication errors
|
|
*/
|
|
async loadGives(activeDid: string, contact: Contact) {
|
|
try {
|
|
let result: Array<GiveSummaryRecord> = [];
|
|
const url =
|
|
this.apiServer +
|
|
"/api/v2/report/gives?agentDid=" +
|
|
encodeURIComponent(this.activeDid) +
|
|
"&recipientDid=" +
|
|
encodeURIComponent(contact.did);
|
|
const headers = await getHeaders(activeDid);
|
|
const resp = await this.axios.get(url, { headers });
|
|
if (resp.status === 200) {
|
|
result = resp.data.data;
|
|
} else {
|
|
await this.$logError(
|
|
`Got bad response status & data of ${resp.status} ${JSON.stringify(resp.data)}`,
|
|
);
|
|
this.notify.error(NOTIFY_SERVER_RETRIEVAL_ERROR.message, TIMEOUTS.LONG);
|
|
}
|
|
|
|
const url2 =
|
|
this.apiServer +
|
|
"/api/v2/report/gives?agentDid=" +
|
|
encodeURIComponent(contact.did) +
|
|
"&recipientDid=" +
|
|
encodeURIComponent(this.activeDid);
|
|
const headers2 = await getHeaders(activeDid);
|
|
const resp2 = await this.axios.get(url2, { headers: headers2 });
|
|
if (resp2.status === 200) {
|
|
result = R.concat(result, resp2.data.data);
|
|
} else {
|
|
await this.$logError(
|
|
`Got bad response status & data of ${resp2.status} ${JSON.stringify(resp2.data)}`,
|
|
);
|
|
this.notify.error(NOTIFY_SERVER_RETRIEVAL_ERROR.message, TIMEOUTS.LONG);
|
|
}
|
|
|
|
const sortedResult: Array<GiveSummaryRecord> = R.sort(
|
|
(a, b) =>
|
|
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
|
|
result,
|
|
);
|
|
this.giveRecords = sortedResult;
|
|
} catch (error) {
|
|
this.notify.error(error as string, TIMEOUTS.LONG);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Confirms an unconfirmed transfer by creating and submitting a confirmation claim
|
|
*
|
|
* Creates an AgreeAction claim for the transfer and submits it to the server
|
|
* to confirm receipt of the transfer.
|
|
*
|
|
* @param record The transfer record to confirm
|
|
* @throws Will not throw but notifies on server errors
|
|
* @emits Notification on confirmation success or failure
|
|
*/
|
|
async confirm(record: GiveSummaryRecord) {
|
|
// Make claim
|
|
// I use clone here because otherwise it gets a Proxy object.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const origClaim: GiveActionClaim = R.clone(record.fullClaim);
|
|
if (record.fullClaim["@context"] == SCHEMA_ORG_CONTEXT) {
|
|
delete origClaim["@context"];
|
|
}
|
|
origClaim["identifier"] = record.handleId;
|
|
const vcClaim: AgreeActionClaim = {
|
|
"@context": SCHEMA_ORG_CONTEXT,
|
|
"@type": "AgreeAction",
|
|
object: origClaim,
|
|
};
|
|
|
|
const vcJwt: string = await createEndorserJwtVcFromClaim(
|
|
this.activeDid,
|
|
vcClaim,
|
|
);
|
|
|
|
// Make the xhr request payload
|
|
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
|
const url = this.apiServer + "/api/v2/claim";
|
|
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
|
|
|
|
try {
|
|
const resp = await this.axios.post(url, payload, { headers });
|
|
if (resp.data?.success) {
|
|
record.amountConfirmed =
|
|
(origClaim.object?.amountOfThisGood as number) || 1;
|
|
}
|
|
} catch (error) {
|
|
let userMessage = "There was an error.";
|
|
const serverError = error as AxiosError;
|
|
if (serverError) {
|
|
if (serverError.message) {
|
|
userMessage = serverError.message; // Info for the user
|
|
} else {
|
|
userMessage = JSON.stringify(serverError.toJSON());
|
|
}
|
|
} else {
|
|
userMessage = error as string;
|
|
}
|
|
// Now set that error for the user to see.
|
|
this.notify.error(userMessage, TIMEOUTS.LONG);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows notification that user cannot confirm a transfer they didn't receive
|
|
*
|
|
* Only the recipient of a transfer can confirm its receipt.
|
|
* This method notifies users when they try to confirm a transfer
|
|
* that was sent to someone else.
|
|
*
|
|
* @emits Notification explaining confirmation restrictions
|
|
*/
|
|
cannotConfirmMessage() {
|
|
this.notify.error(
|
|
NOTIFY_CONFIRMATION_RESTRICTION.message,
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
/*
|
|
Tooltip, generated on "title" attributes on "fa" icons
|
|
Kudos to https://www.w3schools.com/css/css_tooltip.asp
|
|
*/
|
|
/* Tooltip container */
|
|
.tooltip {
|
|
position: relative;
|
|
display: inline-block;
|
|
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
|
|
}
|
|
|
|
/* Tooltip text */
|
|
.tooltip .tooltiptext {
|
|
visibility: hidden;
|
|
width: 200px;
|
|
background-color: black;
|
|
color: #fff;
|
|
text-align: center;
|
|
padding: 5px 0;
|
|
border-radius: 6px;
|
|
|
|
position: absolute;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Show the tooltip text when you mouse over the tooltip container */
|
|
.tooltip:hover .tooltiptext {
|
|
visibility: visible;
|
|
}
|
|
.tooltip:hover .tooltiptext-left {
|
|
visibility: visible;
|
|
}
|
|
</style>
|
|
|