Browse Source
refactor: Extract ActivityListItem component and add claim confirmation - Move activity list item from HomeView to dedicated component - Add claim confirmation functionality with AgreeAction schema - Update feed data handling for confirmation status - Improve error handling with structured logging - Add user confirmation dialog for claim verification The changes improve code organization by: 1. Separating activity item UI into reusable component 2. Adding proper type definitions for activity records 3. Implementing structured claim confirmation flow 4. Adding user feedback for confirmation actions 5. Improving error handling with logger utility Technical details: - Added ActivityListItem.vue component - Added confirmClaim method with schema.org AgreeAction - Updated feed refresh after confirmation - Added proper TypeScript interfaces - Improved notification handlingapp_id_fix
4 changed files with 695 additions and 96 deletions
@ -0,0 +1,334 @@ |
|||
<template> |
|||
<li> |
|||
<!-- Last viewed separator --> |
|||
<div |
|||
v-if="record.jwtId == lastViewedClaimId" |
|||
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-6 font-bold text-sm" |
|||
> |
|||
<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"> |
|||
<img |
|||
src="https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg" |
|||
class="size-8 object-cover rounded-full" |
|||
/> |
|||
|
|||
<div> |
|||
<h3 class="font-semibold"> |
|||
{{ |
|||
record.giver.known ? record.giver.displayName : "Anonymous Giver" |
|||
}} |
|||
</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" |
|||
alt="Activity image" |
|||
@load="$emit('cacheImage', record.image)" |
|||
/> |
|||
</a> |
|||
</div> |
|||
|
|||
<div class="relative flex justify-between gap-4 max-w-lg mx-auto mb-5"> |
|||
<!-- Source --> |
|||
<div |
|||
class="w-28 sm:w-40 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.providerPlanName |
|||
? 'rounded-full size-[3rem] sm:size-[4rem] object-cover' |
|||
: 'rounded size-[3rem] sm:size-[4rem] object-cover', |
|||
]" |
|||
/> |
|||
</template> |
|||
<template v-else> |
|||
<!-- Project Icon --> |
|||
<template v-if="record.providerPlanName"> |
|||
<ProjectIcon |
|||
:entity-id="record.providerPlanName" |
|||
:icon-size="48" |
|||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full" |
|||
/> |
|||
</template> |
|||
<!-- Identicon for DIDs --> |
|||
<template v-else-if="record.giver.did"> |
|||
<img |
|||
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`" |
|||
class="rounded-full size-[3rem] sm:size-[4rem]" |
|||
alt="Identicon" |
|||
/> |
|||
</template> |
|||
<!-- Unknown Person --> |
|||
<template v-else> |
|||
<fa |
|||
icon="person-circle-question" |
|||
class="text-slate-300 text-[3rem] sm:text-[4rem]" |
|||
/> |
|||
</template> |
|||
</template> |
|||
</div> |
|||
<div class="text-xs mt-2 line-clamp-3 sm:line-clamp-2"> |
|||
<fa |
|||
:icon="record.providerPlanName ? 'building' : 'user'" |
|||
class="fa-fw text-slate-400" |
|||
/> |
|||
{{ record.giver.displayName }} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Arrow --> |
|||
<div |
|||
class="absolute inset-x-28 sm:inset-x-40 mx-2 top-1/2 -translate-y-1/2" |
|||
> |
|||
<div class="text-sm text-center leading-none font-semibold"> |
|||
{{ fetchAmount }} |
|||
</div> |
|||
|
|||
<div class="flex items-center"> |
|||
<hr |
|||
class="grow border-t-[18px] sm:border-t-[24px] border-slate-300" |
|||
/> |
|||
|
|||
<div |
|||
class="shrink-0 w-0 h-0 border border-slate-300 border-t-[20px] sm:border-t-[25px] border-t-transparent border-b-[20px] sm:border-b-[25px] border-b-transparent border-s-[27px] sm:border-s-[34px] border-e-0" |
|||
></div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Destination --> |
|||
<div |
|||
class="w-28 sm:w-40 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.recipientProjectName |
|||
? 'rounded-full size-[3rem] sm:size-[4rem] object-cover' |
|||
: 'rounded size-[3rem] sm:size-[4rem] object-cover', |
|||
]" |
|||
/> |
|||
</template> |
|||
<template v-else> |
|||
<!-- Project Icon --> |
|||
<template v-if="record.recipientProjectName"> |
|||
<ProjectIcon |
|||
:entity-id="record.recipientProjectName" |
|||
:icon-size="48" |
|||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full" |
|||
/> |
|||
</template> |
|||
<!-- Identicon for DIDs --> |
|||
<template v-else-if="record.receiver.did"> |
|||
<img |
|||
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`" |
|||
class="rounded-full size-[3rem] sm:size-[4rem]" |
|||
alt="Identicon" |
|||
/> |
|||
</template> |
|||
<!-- Unknown Person --> |
|||
<template v-else> |
|||
<fa |
|||
icon="person-circle-question" |
|||
class="text-slate-300 text-[3rem] sm:text-[4rem]" |
|||
/> |
|||
</template> |
|||
</template> |
|||
</div> |
|||
<div class="text-xs mt-2 line-clamp-3 sm:line-clamp-2"> |
|||
<fa |
|||
:icon="record.recipientProjectName ? 'building' : 'user'" |
|||
class="fa-fw text-slate-400" |
|||
/> |
|||
{{ record.receiver.displayName }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Description --> |
|||
<p class="font-medium"> |
|||
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)"> |
|||
{{ 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 class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)"> |
|||
<fa icon="circle-info" class="fa-fw text-slate-500" /> |
|||
</a> |
|||
</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"; |
|||
import ProjectIcon from "./ProjectIcon.vue"; |
|||
|
|||
@Component({ |
|||
components: { |
|||
EntityIcon, |
|||
ProjectIcon, |
|||
}, |
|||
}) |
|||
export default class ActivityListItem extends Vue { |
|||
@Prop() record!: GiveRecordWithContactInfo; |
|||
@Prop() lastViewedClaimId?: string; |
|||
@Prop() isRegistered!: boolean; |
|||
@Prop() activeDid!: string; |
|||
@Prop() confirmerIdList?: string[]; |
|||
|
|||
get fetchAmount(): string { |
|||
const claim = |
|||
(this.record.fullClaim as unknown).claim || this.record.fullClaim; |
|||
|
|||
const amount = claim.object?.amountOfThisGood |
|||
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood) |
|||
: ""; |
|||
|
|||
return 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; |
|||
|
|||
if (!claim.description) { |
|||
return "something not described"; |
|||
} |
|||
|
|||
return `${claim.description}`; |
|||
} |
|||
|
|||
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> |
@ -0,0 +1,20 @@ |
|||
export interface GiveRecordWithContactInfo { |
|||
jwtId: string; |
|||
fullClaim: unknown; // Replace with proper type
|
|||
giver: { |
|||
known: boolean; |
|||
displayName: string; |
|||
profileImageUrl?: string; |
|||
}; |
|||
receiver: { |
|||
known: boolean; |
|||
displayName: string; |
|||
profileImageUrl?: string; |
|||
}; |
|||
providerPlanName?: string; |
|||
recipientProjectName?: string; |
|||
description?: string; |
|||
subDescription?: string; |
|||
image?: string; |
|||
timestamp: string; |
|||
} |
Loading…
Reference in new issue