reworked stack

This commit is contained in:
johba 2025-10-07 21:57:32 +00:00
parent 6cbb1781ce
commit f7ef56f65f
12 changed files with 853 additions and 458 deletions

View file

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

@ -5,9 +5,6 @@ import type { EIP1193Provider } from 'viem';
declare global {
interface Window {
ethereum?: EIP1193Provider;
__testHelpers?: {
fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise<void>;
};
}
}

View file

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

View file

@ -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(() => ({

View file

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