Compare commits

...
3 Commits
Author SHA1 Message Date
nobswebdev 2d8a508898 Fix Monero withdraw form reset after successful withdrawal.
Use resetFields so validation state clears without re-triggering required rules on an empty address.
2026-09-07 02:08:22 +02:00
nobswebdev de52534271 Add Bitcoin wallet withdraw and seed reveal UI in CMS.
Wire admin actions to the backend API with client-side address validation and configurable max fee rate.
2026-09-07 02:08:17 +02:00
nobswebdev 626f2ea4f0 add bitcoin withdraw max fee validation config 2026-09-07 01:35:57 +02:00
17 changed files with 341 additions and 11 deletions
+2
View File
@@ -53,6 +53,7 @@ VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20 VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000 VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000 VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
SIGNED_COOKIE_JWT_SECRET=change-me-in-production SIGNED_COOKIE_JWT_SECRET=change-me-in-production
@@ -146,4 +147,5 @@ VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20 VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000 VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000 VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000 VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000
+2 -1
View File
@@ -103,7 +103,8 @@ export const getAppConfig = (): AppConfig => {
digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'), digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'),
shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'), shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'),
shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_LENGTH'), shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_LENGTH'),
orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH') orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH'),
bitcoinWithdrawMaxFeeRateSatVbyte: envInt('VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE')
} }
}; };
}; };
+6
View File
@@ -153,6 +153,12 @@ class EnvironmentVariables {
@Max(10000) @Max(10000)
VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number; VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(1000)
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE: number;
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(4) @Min(4)
@@ -1,7 +1,12 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { getAppConfig } from '../../../config';
import { IsBitcoinAddress } from '../../../validation/decorators/isBitcoinAddress'; import { IsBitcoinAddress } from '../../../validation/decorators/isBitcoinAddress';
const {
validation: { bitcoinWithdrawMaxFeeRateSatVbyte }
} = getAppConfig();
export class BitcoinWalletWithdrawDto { export class BitcoinWalletWithdrawDto {
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value)) @Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsString()
@@ -11,7 +16,7 @@ export class BitcoinWalletWithdrawDto {
@IsInt() @IsInt()
@Min(1) @Min(1)
@Max(100) @Max(bitcoinWithdrawMaxFeeRateSatVbyte)
feeRateSatVbyte: number; feeRateSatVbyte: number;
@IsString() @IsString()
+1
View File
@@ -49,6 +49,7 @@ export interface AppConfig {
shippingNoteMinLength: number; shippingNoteMinLength: number;
shippingNoteMaxLength: number; shippingNoteMaxLength: number;
orderMessageMaxLength: number; orderMessageMaxLength: number;
bitcoinWithdrawMaxFeeRateSatVbyte: number;
}; };
} }
+254 -4
View File
@@ -7,7 +7,7 @@
<el-alert type="warning" :closable="false" show-icon title="Wallet is syncing. Please wait." /> <el-alert type="warning" :closable="false" show-icon title="Wallet is syncing. Please wait." />
</div> </div>
<el-card shadow="never"> <el-card class="mb-24" shadow="never">
<template #header> <template #header>
<div class="flex items-center justify-between gap-16"> <div class="flex items-center justify-between gap-16">
<span>Status</span> <span>Status</span>
@@ -35,29 +35,105 @@
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
<el-card class="mb-24" shadow="never">
<template #header>
<span>Withdraw all</span>
</template> </template>
<el-form
ref="withdrawFormRef"
label-position="top"
:model="withdrawForm"
:rules="withdrawFormRules"
@submit.prevent="onWithdrawSubmit"
>
<el-form-item label="Destination address" prop="destinationAddress">
<el-input
v-model="withdrawForm.destinationAddress"
class="withdraw-address-input"
autocomplete="off"
:placeholder="`${walletStatus.network} Bitcoin address`"
:disabled="withdrawing"
@input="withdrawFormRef?.clearValidate('destinationAddress')"
/>
</el-form-item>
<el-form-item label="Fee rate (sat/vbyte)" prop="feeRateSatVbyte">
<el-input-number
v-model="withdrawForm.feeRateSatVbyte"
class="fee-rate-input"
:min="1"
:max="maxBitcoinWithdrawFeeRateSatVbyte"
:step="1"
:disabled="withdrawing"
/>
</el-form-item>
<el-button type="primary" native-type="submit" :loading="withdrawing">
Withdraw all confirmed funds
</el-button>
</el-form>
</el-card>
<el-card shadow="never">
<template #header>
<span>Recovery seed</span>
</template>
<p class="m-0 mb-16 secondary-text">
This shop is non-custodial. You control the wallet seed. Anyone with the seed can spend all funds.
Store it offline and never share it.
</p>
<el-button type="danger" plain :loading="revealingSeed" @click="onRevealSeedClick">
Reveal seed
</el-button>
</el-card>
</template>
<el-dialog v-model="seedDialogVisible" title="Recovery seed" width="560px" destroy-on-close @closed="clearSeed">
<el-alert type="error" :closable="false" show-icon title="Store this offline. Do not share it." />
<el-input v-model="revealedMnemonic" class="mt-16" type="textarea" :rows="4" readonly autocomplete="off" />
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from 'element-plus'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { onBeforeMount, ref } from 'vue'; import { computed, onBeforeMount, reactive, ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { config } from '@/config';
import { WalletSyncStatus } from '@/types/wallet/WalletSyncStatus'; import { WalletSyncStatus } from '@/types/wallet/WalletSyncStatus';
import { useBitcoinWalletStore } from '@/stores/bitcoinWallet'; import { useBitcoinWalletStore } from '@/stores/bitcoinWallet';
import { isBitcoinAddress } from '@/utils/bitcoin/isBitcoinAddress';
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage'; import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
import { resolveWalletSyncStatusLabel } from '@/utils/wallet/resolveWalletSyncStatusLabel'; import { resolveWalletSyncStatusLabel } from '@/utils/wallet/resolveWalletSyncStatusLabel';
import { resolveWalletSyncStatusTagType } from '@/utils/wallet/resolveWalletSyncStatusTagType'; import { resolveWalletSyncStatusTagType } from '@/utils/wallet/resolveWalletSyncStatusTagType';
const bitcoinWalletStore = useBitcoinWalletStore(); const bitcoinWalletStore = useBitcoinWalletStore();
const {
validation: { bitcoinWithdrawMaxFeeRateSatVbyte: maxBitcoinWithdrawFeeRateSatVbyte }
} = config;
const { status: walletStatus } = storeToRefs(bitcoinWalletStore); const { status: walletStatus } = storeToRefs(bitcoinWalletStore);
const { fetchStatus } = bitcoinWalletStore; const { fetchStatus, withdrawAll, revealSeed } = bitcoinWalletStore;
const loading = ref(true); const loading = ref(true);
const loadError = ref(false); const loadError = ref(false);
const refreshing = ref(false); const refreshing = ref(false);
const withdrawing = ref(false);
const revealingSeed = ref(false);
const withdrawFormRef = ref<FormInstance>();
const withdrawForm = reactive({
destinationAddress: '',
feeRateSatVbyte: 3
});
const seedDialogVisible = ref(false);
const revealedMnemonic = ref('');
onBeforeMount(async () => { onBeforeMount(async () => {
loading.value = true; loading.value = true;
@@ -67,6 +143,53 @@ onBeforeMount(async () => {
loading.value = false; loading.value = false;
}); });
const withdrawFormRules = computed<FormRules>(() => ({
destinationAddress: [
{
validator: (_rule, value, callback) => {
if (walletStatus.value?.syncStatus !== WalletSyncStatus.Synced) {
callback(new Error('Wait until the wallet finishes syncing before withdrawing'));
return;
}
if (typeof value !== 'string' || !value.trim()) {
callback(new Error('Enter a destination address'));
return;
}
const network = walletStatus.value?.network;
if (!network || !isBitcoinAddress(value, network)) {
callback(new Error(`Enter a valid ${network ?? 'Bitcoin'} address.`));
return;
}
callback();
},
trigger: ['blur', 'change']
}
],
feeRateSatVbyte: [
{
validator: (_rule, value, callback) => {
if (!Number.isInteger(value) || value < 1 || value > maxBitcoinWithdrawFeeRateSatVbyte) {
callback(
new Error(`Enter a fee rate between 1 and ${maxBitcoinWithdrawFeeRateSatVbyte} sat/vbyte`)
);
return;
}
callback();
},
trigger: ['blur', 'change']
}
]
}));
const loadWalletStatus = async (): Promise<void> => { const loadWalletStatus = async (): Promise<void> => {
loadError.value = false; loadError.value = false;
@@ -90,4 +213,131 @@ const refreshStatus = async (): Promise<void> => {
refreshing.value = false; refreshing.value = false;
} }
}; };
const promptForPassword = async (title: string): Promise<string | null> => {
try {
const { value } = await ElMessageBox.prompt('Enter your CMS password to continue.', title, {
confirmButtonText: 'Continue',
cancelButtonText: 'Cancel',
inputType: 'password',
inputValidator: value => (value.trim().length > 0 ? true : 'Password is required')
});
return value.trim();
} catch {
return null;
}
};
const onWithdrawSubmit = async (): Promise<void> => {
const formEl = withdrawFormRef.value;
if (!formEl) {
return;
}
try {
await formEl.validate();
} catch {
return;
}
const trimmedAddress = withdrawForm.destinationAddress.trim();
try {
await ElMessageBox.confirm(
`Withdraw all confirmed funds at ${withdrawForm.feeRateSatVbyte} sat/vbyte to:\n${trimmedAddress}`,
'Confirm withdrawal',
{
confirmButtonText: 'Continue',
cancelButtonText: 'Cancel',
type: 'warning'
}
);
} catch {
return;
}
const password = await promptForPassword('Confirm withdrawal');
if (!password) {
return;
}
withdrawing.value = true;
try {
const result = await withdrawAll({
destinationAddress: trimmedAddress,
feeRateSatVbyte: withdrawForm.feeRateSatVbyte,
password
});
ElMessage.success(`Withdrew ${result.amountBtc} BTC`);
await ElMessageBox.alert(result.txHash, 'Transaction hash', {
confirmButtonText: 'OK'
});
withdrawFormRef.value?.resetFields();
await loadWalletStatus();
} catch (error) {
ElMessage.error({ message: resolveAxiosErrorMessage(error, 'Withdrawal failed'), duration: 5000 });
} finally {
withdrawing.value = false;
}
};
const onRevealSeedClick = async (): Promise<void> => {
try {
await ElMessageBox.confirm(
'The recovery seed grants full control over this shop wallet. Store it offline. Never share it with anyone.',
'Reveal recovery seed?',
{
confirmButtonText: 'I understand',
cancelButtonText: 'Cancel',
type: 'warning'
}
);
} catch {
return;
}
const password = await promptForPassword('Reveal recovery seed');
if (!password) {
return;
}
revealingSeed.value = true;
try {
const result = await revealSeed({ password });
revealedMnemonic.value = result.mnemonic;
seedDialogVisible.value = true;
} catch (error) {
const errorMessage = resolveAxiosErrorMessage(error, 'Could not reveal seed');
ElMessage.error(errorMessage);
} finally {
revealingSeed.value = false;
}
};
const clearSeed = (): void => {
revealedMnemonic.value = '';
};
</script> </script>
<style scoped>
.withdraw-address-input {
width: 100%;
max-width: min(560px, 100%);
}
.fee-rate-input {
width: 120px;
}
</style>
+2 -3
View File
@@ -263,8 +263,7 @@ const onWithdrawSubmit = async (): Promise<void> => {
}); });
} }
withdrawForm.destinationAddress = ''; withdrawFormRef.value?.resetFields();
withdrawFormRef.value?.clearValidate();
await loadWalletStatus(); await loadWalletStatus();
} catch (error) { } catch (error) {
@@ -277,7 +276,7 @@ const onWithdrawSubmit = async (): Promise<void> => {
const onRevealSeedClick = async (): Promise<void> => { const onRevealSeedClick = async (): Promise<void> => {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
'The recovery seed grants full control over this shop wallet. Store it offline. Never share it or enter it on untrusted sites.', 'The recovery seed grants full control over this shop wallet. Store it offline. Never share it with anyone.',
'Reveal recovery seed?', 'Reveal recovery seed?',
{ {
confirmButtonText: 'I understand', confirmButtonText: 'I understand',
+2 -1
View File
@@ -42,7 +42,8 @@ export const config = {
digitalStockAttachmentsMax: parseInt(env('VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'), 10), digitalStockAttachmentsMax: parseInt(env('VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'), 10),
shippingNoteMinLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH'), 10), shippingNoteMinLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH'), 10),
shippingNoteMaxLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH'), 10), shippingNoteMaxLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH'), 10),
orderMessageMaxLength: parseInt(env('VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH'), 10) orderMessageMaxLength: parseInt(env('VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH'), 10),
bitcoinWithdrawMaxFeeRateSatVbyte: parseInt(env('VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE'), 10)
}, },
orders: { orders: {
+19 -1
View File
@@ -1,7 +1,11 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import { api } from '@/plugins/axios'; import { api } from '@/plugins/axios';
import type { BitcoinWalletRevealSeedPayload } from '@/types/bitcoinWallet/BitcoinWalletRevealSeedPayload';
import type { BitcoinWalletRevealSeedResult } from '@/types/bitcoinWallet/BitcoinWalletRevealSeedResult';
import type { BitcoinWalletStatus } from '@/types/bitcoinWallet/BitcoinWalletStatus'; import type { BitcoinWalletStatus } from '@/types/bitcoinWallet/BitcoinWalletStatus';
import type { BitcoinWalletWithdrawPayload } from '@/types/bitcoinWallet/BitcoinWalletWithdrawPayload';
import type { BitcoinWalletWithdrawResult } from '@/types/bitcoinWallet/BitcoinWalletWithdrawResult';
export const useBitcoinWalletStore = defineStore('bitcoinWallet', () => { export const useBitcoinWalletStore = defineStore('bitcoinWallet', () => {
const status = ref<BitcoinWalletStatus | null>(null); const status = ref<BitcoinWalletStatus | null>(null);
@@ -14,8 +18,22 @@ export const useBitcoinWalletStore = defineStore('bitcoinWallet', () => {
return data; return data;
}; };
const withdrawAll = async (payload: BitcoinWalletWithdrawPayload): Promise<BitcoinWalletWithdrawResult> => {
const { data } = await api.post<BitcoinWalletWithdrawResult>('/bitcoin-wallet/withdraw', payload);
return data;
};
const revealSeed = async (payload: BitcoinWalletRevealSeedPayload): Promise<BitcoinWalletRevealSeedResult> => {
const { data } = await api.post<BitcoinWalletRevealSeedResult>('/bitcoin-wallet/reveal-seed', payload);
return data;
};
return { return {
status, status,
fetchStatus fetchStatus,
withdrawAll,
revealSeed
}; };
}); });
@@ -0,0 +1,3 @@
export interface BitcoinWalletRevealSeedPayload {
password: string;
}
@@ -0,0 +1,3 @@
export interface BitcoinWalletRevealSeedResult {
mnemonic: string;
}
@@ -0,0 +1,5 @@
export interface BitcoinWalletWithdrawPayload {
destinationAddress: string;
feeRateSatVbyte: number;
password: string;
}
@@ -0,0 +1,4 @@
export interface BitcoinWalletWithdrawResult {
txHash: string;
amountBtc: string;
}
+28
View File
@@ -0,0 +1,28 @@
import type { BitcoinNetwork } from '@/types/bitcoinWallet/BitcoinNetwork';
// Soft validation only: prefix/length checks to reject obvious garbage and wrong-network
// addresses early. Checksums and spendability are validated by Electrum on payto.
const BASE58 = '[1-9A-HJ-NP-Za-km-z]';
const NETWORK_ADDRESS_PATTERNS: Record<BitcoinNetwork, RegExp[]> = {
mainnet: [
new RegExp(`^1${BASE58}{25,34}$`),
new RegExp(`^3${BASE58}{25,34}$`),
/^bc1[a-z0-9]{25,87}$/
],
testnet4: [
new RegExp(`^[mn]${BASE58}{25,34}$`),
new RegExp(`^2${BASE58}{25,34}$`),
/^(?:tb1|bcrt1)[a-z0-9]{25,87}$/
]
};
export const isBitcoinAddress = (value: unknown, network: BitcoinNetwork): boolean => {
if (typeof value !== 'string') {
return false;
}
const trimmed = value.trim();
return NETWORK_ADDRESS_PATTERNS[network].some(pattern => pattern.test(trimmed));
};
+1
View File
@@ -29,6 +29,7 @@ interface ImportMetaEnv {
readonly VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH?: string; readonly VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH?: string;
readonly VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH?: string; readonly VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH?: string;
readonly VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH?: string; readonly VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH?: string;
readonly VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE?: string;
readonly VITE_SHOP_FIAT_CURRENCY?: string; readonly VITE_SHOP_FIAT_CURRENCY?: string;
} }
+1
View File
@@ -140,6 +140,7 @@ services:
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH: ${VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH} VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH: ${VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH}
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH: ${VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH} VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH: ${VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH}
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH: ${VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH} VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH: ${VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH}
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE: ${VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE}
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS: ${VITE_ORDERS_DETAIL_POLL_INTERVAL_MS} VITE_ORDERS_DETAIL_POLL_INTERVAL_MS: ${VITE_ORDERS_DETAIL_POLL_INTERVAL_MS}
container_name: ${COMPOSE_PROJECT_NAME}_nginx container_name: ${COMPOSE_PROJECT_NAME}_nginx
restart: unless-stopped restart: unless-stopped
+2
View File
@@ -25,6 +25,7 @@ ARG VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX
ARG VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH ARG VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH
ARG VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH ARG VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH
ARG VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH ARG VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH
ARG VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE
ARG VITE_ORDERS_DETAIL_POLL_INTERVAL_MS ARG VITE_ORDERS_DETAIL_POLL_INTERVAL_MS
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
@@ -45,6 +46,7 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=$VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH \ VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=$VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH \
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=$VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH \ VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=$VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH \
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=$VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH \ VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=$VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH \
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=$VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE \
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=$VITE_ORDERS_DETAIL_POLL_INTERVAL_MS VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=$VITE_ORDERS_DETAIL_POLL_INTERVAL_MS
RUN npm run build RUN npm run build