forked from trent_larson/crowd-funder-for-time-pwa
• Database Migration: Replace databaseUtil with PlatformServiceMixin methods • SQL Abstraction: Replace raw SQL with $getAllContacts() and $accountSettings() • Template Streamlining: Add 5 computed properties for consistent styling • Vue Syntax Fix: Correct vue-facing-decorator mixin and computed property syntax Migration Details: - Removed: databaseUtil imports and PlatformServiceFactory usage - Added: PlatformServiceMixin with $accountSettings(), $getAllContacts(), $updateSettings() - Created: 5 computed properties (primaryButtonClasses, secondaryButtonClasses, etc.) - Fixed: Proper @Component mixin declaration and class getter syntax - Quality: Zero linting errors, full TypeScript compliance Component provides 3-page onboarding flow (Home, Discover, Create) with dynamic content based on user registration and contact status. Ready for human testing across all platforms.
412 lines
13 KiB
Vue
412 lines
13 KiB
Vue
<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>
|