feat: add withdraw functionality and related enums

- Introduced WithdrawMethodEnum and ChainEnum in enum.ts for withdrawal methods and blockchain types.
- Updated types.ts to include WithdrawBody type for withdrawal requests.
- Created a new useResetRef composable for managing form state resets.
- Added a withdraw page with form handling in index.vue, including validation and submission logic.
- Integrated the new withdraw functionality into the wallet card component.
- Updated the main.ts file to include Pinia for state management.
- Created a wallet store to manage user balances.
- Modified the deposit page to improve user experience and validation.
- Added number pattern validation for input fields.
- Updated the router to include a new route for the withdraw page.
- Refactored input-label component styles for better layout.
- Added a new rules.ts file for future validation rules.
This commit is contained in:
2025-12-14 18:31:57 +07:00
parent 49414095f1
commit 28ddf12d45
22 changed files with 1838 additions and 95 deletions

2
.env
View File

@@ -1 +1 @@
VITE_API_URL=http://192.168.1.36:9528
VITE_API_URL=http://192.168.1.36:9527

6
auto-imports.d.ts vendored
View File

@@ -52,6 +52,7 @@ declare global {
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const numberPattern: typeof import('./src/utils/pattern').numberPattern
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
@@ -230,6 +231,7 @@ declare global {
const usePrevious: typeof import('@vueuse/core').usePrevious
const useRafFn: typeof import('@vueuse/core').useRafFn
const useRefHistory: typeof import('@vueuse/core').useRefHistory
const useResetRef: typeof import('./src/composables/useResetRef').useResetRef
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
@@ -277,6 +279,7 @@ declare global {
const useVibrate: typeof import('@vueuse/core').useVibrate
const useVirtualList: typeof import('@vueuse/core').useVirtualList
const useWakeLock: typeof import('@vueuse/core').useWakeLock
const useWalletStore: typeof import('./src/store/wallet').useWalletStore
const useWebNotification: typeof import('@vueuse/core').useWebNotification
const useWebSocket: typeof import('@vueuse/core').useWebSocket
const useWebWorker: typeof import('@vueuse/core').useWebWorker
@@ -362,6 +365,7 @@ declare module 'vue' {
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly numberPattern: UnwrapRef<typeof import('./src/utils/pattern')['numberPattern']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
@@ -540,6 +544,7 @@ declare module 'vue' {
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResetRef: UnwrapRef<typeof import('./src/composables/useResetRef')['useResetRef']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
@@ -587,6 +592,7 @@ declare module 'vue' {
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>
readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>
readonly useWalletStore: UnwrapRef<typeof import('./src/store/wallet')['useWalletStore']>
readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>

8
components.d.ts vendored
View File

@@ -35,7 +35,11 @@ declare module 'vue' {
IonLabel: typeof import('@ionic/vue')['IonLabel']
IonList: typeof import('@ionic/vue')['IonList']
IonModal: typeof import('@ionic/vue')['IonModal']
IonNavLink: typeof import('@ionic/vue')['IonNavLink']
IonNote: typeof import('@ionic/vue')['IonNote']
IonPage: typeof import('@ionic/vue')['IonPage']
IonRadio: typeof import('@ionic/vue')['IonRadio']
IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup']
IonRouterOutlet: typeof import('@ionic/vue')['IonRouterOutlet']
IonSearchbar: typeof import('@ionic/vue')['IonSearchbar']
IonSelect: typeof import('@ionic/vue')['IonSelect']
@@ -81,7 +85,11 @@ declare global {
const IonLabel: typeof import('@ionic/vue')['IonLabel']
const IonList: typeof import('@ionic/vue')['IonList']
const IonModal: typeof import('@ionic/vue')['IonModal']
const IonNavLink: typeof import('@ionic/vue')['IonNavLink']
const IonNote: typeof import('@ionic/vue')['IonNote']
const IonPage: typeof import('@ionic/vue')['IonPage']
const IonRadio: typeof import('@ionic/vue')['IonRadio']
const IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup']
const IonRouterOutlet: typeof import('@ionic/vue')['IonRouterOutlet']
const IonSearchbar: typeof import('@ionic/vue')['IonSearchbar']
const IonSelect: typeof import('@ionic/vue')['IonSelect']

View File

@@ -31,5 +31,6 @@ export default antfu({
"unused-imports/no-unused-imports": "off",
"vue/no-deprecated-slot-attribute": "off",
"@typescript-eslint/no-explicit-any": "off",
"prefer-promise-reject-errors": "off",
},
});

View File

@@ -23,11 +23,13 @@
"@elysiajs/eden": "^1.4.5",
"@ionic/vue": "^8.7.11",
"@ionic/vue-router": "^8.7.11",
"@riwa/api-types": "http://192.168.1.36:9527/api/riwa-api-types-0.0.1.tgz",
"@nuxt/ui": "^4.2.1",
"@riwa/api-types": "http://192.168.1.36:9527/api/riwa-api-types-0.0.6.tgz",
"@vueuse/core": "^14.1.0",
"better-auth": "^1.4.6",
"ionicons": "^8.0.13",
"lodash-es": "^4.17.21",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-router": "^4.6.3"

1603
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,3 +7,15 @@ export enum AssetCodeEnum {
USDT = "USDT",
OPTS = "OPTS",
}
export enum WithdrawMethodEnum {
BANK = "bank",
CRYPTO = "crypto",
CASH = "cash",
}
export enum ChainEnum {
BEP20 = "BEP20",
ERC20 = "ERC20",
TRC20 = "TRC20",
}

View File

@@ -1,10 +1,17 @@
import type { Treaty } from "@elysiajs/eden";
import type { client } from ".";
import type { AssetCodeEnum, PaymentChannelEnum } from "./enum";
import type { AssetCodeEnum, PaymentChannelEnum, WithdrawMethodEnum } from "./enum";
export type DepositFiatBody = Parameters<typeof client.api.asset.deposit.fiat.post>[0] & {
export type DepositFiatBody = Parameters<typeof client.api.deposit.fiat.post>[0] & {
paymentChannel: PaymentChannelEnum;
assetCode: AssetCodeEnum;
};
export type DepositFiatData = Treaty.Data<typeof client.api.asset.deposit.fiat.post>;
export type DepositFiatData = Treaty.Data<typeof client.api.deposit.fiat.post>;
export type BalancesData = Treaty.Data<typeof client.api.asset.balances.get>;
export type WithdrawBody = Omit<Parameters<typeof client.api.asset.withdraw.post>[0], "assetCode" | "withdrawMethod"> & {
assetCode: AssetCodeEnum;
withdrawMethod: WithdrawMethodEnum;
};

View File

@@ -29,7 +29,6 @@ defineExpose({} as ComponentInstance<typeof UiInput>);
font-size: 14px;
font-weight: 500;
margin-bottom: 14px;
margin-left: 8px;
color: var(--ion-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,14 @@
import type { MaybeRef } from "vue";
import cloneDeepWith from "lodash-es/cloneDeepWith";
import { isRef, ref } from "vue";
export function useResetRef<T>(value: MaybeRef<T>) {
const _valueDefine = cloneDeepWith(value as any);
const _value = isRef(value) ? value : ref(value);
function reset(value?: T) {
_value.value = value ? cloneDeepWith(value) : cloneDeepWith(_valueDefine);
}
return [_value, reset] as const;
}

View File

@@ -1,4 +1,6 @@
import { IonicVue } from "@ionic/vue";
import ui from "@nuxt/ui/vue-plugin";
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
@@ -39,10 +41,14 @@ import "@ionic/vue/css/palettes/dark.system.css";
import "./theme/variables.css";
import "./theme/ionic.css";
const pinia = createPinia();
const app = createApp(App)
.use(IonicVue)
.use(router)
.use(i18n);
.use(pinia)
.use(i18n)
.use(ui);
router.isReady().then(() => {
app.mount("#app");

View File

@@ -44,6 +44,10 @@ const routes: Array<RouteRecordRaw> = [
path: "/deposit/fiat",
component: () => import("@/views/deposit/fiat.vue"),
},
{
path: "/withdraw/index",
component: () => import("@/views/withdraw/index.vue"),
},
];
const router = createRouter({

1
src/store/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./wallet";

16
src/store/wallet.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { BalancesData } from "@/api/types";
import { defineStore } from "pinia";
interface State {
balances: BalancesData | null;
}
export const useWalletStore = defineStore("wallet", () => {
const state = reactive<State>({
balances: null,
});
return {
state,
};
});

View File

@@ -3,7 +3,7 @@ export function formatBalance(amount: MaybeRefOrGetter<number | string>, locale:
if (!value) {
value = 0;
}
if (typeof value === "string" && !Number.isNaN(Number(value))) {
if (typeof value === "string" && Number.isNaN(Number(value))) {
value = 0;
}

View File

@@ -1 +1,2 @@
export const emailPattern = /^(?=.{1,254}$)(?=.{1,64}@)[\w!#$%&'*+/=?^`{|}~-]+(?:\.[\w!#$%&'*+/=?^`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
export const numberPattern = /^\d+(?:\.\d+)?$/;

View File

@@ -1,5 +1,7 @@
<script lang='ts' setup>
import type { DepositFiatBody } from "@/api/types";
import { toastController } from "@ionic/vue";
import { client } from "@/api";
import { AssetCodeEnum, PaymentChannelEnum } from "@/api/enum";
const form = ref<DepositFiatBody>({
@@ -19,11 +21,24 @@ function validate(value: string) {
if (value === "") {
return false;
}
const isEmailValid = emailPattern.test(form.value.amount);
const isNumber = numberPattern.test(value);
isEmailValid ? inputInstance.value?.$el.classList.add("ion-valid") : inputInstance.value?.$el.classList.add("ion-invalid");
isNumber ? inputInstance.value?.$el.classList.add("ion-valid") : inputInstance.value?.$el.classList.add("ion-invalid");
return isEmailValid;
return isNumber;
}
async function onSubmit() {
const { data, status } = await client.api.deposit.fiat.post(form.value);
if (status === 200) {
const toast = await toastController.create({
message: "Submission successful!",
duration: 1500,
position: "bottom",
});
await toast.present();
}
}
</script>
@@ -38,7 +53,7 @@ function validate(value: string) {
</ion-toolbar>
</IonHeader>
<IonContent :fullscreen="true" class="ion-padding">
<div class="flex flex-col gap-10px">
<div class="flex flex-col gap-20px">
<ui-input-label
label="Recharge bank card account"
model-value="74321329321312"
@@ -47,11 +62,22 @@ function validate(value: string) {
disabled
/>
<ion-select v-model="form.assetCode" label="Select Currency" placeholder="Select One" label-placement="floating">
<ion-radio-group v-model="form.assetCode">
<ion-label class="text-sm">
Choose Currency
</ion-label>
<ion-item v-for="item in AssetCodeEnum" :key="item">
<ion-radio :value="item" justify="space-between">
{{ item }}
</ion-radio>
</ion-item>
</ion-radio-group>
<!-- <ion-select v-model="form.assetCode" label="Select Currency" placeholder="Select One" label-placement="floating">
<ion-select-option v-for="item in AssetCodeEnum" :key="item" :value="item">
{{ item }}
</ion-select-option>
</ion-select>
</ion-select> -->
<ui-input-label
ref="inputInstance"
@@ -59,10 +85,17 @@ function validate(value: string) {
label="Amount"
placeholder="Enter the amount"
type="number"
inputmode="numeric"
error-text="Please enter a valid amount."
@ion-input="validate($event.target.value as string)"
@ion-blur="markTouched"
/>
<ion-note>Please make sure to enter the correct amount. After submission, the funds will be credited to your account after review in the background.</ion-note>
<ion-button expand="block" @click="onSubmit">
Submit
</ion-button>
</div>
</IonContent>
</ion-page>

View File

@@ -1,20 +1,41 @@
<script lang='ts' setup>
import { onIonViewDidEnter, onIonViewWillEnter } from "@ionic/vue";
import { client } from "@/api";
import RechargeChannel from "./recharge-channel.vue";
const { t } = useI18n();
const { data } = await client.api.asset.balances.get();
const router = useRouter();
const { state } = useWalletStore();
const rechargeInstance = ref<ModalInstance>();
async function init() {
const { data } = await client.api.asset.balances.get();
state.balances = data;
}
function onCloseModal() {
rechargeInstance.value?.$el.dismiss(null, "confirm");
}
function handleWithdraw() {
router.push("/withdraw/index");
}
onMounted(() => {
init();
});
onIonViewWillEnter(() => {
init();
});
onIonViewDidEnter(() => {
init();
});
</script>
<template>
<div class="mt-20px">
<div class="grid grid-cols-2 gap-20px p-20px bg-[var(--ion-card-background)] rounded-t-6px">
<div v-for="item in data" :key="item.assetCode" class="flex flex-col gap-4px">
<div class="mt-20px shadow-md rounded-6px">
<div class="grid grid-cols-2 gap-20px p-20px bg-[var(--ion-card-background)]">
<div v-for="item in state.balances" :key="item.assetCode" class="flex flex-col gap-4px">
<div class="ion-text-uppercase text-xs color-text-400 font-500 tracking-0.4px">
{{ item.assetCode }}
</div>
@@ -24,26 +45,27 @@ function onCloseModal() {
</div>
</div>
<div class="px-10px pb-20px bg-[var(--ion-card-background)] rounded-b-6px">
<div class="px-10px pb-20px bg-[var(--ion-card-background)]">
<ion-buttons class="gap-10px" expand="block">
<ion-button id="open-modal" expand="block" fill="clear">
<ion-button id="open-recharge-modal" expand="block" fill="clear">
{{ t("wallet.recharge") }}
</ion-button>
<ion-button expand="block" fill="clear">
<ion-button expand="block" fill="clear" @click="handleWithdraw">
{{ t("wallet.withdraw") }}
</ion-button>
</ion-buttons>
</div>
</div>
<ion-modal ref="rechargeInstance" class="recharge-channel-modal" trigger="open-modal" :initial-breakpoint="1" :breakpoints="[0, 1]">
<ion-modal ref="rechargeInstance" class="recharge-channel-modal" trigger="open-recharge-modal" :initial-breakpoint="1" :breakpoints="[0, 1]">
<RechargeChannel @close="onCloseModal" />
</ion-modal>
</template>
<style scoped>
.recharge-channel-modal {
.recharge-channel-modal,
.withdraw-channel-modal {
--height: auto;
}
</style>

View File

@@ -0,0 +1,143 @@
<script lang='ts' setup>
import type { WithdrawBody } from "@/api/types";
import { toastController } from "@ionic/vue";
import { client } from "@/api";
import { AssetCodeEnum, ChainEnum, WithdrawMethodEnum } from "@/api/enum";
const amountInputInst = useTemplateRef<InputInstance>("amountInputInst");
const [form, resetForm] = useResetRef<WithdrawBody>({
assetCode: AssetCodeEnum.USDT,
amount: "",
withdrawMethod: WithdrawMethodEnum.BANK,
toAddress: "",
bankAccountId: "",
chain: "BEP20",
});
const { state } = useWalletStore();
const maxAmount = computed(() => {
const balance = state.balances?.find(item => item.assetCode === form.value.assetCode);
return balance ? balance.available : "0";
});
function markTouched() {
amountInputInst.value?.$el.classList.add("ion-touched");
}
function validate(value: string) {
amountInputInst.value?.$el.classList.remove("ion-valid");
amountInputInst.value?.$el.classList.remove("ion-invalid");
if (value === "") {
return false;
}
const isNumber = numberPattern.test(value);
isNumber ? amountInputInst.value?.$el.classList.add("ion-valid") : amountInputInst.value?.$el.classList.add("ion-invalid");
return isNumber;
}
function handleCurrentChange() {
form.value.amount = "";
}
async function onSubmit() {
const { data, status } = await client.api.asset.withdraw.post(form.value);
if (status === 200) {
const toast = await toastController.create({
message: "Submission successful!",
duration: 1500,
position: "bottom",
});
await toast.present();
resetForm();
}
}
</script>
<template>
<ion-page>
<IonHeader>
<ion-toolbar class="ui-toolbar">
<ion-buttons slot="start">
<ion-back-button />
</ion-buttons>
<ion-title>Withdraw</ion-title>
</ion-toolbar>
</IonHeader>
<IonContent :fullscreen="true" class="ion-padding">
<div class="flex flex-col gap-20px">
<ion-radio-group v-model="form.assetCode" @ion-change="handleCurrentChange">
<ion-label class="text-sm">
Choose Currency
</ion-label>
<ion-item v-for="item in AssetCodeEnum" :key="item">
<ion-radio :value="item" justify="space-between">
{{ item }}
</ion-radio>
</ion-item>
</ion-radio-group>
<ion-radio-group v-model="form.withdrawMethod">
<ion-label class="text-sm">
Choose Withdraw Method
</ion-label>
<ion-item v-for="item in WithdrawMethodEnum" :key="item">
<ion-radio :value="item" justify="space-between">
{{ item }}
</ion-radio>
</ion-item>
</ion-radio-group>
<ui-input-label
ref="amountInputInst"
v-model="form.amount"
label="Amount"
:placeholder="`Enter the amount (Max: ${maxAmount})`"
type="number"
inputmode="numeric"
error-text="Please enter a valid amount."
:max="maxAmount"
@ion-input="validate($event.target.value as string)"
@ion-blur="markTouched"
/>
<ui-input-label
v-if="form.withdrawMethod === WithdrawMethodEnum.BANK"
v-model="form.bankAccountId"
label="Bank Account ID"
placeholder="Enter the bank account ID"
type="text"
inputmode="text"
error-text="Please enter a valid bank account ID."
/>
<template v-else-if="form.withdrawMethod === WithdrawMethodEnum.CRYPTO">
<ion-radio-group v-model="form.chain">
<ion-label class="text-sm">
Choose Chain
</ion-label>
<ion-item v-for="item in ChainEnum" :key="item">
<ion-radio :value="item" justify="space-between">
{{ item }}
</ion-radio>
</ion-item>
</ion-radio-group>
<ui-input-label
v-model="form.toAddress"
label="Crypto Address"
placeholder="Enter the crypto address"
type="text"
inputmode="text"
error-text="Please enter a valid crypto address."
/>
</template>
<ion-button expand="block" @click="onSubmit">
Submit
</ion-button>
</div>
</IonContent>
</ion-page>
</template>
<style lang='css' scoped></style>

View File

View File

@@ -3,6 +3,11 @@
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
"#build/ui": [
"./node_modules/.nuxt-ui/ui"
]
},
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import ui from "@nuxt/ui/vite";
import legacy from "@vitejs/plugin-legacy";
import vue from "@vitejs/plugin-vue";
import jsx from "@vitejs/plugin-vue-jsx";
@@ -22,7 +23,7 @@ export default defineConfig({
jsx(),
legacy(),
autoImport({
dirs: ["src/composables", "src/utils"],
dirs: ["src/composables", "src/utils", "src/store"],
imports: ["vue", "vue-router", "@vueuse/core", "vue-i18n"],
resolvers: [IonicResolver()],
vueTemplate: true,
@@ -35,6 +36,7 @@ export default defineConfig({
autoInstall: true,
}),
UnoCSS(),
ui(),
],
resolve: {
alias: {