Compare commits

...

32 Commits

Author SHA1 Message Date
Matthew Raymer c3851371c0 Fix TypeScript any types, console statements, and clean up duplicates 2 days ago
Matthew Raymer b388dc3d8e Merge branch 'gifting-periphery-improvements' into build-improvement 2 days ago
Matthew Raymer 966dbc5164 Fix TypeScript any types and remove deprecated Dexie code 2 days ago
Trent Larson 25512d3db1 some fixes to the gifted-dialog logic 6 days ago
Jose Olarte III bf9fee7ee9 Various aesthetic improvements and optimizations 1 week ago
Jose Olarte III 08c46a27d3 Add project-to-project case 1 week ago
Jose Olarte III c9405839c3 Merge branch 'gifting-ui-2025-05' into gifting-periphery-improvements 1 week ago
Trent Larson 0e6a9c4f89 adjust grammar for recording receipt 1 week ago
Jose Olarte III b6278ca148 Unit codes pulled from util.ts 2 weeks ago
Jose Olarte III d8e237f8cb Describe firstStep variable 2 weeks ago
Jose Olarte III 4b539ccc55 Better handling of No-name and Unnamed entities 2 weeks ago
Jose Olarte III ea49173885 Changed currentStep to boolean 2 weeks ago
Jose Olarte III 447a7cb089 Style "unnamed" entity 2 weeks ago
Jose Olarte III c0ddba8898 Various design tweaks 2 weeks ago
Jose Olarte III fe4ae90849 Giver-recipient display fixes 2 weeks ago
Jose Olarte III ce04312baa Updated amount input controls 2 weeks ago
Jose Olarte III a8cc480960 Merge branch 'master' into gifting-periphery-improvements 2 weeks ago
Jose Olarte III 357822d713 Fix: truncate text blocks 2 weeks ago
Jose Olarte III ca22161f12 Fix: entity-type identifier validation 2 weeks ago
Jose Olarte III d3b80fbe47 Feature: giver-recipient validation 2 weeks ago
Jose Olarte III 0342c872f4 Fix: added context for ContactGiftingView 2 weeks ago
Jose Olarte III a7e65b3b49 Giver-recipient controls 2 weeks ago
Jose Olarte III eb7605991c Fixed more gifting use cases 3 weeks ago
Trent Larson fa21660fd1 fix spelling 3 weeks ago
Jose Olarte III df1c1f0186 Fix: pass project info 3 weeks ago
Jose Olarte III 3daf1c8a5c Feature: Project Gifting 3 weeks ago
Jose Olarte III 7eefee1ea5 Fix: Conditional show-all link 3 weeks ago
Jose Olarte III 140c36a416 Merge branch 'master' into gifting-ui-2025-05 4 weeks ago
Jose Olarte III 988244b7ae Added check for "Unnamed" giver 2 months ago
Jose Olarte III 4b355a5448 WIP: two-step dialog + functionality 2 months ago
Jose Olarte III b511f9cd24 WIP: adjustments to bring closer to original mockups 2 months ago
Jose Olarte III 579cecbe6e WIP: gifting UI revamp 2 months ago
  1. 226
      src/components/AmountInput.vue
  2. 264
      src/components/EntityGrid.vue
  3. 254
      src/components/EntitySelectionStep.vue
  4. 145
      src/components/EntitySummaryButton.vue
  5. 416
      src/components/GiftDetailsStep.vue
  6. 523
      src/components/GiftedDialog.vue
  7. 114
      src/components/PersonCard.vue
  8. 96
      src/components/ProjectCard.vue
  9. 66
      src/components/ShowAllCard.vue
  10. 135
      src/components/SpecialEntityCard.vue
  11. 4
      src/constants/app.ts
  12. 6
      src/libs/fontawesome.ts
  13. 2
      src/libs/util.ts
  14. 6
      src/services/indexedDBMigrationService.ts
  15. 199
      src/views/ContactGiftingView.vue
  16. 176
      src/views/GiftedDetailsView.vue
  17. 188
      src/views/HomeView.vue
  18. 118
      src/views/ProjectViewView.vue

226
src/components/AmountInput.vue

@ -0,0 +1,226 @@
/** * AmountInput.vue - Specialized amount input with increment/decrement
controls * * Extracted from GiftedDialog.vue to handle numeric amount input *
with increment/decrement buttons and validation. * * @author Matthew Raymer */
<template>
<div class="flex flex-grow">
<button
class="rounded-s border border-e-0 border-slate-400 bg-slate-200 px-4 py-2"
:disabled="isAtMinimum"
type="button"
@click.prevent="decrement"
>
<font-awesome icon="chevron-left" />
</button>
<input
:id="inputId"
v-model="displayValue"
type="number"
:min="min"
:max="max"
:step="step"
class="flex-1 border border-e-0 border-slate-400 px-2 py-2 text-center w-[1px]"
@input="handleInput"
@blur="handleBlur"
/>
<button
class="rounded-e border border-slate-400 bg-slate-200 px-4 py-2"
:disabled="isAtMaximum"
type="button"
@click.prevent="increment"
>
<font-awesome icon="chevron-right" />
</button>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch, Emit } from "vue-facing-decorator";
import { logger } from "@/utils/logger";
/**
* AmountInput - Numeric input with increment/decrement controls
*
* Features:
* - Increment/decrement buttons with validation
* - Configurable min/max values and step size
* - Input validation and formatting
* - Disabled state handling for boundary values
* - Emits update events for v-model compatibility
*/
@Component
export default class AmountInput extends Vue {
/** Current numeric value */
@Prop({ required: true })
value!: number;
/** Minimum allowed value */
@Prop({ default: 0 })
min!: number;
/** Maximum allowed value */
@Prop({ default: Number.MAX_SAFE_INTEGER })
max!: number;
/** Step size for increment/decrement */
@Prop({ default: 1 })
step!: number;
/** Input element ID for accessibility */
@Prop({ default: "amount-input" })
inputId!: string;
/** Internal display value for input field */
private displayValue: string = "0";
/**
* Initialize display value from prop
*/
mounted(): void {
logger.debug("[AmountInput] mounted()", {
value: this.value,
min: this.min,
max: this.max,
step: this.step,
});
this.displayValue = this.value.toString();
logger.debug("[AmountInput] mounted() - displayValue set", {
displayValue: this.displayValue,
});
}
/**
* Watch for external value changes
*/
@Watch("value")
onValueChange(newValue: number): void {
this.displayValue = newValue.toString();
}
/**
* Check if current value is at minimum
*/
get isAtMinimum(): boolean {
const result = this.value <= this.min;
logger.debug("[AmountInput] isAtMinimum", {
value: this.value,
min: this.min,
result,
});
return result;
}
/**
* Check if current value is at maximum
*/
get isAtMaximum(): boolean {
const result = this.value >= this.max;
logger.debug("[AmountInput] isAtMaximum", {
value: this.value,
max: this.max,
result,
});
return result;
}
/**
* Increment the value by step size
*/
increment(): void {
logger.debug("[AmountInput] increment() called", {
currentValue: this.value,
step: this.step,
});
const newValue = Math.min(this.value + this.step, this.max);
logger.debug("[AmountInput] increment() calculated newValue", {
newValue,
});
this.updateValue(newValue);
}
/**
* Decrement the value by step size
*/
decrement(): void {
logger.debug("[AmountInput] decrement() called", {
currentValue: this.value,
step: this.step,
});
const newValue = Math.max(this.value - this.step, this.min);
logger.debug("[AmountInput] decrement() calculated newValue", {
newValue,
});
this.updateValue(newValue);
}
/**
* Handle direct input changes
*/
handleInput(): void {
const numericValue = parseFloat(this.displayValue);
if (!isNaN(numericValue)) {
const clampedValue = Math.max(this.min, Math.min(numericValue, this.max));
this.updateValue(clampedValue);
}
}
/**
* Handle input blur - ensure display value matches actual value
*/
handleBlur(): void {
this.displayValue = this.value.toString();
}
/**
* Update the value and emit change event
*/
private updateValue(newValue: number): void {
logger.debug("[AmountInput] updateValue() called", {
oldValue: this.value,
newValue,
});
if (newValue !== this.value) {
logger.debug(
"[AmountInput] updateValue() - values different, updating and emitting",
);
this.displayValue = newValue.toString();
this.emitUpdateValue(newValue);
} else {
logger.debug(
"[AmountInput] updateValue() - values same, skipping update",
);
}
}
/**
* Emit update:value event
*/
@Emit("update:value")
emitUpdateValue(value: number): number {
logger.debug("[AmountInput] emitUpdateValue() - emitting value", {
value,
});
return value;
}
}
</script>
<style scoped>
/* Remove spinner arrows from number input */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* Disabled button styles */
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

264
src/components/EntityGrid.vue

@ -0,0 +1,264 @@
/** * EntityGrid.vue - Unified entity grid layout component * * Extracted from
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */
<template>
<ul :class="gridClasses">
<!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'">
<!-- "You" entity -->
<SpecialEntityCard
v-if="showYouEntity"
entity-type="you"
label="You"
icon="hand"
:selectable="youSelectable"
:conflicted="youConflicted"
:entity-data="youEntityData"
@entity-selected="handleEntitySelected"
/>
<!-- "Unnamed" entity -->
<SpecialEntityCard
entity-type="unnamed"
label="Unnamed"
icon="circle-question"
:entity-data="unnamedEntityData"
@entity-selected="handleEntitySelected"
/>
</template>
<!-- Empty state message -->
<li
v-if="entities.length === 0"
class="text-xs text-slate-500 italic col-span-full"
>
{{ emptyStateMessage }}
</li>
<!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'">
<PersonCard
v-for="person in displayedEntities"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
@person-selected="handlePersonSelected"
/>
</template>
<template v-else-if="entityType === 'projects'">
<ProjectCard
v-for="project in displayedEntities"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
@project-selected="handleProjectSelected"
/>
</template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
/**
* EntityGrid - Unified grid layout for displaying people or projects
*
* Features:
* - Responsive grid layout for people/projects
* - Special entity integration (You, Unnamed)
* - Conflict detection integration
* - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection
*/
@Component({
components: {
PersonCard,
ProjectCard,
SpecialEntityCard,
ShowAllCard,
},
})
export default class EntityGrid extends Vue {
/** Type of entities to display */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Array of entities to display */
@Prop({ required: true })
entities!: Contact[] | PlanData[];
/** Maximum number of entities to display */
@Prop({ default: 10 })
maxItems!: number;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** All contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Function to check if a person DID would create a conflict */
@Prop({ required: true })
conflictChecker!: (did: string) => boolean;
/** Whether to show the "You" entity for people grids */
@Prop({ default: true })
showYouEntity!: boolean;
/** Whether the "You" entity is selectable */
@Prop({ default: true })
youSelectable!: boolean;
/** Route name for "Show All" navigation */
@Prop({ default: "" })
showAllRoute!: string;
/** Query parameters for "Show All" navigation */
@Prop({ default: () => ({}) })
showAllQueryParams!: Record<string, string>;
/**
* Computed CSS classes for the grid layout
*/
get gridClasses(): string {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
if (this.entityType === "projects") {
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
} else {
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
}
}
/**
* Computed entities to display (limited by maxItems)
*/
get displayedEntities(): Contact[] | PlanData[] {
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
return this.entities.slice(0, maxDisplay);
}
/**
* Computed empty state message based on entity type
*/
get emptyStateMessage(): string {
if (this.entityType === "projects") {
return "(No projects found.)";
} else {
return "(Add friends to see more people worthy of recognition.)";
}
}
/**
* Whether to show the "Show All" navigation
*/
get shouldShowAll(): boolean {
return this.entities.length > 0 && this.showAllRoute !== "";
}
/**
* Whether the "You" entity is conflicted
*/
get youConflicted(): boolean {
return this.conflictChecker(this.activeDid);
}
/**
* Entity data for the "You" special entity
*/
get youEntityData(): { did: string; name: string } {
return {
did: this.activeDid,
name: "You",
};
}
/**
* Entity data for the "Unnamed" special entity
*/
get unnamedEntityData(): { did: string; name: string } {
return {
did: "",
name: "Unnamed",
};
}
/**
* Check if a person DID is conflicted
*/
isPersonConflicted(did: string): boolean {
return this.conflictChecker(did);
}
/**
* Handle person selection from PersonCard
*/
handlePersonSelected(person: Contact): void {
this.emitEntitySelected({
type: "person",
data: person,
});
}
/**
* Handle project selection from ProjectCard
*/
handleProjectSelected(project: PlanData): void {
this.emitEntitySelected({
type: "project",
data: project,
});
}
/**
* Handle special entity selection from SpecialEntityCard
*/
handleEntitySelected(event: {
type: string;
entityType: string;
data: any;
}): void {
this.emitEntitySelected({
type: "special",
entityType: event.entityType,
data: event.data,
});
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
emitEntitySelected(data: any): any {
return data;
}
}
</script>
<style scoped>
/* Grid-specific styles if needed */
</style>

254
src/components/EntitySelectionStep.vue

@ -0,0 +1,254 @@
/** * EntitySelectionStep.vue - Entity selection step component * * Extracted
from GiftedDialog.vue to handle the complete step 1 * entity selection interface
with dynamic labeling and grid display. * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
{{ stepLabel }}
</label>
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
@entity-selected="handleEntitySelected"
/>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg"
@click="handleCancel"
>
Cancel
</button>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
/**
* Entity data interface for giver/receiver
*/
interface EntityData {
did?: string;
handleId?: string;
name?: string;
image?: string;
}
/**
* Entity selection event data structure
*/
interface EntitySelectionEvent {
type: "person" | "project" | "special";
entityType?: string;
data: Contact | PlanData | EntityData;
}
/**
* EntitySelectionStep - Complete step 1 entity selection interface
*
* Features:
* - Dynamic step labeling based on context
* - EntityGrid integration for unified entity display
* - Conflict detection and prevention
* - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality
* - Event delegation for entity selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class EntitySelectionStep extends Vue {
/** Type of step: 'giver' or 'recipient' */
@Prop({ required: true })
stepType!: "giver" | "recipient";
/** Type of giver entity: 'person' or 'project' */
@Prop({ required: true })
giverEntityType!: "person" | "project";
/** Type of recipient entity: 'person' or 'project' */
@Prop({ required: true })
recipientEntityType!: "person" | "project";
/** Whether to show projects instead of people */
@Prop({ default: false })
showProjects!: boolean;
/** Whether this is from a project view */
@Prop({ default: false })
isFromProjectView!: boolean;
/** Array of available projects */
@Prop({ required: true })
projects!: PlanData[];
/** Array of available contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** Function to check if a DID would create a conflict */
@Prop({ required: true })
conflictChecker!: (did: string) => boolean;
/** Project ID for context (giver) */
@Prop({ default: "" })
fromProjectId!: string;
/** Project ID for context (recipient) */
@Prop({ default: "" })
toProjectId!: string;
/** Current giver entity for context */
@Prop()
giver?: EntityData | null;
/** Current receiver entity for context */
@Prop()
receiver?: EntityData | null;
/**
* Computed step label based on context
*/
get stepLabel(): string {
if (this.stepType === "recipient") {
return "Choose who received the gift:";
} else if (this.showProjects) {
return "Choose a project benefitted from:";
} else {
return "Choose a person received from:";
}
}
/**
* Whether to show projects in the grid
*/
get shouldShowProjects(): boolean {
return (
(this.stepType === "giver" && this.giverEntityType === "project") ||
(this.stepType === "recipient" && this.recipientEntityType === "project")
);
}
/**
* Whether to show the "You" entity
*/
get shouldShowYouEntity(): boolean {
return (
this.stepType === "recipient" ||
(this.stepType === "giver" && this.isFromProjectView)
);
}
/**
* Whether the "You" entity is selectable
*/
get youSelectable(): boolean {
return !this.conflictChecker(this.activeDid);
}
/**
* Route name for "Show All" navigation
*/
get showAllRoute(): string {
if (this.shouldShowProjects) {
return "discover";
} else if (this.allContacts.length > 0) {
return "contact-gift";
}
return "";
}
/**
* Query parameters for "Show All" navigation
*/
get showAllQueryParams(): Record<string, string> {
if (this.shouldShowProjects) {
return {};
}
return {
stepType: this.stepType,
giverEntityType: this.giverEntityType,
recipientEntityType: this.recipientEntityType,
...(this.stepType === "giver"
? {
recipientProjectId: this.toProjectId || "",
recipientProjectName: this.receiver?.name || "",
recipientProjectImage: this.receiver?.image || "",
recipientProjectHandleId: this.receiver?.handleId || "",
recipientDid: this.receiver?.did || "",
}
: {
giverProjectId: this.fromProjectId || "",
giverProjectName: this.giver?.name || "",
giverProjectImage: this.giver?.image || "",
giverProjectHandleId: this.giver?.handleId || "",
giverDid: this.giver?.did || "",
}),
fromProjectId: this.fromProjectId,
toProjectId: this.toProjectId,
showProjects: this.showProjects.toString(),
isFromProjectView: this.isFromProjectView.toString(),
};
}
/**
* Handle entity selection from EntityGrid
*/
handleEntitySelected(event: EntitySelectionEvent): void {
this.emitEntitySelected({
stepType: this.stepType,
...event,
});
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.emitCancel();
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
emitEntitySelected(
data: EntitySelectionEvent & { stepType: string },
): EntitySelectionEvent & { stepType: string } {
return data;
}
@Emit("cancel")
emitCancel(): void {
// No return value needed
}
}
</script>
<style scoped>
/* Component-specific styles if needed */
</style>

145
src/components/EntitySummaryButton.vue

@ -0,0 +1,145 @@
/** * EntitySummaryButton.vue - Displays selected entity with edit capability *
* Extracted from GiftedDialog.vue to handle entity summary display * in the gift
details step with edit functionality. * * @author Matthew Raymer */
<template>
<component
:is="editable ? 'button' : 'div'"
class="flex-1 flex items-center gap-2 bg-slate-100 border border-slate-300 rounded-md p-2"
@click="handleClick"
>
<!-- Entity Icon/Avatar -->
<div>
<template v-if="entityType === 'project'">
<ProjectIcon
v-if="entity?.handleId"
:entity-id="entity.handleId"
:icon-size="32"
:image-url="entity.image"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/>
</template>
<template v-else>
<EntityIcon
v-if="entity?.did"
:contact="entity"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-3xl"
/>
</template>
</div>
<!-- Entity Information -->
<div class="text-start min-w-0">
<p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">
{{ label }}
</p>
<h3 class="font-semibold truncate">
{{ entity?.name || "Unnamed" }}
</h3>
</div>
<!-- Edit/Lock Icon -->
<p class="ms-auto text-sm pe-1" :class="iconClasses">
<font-awesome
:icon="editable ? 'pen' : 'lock'"
:title="editable ? 'Change' : 'Can\'t be changed'"
/>
</p>
</component>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityIcon from "./EntityIcon.vue";
import ProjectIcon from "./ProjectIcon.vue";
import { Contact } from "../db/tables/contacts";
/**
* Entity interface for both person and project entities
*/
interface EntityData {
did?: string;
handleId?: string;
name?: string;
image?: string;
}
/**
* EntitySummaryButton - Displays selected entity with optional edit capability
*
* Features:
* - Shows entity avatar (person or project)
* - Displays entity name and role label
* - Handles editable vs locked states
* - Emits edit events when clicked and editable
* - Supports both person and project entity types
*/
@Component({
components: {
EntityIcon,
ProjectIcon,
},
})
export default class EntitySummaryButton extends Vue {
/** Entity data to display */
@Prop({ required: true })
entity!: EntityData | Contact | null;
/** Type of entity: 'person' or 'project' */
@Prop({ required: true })
entityType!: "person" | "project";
/** Display label for the entity role */
@Prop({ required: true })
label!: string;
/** Whether the entity can be edited */
@Prop({ default: true })
editable!: boolean;
/**
* Computed CSS classes for the edit/lock icon
*/
get iconClasses(): string {
return this.editable ? "text-blue-500" : "text-slate-400";
}
/**
* Handle click event - only emit if editable
*/
handleClick(): void {
if (this.editable) {
this.emitEditRequested({
entityType: this.entityType,
entity: this.entity,
});
}
}
// Emit methods using @Emit decorator
@Emit("edit-requested")
emitEditRequested(data: any): any {
return data;
}
}
</script>
<style scoped>
/* Ensure button styling is consistent */
button {
cursor: pointer;
}
button:hover {
background-color: #f1f5f9; /* hover:bg-slate-100 */
}
div {
cursor: default;
}
</style>

416
src/components/GiftDetailsStep.vue

@ -0,0 +1,416 @@
/** * GiftDetailsStep.vue - Gift details step component * * Extracted from
GiftedDialog.vue to handle the complete step 2 * gift details form interface
with entity summaries and validation. * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGift">
<!-- Entity Summary Buttons -->
<div class="grid grid-cols-2 gap-2 mb-4">
<!-- Giver Button -->
<EntitySummaryButton
:entity="giver"
:entity-type="giverEntityType"
:label="giverLabel"
:editable="canEditGiver"
@edit-requested="handleEditGiver"
/>
<!-- Recipient Button -->
<EntitySummaryButton
:entity="receiver"
:entity-type="recipientEntityType"
:label="recipientLabel"
:editable="canEditRecipient"
@edit-requested="handleEditRecipient"
/>
</div>
<!-- Gift Description Input -->
<input
v-model="localDescription"
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2 mb-4 placeholder:italic"
:placeholder="prompt || 'What was given?'"
@input="handleDescriptionChange"
/>
<!-- Amount Input and Unit Selection -->
<div class="flex mb-4">
<AmountInput
:value="localAmount"
:min="0"
input-id="inputGivenAmount"
@update:value="handleAmountChange"
/>
<select
v-model="localUnitCode"
class="flex-1 rounded border border-slate-400 ms-2 px-3 py-2"
@change="handleUnitCodeChange"
>
<option value="HUR">Hours</option>
<option value="USD">US $</option>
<option value="BTC">BTC</option>
<option value="BX">BX</option>
<option value="ETH">ETH</option>
</select>
</div>
<!-- Photo & More Options Link -->
<router-link
:to="photoOptionsRoute"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg mb-4"
>
Photo &amp; more options&hellip;
</router-link>
<!-- Sign & Send Info -->
<p class="text-center text-sm mb-4">
<b class="font-medium">Sign &amp; Send</b> to publish to the world
<font-awesome
icon="circle-info"
class="fa-fw text-blue-500 text-base cursor-pointer"
@click="handleExplainData"
/>
</p>
<!-- Conflict Warning -->
<div
v-if="hasConflict"
class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md"
>
<p class="text-red-700 text-sm text-center">
<font-awesome icon="exclamation-triangle" class="fa-fw mr-1" />
Cannot record: Same person selected as both giver and recipient
</p>
</div>
<!-- Action Buttons -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
:disabled="hasConflict"
:class="submitButtonClasses"
@click="handleSubmit"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch, Emit } from "vue-facing-decorator";
import EntitySummaryButton from "./EntitySummaryButton.vue";
import AmountInput from "./AmountInput.vue";
import { RouteLocationRaw } from "vue-router";
import { logger } from "@/utils/logger";
/**
* Entity data interface for giver/receiver
*/
interface EntityData {
did?: string;
handleId?: string;
name?: string;
image?: string;
}
/**
* GiftDetailsStep - Complete step 2 gift details form interface
*
* Features:
* - Entity summary display with edit capability
* - Gift description input with placeholder support
* - Amount input with increment/decrement controls
* - Unit code selection (HUR, USD, BTC, etc.)
* - Photo & more options navigation
* - Conflict detection and warning display
* - Form validation and submission
* - Cancel functionality
*/
@Component({
components: {
EntitySummaryButton,
AmountInput,
},
})
export default class GiftDetailsStep extends Vue {
/** Giver entity data */
@Prop({ required: true })
giver!: EntityData | null;
/** Receiver entity data */
@Prop({ required: true })
receiver!: EntityData | null;
/** Type of giver entity: 'person' or 'project' */
@Prop({ required: true })
giverEntityType!: "person" | "project";
/** Type of recipient entity: 'person' or 'project' */
@Prop({ required: true })
recipientEntityType!: "person" | "project";
/** Gift description */
@Prop({ default: "" })
description!: string;
/** Gift amount */
@Prop({ default: 0 })
amount!: number;
/** Unit code (HUR, USD, etc.) */
@Prop({ default: "HUR" })
unitCode!: string;
/** Input placeholder text */
@Prop({ default: "" })
prompt!: string;
/** Whether this is from a project view */
@Prop({ default: false })
isFromProjectView!: boolean;
/** Whether there's a conflict between giver and receiver */
@Prop({ default: false })
hasConflict!: boolean;
/** Offer ID for context */
@Prop({ default: "" })
offerId!: string;
/** Project ID for context (giver) */
@Prop({ default: "" })
fromProjectId!: string;
/** Project ID for context (recipient) */
@Prop({ default: "" })
toProjectId!: string;
/** Local reactive copies of props for v-model */
private localDescription: string = "";
private localAmount: number = 0;
private localUnitCode: string = "HUR";
/**
* Initialize local values from props
*/
mounted(): void {
this.localDescription = this.description;
this.localAmount = this.amount;
this.localUnitCode = this.unitCode;
}
/**
* Watch for external prop changes
*/
@Watch("description")
onDescriptionChange(newValue: string): void {
this.localDescription = newValue;
}
@Watch("amount")
onAmountChange(newValue: number): void {
this.localAmount = newValue;
}
@Watch("unitCode")
onUnitCodeChange(newValue: string): void {
this.localUnitCode = newValue;
}
/**
* Computed label for giver entity
*/
get giverLabel(): string {
return this.giverEntityType === "project"
? "Benefited from:"
: "Received from:";
}
/**
* Computed label for recipient entity
*/
get recipientLabel(): string {
return this.recipientEntityType === "project"
? "Given to project:"
: "Given to:";
}
/**
* Whether the giver can be edited
*/
get canEditGiver(): boolean {
return !(this.isFromProjectView && this.giverEntityType === "project");
}
/**
* Whether the recipient can be edited
*/
get canEditRecipient(): boolean {
return this.recipientEntityType === "person";
}
/**
* Computed CSS classes for submit button
*/
get submitButtonClasses(): string {
if (this.hasConflict) {
return "block w-full text-center text-md uppercase font-bold bg-gradient-to-b from-slate-300 to-slate-500 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-slate-400 px-1.5 py-2 rounded-lg cursor-not-allowed";
}
return "block w-full text-center text-md uppercase font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg";
}
/**
* Computed route for photo & more options
*/
get photoOptionsRoute(): RouteLocationRaw {
return {
name: "gifted-details",
query: {
amountInput: this.localAmount.toString(),
description: this.localDescription,
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.localUnitCode,
},
};
}
/**
* Handle description input changes
*/
handleDescriptionChange(): void {
this.emitUpdateDescription(this.localDescription);
}
/**
* Handle amount input changes
*/
handleAmountChange(newAmount: number): void {
logger.debug("[GiftDetailsStep] handleAmountChange() called", {
oldAmount: this.localAmount,
newAmount,
});
this.localAmount = newAmount;
this.emitUpdateAmount(newAmount);
}
/**
* Handle unit code selection changes
*/
handleUnitCodeChange(): void {
this.emitUpdateUnitCode(this.localUnitCode);
}
/**
* Handle giver edit request
*/
handleEditGiver(): void {
this.emitEditEntity({
entityType: "giver",
currentEntity: this.giver,
});
}
/**
* Handle recipient edit request
*/
handleEditRecipient(): void {
this.emitEditEntity({
entityType: "recipient",
currentEntity: this.receiver,
});
}
/**
* Handle explain data info click
*/
handleExplainData(): void {
this.emitExplainData();
}
/**
* Handle form submission
*/
handleSubmit(): void {
if (!this.hasConflict) {
this.emitSubmit({
description: this.localDescription,
amount: this.localAmount,
unitCode: this.localUnitCode,
});
}
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.emitCancel();
}
// Emit methods using @Emit decorator
@Emit("update:description")
emitUpdateDescription(description: string): string {
return description;
}
@Emit("update:amount")
emitUpdateAmount(amount: number): number {
logger.debug("[GiftDetailsStep] emitUpdateAmount() - emitting amount", {
amount,
});
return amount;
}
@Emit("update:unitCode")
emitUpdateUnitCode(unitCode: string): string {
return unitCode;
}
@Emit("edit-entity")
emitEditEntity(data: any): any {
return data;
}
@Emit("explain-data")
emitExplainData(): void {
// No return value needed
}
@Emit("submit")
emitSubmit(data: any): any {
return data;
}
@Emit("cancel")
emitCancel(): void {
// No return value needed
}
}
</script>
<style scoped>
/* Component-specific styles if needed */
</style>

523
src/components/GiftedDialog.vue

@ -1,113 +1,108 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">
{{ customTitle }}
</h1>
<input
v-model="description"
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
:placeholder="prompt || 'What was given?'"
<!-- 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"
@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 class="flex flex-row justify-center">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<font-awesome icon="chevron-left" />
</div>
<input
id="inputGivenAmount"
v-model="amountInput"
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<font-awesome icon="chevron-right" />
</div>
</div>
<div class="mt-4 flex justify-center">
<span>
<router-link
:to="{
name: 'gifted-details',
query: {
amountInput,
description,
giverDid: giver?.did,
giverName: giver?.name,
offerId,
fulfillsProjectId: toProjectId,
providerProjectId: fromProjectId,
recipientDid: receiver?.did,
recipientName: receiver?.name,
unitCode,
},
}"
class="text-blue-500"
>
Photo & more options ...
</router-link>
</span>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<font-awesome
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
/>
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { Vue, Component, Prop, Watch } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import {
createAndSubmitGive,
didInfo,
serverMessageForUser,
getHeaders,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
// Removed unused imports: db, retrieveSettingsForActiveAccount
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
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";
@Component
@Component({
components: {
EntityIcon,
ProjectIcon,
EntitySelectionStep,
GiftDetailsStep,
},
})
export default class GiftedDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@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> = [];
@ -118,6 +113,7 @@ export default class GiftedDialog extends Vue {
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 = "";
@ -127,6 +123,90 @@ export default class GiftedDialog extends Vue {
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,
@ -139,10 +219,17 @@ export default class GiftedDialog extends Vue {
this.giver = giver;
this.prompt = prompt || "";
this.receiver = receiver;
// if we show "given to user" selection, default checkbox to true
this.amountInput = "0";
this.callbackOnSuccess = callbackOnSuccess;
this.offerId = offerId || "";
this.firstStep = !giver;
this.stepType = "giver";
// Update entity types based on current props
this.updateEntityTypes();
// Update entity types based on current props
this.updateEntityTypes();
try {
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
@ -167,15 +254,27 @@ export default class GiftedDialog extends Vue {
this.allContacts,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
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(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
text:
err instanceof Error
? err.message
: "There was an error retrieving your settings.",
},
-1,
);
@ -217,6 +316,7 @@ export default class GiftedDialog extends Vue {
this.amountInput = "0";
this.prompt = "";
this.unitCode = "HUR";
this.firstStep = true;
}
async confirm() {
@ -259,6 +359,34 @@ export default class GiftedDialog extends Vue {
return;
}
// Check for person conflict
if (this.hasPersonConflict) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You cannot select the same person as both giver and recipient.",
},
3000,
);
return;
}
// Check for person conflict
if (this.hasPersonConflict) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You cannot select the same person as both giver and recipient.",
},
3000,
);
return;
}
this.close();
this.$notify(
{
@ -297,24 +425,56 @@ export default class GiftedDialog extends Vue {
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,
giverDid as string,
recipientDid as string,
fromDid,
toDid,
description,
amount,
unitCode,
this.toProjectId,
fulfillsProjectHandleId,
this.offerId,
false,
undefined,
this.fromProjectId,
providerPlanHandleId,
);
if (!result.success) {
const errorMessage = result.error;
const errorMessage = this.getGiveCreationErrorMessage(result);
logger.error("Error with give creation result:", result);
this.$notify(
{
@ -360,6 +520,19 @@ export default class GiftedDialog extends Vue {
// 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(
{
@ -371,6 +544,174 @@ export default class GiftedDialog extends Vue {
-1,
);
}
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(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to load projects",
},
3000,
);
}
}
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,
});
}
}
</script>

114
src/components/PersonCard.vue

@ -0,0 +1,114 @@
/** * PersonCard.vue - Individual person display component * * Extracted from
GiftedDialog.vue to handle person entity display * with selection states and
conflict detection. * * @author Matthew Raymer */
<template>
<li :class="cardClasses" @click="handleClick">
<div class="relative w-fit mx-auto">
<EntityIcon
v-if="person.did"
:contact="person"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-5xl mb-1"
/>
<!-- Time icon overlay for contacts -->
<div
v-if="person.did && showTimeIcon"
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
>
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
</div>
</div>
<h3 :class="nameClasses">
{{ person.name || person.did || "Unnamed" }}
</h3>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityIcon from "./EntityIcon.vue";
import { Contact } from "../db/tables/contacts";
/**
* PersonCard - Individual person display with selection capability
*
* Features:
* - Person avatar using EntityIcon
* - Selection states (selectable, conflicted, disabled)
* - Time icon overlay for contacts
* - Click event handling
* - Emits click events for parent handling
*/
@Component({
components: {
EntityIcon,
},
})
export default class PersonCard extends Vue {
/** Contact data to display */
@Prop({ required: true })
person!: Contact;
/** Whether this person can be selected */
@Prop({ default: true })
selectable!: boolean;
/** Whether this person would create a conflict if selected */
@Prop({ default: false })
conflicted!: boolean;
/** Whether to show time icon overlay */
@Prop({ default: false })
showTimeIcon!: boolean;
/**
* Computed CSS classes for the card
*/
get cardClasses(): string {
if (!this.selectable || this.conflicted) {
return "opacity-50 cursor-not-allowed";
}
return "cursor-pointer hover:bg-slate-50";
}
/**
* Computed CSS classes for the person name
*/
get nameClasses(): string {
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
}
return baseClasses;
}
/**
* Handle card click - only emit if selectable and not conflicted
*/
handleClick(): void {
if (this.selectable && !this.conflicted) {
this.emitPersonSelected(this.person);
}
}
// Emit methods using @Emit decorator
@Emit("person-selected")
emitPersonSelected(person: Contact): Contact {
return person;
}
}
</script>
<style scoped>
/* Component-specific styles if needed */
</style>

96
src/components/ProjectCard.vue

@ -0,0 +1,96 @@
/** * ProjectCard.vue - Individual project display component * * Extracted from
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer" @click="handleClick">
<div class="relative w-fit mx-auto">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
</div>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ project.name }}
</h3>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ issuerDisplayName }}
</div>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import ProjectIcon from "./ProjectIcon.vue";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
import { didInfo } from "../libs/endorserServer";
/**
* ProjectCard - Displays a project entity with selection capability
*
* Features:
* - Shows project icon using ProjectIcon
* - Displays project name and issuer information
* - Handles click events for selection
* - Shows issuer name using didInfo utility
*/
@Component({
components: {
ProjectIcon,
},
})
export default class ProjectCard extends Vue {
/** Project entity to display */
@Prop({ required: true })
project!: PlanData;
/** Active user's DID for issuer display */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs for issuer display */
@Prop({ required: true })
allMyDids!: string[];
/** All contacts for issuer display */
@Prop({ required: true })
allContacts!: Contact[];
/**
* Computed display name for the project issuer
*/
get issuerDisplayName(): string {
return didInfo(
this.project.issuerDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
/**
* Handle card click - emit project selection
*/
handleClick(): void {
this.emitProjectSelected(this.project);
}
// Emit methods using @Emit decorator
@Emit("project-selected")
emitProjectSelected(project: PlanData): PlanData {
return project;
}
}
</script>
<style scoped>
/* Component-specific styles if needed */
</style>

66
src/components/ShowAllCard.vue

@ -0,0 +1,66 @@
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
entity types. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer">
<router-link :to="navigationRoute" class="block text-center">
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
<h3
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
Show All
</h3>
</router-link>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { RouteLocationRaw } from "vue-router";
/**
* ShowAllCard - Displays "Show All" navigation for entity grids
*
* Features:
* - Provides navigation to full entity listings
* - Supports different routes based on entity type
* - Maintains context through query parameters
* - Consistent visual styling with other cards
*/
@Component
export default class ShowAllCard extends Vue {
/** Type of entities being shown */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Route name to navigate to */
@Prop({ required: true })
routeName!: string;
/** Query parameters to pass to the route */
@Prop({ default: () => ({}) })
queryParams!: Record<string, string>;
/**
* Computed navigation route with query parameters
*/
get navigationRoute(): RouteLocationRaw {
return {
name: this.routeName,
query: this.queryParams,
};
}
}
</script>
<style scoped>
/* Ensure router-link styling is consistent */
a {
text-decoration: none;
}
a:hover .fa-circle-right {
transform: scale(1.1);
transition: transform 0.2s ease;
}
</style>

135
src/components/SpecialEntityCard.vue

@ -0,0 +1,135 @@
/** * SpecialEntityCard.vue - Special entity display component * * Extracted
from GiftedDialog.vue to handle special entities like "You" * and "Unnamed" with
conflict detection and selection capability. * * @author Matthew Raymer */
<template>
<li :class="cardClasses" @click="handleClick">
<font-awesome :icon="icon" :class="iconClasses" />
<h3 :class="nameClasses">
{{ label }}
</h3>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { Emit } from "vue-facing-decorator";
/**
* SpecialEntityCard - Displays special entities with selection capability
*
* Features:
* - Displays special entities like "You" and "Unnamed"
* - Shows appropriate FontAwesome icons
* - Handles conflict states and selection
* - Emits selection events with entity data
* - Configurable styling based on entity type
*/
@Component({
emits: ["entity-selected"],
})
export default class SpecialEntityCard extends Vue {
/** Type of special entity */
@Prop({ required: true })
entityType!: "you" | "unnamed";
/** Display label for the entity */
@Prop({ required: true })
label!: string;
/** FontAwesome icon name */
@Prop({ required: true })
icon!: string;
/** Whether this entity can be selected */
@Prop({ default: true })
selectable!: boolean;
/** Whether selecting this entity would create a conflict */
@Prop({ default: false })
conflicted!: boolean;
/** Entity data to emit when selected */
@Prop({ required: true })
entityData!: { did?: string; name: string };
/**
* Computed CSS classes for the card container
*/
get cardClasses(): string {
const baseClasses = "block";
if (!this.selectable || this.conflicted) {
return `${baseClasses} cursor-not-allowed opacity-50`;
}
return `${baseClasses} cursor-pointer`;
}
/**
* Computed CSS classes for the icon
*/
get iconClasses(): string {
const baseClasses = "text-5xl mb-1";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
}
// Different colors for different entity types
switch (this.entityType) {
case "you":
return `${baseClasses} text-blue-500`;
case "unnamed":
return `${baseClasses} text-slate-400`;
default:
return `${baseClasses} text-slate-400`;
}
}
/**
* Computed CSS classes for the entity name/label
*/
get nameClasses(): string {
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
}
// Different colors for different entity types
switch (this.entityType) {
case "you":
return `${baseClasses} text-blue-500`;
case "unnamed":
return `${baseClasses} text-slate-500 italic`;
default:
return `${baseClasses} text-slate-500`;
}
}
/**
* Handle card click - only emit if selectable and not conflicted
*/
handleClick(): void {
if (this.selectable && !this.conflicted) {
this.emitEntitySelected({
type: "special",
entityType: this.entityType,
data: this.entityData,
});
}
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
emitEntitySelected(data: any): any {
return data;
}
}
</script>
<style scoped>
/* Component-specific styles if needed */
</style>

4
src/constants/app.ts

@ -15,11 +15,11 @@ export enum AppString {
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "https://image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "http://127.0.0.1:3001",
PROD_PARTNER_API_SERVER = "https://partner-api.endorser.ch",
TEST_PARTNER_API_SERVER = "https://test-partner-api.endorser.ch",
LOCAL_PARTNER_API_SERVER = "https://partner-api.endorser.ch",
LOCAL_PARTNER_API_SERVER = "http://127.0.0.1:3002",
PROD_PUSH_SERVER = "https://timesafari.app",
TEST1_PUSH_SERVER = "https://test.timesafari.app",

6
src/libs/fontawesome.ts

@ -29,6 +29,7 @@ import {
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleRight,
faCircleUser,
faClock,
faCoins,
@ -60,6 +61,7 @@ import {
faLightbulb,
faLink,
faLocationDot,
faLock,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
@ -79,6 +81,7 @@ import {
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faThumbtack,
faTrashCan,
faTriangleExclamation,
faUser,
@ -111,6 +114,7 @@ library.add(
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleRight,
faCircleUser,
faClock,
faCoins,
@ -142,6 +146,7 @@ library.add(
faLightbulb,
faLink,
faLocationDot,
faLock,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
@ -161,6 +166,7 @@ library.add(
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faThumbtack,
faTrashCan,
faTriangleExclamation,
faUser,

2
src/libs/util.ts

@ -39,6 +39,8 @@ import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
export interface GiverReceiverInputInfo {
did?: string;
name?: string;
image?: string;
handleId?: string;
}
export enum OnboardPage {

6
src/services/indexedDBMigrationService.ts

@ -11,10 +11,8 @@
* for safe migration of data between the two storage systems.
*
* Usage:
* 1. Enable Dexie temporarily by setting USE_DEXIE_DB = true in constants/app.ts
* 2. Use compareDatabases() to see differences between databases
* 3. Use migrateContacts() and/or migrateSettings() to transfer data
* 4. Disable Dexie again after migration is complete
* 1. Use compareDatabases() to see differences between databases
* 2. Use migrateContacts() and/or migrateSettings() to transfer data
*
* @author Matthew Raymer
* @version 1.0.0

199
src/views/ContactGiftingView.vue

@ -4,14 +4,14 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<h1 class="text-2xl text-center font-semibold relative px-7">
<!-- Back -->
<router-link
:to="{ name: 'home' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
Given by...
{{ stepType === "giver" ? "Given by..." : "Given to..." }}
</h1>
</div>
@ -19,19 +19,18 @@
<ul class="border-t border-slate-300">
<li class="border-b border-slate-300 py-3">
<h2 class="text-base flex gap-4 items-center">
<span class="grow">
<img
src="../assets/blank-square.svg"
width="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
<span class="grow flex gap-2 items-center font-medium">
<font-awesome
icon="circle-question"
class="text-slate-400 text-4xl"
/>
Unnamed/Unknown
<span class="italic text-slate-400">(Unnamed/Unknown)</span>
</span>
<span class="text-right">
<button
type="button"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="openDialog()"
@click="openDialog('Unnamed')"
>
<font-awesome icon="gift" class="fa-fw"></font-awesome>
</button>
@ -44,13 +43,14 @@
class="border-b border-slate-300 py-3"
>
<h2 class="text-base flex gap-4 items-center">
<span class="grow font-semibold">
<span class="grow flex gap-2 items-center font-medium">
<EntityIcon
:contact="contact"
:icon-size="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
:icon-size="34"
class="inline-block align-middle border border-slate-300 rounded-full overflow-hidden"
/>
{{ contact.name || "(no name)" }}
<span v-if="contact.name">{{ contact.name }}</span>
<span v-else class="italic text-slate-400">(No name)</span>
</span>
<span class="text-right">
<button
@ -65,7 +65,13 @@
</li>
</ul>
<GiftedDialog ref="customDialog" :to-project-id="projectId" />
<GiftedDialog
ref="customDialog"
:from-project-id="fromProjectId"
:to-project-id="toProjectId"
:show-projects="showProjects"
:is-from-project-view="isFromProjectView"
/>
</section>
</template>
@ -96,6 +102,24 @@ export default class ContactGiftingView extends Vue {
description = "";
projectId = "";
prompt = "";
recipientProjectName = "";
recipientProjectImage = "";
recipientProjectHandleId = "";
// New context parameters
stepType = "giver";
giverEntityType = "person" as "person" | "project";
recipientEntityType = "person" as "person" | "project";
giverProjectId = "";
giverProjectName = "";
giverProjectImage = "";
giverProjectHandleId = "";
giverDid = "";
recipientDid = "";
fromProjectId = "";
toProjectId = "";
showProjects = false;
isFromProjectView = false;
async created() {
try {
@ -111,9 +135,41 @@ export default class ContactGiftingView extends Vue {
dbAllContacts,
) as unknown as Contact[];
this.projectId = (this.$route.query["projectId"] as string) || "";
this.projectId =
(this.$route.query["recipientProjectId"] as string) || "";
this.recipientProjectName =
(this.$route.query["recipientProjectName"] as string) || "";
this.recipientProjectImage =
(this.$route.query["recipientProjectImage"] as string) || "";
this.recipientProjectHandleId =
(this.$route.query["recipientProjectHandleId"] as string) || "";
this.prompt = (this.$route.query["prompt"] as string) ?? this.prompt;
// Read new context parameters
this.stepType = (this.$route.query["stepType"] as string) || "giver";
this.giverEntityType =
(this.$route.query["giverEntityType"] as "person" | "project") ||
"person";
this.recipientEntityType =
(this.$route.query["recipientEntityType"] as "person" | "project") ||
"person";
this.giverProjectId =
(this.$route.query["giverProjectId"] as string) || "";
this.giverProjectName =
(this.$route.query["giverProjectName"] as string) || "";
this.giverProjectImage =
(this.$route.query["giverProjectImage"] as string) || "";
this.giverProjectHandleId =
(this.$route.query["giverProjectHandleId"] as string) || "";
this.giverDid = (this.$route.query["giverDid"] as string) || "";
this.recipientDid = (this.$route.query["recipientDid"] as string) || "";
this.fromProjectId = (this.$route.query["fromProjectId"] as string) || "";
this.toProjectId = (this.$route.query["toProjectId"] as string) || "";
this.showProjects =
(this.$route.query["showProjects"] as string) === "true";
this.isFromProjectView =
(this.$route.query["isFromProjectView"] as string) === "true";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error("Error retrieving settings & contacts:", err);
@ -131,17 +187,108 @@ export default class ContactGiftingView extends Vue {
}
}
openDialog(giver?: GiverReceiverInputInfo) {
const recipient = this.projectId
? undefined
: { did: this.activeDid, name: "you" };
(this.$refs.customDialog as GiftedDialog).open(
giver,
recipient,
undefined,
"Given by " + (giver?.name || "someone not named"),
this.prompt,
);
openDialog(contact?: GiverReceiverInputInfo | "Unnamed") {
if (contact === "Unnamed") {
// Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
let recipient: GiverReceiverInputInfo;
let giver: GiverReceiverInputInfo | undefined;
if (this.stepType === "giver") {
// We're selecting a giver, so recipient is either a project or the current user
if (this.recipientEntityType === "project") {
recipient = {
did: this.recipientProjectHandleId,
name: this.recipientProjectName,
image: this.recipientProjectImage,
handleId: this.recipientProjectHandleId,
};
} else {
recipient = { did: this.activeDid, name: "You" };
}
giver = undefined; // Will be set to "Unnamed" in GiftedDialog
} else {
// We're selecting a recipient, so recipient is "Unnamed" and giver is preserved from context
recipient = { did: "", name: "Unnamed" };
// Preserve the existing giver from the context
if (this.giverEntityType === "project") {
giver = {
// no did, because it's a project
name: this.giverProjectName,
image: this.giverProjectImage,
handleId: this.giverProjectHandleId,
};
} else if (this.giverDid) {
giver = {
did: this.giverDid,
name: this.giverProjectName || "Someone",
};
} else {
giver = { did: this.activeDid, name: "You" };
}
}
(this.$refs.customDialog as GiftedDialog).open(
giver,
recipient,
undefined,
this.stepType === "giver" ? "Given by Unnamed" : "Given to Unnamed",
this.prompt,
);
// Immediately select "Unnamed" and move to Step 2
(this.$refs.customDialog as GiftedDialog).selectGiver();
} else {
// Regular case: contact is a GiverReceiverInputInfo
let giver: GiverReceiverInputInfo;
let recipient: GiverReceiverInputInfo;
if (this.stepType === "giver") {
// We're selecting a giver, so the contact becomes the giver
giver = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
// Recipient is either a project or the current user
if (this.recipientEntityType === "project") {
recipient = {
did: this.recipientProjectHandleId,
name: this.recipientProjectName,
image: this.recipientProjectImage,
handleId: this.recipientProjectHandleId,
};
} else {
recipient = { did: this.activeDid, name: "You" };
}
} else {
// We're selecting a recipient, so the contact becomes the recipient
recipient = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
// Preserve the existing giver from the context
if (this.giverEntityType === "project") {
giver = {
did: this.giverProjectHandleId,
name: this.giverProjectName,
image: this.giverProjectImage,
handleId: this.giverProjectHandleId,
};
} else if (this.giverDid) {
giver = {
did: this.giverDid,
name: this.giverProjectName || "Someone",
};
} else {
giver = { did: this.activeDid, name: "You" };
}
}
(this.$refs.customDialog as GiftedDialog).open(
giver,
recipient,
undefined,
this.stepType === "giver"
? "Given by " + (contact?.name || "someone not named")
: "Given to " + (contact?.name || "someone not named"),
this.prompt,
);
}
}
}
</script>

176
src/views/GiftedDetailsView.vue

@ -4,87 +4,91 @@
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div
v-if="!hideBackButton"
class="text-lg text-center font-light relative px-7"
>
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-2xl text-center font-semibold relative px-7 mb-2">
<!-- Back -->
<div
v-if="!hideBackButton"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</div>
What Was Given
</h1>
<h2 class="text-lg font-normal text-center overflow-hidden">
<div class="truncate">
From
{{
providedByProject
? providerProjectName
: providedByGiver
? giverName
: "someone not named"
}}
</div>
<div class="truncate">
to
{{
givenToProject
? fulfillsProjectName
: givenToRecipient
? recipientName
: "someone not named"
}}
</div>
</h2>
</div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
<h1 class="text-xl font-bold text-center mb-4">
<span>
From
{{
providedByProject
? providerProjectName
: providedByGiver
? giverName
: "someone not named"
}}
</span>
<br />
<span>
to
{{
givenToProject
? fulfillsProjectName
: givenToRecipient
? recipientName
: "someone not named"
}}</span
>
</h1>
<textarea
v-model="description"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received"
/>
<div class="flex flex-row justify-center">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
<div class="flex mb-4">
<button
class="rounded-s border border-e-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<font-awesome icon="chevron-left" />
</div>
</button>
<input
id="inputGivenAmount"
v-model="amountInput"
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
class="flex-1 border border-e-0 border-slate-400 px-2 py-2 text-center w-[1px]"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
<button
class="rounded-e border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<font-awesome icon="chevron-right" />
</div>
</button>
<select
v-model="unitCode"
class="flex-1 rounded border border-slate-400 ms-2 px-3 py-2"
>
<option
v-for="(displayName, code) in unitOptions"
:key="code"
:value="code"
>
{{ displayName }}
</option>
</select>
</div>
<div class="flex justify-center mt-4" data-testId="imagery">
<span v-if="imageUrl" class="flex justify-between">
<span v-if="imageUrl" class="flex items-end gap-3">
<a :href="imageUrl" target="_blank">
<img
:src="libsUtil.transformImageUrlForCors(imageUrl)"
class="h-24 rounded-xl"
/>
<img :src="imageUrl" class="h-36 rounded-lg" />
</a>
<font-awesome
icon="trash-can"
class="text-red-500 fa-fw ml-8 mt-10"
class="text-red-500 fa-fw cursor-pointer"
@click="confirmDeleteImage"
/>
</span>
@ -98,22 +102,24 @@
</div>
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<div class="mt-4 flex justify-between gap-2">
<div class="mt-4 sm:flex justify-between gap-2">
<!-- First Column for Giver -->
<div class="flex-grow border border-slate-400 p-2 rounded-md">
<div class="flex">
<div
class="sm:flex-grow sm:w-1/2 border border-slate-400 p-2 rounded-md overflow-hidden"
>
<div class="flex items-center">
<input
v-if="giverDid && !providedByProject"
v-model="providedByGiver"
type="checkbox"
class="h-6 w-6 mr-2"
class="flex-shrink-0 h-6 w-6 mr-2"
/>
<font-awesome
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
<label class="text-sm truncate">
{{
giverDid
? "This was provided by " + giverName + "."
@ -123,24 +129,24 @@
<font-awesome
v-if="!giverDid || providedByProject"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
class="text-base cursor-pointer bg-white text-slate-500 ms-1"
@click="notifyUserOfGiver()"
/>
</div>
<div class="flex">
<div class="flex items-center">
<input
v-if="providerProjectId && !providedByGiver"
v-model="providedByProject"
type="checkbox"
class="h-6 w-6 mr-2"
class="flex-shrink-0 h-6 w-6 mr-2"
/>
<font-awesome
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
<label class="text-sm truncate">
{{
providerProjectId
? "This was provided by " + providerProjectName + "."
@ -150,31 +156,36 @@
<font-awesome
v-if="!providerProjectId || providedByGiver"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
class="text-base cursor-pointer bg-white text-slate-500 ms-1"
@click="notifyUserOfProvidingProject()"
/>
</div>
</div>
<div class="flex-shrink flex justify-center items-center">
<font-awesome icon="arrow-right" class="fa-fw h-7" />
<div class="sm:flex-shrink flex justify-center items-center my-1 sm:my-0">
<font-awesome
icon="arrow-right"
class="fa-fw h-7 rotate-90 sm:rotate-0"
/>
</div>
<!-- Third Column for Recipient -->
<div class="flex-grow border border-slate-400 p-2 rounded-md">
<div class="flex">
<div
class="sm:flex-grow sm:w-1/2 border border-slate-400 p-2 rounded-md overflow-hidden"
>
<div class="flex items-center">
<input
v-if="recipientDid && !givenToProject"
v-model="givenToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
class="flex-shrink-0 h-6 w-6 mr-2"
/>
<font-awesome
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
<label class="text-sm truncate">
{{
recipientDid
? "This was given to " + recipientName + "."
@ -184,24 +195,24 @@
<font-awesome
v-if="!recipientDid || givenToProject"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
class="text-base cursor-pointer bg-white text-slate-500 ms-1"
@click="notifyUserOfRecipient()"
/>
</div>
<div class="flex">
<div class="flex items-center">
<input
v-if="fulfillsProjectId && !givenToRecipient"
v-model="givenToProject"
type="checkbox"
class="h-6 w-6 mr-2"
class="flex-shrink-0 h-6 w-6 mr-2"
/>
<font-awesome
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
<label class="text-sm truncate">
{{
fulfillsProjectId
? "This was given to " + fulfillsProjectName + ". "
@ -211,7 +222,7 @@
<font-awesome
v-if="!fulfillsProjectId || givenToRecipient"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
class="text-base cursor-pointer bg-white text-slate-500 ms-1"
@click="notifyUserFulfillsProject()"
/>
</div>
@ -232,11 +243,11 @@
</router-link>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<p class="text-center text-sm my-4">
<b class="font-medium">Sign &amp; Send</b> to publish to the world
<font-awesome
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
class="fa-fw text-blue-500 text-base cursor-pointer"
@click="explainData()"
/>
</p>
@ -902,5 +913,10 @@ export default class GiftedDetails extends Vue {
7000,
);
}
// Computed property to get unit options
get unitOptions() {
return this.libsUtil.UNIT_SHORT;
}
}
</script>

188
src/views/HomeView.vue

@ -118,101 +118,73 @@ Raymer * @version 1.0.0 */
</div>
<div v-else id="sectionRecordSomethingGiven">
<!-- !isCreatingIdentifier && isRegistered -->
<!-- show the actions for recognizing a give -->
<div class="flex">
<h2 class="text-xl font-bold">What have you seen someone do?</h2>
<button
class="ml-2 block text-xs text-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 rounded-md"
@click="openGiftedPrompts()"
>
<font-awesome icon="lightbulb" class="fa-fw" />
</button>
</div>
<ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mt-4"
>
<li @click="openDialog()">
<img
src="../assets/blank-square.svg"
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
<h2 class="text-xl font-bold">Record something given by:</h2>
<button
class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
@click="openGiftedPrompts()"
>
Unnamed/Unknown
</h3>
</li>
<li v-if="allContacts.length === 0" class="text-sm">
(Add friends to see more people worthy of recognition.)
</li>
<li
v-for="contact in allContacts.slice(0, 6)"
:key="contact.did"
@click="openDialog(contact)"
>
<EntityIcon
:contact="contact"
:icon-size="64"
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
<font-awesome
icon="lightbulb"
class="block text-center w-[1em]"
/>
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openDialogPerson()"
>
{{ contact.name || contact.did }}
</h3>
</li>
<li>
<router-link
v-if="allContacts.length >= 6"
:to="{ name: 'contact-gift' }"
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
<font-awesome icon="user" />
Person
</button>
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openProjectDialog()"
>
... or someone else...
</router-link>
</li>
</ul>
<font-awesome icon="folder-open" />
Project
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<GiftedDialog ref="customDialog" />
<GiftedDialog ref="customDialog" :show-projects="showProjectsDialog" />
<GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" />
<div class="relative">
<button
v-if="isRegistered"
class="absolute right-6 bottom-0 transform translate-y-1/2 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
@click="openDialog()"
>
<font-awesome icon="plus" class="fa-fw" />
</button>
</div>
<!-- Results List -->
<div class="mt-4 mb-4">
<div class="flex items-center mb-4">
<h2 class="text-xl font-bold flex items-center gap-4">
Latest Activity
<button
v-if="resultsAreFiltered()"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
@click="openFeedFilters()"
>
<font-awesome icon="filter" class="fa-fw" />
</button>
<button
v-else
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
@click="openFeedFilters()"
>
<font-awesome icon="filter" class="fa-fw" />
</button>
</h2>
<div class="flex gap-2 items-center mb-3">
<h2 class="text-xl font-bold">Latest Activity</h2>
<button
v-if="resultsAreFiltered()"
class="block ms-auto text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
@click="openFeedFilters()"
>
<font-awesome
icon="filter"
class="block text-center w-[1em] translate-y-[0.05em]"
/>
</button>
<button
v-else
class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
@click="openFeedFilters()"
>
<font-awesome
icon="filter"
class="block text-center w-[1em] translate-y-[0.05em]"
/>
</button>
</div>
<div
@ -467,6 +439,7 @@ export default class HomeView extends Vue {
selectedImageData: Blob | null = null;
isImageViewerOpen = false;
imageCache: Map<string, Blob | null> = new Map();
showProjectsDialog = false;
/**
* Initializes the component on mount
@ -1626,17 +1599,33 @@ export default class HomeView extends Vue {
* @param giver Optional contact info for giver
* @param description Optional gift description
*/
openDialog(giver?: GiverReceiverInputInfo, description?: string) {
(this.$refs.customDialog as GiftedDialog).open(
giver,
{
did: this.activeDid,
name: "you",
} as GiverReceiverInputInfo,
undefined,
"Given by " + (giver?.name || "someone not named"),
description,
);
openDialog(giver?: GiverReceiverInputInfo | "Unnamed", description?: string) {
if (giver === "Unnamed") {
// Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
(this.$refs.customDialog as GiftedDialog).open(
undefined,
{
did: this.activeDid,
name: "You",
} as GiverReceiverInputInfo,
undefined,
"Given by Unnamed",
description,
);
// Immediately select "Unnamed" and move to Step 2
(this.$refs.customDialog as GiftedDialog).selectGiver();
} else {
(this.$refs.customDialog as GiftedDialog).open(
giver,
{
did: this.activeDid,
name: "You",
} as GiverReceiverInputInfo,
undefined,
"Given by " + (giver?.name || "someone not named"),
description,
);
}
}
/**
@ -1870,5 +1859,18 @@ export default class HomeView extends Vue {
this.$router.push({ name: "contact-qr" });
}
}
openDialogPerson(
giver?: GiverReceiverInputInfo | "Unnamed",
description?: string,
) {
this.showProjectsDialog = false;
this.openDialog(giver, description);
}
openProjectDialog() {
this.showProjectsDialog = true;
(this.$refs.customDialog as any).open();
}
}
</script>

118
src/views/ProjectViewView.vue

@ -214,63 +214,11 @@
</div>
</div>
<div v-if="activeDid && isRegistered">
<div class="text-center">
<p class="mt-2 mt-4 text-center">Record a contribution from:</p>
</div>
<ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5 mt-2"
>
<li @click="openGiftDialogToProject({ name: 'you', did: activeDid })">
<font-awesome
icon="hand"
class="fa-fw text-blue-500 text-5xl cursor-pointer"
/>
<h3
class="mt-5 text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
You
</h3>
</li>
<li @click="openGiftDialogToProject()">
<img
src="../assets/blank-square.svg"
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
Unnamed/Unknown
</h3>
</li>
<li
v-for="contact in allContacts.slice(0, 5)"
:key="contact.did"
@click="openGiftDialogToProject(contact)"
>
<EntityIcon
:contact="contact"
:icon-size="64"
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
{{ contact.name || "(no name)" }}
</h3>
</li>
<li>
<span
v-if="allContacts.length >= 5"
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
@click="onClickAllContactsGifting()"
>
... or someone else...
</span>
</li>
</ul>
</div>
<GiftedDialog ref="giveDialogToThis" :to-project-id="projectId" />
<GiftedDialog
ref="giveDialogToThis"
:to-project-id="projectId"
:is-from-project-view="true"
/>
<!-- Offers & Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
@ -536,7 +484,12 @@
</button>
</div>
</div>
<GiftedDialog ref="giveDialogFromThis" :from-project-id="projectId" />
<GiftedDialog
ref="giveDialogFromThis"
:from-project-id="projectId"
:show-projects="true"
:is-from-project-view="true"
/>
<h3 class="text-lg font-bold mb-3 mt-4">
Benefitted From This Project
@ -1263,21 +1216,52 @@ export default class ProjectViewView extends Vue {
);
}
openGiftDialogToProject(contact?: libsUtil.GiverReceiverInputInfo) {
(this.$refs.giveDialogToThis as GiftedDialog).open(
contact,
undefined,
undefined,
(contact?.name || "Someone not named") + ` gave to this project`,
);
openGiftDialogToProject(
contact?: libsUtil.GiverReceiverInputInfo | "Unnamed",
) {
if (contact === "Unnamed") {
// Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
(this.$refs.giveDialogToThis as GiftedDialog).open(
undefined,
undefined,
undefined,
"Given by Unnamed to this project",
);
// Immediately select "Unnamed" and move to Step 2
(this.$refs.giveDialogToThis as GiftedDialog).selectGiver();
} else {
// Open straight to Step 2 with current user as giver and current project as recipient
(this.$refs.giveDialogToThis as GiftedDialog).open(
{
did: this.activeDid,
name: "You",
},
{
did: this.issuer,
name: this.name,
handleId: this.projectId,
image: this.imageUrl,
},
undefined,
`Given to ${this.name}`,
);
}
}
openGiftDialogFromProject() {
// Set the project as giver and the current user as recipient
(this.$refs.giveDialogFromThis as GiftedDialog).open(
undefined,
{
did: undefined,
name: this.name,
handleId: this.projectId,
image: this.imageUrl,
},
{ did: this.activeDid, name: "You" },
undefined,
`This project gave to you`,
`${this.name} gave to you`,
undefined,
undefined,
);
}

Loading…
Cancel
Save