You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

787 lines
24 KiB

<template>
<QuickNav selected="Projects" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Your Project Ideas
</h1>
<OnboardingDialog ref="onboardingDialog" />
<!-- Result Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300 mt-8">
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li>
<a
href="#"
:class="computedOfferTabClassNames()"
@click="
offers = [];
projects = [];
showOffers = true;
showProjects = false;
loadOffers();
"
>
Offers
</a>
</li>
<li>
<a
href="#"
:class="computedProjectTabClassNames()"
@click="
offers = [];
projects = [];
showOffers = false;
showProjects = true;
loadProjects();
"
>
Projects
</a>
</li>
</ul>
</div>
<!-- Quick Search -->
<!--
<div id="QuickSearch" class="mb-4 flex">
<input
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
/>
<button
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
>
<font-awesome icon="magnifying-glass" class="fa-fw"></font-awesome>
</button>
</div>
-->
<!-- New Project -->
<button
v-if="isRegistered && showProjects"
:class="newProjectButtonClasses"
@click="onClickNewProject()"
>
<font-awesome icon="plus" class="fa-fw"></font-awesome>
</button>
<!-- Loading Animation -->
<div v-if="isLoading" :class="loadingAnimationClasses">
<font-awesome icon="spinner" class="fa-spin-pulse"></font-awesome>
</div>
<!-- Offer Results List -->
<InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData">
<div v-if="offers.length === 0" class="text-center py-4">
You have not offered anything.
<br />
<router-link to="/discover" class="text-blue-600">
Look for projects worth some of your time.
</router-link>
</div>
<ul id="listOffers" class="border-t border-slate-300">
<li
v-for="offer in offers"
:key="offer.handleId"
class="border-b border-slate-300"
>
<div class="block py-4 flex gap-4">
<div v-if="offer.fulfillsPlanHandleId" class="flex-none">
<ProjectIcon
:entity-id="offer.fulfillsPlanHandleId"
:icon-size="48"
:class="projectIconClasses"
/>
</div>
<div v-if="offer.recipientDid" class="flex-none w-12">
<EntityIcon
:entity-id="offer.recipientDid"
:icon-size="48"
:class="entityIconClasses"
/>
</div>
<div>
<div>
To
{{
offer.fulfillsPlanHandleId
? projectNameFromHandleId[offer.fulfillsPlanHandleId]
: didInfo(
offer.recipientDid,
activeDid,
allMyDids,
allContacts,
)
}}
</div>
<div>
{{ offer.objectDescription }}
</div>
<span class="text-sm">
<span v-if="offer.amount">
<font-awesome
:icon="iconForUnitCode(offer.unit)"
class="fa-fw text-slate-400"
/>
<span v-if="offer.amountGiven >= offer.amount">
<font-awesome
icon="check-circle"
class="fa-fw text-green-500"
/>
All {{ offer.amount }} given
</span>
<span v-else>
<font-awesome
icon="triangle-exclamation"
class="fa-fw text-yellow-500"
/>
{{ offer.amountGiven ? "" : "All" }}
{{ offer.amount - (offer.amountGiven || 0) }} remaining
</span>
<span v-if="offer.amountGiven > 0">
<span class="text-sm text-slate-400">
({{ offer.amountGiven }} given,
<span
v-if="offer.amountGivenConfirmed >= offer.amountGiven"
>
<!--
There's no need for a green icon:
it's unnecessary if there's already a green, and confusing if there's a
yellow.
-->
all
</span>
<span v-else>
<!-- only show icon if there's not already a warning -->
<font-awesome
v-if="offer.amountGiven >= offer.amount"
icon="triangle-exclamation"
class="fa-fw text-yellow-300"
/>
{{ offer.amountGivenConfirmed || 0 }}
</span>
of that is confirmed)
</span>
</span>
</span>
<span v-else>
<!-- Non-amount offer -->
<span v-if="offer.nonAmountGivenConfirmed">
<font-awesome
icon="check-circle"
class="fa-fw text-green-500"
/>
{{ offer.nonAmountGivenConfirmed }}
{{ offer.nonAmountGivenConfirmed == 1 ? "give" : "gives" }}
are confirmed.
</span>
<span v-else>
<font-awesome
icon="triangle-exclamation"
class="fa-fw text-yellow-500"
/>
<span class="text-sm">Not confirmed by anyone</span>
</span>
</span>
<a @click="onClickLoadClaim(offer.jwtId)">
<font-awesome
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
></font-awesome>
</a>
</span>
</div>
</div>
</li>
</ul>
</InfiniteScroll>
<!-- Project Results List -->
<InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData">
<div v-if="projects.length === 0" class="text-center py-4">
You have not announced any projects.
<div v-if="isRegistered">
Hit the big
<font-awesome icon="plus" :class="plusIconClasses" />
button. You'll never know until you try.
</div>
<div v-else>
<button
:class="onboardingButtonClasses"
@click="showNameThenIdDialog()"
>
Get someone to onboard you.
</button>
<UserNameDialog ref="userNameDialog" />
</div>
</div>
<ul id="listProjects" class="border-t border-slate-300">
<li
v-for="project in projects"
:key="project.handleId"
class="border-b border-slate-300"
>
<a
class="block py-4 flex gap-4 cursor-pointer"
@click="onClickLoadProject(project.handleId)"
>
<div class="flex-none">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
:class="projectIconClasses"
/>
</div>
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">
{{ project.name || "Unnamed Project" }}
</h2>
<div class="text-sm truncate">
{{ project.description }}
</div>
</div>
</a>
</li>
</ul>
</InfiniteScroll>
</section>
</template>
<script lang="ts">
import { AxiosRequestConfig } from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
// Capacitor import removed - using QRNavigationService instead
import { NotificationIface } from "../constants/app";
import EntityIcon from "../components/EntityIcon.vue";
import InfiniteScroll from "../components/InfiniteScroll.vue";
import QuickNav from "../components/QuickNav.vue";
import OnboardingDialog from "../components/OnboardingDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import { Contact } from "../db/tables/contacts";
import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
import { OnboardPage, iconForUnitCode } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { QRNavigationService } from "@/services/QRNavigationService";
import {
NOTIFY_NO_ACCOUNT_ERROR,
NOTIFY_PROJECT_LOAD_ERROR,
NOTIFY_PROJECT_INIT_ERROR,
NOTIFY_OFFERS_LOAD_ERROR,
NOTIFY_OFFERS_FETCH_ERROR,
NOTIFY_CAMERA_SHARE_METHOD,
} from "@/constants/notifications";
/**
* Projects View Component
*
* Main dashboard for managing user projects and offers within the TimeSafari platform.
* Provides dual-mode interface for viewing:
* - Personal projects: Ideas and plans created by the user
* - Active offers: Commitments made to help with other projects
*
* Key Features:
* - Infinite scrolling for large datasets
* - Project creation and navigation
* - Offer tracking with confirmation status
* - Onboarding integration for new users
* - Cross-platform compatibility (web, mobile, desktop)
*
* Security: All API calls are authenticated using user's DID
* Privacy: Only user's own projects and offers are displayed
*/
@Component({
name: "ProjectsView",
components: {
EntityIcon,
InfiniteScroll,
QuickNav,
OnboardingDialog,
ProjectIcon,
TopMessage,
UserNameDialog,
},
mixins: [PlatformServiceMixin],
})
export default class ProjectsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
notify!: ReturnType<typeof createNotifyHelpers>;
// User account state
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
givenName = "";
isLoading = false;
isRegistered = false;
// Data collections
offers: OfferSummaryRecord[] = [];
projectNameFromHandleId: Record<string, string> = {}; // mapping from handleId to description
projects: PlanData[] = [];
// UI state
showOffers = false;
showProjects = true;
// Utility imports
didInfo = didInfo;
iconForUnitCode = iconForUnitCode;
/**
* Initializes notification helpers
*/
created() {
this.notify = createNotifyHelpers(this.$notify);
}
/**
* Component initialization
*
* Workflow:
* 1. Load user settings and account information
* 2. Load contacts for displaying offer recipients
* 3. Initialize onboarding dialog if needed
* 4. Load initial project data
*
* Error handling: Shows appropriate user messages for different failure scenarios
*/
async mounted() {
try {
await this.initializeUserSettings();
await this.loadContactsData();
await this.initializeUserIdentities();
await this.checkOnboardingStatus();
await this.loadInitialData();
} catch (err) {
logger.error("Error initializing ProjectsView:", err);
this.notify.error(NOTIFY_PROJECT_INIT_ERROR.message, TIMEOUTS.LONG);
}
}
/**
* Loads user settings from active account
*/
private async initializeUserSettings() {
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
this.givenName = settings.firstName || "";
}
/**
* Loads contacts data for displaying offer recipients
*/
private async loadContactsData() {
this.allContacts = await this.$contacts();
}
/**
* Initializes user identity information
*/
private async initializeUserIdentities() {
this.allMyDids = await this.$getAllAccountDids();
}
/**
* Checks if onboarding dialog should be shown
*/
private async checkOnboardingStatus() {
const settings = await this.$accountSettings();
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Create,
);
}
}
/**
* Loads initial project data if user has valid account
*/
private async loadInitialData() {
if (this.allMyDids.length === 0) {
logger.error("No accounts found for user");
this.notify.error(NOTIFY_NO_ACCOUNT_ERROR.message, TIMEOUTS.LONG);
} else {
await this.loadProjects();
}
}
/**
* Core project data loader
*
* Fetches project data from the endorser server and populates the projects array.
* Handles authentication, error scenarios, and loading states.
*
* @param url - The API endpoint URL for fetching project data
*/
async projectDataLoader(url: string) {
try {
const headers = await getHeaders(this.activeDid, this.$notify);
this.isLoading = true;
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) {
const plans: PlanData[] = resp.data.data;
for (const plan of plans) {
const { name, description, handleId, image, issuerDid, rowId } = plan;
this.projects.push({
name,
description,
image,
handleId,
issuerDid,
rowId,
});
}
} else {
logger.error(
"Bad server response & data for plans:",
resp.status,
resp.data,
);
this.notify.error(NOTIFY_PROJECT_LOAD_ERROR.message, TIMEOUTS.LONG);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error("Got error loading plans:", errorMessage);
this.notify.error(NOTIFY_PROJECT_LOAD_ERROR.message, TIMEOUTS.LONG);
} finally {
this.isLoading = false;
}
}
/**
* Data loader used by infinite scroller
*
* Implements pagination by loading additional projects when user scrolls to bottom.
* Uses the last project's rowId as a cursor for the next batch.
*
* @param payload - Flag from InfiniteScroll component indicating if more data should be loaded
*/
async loadMoreProjectData(payload: boolean) {
if (this.projects.length > 0 && payload) {
const latestProject = this.projects[this.projects.length - 1];
await this.loadProjects(`beforeId=${latestProject.rowId}`);
}
}
/**
* Load projects initially or with pagination
*
* Constructs the API URL for fetching user's projects and delegates to projectDataLoader.
*
* @param urlExtra - Additional URL parameters for pagination (e.g., "beforeId=123")
*/
async loadProjects(urlExtra: string = "") {
const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`;
await this.projectDataLoader(url);
}
/**
* Handle clicking on a project entry
*
* Navigates to the detailed project view for the selected project.
*
* @param id - The unique identifier of the project to view
*/
onClickLoadProject(id: string) {
const route = {
path: "/project/" + encodeURIComponent(id),
};
this.$router.push(route);
}
/**
* Handle clicking on the new project button
*
* Navigates to the project creation/editing interface.
*/
onClickNewProject(): void {
const route = {
name: "new-edit-project",
};
this.$router.push(route);
}
/**
* Handle clicking on a claim/offer link
*
* Navigates to the detailed claim view for the selected offer.
*
* @param jwtId - The JWT identifier of the claim to view
*/
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
}
/**
* Core offer data loader
*
* Fetches offer data from the endorser server and populates the offers array.
* Also retrieves associated project names for display purposes.
*
* @param url - The API endpoint URL for fetching offer data
*/
async offerDataLoader(url: string) {
const headers = await getHeaders(this.activeDid);
try {
this.isLoading = true;
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) {
// Process offers one-by-one to retrieve project names from server cache
for (const offer of resp.data.data) {
if (offer.fulfillsPlanHandleId) {
const project = await getPlanFromCache(
offer.fulfillsPlanHandleId,
this.axios,
this.apiServer,
this.activeDid,
);
const projectName = project?.name as string;
this.projectNameFromHandleId[offer.fulfillsPlanHandleId] =
projectName;
}
this.offers = this.offers.concat([offer]);
}
} else {
logger.error(
"Bad server response & data for offers:",
resp.status,
resp.data,
);
this.notify.error(NOTIFY_OFFERS_LOAD_ERROR.message, TIMEOUTS.LONG);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error("Got error loading offers:", errorMessage);
this.notify.error(NOTIFY_OFFERS_FETCH_ERROR.message, TIMEOUTS.LONG);
} finally {
this.isLoading = false;
}
}
/**
* Data loader used by infinite scroller for offers
*
* Implements pagination by loading additional offers when user scrolls to bottom.
* Uses the last offer's jwtId as a cursor for the next batch.
*
* @param payload - Flag from InfiniteScroll component indicating if more data should be loaded
*/
async loadMoreOfferData(payload: boolean) {
if (this.offers.length > 0 && payload) {
const latestOffer = this.offers[this.offers.length - 1];
await this.loadOffers(`&beforeId=${latestOffer.jwtId}`);
}
}
/**
* Load offers initially or with pagination
*
* Constructs the API URL for fetching user's offers and delegates to offerDataLoader.
*
* @param urlExtra - Additional URL parameters for pagination (e.g., "&beforeId=123")
*/
async loadOffers(urlExtra: string = "") {
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${this.activeDid}${urlExtra}`;
await this.offerDataLoader(url);
}
/**
* Shows name dialog if needed, then prompts for share method
*
* Ensures user has provided their name before proceeding with contact sharing.
* Uses UserNameDialog component if name is not set.
*/
showNameThenIdDialog() {
if (!this.givenName) {
(this.$refs.userNameDialog as UserNameDialog).open(() => {
this.promptForShareMethod();
});
} else {
this.promptForShareMethod();
}
}
/**
* Prompts user to choose contact sharing method
*
* Presents modal dialog asking if users are nearby with cameras.
* Routes to appropriate sharing method based on user's choice:
* - QR code sharing for nearby users with cameras
* - Alternative sharing methods for remote users
*/
promptForShareMethod() {
this.$notify(
{
group: "modal",
type: "confirm",
title: NOTIFY_CAMERA_SHARE_METHOD.title,
text: NOTIFY_CAMERA_SHARE_METHOD.text,
onYes: () => this.handleQRCodeClick(),
onNo: () => this.$router.push({ name: "share-my-contact-info" }),
yesText: NOTIFY_CAMERA_SHARE_METHOD.yesText,
noText: NOTIFY_CAMERA_SHARE_METHOD.noText,
},
TIMEOUTS.MODAL,
);
}
/**
* Computed properties for template logic streamlining
*/
/**
* CSS class names for offer tab styling
* @returns Object with CSS classes based on current tab selection
*/
get offerTabClasses() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.showOffers,
"text-black": this.showOffers,
"border-black": this.showOffers,
"font-semibold": this.showOffers,
"text-blue-600": !this.showOffers,
"border-transparent": !this.showOffers,
"hover:border-slate-400": !this.showOffers,
};
}
/**
* CSS class names for new project button
* @returns String with CSS classes for the floating new project button
*/
get newProjectButtonClasses() {
return "fixed right-6 top-24 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full";
}
/**
* CSS class names for loading animation
* @returns String with CSS classes for the loading spinner
*/
get loadingAnimationClasses() {
return "fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full";
}
/**
* CSS class names for project icon
* @returns String with CSS classes for project icons
*/
get projectIconClasses() {
return "inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12";
}
/**
* CSS class names for entity icon
* @returns String with CSS classes for entity icons
*/
get entityIconClasses() {
return "inline-block align-middle border border-slate-300 rounded-md";
}
/**
* CSS class names for plus icon in empty state
* @returns String with CSS classes for the plus icon
*/
get plusIconClasses() {
return "bg-green-600 text-white px-1.5 py-1 rounded-full";
}
/**
* CSS class names for onboarding button
* @returns String with CSS classes for the onboarding button
*/
get onboardingButtonClasses() {
return "text-md 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 mt-2 px-2 py-3 rounded-md";
}
/**
* CSS class names for project tab styling
* @returns Object with CSS classes based on current tab selection
*/
get projectTabClasses() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.showProjects,
"text-black": this.showProjects,
"border-black": this.showProjects,
"font-semibold": this.showProjects,
"text-blue-600": !this.showProjects,
"border-transparent": !this.showProjects,
"hover:border-slate-400": !this.showProjects,
};
}
/**
* Utility methods
*/
/**
* Handles QR code sharing functionality with platform detection
*
* Routes to appropriate QR code interface based on current platform:
* - Full QR scanner for native mobile platforms
* - Web-based QR interface for browser environments
*/
private handleQRCodeClick() {
const qrNavigationService = QRNavigationService.getInstance();
const route = qrNavigationService.getQRScannerRoute();
this.$router.push(route);
}
/**
* Legacy method compatibility
* @deprecated Use computedOfferTabClassNames for backward compatibility
*/
public computedOfferTabClassNames() {
return this.offerTabClasses;
}
/**
* Legacy method compatibility
* @deprecated Use computedProjectTabClassNames for backward compatibility
*/
public computedProjectTabClassNames() {
return this.projectTabClasses;
}
}
</script>