|
- <template>
- <div class="container is-max-desktop pb-4">
- <section class="section is-small">
- <div
- class="is-block-mobile
- is-flex-tablet-only
- is-flex-desktop
- is-flex-direction-row
- is-justify-content-space-between">
- <div class="my-auto">
- <div class="is-size-1">{{ fund.fundInfo.asset }}</div>
- <div>
- <a :href="fund.fundInfo.telegramLink" target="_blank" class="has-text-grey-light">
- {{ fund.fundInfo.telegramLink }}
- </a>
- </div>
- </div>
- <div
- class="fund-details is-flex is-flex-direction-row is-justify-content-end my-auto py-6">
- <ul>
- <li v-for="(detail, i) in fundDetails" v-bind:key="i">
- <span class="has-text-weight-bold is-size-6 mr-2">{{ detail.title }}</span>
- <span class="is-size-6">{{ detail.val }}</span>
- </li>
- </ul>
- </div>
- </div>
- </section>
- <section class="section is-small">
- <div class="box">
- <div class="title is-size-4 has-text-grey-dark has-text-centered">
- Tracker
- </div>
- <div class="mb-4">
- <div class="level" v-if="fund.fundInfo.bonuses.length > 0">
- <div class="level-item has-text-centered"
- v-for="bonus in fund.fundInfo.bonuses.sort((v1, v2) => v1.goal - v2.goal)"
- v-bind:key="bonus.goal"
- >
- <div>
- <p
- class="heading"
- :class="amountHeld >= bonus.goal
- ? 'has-text-success' : 'has-text-grey-dark'"
- >
- {{ bonus.goal.toLocaleString() }} XLM
- </p>
- <p
- class="title"
- :class="amountHeld >= bonus.goal
- ? 'has-text-success' : 'has-text-grey-dark'"
- >
- {{ bonus.percent }}%
- </p>
- </div>
- </div>
- </div>
- <div v-else>
- <div class="has-text-centered">This group fund has no rewards available</div>
- </div>
- </div>
- <progress class="progress is-large is-info" :value="calcPctComplete()"
- max="100">
- {{ calcPctComplete() }}%
- </progress>
- </div>
- </section>
- <section class="section is-small">
- <div class="title is-size-4 has-text-white-ter has-text-centered">
- Contribute
- </div>
- <ErrorDisplay :errors="errs" v-if="invalidContributionForm"/>
- <div class="control my-2">
- <input class="input is-normal has-background-white has-text-black" type="text"
- placeholder="Private Key" aria-label="Wallet" v-model="pk" @blur="queryAccount">
- </div>
- <div class="control my-2" :class="loading.balance ? 'is-loading' : null">
- <input
- class="input is-normal has-background-white has-text-black"
- type="number"
- placeholder="Amount"
- aria-label="Amount"
- v-model="amount"
- :max="acctBalance"
- :disabled="loading.balance"
- >
- </div>
- <div class="is-flex is-justify-content-end">
- <button
- class="button is-primary"
- :class="loading.contribution ? 'is-loading' : ''"
- :disabled="invalidContributionForm"
- @click="makeContribution"
- >Submit
- </button>
- </div>
- </section>
- <section class="section is-small" v-if="contributions.length > 0">
- <div class="title is-size-4 has-text-white-ter has-text-centered">
- Contributions
- </div>
- <div class="is-flex is-justify-content-space-between is-rounded my-2">
- <div class="select">
- <select v-model="selectedDate" aria-label="Filter by date">
- <option v-for="date in selectableDates" v-bind:key="date" :value="date">
- {{ date ?? 'Cutoff Date' }}
- </option>
- </select>
- </div>
- <div class="consolidate-option has-background-white px-4 py-1 my-auto">
- <label for="consolidate" class="checkbox has-text-dark is-size-6">
- <span class="consolidate-label is-inline-block">
- Consolidate wallets
- </span>
- <input
- type="checkbox"
- class="ml-2"
- id="consolidate"
- aria-label="Consolidate wallets"
- v-model="enableConsolidation"
- >
- </label>
- </div>
- </div>
- <div id="contribution-container" @scroll="loadMoreIfNeeded">
- <p v-if="store.getters.getToken && hasPermission(Privileges.Admin)">
- Enable contribution consolidation in order to select.
- Click to select a row in order to mark a wallet to receive rewards.
- </p>
- <table class="contribution-table table is-fullwidth">
- <thead>
- <tr>
- <th>Wallet</th>
- <th>Amount</th>
- <th v-if="!enableConsolidation">Time</th>
- <th v-else>Bonus</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="(contribution, i) in contributions"
- :class="{ 'contribution-selected': contribution.selected }"
- v-bind:key="i" @click="selectContribution(contribution)">
- <td>{{ truncateWallet(contribution.wallet, calculateWalletChars(), undefined) }}</td>
- <td>{{ contribution.amount }}</td>
- <td v-if="!enableConsolidation">
- <span class="transaction-date" :title="formatDate(contribution.createdAt, true)">
- {{ formatDate(contribution.createdAt, true) }}
- </span>
- </td>
- <td v-else>
- <span>{{ calculateReward(contribution.amount.div(fund.fundInfo.price)) }}</span>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- <div v-if="store.getters.getToken && hasPermission(Privileges.Admin)">
- <div class="box is-flex is-flex-direction-row is-justify-content-space-between">
- <div class="my-auto">
- <label for="distribute-confirm" class="checkbox">
- <input type="checkbox" id="distribute-confirm" v-model="allowDistribution">
- Allow Distribution
- </label>
- </div>
- <div>
- <button class="button is-success"
- :disabled="!allowDistribution" @click="distributeFund">
- Distribute Rewards
- </button>
- </div>
- </div>
- </div>
- </section>
- <section class="section is-small px-0"
- v-if="store.getters.getToken && hasPermission(Privileges.Admin)">
- <div class="title is-size-4 has-text-white-ter has-text-centered">
- Submit Group Fund
- </div>
- <div class="box is-flex is-flex-direction-row is-justify-content-space-between">
- <div class="my-auto">
- <label class="checkbox" for="submit-confirm">
- <input type="checkbox" id="submit-confirm" v-model="allowSubmit"> Allow Submit
- </label>
- </div>
- <div>
- <button
- class="button is-success"
- :disabled="!allowSubmit"
- @click="submitFund"
- >
- Submit
- </button>
- </div>
- </div>
- </section>
- <section v-if="store.getters.getToken && hasPermission(Privileges.AdminPlus)">
- <div class="title is-size-4 has-text-white-ter has-text-centered">
- Close Group Fund
- </div>
- <div class="box is-flex is-flex-direction-row is-justify-content-space-between">
- <div class="my-auto">
- <label class="checkbox" for="delete-confirm">
- <input type="checkbox" id="delete-confirm" v-model="allowDelete"> Allow Close
- </label>
- </div>
- <div>
- <button
- class="button is-danger"
- :disabled="!allowDelete"
- @click="deleteFund"
- >
- Close
- </button>
- </div>
- </div>
- </section>
- </div>
- </template>
-
- <script setup lang="ts">
- import {
- useRoute,
- useRouter,
- } from 'vue-router';
- import {
- Bonus,
- Contribution,
- FundInfo,
- GetRewardFundResponse,
- Privileges,
- } from '@/api/types';
- import {
- computed,
- Ref,
- ref,
- watch,
- } from 'vue';
- import {
- useWebSocket,
- useWindowSize,
- } from '@vueuse/core';
- import store from '@/store';
- import {
- sanitize,
- truncateWallet,
- } from '@/lib/helpers';
- import * as luxon from 'luxon';
- import hasPermission from '@/lib/auth';
- import ErrorDisplay, { SignetError } from '@/components/ErrorDisplay.vue';
- import {
- contribute,
- deleteRewardFund,
- distributeRewardFund,
- getBalance,
- getContributions,
- getRewardFund,
- submitRewardFund,
- } from '@/api/composed';
- import Decimal from 'decimal.js';
-
- const route = useRoute();
- const router = useRouter();
- const { id } = route.params;
-
- const identifier = parseInt(id as string, 10);
-
- const formatDate = (time: string, includeTime = false) => {
- const s = luxon.DateTime.fromISO(time)
- .toUTC();
- const date = s.toFormat('yyyy-LLL-dd');
- return includeTime ? `${date} ${s.toFormat('HH:mm')} (UTC)` : date;
- };
-
- const pk = ref('');
- const amt = ref(undefined as Decimal | undefined);
- const selectableDates = ref([undefined] as (string | undefined)[]);
- const selectedDate = ref(undefined as string | undefined);
-
- const { width } = useWindowSize();
-
- const amount = computed({
- get: () => amt.value,
- set: (v) => {
- if (v) {
- amt.value = new Decimal(v);
- } else {
- amt.value = undefined;
- }
- },
- });
-
- const enableConsolidation = ref(false);
- const fund = ref(
- {
- fundInfo: {} as FundInfo,
- contributions: {
- list: [],
- dates: [] as string[],
- total: 0,
- },
- total: { amountHeld: 0 },
- } as GetRewardFundResponse | null,
- );
- const fundDetails = ref([{
- title: '',
- val: '',
- }]);
-
- fund.value = await getRewardFund(identifier, enableConsolidation.value);
- if (!fund.value) {
- router.push('/');
- throw new Error('Fund not found');
- }
- fundDetails.value = [
- {
- title: 'Asset',
- val: fund.value.fundInfo.asset,
- },
- {
- title: 'Minimum',
- val: `${fund.value.fundInfo.minContribution.toLocaleString()}`,
- },
- {
- title: 'Remaining',
- val: `${fund.value.fundInfo.amountAvailable.toLocaleString()}`,
- },
- {
- title: 'Memo',
- val: `"${fund.value.fundInfo.memo}"`,
- },
- ];
- if (fund.value.contributions.dates) {
- selectableDates.value = selectableDates.value.concat(
- fund.value.contributions.dates.map((d) => formatDate(d)),
- );
- }
-
- interface SelectableContribution extends Contribution {
- selected: boolean;
- }
-
- const processContributions = (contributions: Contribution[]) => contributions.map((c) => ({
- ...c,
- amount: new Decimal(c.amount),
- selected: false,
- }));
-
- const reward = ref(new Decimal(0));
- const maxBonus = ref(0);
- const bonus = ref(undefined as Bonus | undefined);
- const amountHeld = ref(new Decimal(fund.value.total.amountHeld));
- const amountAvailable = ref(new Decimal(fund.value.fundInfo.amountAvailable));
- const contributions: Ref<SelectableContribution[]> = ref(processContributions(
- fund.value.contributions.list ?? [],
- ));
- const offset = ref(contributions.value.length);
- const total = ref(fund.value.contributions.total);
- const contributionsLoading = ref(false);
- const acctBalance = ref(undefined as Decimal | undefined);
- const unknownAcct = ref(true);
- const loading = ref({
- contribution: false,
- balance: false,
- });
-
- const round = (num: number, figures = 1) => {
- const factor = 10 ** figures;
- return Math.round(num * factor) / factor;
- };
-
- const calcPctComplete = () => round(amountHeld.value.div(amountAvailable.value)
- .mul(100)
- .toNumber());
-
- const calculateWalletChars = () => round(width.value / 110, 0);
-
- const hasInvalidValues = () => {
- if (!fund.value) throw new Error('Fund was not loaded!');
- if ([pk, amount].some((v) => v.value === undefined || v.value === '')) return false;
- if (!pk.value || !amount.value) throw new Error('One or more validation values were undefined');
- return (amount.value.isZero()
- || amount.value > amountAvailable.value
- || amount.value.lt(fund.value.fundInfo.minContribution)
- || (acctBalance.value && amount.value.gt(acctBalance.value))
- || unknownAcct.value);
- };
-
- const invalidContributionForm = computed(() => hasInvalidValues());
-
- const getCurrentBonus = () => {
- if (!fund.value) throw new Error('Fund not found!');
- const achievedBonuses = fund.value.fundInfo.bonuses.filter(
- (b) => {
- if (!fund.value) throw new Error('Fund not found');
- return b.goal && fund.value.total.amountHeld >= b.goal;
- },
- );
- if (achievedBonuses.length > 0) {
- maxBonus.value = Math.max(...achievedBonuses.map((b) => b.goal ?? -1));
- bonus.value = achievedBonuses.find((b) => b.goal === maxBonus.value);
- if (!Object.entries(bonus).length) throw new Error('Something went wrong');
- }
- };
-
- const calculateReward = (bought: Decimal) => {
- if (bonus.value) {
- if (!bonus.value.percent) throw new Error('Bonus did not have percent for some reason');
- reward.value = bought.add(bought)
- .mul(new Decimal(bonus.value.percent / 100));
- } else {
- reward.value = bought;
- }
- return reward.value.toLocaleString();
- };
-
- const selectedContributions = ref([] as Contribution[]);
- const selectContribution = (contribution: SelectableContribution) => {
- if (!store.getters.getToken || !hasPermission(Privileges.Admin)) return;
- if (enableConsolidation.value) {
- if (!contribution.selected) {
- selectedContributions.value.push(contribution);
- } else {
- const existingContributionIndex = selectedContributions.value.findIndex(
- (c) => c.wallet === contribution.wallet,
- );
- selectedContributions.value.splice(existingContributionIndex, 1);
- }
- const newContribution: SelectableContribution = {
- ...contribution,
- selected: !contribution.selected,
- };
- const toReplaceIndex = contributions.value.findIndex((c) => c.wallet === contribution.wallet);
- if (toReplaceIndex === -1) throw new Error('Could not find contribution to select');
- contributions.value.splice(toReplaceIndex, 1, newContribution);
- }
- };
-
- const allowDelete = ref(false);
- const deleteFund = async () => {
- const deleted = await deleteRewardFund(identifier, allowDelete.value);
- if (deleted && deleted.success) {
- await router.push('/');
- }
- };
- const delTimeout = ref(undefined as number | undefined);
-
- watch(allowDelete, () => {
- if (delTimeout.value) window.clearTimeout(delTimeout.value);
- delTimeout.value = window.setTimeout(() => {
- allowDelete.value = false;
- delTimeout.value = undefined;
- }, 10000);
- });
-
- const allowDistribution = ref(false);
- const distributeFund = async () => {
- const distributed = await distributeRewardFund(
- identifier,
- selectedContributions.value.map((c) => ({
- destination: c.wallet,
- amount: c.amount.toNumber(),
- })),
- allowDistribution.value,
- );
- if (distributed && distributed.success) {
- console.log('distributed'); // TODO: provide feedback for distribution
- }
- };
- const distTimeout = ref(undefined as number | undefined);
-
- watch(allowDistribution, () => {
- if (distTimeout.value) window.clearTimeout(distTimeout.value);
- distTimeout.value = window.setTimeout(() => {
- allowDistribution.value = false;
- distTimeout.value = undefined;
- }, 10000);
- });
-
- const allowSubmit = ref(false);
- const submitFund = async () => {
- const submitted = await submitRewardFund(identifier, allowSubmit.value);
- if (submitted && submitted.success) {
- console.log('submitted'); // TODO: provide feedback for submission
- }
- };
- const subTimeout = ref(undefined as number | undefined);
-
- watch(allowSubmit, () => {
- if (subTimeout.value) window.clearTimeout(subTimeout.value);
- subTimeout.value = window.setTimeout(() => {
- allowSubmit.value = false;
- subTimeout.value = undefined;
- }, 10000);
- });
-
- const errs: SignetError[] = [
- {
- text: 'Amount is required',
- condition: amt.value === undefined,
- },
- {
- text: 'Amount must be greater than 0',
- condition: amt.value && amt.value.isZero() && !fund.value.fundInfo.minContribution,
- },
- {
- text: 'Amount is less than the minimum contribution',
- condition: amt.value && amt.value.lt(fund.value.fundInfo.minContribution),
- },
- {
- text: `Not enough ${fund.value.fundInfo.asset} for sale in ICO`,
- condition: amt.value && amt.value > amountAvailable.value,
- },
- {
- text: `Not enough XLM to send (${amt.value?.toLocaleString()})`,
- condition: amt.value && acctBalance.value && amt.value.gt(acctBalance.value),
- },
- {
- text: 'Could not find Stellar wallet',
- condition: unknownAcct,
- },
- ];
-
- document.title = `Beignet - ${fund.value.fundInfo.asset}`;
-
- watch(selectedDate, async (newVal) => {
- offset.value = 0;
- const conts = await getContributions(identifier, offset.value, newVal, enableConsolidation.value);
- if (!fund.value) throw new Error('Fund not found');
- if (!conts) throw new Error('Contributions not found');
- contributions.value = processContributions(conts.list);
- offset.value = contributions.value.length;
- total.value = fund.value.contributions.total;
- });
-
- const loadMoreIfNeeded = async (e: Event) => {
- const target = e.target as Element | null;
- const canLoadMore = () => target
- && (target.scrollTop + target.clientHeight) / target.scrollHeight > 0.8
- && offset.value < total.value && !contributionsLoading.value;
-
- if (canLoadMore()) {
- contributionsLoading.value = true;
- const moreContribs = await getContributions(
- identifier,
- offset.value,
- selectedDate.value,
- enableConsolidation.value,
- );
- if (!moreContribs) throw new Error('Contributions not found');
- offset.value += moreContribs.list.length;
- total.value = moreContribs.total;
- contributions.value = contributions.value.concat(processContributions(moreContribs.list));
- contributionsLoading.value = false;
- }
- };
-
- const {
- status,
- data,
- } = useWebSocket(
- 'ws://127.0.0.1:7300/ContributorStream',
- {
- immediate: true,
- autoReconnect: true,
- },
- );
-
- const queryAccount = async () => {
- if (pk.value && pk.value.startsWith('S')) {
- loading.value.balance = true;
- const resp = await getBalance(pk.value);
- if (resp === null) {
- unknownAcct.value = true;
- acctBalance.value = undefined;
- } else {
- unknownAcct.value = false;
- if (resp && resp.balance) {
- acctBalance.value = new Decimal(resp.balance);
- if (amt.value && amt.value.gt(acctBalance.value)) {
- amt.value = acctBalance.value;
- }
- }
- }
- loading.value.balance = false;
- }
- };
-
- const makeContribution = async () => {
- if (!fund.value) throw new Error('Fund not found');
- if (!amount.value) return;
- if (!/^[0-9]+$/.test(amount.value.toString())) return;
- if (unknownAcct.value) return;
- if (!loading.value.contribution && pk.value
- && amount.value && amount.value <= amountAvailable.value) {
- loading.value.contribution = true;
- await contribute(sanitize(pk.value), amount.value.toNumber(), fund.value.fundInfo.id);
- loading.value.contribution = false;
- pk.value = '';
- amt.value = undefined;
- }
- };
-
- watch(enableConsolidation, async () => {
- offset.value = 0;
- const conts = await getContributions(
- identifier,
- offset.value,
- selectedDate.value,
- enableConsolidation.value,
- );
- if (!fund.value) throw new Error('Fund not found');
- if (!conts) throw new Error('Contributions not found');
- contributions.value = processContributions(conts.list);
- offset.value = contributions.value.length;
- total.value = fund.value.contributions.total;
- });
-
- watch(data, (newVal) => {
- if (!fund.value) throw new Error('Fund not found');
- getCurrentBonus();
- if (status.value === 'OPEN') {
- const v = {
- ...JSON.parse(newVal.trim()),
- selected: false,
- } as SelectableContribution;
- v.createdAt = luxon.DateTime.now()
- .toISO();
- const formattedDate = formatDate(v.createdAt);
- if (!selectableDates.value.includes(formattedDate)) {
- selectableDates.value.push(formattedDate);
- }
- if (enableConsolidation.value && contributions.value
- && contributions.value.map((c: Contribution) => c.wallet)
- .includes(v.wallet)) {
- const hasContribution = contributions.value.find((c: Contribution) => c.wallet === v.wallet);
- if (!hasContribution) throw new Error('Something went wrong');
- hasContribution.amount = new Decimal(hasContribution.amount).add(v.amount);
- } else {
- contributions.value.splice(0, 0, v);
- offset.value += 1;
- }
- amountHeld.value = new Decimal(amountHeld.value).add(v.amount);
- amountAvailable.value = new Decimal(amountAvailable.value).sub(v.amount);
- }
- });
- </script>
-
- <style scoped lang="stylus">
- .transaction-date
- white-space nowrap
- overflow hidden
- max-width 20vw
- display block
-
- .consolidate-label
- max-width: 24vw;
- white-space: nowrap;
- overflow: hidden;
- vertical-align: bottom;
- text-overflow: ellipsis;
-
- .total-label, .remaining-label
- border-right 1px solid #8f8f8f
- font-variant all-petite-caps
-
- .signet-asset-name
- cursor pointer
-
- tbody tr
- cursor pointer
-
- #contribution-container
- max-height 360px
- overflow-y auto
-
- .consolidate-option
- border-radius 16px
-
- .fund-description
- min-height 280px
-
- .fund-details
- width 182px
-
- .contribution-selected
- background-color #e0cdbb
-
- #consolidate
- vertical-align middle
-
- @media screen and (min-width: 1024px)
- .fund-details
- max-width 14vw
- border-left 1px #777 solid
- padding-left 10px
- </style>
|