The frontend for the project formerly known as signet, now known as beignet.
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

654 satır
20 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. <p class="py-2" v-if="store.getters.token && hasPermission(Privileges.Admin)">
  126. Enable contribution consolidation in order to select.
  127. Click to select a row in order to mark a wallet to receive rewards.
  128. </p>
  129. <div id="contribution-container" @scroll="loadMoreIfNeeded">
  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 class="my-4" v-if="store.getters.token && hasPermission(Privileges.Admin)">
  158. <ButtonWithConfirmation
  159. :condition="selectedContributions.length === 0"
  160. :inputs="{ button: { label: 'Distribute Rewards', style: 'is-success' },
  161. checkbox: { label: 'Allow Distribution' }}"
  162. :modification="distributeFund"
  163. @confirmed="setAllowDistribution"
  164. />
  165. </div>
  166. </section>
  167. <section class="section is-small px-0"
  168. v-if="store.getters.token && hasPermission(Privileges.Admin)">
  169. <div class="title is-size-4 has-text-white-ter has-text-centered">
  170. Submit Group Fund
  171. </div>
  172. <ButtonWithConfirmation
  173. :inputs="{ button: { label: 'Submit Fund', style: 'is-success' },
  174. checkbox: { label: 'Allow Submission' }}"
  175. :modification="submitFund"
  176. @confirmed="setAllowSubmit"
  177. />
  178. </section>
  179. <section v-if="store.getters.token && hasPermission(Privileges.AdminPlus)">
  180. <div class="title is-size-4 has-text-white-ter has-text-centered">
  181. Close Group Fund
  182. </div>
  183. <ButtonWithConfirmation
  184. :inputs="{ button: { label: 'Close Fund', style: 'is-danger' },
  185. checkbox: { label: 'Allow Closing' }}"
  186. :modification="deleteFund"
  187. @confirmed="setAllowDelete"
  188. />
  189. </section>
  190. </div>
  191. </template>
  192. <script setup lang="ts">
  193. import {
  194. useRoute,
  195. useRouter,
  196. } from 'vue-router';
  197. import {
  198. Bonus,
  199. Contribution,
  200. FundInfo,
  201. GetRewardFundResponse,
  202. Privileges,
  203. } from '@/api/types';
  204. import {
  205. computed,
  206. Ref,
  207. ref,
  208. watch,
  209. } from 'vue';
  210. import {
  211. useWebSocket,
  212. useWindowSize,
  213. } from '@vueuse/core';
  214. import store from '@/store';
  215. import {
  216. sanitize,
  217. truncateWallet,
  218. } from '@/lib/helpers';
  219. import * as luxon from 'luxon';
  220. import hasPermission from '@/lib/auth';
  221. import ErrorDisplay, { SignetError } from '@/components/ErrorDisplay.vue';
  222. import {
  223. contribute,
  224. deleteRewardFund,
  225. distributeRewardFund,
  226. getBalance,
  227. getContributions,
  228. getRewardFund,
  229. submitRewardFund,
  230. } from '@/api/composed';
  231. import Decimal from 'decimal.js';
  232. import ButtonWithConfirmation from '@/components/ButtonWithConfirmation.vue';
  233. const route = useRoute();
  234. const router = useRouter();
  235. const { id } = route.params;
  236. const identifier = parseInt(id as string, 10);
  237. const formatDate = (time: string, includeTime = false) => {
  238. const s = luxon.DateTime.fromISO(time)
  239. .toUTC();
  240. const date = s.toFormat('yyyy-LLL-dd');
  241. return includeTime ? `${date} ${s.toFormat('HH:mm')} (UTC)` : date;
  242. };
  243. const pk = ref('');
  244. const amt = ref(undefined as Decimal | undefined);
  245. const selectableDates = ref([undefined] as (string | undefined)[]);
  246. const selectedDate = ref(undefined as string | undefined);
  247. const { width } = useWindowSize();
  248. const amount = computed({
  249. get: () => amt.value,
  250. set: (v) => {
  251. if (v) {
  252. amt.value = new Decimal(v);
  253. } else {
  254. amt.value = undefined;
  255. }
  256. },
  257. });
  258. const enableConsolidation = ref(false);
  259. const fund = ref(
  260. {
  261. fundInfo: {} as FundInfo,
  262. contributions: {
  263. list: [],
  264. dates: [] as string[],
  265. total: 0,
  266. },
  267. total: { amountHeld: 0 },
  268. } as GetRewardFundResponse | null,
  269. );
  270. const fundDetails = ref([{
  271. title: '',
  272. val: '',
  273. }]);
  274. fund.value = await getRewardFund(identifier, enableConsolidation.value);
  275. if (!fund.value) {
  276. router.push('/');
  277. throw new Error('Fund not found');
  278. }
  279. fundDetails.value = [
  280. {
  281. title: 'Asset',
  282. val: fund.value.fundInfo.asset,
  283. },
  284. {
  285. title: 'Minimum',
  286. val: `${fund.value.fundInfo.minContribution.toLocaleString()}`,
  287. },
  288. {
  289. title: 'Remaining',
  290. val: `${fund.value.fundInfo.amountAvailable.toLocaleString()}`,
  291. },
  292. {
  293. title: 'Memo',
  294. val: `"${fund.value.fundInfo.memo}"`,
  295. },
  296. ];
  297. if (fund.value.contributions.dates) {
  298. selectableDates.value = selectableDates.value.concat(
  299. fund.value.contributions.dates.map((d) => formatDate(d)),
  300. );
  301. }
  302. interface SelectableContribution extends Contribution {
  303. selected: boolean;
  304. }
  305. const processContributions = (contributions: Contribution[]) => contributions.map((c) => ({
  306. ...c,
  307. amount: new Decimal(c.amount),
  308. selected: false,
  309. }));
  310. const reward = ref(new Decimal(0));
  311. const maxBonus = ref(0);
  312. const bonus = ref(undefined as Bonus | undefined);
  313. const amountHeld = ref(new Decimal(fund.value.total.amountHeld));
  314. const amountAvailable = ref(new Decimal(fund.value.fundInfo.amountAvailable));
  315. const contributions: Ref<SelectableContribution[]> = ref(processContributions(
  316. fund.value.contributions.list ?? [],
  317. ));
  318. const offset = ref(contributions.value.length);
  319. const total = ref(fund.value.contributions.total);
  320. const contributionsLoading = ref(false);
  321. const acctBalance = ref(undefined as Decimal | undefined);
  322. const unknownAcct = ref(true);
  323. const loading = ref({
  324. contribution: false,
  325. balance: false,
  326. });
  327. const round = (num: number, figures = 1) => {
  328. const factor = 10 ** figures;
  329. return Math.round(num * factor) / factor;
  330. };
  331. const calcPctComplete = () => round(amountHeld.value.div(amountAvailable.value)
  332. .mul(100)
  333. .toNumber());
  334. const calculateWalletChars = () => round(width.value / 110, 0);
  335. const hasInvalidValues = () => {
  336. if (!fund.value) throw new Error('Fund was not loaded!');
  337. if ([pk, amount].some((v) => v.value === undefined || v.value === '')) return false;
  338. if (!pk.value || !amount.value) throw new Error('One or more validation values were undefined');
  339. return (amount.value.isZero()
  340. || amount.value > amountAvailable.value
  341. || amount.value.lt(fund.value.fundInfo.minContribution)
  342. || (acctBalance.value && amount.value.gt(acctBalance.value))
  343. || unknownAcct.value);
  344. };
  345. const invalidContributionForm = computed(() => hasInvalidValues());
  346. const getCurrentBonus = () => {
  347. if (!fund.value) throw new Error('Fund not found!');
  348. const achievedBonuses = fund.value.fundInfo.bonuses.filter(
  349. (b) => {
  350. if (!fund.value) throw new Error('Fund not found');
  351. return b.goal && fund.value.total.amountHeld >= b.goal;
  352. },
  353. );
  354. if (achievedBonuses.length > 0) {
  355. maxBonus.value = Math.max(...achievedBonuses.map((b) => b.goal ?? -1));
  356. bonus.value = achievedBonuses.find((b) => b.goal === maxBonus.value);
  357. if (!Object.entries(bonus).length) throw new Error('Something went wrong');
  358. }
  359. };
  360. const calculateReward = (bought: Decimal) => {
  361. if (bonus.value) {
  362. if (!bonus.value.percent) throw new Error('Bonus did not have percent for some reason');
  363. reward.value = bought.add(bought)
  364. .mul(new Decimal(bonus.value.percent / 100));
  365. } else {
  366. reward.value = bought;
  367. }
  368. return reward.value.toLocaleString();
  369. };
  370. const selectedContributions = ref([] as Contribution[]);
  371. const selectContribution = (contribution: SelectableContribution) => {
  372. if (!store.getters.token || !hasPermission(Privileges.Admin)) return;
  373. if (enableConsolidation.value) {
  374. if (!contribution.selected) {
  375. selectedContributions.value.push(contribution);
  376. } else {
  377. const existingContributionIndex = selectedContributions.value.findIndex(
  378. (c) => c.wallet === contribution.wallet,
  379. );
  380. selectedContributions.value.splice(existingContributionIndex, 1);
  381. }
  382. const newContribution: SelectableContribution = {
  383. ...contribution,
  384. selected: !contribution.selected,
  385. };
  386. const toReplaceIndex = contributions.value.findIndex((c) => c.wallet === contribution.wallet);
  387. if (toReplaceIndex === -1) throw new Error('Could not find contribution to select');
  388. contributions.value.splice(toReplaceIndex, 1, newContribution);
  389. }
  390. };
  391. const allowDelete = ref(false);
  392. const allowDistribution = ref(false);
  393. const allowSubmit = ref(false);
  394. const setAllowDelete = (val: boolean) => {
  395. allowDelete.value = val;
  396. };
  397. const setAllowDistribution = (val: boolean) => {
  398. allowDistribution.value = val;
  399. };
  400. const setAllowSubmit = (val: boolean) => {
  401. allowSubmit.value = val;
  402. };
  403. const deleteFund = async () => {
  404. const deleted = await deleteRewardFund(identifier, allowDelete.value);
  405. if (deleted && deleted.success) {
  406. await router.push('/');
  407. }
  408. };
  409. const distributeFund = async () => {
  410. const distributed = await distributeRewardFund(
  411. identifier,
  412. selectedContributions.value.map((c) => ({
  413. destination: c.wallet,
  414. amount: c.amount.toNumber(),
  415. })),
  416. allowDistribution.value,
  417. );
  418. if (distributed && distributed.success) {
  419. console.log('distributed'); // TODO: provide feedback for distribution
  420. }
  421. };
  422. const submitFund = async () => {
  423. const submitted = await submitRewardFund(identifier, allowSubmit.value);
  424. if (submitted && submitted.success) {
  425. console.log('submitted'); // TODO: provide feedback for submission
  426. }
  427. };
  428. const errs: SignetError[] = [
  429. {
  430. text: 'Amount is required',
  431. condition: amt.value === undefined,
  432. },
  433. {
  434. text: 'Amount must be greater than 0',
  435. condition: amt.value && amt.value.isZero() && !fund.value.fundInfo.minContribution,
  436. },
  437. {
  438. text: 'Amount is less than the minimum contribution',
  439. condition: amt.value && amt.value.lt(fund.value.fundInfo.minContribution),
  440. },
  441. {
  442. text: `Not enough ${fund.value.fundInfo.asset} for sale in ICO`,
  443. condition: amt.value && amt.value > amountAvailable.value,
  444. },
  445. {
  446. text: `Not enough XLM to send (${amt.value?.toLocaleString()})`,
  447. condition: amt.value && acctBalance.value && amt.value.gt(acctBalance.value),
  448. },
  449. {
  450. text: 'Could not find Stellar wallet',
  451. condition: unknownAcct,
  452. },
  453. ];
  454. document.title = `Beignet - ${fund.value.fundInfo.asset}`;
  455. watch(selectedDate, async (newVal) => {
  456. offset.value = 0;
  457. const conts = await getContributions(identifier, offset.value, newVal, enableConsolidation.value);
  458. if (!fund.value) throw new Error('Fund not found');
  459. if (!conts) throw new Error('Contributions not found');
  460. contributions.value = processContributions(conts.list);
  461. offset.value = contributions.value.length;
  462. total.value = fund.value.contributions.total;
  463. });
  464. const loadMoreIfNeeded = async (e: Event) => {
  465. const target = e.target as Element | null;
  466. const canLoadMore = () => target
  467. && (target.scrollTop + target.clientHeight) / target.scrollHeight > 0.8
  468. && offset.value < total.value && !contributionsLoading.value;
  469. if (canLoadMore()) {
  470. contributionsLoading.value = true;
  471. const moreContribs = await getContributions(
  472. identifier,
  473. offset.value,
  474. selectedDate.value,
  475. enableConsolidation.value,
  476. );
  477. if (!moreContribs) throw new Error('Contributions not found');
  478. offset.value += moreContribs.list.length;
  479. total.value = moreContribs.total;
  480. contributions.value = contributions.value.concat(processContributions(moreContribs.list));
  481. contributionsLoading.value = false;
  482. }
  483. };
  484. const {
  485. status,
  486. data,
  487. } = useWebSocket(
  488. 'ws://127.0.0.1:7300/ContributorStream',
  489. {
  490. immediate: true,
  491. autoReconnect: true,
  492. },
  493. );
  494. const queryAccount = async () => {
  495. if (pk.value && pk.value.startsWith('S')) {
  496. loading.value.balance = true;
  497. const resp = await getBalance(pk.value);
  498. if (resp === null) {
  499. unknownAcct.value = true;
  500. acctBalance.value = undefined;
  501. } else {
  502. unknownAcct.value = false;
  503. if (resp && resp.balance) {
  504. acctBalance.value = new Decimal(resp.balance);
  505. if (amt.value && amt.value.gt(acctBalance.value)) {
  506. amt.value = acctBalance.value;
  507. }
  508. }
  509. }
  510. loading.value.balance = false;
  511. }
  512. };
  513. const makeContribution = async () => {
  514. if (!fund.value) throw new Error('Fund not found');
  515. if (!amount.value) return;
  516. if (!/^[0-9]+$/.test(amount.value.toString())) return;
  517. if (unknownAcct.value) return;
  518. if (!loading.value.contribution && pk.value
  519. && amount.value && amount.value <= amountAvailable.value) {
  520. loading.value.contribution = true;
  521. await contribute(sanitize(pk.value), amount.value.toNumber(), fund.value.fundInfo.id);
  522. loading.value.contribution = false;
  523. pk.value = '';
  524. amt.value = undefined;
  525. }
  526. };
  527. watch(enableConsolidation, async () => {
  528. offset.value = 0;
  529. const conts = await getContributions(
  530. identifier,
  531. offset.value,
  532. selectedDate.value,
  533. enableConsolidation.value,
  534. );
  535. if (!fund.value) throw new Error('Fund not found');
  536. if (!conts) throw new Error('Contributions not found');
  537. contributions.value = processContributions(conts.list);
  538. offset.value = contributions.value.length;
  539. total.value = fund.value.contributions.total;
  540. });
  541. watch(data, (newVal) => {
  542. if (!fund.value) throw new Error('Fund not found');
  543. getCurrentBonus();
  544. if (status.value === 'OPEN') {
  545. const v = {
  546. ...JSON.parse(newVal.trim()),
  547. selected: false,
  548. } as SelectableContribution;
  549. v.createdAt = luxon.DateTime.now()
  550. .toISO();
  551. const formattedDate = formatDate(v.createdAt);
  552. if (!selectableDates.value.includes(formattedDate)) {
  553. selectableDates.value.push(formattedDate);
  554. }
  555. if (enableConsolidation.value && contributions.value
  556. && contributions.value.map((c: Contribution) => c.wallet)
  557. .includes(v.wallet)) {
  558. const hasContribution = contributions.value.find((c: Contribution) => c.wallet === v.wallet);
  559. if (!hasContribution) throw new Error('Something went wrong');
  560. hasContribution.amount = new Decimal(hasContribution.amount).add(v.amount);
  561. } else {
  562. contributions.value.splice(0, 0, v);
  563. offset.value += 1;
  564. }
  565. amountHeld.value = new Decimal(amountHeld.value).add(v.amount);
  566. amountAvailable.value = new Decimal(amountAvailable.value).sub(v.amount);
  567. }
  568. });
  569. </script>
  570. <style scoped lang="stylus">
  571. .transaction-date
  572. white-space nowrap
  573. overflow hidden
  574. max-width 20vw
  575. display block
  576. .consolidate-label
  577. max-width: 24vw;
  578. white-space: nowrap;
  579. overflow: hidden;
  580. vertical-align: bottom;
  581. text-overflow: ellipsis;
  582. .total-label, .remaining-label
  583. border-right 1px solid #8f8f8f
  584. font-variant all-petite-caps
  585. .signet-asset-name
  586. cursor pointer
  587. tbody tr
  588. cursor pointer
  589. #contribution-container
  590. max-height 360px
  591. overflow-y auto
  592. .consolidate-option
  593. border-radius 16px
  594. .fund-description
  595. min-height 280px
  596. .fund-details
  597. width 182px
  598. .contribution-selected
  599. background-color #e0cdbb
  600. #consolidate
  601. vertical-align middle
  602. @media screen and (min-width: 1024px)
  603. .fund-details
  604. max-width 14vw
  605. border-left 1px #777 solid
  606. padding-left 10px
  607. </style>