reworked stack
This commit is contained in:
parent
6cbb1781ce
commit
f7ef56f65f
12 changed files with 853 additions and 458 deletions
|
|
@ -34,52 +34,15 @@ npm run build
|
|||
|
||||
## Testing
|
||||
|
||||
### Test Helpers
|
||||
### Accessibility Hooks
|
||||
|
||||
The application exposes test helpers on `window.__testHelpers` in development mode to facilitate E2E testing.
|
||||
The staking form now exposes semantic controls that Playwright can exercise directly:
|
||||
|
||||
#### Available Helpers
|
||||
- Slider: `page.getByRole('slider', { name: 'Token Amount' })`
|
||||
- Amount input: `page.getByLabel('Staking Amount')`
|
||||
- Tax selector: `page.getByLabel('Tax')`
|
||||
|
||||
##### `fillStakeForm(params)`
|
||||
|
||||
Programmatically fills the staking form without requiring fragile UI selectors.
|
||||
|
||||
**Parameters:**
|
||||
- `amount` (number): Amount of KRK tokens to stake (must be >= minimum stake)
|
||||
- `taxRateIndex` (number): Index of the tax rate option (must match one of the configured options)
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// In Playwright test
|
||||
await page.evaluate(async () => {
|
||||
await window.__testHelpers.fillStakeForm({
|
||||
amount: 100,
|
||||
taxRateIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
// Then click the stake button
|
||||
const stakeButton = page.getByRole('button', { name: /Stake|Snatch and Stake/i });
|
||||
await stakeButton.click();
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- Throws if amount is below minimum stake
|
||||
- Throws if amount exceeds wallet balance
|
||||
- Throws if `taxRateIndex` does not match an available option
|
||||
|
||||
**TypeScript Support:**
|
||||
Type declarations are available in `env.d.ts`:
|
||||
```typescript
|
||||
interface Window {
|
||||
__testHelpers?: {
|
||||
fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise<void>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Security:**
|
||||
Test helpers are only available when `import.meta.env.DEV === true` and are automatically stripped from production builds.
|
||||
Tests should rely on these roles and labels instead of private helpers.
|
||||
|
||||
### E2E Tests
|
||||
|
||||
|
|
|
|||
3
web-app/env.d.ts
vendored
3
web-app/env.d.ts
vendored
|
|
@ -5,9 +5,6 @@ import type { EIP1193Provider } from 'viem';
|
|||
declare global {
|
||||
interface Window {
|
||||
ethereum?: EIP1193Provider;
|
||||
__testHelpers?: {
|
||||
fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,72 +8,135 @@
|
|||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="subheader2">Token Amount</div>
|
||||
<FSlider :min="minStakeAmount" :max="maxStakeAmount" v-model="stake.stakingAmountNumber"></FSlider>
|
||||
<div class="formular">
|
||||
<div class="row row-1">
|
||||
<FInput label="Staking Amount" class="staking-amount" v-model="stake.stakingAmountNumber">
|
||||
<template v-slot:details>
|
||||
<div class="balance">Balance: {{ maxStakeAmount.toFixed(2) }} $KRK</div>
|
||||
<div @click="setMaxAmount" class="staking-amount-max">
|
||||
<b>Max</b>
|
||||
<form class="stake-form" @submit.prevent="handleSubmit" :aria-describedby="formStatusId" novalidate>
|
||||
<div class="form-group">
|
||||
<label :id="sliderLabelId" :for="sliderId" class="subheader2">Token Amount</label>
|
||||
<div class="input-range" :class="{ 'input-range--disabled': isSliderDisabled }">
|
||||
<input
|
||||
:id="sliderId"
|
||||
class="input-range__control"
|
||||
type="range"
|
||||
:min="sliderMin"
|
||||
:max="sliderMax"
|
||||
:step="sliderStep"
|
||||
:aria-labelledby="sliderLabelId"
|
||||
:aria-describedby="sliderHelpId"
|
||||
:aria-valuemin="sliderMin"
|
||||
:aria-valuemax="sliderMax"
|
||||
:aria-valuenow="currentStakeAmount"
|
||||
:aria-valuetext="stakeAmountAriaText"
|
||||
:disabled="isSliderDisabled"
|
||||
v-model.number="stake.stakingAmountNumber"
|
||||
:style="{ '--slider-percentage': sliderPercentage + '%' }"
|
||||
/>
|
||||
<output class="input-range__value" :for="sliderId">{{ formattedStakeAmount }}</output>
|
||||
</div>
|
||||
<p :id="sliderHelpId" class="input-range__help">{{ sliderDescription }}</p>
|
||||
<div class="sr-only" aria-live="polite">{{ sliderAnnouncement }}</div>
|
||||
</div>
|
||||
|
||||
<div class="formular">
|
||||
<div class="row row-1">
|
||||
<FInput
|
||||
label="Staking Amount"
|
||||
class="staking-amount"
|
||||
v-model="stake.stakingAmountNumber"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
:aria-describedby="stakeAmountDescriptionId"
|
||||
>
|
||||
<template #details>
|
||||
<div class="balance" :id="stakeAmountDescriptionId">Balance: {{ formattedBalance }} $KRK</div>
|
||||
<button type="button" @click="setMaxAmount" class="staking-amount-max">
|
||||
<b>Max</b>
|
||||
</button>
|
||||
</template>
|
||||
</FInput>
|
||||
<Icon class="stake-arrow" icon="mdi:chevron-triple-right" aria-hidden="true"></Icon>
|
||||
<FInput
|
||||
label="Owner Slots"
|
||||
class="staking-amount"
|
||||
readonly
|
||||
:modelValue="`${stakeSlots} (${supplyFreeze?.toFixed(4) ?? '0.0000'})`"
|
||||
aria-live="polite"
|
||||
>
|
||||
<template #info>
|
||||
Slots correspond to a percentage of ownership in the protocol.<br /><br />1,000 Slots = 1% Ownership<br /><br />When you
|
||||
unstake you get the exact percentage of the current $KRK total supply. When the total supply increased since you staked
|
||||
you get more tokens back than before.
|
||||
</template>
|
||||
</FInput>
|
||||
</div>
|
||||
|
||||
<div class="row row-2">
|
||||
<div class="form-field tax-field">
|
||||
<div class="field-label">
|
||||
<label :for="taxSelectId">Tax</label>
|
||||
<IconInfo size="20px">
|
||||
<template #text>
|
||||
The yearly tax you have to pay to keep your slots open. The tax is paid when unstaking or manually in the dashboard.
|
||||
If someone pays a higher tax they can buy you out.
|
||||
</template>
|
||||
</IconInfo>
|
||||
</div>
|
||||
</template>
|
||||
</FInput>
|
||||
<Icon class="stake-arrow" icon="mdi:chevron-triple-right"></Icon>
|
||||
<FInput label="Owner Slots" class="staking-amount" disabled :modelValue="`${stakeSlots}(${supplyFreeze?.toFixed(4)})`">
|
||||
<template #info>
|
||||
Slots correspond to a percentage of ownership in the protocol.<br /><br />1,000 Slots = 1% Ownership<br /><br />When you
|
||||
unstake you get the exact percentage of the current $KRK total supply. When the total supply increased since you staked you
|
||||
get more tokens back than before.
|
||||
</template>
|
||||
</FInput>
|
||||
<div class="tax-select-wrapper">
|
||||
<select :id="taxSelectId" class="tax-select" v-model.number="taxRateIndex" :aria-describedby="taxHelpId">
|
||||
<option v-for="option in taxOptions" :key="option.index" :value="option.index">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="tax-select__icon" aria-hidden="true">
|
||||
<Icon icon="mdi:chevron-down"></Icon>
|
||||
</span>
|
||||
</div>
|
||||
<p :id="taxHelpId" class="field-help">{{ taxRateDescription }}</p>
|
||||
<div class="sr-only" aria-live="polite">{{ taxRateAnnouncement }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field summary-field" :aria-labelledby="floorTaxLabelId" aria-live="polite">
|
||||
<div class="field-label" :id="floorTaxLabelId">
|
||||
<span>Floor Tax</span>
|
||||
<IconInfo size="20px">
|
||||
<template #text> This is the current minimum tax you have to pay to claim owner slots from other owners. </template>
|
||||
</IconInfo>
|
||||
</div>
|
||||
<p class="form-field__value">{{ floorTaxDisplay }}</p>
|
||||
<p class="field-help">{{ floorTaxHelpText }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-field summary-field" :aria-labelledby="snatchLabelId" aria-live="polite">
|
||||
<div class="field-label" :id="snatchLabelId">
|
||||
<span>Positions Buyout</span>
|
||||
<IconInfo size="20px">
|
||||
<template #text>
|
||||
This shows you the number of staking positions you buy out from current owners by paying a higher tax. If you get
|
||||
bought out yourself you receive the current market value of your position including your profits.
|
||||
</template>
|
||||
</IconInfo>
|
||||
</div>
|
||||
<p class="form-field__value">{{ positionsBuyoutDisplay }}</p>
|
||||
<p class="field-help">{{ snatchHelpText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-2">
|
||||
<FSelect :items="adjustTaxRate.taxRates" label="Tax" v-model="taxRateIndex">
|
||||
<template v-slot:info>
|
||||
The yearly tax you have to pay to keep your slots open. The tax is paid when unstaking or manually in the dashboard. If
|
||||
someone pays a higher tax they can buy you out.
|
||||
</template>
|
||||
</FSelect>
|
||||
<FInput label="Floor Tax" disabled :modelValue="String(snatchSelection.floorTax)">
|
||||
<template v-slot:info> This is the current minimum tax you have to pay to claim owner slots from other owners. </template>
|
||||
</FInput>
|
||||
<FInput label="Positions Buyout" disabled :modelValue="String(snatchSelection.snatchablePositions.value.length)">
|
||||
<template v-slot:info>
|
||||
This shows you the numbers of staking positions you buy out from current owners by paying a higher tax. If you get bought
|
||||
out yourself by new owners you get paid out the current market value of your position incl. your profits.
|
||||
</template>
|
||||
</FInput>
|
||||
|
||||
<section class="stake-summary" :aria-labelledby="stakeSummaryId" aria-live="polite">
|
||||
<h4 class="stake-summary__heading" :id="stakeSummaryId">Stake Summary</h4>
|
||||
<p>{{ stakeSummaryText }}</p>
|
||||
<p>{{ snatchSummaryText }}</p>
|
||||
<p>{{ walletSummaryText }}</p>
|
||||
</section>
|
||||
|
||||
<div class="sr-only" aria-live="assertive">{{ assistiveSummary }}</div>
|
||||
|
||||
<div class="form-status" :id="formStatusId" :role="actionState.tone === 'error' ? 'alert' : 'status'" aria-live="polite">
|
||||
{{ actionState.message }}
|
||||
</div>
|
||||
</div>
|
||||
<FButton size="large" disabled block v-if="stake.state === 'NoBalance'">Insufficient Balance</FButton>
|
||||
<FButton size="large" disabled block v-else-if="stake.stakingAmountNumber < minStakeAmount">Stake amount too low</FButton>
|
||||
<FButton
|
||||
size="large"
|
||||
disabled
|
||||
block
|
||||
v-else-if="
|
||||
!snatchSelection.openPositionsAvailable && stake.state === 'StakeAble' && snatchSelection.snatchablePositions.value.length === 0
|
||||
"
|
||||
>taxRate too low to snatch</FButton
|
||||
>
|
||||
<FButton
|
||||
size="large"
|
||||
block
|
||||
v-else-if="stake.state === 'StakeAble' && snatchSelection.snatchablePositions.value.length === 0"
|
||||
@click="stakeSnatch"
|
||||
>Stake</FButton
|
||||
>
|
||||
<FButton
|
||||
size="large"
|
||||
block
|
||||
v-else-if="stake.state === 'StakeAble' && snatchSelection.snatchablePositions.value.length > 0"
|
||||
@click="stakeSnatch"
|
||||
>Snatch and Stake</FButton
|
||||
>
|
||||
<FButton size="large" outlined block v-else-if="stake.state === 'SignTransaction'">Sign Transaction ...</FButton>
|
||||
<FButton size="large" outlined block v-else-if="stake.state === 'Waiting'">Waiting ...</FButton>
|
||||
|
||||
<FButton size="large" block type="submit" :disabled="actionState.disabled" :outlined="actionState.variant === 'outlined'">
|
||||
{{ actionState.label }}
|
||||
</FButton>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -82,9 +145,8 @@
|
|||
<script setup lang="ts">
|
||||
import FButton from '@/components/fcomponents/FButton.vue';
|
||||
import FInput from '@/components/fcomponents/FInput.vue';
|
||||
import FSelect from '@/components/fcomponents/FSelect.vue';
|
||||
import FLoader from '@/components/fcomponents/FLoader.vue';
|
||||
import FSlider from '@/components/fcomponents/FSlider.vue';
|
||||
import IconInfo from '@/components/icons/IconInfo.vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { bigInt2Number } from '@/utils/helper';
|
||||
import { loadPositions, usePositions, type Position } from '@/composables/usePositions';
|
||||
|
|
@ -95,31 +157,38 @@ import { useSnatchSelection } from '@/composables/useSnatchSelection';
|
|||
import { assetsToShares } from '@/contracts/stake';
|
||||
import { getMinStake } from '@/contracts/harb';
|
||||
import { useWallet } from '@/composables/useWallet';
|
||||
import { ref, onMounted, watch, computed, watchEffect } from 'vue';
|
||||
import { ref, onMounted, watch, computed, watchEffect, getCurrentInstance } from 'vue';
|
||||
import { useStatCollection, loadStats } from '@/composables/useStatCollection';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const demo = sessionStorage.getItem('demo') === 'true';
|
||||
const route = useRoute();
|
||||
|
||||
const adjustTaxRate = useAdjustTaxRate();
|
||||
|
||||
const StakeMenuOpen = ref(false);
|
||||
const defaultTaxRateIndex = adjustTaxRate.taxRates[0]?.index ?? 0;
|
||||
const taxRateIndex = ref<number>(defaultTaxRateIndex);
|
||||
const loading = ref<boolean>(true);
|
||||
const stakeSnatchLoading = ref<boolean>(false);
|
||||
const stake = useStake();
|
||||
const _claim = useClaim();
|
||||
const wallet = useWallet();
|
||||
const statCollection = useStatCollection();
|
||||
|
||||
const { activePositions: _activePositions } = usePositions();
|
||||
|
||||
const minStake = ref(0n);
|
||||
const stakeSlots = ref();
|
||||
const instance = getCurrentInstance();
|
||||
const uid = instance?.uid ?? Math.floor(Math.random() * 10000);
|
||||
const sliderId = `stake-slider-${uid}`;
|
||||
const sliderLabelId = `${sliderId}-label`;
|
||||
const sliderHelpId = `${sliderId}-help`;
|
||||
const stakeAmountDescriptionId = `stake-amount-description-${uid}`;
|
||||
const taxSelectId = `stake-tax-select-${uid}`;
|
||||
const taxHelpId = `${taxSelectId}-help`;
|
||||
const floorTaxLabelId = `stake-floor-tax-${uid}`;
|
||||
const snatchLabelId = `stake-snatch-${uid}`;
|
||||
const stakeSummaryId = `stake-summary-${uid}`;
|
||||
const formStatusId = `stake-status-${uid}`;
|
||||
|
||||
const minStake = ref<bigint>(0n);
|
||||
const stakeSlots = ref<string>('0.00');
|
||||
const supplyFreeze = ref<number>(0);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
watchEffect(() => {
|
||||
if (!stake.stakingAmount) {
|
||||
supplyFreeze.value = 0;
|
||||
|
|
@ -133,151 +202,471 @@ watchEffect(() => {
|
|||
const stakeableSupplyNumber = bigInt2Number(statCollection.stakeableSupply, 18);
|
||||
minStake.value = await getMinStake();
|
||||
|
||||
if (stakeableSupplyNumber === 0) {
|
||||
supplyFreeze.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
supplyFreeze.value = stakingAmountSharesNumber / stakeableSupplyNumber;
|
||||
}, 500);
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
stakeSlots.value = (supplyFreeze.value * 1000)?.toFixed(2);
|
||||
const slots = supplyFreeze.value * 1000;
|
||||
stakeSlots.value = Number.isFinite(slots) ? slots.toFixed(2) : '0.00';
|
||||
});
|
||||
|
||||
const _tokenIssuance = computed(() => {
|
||||
if (statCollection.kraikenTotalSupply === 0n) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
return (statCollection.nettoToken7d / statCollection.kraikenTotalSupply) * 100n;
|
||||
});
|
||||
|
||||
async function stakeSnatch() {
|
||||
if (snatchSelection.snatchablePositions.value.length === 0) {
|
||||
await stake.snatch(stake.stakingAmount, taxRateIndex.value);
|
||||
} else {
|
||||
const snatchAblePositionsIds = snatchSelection.snatchablePositions.value.map((p: Position) => p.positionId);
|
||||
await stake.snatch(stake.stakingAmount, taxRateIndex.value, snatchAblePositionsIds);
|
||||
}
|
||||
stakeSnatchLoading.value = true;
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
await loadPositions();
|
||||
await loadStats();
|
||||
stakeSnatchLoading.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
route,
|
||||
async to => {
|
||||
if (to.hash === '#stake') {
|
||||
StakeMenuOpen.value = true;
|
||||
}
|
||||
},
|
||||
{ flush: 'pre', immediate: true, deep: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
minStake.value = await getMinStake();
|
||||
stake.stakingAmountNumber = minStakeAmount.value;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const minStakeAmount = computed(() => {
|
||||
return bigInt2Number(minStake.value, 18);
|
||||
});
|
||||
const minStakeAmount = computed(() => bigInt2Number(minStake.value, 18));
|
||||
|
||||
const maxStakeAmount = computed(() => {
|
||||
if (wallet.balance?.value) {
|
||||
return bigInt2Number(wallet.balance.value, 18);
|
||||
} else {
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const sliderMin = computed(() => {
|
||||
const value = Number(minStakeAmount.value || 0);
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
});
|
||||
|
||||
const sliderMax = computed(() => {
|
||||
const value = Number(maxStakeAmount.value || 0);
|
||||
if (!Number.isFinite(value) || value <= sliderMin.value) {
|
||||
return sliderMin.value;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
const isSliderDisabled = computed(() => sliderMax.value <= sliderMin.value);
|
||||
|
||||
const sliderStep = computed(() => {
|
||||
if (isSliderDisabled.value) {
|
||||
return 0.01;
|
||||
}
|
||||
const range = sliderMax.value - sliderMin.value;
|
||||
const step = range / 100;
|
||||
if (!Number.isFinite(step) || step <= 0) {
|
||||
return 0.01;
|
||||
}
|
||||
return Number(step.toFixed(4));
|
||||
});
|
||||
|
||||
const currentStakeAmount = computed(() => {
|
||||
const value = Number(stake.stakingAmountNumber);
|
||||
if (!Number.isFinite(value)) {
|
||||
return sliderMin.value;
|
||||
}
|
||||
return Math.min(Math.max(value, sliderMin.value), sliderMax.value);
|
||||
});
|
||||
|
||||
const sliderPercentage = computed(() => {
|
||||
const range = sliderMax.value - sliderMin.value;
|
||||
if (range <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const percent = ((currentStakeAmount.value - sliderMin.value) / range) * 100;
|
||||
return Math.min(100, Math.max(0, Number(percent.toFixed(2))));
|
||||
});
|
||||
|
||||
watch([sliderMin, sliderMax], ([min, max]) => {
|
||||
const value = Number(stake.stakingAmountNumber);
|
||||
if (!Number.isFinite(value)) {
|
||||
stake.stakingAmountNumber = min;
|
||||
return;
|
||||
}
|
||||
if (value < min) {
|
||||
stake.stakingAmountNumber = min;
|
||||
} else if (value > max) {
|
||||
stake.stakingAmountNumber = max;
|
||||
}
|
||||
});
|
||||
|
||||
function formatNumber(value: number, maximumFractionDigits = 2) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0';
|
||||
}
|
||||
return value.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits,
|
||||
});
|
||||
}
|
||||
|
||||
const formattedStakeAmount = computed(() => `${formatNumber(currentStakeAmount.value, 4)} $KRK`);
|
||||
const formattedBalance = computed(() => formatNumber(maxStakeAmount.value, 4));
|
||||
const stakeAmountAriaText = computed(() => `Stake ${formatNumber(currentStakeAmount.value, 4)} Kraiken tokens`);
|
||||
|
||||
const sliderDescription = computed(() => {
|
||||
if (isSliderDisabled.value) {
|
||||
return 'Set a stake amount once you have tokens available in your wallet.';
|
||||
}
|
||||
return `Use arrow keys or type a value between ${formatNumber(sliderMin.value, 4)} and ${formatNumber(sliderMax.value, 4)} $KRK.`;
|
||||
});
|
||||
|
||||
const sliderAnnouncement = computed(() => `Stake amount set to ${formatNumber(currentStakeAmount.value, 4)} $KRK.`);
|
||||
|
||||
const taxOptions = computed(() =>
|
||||
adjustTaxRate.taxRates.map(option => ({
|
||||
index: option.index,
|
||||
year: option.year,
|
||||
daily: option.daily,
|
||||
label: `${option.index} · ${formatNumber(option.year, 2)}% yearly (${option.daily.toFixed(4)}% daily)`,
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedTaxOption = computed(() => adjustTaxRate.taxRates.find(option => option.index === taxRateIndex.value) ?? null);
|
||||
|
||||
const taxRateDescription = computed(() => {
|
||||
if (!selectedTaxOption.value) {
|
||||
return 'Select a tax rate to determine your yearly obligation.';
|
||||
}
|
||||
return `Tax rate index ${selectedTaxOption.value.index} with ${formatNumber(selectedTaxOption.value.year, 2)}% yearly (${selectedTaxOption.value.daily.toFixed(4)}% daily).`;
|
||||
});
|
||||
|
||||
const taxRateAnnouncement = computed(() => {
|
||||
if (!selectedTaxOption.value) {
|
||||
return 'No tax rate selected.';
|
||||
}
|
||||
return `Selected tax rate index ${selectedTaxOption.value.index}. You will pay ${formatNumber(selectedTaxOption.value.year, 2)} percent yearly.`;
|
||||
});
|
||||
|
||||
const snatchSelection = useSnatchSelection(demo, taxRateIndex);
|
||||
|
||||
const floorTaxDisplay = computed(() => `${formatNumber(snatchSelection.floorTax.value ?? 0, 2)} %`);
|
||||
const floorTaxHelpText = 'Your tax needs to exceed this value to displace an existing position.';
|
||||
const snatchPositionsCount = computed(() => snatchSelection.snatchablePositions.value.length);
|
||||
const positionsBuyoutDisplay = computed(() => formatNumber(snatchPositionsCount.value, 0));
|
||||
const snatchHelpText = 'Increasing your tax may buy out existing slots. This count updates automatically as you adjust inputs.';
|
||||
|
||||
const stakeSummaryText = computed(() => {
|
||||
const amount = formatNumber(currentStakeAmount.value, 4);
|
||||
const taxText = selectedTaxOption.value
|
||||
? `${formatNumber(selectedTaxOption.value.year, 2)}% yearly tax (index ${selectedTaxOption.value.index})`
|
||||
: 'no tax rate selected';
|
||||
return `Staking ${amount} $KRK with ${taxText}.`;
|
||||
});
|
||||
|
||||
const snatchSummaryText = computed(() => {
|
||||
if (snatchPositionsCount.value > 0) {
|
||||
const count = snatchPositionsCount.value;
|
||||
const positions = count === 1 ? 'position' : 'positions';
|
||||
return `You will snatch ${count} ${positions} at a floor tax of ${floorTaxDisplay.value}.`;
|
||||
}
|
||||
if (!snatchSelection.openPositionsAvailable.value) {
|
||||
return 'No open positions are available at this tax rate. Increase the tax to claim slots from others.';
|
||||
}
|
||||
return 'No existing positions will be snatched.';
|
||||
});
|
||||
|
||||
const walletSummaryText = computed(() => {
|
||||
const address = wallet.account.address;
|
||||
if (!address) {
|
||||
return 'Receiver wallet unavailable. Connect a wallet to continue.';
|
||||
}
|
||||
return `Receiver wallet: ${address}.`;
|
||||
});
|
||||
|
||||
const assistiveSummary = computed(() => `${stakeSummaryText.value} ${snatchSummaryText.value} ${walletSummaryText.value}`);
|
||||
|
||||
const isStakeAmountTooLow = computed(() => currentStakeAmount.value < minStakeAmount.value);
|
||||
const hasBalance = computed(() => maxStakeAmount.value > 0);
|
||||
|
||||
const actionState = computed(() => {
|
||||
if (!hasBalance.value || stake.state === 'NoBalance') {
|
||||
return {
|
||||
label: 'Insufficient Balance',
|
||||
disabled: true,
|
||||
variant: 'primary',
|
||||
message: 'You need more $KRK to cover the minimum stake amount.',
|
||||
tone: 'error' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (isStakeAmountTooLow.value) {
|
||||
return {
|
||||
label: 'Stake Amount Too Low',
|
||||
disabled: true,
|
||||
variant: 'primary',
|
||||
message: `Minimum stake is ${formatNumber(minStakeAmount.value, 4)} $KRK.`,
|
||||
tone: 'error' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (!snatchSelection.openPositionsAvailable.value && stake.state === 'StakeAble' && snatchPositionsCount.value === 0) {
|
||||
return {
|
||||
label: 'Tax Rate Too Low',
|
||||
disabled: true,
|
||||
variant: 'primary',
|
||||
message: 'Increase your tax rate to open staking slots.',
|
||||
tone: 'error' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (stake.state === 'StakeAble' && snatchPositionsCount.value === 0) {
|
||||
return {
|
||||
label: 'Stake',
|
||||
disabled: false,
|
||||
variant: 'primary',
|
||||
message: 'Ready to stake without snatching other positions.',
|
||||
tone: 'status' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (stake.state === 'StakeAble' && snatchPositionsCount.value > 0) {
|
||||
return {
|
||||
label: 'Snatch and Stake',
|
||||
disabled: false,
|
||||
variant: 'primary',
|
||||
message: `Ready to snatch ${snatchPositionsCount.value} position${snatchPositionsCount.value === 1 ? '' : 's'} while staking.`,
|
||||
tone: 'status' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (stake.state === 'SignTransaction') {
|
||||
return {
|
||||
label: 'Sign Transaction ...',
|
||||
disabled: true,
|
||||
variant: 'outlined',
|
||||
message: 'Check your wallet to sign the staking transaction.',
|
||||
tone: 'status' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (stake.state === 'Waiting') {
|
||||
return {
|
||||
label: 'Waiting ...',
|
||||
disabled: true,
|
||||
variant: 'outlined',
|
||||
message: 'Waiting for the transaction to confirm on-chain.',
|
||||
tone: 'status' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Stake',
|
||||
disabled: true,
|
||||
variant: 'primary',
|
||||
message: '',
|
||||
tone: 'status' as const,
|
||||
};
|
||||
});
|
||||
|
||||
watch(
|
||||
minStakeAmount,
|
||||
async newValue => {
|
||||
if (newValue > stake.stakingAmountNumber && stake.stakingAmountNumber === 0) {
|
||||
stake.stakingAmountNumber = minStakeAmount.value;
|
||||
newValue => {
|
||||
if (!Number.isFinite(newValue)) {
|
||||
return;
|
||||
}
|
||||
if (stake.stakingAmountNumber === 0 || stake.stakingAmountNumber < newValue) {
|
||||
stake.stakingAmountNumber = newValue;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(maxStakeAmount, newValue => {
|
||||
if (!Number.isFinite(newValue)) {
|
||||
return;
|
||||
}
|
||||
if (stake.stakingAmountNumber > newValue) {
|
||||
stake.stakingAmountNumber = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
function setMaxAmount() {
|
||||
stake.stakingAmountNumber = maxStakeAmount.value;
|
||||
}
|
||||
|
||||
const snatchSelection = useSnatchSelection(demo, taxRateIndex);
|
||||
|
||||
// Test helper - only available in dev mode
|
||||
if (import.meta.env.DEV) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__testHelpers = {
|
||||
fillStakeForm: async (params: { amount: number; taxRateIndex: number }) => {
|
||||
// Validate inputs
|
||||
const minStakeNum = bigInt2Number(minStake.value, 18);
|
||||
if (params.amount < minStakeNum) {
|
||||
throw new Error(`Stake amount ${params.amount} is below minimum ${minStakeNum}`);
|
||||
}
|
||||
|
||||
const maxStakeNum = maxStakeAmount.value;
|
||||
if (params.amount > maxStakeNum) {
|
||||
throw new Error(`Stake amount ${params.amount} exceeds balance ${maxStakeNum}`);
|
||||
}
|
||||
|
||||
const options = adjustTaxRate.taxRates;
|
||||
const selectedOption = options[params.taxRateIndex];
|
||||
if (!selectedOption) {
|
||||
throw new Error(`Tax rate index ${params.taxRateIndex} is invalid`);
|
||||
}
|
||||
|
||||
// Fill the form
|
||||
stake.stakingAmountNumber = params.amount;
|
||||
taxRateIndex.value = params.taxRateIndex;
|
||||
|
||||
// Wait for reactive updates
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
},
|
||||
};
|
||||
const max = sliderMax.value;
|
||||
if (Number.isFinite(max)) {
|
||||
stake.stakingAmountNumber = max;
|
||||
}
|
||||
}
|
||||
|
||||
async function stakeSnatch() {
|
||||
if (snatchPositionsCount.value === 0) {
|
||||
await stake.snatch(stake.stakingAmount, taxRateIndex.value);
|
||||
} else {
|
||||
const snatchAblePositionsIds = snatchSelection.snatchablePositions.value.map((p: Position) => p.positionId);
|
||||
await stake.snatch(stake.stakingAmount, taxRateIndex.value, snatchAblePositionsIds);
|
||||
}
|
||||
await loadPositions();
|
||||
await loadStats();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (actionState.value.disabled) {
|
||||
return;
|
||||
}
|
||||
await stakeSnatch();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
minStake.value = await getMinStake();
|
||||
stake.stakingAmountNumber = minStakeAmount.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
.hold-inner
|
||||
.stake-inner
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 24px
|
||||
.formular
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
.row
|
||||
>*
|
||||
flex: 1 1 auto
|
||||
.row-1
|
||||
gap: 12px
|
||||
>:nth-child(2)
|
||||
flex: 0 0 auto
|
||||
.staking-amount
|
||||
.f-input--details
|
||||
display: flex
|
||||
gap: 8px
|
||||
justify-content: flex-end
|
||||
color: #9A9898
|
||||
font-size: 14px
|
||||
.staking-amount-max
|
||||
font-weight: 600
|
||||
&:hover, &:active, &:focus
|
||||
cursor: pointer
|
||||
.row-2
|
||||
justify-content: space-between
|
||||
>*
|
||||
flex: 0 0 30%
|
||||
|
||||
.stake-form
|
||||
position: relative
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 24px
|
||||
|
||||
.form-group
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
|
||||
.input-range
|
||||
position: relative
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 16px
|
||||
margin-top: 8px
|
||||
&__control
|
||||
width: 100%
|
||||
height: 7px
|
||||
border-radius: 12px
|
||||
appearance: none
|
||||
background: linear-gradient(90deg, #7550AE var(--slider-percentage, 0%), #000000 var(--slider-percentage, 0%))
|
||||
outline: none
|
||||
accent-color: #7550AE
|
||||
&__control::-webkit-slider-thumb
|
||||
appearance: none
|
||||
width: 24px
|
||||
height: 24px
|
||||
border-radius: 50%
|
||||
background-color: #7550AE
|
||||
cursor: pointer
|
||||
&__control::-moz-range-thumb
|
||||
width: 24px
|
||||
height: 24px
|
||||
border-radius: 50%
|
||||
background-color: #7550AE
|
||||
cursor: pointer
|
||||
&__value
|
||||
min-width: 120px
|
||||
text-align: right
|
||||
font-weight: 600
|
||||
&--disabled
|
||||
opacity: .6
|
||||
|
||||
.input-range__help
|
||||
font-size: 14px
|
||||
color: #9A9898
|
||||
|
||||
.sr-only
|
||||
position: absolute
|
||||
width: 1px
|
||||
height: 1px
|
||||
padding: 0
|
||||
margin: -1px
|
||||
overflow: hidden
|
||||
clip: rect(0, 0, 0, 0)
|
||||
border: 0
|
||||
|
||||
.formular
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
.row
|
||||
display: flex
|
||||
gap: 12px
|
||||
flex-wrap: wrap
|
||||
>*
|
||||
flex: 1 1 30%
|
||||
.row-1
|
||||
align-items: flex-start
|
||||
>:nth-child(2)
|
||||
flex: 0 0 auto
|
||||
|
||||
.staking-amount
|
||||
.f-input--details
|
||||
display: flex
|
||||
gap: 8px
|
||||
justify-content: flex-end
|
||||
color: #9A9898
|
||||
font-size: 14px
|
||||
.staking-amount-max
|
||||
font-weight: 600
|
||||
background: none
|
||||
border: none
|
||||
color: inherit
|
||||
padding: 0
|
||||
cursor: pointer
|
||||
text-decoration: underline
|
||||
&:focus-visible
|
||||
outline: 2px solid #7550AE
|
||||
outline-offset: 2px
|
||||
|
||||
.stake-arrow
|
||||
align-self: center
|
||||
font-size: 30px
|
||||
|
||||
.form-field
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
|
||||
.field-label
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: 8px
|
||||
font-weight: 600
|
||||
|
||||
.tax-select-wrapper
|
||||
position: relative
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
.tax-select
|
||||
width: 100%
|
||||
padding: 12px
|
||||
border-radius: 12px
|
||||
border: 1px solid black
|
||||
background-color: #2D2D2D
|
||||
color: #FFFFFF
|
||||
appearance: none
|
||||
font-size: 16px
|
||||
|
||||
.tax-select__icon
|
||||
position: absolute
|
||||
right: 12px
|
||||
pointer-events: none
|
||||
color: #FFFFFF
|
||||
|
||||
.field-help
|
||||
font-size: 14px
|
||||
color: #9A9898
|
||||
|
||||
.summary-field
|
||||
.form-field__value
|
||||
font-size: 18px
|
||||
font-weight: 600
|
||||
|
||||
.stake-summary
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
padding: 16px
|
||||
border-radius: 12px
|
||||
background-color: rgba(117, 80, 174, 0.12)
|
||||
.stake-summary__heading
|
||||
margin: 0
|
||||
font-size: 16px
|
||||
|
||||
.form-status
|
||||
min-height: 24px
|
||||
font-size: 14px
|
||||
|
||||
@media (max-width: 767px)
|
||||
.formular .row
|
||||
flex-direction: column
|
||||
>*
|
||||
flex: 1 1 auto
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
<template>
|
||||
<button class="f-btn" :class="classObject" :style="styleObject">
|
||||
<button
|
||||
class="f-btn"
|
||||
:class="classObject"
|
||||
:style="styleObject"
|
||||
:type="props.type"
|
||||
:disabled="props.disabled"
|
||||
:aria-disabled="props.disabled ? 'true' : undefined"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
|
@ -15,6 +22,7 @@ interface Props {
|
|||
bgColor?: string;
|
||||
light?: boolean;
|
||||
dark?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
|
@ -22,6 +30,7 @@ import { computed } from 'vue';
|
|||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'medium',
|
||||
bgColor: '',
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
const classObject = computed(() => ({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="f-input" :class="classObject">
|
||||
<div class="f-input" :class="classObject" v-bind="rootAttrs">
|
||||
<div class="f-input-label subheader2">
|
||||
<label v-if="props.label" :for="name">{{ props.label }}</label>
|
||||
<label v-if="props.label" :for="inputId">{{ props.label }}</label>
|
||||
<Icon>
|
||||
<template v-slot:text v-if="slots.info">
|
||||
<slot name="info"></slot>
|
||||
|
|
@ -13,10 +13,11 @@
|
|||
:disabled="props.disabled"
|
||||
:readonly="props.readonly"
|
||||
:type="props.type"
|
||||
:name="name"
|
||||
:id="name"
|
||||
:name="inputName"
|
||||
:id="inputId"
|
||||
@input="updateModelValue"
|
||||
:value="props.modelValue"
|
||||
v-bind="inputAttrs"
|
||||
/>
|
||||
<div class="f-input--suffix" v-if="slots.suffix">
|
||||
<slot name="suffix">test </slot>
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, useSlots, ref } from 'vue';
|
||||
import { computed, getCurrentInstance, useSlots, ref, useAttrs } from 'vue';
|
||||
import useClickOutside from '@/composables/useClickOutside';
|
||||
|
||||
import Icon from '@/components/icons/IconInfo.vue';
|
||||
|
|
@ -49,7 +50,52 @@ const slots = useSlots();
|
|||
const inputWrapper = ref();
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const name = `f-input-${instance!.uid}`;
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const attrs = useAttrs();
|
||||
|
||||
const generatedId = `f-input-${instance!.uid}`;
|
||||
|
||||
const inputId = computed(() => {
|
||||
const attrId = (attrs as Record<string, unknown>).id;
|
||||
return typeof attrId === 'string' && attrId.length > 0 ? attrId : generatedId;
|
||||
});
|
||||
|
||||
const inputName = computed(() => {
|
||||
const attrName = (attrs as Record<string, unknown>).name;
|
||||
if (typeof attrName === 'string' && attrName.length > 0) {
|
||||
return attrName;
|
||||
}
|
||||
return inputId.value;
|
||||
});
|
||||
|
||||
const rootAttrs = computed(() => {
|
||||
const attrRecord = attrs as Record<string, unknown>;
|
||||
const root: Record<string, unknown> = {};
|
||||
if (typeof attrRecord.class === 'string') {
|
||||
root.class = attrRecord.class;
|
||||
}
|
||||
if (attrRecord.style) {
|
||||
root.style = attrRecord.style;
|
||||
}
|
||||
for (const [key, value] of Object.entries(attrRecord)) {
|
||||
if (key.startsWith('data-')) {
|
||||
root[key] = value;
|
||||
}
|
||||
}
|
||||
return root;
|
||||
});
|
||||
|
||||
const inputAttrs = computed(() => {
|
||||
const attrRecord = attrs as Record<string, unknown>;
|
||||
const input: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(attrRecord)) {
|
||||
if (key === 'class' || key === 'style' || key.startsWith('data-') || key === 'id' || key === 'name') {
|
||||
continue;
|
||||
}
|
||||
input[key] = value;
|
||||
}
|
||||
return input;
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'normal',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue