Prepare product layout and reusable Flutter theme

Relocate the existing Astro website and preserve its assets and hosting configuration. Add the consumer/backend scaffold and themed Flutter package with consumer tests. Related to #1.
This commit is contained in:
Stephan D committed 2026-09-11 15:36:58 +02:00
1 parent ef65a40213
commit d9318053e8
46 files changed
+703 -1

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
import {
calculateQuote,
parseAmount,
type Crypto,
type Fiat,
} from '../lib/quote';
const amount = document.querySelector<HTMLInputElement>('#send-amount')!;
const crypto = document.querySelector<HTMLSelectElement>('#crypto')!;
const fiat = document.querySelector<HTMLSelectElement>('#fiat')!;
const output = document.querySelector<HTMLOutputElement>('#receive-amount')!;
const error = document.querySelector<HTMLElement>('#amount-error')!;
const lang = document.documentElement.lang;
const money = new Intl.NumberFormat(lang === 'ru' ? 'ru-RU' : 'en-GB', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const rateFormat = new Intl.NumberFormat(lang === 'ru' ? 'ru-RU' : 'en-GB', {
maximumFractionDigits: 4,
});
const submit = document.querySelector<HTMLButtonElement>('.exchange-submit')!;
function updateQuote() {
const coin = document.querySelector<HTMLElement>('#coin-icon')!;
coin.textContent = { BTC: '₿', ETH: 'Ξ', USDT: '₮' }[crypto.value]!;
coin.className = `coin coin-${crypto.value.toLowerCase()}`;
document.querySelector('#fiat-icon')!.textContent =
fiat.value === 'EUR' ? '€' : '$';
try {
const result = calculateQuote(
parseAmount(amount.value),
crypto.value as Crypto,
fiat.value as Fiat,
);
output.textContent = money.format(result.receive);
output.classList.toggle('long-amount', output.textContent.length > 12);
document.querySelector('#quote-rate')!.textContent =
`1 ${crypto.value} ≈ ${rateFormat.format(result.rate)} ${fiat.value}`;
document.querySelector('#quote-fee')!.textContent =
`${money.format(result.fee)} ${fiat.value}`;
error.hidden = true;
amount.setAttribute('aria-invalid', 'false');
submit.disabled = false;
return result;
} catch {
output.textContent = '—';
document.querySelector('#quote-fee')!.textContent = '—';
document.querySelector('#quote-rate')!.textContent = '—';
error.hidden = false;
amount.setAttribute('aria-invalid', 'true');
submit.disabled = true;
}
}
amount.addEventListener('input', updateQuote);
crypto.addEventListener('change', updateQuote);
fiat.addEventListener('change', updateQuote);
updateQuote();
const dialog = document.querySelector<HTMLDialogElement>('#account-dialog')!;
const shell = document.querySelector<HTMLElement>('.hero-shell')!;
let opener: HTMLElement | null = null;
document.querySelectorAll<HTMLButtonElement>('[data-auth]').forEach((button) =>
button.addEventListener('click', () => {
const destination =
button.dataset.auth === 'login'
? shell.dataset.loginUrl
: shell.dataset.registerUrl;
if (destination) {
const url = new URL(destination);
url.searchParams.set('lang', lang);
window.location.assign(url.toString());
return;
}
opener = button;
dialog.showModal();
document.body.classList.add('modal-open');
}),
);
dialog
.querySelectorAll('[data-close]')
.forEach((button) => button.addEventListener('click', () => dialog.close()));
dialog.addEventListener('click', (e) => {
if (e.target !== dialog) return;
const r = dialog.getBoundingClientRect();
if (
e.clientX < r.left ||
e.clientX > r.right ||
e.clientY < r.top ||
e.clientY > r.bottom
)
dialog.close();
});
dialog.addEventListener('close', () => {
document.body.classList.remove('modal-open');
opener?.focus();
});
document
.querySelectorAll<HTMLAnchorElement>('.language-switch a, .footer-locales a')
.forEach((a) =>
a.addEventListener('click', () => {
a.hash = window.location.hash;
}),
);