@@ -21,3 +21,6 @@ pnpm-debug.log* | |||||
*.njsproj | *.njsproj | ||||
*.sln | *.sln | ||||
*.sw? | *.sw? | ||||
vue.config.js |
@@ -1,4 +1,4 @@ | |||||
# signet | |||||
# beignet | |||||
## Project setup | ## Project setup | ||||
``` | ``` | ||||
@@ -1,5 +1,5 @@ | |||||
{ | { | ||||
"name": "signet", | |||||
"name": "beignet", | |||||
"version": "0.1.0", | "version": "0.1.0", | ||||
"private": true, | "private": true, | ||||
"scripts": { | "scripts": { | ||||
@@ -9,13 +9,20 @@ | |||||
"lint": "vue-cli-service lint" | "lint": "vue-cli-service lint" | ||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"@mdi/js": "^7.0.96", | |||||
"@vueuse/core": "^9.5.0", | |||||
"bulma": "^0.9.4", | |||||
"core-js": "^3.8.3", | "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": "^3.2.13", | ||||
"vue-router": "^4.0.3", | "vue-router": "^4.0.3", | ||||
"vuex": "^4.0.0" | "vuex": "^4.0.0" | ||||
}, | }, | ||||
"devDependencies": { | "devDependencies": { | ||||
"@types/chai": "^4.2.15", | "@types/chai": "^4.2.15", | ||||
"@types/luxon": "^3.1.0", | |||||
"@types/mocha": "^8.2.1", | "@types/mocha": "^8.2.1", | ||||
"@typescript-eslint/eslint-plugin": "^5.4.0", | "@typescript-eslint/eslint-plugin": "^5.4.0", | ||||
"@typescript-eslint/parser": "^5.4.0", | "@typescript-eslint/parser": "^5.4.0", | ||||
@@ -1,17 +1,88 @@ | |||||
<template> | <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> | </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> | </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"> | <style lang="stylus"> | ||||
@import "../node_modules/bulma/css/bulma.min.css" | |||||
#content | |||||
min-height 80vh | |||||
#app | #app | ||||
font-family Avenir, Helvetica, Arial, sans-serif | font-family Avenir, Helvetica, Arial, sans-serif | ||||
-webkit-font-smoothing antialiased | -webkit-font-smoothing antialiased | ||||
-moz-osx-font-smoothing grayscale | -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> | </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 { createApp } from 'vue'; | ||||
import App from './App.vue'; | import App from './App.vue'; | ||||
import router from './router'; | import router from './router'; | ||||
import store from './store'; | 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> = [ | const routes: Array<RouteRecordRaw> = [ | ||||
{ | { | ||||
path: '/', | path: '/', | ||||
name: 'home', | name: 'home', | ||||
component: HomeView, | 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, | 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; | export default router; |
@@ -2,12 +2,26 @@ import { createStore } from 'vuex'; | |||||
export default createStore({ | export default createStore({ | ||||
state: { | state: { | ||||
token: undefined as string | undefined, | |||||
}, | }, | ||||
getters: { | getters: { | ||||
getToken: (state) => state.token, | |||||
}, | }, | ||||
mutations: { | mutations: { | ||||
setToken: (state, token) => { | |||||
state.token = token; | |||||
}, | |||||
clearToken: (state) => { | |||||
state.token = undefined; | |||||
}, | |||||
}, | }, | ||||
actions: { | actions: { | ||||
setToken: (context) => { | |||||
context.commit('setToken'); | |||||
}, | |||||
clearToken: (context) => { | |||||
context.commit('clearToken'); | |||||
}, | |||||
}, | }, | ||||
modules: { | 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> | <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> | </div> | ||||
</template> | </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> | </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> |