forked from trent_larson/crowd-funder-for-time-pwa
- Add confirm button functionality to ActivityListItem - Implement confirmation logic in HomeView - Add proper button state handling and validation Technical Changes: - Add canConfirm computed property to validate confirmation ability - Add handleConfirmClick method with proper error handling - Pass required props (isRegistered, activeDid, confirmerIdList) - Add confirmation dialog with user verification - Implement claim submission with proper cleanup - Add visual feedback for button states - Update feed after successful confirmation UI/UX Improvements: - Add disabled state styling for confirm button - Show proper error messages for invalid confirmation attempts - Add loading and success notifications - Improve button accessibility with proper states Bug Fixes: - Make apiServer optional in settings type - Fix settings update during registration - Add proper type checking for claim confirmation This adds the ability to confirm claims directly from the activity feed with proper validation, error handling, and user feedback. The confirmation flow matches the existing claim view confirmation functionality.
327 lines
10 KiB
Vue
327 lines
10 KiB
Vue
<template>
|
|
<li>
|
|
<!-- Last viewed separator -->
|
|
<div
|
|
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-6 font-bold text-sm"
|
|
v-if="record.jwtId == lastViewedClaimId"
|
|
>
|
|
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
|
You've already seen all the following
|
|
</span>
|
|
</div>
|
|
|
|
<div class="bg-slate-100 rounded-t-md border border-slate-300 p-3 sm:p-4">
|
|
<div class="flex items-center gap-2 mb-6">
|
|
<a href=""
|
|
><img
|
|
src="https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg"
|
|
class="size-8 object-cover rounded-full"
|
|
/></a>
|
|
|
|
<div>
|
|
<h3 class="font-semibold">
|
|
<a href="" class="hover:underline">
|
|
{{ record.giver.known ? record.giver.displayName : 'Anonymous Giver' }}
|
|
</a>
|
|
</h3>
|
|
<p class="ms-auto text-xs text-slate-500 italic">
|
|
{{ friendlyDate }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Record Image -->
|
|
<div
|
|
v-if="record.image"
|
|
class="bg-cover mb-6 -mx-3 sm:-mx-4"
|
|
:style="`background-image: url(${record.image});`"
|
|
>
|
|
<a
|
|
class="block bg-slate-100/50 backdrop-blur-md px-6 py-4 cursor-pointer"
|
|
@click="$emit('viewImage', record.image)"
|
|
>
|
|
<img
|
|
class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md"
|
|
:src="record.image"
|
|
@load="$emit('cacheImage', $event, record.image)"
|
|
/>
|
|
</a>
|
|
</div>
|
|
|
|
<div class="relative flex justify-between gap-4 mb-5">
|
|
<!-- Source -->
|
|
<a
|
|
href=""
|
|
class="w-28 sm:w-48 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
|
>
|
|
<div class="relative w-fit mx-auto">
|
|
<template v-if="record.giver.profileImageUrl">
|
|
<EntityIcon
|
|
:profile-image-url="record.giver.profileImageUrl"
|
|
:class="[
|
|
record.giver.known
|
|
? 'rounded-full size-12 sm:size-24 object-cover'
|
|
: 'rounded size-12 sm:size-24 object-cover'
|
|
]"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<!-- Identicon for DIDs -->
|
|
<img
|
|
v-if="record.giver.did"
|
|
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`"
|
|
:class="[
|
|
record.giver.known
|
|
? 'rounded-full size-12 sm:size-24'
|
|
: 'rounded size-12 sm:size-24'
|
|
]"
|
|
alt="Identicon"
|
|
/>
|
|
<!-- Fallback icon for projects -->
|
|
<fa
|
|
v-else
|
|
icon="person-circle-question"
|
|
class="text-slate-300 text-5xl sm:text-8xl"
|
|
/>
|
|
</template>
|
|
</div>
|
|
<div class="text-xs mt-2 line-clamp-2">
|
|
<fa
|
|
:icon="record.giver.known ? 'user' : 'hammer'"
|
|
class="fa-fw text-slate-400"
|
|
/>
|
|
{{ record.giver.displayName }}
|
|
</div>
|
|
</a>
|
|
|
|
<!-- Arrow -->
|
|
<div
|
|
class="absolute inset-28 sm:inset-x-48 mx-4 sm:mx-8 top-1/2 flex items-center"
|
|
>
|
|
<hr class="grow border-t-[25px] border-slate-300" />
|
|
<div
|
|
class="shrink-0 w-0 h-0 border border-slate-300 border-t-[30px] border-t-transparent border-b-[30px] border-b-transparent border-s-[40px] border-e-0"
|
|
></div>
|
|
</div>
|
|
|
|
<!-- Destination -->
|
|
<a
|
|
href=""
|
|
class="w-28 sm:w-48 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
|
>
|
|
<div class="relative w-fit mx-auto">
|
|
<template v-if="record.receiver.profileImageUrl">
|
|
<EntityIcon
|
|
:profile-image-url="record.receiver.profileImageUrl"
|
|
:class="[
|
|
record.receiver.known
|
|
? 'rounded-full size-12 sm:size-24 object-cover'
|
|
: 'rounded size-12 sm:size-24 object-cover'
|
|
]"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<!-- Identicon for DIDs -->
|
|
<img
|
|
v-if="record.receiver.did"
|
|
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`"
|
|
:class="[
|
|
record.receiver.known
|
|
? 'rounded-full size-12 sm:size-24'
|
|
: 'rounded size-12 sm:size-24'
|
|
]"
|
|
alt="Identicon"
|
|
/>
|
|
<!-- Fallback icon for projects -->
|
|
<fa
|
|
v-else
|
|
icon="image"
|
|
class="text-slate-300 text-5xl sm:text-8xl"
|
|
/>
|
|
</template>
|
|
</div>
|
|
<div class="text-xs mt-2 line-clamp-2">
|
|
<fa
|
|
:icon="record.receiver.known ? 'user' : 'hammer'"
|
|
class="fa-fw text-slate-400"
|
|
/>
|
|
{{ record.receiver.displayName }}
|
|
</div>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<p class="font-medium">
|
|
<a @click="$emit('loadClaim', record.jwtId)" class="cursor-pointer">
|
|
{{ description }}
|
|
</a>
|
|
</p>
|
|
<p class="text-sm">{{ subDescription }}</p>
|
|
</div>
|
|
|
|
<div
|
|
class="flex items-center gap-2 text-lg bg-slate-300 rounded-b-md px-3 sm:px-4 py-1 sm:py-2"
|
|
>
|
|
<a @click="$emit('loadClaim', record.jwtId)" class="cursor-pointer">
|
|
<fa icon="circle-info" class="fa-fw text-slate-500" />
|
|
</a>
|
|
<button
|
|
@click="handleConfirmClick"
|
|
:disabled="!canConfirm"
|
|
class="text-sm text-white px-3 py-1.5 ms-auto rounded-md"
|
|
:class="[
|
|
canConfirm
|
|
? 'bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)]'
|
|
: 'bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] opacity-50'
|
|
]"
|
|
>
|
|
Confirm
|
|
</button>
|
|
</div>
|
|
</li>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { Component, Prop, Vue } from "vue-facing-decorator";
|
|
import { GiveRecordWithContactInfo } from "../types";
|
|
import EntityIcon from "./EntityIcon.vue";
|
|
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
|
|
import { containsHiddenDid } from "../libs/endorserServer";
|
|
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
},
|
|
})
|
|
export default class ActivityListItem extends Vue {
|
|
@Prop() record!: GiveRecordWithContactInfo;
|
|
@Prop() lastViewedClaimId?: string;
|
|
@Prop() isRegistered!: boolean;
|
|
@Prop() activeDid!: string;
|
|
@Prop() confirmerIdList?: string[];
|
|
|
|
private formatAmount(claim: unknown): string {
|
|
const amount = claim.object?.amountOfThisGood
|
|
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
|
: "";
|
|
|
|
if (!claim.description && !amount) {
|
|
return "something not described";
|
|
}
|
|
|
|
if (!amount) return claim.description;
|
|
if (!claim.description) return amount;
|
|
|
|
return `${claim.description} (and ${amount})`;
|
|
}
|
|
|
|
private formatParticipantInfo(): string {
|
|
const { giver, receiver } = this.record;
|
|
|
|
// Both participants are known contacts
|
|
if (giver.known && receiver.known) {
|
|
return `${giver.displayName} gave to ${receiver.displayName}`;
|
|
}
|
|
|
|
// Only giver is known
|
|
if (giver.known) {
|
|
const recipient = this.record.recipientProjectName
|
|
? `the project "${this.record.recipientProjectName}"`
|
|
: receiver.displayName;
|
|
return `${giver.displayName} gave to ${recipient}`;
|
|
}
|
|
|
|
// Only receiver is known
|
|
if (receiver.known) {
|
|
const provider = this.record.providerPlanName
|
|
? `the project "${this.record.providerPlanName}"`
|
|
: giver.displayName;
|
|
return `${receiver.displayName} received from ${provider}`;
|
|
}
|
|
|
|
// Neither is known
|
|
return this.formatUnknownParticipants();
|
|
}
|
|
|
|
private formatUnknownParticipants(): string {
|
|
const { giver, receiver, providerPlanName, recipientProjectName } =
|
|
this.record;
|
|
|
|
if (providerPlanName || recipientProjectName) {
|
|
const from = providerPlanName
|
|
? `the project "${providerPlanName}"`
|
|
: giver.displayName;
|
|
const to = recipientProjectName
|
|
? `the project "${recipientProjectName}"`
|
|
: receiver.displayName;
|
|
return `from ${from} to ${to}`;
|
|
}
|
|
|
|
return giver.displayName === receiver.displayName
|
|
? `between two who are ${giver.displayName}`
|
|
: `from ${giver.displayName} to ${receiver.displayName}`;
|
|
}
|
|
|
|
get description(): string {
|
|
const claim =
|
|
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
|
|
const amount = this.formatAmount(claim);
|
|
|
|
return `${amount}`;
|
|
}
|
|
|
|
get subDescription(): string {
|
|
const participants = this.formatParticipantInfo();
|
|
|
|
return `${participants}`;
|
|
}
|
|
|
|
private displayAmount(code: string, amt: number) {
|
|
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`;
|
|
}
|
|
|
|
private currencyShortWordForCode(unitCode: string, single: boolean) {
|
|
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
|
}
|
|
|
|
get formattedTimestamp() {
|
|
// Add your timestamp formatting logic here
|
|
return this.record.timestamp;
|
|
}
|
|
|
|
get canConfirm(): boolean {
|
|
if (!this.isRegistered) return false;
|
|
if (!isGiveClaimType(this.record.fullClaim?.["@type"])) return false;
|
|
if (this.confirmerIdList?.includes(this.activeDid)) return false;
|
|
if (this.record.issuerDid === this.activeDid) return false;
|
|
if (containsHiddenDid(this.record.fullClaim)) return false;
|
|
return true;
|
|
}
|
|
|
|
handleConfirmClick() {
|
|
if (!this.canConfirm) {
|
|
notifyWhyCannotConfirm(
|
|
this.$notify,
|
|
this.isRegistered,
|
|
this.record.fullClaim?.["@type"],
|
|
this.record,
|
|
this.activeDid,
|
|
this.confirmerIdList
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.$emit('confirmClaim', this.record);
|
|
}
|
|
|
|
get friendlyDate(): string {
|
|
const date = new Date(this.record.issuedAt);
|
|
return date.toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
}
|
|
}
|
|
</script>
|