| @@ -21,3 +21,6 @@ pnpm-debug.log* | |||
| *.njsproj | |||
| *.sln | |||
| *.sw? | |||
| vue.config.js | |||
| @@ -1,4 +1,4 @@ | |||
| # signet | |||
| # beignet | |||
| ## Project setup | |||
| ``` | |||
| @@ -1,5 +1,5 @@ | |||
| { | |||
| "name": "signet", | |||
| "name": "beignet", | |||
| "version": "0.1.0", | |||
| "private": true, | |||
| "scripts": { | |||
| @@ -9,13 +9,20 @@ | |||
| "lint": "vue-cli-service lint" | |||
| }, | |||
| "dependencies": { | |||
| "@mdi/js": "^7.0.96", | |||
| "@vueuse/core": "^9.5.0", | |||
| "bulma": "^0.9.4", | |||
| "core-js": "^3.8.3", | |||
| "jenesius-vue-modal": "^1.8.2", | |||
| "jwt-decode": "^3.1.2", | |||
| "luxon": "^3.1.0", | |||
| "vue": "^3.2.13", | |||
| "vue-router": "^4.0.3", | |||
| "vuex": "^4.0.0" | |||
| }, | |||
| "devDependencies": { | |||
| "@types/chai": "^4.2.15", | |||
| "@types/luxon": "^3.1.0", | |||
| "@types/mocha": "^8.2.1", | |||
| "@typescript-eslint/eslint-plugin": "^5.4.0", | |||
| "@typescript-eslint/parser": "^5.4.0", | |||
| @@ -1,17 +1,88 @@ | |||
| <template> | |||
| <nav> | |||
| <router-link to="/">Home</router-link> | | |||
| <router-link to="/about">About</router-link> | |||
| <nav class="navbar has-background-grey-dark" role="navigation" aria-label="main navigation"> | |||
| <div class="navbar-brand"> | |||
| <RouterLink to="/" class="navbar-item"> | |||
| <span class="title is-3-desktop is-4-mobile has-text-white-ter">Beignet</span> | |||
| </RouterLink> | |||
| <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false"> | |||
| <span aria-hidden="true"></span> | |||
| <span aria-hidden="true"></span> | |||
| <span aria-hidden="true"></span> | |||
| </a> | |||
| </div> | |||
| <div class="navbar-end"> | |||
| <div class="navbar-item"> | |||
| <div class="buttons is-hidden-mobile is-hidden-tablet-only"> | |||
| <div class="authentication" v-if="!hasToken"> | |||
| <RouterLink to="/login" class="button is-primary"> | |||
| Log in | |||
| </RouterLink> | |||
| </div> | |||
| <div v-else> | |||
| <RouterLink to="/addfund" class="button is-primary"> | |||
| Add Fund | |||
| </RouterLink> | |||
| <RouterLink to="/register" class="button is-white"> | |||
| Register | |||
| </RouterLink> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </nav> | |||
| <router-view/> | |||
| <div id="content"> | |||
| <RouterView v-slot="{ Component }"> | |||
| <Suspense> | |||
| <Component :is="Component" /> | |||
| <template #fallback> | |||
| <span style="font-size: 4em; color: saddlebrown">Loading</span> | |||
| </template> | |||
| </Suspense> | |||
| </RouterView> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { useSessionStorage } from '@vueuse/core'; | |||
| import store from '@/store'; | |||
| import { | |||
| computed, | |||
| ref, | |||
| } from 'vue'; | |||
| import jwtDecode from 'jwt-decode'; | |||
| import { Claims } from '@/api/types'; | |||
| const userData = ref({ username: '', privileges: -1, exp: -1 } as Claims); | |||
| const state = useSessionStorage('jwt', { token: '' }); | |||
| if (state.value.token) { | |||
| store.commit('setToken', state.value.token); | |||
| userData.value = jwtDecode<Claims>(state.value.token); | |||
| } | |||
| const hasToken = computed(() => !!store.getters.getToken); | |||
| </script> | |||
| <style lang="stylus"> | |||
| @import "../node_modules/bulma/css/bulma.min.css" | |||
| #content | |||
| min-height 80vh | |||
| #app | |||
| font-family Avenir, Helvetica, Arial, sans-serif | |||
| -webkit-font-smoothing antialiased | |||
| -moz-osx-font-smoothing grayscale | |||
| text-align center | |||
| color #2c3e50 | |||
| margin-top 60px | |||
| html, body | |||
| padding 0 | |||
| margin 0 | |||
| body | |||
| min-height 100vh | |||
| color #e8dbca | |||
| background-color #313538 | |||
| </style> | |||
| @@ -0,0 +1,51 @@ | |||
| const setHeaders = (headers: HeadersInit | undefined, token: string | undefined) => { | |||
| if (!headers && !!token) { | |||
| return { Authorization: `Bearer ${token}` }; | |||
| } | |||
| if (!!headers && !token) { | |||
| return headers; | |||
| } | |||
| if (!!headers && !!token) { | |||
| return { ...headers, Authorization: `Bearer ${token}` }; | |||
| } | |||
| return {}; | |||
| }; | |||
| class SignetRequestController { | |||
| token?: string = undefined; | |||
| constructor(token?: string) { | |||
| this.token = token; | |||
| } | |||
| get = async <T>(endpoint: string): Promise<T | null> => { | |||
| const resp = await fetch( | |||
| `/api/${endpoint}`, | |||
| { | |||
| method: 'GET', | |||
| headers: setHeaders(undefined, this.token), | |||
| }, | |||
| ); | |||
| if (resp.status === 404) { | |||
| return null; | |||
| } | |||
| return await resp.json() as Promise<T>; | |||
| }; | |||
| post = async <T1, T2>(endpoint: string, payload: T2): Promise<T1 | null> => { | |||
| const resp = await fetch( | |||
| `/api/${endpoint}`, | |||
| { | |||
| method: 'POST', | |||
| body: JSON.stringify(payload), | |||
| headers: setHeaders({ 'Content-Type': 'application/json' }, this.token), | |||
| }, | |||
| ); | |||
| if (resp.status === 404) { | |||
| return null; | |||
| } | |||
| return await resp.json() as Promise<T1>; | |||
| }; | |||
| } | |||
| export default SignetRequestController; | |||
| @@ -0,0 +1,127 @@ | |||
| // eslint-disable-next-line no-shadow | |||
| export enum Privileges { | |||
| None = -1, | |||
| SuperUser, | |||
| AdminPlus, | |||
| Admin | |||
| } | |||
| export interface Tag { | |||
| CreatedAt: string; | |||
| DeletedAt: string; | |||
| ID: number; | |||
| UpdatedAt: string; | |||
| description: string; | |||
| active: boolean; | |||
| contribution: number; | |||
| } | |||
| export interface Contribution { | |||
| CreatedAt: string; | |||
| amount: number; | |||
| rewardFundID: number; | |||
| tags: Tag[]; | |||
| transactionID: string; | |||
| wallet: string; | |||
| } | |||
| interface Contributions { | |||
| list: Contribution[] | |||
| dates: string[] | |||
| total: number | |||
| } | |||
| export interface RewardFund { | |||
| id: number | |||
| asset: string | |||
| wallet: string | |||
| memo: string | |||
| amountGoal: number | |||
| minContribution: number | |||
| contributions: Contribution[] | null | |||
| title: string | |||
| description: string | |||
| } | |||
| export interface SuccessResponse { | |||
| success: boolean | |||
| } | |||
| export interface GetRewardFundRequest { | |||
| id: number | |||
| consolidateContributions: boolean | |||
| } | |||
| export interface Bonus { | |||
| goal?: number; | |||
| percent?: number; | |||
| } | |||
| export interface FundInfo { | |||
| id: number; | |||
| asset: string; | |||
| fundWallet: string; | |||
| fundSecret: string; | |||
| issuerWallet: string; | |||
| memo: string; | |||
| price: number; | |||
| amountGoal: number; | |||
| minContribution: number; | |||
| title: string; | |||
| description: string; | |||
| bonuses: Bonus[]; | |||
| } | |||
| interface Total { | |||
| amountHeld: number; | |||
| } | |||
| export interface GetRewardFundResponse { | |||
| fundInfo: FundInfo | |||
| contributions: Contributions | |||
| total: Total | |||
| } | |||
| export interface ContributeRequest { | |||
| privateKey: string | |||
| amount: number | |||
| rewardFund: number | |||
| } | |||
| export interface AuthenticationRequest { | |||
| username: string; | |||
| password: string; | |||
| } | |||
| export interface LoginResponse { | |||
| token: string | null; | |||
| } | |||
| export interface GetRewardFundsRequest { | |||
| offset: number; | |||
| } | |||
| export interface GetRewardFundsResponse { | |||
| rewardFunds: FundInfo[] | |||
| total: number | |||
| } | |||
| export interface Claims { | |||
| username: string | |||
| privileges: Privileges; | |||
| exp: number; | |||
| } | |||
| export interface GetContributionsRequest { | |||
| id: number | |||
| offset: number | |||
| forDate: string | undefined | |||
| consolidateContributions: boolean | |||
| } | |||
| export type GetContributionsResponse = Contributions; | |||
| export interface CloseRewardFundRequest { | |||
| id: number; | |||
| close: boolean; | |||
| } | |||
| @@ -0,0 +1,72 @@ | |||
| <template> | |||
| <div class="card py-2 px-4 has-text-dark" | |||
| :style="generateBackgroundStyle(`${fund.asset} ${fund.title}`)"> | |||
| <div class="is-size-2-desktop is-size-2-tablet is-size-3-mobile"> | |||
| {{ fund.asset }} | |||
| </div> | |||
| <div> | |||
| <ul class="is-flex is-flex-direction-row is-justify-content-space-between"> | |||
| <li class="is-size-7 is-inline-block px-2"> | |||
| <span class="stellar-icon-base coin"></span> | |||
| <span class="fund-label">{{ props.fund.minContribution.toLocaleString() }}</span> | |||
| </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> | |||
| </li> | |||
| <li class="is-size-7 is-inline-block px-2"> | |||
| <span class="stellar-icon-base memo"></span> | |||
| <span class="fund-label">{{ props.fund.memo }}</span> | |||
| </li> | |||
| <li class="is-size-7 is-inline-block px-2"> | |||
| <span class="stellar-icon-base wallet"></span> | |||
| <span class="fund-label"> | |||
| {{ truncateWallet(props.fund.fundWallet, 5, undefined) }} | |||
| </span> | |||
| </li> | |||
| </ul> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { truncateWallet } from '@/lib/helpers'; | |||
| import { | |||
| FundInfo, | |||
| } from '@/api/types'; | |||
| import { PropType } from 'vue'; | |||
| // eslint-disable-next-line no-undef | |||
| const props = defineProps({ fund: Object as PropType<FundInfo> }); | |||
| const generateHue = (seed: string) => seed.split('').map((c) => c.charCodeAt(0)).reduce((v1, v2) => v1 + v2) % 256; | |||
| const generateBackgroundStyle = (hueSeed: string) => { | |||
| const hue = generateHue(hueSeed); | |||
| return `background-color: hsl(${hue}deg, 54%, 76%)`; | |||
| }; | |||
| </script> | |||
| <style scoped lang="stylus"> | |||
| .fund-label | |||
| vertical-align top | |||
| .stellar-icon-base | |||
| display inline-block | |||
| height 20px | |||
| width 20px | |||
| margin-right 4px | |||
| background-position center | |||
| background-size 16px | |||
| background-repeat no-repeat | |||
| &.coin | |||
| background-image url("../assets/icons8-expensive-24.png") | |||
| &.goal | |||
| background-image url("../assets/icons8-goal-48.png") | |||
| &.memo | |||
| background-image url("../assets/icons8-note-24.png") | |||
| &.wallet | |||
| background-image url("../assets/icons8-wallet-24.png") | |||
| </style> | |||
| @@ -0,0 +1,72 @@ | |||
| <template> | |||
| <div> | |||
| <table class="table is-fullwidth"> | |||
| <thead> | |||
| <tr> | |||
| <th>Goal Amount</th> | |||
| <th>Percent Bonus</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <tr v-for="kv in bonuses" v-bind:key="kv.id"> | |||
| <td class="p-0"> | |||
| <input | |||
| type="text" | |||
| class="input is-small" | |||
| v-model="kv.goal" | |||
| :aria-label="`Goal #${bonuses.length}`" | |||
| @input="checkInputs" | |||
| @blur="saveValues" | |||
| > | |||
| </td> | |||
| <td class="p-0"> | |||
| <input | |||
| type="text" | |||
| class="input is-small" | |||
| :class="kv.percent < 1 ? 'is-danger' : ''" | |||
| v-model="kv.percent" | |||
| :aria-label="`Cashback percent #${bonuses.length}`" | |||
| @input="checkInputs" | |||
| @blur="saveValues" | |||
| > | |||
| </td> | |||
| </tr> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { ref } from 'vue'; | |||
| interface Bonus { | |||
| id: number; | |||
| goal: string | undefined; | |||
| percent: string | undefined; | |||
| } | |||
| // eslint-disable-next-line no-undef | |||
| const emits = defineEmits(['save']); | |||
| const bonuses = ref([{ id: 1, goal: undefined, percent: undefined }] as Bonus[]); | |||
| const getNextId = () => bonuses.value.length + 1; | |||
| const checkInputs = () => { | |||
| if (bonuses.value.every((b) => Object.values(b).every((v) => !!v))) { | |||
| bonuses.value.push({ | |||
| id: getNextId(), goal: undefined, percent: undefined, | |||
| }); | |||
| } | |||
| }; | |||
| const saveValues = () => { | |||
| emits('save', bonuses.value.filter((b) => !!b.goal && !!b.percent).map((b) => ( | |||
| { goal: parseFloat(b.goal as string), percent: parseFloat(b.percent as string) } | |||
| ))); | |||
| }; | |||
| </script> | |||
| <style scoped lang="stylus"> | |||
| </style> | |||
| @@ -0,0 +1,33 @@ | |||
| import store from '@/store'; | |||
| import jwtDecode from 'jwt-decode'; | |||
| import { Claims } from '@/api/types'; | |||
| import * as luxon from 'luxon'; | |||
| const removeToken = () => { | |||
| sessionStorage.removeItem('jwt'); | |||
| store.commit('clearToken'); | |||
| }; | |||
| const hasPermission = (requiredRights: number) => { | |||
| const jwt = store.getters.getToken; | |||
| if (jwt !== undefined && requiredRights !== undefined) { | |||
| try { | |||
| const decoded = jwtDecode<Claims>(store.getters.getToken); | |||
| const expired = luxon.DateTime.now() | |||
| .toUnixInteger() > decoded.exp; | |||
| jwtDecode(jwt, { header: true }); | |||
| if (!expired && decoded.privileges <= requiredRights) { | |||
| return true; | |||
| } | |||
| if (expired) { | |||
| removeToken(); | |||
| } | |||
| return false; | |||
| } catch { | |||
| removeToken(); | |||
| return false; | |||
| } | |||
| } | |||
| return !(jwt === undefined && requiredRights !== undefined); | |||
| }; | |||
| export default hasPermission; | |||
| @@ -0,0 +1,6 @@ | |||
| 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); | |||
| @@ -1,6 +1,10 @@ | |||
| import { createApp } from 'vue'; | |||
| import App from './App.vue'; | |||
| import router from './router'; | |||
| import store from './store'; | |||
| createApp(App).use(store).use(router).mount('#app'); | |||
| createApp(App) | |||
| .use(store) | |||
| .use(router) | |||
| .mount('#app'); | |||
| @@ -1,19 +1,52 @@ | |||
| import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; | |||
| import HomeView from '../views/HomeView.vue'; | |||
| import { | |||
| createRouter, | |||
| createWebHistory, | |||
| RouteRecordRaw, | |||
| } from 'vue-router'; | |||
| import RegisterView from '@/views/RegisterView.vue'; | |||
| import LoginView from '@/views/LoginView.vue'; | |||
| import { Privileges } 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'; | |||
| const routes: Array<RouteRecordRaw> = [ | |||
| { | |||
| path: '/', | |||
| name: 'home', | |||
| component: HomeView, | |||
| meta: { title: 'Home' }, | |||
| }, | |||
| { | |||
| path: '/about', | |||
| name: 'about', | |||
| // route level code-splitting | |||
| // this generates a separate chunk (about.[hash].js) for this route | |||
| // which is lazy-loaded when the route is visited. | |||
| component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue'), | |||
| path: '/fund/:id', | |||
| name: 'fund', | |||
| component: FundView, | |||
| meta: { title: undefined }, | |||
| }, | |||
| { | |||
| path: '/login', | |||
| name: 'login', | |||
| component: LoginView, | |||
| meta: { title: 'Login' }, | |||
| }, | |||
| { | |||
| path: '/register', | |||
| name: 'register', | |||
| component: RegisterView, | |||
| meta: { | |||
| requiredRights: Privileges.AdminPlus, | |||
| title: 'Register', | |||
| }, | |||
| }, | |||
| { | |||
| path: '/addfund', | |||
| name: 'addfund', | |||
| component: AddFundView, | |||
| meta: { | |||
| requiredRights: Privileges.Admin, | |||
| title: 'Add Group Fund', | |||
| }, | |||
| }, | |||
| ]; | |||
| @@ -22,4 +55,14 @@ const router = createRouter({ | |||
| routes, | |||
| }); | |||
| router.beforeEach(async (to, from, next) => { | |||
| document.title = `Beignet - ${to.meta.title}`; | |||
| if (hasPermission(to.meta.requiredRights as number)) { | |||
| next(); | |||
| } else { | |||
| next('/'); | |||
| } | |||
| }); | |||
| export default router; | |||
| @@ -2,12 +2,26 @@ import { createStore } from 'vuex'; | |||
| export default createStore({ | |||
| state: { | |||
| token: undefined as string | undefined, | |||
| }, | |||
| getters: { | |||
| getToken: (state) => state.token, | |||
| }, | |||
| mutations: { | |||
| setToken: (state, token) => { | |||
| state.token = token; | |||
| }, | |||
| clearToken: (state) => { | |||
| state.token = undefined; | |||
| }, | |||
| }, | |||
| actions: { | |||
| setToken: (context) => { | |||
| context.commit('setToken'); | |||
| }, | |||
| clearToken: (context) => { | |||
| context.commit('clearToken'); | |||
| }, | |||
| }, | |||
| modules: { | |||
| }, | |||
| @@ -0,0 +1,125 @@ | |||
| <template> | |||
| <div class="container is-max-desktop"> | |||
| <section class="section is-small"> | |||
| <div class="title is-4 has-text-white-ter has-text-centered">Add Fund</div> | |||
| <section class="section px-0 py-4"> | |||
| <div class="title is-5 has-text-white-ter">Post</div> | |||
| <div class="control my-2"> | |||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||
| placeholder="Title" aria-label="Title" v-model="title"> | |||
| </div> | |||
| <div class="control my-2"> | |||
| <textarea class="textarea is-normal has-background-white has-text-black" | |||
| placeholder="Description" aria-label="Description" v-model="description"> | |||
| </textarea> | |||
| </div> | |||
| </section> | |||
| <section class="section px-0 py-4"> | |||
| <div class="title is-5 has-text-white-ter">Wallet</div> | |||
| <div class="control my-2"> | |||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||
| placeholder="Fund Wallet" aria-label="Fund Wallet" v-model="fundWallet"> | |||
| </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"> | |||
| </div> | |||
| <div class="control my-2"> | |||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||
| placeholder="Issuer Wallet" aria-label="Issuer Wallet" v-model="issuerWallet"> | |||
| </div> | |||
| </section> | |||
| <section class="section px-0 py-4"> | |||
| <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="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"> | |||
| </div> | |||
| </section> | |||
| <section class="section px-0 py-4"> | |||
| <div class="title is-5 has-text-white-ter">Bonus Structure</div> | |||
| <FundTierInput @save="saveBonuses" /> | |||
| </section> | |||
| </section> | |||
| <div class="buttons is-flex is-justify-content-end mt-5"> | |||
| <button | |||
| class="button is-success" | |||
| :class="requesting ? 'is-loading' : ''" | |||
| @click="submit" | |||
| >Submit</button> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import SignetRequestController from '@/api/requests'; | |||
| import { | |||
| Bonus, | |||
| FundInfo, | |||
| SuccessResponse, | |||
| } from '@/api/types'; | |||
| import { ref } from 'vue'; | |||
| import store from '@/store'; | |||
| import { useRouter } from 'vue-router'; | |||
| import FundTierInput from '@/components/FundTierInput.vue'; | |||
| const router = useRouter(); | |||
| document.title = 'Beignet - Add Fund'; | |||
| const controller = new SignetRequestController(store.getters.getToken); | |||
| const title = ref(''); | |||
| const description = ref(''); | |||
| const fundWallet = 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 bonuses = ref([] as Bonus[]); | |||
| const saveBonuses = (evt: Bonus[]) => { | |||
| bonuses.value = evt; | |||
| }; | |||
| const requesting = ref(false); | |||
| const submit = async () => { | |||
| if (!requesting.value) { | |||
| requesting.value = true; | |||
| const resp = await controller.post<SuccessResponse, Partial<FundInfo>>('CreateRewardFund', { | |||
| asset: asset.value, | |||
| fundWallet: fundWallet.value, | |||
| fundSecret: fundSecret.value, | |||
| issuerWallet: issuerWallet.value, | |||
| memo: memo.value, | |||
| amountGoal: amtGoal.value as number, | |||
| minContribution: minContribution.value as number, | |||
| title: title.value, | |||
| description: description.value, | |||
| bonuses: bonuses.value, | |||
| }); | |||
| requesting.value = false; | |||
| if (!resp) throw new Error('Could not get response for fund creation'); | |||
| if (resp.success) { | |||
| await router.push('/'); | |||
| } | |||
| } | |||
| }; | |||
| </script> | |||
| <style scoped lang="stylus"> | |||
| input::placeholder, textarea::placeholder | |||
| color #7d7d7d | |||
| </style> | |||
| @@ -0,0 +1,36 @@ | |||
| <template> | |||
| <!-- Unused --> | |||
| <div class="is-flex is-flex-direction-row"> | |||
| <nav class="is-hidden-tablet-only is-hidden-mobile has-background-white-ter"> | |||
| <div class="has-background-primary"> | |||
| <span class="has-text-white has-text-weight-bold my-4 mx-2">Administration</span> | |||
| </div> | |||
| <ul class="p-2"> | |||
| <li v-for="(link, i) in links" v-bind:key="i"> | |||
| <RouterLink :to="link.to">{{ link.text }}</RouterLink> | |||
| </li> | |||
| </ul> | |||
| </nav> | |||
| <div class="container is-max-desktop"> | |||
| <RouterView></RouterView> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| const links = [ | |||
| { text: 'Add Fund', to: '/admin/addfund' }, | |||
| { text: 'Modify Fund', to: '' }, | |||
| { text: 'Create Tag', to: '' }, | |||
| ]; | |||
| </script> | |||
| <style scoped lang="stylus"> | |||
| nav | |||
| min-width 134px | |||
| min-height 100vh | |||
| li | |||
| font-variant all-petite-caps | |||
| font-size 18px | |||
| </style> | |||
| @@ -0,0 +1,459 @@ | |||
| <template> | |||
| <div class="container is-max-desktop pb-4"> | |||
| <section class="section is-small"> | |||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||
| {{ fund.fundInfo.title }} | |||
| </div> | |||
| <div | |||
| class="is-block-mobile | |||
| is-flex-tablet-only | |||
| is-flex-desktop | |||
| is-flex-direction-row | |||
| is-justify-content-space-between"> | |||
| <div class="fund-description pr-5"> | |||
| {{ fund.fundInfo.description }} | |||
| </div> | |||
| <div | |||
| class="fund-details is-flex is-flex-direction-row is-justify-content-end my-auto py-6"> | |||
| <ul> | |||
| <li v-for="(detail, i) in fundDetails" v-bind:key="i"> | |||
| <span class="has-text-weight-bold is-size-6 mr-2">{{ detail.title }}</span> | |||
| <span class="is-size-6">{{ detail.val }}</span> | |||
| </li> | |||
| </ul> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| <section> | |||
| <div class="box"> | |||
| <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)" | |||
| v-bind:key="bonus.goal" | |||
| > | |||
| <div> | |||
| <p | |||
| class="heading" | |||
| :class="fund.total.amountHeld >= bonus.goal | |||
| ? 'has-text-success' : 'has-text-grey-dark'" | |||
| > | |||
| {{ bonus.goal.toLocaleString() }} XLM | |||
| </p> | |||
| <p | |||
| class="title" | |||
| :class="fund.total.amountHeld >= bonus.goal | |||
| ? 'has-text-success' : 'has-text-grey-dark'" | |||
| > | |||
| {{ bonus.percent }}% | |||
| </p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| <section class="section is-small"> | |||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||
| Contribute | |||
| </div> | |||
| <div class="control my-2"> | |||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||
| placeholder="Private Key" aria-label="Wallet" v-model="pk"> | |||
| </div> | |||
| <div class="control my-2"> | |||
| <input class="input is-normal has-background-white has-text-black" type="number" | |||
| placeholder="Amount" aria-label="Amount" v-model="amt"> | |||
| </div> | |||
| <div class="is-flex is-justify-content-end"> | |||
| <button | |||
| class="button is-primary" | |||
| :class="requesting ? 'is-loading' : ''" | |||
| @click="makeContribution" | |||
| >Submit</button> | |||
| </div> | |||
| </section> | |||
| <section class="section is-small" v-if="contributions.length > 0"> | |||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||
| Contributions | |||
| </div> | |||
| <div class="is-flex is-justify-content-space-between is-rounded my-2"> | |||
| <div class="select"> | |||
| <select v-model="selectedDate" aria-label="Filter by date"> | |||
| <option v-for="date in selectableDates" v-bind:key="date" :value="date"> | |||
| {{ date ?? 'Cutoff Date' }} | |||
| </option> | |||
| </select> | |||
| </div> | |||
| <div class="consolidate-option has-background-white px-4 py-1 my-auto"> | |||
| <label for="consolidate" class="checkbox has-text-dark is-size-6"> | |||
| <span class="consolidate-label is-inline-block"> | |||
| Consolidate wallets | |||
| </span> | |||
| <input | |||
| type="checkbox" | |||
| class="ml-2" | |||
| id="consolidate" | |||
| aria-label="Consolidate wallets" | |||
| v-model="enableConsolidation" | |||
| > | |||
| </label> | |||
| </div> | |||
| </div> | |||
| <div id="contribution-container" @scroll="loadMoreIfNeeded"> | |||
| <table class="contribution-table table is-fullwidth"> | |||
| <thead> | |||
| <tr> | |||
| <th>Wallet</th> | |||
| <th>Amount</th> | |||
| <th v-if="!enableConsolidation">Time</th> | |||
| <th v-else>Tokens</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <tr v-for="(contribution, i) in contributions" v-bind:key="i"> | |||
| <td>{{ truncateWallet(contribution.wallet, 6, undefined) }}</td> | |||
| <td>{{ contribution.amount }}</td> | |||
| <td v-if="!enableConsolidation"> | |||
| <span class="transaction-date" :title="formatDate(contribution.CreatedAt, true)"> | |||
| {{ formatDate(contribution.CreatedAt, true) }} | |||
| </span> | |||
| </td> | |||
| <td v-else> | |||
| <span>{{ calculateReward(contribution.amount / fund.fundInfo.price) }}</span> | |||
| </td> | |||
| </tr> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| </section> | |||
| <section v-if="store.getters.getToken && hasPermission(Privileges.AdminPlus)"> | |||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||
| Close Group Fund | |||
| </div> | |||
| <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 | |||
| </label> | |||
| </div> | |||
| <div> | |||
| <button | |||
| class="button is-danger" | |||
| :disabled="!allowDelete" | |||
| @click="deleteFund" | |||
| > | |||
| Delete | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { | |||
| useRoute, | |||
| useRouter, | |||
| } from 'vue-router'; | |||
| import { | |||
| Bonus, | |||
| CloseRewardFundRequest, | |||
| ContributeRequest, | |||
| Contribution, | |||
| FundInfo, | |||
| GetContributionsRequest, | |||
| GetContributionsResponse, | |||
| GetRewardFundRequest, | |||
| GetRewardFundResponse, | |||
| Privileges, | |||
| SuccessResponse, | |||
| } from '@/api/types'; | |||
| import { | |||
| Ref, | |||
| ref, | |||
| watch, | |||
| } from 'vue'; | |||
| import { useWebSocket } from '@vueuse/core'; | |||
| import SignetRequestController from '@/api/requests'; | |||
| import store from '@/store'; | |||
| import { truncateWallet } from '@/lib/helpers'; | |||
| import * as luxon from 'luxon'; | |||
| import hasPermission from '@/lib/auth'; | |||
| const controller = new SignetRequestController(store.getters.getToken); | |||
| const route = useRoute(); | |||
| const router = useRouter(); | |||
| const { id } = route.params; | |||
| const identifier = parseInt(id as string, 10); | |||
| const formatDate = (time: string, includeTime = false) => { | |||
| const s = luxon.DateTime.fromISO(time).toUTC(); | |||
| const date = s.toFormat('yyyy-LLL-dd'); | |||
| return includeTime ? `${date} ${s.toFormat('HH:mm')} (UTC)` : date; | |||
| }; | |||
| const pk = ref(''); | |||
| const amt = ref(undefined as number | undefined); | |||
| const selectableDates = ref([undefined] as (string | undefined)[]); | |||
| const selectedDate = ref(undefined as string | undefined); | |||
| const allowDelete = ref(false); | |||
| const deleteFund = async () => { | |||
| if (allowDelete.value) { | |||
| const deleted = await controller.post<SuccessResponse, CloseRewardFundRequest>( | |||
| 'CloseRewardFund', | |||
| { | |||
| id: identifier, | |||
| close: true, | |||
| }, | |||
| ); | |||
| if (deleted && deleted.success) { | |||
| await router.push('/'); | |||
| } | |||
| } | |||
| }; | |||
| const enableConsolidation = ref(false); | |||
| const fund = ref( | |||
| { | |||
| fundInfo: {} as FundInfo, | |||
| contributions: { list: [], dates: [] as string[], total: 0 }, | |||
| total: { amountHeld: 0 }, | |||
| } as GetRewardFundResponse | null, | |||
| ); | |||
| const fundDetails = ref([{ title: '', val: '' }]); | |||
| fund.value = await controller.post<GetRewardFundResponse, GetRewardFundRequest>('GetRewardFund', { | |||
| id: identifier, | |||
| consolidateContributions: enableConsolidation.value, | |||
| }); | |||
| if (!fund.value) { | |||
| router.push('/'); | |||
| throw new Error('Fund not found'); | |||
| } | |||
| fundDetails.value = [ | |||
| { | |||
| title: 'Asset', | |||
| val: fund.value.fundInfo.asset, | |||
| }, | |||
| { | |||
| title: 'Min', | |||
| val: `${fund.value.fundInfo.minContribution.toLocaleString()}`, | |||
| }, | |||
| { | |||
| title: 'Goal', | |||
| val: `${fund.value.fundInfo.amountGoal.toLocaleString()}`, | |||
| }, | |||
| { | |||
| title: 'Memo', | |||
| val: `"${fund.value.fundInfo.memo}"`, | |||
| }, | |||
| ]; | |||
| if (fund.value.contributions.dates) { | |||
| selectableDates.value = selectableDates.value.concat( | |||
| fund.value.contributions.dates.map((d) => formatDate(d)), | |||
| ); | |||
| } | |||
| 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 calculateReward = (bought: number) => { | |||
| if (achievedBonuses.length > 0) { | |||
| if (!bonus.value || !bonus.value.percent) throw new Error('Something went wrong'); | |||
| reward.value = bought + bought * (bonus.value.percent / 100); | |||
| } else { | |||
| reward.value = bought; | |||
| } | |||
| return reward.value.toLocaleString(); | |||
| }; | |||
| document.title = `Beignet - ${fund.value.fundInfo.title}`; | |||
| const contributions: Ref<Contribution[]> = ref(fund.value.contributions.list ?? []); | |||
| const offset = ref(contributions.value.length); | |||
| const total = ref(fund.value.contributions.total); | |||
| watch(selectedDate, async (newVal) => { | |||
| offset.value = 0; | |||
| const conts = await controller.post< | |||
| GetContributionsResponse, GetContributionsRequest | |||
| >( | |||
| 'GetContributions', | |||
| { | |||
| id: identifier, | |||
| offset: offset.value, | |||
| forDate: newVal, | |||
| consolidateContributions: enableConsolidation.value, | |||
| }, | |||
| ); | |||
| if (!fund.value) throw new Error('Fund not found'); | |||
| if (!conts) throw new Error('Contributions not found'); | |||
| contributions.value = conts.list; | |||
| offset.value = contributions.value.length; | |||
| total.value = fund.value.contributions.total; | |||
| }); | |||
| watch(enableConsolidation, async () => { | |||
| offset.value = 0; | |||
| const conts = await controller.post< | |||
| GetContributionsResponse, GetContributionsRequest | |||
| >( | |||
| 'GetContributions', | |||
| { | |||
| id: identifier, | |||
| offset: offset.value, | |||
| forDate: selectedDate.value, | |||
| consolidateContributions: enableConsolidation.value, | |||
| }, | |||
| ); | |||
| if (!fund.value) throw new Error('Fund not found'); | |||
| if (!conts) throw new Error('Contributions not found'); | |||
| contributions.value = conts.list; | |||
| offset.value = contributions.value.length; | |||
| total.value = fund.value.contributions.total; | |||
| }); | |||
| const contributionsLoading = ref(false); | |||
| const loadMoreIfNeeded = async (e: Event) => { | |||
| const target = e.target as Element | null; | |||
| const canLoadMore = () => target | |||
| && (target.scrollTop + target.clientHeight) / target.scrollHeight > 0.8 | |||
| && offset.value < total.value && !contributionsLoading.value; | |||
| if (canLoadMore()) { | |||
| contributionsLoading.value = true; | |||
| const moreContribs = await controller.post<GetContributionsResponse, GetContributionsRequest>( | |||
| 'GetContributions', | |||
| { | |||
| id: identifier, | |||
| offset: offset.value, | |||
| forDate: selectedDate.value, | |||
| consolidateContributions: enableConsolidation.value, | |||
| }, | |||
| ); | |||
| if (!moreContribs) throw new Error('Contributions not found'); | |||
| offset.value += moreContribs.list.length; | |||
| total.value = moreContribs.total; | |||
| contributions.value = contributions.value.concat(moreContribs.list); | |||
| contributionsLoading.value = false; | |||
| } | |||
| }; | |||
| const { | |||
| status, | |||
| data, | |||
| } = useWebSocket( | |||
| 'ws://127.0.0.1:7300/ContributorStream', // TODO: change url | |||
| { | |||
| immediate: true, | |||
| autoReconnect: true, | |||
| }, | |||
| ); | |||
| watch(data, (newVal) => { | |||
| if (!fund.value) throw new Error('Fund not found'); | |||
| if (status.value === 'OPEN') { | |||
| const v = JSON.parse(newVal.trim()) as Contribution; | |||
| v.CreatedAt = luxon.DateTime.now().toISO(); | |||
| 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); | |||
| if (!hasContribution) throw new Error('Something went wrong'); | |||
| hasContribution.amount += v.amount; | |||
| } 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; | |||
| } | |||
| }); | |||
| const requesting = ref(false); | |||
| const makeContribution = async () => { | |||
| if (!fund.value) throw new Error('Fund not found'); | |||
| if (!requesting.value && pk.value && amt.value) { | |||
| requesting.value = true; | |||
| await controller.post<SuccessResponse, ContributeRequest>('Contribute', { | |||
| privateKey: pk.value, | |||
| amount: amt.value, | |||
| rewardFund: fund.value.fundInfo.id, | |||
| }); | |||
| requesting.value = false; | |||
| pk.value = ''; | |||
| amt.value = undefined; | |||
| } | |||
| }; | |||
| </script> | |||
| <style scoped lang="stylus"> | |||
| .transaction-date | |||
| white-space nowrap | |||
| overflow hidden | |||
| max-width 20vw | |||
| display block | |||
| .consolidate-label | |||
| max-width: 24vw; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| vertical-align: bottom; | |||
| text-overflow: ellipsis; | |||
| .total-label | |||
| border-right 1px solid #8f8f8f | |||
| font-variant all-petite-caps | |||
| .signet-asset-name | |||
| cursor pointer | |||
| #contribution-container | |||
| max-height 360px | |||
| overflow-y auto | |||
| .consolidate-option | |||
| border-radius 16px | |||
| .fund-description | |||
| min-height 280px | |||
| .fund-details | |||
| width 182px | |||
| #consolidate | |||
| vertical-align middle | |||
| @media screen and (min-width: 1024px) | |||
| .fund-details | |||
| max-width 14vw | |||
| border-left 1px #777 solid | |||
| padding-left 10px | |||
| </style> | |||
| @@ -1,18 +1,46 @@ | |||
| <template> | |||
| <div class="home"> | |||
| <img alt="Vue logo" src="../assets/logo.png"> | |||
| <HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/> | |||
| <div class="container is-max-desktop"> | |||
| <section class="section is-small px-0"> | |||
| <div class="container-grid"> | |||
| <template v-for="fund in rewardFunds" v-bind:key="fund.id"> | |||
| <RouterLink :to="`/fund/${fund.id}`"> | |||
| <FundLink :fund="fund"/> | |||
| </RouterLink> | |||
| </template> | |||
| </div> | |||
| </section> | |||
| </div> | |||
| </template> | |||
| <script lang="ts"> | |||
| import { defineComponent } from 'vue'; | |||
| import HelloWorld from '@/components/HelloWorld.vue'; // @ is an alias to /src | |||
| <script setup lang="ts"> | |||
| import { | |||
| GetRewardFundsRequest, | |||
| GetRewardFundsResponse, | |||
| } from '@/api/types'; | |||
| import { ref } from 'vue'; | |||
| import SignetRequestController from '@/api/requests'; | |||
| import store from '@/store'; | |||
| import FundLink from '@/components/FundLink.vue'; | |||
| export default defineComponent({ | |||
| name: 'HomeView', | |||
| components: { | |||
| HelloWorld, | |||
| }, | |||
| }); | |||
| const controller = new SignetRequestController(store.getters.getToken); | |||
| const offset = ref(0); | |||
| const response = await controller.post<GetRewardFundsResponse, GetRewardFundsRequest>('GetRewardFunds', { offset: offset.value }); | |||
| if (!response) throw new Error('Could not get reward funds'); | |||
| const { total, rewardFunds } = response; | |||
| offset.value = total; | |||
| </script> | |||
| <style scoped lang="stylus"> | |||
| .container-grid | |||
| display grid | |||
| grid-template-columns: repeat(2, 1fr); | |||
| gap: 10px; | |||
| grid-auto-rows: 120px; | |||
| @media screen and (max-width: 768px) | |||
| .container-grid | |||
| display flex | |||
| flex-direction column | |||
| </style> | |||
| @@ -0,0 +1,50 @@ | |||
| <template> | |||
| <div class="container is-max-desktop"> | |||
| <section class="section is-large"> | |||
| <div class="title is-4 has-text-white-ter has-text-centered">Login</div> | |||
| <div class="control my-2"> | |||
| <input class="input is-medium" type="text" placeholder="Username" | |||
| aria-label="Username" v-model="username" @keyup.enter="submit"> | |||
| </div> | |||
| <div class="control my-2"> | |||
| <input class="input is-medium" type="password" placeholder="Password" | |||
| aria-label="Password" v-model="password" @keyup.enter="submit"> | |||
| </div> | |||
| <div class="buttons is-flex is-justify-content-end mt-5"> | |||
| <button class="button is-success" @click="submit">Submit</button> | |||
| </div> | |||
| </section> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { ref } from 'vue'; | |||
| import { useRouter } from 'vue-router'; | |||
| import store from '@/store'; | |||
| import { | |||
| AuthenticationRequest, | |||
| LoginResponse, | |||
| } from '@/api/types'; | |||
| import SignetRequestController from '@/api/requests'; | |||
| const controller = new SignetRequestController(store.getters.getToken); | |||
| const router = useRouter(); | |||
| const username = ref(''); | |||
| const password = ref(''); | |||
| const submit = async () => { | |||
| const resp = await controller.post<LoginResponse, AuthenticationRequest>('Login', { username: username.value, password: password.value }); | |||
| if (!resp) throw new Error('Could not get response from login'); | |||
| if (resp.token !== null) { | |||
| sessionStorage.setItem('jwt', JSON.stringify({ token: resp.token })); | |||
| store.commit('setToken', resp.token); | |||
| await router.push('/'); | |||
| } | |||
| }; | |||
| </script> | |||
| <style scoped lang="stylus"> | |||
| </style> | |||
| @@ -0,0 +1,50 @@ | |||
| <template> | |||
| <div class="container is-max-desktop"> | |||
| <section class="section is-large"> | |||
| <div class="title is-4 has-text-white-ter has-text-centered">Register</div> | |||
| <div class="control my-2"> | |||
| <input class="input is-medium" type="text" placeholder="Username" | |||
| aria-label="Username" v-model="username"> | |||
| </div> | |||
| <div class="control my-2"> | |||
| <input class="input is-medium" type="password" placeholder="Password" | |||
| aria-label="Password" v-model="password"> | |||
| </div> | |||
| <div class="buttons is-flex is-justify-content-end mt-5"> | |||
| <button class="button is-success" @click="submit">Submit</button> | |||
| </div> | |||
| </section> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { ref } from 'vue'; | |||
| import { | |||
| AuthenticationRequest, | |||
| SuccessResponse, | |||
| } from '@/api/types'; | |||
| import SignetRequestController from '@/api/requests'; | |||
| import store from '@/store'; | |||
| import { useRouter } from 'vue-router'; | |||
| const controller = new SignetRequestController(store.getters.getToken); | |||
| const router = useRouter(); | |||
| const username = ref(''); | |||
| const password = ref(''); | |||
| const success = ref(false); | |||
| const submit = async () => { | |||
| const resp = await controller.post<SuccessResponse, AuthenticationRequest>('Register', { username: username.value, password: password.value }); | |||
| if (!resp) throw new Error('Could not get response from registration'); | |||
| success.value = resp.success; | |||
| if (success.value) { | |||
| await router.push('/login'); | |||
| } | |||
| }; | |||
| </script> | |||
| <style scoped lang="stylus"> | |||
| </style> | |||