The frontend for the project formerly known as signet, now known as beignet.
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

FundView.vue 20 KiB

2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
2 lat temu
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  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>