forked from jsnbuchanan/crowd-funder-for-time-pwa
- Replace @Emit("update:description"), @Emit("update:amount"), @Emit("update:unitCode") with function props
- Add onUpdateDescription, onUpdateAmount, onUpdateUnitCode function props with TypeScript typing
- Update GiftedDialog to use new function prop interface for update handlers
- Remove emit decorators and methods for update events
- Keep emit pattern for non-update events (edit-entity, explain-data, submit, cancel)
- Improve component documentation to reflect new architecture
This change provides better parent control over validation and update behavior
for form fields while maintaining existing functionality for other events.
701 lines
19 KiB
Vue
701 lines
19 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"
|
|
:on-update-description="(desc: string) => (description = desc)"
|
|
:on-update-amount="handleAmountUpdate"
|
|
:on-update-unit-code="(code: string) => (unitCode = code)"
|
|
@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!: (notification: any, timeout?: number) => void;
|
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
|
|
|
/**
|
|
* Safe notification method that ensures notify helpers are available
|
|
*/
|
|
get safeNotify() {
|
|
if (!this.notify && this.$notify) {
|
|
this.notify = createNotifyHelpers(this.$notify);
|
|
}
|
|
return this.notify;
|
|
}
|
|
|
|
@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.safeNotify.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.safeNotify.error(
|
|
"You must select an identifier before you can record a give.",
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
return;
|
|
}
|
|
if (parseFloat(this.amountInput) < 0) {
|
|
this.safeNotify.error(
|
|
"You may not send a negative number.",
|
|
TIMEOUTS.SHORT,
|
|
);
|
|
return;
|
|
}
|
|
if (!this.description && !parseFloat(this.amountInput)) {
|
|
this.safeNotify.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.safeNotify.error(
|
|
"You cannot select the same person as both giver and recipient.",
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.close();
|
|
this.safeNotify.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.safeNotify.error(
|
|
errorMessage || "There was an error creating the give.",
|
|
TIMEOUTS.MODAL,
|
|
);
|
|
} else {
|
|
this.safeNotify.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.safeNotify.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.safeNotify.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.safeNotify.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, project, or special) with stepType
|
|
*/
|
|
handleEntitySelected(entity: {
|
|
type: "person" | "project" | "special";
|
|
entityType?: string;
|
|
data: Contact | PlanData | { did?: string; name: string };
|
|
stepType: string;
|
|
}) {
|
|
if (entity.type === "person") {
|
|
const contact = entity.data as Contact;
|
|
if (entity.stepType === "giver") {
|
|
this.selectGiver(contact);
|
|
} else {
|
|
this.selectRecipient(contact);
|
|
}
|
|
} else if (entity.type === "project") {
|
|
const project = entity.data as PlanData;
|
|
if (entity.stepType === "giver") {
|
|
this.selectProject(project);
|
|
} else {
|
|
this.selectRecipientProject(project);
|
|
}
|
|
} else if (entity.type === "special") {
|
|
// Handle special entities like "You" and "Unnamed"
|
|
if (entity.entityType === "you") {
|
|
// "You" entity selected
|
|
const youEntity = {
|
|
did: this.activeDid,
|
|
name: "You",
|
|
};
|
|
if (entity.stepType === "giver") {
|
|
this.giver = youEntity;
|
|
} else {
|
|
this.receiver = youEntity;
|
|
}
|
|
this.firstStep = false;
|
|
} else if (entity.entityType === "unnamed") {
|
|
// "Unnamed" entity selected
|
|
const unnamedEntity = {
|
|
did: "",
|
|
name: "Unnamed",
|
|
};
|
|
if (entity.stepType === "giver") {
|
|
this.giver = unnamedEntity;
|
|
} else {
|
|
this.receiver = unnamedEntity;
|
|
}
|
|
this.firstStep = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle edit entity request from GiftDetailsStep
|
|
* @param data - Object containing entityType and currentEntity
|
|
*/
|
|
handleEditEntity(data: { entityType: string; currentEntity: any }) {
|
|
this.goBackToStep1(data.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() {
|
|
// Initialize notify helpers when component is created
|
|
if (this.$notify) {
|
|
this.notify = createNotifyHelpers(this.$notify);
|
|
}
|
|
}
|
|
|
|
mounted() {
|
|
// Ensure notify helpers are initialized if not already done
|
|
if (!this.notify && this.$notify) {
|
|
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>
|