466 lines
15 KiB
JavaScript
466 lines
15 KiB
JavaScript
|
|
const LOAN_FEE_ATOMIC = 300000000n;
|
||
|
|
let loanOfferLoadPromise = null;
|
||
|
|
let loanCollateralAssets = [];
|
||
|
|
let loanNftKeys = new Set();
|
||
|
|
let loanOfferAssetsKey = "";
|
||
|
|
|
||
|
|
function setLoanOfferMessage(message, type = "") {
|
||
|
|
setScopedTransactionMessage("Create Loan", loanOfferMessage, message, type);
|
||
|
|
}
|
||
|
|
|
||
|
|
function loanNftKey(asset, series) {
|
||
|
|
return `${String(asset || "").trim().toLowerCase()}|${Number(series || 0)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function selectedLoanAsset() {
|
||
|
|
const option = loanAssetSelect?.selectedOptions?.[0];
|
||
|
|
if (!option?.value) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
asset: option.dataset.asset || "",
|
||
|
|
balance: BigInt(option.dataset.balance || "0")
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function selectedLoanCollateral() {
|
||
|
|
const option = loanCollateralSelect?.selectedOptions?.[0];
|
||
|
|
if (!option?.value) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
asset: option.dataset.asset || "",
|
||
|
|
kind: option.dataset.kind || "token",
|
||
|
|
series: Number(option.dataset.series || 0),
|
||
|
|
wireName: option.dataset.wireName || option.dataset.asset || ""
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function loanLocalInputValue(timestampMs) {
|
||
|
|
const date = new Date(timestampMs);
|
||
|
|
const pad = (value) => String(value).padStart(2, "0");
|
||
|
|
return [
|
||
|
|
date.getFullYear(),
|
||
|
|
"-",
|
||
|
|
pad(date.getMonth() + 1),
|
||
|
|
"-",
|
||
|
|
pad(date.getDate()),
|
||
|
|
"T",
|
||
|
|
pad(date.getHours()),
|
||
|
|
":",
|
||
|
|
pad(date.getMinutes())
|
||
|
|
].join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
function loanStartTimestamp() {
|
||
|
|
const value = String(loanStartInput?.value || "");
|
||
|
|
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/);
|
||
|
|
if (!match) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const timestamp = new Date(
|
||
|
|
Number(match[1]),
|
||
|
|
Number(match[2]) - 1,
|
||
|
|
Number(match[3]),
|
||
|
|
Number(match[4]),
|
||
|
|
Number(match[5]),
|
||
|
|
0
|
||
|
|
).getTime();
|
||
|
|
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return Math.floor(timestamp / 1000);
|
||
|
|
}
|
||
|
|
|
||
|
|
function populateLoanAssetSelect() {
|
||
|
|
if (!loanAssetSelect) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const previous = loanAssetSelect.value;
|
||
|
|
const assets = (Array.isArray(activeWalletBalances) ? activeWalletBalances : [])
|
||
|
|
.filter((balance) => {
|
||
|
|
const asset = String(balance.asset || "").trim();
|
||
|
|
const series = Number(balance.nft_series || 0);
|
||
|
|
return asset
|
||
|
|
&& balanceAtomicValue(balance) > 0n
|
||
|
|
&& !loanNftKeys.has(loanNftKey(asset, series));
|
||
|
|
})
|
||
|
|
.sort((left, right) => String(left.asset).localeCompare(String(right.asset)));
|
||
|
|
|
||
|
|
loanAssetSelect.innerHTML = "";
|
||
|
|
assets.forEach((balance) => {
|
||
|
|
const option = document.createElement("option");
|
||
|
|
option.value = String(balance.asset || "").trim();
|
||
|
|
option.textContent =
|
||
|
|
`${option.value} (${formatAtomicBalance(String(balance.balance || "0"))})`;
|
||
|
|
option.dataset.asset = option.value;
|
||
|
|
option.dataset.balance = String(balance.balance || "0");
|
||
|
|
loanAssetSelect.appendChild(option);
|
||
|
|
});
|
||
|
|
if (!assets.length) {
|
||
|
|
const option = document.createElement("option");
|
||
|
|
option.value = "";
|
||
|
|
option.textContent = walletLoaded ? "No lendable assets available" : "Load a wallet first";
|
||
|
|
loanAssetSelect.appendChild(option);
|
||
|
|
} else if ([...loanAssetSelect.options].some((option) => option.value === previous)) {
|
||
|
|
loanAssetSelect.value = previous;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function populateLoanCollateralSelect() {
|
||
|
|
if (!loanCollateralSelect) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const previous = loanCollateralSelect.value;
|
||
|
|
loanCollateralSelect.innerHTML = "";
|
||
|
|
loanCollateralAssets.forEach((asset) => {
|
||
|
|
const option = document.createElement("option");
|
||
|
|
option.value = `${asset.kind}|${asset.wireName}`;
|
||
|
|
option.textContent = asset.label;
|
||
|
|
option.dataset.asset = asset.asset;
|
||
|
|
option.dataset.kind = asset.kind;
|
||
|
|
option.dataset.series = String(asset.series || 0);
|
||
|
|
option.dataset.wireName = asset.wireName;
|
||
|
|
loanCollateralSelect.appendChild(option);
|
||
|
|
});
|
||
|
|
if (!loanCollateralAssets.length) {
|
||
|
|
const option = document.createElement("option");
|
||
|
|
option.value = "";
|
||
|
|
option.textContent = "No collateral assets available";
|
||
|
|
loanCollateralSelect.appendChild(option);
|
||
|
|
} else if ([...loanCollateralSelect.options].some((option) => option.value === previous)) {
|
||
|
|
loanCollateralSelect.value = previous;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshLoanOfferSelects() {
|
||
|
|
populateLoanAssetSelect();
|
||
|
|
populateLoanCollateralSelect();
|
||
|
|
updateLoanOfferDerivedFields();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadLoanOfferAssets() {
|
||
|
|
if (loanOfferLoadPromise) {
|
||
|
|
return loanOfferLoadPromise;
|
||
|
|
}
|
||
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
||
|
|
const broadcastNode = activeBroadcastNode();
|
||
|
|
if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`;
|
||
|
|
if (loanOfferAssetsKey === key && loanCollateralAssets.length) {
|
||
|
|
refreshLoanOfferSelects();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
loanOfferLoadPromise = (async () => {
|
||
|
|
setLoanOfferMessage("Loading loan assets...");
|
||
|
|
const [balances, tokens, nfts] = await Promise.all([
|
||
|
|
activeWalletBalances.length
|
||
|
|
? Promise.resolve(activeWalletBalances)
|
||
|
|
: invoke("get_active_wallet_balances", { broadcastNode }),
|
||
|
|
invoke("token_list", { broadcastNode }),
|
||
|
|
invoke("nft_list", { broadcastNode })
|
||
|
|
]);
|
||
|
|
activeWalletBalances = Array.isArray(balances) ? balances : [];
|
||
|
|
const tokenRows = Array.isArray(tokens) ? tokens : [];
|
||
|
|
const nftRows = Array.isArray(nfts) ? nfts : [];
|
||
|
|
loanNftKeys = new Set(nftRows.map((nft) => loanNftKey(nft.nft_name, nft.series)));
|
||
|
|
|
||
|
|
loanCollateralAssets = [
|
||
|
|
{
|
||
|
|
asset: activeBaseCoin(),
|
||
|
|
wireName: activeBaseCoin(),
|
||
|
|
series: 0,
|
||
|
|
kind: "coin",
|
||
|
|
label: `${activeBaseCoin()} (Base Coin)`
|
||
|
|
},
|
||
|
|
...tokenRows
|
||
|
|
.filter((token) => {
|
||
|
|
return String(token.ticker || "").trim().toLowerCase()
|
||
|
|
!== activeBaseCoin().toLowerCase();
|
||
|
|
})
|
||
|
|
.map((token) => {
|
||
|
|
const asset = String(token.ticker || "").trim();
|
||
|
|
return { asset, wireName: asset, series: 0, kind: "token", label: `${asset} (Token)` };
|
||
|
|
}),
|
||
|
|
...nftRows.map((nft) => {
|
||
|
|
const asset = String(nft.nft_name || "").trim();
|
||
|
|
const series = Number(nft.series || 0);
|
||
|
|
return {
|
||
|
|
asset,
|
||
|
|
wireName: series > 0 ? `${asset}_${series}` : asset,
|
||
|
|
series,
|
||
|
|
kind: "nft",
|
||
|
|
label: series > 0 ? `${asset} #${series} (NFT)` : `${asset} (NFT)`
|
||
|
|
};
|
||
|
|
})
|
||
|
|
].filter((asset) => {
|
||
|
|
return asset.asset
|
||
|
|
&& asset.wireName.length <= 21
|
||
|
|
&& (asset.kind !== "nft" || asset.series <= 99999);
|
||
|
|
});
|
||
|
|
loanCollateralAssets.sort((left, right) => left.label.localeCompare(right.label));
|
||
|
|
loanOfferAssetsKey = key;
|
||
|
|
refreshLoanOfferSelects();
|
||
|
|
setLoanOfferMessage("");
|
||
|
|
})().catch((error) => {
|
||
|
|
setLoanOfferMessage(String(error), "error");
|
||
|
|
throw error;
|
||
|
|
}).finally(() => {
|
||
|
|
loanOfferLoadPromise = null;
|
||
|
|
});
|
||
|
|
|
||
|
|
return loanOfferLoadPromise;
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateLoanOfferDerivedFields() {
|
||
|
|
const loanAsset = selectedLoanAsset();
|
||
|
|
const collateral = selectedLoanCollateral();
|
||
|
|
if (loanLender) {
|
||
|
|
loanLender.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
|
||
|
|
}
|
||
|
|
if (loanAvailable) {
|
||
|
|
loanAvailable.textContent = loanAsset
|
||
|
|
? `${formatAtomicBalance(String(loanAsset.balance))} ${loanAsset.asset}`
|
||
|
|
: "--";
|
||
|
|
}
|
||
|
|
if (loanFeeInput) {
|
||
|
|
loanFeeInput.value = atomicToDecimal(LOAN_FEE_ATOMIC);
|
||
|
|
}
|
||
|
|
if (collateral?.kind === "nft") {
|
||
|
|
loanCollateralAmountInput.value = "1";
|
||
|
|
loanCollateralAmountInput.disabled = true;
|
||
|
|
} else if (loanCollateralAmountInput) {
|
||
|
|
loanCollateralAmountInput.disabled = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const paymentAmount = decimalToAtomic(loanPaymentAmountInput?.value);
|
||
|
|
const paymentNumber = Number.parseInt(loanPaymentNumberInput?.value || "0", 10);
|
||
|
|
if (loanTotalRepayment) {
|
||
|
|
loanTotalRepayment.textContent =
|
||
|
|
paymentAmount !== null && paymentAmount > 0n && paymentNumber > 0
|
||
|
|
? `${atomicToDecimal(paymentAmount * BigInt(paymentNumber))} ${loanAsset?.asset || ""}`.trim()
|
||
|
|
: "--";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function validateLoanOfferForm() {
|
||
|
|
if (!walletLoaded) {
|
||
|
|
return "Load a wallet before creating a loan offer.";
|
||
|
|
}
|
||
|
|
if (!registrationRegistered) {
|
||
|
|
return "Address registration is required before creating a loan offer.";
|
||
|
|
}
|
||
|
|
const loanAsset = selectedLoanAsset();
|
||
|
|
const collateral = selectedLoanCollateral();
|
||
|
|
if (!loanAsset) {
|
||
|
|
return "Select an asset to lend.";
|
||
|
|
}
|
||
|
|
if (!collateral) {
|
||
|
|
return "Select the collateral required from the borrower.";
|
||
|
|
}
|
||
|
|
const borrower = String(loanBorrowerInput?.value || "").trim();
|
||
|
|
if (!borrower) {
|
||
|
|
return "Enter the borrower wallet address.";
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
borrower.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase()
|
||
|
|
|| borrower.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase()
|
||
|
|
) {
|
||
|
|
return "Borrower cannot be the active lender wallet.";
|
||
|
|
}
|
||
|
|
|
||
|
|
const loanAmount = decimalToAtomic(loanAmountInput?.value);
|
||
|
|
const collateralAmount = collateral.kind === "nft"
|
||
|
|
? ATOMIC_UNITS_PER_COIN
|
||
|
|
: decimalToAtomic(loanCollateralAmountInput?.value);
|
||
|
|
const paymentAmount = decimalToAtomic(loanPaymentAmountInput?.value);
|
||
|
|
const maxLateValue = decimalToAtomic(loanMaxLateValueInput?.value);
|
||
|
|
if (loanAmount === null || loanAmount <= 0n) {
|
||
|
|
return "Enter a valid loan amount.";
|
||
|
|
}
|
||
|
|
if (collateralAmount === null || collateralAmount <= 0n) {
|
||
|
|
return "Enter a valid collateral amount.";
|
||
|
|
}
|
||
|
|
if (paymentAmount === null || paymentAmount <= 0n) {
|
||
|
|
return "Enter a valid payment amount.";
|
||
|
|
}
|
||
|
|
if (maxLateValue === null) {
|
||
|
|
return "Enter a valid maximum overdue value.";
|
||
|
|
}
|
||
|
|
if (paymentAmount > loanAmount) {
|
||
|
|
return "A single payment cannot exceed the loan amount.";
|
||
|
|
}
|
||
|
|
|
||
|
|
const paymentNumber = Number.parseInt(loanPaymentNumberInput?.value || "0", 10);
|
||
|
|
const gracePeriod = Number.parseInt(loanGracePeriodInput?.value || "0", 10);
|
||
|
|
if (!Number.isInteger(paymentNumber) || paymentNumber < 1 || paymentNumber > 255) {
|
||
|
|
return "Number of payments must be between 1 and 255.";
|
||
|
|
}
|
||
|
|
if (!Number.isInteger(gracePeriod) || gracePeriod < 0 || gracePeriod > paymentNumber) {
|
||
|
|
return "Missed payments allowed must be between 0 and the payment count.";
|
||
|
|
}
|
||
|
|
if (paymentAmount * BigInt(paymentNumber) < loanAmount) {
|
||
|
|
return "Scheduled payments must repay at least the loan principal.";
|
||
|
|
}
|
||
|
|
if (maxLateValue > loanAmount) {
|
||
|
|
return "Maximum overdue value cannot exceed the loan amount.";
|
||
|
|
}
|
||
|
|
|
||
|
|
const startTimestamp = loanStartTimestamp();
|
||
|
|
if (startTimestamp === null || startTimestamp > 4294967295) {
|
||
|
|
return "Enter a valid loan start date and time.";
|
||
|
|
}
|
||
|
|
if (loanAmount > loanAsset.balance) {
|
||
|
|
return "Loan amount exceeds the available asset balance.";
|
||
|
|
}
|
||
|
|
const baseBalance = baseCoinBalanceAtomic();
|
||
|
|
if (isBaseTransferAsset(loanAsset.asset)) {
|
||
|
|
if (loanAmount + LOAN_FEE_ATOMIC > loanAsset.balance) {
|
||
|
|
return `The loan amount and ${atomicToDecimal(LOAN_FEE_ATOMIC)} ${activeBaseCoin()} fee exceed the available balance.`;
|
||
|
|
}
|
||
|
|
} else if (baseBalance < LOAN_FEE_ATOMIC) {
|
||
|
|
return `Creating a loan requires ${atomicToDecimal(LOAN_FEE_ATOMIC)} ${activeBaseCoin()} for the lender fee.`;
|
||
|
|
}
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
function loanOfferPayload() {
|
||
|
|
const loanAsset = selectedLoanAsset();
|
||
|
|
const collateral = selectedLoanCollateral();
|
||
|
|
const collateralAmount = collateral.kind === "nft"
|
||
|
|
? ATOMIC_UNITS_PER_COIN
|
||
|
|
: decimalToAtomic(loanCollateralAmountInput.value);
|
||
|
|
return {
|
||
|
|
broadcastNode: activeBroadcastNode(),
|
||
|
|
loanCoin: loanAsset.asset,
|
||
|
|
loanAmount: String(decimalToAtomic(loanAmountInput.value)),
|
||
|
|
borrower: String(loanBorrowerInput.value || "").trim(),
|
||
|
|
collateral: collateral.wireName,
|
||
|
|
collateralAmount: String(collateralAmount),
|
||
|
|
paymentPeriod: String(loanPaymentPeriodSelect.value || "d"),
|
||
|
|
paymentNumber: Number.parseInt(loanPaymentNumberInput.value, 10),
|
||
|
|
paymentAmount: String(decimalToAtomic(loanPaymentAmountInput.value)),
|
||
|
|
gracePeriod: Number.parseInt(loanGracePeriodInput.value, 10),
|
||
|
|
maxLateValue: String(decimalToAtomic(loanMaxLateValueInput.value)),
|
||
|
|
startTimestamp: loanStartTimestamp()
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearLoanOfferForm() {
|
||
|
|
loanAmountInput.value = "";
|
||
|
|
loanBorrowerInput.value = "";
|
||
|
|
loanCollateralAmountInput.value = "";
|
||
|
|
loanPaymentPeriodSelect.value = "d";
|
||
|
|
loanPaymentNumberInput.value = "1";
|
||
|
|
loanPaymentAmountInput.value = "";
|
||
|
|
loanGracePeriodInput.value = "0";
|
||
|
|
loanMaxLateValueInput.value = "";
|
||
|
|
loanStartInput.value = loanLocalInputValue(Date.now() + 24 * 60 * 60 * 1000);
|
||
|
|
updateLoanOfferDerivedFields();
|
||
|
|
}
|
||
|
|
|
||
|
|
function setSavedLoanOfferPath(path = "") {
|
||
|
|
if (loanSavedPathInput) {
|
||
|
|
loanSavedPathInput.value = path;
|
||
|
|
}
|
||
|
|
loanSavedPathPanel?.classList.toggle("hidden", !path);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveLoanOffer() {
|
||
|
|
const validationError = validateLoanOfferForm();
|
||
|
|
if (validationError) {
|
||
|
|
setLoanOfferMessage(validationError, "error");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
||
|
|
if (!invoke) {
|
||
|
|
setLoanOfferMessage("Wallet bridge is not available.", "error");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const walletKey = await requestSigningWalletKey();
|
||
|
|
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
|
||
|
|
setLoanOfferMessage("Signing cancelled.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const button = document.querySelector("[data-loan-offer-save]");
|
||
|
|
button.disabled = true;
|
||
|
|
setSavedLoanOfferPath("");
|
||
|
|
setLoanOfferMessage("Signing and saving loan offer...");
|
||
|
|
try {
|
||
|
|
const result = await invoke("save_loan_offer", {
|
||
|
|
...loanOfferPayload(),
|
||
|
|
walletKey
|
||
|
|
});
|
||
|
|
clearLoanOfferForm();
|
||
|
|
setSavedLoanOfferPath(result.file_path || "");
|
||
|
|
setLoanOfferMessage(
|
||
|
|
`Loan offer signed and saved as ${result.review?.file_name || "a local transaction file"}. Send this file to the borrower for review and signature.`,
|
||
|
|
"success"
|
||
|
|
);
|
||
|
|
if (typeof refreshUnbroadcastTransactions === "function") {
|
||
|
|
refreshUnbroadcastTransactions();
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
setLoanOfferMessage(String(error), "error");
|
||
|
|
} finally {
|
||
|
|
button.disabled = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function prepareLoanOfferForm() {
|
||
|
|
if (!loanStartInput.value) {
|
||
|
|
loanStartInput.value = loanLocalInputValue(Date.now() + 24 * 60 * 60 * 1000);
|
||
|
|
}
|
||
|
|
setLoanOfferMessage("");
|
||
|
|
loadLoanOfferAssets().catch(() => {
|
||
|
|
// The form displays the lookup error.
|
||
|
|
});
|
||
|
|
updateLoanOfferDerivedFields();
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetLoanOfferSession() {
|
||
|
|
loanOfferLoadPromise = null;
|
||
|
|
loanCollateralAssets = [];
|
||
|
|
loanNftKeys = new Set();
|
||
|
|
loanOfferAssetsKey = "";
|
||
|
|
clearLoanOfferForm();
|
||
|
|
setSavedLoanOfferPath("");
|
||
|
|
loanAssetSelect.innerHTML = "";
|
||
|
|
loanCollateralSelect.innerHTML = "";
|
||
|
|
setLoanOfferMessage("");
|
||
|
|
}
|
||
|
|
|
||
|
|
[
|
||
|
|
loanAssetSelect,
|
||
|
|
loanCollateralSelect,
|
||
|
|
loanAmountInput,
|
||
|
|
loanCollateralAmountInput,
|
||
|
|
loanPaymentPeriodSelect,
|
||
|
|
loanPaymentNumberInput,
|
||
|
|
loanPaymentAmountInput,
|
||
|
|
loanGracePeriodInput,
|
||
|
|
loanMaxLateValueInput,
|
||
|
|
loanStartInput
|
||
|
|
].forEach((element) => element?.addEventListener("input", () => {
|
||
|
|
setLoanOfferMessage("");
|
||
|
|
updateLoanOfferDerivedFields();
|
||
|
|
}));
|
||
|
|
loanBorrowerInput?.addEventListener("input", () => setLoanOfferMessage(""));
|
||
|
|
document.querySelector("[data-loan-offer-save]")?.addEventListener("click", saveLoanOffer);
|
||
|
|
loanCopyPathButton?.addEventListener("click", async () => {
|
||
|
|
const path = String(loanSavedPathInput?.value || "");
|
||
|
|
if (!path || !(await writeClipboardText(path))) {
|
||
|
|
setLoanOfferMessage("Unable to copy the saved loan offer path.", "error");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const original = loanCopyPathButton.textContent;
|
||
|
|
loanCopyPathButton.textContent = "Copied";
|
||
|
|
setTimeout(() => {
|
||
|
|
loanCopyPathButton.textContent = original;
|
||
|
|
}, 1200);
|
||
|
|
});
|