forked from jsnbuchanan/crowd-funder-for-time-pwa
Move all user-facing notification messages to src/constants/notifications.ts Use TIMEOUTS constants from src/utils/notify.ts for all notification durations Refactor ActivityListItem.vue: Use notification message and duration constants Initialize notify helper in created() with createNotifyHelpers(this.$notify) Add $notify property for Vue runtime injection to satisfy type checker Use type guards or 'as any' for unknown notification payloads Wrap notifyWhyCannotConfirm calls to match expected function signature Fix type import for GiveRecordWithContactInfo Add 'Notification Best Practices and Nuances' section to migration-progress-tracker.md: Document message/duration constants, notify helper pattern, type safety, and wrapper function usage Remove all hardcoded notification strings and durations from components
648 lines
17 KiB
Vue
648 lines
17 KiB
Vue
<template>
|
|
<div v-if="visible" class="dialog-overlay">
|
|
<div class="dialog">
|
|
<!-- Step 1: Entity Selection -->
|
|
<EntitySelectionStep
|
|
v-show="firstStep"
|
|
:step-type="stepType"
|
|
:giver-entity-type="giverEntityType"
|
|
:recipient-entity-type="recipientEntityType"
|
|
:show-projects="showProjects"
|
|
:is-from-project-view="isFromProjectView"
|
|
:projects="projects"
|
|
:all-contacts="allContacts"
|
|
:active-did="activeDid"
|
|
:all-my-dids="allMyDids"
|
|
:conflict-checker="wouldCreateConflict"
|
|
:from-project-id="fromProjectId"
|
|
:to-project-id="toProjectId"
|
|
:giver="giver"
|
|
:receiver="receiver"
|
|
:notify="notify"
|
|
@entity-selected="handleEntitySelected"
|
|
@cancel="cancel"
|
|
/>
|
|
|
|
<!-- Step 2: Gift Details -->
|
|
<GiftDetailsStep
|
|
v-show="!firstStep"
|
|
:giver="giver"
|
|
:receiver="receiver"
|
|
:giver-entity-type="giverEntityType"
|
|
:recipient-entity-type="recipientEntityType"
|
|
:description="description"
|
|
:amount="parseFloat(amountInput) || 0"
|
|
:unit-code="unitCode"
|
|
:prompt="prompt"
|
|
:is-from-project-view="isFromProjectView"
|
|
:has-conflict="hasPersonConflict"
|
|
:offer-id="offerId"
|
|
:from-project-id="fromProjectId"
|
|
:to-project-id="toProjectId"
|
|
@update:description="description = $event"
|
|
@update:amount="handleAmountUpdate"
|
|
@update:unit-code="unitCode = $event"
|
|
@edit-entity="handleEditEntity"
|
|
@explain-data="explainData"
|
|
@submit="handleSubmit"
|
|
@cancel="cancel"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { Vue, Component, Prop, Watch } from "vue-facing-decorator";
|
|
|
|
import {
|
|
createAndSubmitGive,
|
|
didInfo,
|
|
serverMessageForUser,
|
|
getHeaders,
|
|
} from "../libs/endorserServer";
|
|
import * as libsUtil from "../libs/util";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import { retrieveAccountDids } from "../libs/util";
|
|
import { logger } from "../utils/logger";
|
|
import EntityIcon from "../components/EntityIcon.vue";
|
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
|
import EntitySelectionStep from "../components/EntitySelectionStep.vue";
|
|
import GiftDetailsStep from "../components/GiftDetailsStep.vue";
|
|
import { PlanData } from "../interfaces/records";
|
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
|
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
ProjectIcon,
|
|
EntitySelectionStep,
|
|
GiftDetailsStep,
|
|
},
|
|
mixins: [PlatformServiceMixin],
|
|
})
|
|
export default class GiftedDialog extends Vue {
|
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
|
|
|
@Prop() fromProjectId = "";
|
|
@Prop() toProjectId = "";
|
|
@Prop({ default: false }) showProjects = false;
|
|
@Prop() isFromProjectView = false;
|
|
|
|
@Watch("showProjects")
|
|
onShowProjectsChange() {
|
|
this.updateEntityTypes();
|
|
}
|
|
|
|
@Watch("fromProjectId")
|
|
onFromProjectIdChange() {
|
|
this.updateEntityTypes();
|
|
}
|
|
|
|
@Watch("toProjectId")
|
|
onToProjectIdChange() {
|
|
this.updateEntityTypes();
|
|
}
|
|
|
|
activeDid = "";
|
|
allContacts: Array<Contact> = [];
|
|
allMyDids: Array<string> = [];
|
|
apiServer = "";
|
|
|
|
amountInput = "0";
|
|
callbackOnSuccess?: (amount: number) => void = () => {};
|
|
customTitle?: string;
|
|
description = "";
|
|
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
|
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
|
offerId = "";
|
|
prompt = "";
|
|
receiver?: libsUtil.GiverReceiverInputInfo;
|
|
unitCode = "HUR";
|
|
visible = false;
|
|
|
|
libsUtil = libsUtil;
|
|
|
|
projects: PlanData[] = [];
|
|
|
|
didInfo = didInfo;
|
|
|
|
// Computed property to help debug template logic
|
|
get shouldShowProjects() {
|
|
const result =
|
|
(this.stepType === "giver" && this.giverEntityType === "project") ||
|
|
(this.stepType === "recipient" && this.recipientEntityType === "project");
|
|
return result;
|
|
}
|
|
|
|
// Computed property to check if current selection would create a conflict
|
|
get hasPersonConflict() {
|
|
// Only check for conflicts when both entities are persons
|
|
if (
|
|
this.giverEntityType !== "person" ||
|
|
this.recipientEntityType !== "person"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// Check if giver and recipient are the same person
|
|
if (
|
|
this.giver?.did &&
|
|
this.receiver?.did &&
|
|
this.giver.did === this.receiver.did
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Computed property to check if a contact would create a conflict when selected
|
|
wouldCreateConflict(contactDid: string) {
|
|
// Only check for conflicts when both entities are persons
|
|
if (
|
|
this.giverEntityType !== "person" ||
|
|
this.recipientEntityType !== "person"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (this.stepType === "giver") {
|
|
// If selecting as giver, check if it conflicts with current recipient
|
|
return this.receiver?.did === contactDid;
|
|
} else if (this.stepType === "recipient") {
|
|
// If selecting as recipient, check if it conflicts with current giver
|
|
return this.giver?.did === contactDid;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
stepType = "giver";
|
|
giverEntityType = "person" as "person" | "project";
|
|
recipientEntityType = "person" as "person" | "project";
|
|
|
|
updateEntityTypes() {
|
|
// Reset and set entity types based on current context
|
|
this.giverEntityType = "person";
|
|
this.recipientEntityType = "person";
|
|
|
|
// Determine entity types based on current context
|
|
if (this.showProjects) {
|
|
// HomeView "Project" button or ProjectViewView "Given by This"
|
|
this.giverEntityType = "project";
|
|
this.recipientEntityType = "person";
|
|
} else if (this.fromProjectId) {
|
|
// ProjectViewView "Given by This" button (project is giver)
|
|
this.giverEntityType = "project";
|
|
this.recipientEntityType = "person";
|
|
} else if (this.toProjectId) {
|
|
// ProjectViewView "Given to This" button (project is recipient)
|
|
this.giverEntityType = "person";
|
|
this.recipientEntityType = "project";
|
|
} else {
|
|
// HomeView "Person" button
|
|
this.giverEntityType = "person";
|
|
this.recipientEntityType = "person";
|
|
}
|
|
}
|
|
|
|
async open(
|
|
giver?: libsUtil.GiverReceiverInputInfo,
|
|
receiver?: libsUtil.GiverReceiverInputInfo,
|
|
offerId?: string,
|
|
customTitle?: string,
|
|
prompt?: string,
|
|
callbackOnSuccess: (amount: number) => void = () => {},
|
|
) {
|
|
this.customTitle = customTitle;
|
|
this.giver = giver;
|
|
this.prompt = prompt || "";
|
|
this.receiver = receiver;
|
|
this.amountInput = "0";
|
|
this.callbackOnSuccess = callbackOnSuccess;
|
|
this.offerId = offerId || "";
|
|
this.firstStep = !giver;
|
|
this.stepType = "giver";
|
|
|
|
// Update entity types based on current props
|
|
this.updateEntityTypes();
|
|
|
|
try {
|
|
const settings = await this.$settings();
|
|
this.apiServer = settings.apiServer || "";
|
|
this.activeDid = settings.activeDid || "";
|
|
|
|
this.allContacts = await this.$contacts();
|
|
|
|
this.allMyDids = await retrieveAccountDids();
|
|
|
|
if (this.giver && !this.giver.name) {
|
|
this.giver.name = didInfo(
|
|
this.giver.did,
|
|
this.activeDid,
|
|
this.allMyDids,
|
|
this.allContacts,
|
|
);
|
|
}
|
|
|
|
if (
|
|
this.giverEntityType === "project" ||
|
|
this.recipientEntityType === "project"
|
|
) {
|
|
await this.loadProjects();
|
|
} else {
|
|
// Clear projects array when not needed
|
|
this.projects = [];
|
|
}
|
|
} catch (err: unknown) {
|
|
logger.error("Error retrieving settings from database:", err);
|
|
this.notify.error(
|
|
err instanceof Error
|
|
? err.message
|
|
: "There was an error retrieving your settings.",
|
|
TIMEOUTS.MODAL,
|
|
);
|
|
}
|
|
|
|
this.visible = true;
|
|
}
|
|
|
|
close() {
|
|
// close the dialog but don't change values (since it might be submitting info)
|
|
this.visible = false;
|
|
}
|
|
|
|
changeUnitCode() {
|
|
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
|
const index = units.indexOf(this.unitCode);
|
|
this.unitCode = units[(index + 1) % units.length];
|
|
}
|
|
|
|
increment() {
|
|
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
|
}
|
|
|
|
decrement() {
|
|
this.amountInput = `${Math.max(
|
|
0,
|
|
(parseFloat(this.amountInput) || 1) - 1,
|
|
)}`;
|
|
}
|
|
|
|
cancel() {
|
|
this.close();
|
|
this.eraseValues();
|
|
}
|
|
|
|
eraseValues() {
|
|
this.description = "";
|
|
this.giver = undefined;
|
|
this.amountInput = "0";
|
|
this.prompt = "";
|
|
this.unitCode = "HUR";
|
|
this.firstStep = true;
|
|
}
|
|
|
|
async confirm() {
|
|
if (!this.activeDid) {
|
|
this.notify.error(
|
|
"You must select an identifier before you can record a give.",
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
return;
|
|
}
|
|
if (parseFloat(this.amountInput) < 0) {
|
|
this.notify.error("You may not send a negative number.", TIMEOUTS.SHORT);
|
|
return;
|
|
}
|
|
if (!this.description && !parseFloat(this.amountInput)) {
|
|
this.notify.error(
|
|
`You must enter a description or some number of ${
|
|
this.libsUtil.UNIT_LONG[this.unitCode]
|
|
}.`,
|
|
TIMEOUTS.SHORT,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Check for person conflict
|
|
if (this.hasPersonConflict) {
|
|
this.notify.error(
|
|
"You cannot select the same person as both giver and recipient.",
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.close();
|
|
this.notify.toast("Recording the give...", undefined, TIMEOUTS.BRIEF);
|
|
// this is asynchronous, but we don't need to wait for it to complete
|
|
await this.recordGive(
|
|
(this.giver?.did as string) || null,
|
|
(this.receiver?.did as string) || null,
|
|
this.description,
|
|
parseFloat(this.amountInput),
|
|
this.unitCode,
|
|
).then(() => {
|
|
this.eraseValues();
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param giverDid may be null
|
|
* @param recipientDid may be null
|
|
* @param description may be an empty string
|
|
* @param amount may be 0
|
|
* @param unitCode may be omitted, defaults to "HUR"
|
|
*/
|
|
async recordGive(
|
|
giverDid: string | null,
|
|
recipientDid: string | null,
|
|
description: string,
|
|
amount: number,
|
|
unitCode: string = "HUR",
|
|
) {
|
|
try {
|
|
// Determine the correct parameters based on entity types
|
|
let fromDid: string | undefined;
|
|
let toDid: string | undefined;
|
|
let fulfillsProjectHandleId: string | undefined;
|
|
let providerPlanHandleId: string | undefined;
|
|
|
|
if (
|
|
this.giverEntityType === "project" &&
|
|
this.recipientEntityType === "person"
|
|
) {
|
|
// Project-to-person gift
|
|
fromDid = undefined; // No person giver
|
|
toDid = recipientDid as string; // Person recipient
|
|
fulfillsProjectHandleId = undefined; // No project recipient
|
|
providerPlanHandleId = this.giver?.handleId; // Project giver
|
|
} else if (
|
|
this.giverEntityType === "person" &&
|
|
this.recipientEntityType === "project"
|
|
) {
|
|
// Person-to-project gift
|
|
fromDid = giverDid as string; // Person giver
|
|
toDid = undefined; // No person recipient
|
|
fulfillsProjectHandleId = this.toProjectId; // Project recipient
|
|
providerPlanHandleId = undefined; // No project giver
|
|
} else {
|
|
// Person-to-person gift
|
|
fromDid = giverDid as string;
|
|
toDid = recipientDid as string;
|
|
fulfillsProjectHandleId = undefined;
|
|
providerPlanHandleId = undefined;
|
|
}
|
|
|
|
const result = await createAndSubmitGive(
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
fromDid,
|
|
toDid,
|
|
description,
|
|
amount,
|
|
unitCode,
|
|
fulfillsProjectHandleId,
|
|
this.offerId,
|
|
false,
|
|
undefined,
|
|
providerPlanHandleId,
|
|
);
|
|
|
|
if (!result.success) {
|
|
const errorMessage = this.getGiveCreationErrorMessage(result);
|
|
logger.error("Error with give creation result:", result);
|
|
this.notify.error(
|
|
errorMessage || "There was an error creating the give.",
|
|
TIMEOUTS.MODAL,
|
|
);
|
|
} else {
|
|
this.notify.success("That gift was recorded.", TIMEOUTS.VERY_LONG);
|
|
if (this.callbackOnSuccess) {
|
|
this.callbackOnSuccess(amount);
|
|
}
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (error: any) {
|
|
logger.error("Error with give recordation caught:", error);
|
|
const errorMessage =
|
|
error.userMessage ||
|
|
serverMessageForUser(error) ||
|
|
"There was an error recording the give.";
|
|
this.notify.error(errorMessage, TIMEOUTS.MODAL);
|
|
}
|
|
}
|
|
|
|
// Helper functions for readability
|
|
|
|
/**
|
|
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
|
* @returns best guess at an error message
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
getGiveCreationErrorMessage(result: any) {
|
|
return (
|
|
result.error?.userMessage ||
|
|
result.error?.error ||
|
|
result.response?.data?.error?.message
|
|
);
|
|
}
|
|
|
|
explainData() {
|
|
this.notify.info(libsUtil.PRIVACY_MESSAGE, TIMEOUTS.MODAL);
|
|
}
|
|
|
|
selectGiver(contact?: Contact) {
|
|
if (contact) {
|
|
this.giver = {
|
|
did: contact.did,
|
|
name: contact.name || contact.did,
|
|
};
|
|
} else {
|
|
this.giver = {
|
|
did: "",
|
|
name: "Unnamed",
|
|
};
|
|
}
|
|
this.firstStep = false;
|
|
}
|
|
|
|
goBackToStep1(step: string) {
|
|
this.stepType = step;
|
|
this.firstStep = true;
|
|
}
|
|
|
|
async loadProjects() {
|
|
try {
|
|
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
|
|
method: "GET",
|
|
headers: await getHeaders(this.activeDid),
|
|
});
|
|
|
|
if (response.status !== 200) {
|
|
throw new Error("Failed to load projects");
|
|
}
|
|
|
|
const results = await response.json();
|
|
if (results.data) {
|
|
this.projects = results.data;
|
|
}
|
|
} catch (error) {
|
|
logger.error("Error loading projects:", error);
|
|
this.notify.error("Failed to load projects", TIMEOUTS.STANDARD);
|
|
}
|
|
}
|
|
|
|
selectProject(project: PlanData) {
|
|
this.giver = {
|
|
did: project.handleId,
|
|
name: project.name,
|
|
image: project.image,
|
|
handleId: project.handleId,
|
|
};
|
|
this.receiver = {
|
|
did: this.activeDid,
|
|
name: "You",
|
|
};
|
|
this.firstStep = false;
|
|
}
|
|
|
|
selectRecipient(contact?: Contact) {
|
|
if (contact) {
|
|
this.receiver = {
|
|
did: contact.did,
|
|
name: contact.name || contact.did,
|
|
};
|
|
} else {
|
|
this.receiver = {
|
|
did: "",
|
|
name: "Unnamed",
|
|
};
|
|
}
|
|
this.firstStep = false;
|
|
}
|
|
|
|
selectRecipientProject(project: PlanData) {
|
|
this.receiver = {
|
|
did: project.handleId,
|
|
name: project.name,
|
|
image: project.image,
|
|
handleId: project.handleId,
|
|
};
|
|
this.firstStep = false;
|
|
}
|
|
|
|
// Computed property for the query parameters
|
|
get giftedDetailsQuery() {
|
|
return {
|
|
amountInput: this.amountInput,
|
|
description: this.description,
|
|
giverDid: this.giverEntityType === "person" ? this.giver?.did : undefined,
|
|
giverName: this.giver?.name,
|
|
offerId: this.offerId,
|
|
fulfillsProjectId:
|
|
this.giverEntityType === "person" &&
|
|
this.recipientEntityType === "project"
|
|
? this.toProjectId
|
|
: undefined,
|
|
providerProjectId:
|
|
this.giverEntityType === "project" &&
|
|
this.recipientEntityType === "person"
|
|
? this.giver?.handleId
|
|
: this.fromProjectId,
|
|
recipientDid: this.receiver?.did,
|
|
recipientName: this.receiver?.name,
|
|
unitCode: this.unitCode,
|
|
};
|
|
}
|
|
|
|
// New event handlers for component integration
|
|
|
|
/**
|
|
* Handle entity selection from EntitySelectionStep
|
|
* @param entity - The selected entity (person or project)
|
|
*/
|
|
handleEntitySelected(entity: {
|
|
type: "person" | "project";
|
|
data: Contact | PlanData;
|
|
}) {
|
|
if (entity.type === "person") {
|
|
const contact = entity.data as Contact;
|
|
if (this.stepType === "giver") {
|
|
this.selectGiver(contact);
|
|
} else {
|
|
this.selectRecipient(contact);
|
|
}
|
|
} else {
|
|
const project = entity.data as PlanData;
|
|
if (this.stepType === "giver") {
|
|
this.selectProject(project);
|
|
} else {
|
|
this.selectRecipientProject(project);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle edit entity request from GiftDetailsStep
|
|
* @param entityType - 'giver' or 'recipient'
|
|
*/
|
|
handleEditEntity(entityType: "giver" | "recipient") {
|
|
this.goBackToStep1(entityType);
|
|
}
|
|
|
|
/**
|
|
* Handle form submission from GiftDetailsStep
|
|
*/
|
|
handleSubmit() {
|
|
this.confirm();
|
|
}
|
|
|
|
/**
|
|
* Handle amount update from GiftDetailsStep
|
|
*/
|
|
handleAmountUpdate(newAmount: number) {
|
|
logger.debug("[GiftedDialog] handleAmountUpdate() called", {
|
|
oldAmount: this.amountInput,
|
|
newAmount,
|
|
});
|
|
this.amountInput = newAmount.toString();
|
|
logger.debug("[GiftedDialog] handleAmountUpdate() - amountInput updated", {
|
|
amountInput: this.amountInput,
|
|
});
|
|
}
|
|
|
|
created() {
|
|
this.notify = createNotifyHelpers(this.$notify);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.dialog-overlay {
|
|
z-index: 50;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.dialog {
|
|
background-color: white;
|
|
padding: 1rem;
|
|
border-radius: 0.5rem;
|
|
width: 100%;
|
|
max-width: 500px;
|
|
}
|
|
</style>
|