| @@ -43,6 +43,12 @@ | |||
| </Suspense> | |||
| </RouterView> | |||
| </div> | |||
| <footer> | |||
| <div> | |||
| Proudly made in Michigan <div class="michigan-icon"></div> | |||
| </div> | |||
| </footer> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| @@ -85,4 +91,23 @@ body | |||
| min-height 100vh | |||
| color #e8dbca | |||
| background-color #313538 | |||
| footer | |||
| color #707070 | |||
| padding 12px | |||
| margin-top 10px | |||
| & * | |||
| vertical-align middle | |||
| text-align center | |||
| .michigan-icon | |||
| display inline-block | |||
| width 28px | |||
| height 28px | |||
| background-image url("./assets/icons8-michigan-50.png") | |||
| background-repeat no-repeat | |||
| background-position center | |||
| background-size 28px | |||
| margin-left 4px | |||
| </style> | |||
| @@ -61,10 +61,11 @@ export interface FundInfo { | |||
| id: number; | |||
| asset: string; | |||
| fundWallet: string; | |||
| fundSecret: string; | |||
| sellingWallet: string; | |||
| issuerWallet: string; | |||
| memo: string; | |||
| price: number; | |||
| amountAvailable: number; | |||
| amountGoal: number; | |||
| minContribution: number; | |||
| title: string; | |||
| @@ -12,7 +12,7 @@ | |||
| </li> | |||
| <li class="is-size-7 is-inline-block px-2"> | |||
| <span class="stellar-icon-base goal"></span> | |||
| <span class="fund-label">{{ props.fund.amountGoal.toLocaleString() }}</span> | |||
| <span class="fund-label">{{ props.fund.amountAvailable.toLocaleString() }}</span> | |||
| </li> | |||
| <li class="is-size-7 is-inline-block px-2"> | |||
| <span class="stellar-icon-base memo"></span> | |||
| @@ -1,6 +1,2 @@ | |||
| import { useRouter } from 'vue-router'; | |||
| const router = useRouter(); | |||
| export const truncateWallet: (wallet: string, preDigits: number, postDigits: number | undefined) => string = (wallet: string, preDigits: number, postDigits = preDigits) => `${wallet.slice(0, preDigits)}...${wallet.slice(-(postDigits + 1), -1)}`; | |||
| export const isNumber = (s: string) => /^[0-9]+$/.test(s); | |||
| @@ -5,11 +5,16 @@ import { | |||
| } from 'vue-router'; | |||
| import RegisterView from '@/views/RegisterView.vue'; | |||
| import LoginView from '@/views/LoginView.vue'; | |||
| import { Privileges } from '@/api/types'; | |||
| import { | |||
| Privileges, | |||
| SuccessResponse, | |||
| } from '@/api/types'; | |||
| import HomeView from '@/views/HomeView.vue'; | |||
| import FundView from '@/views/FundView.vue'; | |||
| import AddFundView from '@/views/AddFundView.vue'; | |||
| import hasPermission from '@/lib/auth'; | |||
| import SignetRequestController from '@/api/requests'; | |||
| import store from '@/store'; | |||
| const routes: Array<RouteRecordRaw> = [ | |||
| { | |||
| @@ -36,6 +41,11 @@ const routes: Array<RouteRecordRaw> = [ | |||
| component: RegisterView, | |||
| meta: { | |||
| requiredRights: Privileges.AdminPlus, | |||
| accessible: async () => { | |||
| const controller = new SignetRequestController(store.getters.getToken); | |||
| const canProceed = await controller.post<SuccessResponse, null>('/UsersExist', null); | |||
| return canProceed?.success; | |||
| }, | |||
| title: 'Register', | |||
| }, | |||
| }, | |||
| @@ -58,7 +68,12 @@ const router = createRouter({ | |||
| router.beforeEach(async (to, from, next) => { | |||
| document.title = `Beignet - ${to.meta.title}`; | |||
| if (hasPermission(to.meta.requiredRights as number)) { | |||
| const requiredRights = to.meta.requiredRights as number | undefined; | |||
| const accessible = to.meta.accessible as (() => SuccessResponse) | undefined; | |||
| const allowed = requiredRights ? hasPermission(requiredRights) | |||
| || (accessible && !accessible().success) : true; | |||
| if (allowed) { | |||
| next(); | |||
| } else { | |||
| next('/'); | |||
| @@ -22,7 +22,7 @@ | |||
| </div> | |||
| <div class="control my-2"> | |||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||
| placeholder="Fund Secret" aria-label="Fund Wallet" v-model="fundSecret"> | |||
| placeholder="Selling Wallet" aria-label="Selling Wallet" v-model="sellWallet"> | |||
| </div> | |||
| <div class="control my-2"> | |||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||
| @@ -33,16 +33,17 @@ | |||
| <div class="title is-5 has-text-white-ter">Fund</div> | |||
| <div class="control my-2 is-flex is-justify-content-space-between"> | |||
| <input class="input is-normal mr-1 has-background-white has-text-black" type="text" | |||
| placeholder="Asset" aria-label="Asset" v-model="asset"> | |||
| <input class="input is-normal ml-1 has-background-white has-text-black" type="text" | |||
| placeholder="Asset Code" aria-label="Asset" v-model="asset"> | |||
| <input class="input is-normal mx-1 has-background-white has-text-black" type="text" | |||
| placeholder="Memo" aria-label="Memo" v-model="memo"> | |||
| </div> | |||
| <div class="control my-2 is-flex is-justify-content-space-between"> | |||
| <input class="input is-normal mr-1 has-background-white has-text-black" type="number" | |||
| placeholder="Min Contribution" aria-label="Asset" v-model="minContribution"> | |||
| <input class="input is-normal ml-1 has-background-white has-text-black" type="number" | |||
| placeholder="Amount Goal" aria-label="Memo" v-model="amtGoal"> | |||
| placeholder="Min Contribution" aria-label="Asset" v-model="minContribution"> | |||
| </div> | |||
| <!-- <div class="control my-2 is-flex is-justify-content-space-between">--> | |||
| <!-- <input class="input is-normal ml-1 has-background-white has-text-black" type="number"--> | |||
| <!-- placeholder="Amount Goal" aria-label="Memo" v-model="amtGoal">--> | |||
| <!-- </div>--> | |||
| </section> | |||
| <section class="section px-0 py-4"> | |||
| <div class="title is-5 has-text-white-ter">Bonus Structure</div> | |||
| @@ -81,12 +82,13 @@ const controller = new SignetRequestController(store.getters.getToken); | |||
| const title = ref(''); | |||
| const description = ref(''); | |||
| const fundWallet = ref(''); | |||
| const fundSecret = ref(''); | |||
| const sellWallet = ref(''); | |||
| // const fundSecret = ref(''); | |||
| const issuerWallet = ref(''); | |||
| const asset = ref(''); | |||
| const memo = ref(''); | |||
| const minContribution = ref(undefined as number | undefined); | |||
| const amtGoal = ref(undefined as number | undefined); | |||
| // const amtGoal = ref(undefined as number | undefined); | |||
| const bonuses = ref([] as Bonus[]); | |||
| const saveBonuses = (evt: Bonus[]) => { | |||
| @@ -100,10 +102,10 @@ const submit = async () => { | |||
| const resp = await controller.post<SuccessResponse, Partial<FundInfo>>('CreateRewardFund', { | |||
| asset: asset.value, | |||
| fundWallet: fundWallet.value, | |||
| fundSecret: fundSecret.value, | |||
| sellingWallet: sellWallet.value, | |||
| issuerWallet: issuerWallet.value, | |||
| memo: memo.value, | |||
| amountGoal: amtGoal.value as number, | |||
| // amountGoal: amtGoal.value as number, | |||
| minContribution: minContribution.value as number, | |||
| title: title.value, | |||
| description: description.value, | |||
| @@ -29,14 +29,6 @@ | |||
| <div class="title is-size-4 has-text-grey-dark has-text-centered"> | |||
| Tracker | |||
| </div> | |||
| <div class="has-text-centered is-size-4 has-text-grey-dark mb-3"> | |||
| <span class="total-label is-size-3 pr-2 has-text-weight-light"> | |||
| Total | |||
| </span> | |||
| <span class="pl-3 has-text-weight-bold"> | |||
| {{ fund.total.amountHeld.toLocaleString() }} XLM | |||
| </span> | |||
| </div> | |||
| <div class="level" v-if="fund.fundInfo.bonuses.length > 0"> | |||
| <div class="level-item has-text-centered" | |||
| v-for="bonus in fund.fundInfo.bonuses.sort((v1, v2) => v1.goal - v2.goal)" | |||
| @@ -45,14 +37,14 @@ | |||
| <div> | |||
| <p | |||
| class="heading" | |||
| :class="fund.total.amountHeld >= bonus.goal | |||
| :class="amountHeld >= bonus.goal | |||
| ? 'has-text-success' : 'has-text-grey-dark'" | |||
| > | |||
| {{ bonus.goal.toLocaleString() }} XLM | |||
| </p> | |||
| <p | |||
| class="title" | |||
| :class="fund.total.amountHeld >= bonus.goal | |||
| :class="amountHeld >= bonus.goal | |||
| ? 'has-text-success' : 'has-text-grey-dark'" | |||
| > | |||
| {{ bonus.percent }}% | |||
| @@ -61,6 +53,31 @@ | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div | |||
| class="is-flex | |||
| is-flex-direction-row | |||
| is-justify-content-space-around | |||
| is-size-4 | |||
| has-text-white | |||
| mb-3" | |||
| > | |||
| <div class="total"> | |||
| <span class="total-label is-size-3 pr-2 has-text-weight-light"> | |||
| Raised | |||
| </span> | |||
| <span class="pl-3 has-text-weight-bold"> | |||
| {{ amountHeld.toLocaleString() }} XLM | |||
| </span> | |||
| </div> | |||
| <div class="remaining"> | |||
| <span class="remaining-label is-size-3 pr-2 has-text-weight-light"> | |||
| Remaining | |||
| </span> | |||
| <span class="pl-3 has-text-weight-bold"> | |||
| {{ amountAvailable.toLocaleString() }} XLM | |||
| </span> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| <section class="section is-small"> | |||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||
| @@ -78,6 +95,7 @@ | |||
| <button | |||
| class="button is-primary" | |||
| :class="requesting ? 'is-loading' : ''" | |||
| :disabled="amt > amountAvailable" | |||
| @click="makeContribution" | |||
| >Submit</button> | |||
| </div> | |||
| @@ -143,7 +161,7 @@ | |||
| <div class="box is-flex is-flex-direction-row is-justify-content-space-between"> | |||
| <div class="my-auto"> | |||
| <label class="checkbox" for="delete-confirm"> | |||
| <input type="checkbox" id="delete-confirm" v-model="allowDelete"> Delete | |||
| <input type="checkbox" id="delete-confirm" v-model="allowDelete"> Close | |||
| </label> | |||
| </div> | |||
| <div> | |||
| @@ -152,7 +170,7 @@ | |||
| :disabled="!allowDelete" | |||
| @click="deleteFund" | |||
| > | |||
| Delete | |||
| Close | |||
| </button> | |||
| </div> | |||
| </div> | |||
| @@ -254,7 +272,7 @@ fundDetails.value = [ | |||
| }, | |||
| { | |||
| title: 'Goal', | |||
| val: `${fund.value.fundInfo.amountGoal.toLocaleString()}`, | |||
| val: `${fund.value.fundInfo.amountAvailable.toLocaleString()}`, | |||
| }, | |||
| { | |||
| title: 'Memo', | |||
| @@ -270,21 +288,27 @@ if (fund.value.contributions.dates) { | |||
| const reward = ref(0); | |||
| const maxBonus = ref(0); | |||
| const bonus = ref(undefined as Bonus | undefined); | |||
| const achievedBonuses = fund.value.fundInfo.bonuses.filter( | |||
| (b) => { | |||
| if (!fund.value) throw new Error('Fund not found'); | |||
| return b.goal && fund.value.total.amountHeld >= b.goal; | |||
| }, | |||
| ); | |||
| if (achievedBonuses.length > 0) { | |||
| maxBonus.value = Math.max(...achievedBonuses.map((b) => b.goal ?? -1)); | |||
| bonus.value = achievedBonuses.find((b) => b.goal === maxBonus.value); | |||
| if (!Object.entries(bonus).length) throw new Error('Something went wrong'); | |||
| } | |||
| const amountHeld = ref(fund.value.total.amountHeld); | |||
| const amountAvailable = ref(fund.value.fundInfo.amountAvailable); | |||
| const calculateReward = (bought: number) => { | |||
| const getCurrentBonus = () => { | |||
| if (!fund.value) throw new Error('Fund not found!'); | |||
| const achievedBonuses = fund.value.fundInfo.bonuses.filter( | |||
| (b) => { | |||
| if (!fund.value) throw new Error('Fund not found'); | |||
| return b.goal && fund.value.total.amountHeld >= b.goal; | |||
| }, | |||
| ); | |||
| if (achievedBonuses.length > 0) { | |||
| if (!bonus.value || !bonus.value.percent) throw new Error('Something went wrong'); | |||
| maxBonus.value = Math.max(...achievedBonuses.map((b) => b.goal ?? -1)); | |||
| bonus.value = achievedBonuses.find((b) => b.goal === maxBonus.value); | |||
| if (!Object.entries(bonus).length) throw new Error('Something went wrong'); | |||
| } | |||
| }; | |||
| const calculateReward = (bought: number) => { | |||
| if (bonus.value) { | |||
| if (!bonus.value.percent) throw new Error('Bonus did not have percent for some reason'); | |||
| reward.value = bought + bought * (bonus.value.percent / 100); | |||
| } else { | |||
| reward.value = bought; | |||
| @@ -377,9 +401,14 @@ const { | |||
| watch(data, (newVal) => { | |||
| if (!fund.value) throw new Error('Fund not found'); | |||
| getCurrentBonus(); | |||
| if (status.value === 'OPEN') { | |||
| const v = JSON.parse(newVal.trim()) as Contribution; | |||
| v.CreatedAt = luxon.DateTime.now().toISO(); | |||
| const formattedDate = formatDate(v.CreatedAt); | |||
| if (!selectableDates.value.includes(formattedDate)) { | |||
| selectableDates.value.push(formattedDate); | |||
| } | |||
| if (enableConsolidation.value && contributions.value | |||
| && contributions.value.map((c: Contribution) => c.wallet).includes(v.wallet)) { | |||
| const hasContribution = contributions.value.find((c: Contribution) => c.wallet === v.wallet); | |||
| @@ -388,19 +417,16 @@ watch(data, (newVal) => { | |||
| } else { | |||
| contributions.value.splice(0, 0, v); | |||
| offset.value += 1; | |||
| const formattedDate = formatDate(v.CreatedAt); | |||
| if (!selectableDates.value.includes(formattedDate)) { | |||
| selectableDates.value.push(formattedDate); | |||
| } | |||
| } | |||
| fund.value.total.amountHeld += v.amount; | |||
| amountHeld.value += v.amount; | |||
| amountAvailable.value -= v.amount; | |||
| } | |||
| }); | |||
| const requesting = ref(false); | |||
| const makeContribution = async () => { | |||
| if (!fund.value) throw new Error('Fund not found'); | |||
| if (!requesting.value && pk.value && amt.value) { | |||
| if (!requesting.value && pk.value && amt.value && amt.value <= amountAvailable.value) { | |||
| requesting.value = true; | |||
| await controller.post<SuccessResponse, ContributeRequest>('Contribute', { | |||
| privateKey: pk.value, | |||
| @@ -428,7 +454,7 @@ const makeContribution = async () => { | |||
| vertical-align: bottom; | |||
| text-overflow: ellipsis; | |||
| .total-label | |||
| .total-label, .remaining-label | |||
| border-right 1px solid #8f8f8f | |||
| font-variant all-petite-caps | |||
| @@ -1,13 +1,18 @@ | |||
| <template> | |||
| <div class="container is-max-desktop"> | |||
| <section class="section is-small px-0"> | |||
| <div class="container-grid"> | |||
| <div class="container-grid" v-if="rewardFunds"> | |||
| <template v-for="fund in rewardFunds" v-bind:key="fund.id"> | |||
| <RouterLink :to="`/fund/${fund.id}`"> | |||
| <FundLink :fund="fund"/> | |||
| </RouterLink> | |||
| </template> | |||
| </div> | |||
| <div v-else> | |||
| <div class="has-text-centered is-size-4"> | |||
| No group funds yet! | |||
| </div> | |||
| </div> | |||
| </section> | |||
| </div> | |||
| </template> | |||
| @@ -40,7 +40,7 @@ const submit = async () => { | |||
| if (!resp) throw new Error('Could not get response from registration'); | |||
| success.value = resp.success; | |||
| if (success.value) { | |||
| await router.push('/login'); | |||
| await router.push('/'); | |||
| } | |||
| }; | |||
| </script> | |||
| @@ -1,5 +1,16 @@ | |||
| const { defineConfig } = require('@vue/cli-service'); | |||
| module.exports = defineConfig({ | |||
| devServer: { | |||
| proxy: { | |||
| '^/api': { | |||
| target: 'http://localhost:7300', | |||
| changeOrigin: true, | |||
| secure: false, | |||
| pathRewrite: { '^/api': '' }, | |||
| logLevel: 'debug', | |||
| }, | |||
| }, | |||
| }, | |||
| transpileDependencies: true, | |||
| }); | |||