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