forked from trent_larson/crowd-funder-for-time-pwa
Fix TypeScript any types and remove deprecated Dexie code
- Replace Record<string, any> with Record<string, string> for query params - Fix error handling from catch(err: any) to catch(err: unknown) - Add EntityData interface for proper entity typing - Update EntitySelectionEvent interface with union types - Remove USE_DEXIE_DB conditionals and unused import - Clean up database service calls removing Dexie fallbacks Resolves 9 TypeScript any type warnings, improves type safety across entity selection components, and removes deprecated database migration code.
This commit is contained in:
226
src/components/AmountInput.vue
Normal file
226
src/components/AmountInput.vue
Normal file
@@ -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
Normal file
264
src/components/EntityGrid.vue
Normal file
@@ -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
Normal file
254
src/components/EntitySelectionStep.vue
Normal file
@@ -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
Normal file
145
src/components/EntitySummaryButton.vue
Normal file
@@ -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
Normal file
416
src/components/GiftDetailsStep.vue
Normal file
@@ -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 & more options…
|
||||
</router-link>
|
||||
|
||||
<!-- Sign & Send Info -->
|
||||
<p class="text-center text-sm mb-4">
|
||||
<b class="font-medium">Sign & 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 & 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>
|
||||
@@ -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="currentStep === 1"
|
||||
: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="currentStep === 2"
|
||||
: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 & 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";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
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> = [];
|
||||
@@ -124,9 +119,94 @@ export default class GiftedDialog extends Vue {
|
||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||
unitCode = "HUR";
|
||||
visible = false;
|
||||
currentStep = 1;
|
||||
|
||||
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,13 +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.currentStep = giver ? 2 : 1;
|
||||
this.stepType = "giver";
|
||||
|
||||
// Update entity types based on current props
|
||||
this.updateEntityTypes();
|
||||
|
||||
try {
|
||||
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
@@ -167,15 +251,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 +313,7 @@ export default class GiftedDialog extends Vue {
|
||||
this.amountInput = "0";
|
||||
this.prompt = "";
|
||||
this.unitCode = "HUR";
|
||||
this.currentStep = 1;
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
@@ -259,6 +356,20 @@ 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;
|
||||
}
|
||||
|
||||
this.close();
|
||||
this.$notify(
|
||||
{
|
||||
@@ -297,24 +408,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 +503,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 +527,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.currentStep = 2;
|
||||
}
|
||||
|
||||
goBackToStep1(step: string) {
|
||||
this.stepType = step;
|
||||
this.currentStep = 1;
|
||||
}
|
||||
|
||||
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.currentStep = 2;
|
||||
}
|
||||
|
||||
selectRecipient(contact?: Contact) {
|
||||
if (contact) {
|
||||
this.receiver = {
|
||||
did: contact.did,
|
||||
name: contact.name || contact.did,
|
||||
};
|
||||
} else {
|
||||
this.receiver = {
|
||||
did: "",
|
||||
name: "Unnamed",
|
||||
};
|
||||
}
|
||||
this.currentStep = 2;
|
||||
}
|
||||
|
||||
selectRecipientProject(project: PlanData) {
|
||||
this.receiver = {
|
||||
did: project.handleId,
|
||||
name: project.name,
|
||||
image: project.image,
|
||||
handleId: project.handleId,
|
||||
};
|
||||
this.currentStep = 2;
|
||||
}
|
||||
|
||||
// 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
Normal file
114
src/components/PersonCard.vue
Normal file
@@ -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
Normal file
96
src/components/ProjectCard.vue
Normal file
@@ -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
Normal file
66
src/components/ShowAllCard.vue
Normal file
@@ -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
Normal file
135
src/components/SpecialEntityCard.vue
Normal file
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faThumbtack,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
@@ -111,6 +113,7 @@ library.add(
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
@@ -161,6 +164,7 @@ library.add(
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faThumbtack,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,62 +118,40 @@ Raymer * @version 1.0.0 */
|
||||
</div>
|
||||
|
||||
<div v-else id="sectionRecordSomethingGiven">
|
||||
<!-- !isCreatingIdentifier && isRegistered -->
|
||||
<!-- 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()"
|
||||
>
|
||||
<font-awesome
|
||||
icon="lightbulb"
|
||||
class="block text-center w-[1em]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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 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()"
|
||||
>
|
||||
<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()"
|
||||
>
|
||||
<font-awesome icon="folder-open" />
|
||||
Project
|
||||
</button>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
... or someone else...
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user