@@ -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> |