The frontend for the project formerly known as signet, now known as beignet.
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.
 
 
 
 

697 line
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>