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.
588 lines
18 KiB
588 lines
18 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 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="#"
|
|
@click="
|
|
offers = [];
|
|
projects = [];
|
|
showOffers = true;
|
|
showProjects = false;
|
|
loadOffers();
|
|
"
|
|
v-bind:class="computedOfferTabClassNames()"
|
|
>
|
|
Offers
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
href="#"
|
|
@click="
|
|
offers = [];
|
|
projects = [];
|
|
showOffers = false;
|
|
showProjects = true;
|
|
loadProjects();
|
|
"
|
|
v-bind:class="computedProjectTabClassNames()"
|
|
>
|
|
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"
|
|
>
|
|
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
|
</button>
|
|
</div>
|
|
-->
|
|
|
|
<!-- New Project -->
|
|
<button
|
|
v-if="isRegistered && showProjects"
|
|
class="fixed right-6 top-24 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
|
|
@click="onClickNewProject()"
|
|
>
|
|
<fa icon="plus" class="fa-fw"></fa>
|
|
</button>
|
|
|
|
<!-- Loading Animation -->
|
|
<div
|
|
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
|
|
v-if="isLoading"
|
|
>
|
|
<fa icon="spinner" class="fa-spin-pulse"></fa>
|
|
</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
|
|
class="border-b border-slate-300"
|
|
v-for="offer in offers"
|
|
:key="offer.handleId"
|
|
>
|
|
<div class="block py-4 flex gap-4">
|
|
<div v-if="offer.fulfillsPlanHandleId" class="flex-none">
|
|
<ProjectIcon
|
|
:entityId="offer.fulfillsPlanHandleId"
|
|
:iconSize="48"
|
|
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
|
|
/>
|
|
</div>
|
|
<div v-if="offer.recipientDid" class="flex-none w-12">
|
|
<EntityIcon
|
|
:entityId="offer.recipientDid"
|
|
:iconSize="48"
|
|
class="inline-block align-middle border border-slate-300 rounded-md"
|
|
/>
|
|
</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">
|
|
<fa
|
|
:icon="libsUtil.iconForUnitCode(offer.unit)"
|
|
class="fa-fw text-slate-400"
|
|
/>
|
|
|
|
<span v-if="offer.amountGiven >= offer.amount">
|
|
<fa icon="check-circle" class="fa-fw text-green-500" />
|
|
All {{ offer.amount }} given
|
|
</span>
|
|
<span v-else>
|
|
<fa
|
|
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 -->
|
|
<fa
|
|
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">
|
|
<fa icon="check-circle" class="fa-fw text-green-500" />
|
|
{{ offer.nonAmountGivenConfirmed }}
|
|
{{ offer.nonAmountGivenConfirmed == 1 ? "give" : "gives" }}
|
|
are confirmed.
|
|
</span>
|
|
<span v-else>
|
|
<fa
|
|
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)">
|
|
<fa
|
|
icon="file-lines"
|
|
class="pl-2 text-blue-500 cursor-pointer"
|
|
></fa>
|
|
</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
|
|
<fa
|
|
icon="plus"
|
|
class="bg-green-600 text-white px-1.5 py-1 rounded-full"
|
|
/>
|
|
button. You'll never know until you try.
|
|
</div>
|
|
<div v-else>
|
|
<button
|
|
@click="showNameThenIdDialog()"
|
|
class="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"
|
|
>
|
|
Get someone to onboard you.
|
|
</button>
|
|
<UserNameDialog ref="userNameDialog" />
|
|
</div>
|
|
</div>
|
|
<ul id="listProjects" class="border-t border-slate-300">
|
|
<li
|
|
class="border-b border-slate-300"
|
|
v-for="project in projects"
|
|
:key="project.handleId"
|
|
>
|
|
<a
|
|
@click="onClickLoadProject(project.handleId)"
|
|
class="block py-4 flex gap-4"
|
|
>
|
|
<div class="flex-none">
|
|
<ProjectIcon
|
|
:entityId="project.handleId"
|
|
:iconSize="48"
|
|
:imageUrl="project.image"
|
|
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grow overflow-hidden">
|
|
<h2 class="text-base font-semibold">{{ project.name }}</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";
|
|
|
|
import { NotificationIface } from "@/constants/app";
|
|
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
|
|
import * as libsUtil from "@/libs/util";
|
|
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,
|
|
OfferSummaryRecord,
|
|
PlanData,
|
|
} from "@/libs/endorserServer";
|
|
import { OnboardPage } from "@/libs/util";
|
|
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
InfiniteScroll,
|
|
QuickNav,
|
|
OnboardingDialog,
|
|
ProjectIcon,
|
|
TopMessage,
|
|
UserNameDialog,
|
|
},
|
|
})
|
|
export default class ProjectsView extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
errNote(message) {
|
|
this.$notify(
|
|
{ group: "alert", type: "danger", title: "Error", text: message },
|
|
5000,
|
|
);
|
|
}
|
|
|
|
activeDid = "";
|
|
allContacts: Array<Contact> = [];
|
|
allMyDids: Array<string> = [];
|
|
apiServer = "";
|
|
givenName = "";
|
|
isLoading = false;
|
|
isRegistered = false;
|
|
offers: OfferSummaryRecord[] = [];
|
|
projectNameFromHandleId: Record<string, string> = {}; // mapping from handleId to description
|
|
projects: PlanData[] = [];
|
|
showOffers = false;
|
|
showProjects = true;
|
|
|
|
libsUtil = libsUtil;
|
|
didInfo = didInfo;
|
|
|
|
async mounted() {
|
|
try {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.activeDid = settings.activeDid || "";
|
|
this.apiServer = settings.apiServer || "";
|
|
this.isRegistered = !!settings.isRegistered;
|
|
this.givenName = settings.firstName || "";
|
|
|
|
this.allContacts = await db.contacts.toArray();
|
|
|
|
await accountsDB.open();
|
|
const allAccounts = await accountsDB.accounts.toArray();
|
|
this.allMyDids = allAccounts.map((acc) => acc.did);
|
|
|
|
if (!settings.finishedOnboarding) {
|
|
(this.$refs.onboardingDialog as OnboardingDialog).open(
|
|
OnboardPage.Create,
|
|
);
|
|
}
|
|
|
|
if (allAccounts.length === 0) {
|
|
console.error("No accounts found.");
|
|
this.errNote("You need an identifier to load your projects.");
|
|
} else {
|
|
await this.loadProjects();
|
|
}
|
|
} catch (err) {
|
|
console.error("Error initializing:", err);
|
|
this.errNote("Something went wrong loading your projects.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Core project data loader
|
|
* @param url the url used to fetch the data
|
|
* @param token Authorization token
|
|
**/
|
|
async projectDataLoader(url: string) {
|
|
try {
|
|
const headers = await getHeaders(this.activeDid);
|
|
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 {
|
|
console.error(
|
|
"Bad server response & data for plans:",
|
|
resp.status,
|
|
resp.data,
|
|
);
|
|
this.errNote("Failed to get projects from the server.");
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (error: any) {
|
|
console.error("Got error loading plans:", error.message || error);
|
|
this.errNote("Got an error loading projects.");
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Data loader used by infinite scroller
|
|
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
|
**/
|
|
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
|
|
* @param issuerDid of the user
|
|
* @param urlExtra additional url parameters in a string
|
|
**/
|
|
async loadProjects(urlExtra: string = "") {
|
|
const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`;
|
|
await this.projectDataLoader(url);
|
|
}
|
|
|
|
/**
|
|
* Handle clicking on a project entry found in the list
|
|
* @param id of the project
|
|
**/
|
|
onClickLoadProject(id: string) {
|
|
const route = {
|
|
path: "/project/" + encodeURIComponent(id),
|
|
};
|
|
(this.$router as Router).push(route);
|
|
}
|
|
|
|
/**
|
|
* Handling clicking on the new project button
|
|
**/
|
|
onClickNewProject(): void {
|
|
const route = {
|
|
name: "new-edit-project",
|
|
};
|
|
(this.$router as Router).push(route);
|
|
}
|
|
|
|
onClickLoadClaim(jwtId: string) {
|
|
const route = {
|
|
path: "/claim/" + encodeURIComponent(jwtId),
|
|
};
|
|
(this.$router as Router).push(route);
|
|
}
|
|
|
|
/**
|
|
* Core offer data loader
|
|
* @param url the url used to fetch the data
|
|
* @param token Authorization token
|
|
**/
|
|
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) {
|
|
// add one-by-one as they retrieve project names, potentially from the server
|
|
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 {
|
|
console.error(
|
|
"Bad server response & data for offers:",
|
|
resp.status,
|
|
resp.data,
|
|
);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to get offers from the server. Try again later.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (error: any) {
|
|
console.error("Got error loading offers:", error.message || error);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Got an error loading offers.",
|
|
},
|
|
-1,
|
|
);
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Data loader used by infinite scroller
|
|
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
|
**/
|
|
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
|
|
* @param issuerDid of the user
|
|
* @param urlExtra additional url parameters in a string
|
|
**/
|
|
async loadOffers(urlExtra: string = "") {
|
|
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${this.activeDid}${urlExtra}`;
|
|
await this.offerDataLoader(url);
|
|
}
|
|
|
|
showNameThenIdDialog() {
|
|
if (!this.givenName) {
|
|
(this.$refs.userNameDialog as UserNameDialog).open(() => {
|
|
this.promptForShareMethod();
|
|
});
|
|
} else {
|
|
this.promptForShareMethod();
|
|
}
|
|
}
|
|
|
|
promptForShareMethod() {
|
|
this.$notify(
|
|
{
|
|
group: "modal",
|
|
type: "confirm",
|
|
title: "Are you nearby with cameras?",
|
|
text: "If so, we'll use those with QR codes to share.",
|
|
onCancel: async () => {},
|
|
onNo: async () => {
|
|
(this.$router as Router).push({ name: "share-my-contact-info" });
|
|
},
|
|
onYes: async () => {
|
|
(this.$router as Router).push({ name: "contact-qr" });
|
|
},
|
|
noText: "we will share another way",
|
|
yesText: "we are nearby with cameras",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
|
|
public computedOfferTabClassNames() {
|
|
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,
|
|
};
|
|
}
|
|
|
|
public computedProjectTabClassNames() {
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
</script>
|
|
|