The frontend for the project formerly known as signet, now known as beignet.
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

697 linhas
21 KiB

  1. <template>
  2. <div class="container is-max-desktop pb-4">
  3. <section class="section is-small">
  4. <div
  5. class="is-block-mobile
  6. is-flex-tablet-only
  7. is-flex-desktop
  8. is-flex-direction-row
  9. is-justify-content-space-between">
  10. <div class="my-auto">
  11. <div class="is-size-1">{{ fund.fundInfo.asset }}</div>
  12. <div>
  13. <a :href="fund.fundInfo.telegramLink" target="_blank" class="has-text-grey-light">
  14. {{ fund.fundInfo.telegramLink }}
  15. </a>
  16. </div>
  17. </div>
  18. <div
  19. class="fund-details is-flex is-flex-direction-row is-justify-content-end my-auto py-6">
  20. <ul>
  21. <li v-for="(detail, i) in fundDetails" v-bind:key="i">
  22. <span class="has-text-weight-bold is-size-6 mr-2">{{ detail.title }}</span>
  23. <span class="is-size-6">{{ detail.val }}</span>
  24. </li>
  25. </ul>
  26. </div>
  27. </div>
  28. </section>
  29. <section class="section is-small">
  30. <div class="box">
  31. <div class="title is-size-4 has-text-grey-dark has-text-centered">
  32. Tracker
  33. </div>
  34. <div class="mb-4">
  35. <div class="level" v-if="fund.fundInfo.bonuses.length > 0">
  36. <div class="level-item has-text-centered"
  37. v-for="bonus in fund.fundInfo.bonuses.sort((v1, v2) => v1.goal - v2.goal)"
  38. v-bind:key="bonus.goal"
  39. >
  40. <div>
  41. <p
  42. class="heading"
  43. :class="amountHeld >= bonus.goal
  44. ? 'has-text-success' : 'has-text-grey-dark'"
  45. >
  46. {{ bonus.goal.toLocaleString() }} XLM
  47. </p>
  48. <p
  49. class="title"
  50. :class="amountHeld >= bonus.goal
  51. ? 'has-text-success' : 'has-text-grey-dark'"
  52. >
  53. {{ bonus.percent }}%
  54. </p>
  55. </div>
  56. </div>
  57. </div>
  58. <div v-else>
  59. <div class="has-text-centered">This group fund has no rewards available</div>
  60. </div>
  61. </div>
  62. <progress class="progress is-large is-info" :value="calcPctComplete()"
  63. max="100">
  64. {{ calcPctComplete() }}%
  65. </progress>
  66. </div>
  67. </section>
  68. <section class="section is-small">
  69. <div class="title is-size-4 has-text-white-ter has-text-centered">
  70. Contribute
  71. </div>
  72. <ErrorDisplay :errors="errs" v-if="invalidContributionForm"/>
  73. <div class="control my-2">
  74. <input class="input is-normal has-background-white has-text-black" type="text"
  75. placeholder="Private Key" aria-label="Wallet" v-model="pk" @blur="queryAccount">
  76. </div>
  77. <div class="control my-2" :class="loading.balance ? 'is-loading' : null">
  78. <input
  79. class="input is-normal has-background-white has-text-black"
  80. type="number"
  81. placeholder="Amount"
  82. aria-label="Amount"
  83. v-model="amount"
  84. :max="acctBalance"
  85. :disabled="loading.balance"
  86. >
  87. </div>
  88. <div class="is-flex is-justify-content-end">
  89. <button
  90. class="button is-primary"
  91. :class="loading.contribution ? 'is-loading' : ''"
  92. :disabled="invalidContributionForm"
  93. @click="makeContribution"
  94. >Submit
  95. </button>
  96. </div>
  97. </section>
  98. <section class="section is-small" v-if="contributions.length > 0">
  99. <div class="title is-size-4 has-text-white-ter has-text-centered">
  100. Contributions
  101. </div>
  102. <div class="is-flex is-justify-content-space-between is-rounded my-2">
  103. <div class="select">
  104. <select v-model="selectedDate" aria-label="Filter by date">
  105. <option v-for="date in selectableDates" v-bind:key="date" :value="date">
  106. {{ date ?? 'Cutoff Date' }}
  107. </option>
  108. </select>
  109. </div>
  110. <div class="consolidate-option has-background-white px-4 py-1 my-auto">
  111. <label for="consolidate" class="checkbox has-text-dark is-size-6">
  112. <span class="consolidate-label is-inline-block">
  113. Consolidate wallets
  114. </span>
  115. <input
  116. type="checkbox"
  117. class="ml-2"
  118. id="consolidate"
  119. aria-label="Consolidate wallets"
  120. v-model="enableConsolidation"
  121. >
  122. </label>
  123. </div>
  124. </div>
  125. <div id="contribution-container" @scroll="loadMoreIfNeeded">
  126. <p v-if="store.getters.getToken && hasPermission(Privileges.Admin)">
  127. Enable contribution consolidation in order to select.
  128. Click to select a row in order to mark a wallet to receive rewards.
  129. </p>
  130. <table class="contribution-table table is-fullwidth">
  131. <thead>
  132. <tr>
  133. <th>Wallet</th>
  134. <th>Amount</th>
  135. <th v-if="!enableConsolidation">Time</th>
  136. <th v-else>Bonus</th>
  137. </tr>
  138. </thead>
  139. <tbody>
  140. <tr v-for="(contribution, i) in contributions"
  141. :class="{ 'contribution-selected': contribution.selected }"
  142. v-bind:key="i" @click="selectContribution(contribution)">
  143. <td>{{ truncateWallet(contribution.wallet, calculateWalletChars(), undefined) }}</td>
  144. <td>{{ contribution.amount }}</td>
  145. <td v-if="!enableConsolidation">
  146. <span class="transaction-date" :title="formatDate(contribution.createdAt, true)">
  147. {{ formatDate(contribution.createdAt, true) }}
  148. </span>
  149. </td>
  150. <td v-else>
  151. <span>{{ calculateReward(contribution.amount.div(fund.fundInfo.price)) }}</span>
  152. </td>
  153. </tr>
  154. </tbody>
  155. </table>
  156. </div>
  157. <div v-if="store.getters.getToken && hasPermission(Privileges.Admin)">
  158. <div class="box is-flex is-flex-direction-row is-justify-content-space-between">
  159. <div class="my-auto">
  160. <label for="distribute-confirm" class="checkbox">
  161. <input type="checkbox" id="distribute-confirm" v-model="allowDistribution">
  162. Allow Distribution
  163. </label>
  164. </div>
  165. <div>
  166. <button class="button is-success"
  167. :disabled="!allowDistribution" @click="distributeFund">
  168. Distribute Rewards
  169. </button>
  170. </div>
  171. </div>
  172. </div>
  173. </section>
  174. <section class="section is-small px-0"
  175. v-if="store.getters.getToken && hasPermission(Privileges.Admin)">
  176. <div class="title is-size-4 has-text-white-ter has-text-centered">
  177. Submit Group Fund
  178. </div>
  179. <div class="box is-flex is-flex-direction-row is-justify-content-space-between">
  180. <div class="my-auto">
  181. <label class="checkbox" for="submit-confirm">
  182. <input type="checkbox" id="submit-confirm" v-model="allowSubmit"> Allow Submit
  183. </label>
  184. </div>
  185. <div>
  186. <button
  187. class="button is-success"
  188. :disabled="!allowSubmit"
  189. @click="submitFund"
  190. >
  191. Submit
  192. </button>
  193. </div>
  194. </div>
  195. </section>
  196. <section v-if="store.getters.getToken && hasPermission(Privileges.AdminPlus)">
  197. <div class="title is-size-4 has-text-white-ter has-text-centered">
  198. Close Group Fund
  199. </div>
  200. <div class="box is-flex is-flex-direction-row is-justify-content-space-between">
  201. <div class="my-auto">
  202. <label class="checkbox" for="delete-confirm">
  203. <input type="checkbox" id="delete-confirm" v-model="allowDelete"> Allow Close
  204. </label>
  205. </div>
  206. <div>
  207. <button
  208. class="button is-danger"
  209. :disabled="!allowDelete"
  210. @click="deleteFund"
  211. >
  212. Close
  213. </button>
  214. </div>
  215. </div>
  216. </section>
  217. </div>
  218. </template>
  219. <script setup lang="ts">
  220. import {
  221. useRoute,
  222. useRouter,
  223. } from 'vue-router';
  224. import {
  225. Bonus,
  226. Contribution,
  227. FundInfo,
  228. GetRewardFundResponse,
  229. Privileges,
  230. } from '@/api/types';
  231. import {
  232. computed,
  233. Ref,
  234. ref,
  235. watch,
  236. } from 'vue';
  237. import {
  238. useWebSocket,
  239. useWindowSize,
  240. } from '@vueuse/core';
  241. import store from '@/store';
  242. import {
  243. sanitize,
  244. truncateWallet,
  245. } from '@/lib/helpers';
  246. import * as luxon from 'luxon';
  247. import hasPermission from '@/lib/auth';
  248. import ErrorDisplay, { SignetError } from '@/components/ErrorDisplay.vue';
  249. import {
  250. contribute,
  251. deleteRewardFund,
  252. distributeRewardFund,
  253. getBalance,
  254. getContributions,
  255. getRewardFund,
  256. submitRewardFund,
  257. } from '@/api/composed';
  258. import Decimal from 'decimal.js';
  259. const route = useRoute();
  260. const router = useRouter();
  261. const { id } = route.params;
  262. const identifier = parseInt(id as string, 10);
  263. const formatDate = (time: string, includeTime = false) => {
  264. const s = luxon.DateTime.fromISO(time)
  265. .toUTC();
  266. const date = s.toFormat('yyyy-LLL-dd');
  267. return includeTime ? `${date} ${s.toFormat('HH:mm')} (UTC)` : date;
  268. };
  269. const pk = ref('');
  270. const amt = ref(undefined as Decimal | undefined);
  271. const selectableDates = ref([undefined] as (string | undefined)[]);
  272. const selectedDate = ref(undefined as string | undefined);
  273. const { width } = useWindowSize();
  274. const amount = computed({
  275. get: () => amt.value,
  276. set: (v) => {
  277. if (v) {
  278. amt.value = new Decimal(v);
  279. } else {
  280. amt.value = undefined;
  281. }
  282. },
  283. });
  284. const enableConsolidation = ref(false);
  285. const fund = ref(
  286. {
  287. fundInfo: {} as FundInfo,
  288. contributions: {
  289. list: [],
  290. dates: [] as string[],
  291. total: 0,
  292. },
  293. total: { amountHeld: 0 },
  294. } as GetRewardFundResponse | null,
  295. );
  296. const fundDetails = ref([{
  297. title: '',
  298. val: '',
  299. }]);
  300. fund.value = await getRewardFund(identifier, enableConsolidation.value);
  301. if (!fund.value) {
  302. router.push('/');
  303. throw new Error('Fund not found');
  304. }
  305. fundDetails.value = [
  306. {
  307. title: 'Asset',
  308. val: fund.value.fundInfo.asset,
  309. },
  310. {
  311. title: 'Minimum',
  312. val: `${fund.value.fundInfo.minContribution.toLocaleString()}`,
  313. },
  314. {
  315. title: 'Remaining',
  316. val: `${fund.value.fundInfo.amountAvailable.toLocaleString()}`,
  317. },
  318. {
  319. title: 'Memo',
  320. val: `"${fund.value.fundInfo.memo}"`,
  321. },
  322. ];
  323. if (fund.value.contributions.dates) {
  324. selectableDates.value = selectableDates.value.concat(
  325. fund.value.contributions.dates.map((d) => formatDate(d)),
  326. );
  327. }
  328. interface SelectableContribution extends Contribution {
  329. selected: boolean;
  330. }
  331. const processContributions = (contributions: Contribution[]) => contributions.map((c) => ({
  332. ...c,
  333. amount: new Decimal(c.amount),
  334. selected: false,
  335. }));
  336. const reward = ref(new Decimal(0));
  337. const maxBonus = ref(0);
  338. const bonus = ref(undefined as Bonus | undefined);
  339. const amountHeld = ref(new Decimal(fund.value.total.amountHeld));
  340. const amountAvailable = ref(new Decimal(fund.value.fundInfo.amountAvailable));
  341. const contributions: Ref<SelectableContribution[]> = ref(processContributions(
  342. fund.value.contributions.list ?? [],
  343. ));
  344. const offset = ref(contributions.value.length);
  345. const total = ref(fund.value.contributions.total);
  346. const contributionsLoading = ref(false);
  347. const acctBalance = ref(undefined as Decimal | undefined);
  348. const unknownAcct = ref(true);
  349. const loading = ref({
  350. contribution: false,
  351. balance: false,
  352. });
  353. const round = (num: number, figures = 1) => {
  354. const factor = 10 ** figures;
  355. return Math.round(num * factor) / factor;
  356. };
  357. const calcPctComplete = () => round(amountHeld.value.div(amountAvailable.value)
  358. .mul(100)
  359. .toNumber());
  360. const calculateWalletChars = () => round(width.value / 110, 0);
  361. const hasInvalidValues = () => {
  362. if (!fund.value) throw new Error('Fund was not loaded!');
  363. if ([pk, amount].some((v) => v.value === undefined || v.value === '')) return false;
  364. if (!pk.value || !amount.value) throw new Error('One or more validation values were undefined');
  365. return (amount.value.isZero()
  366. || amount.value > amountAvailable.value
  367. || amount.value.lt(fund.value.fundInfo.minContribution)
  368. || (acctBalance.value && amount.value.gt(acctBalance.value))
  369. || unknownAcct.value);
  370. };
  371. const invalidContributionForm = computed(() => hasInvalidValues());
  372. const getCurrentBonus = () => {
  373. if (!fund.value) throw new Error('Fund not found!');
  374. const achievedBonuses = fund.value.fundInfo.bonuses.filter(
  375. (b) => {
  376. if (!fund.value) throw new Error('Fund not found');
  377. return b.goal && fund.value.total.amountHeld >= b.goal;
  378. },
  379. );
  380. if (achievedBonuses.length > 0) {
  381. maxBonus.value = Math.max(...achievedBonuses.map((b) => b.goal ?? -1));
  382. bonus.value = achievedBonuses.find((b) => b.goal === maxBonus.value);
  383. if (!Object.entries(bonus).length) throw new Error('Something went wrong');
  384. }
  385. };
  386. const calculateReward = (bought: Decimal) => {
  387. if (bonus.value) {
  388. if (!bonus.value.percent) throw new Error('Bonus did not have percent for some reason');
  389. reward.value = bought.add(bought)
  390. .mul(new Decimal(bonus.value.percent / 100));
  391. } else {
  392. reward.value = bought;
  393. }
  394. return reward.value.toLocaleString();
  395. };
  396. const selectedContributions = ref([] as Contribution[]);
  397. const selectContribution = (contribution: SelectableContribution) => {
  398. if (!store.getters.getToken || !hasPermission(Privileges.Admin)) return;
  399. if (enableConsolidation.value) {
  400. if (!contribution.selected) {
  401. selectedContributions.value.push(contribution);
  402. } else {
  403. const existingContributionIndex = selectedContributions.value.findIndex(
  404. (c) => c.wallet === contribution.wallet,
  405. );
  406. selectedContributions.value.splice(existingContributionIndex, 1);
  407. }
  408. const newContribution: SelectableContribution = {
  409. ...contribution,
  410. selected: !contribution.selected,
  411. };
  412. const toReplaceIndex = contributions.value.findIndex((c) => c.wallet === contribution.wallet);
  413. if (toReplaceIndex === -1) throw new Error('Could not find contribution to select');
  414. contributions.value.splice(toReplaceIndex, 1, newContribution);
  415. }
  416. };
  417. const allowDelete = ref(false);
  418. const deleteFund = async () => {
  419. const deleted = await deleteRewardFund(identifier, allowDelete.value);
  420. if (deleted && deleted.success) {
  421. await router.push('/');
  422. }
  423. };
  424. const delTimeout = ref(undefined as number | undefined);
  425. watch(allowDelete, () => {
  426. if (delTimeout.value) window.clearTimeout(delTimeout.value);
  427. delTimeout.value = window.setTimeout(() => {
  428. allowDelete.value = false;
  429. delTimeout.value = undefined;
  430. }, 10000);
  431. });
  432. const allowDistribution = ref(false);
  433. const distributeFund = async () => {
  434. const distributed = await distributeRewardFund(
  435. identifier,
  436. selectedContributions.value.map((c) => ({
  437. destination: c.wallet,
  438. amount: c.amount.toNumber(),
  439. })),
  440. allowDistribution.value,
  441. );
  442. if (distributed && distributed.success) {
  443. console.log('distributed'); // TODO: provide feedback for distribution
  444. }
  445. };
  446. const distTimeout = ref(undefined as number | undefined);
  447. watch(allowDistribution, () => {
  448. if (distTimeout.value) window.clearTimeout(distTimeout.value);
  449. distTimeout.value = window.setTimeout(() => {
  450. allowDistribution.value = false;
  451. distTimeout.value = undefined;
  452. }, 10000);
  453. });
  454. const allowSubmit = ref(false);
  455. const submitFund = async () => {
  456. const submitted = await submitRewardFund(identifier, allowSubmit.value);
  457. if (submitted && submitted.success) {
  458. console.log('submitted'); // TODO: provide feedback for submission
  459. }
  460. };
  461. const subTimeout = ref(undefined as number | undefined);
  462. watch(allowSubmit, () => {
  463. if (subTimeout.value) window.clearTimeout(subTimeout.value);
  464. subTimeout.value = window.setTimeout(() => {
  465. allowSubmit.value = false;
  466. subTimeout.value = undefined;
  467. }, 10000);
  468. });
  469. const errs: SignetError[] = [
  470. {
  471. text: 'Amount is required',
  472. condition: amt.value === undefined,
  473. },
  474. {
  475. text: 'Amount must be greater than 0',
  476. condition: amt.value && amt.value.isZero() && !fund.value.fundInfo.minContribution,
  477. },
  478. {
  479. text: 'Amount is less than the minimum contribution',
  480. condition: amt.value && amt.value.lt(fund.value.fundInfo.minContribution),
  481. },
  482. {
  483. text: `Not enough ${fund.value.fundInfo.asset} for sale in ICO`,
  484. condition: amt.value && amt.value > amountAvailable.value,
  485. },
  486. {
  487. text: `Not enough XLM to send (${amt.value?.toLocaleString()})`,
  488. condition: amt.value && acctBalance.value && amt.value.gt(acctBalance.value),
  489. },
  490. {
  491. text: 'Could not find Stellar wallet',
  492. condition: unknownAcct,
  493. },
  494. ];
  495. document.title = `Beignet - ${fund.value.fundInfo.asset}`;
  496. watch(selectedDate, async (newVal) => {
  497. offset.value = 0;
  498. const conts = await getContributions(identifier, offset.value, newVal, enableConsolidation.value);
  499. if (!fund.value) throw new Error('Fund not found');
  500. if (!conts) throw new Error('Contributions not found');
  501. contributions.value = processContributions(conts.list);
  502. offset.value = contributions.value.length;
  503. total.value = fund.value.contributions.total;
  504. });
  505. const loadMoreIfNeeded = async (e: Event) => {
  506. const target = e.target as Element | null;
  507. const canLoadMore = () => target
  508. && (target.scrollTop + target.clientHeight) / target.scrollHeight > 0.8
  509. && offset.value < total.value && !contributionsLoading.value;
  510. if (canLoadMore()) {
  511. contributionsLoading.value = true;
  512. const moreContribs = await getContributions(
  513. identifier,
  514. offset.value,
  515. selectedDate.value,
  516. enableConsolidation.value,
  517. );
  518. if (!moreContribs) throw new Error('Contributions not found');
  519. offset.value += moreContribs.list.length;
  520. total.value = moreContribs.total;
  521. contributions.value = contributions.value.concat(processContributions(moreContribs.list));
  522. contributionsLoading.value = false;
  523. }
  524. };
  525. const {
  526. status,
  527. data,
  528. } = useWebSocket(
  529. 'ws://127.0.0.1:7300/ContributorStream',
  530. {
  531. immediate: true,
  532. autoReconnect: true,
  533. },
  534. );
  535. const queryAccount = async () => {
  536. if (pk.value && pk.value.startsWith('S')) {
  537. loading.value.balance = true;
  538. const resp = await getBalance(pk.value);
  539. if (resp === null) {
  540. unknownAcct.value = true;
  541. acctBalance.value = undefined;
  542. } else {
  543. unknownAcct.value = false;
  544. if (resp && resp.balance) {
  545. acctBalance.value = new Decimal(resp.balance);
  546. if (amt.value && amt.value.gt(acctBalance.value)) {
  547. amt.value = acctBalance.value;
  548. }
  549. }
  550. }
  551. loading.value.balance = false;
  552. }
  553. };
  554. const makeContribution = async () => {
  555. if (!fund.value) throw new Error('Fund not found');
  556. if (!amount.value) return;
  557. if (!/^[0-9]+$/.test(amount.value.toString())) return;
  558. if (unknownAcct.value) return;
  559. if (!loading.value.contribution && pk.value
  560. && amount.value && amount.value <= amountAvailable.value) {
  561. loading.value.contribution = true;
  562. await contribute(sanitize(pk.value), amount.value.toNumber(), fund.value.fundInfo.id);
  563. loading.value.contribution = false;
  564. pk.value = '';
  565. amt.value = undefined;
  566. }
  567. };
  568. watch(enableConsolidation, async () => {
  569. offset.value = 0;
  570. const conts = await getContributions(
  571. identifier,
  572. offset.value,
  573. selectedDate.value,
  574. enableConsolidation.value,
  575. );
  576. if (!fund.value) throw new Error('Fund not found');
  577. if (!conts) throw new Error('Contributions not found');
  578. contributions.value = processContributions(conts.list);
  579. offset.value = contributions.value.length;
  580. total.value = fund.value.contributions.total;
  581. });
  582. watch(data, (newVal) => {
  583. if (!fund.value) throw new Error('Fund not found');
  584. getCurrentBonus();
  585. if (status.value === 'OPEN') {
  586. const v = {
  587. ...JSON.parse(newVal.trim()),
  588. selected: false,
  589. } as SelectableContribution;
  590. v.createdAt = luxon.DateTime.now()
  591. .toISO();
  592. const formattedDate = formatDate(v.createdAt);
  593. if (!selectableDates.value.includes(formattedDate)) {
  594. selectableDates.value.push(formattedDate);
  595. }
  596. if (enableConsolidation.value && contributions.value
  597. && contributions.value.map((c: Contribution) => c.wallet)
  598. .includes(v.wallet)) {
  599. const hasContribution = contributions.value.find((c: Contribution) => c.wallet === v.wallet);
  600. if (!hasContribution) throw new Error('Something went wrong');
  601. hasContribution.amount = new Decimal(hasContribution.amount).add(v.amount);
  602. } else {
  603. contributions.value.splice(0, 0, v);
  604. offset.value += 1;
  605. }
  606. amountHeld.value = new Decimal(amountHeld.value).add(v.amount);
  607. amountAvailable.value = new Decimal(amountAvailable.value).sub(v.amount);
  608. }
  609. });
  610. </script>
  611. <style scoped lang="stylus">
  612. .transaction-date
  613. white-space nowrap
  614. overflow hidden
  615. max-width 20vw
  616. display block
  617. .consolidate-label
  618. max-width: 24vw;
  619. white-space: nowrap;
  620. overflow: hidden;
  621. vertical-align: bottom;
  622. text-overflow: ellipsis;
  623. .total-label, .remaining-label
  624. border-right 1px solid #8f8f8f
  625. font-variant all-petite-caps
  626. .signet-asset-name
  627. cursor pointer
  628. tbody tr
  629. cursor pointer
  630. #contribution-container
  631. max-height 360px
  632. overflow-y auto
  633. .consolidate-option
  634. border-radius 16px
  635. .fund-description
  636. min-height 280px
  637. .fund-details
  638. width 182px
  639. .contribution-selected
  640. background-color #e0cdbb
  641. #consolidate
  642. vertical-align middle
  643. @media screen and (min-width: 1024px)
  644. .fund-details
  645. max-width 14vw
  646. border-left 1px #777 solid
  647. padding-left 10px
  648. </style>