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

@@ -0,0 +1,21 @@
---
import { copy, type Lang } from '../i18n/copy';
const { lang } = Astro.props as { lang: Lang };
const t = copy[lang];
---
<dialog
id="account-dialog"
aria-labelledby="account-title"
aria-describedby="account-description"
>
<button class="dialog-x" data-close aria-label={t.dialogClose}>×</button><span
class="dialog-symbol"
aria-hidden="true">↗</span
><p class="eyebrow">SXCHNGE / {t.demo}</p><h2 id="account-title">
{t.authTitle}
</h2><p id="account-description">{t.authBody}</p><button
class="button button-dark"
data-close
autofocus>{t.close}<span aria-hidden="true">↗</span></button>
</dialog>
+51
View File
@@ -0,0 +1,51 @@
---
import { copy, type Lang } from '../i18n/copy';
const { lang } = Astro.props as { lang: Lang };
const t = copy[lang];
const policies = ['terms', 'privacy', 'aml', 'kyc'];
---
<footer class="site-footer">
<div class="wrap">
<div class="footer-grid">
<div class="footer-brand">
<a class="wordmark" href={`/${lang}/`}
><span class="brand-icon" aria-hidden="true">↗</span> sxchnge<span
class="brand-dot">®</span
></a
><p>{t.heroNote}</p><span class="footer-direction" aria-hidden="true"
>₿ <span>⟶</span> €</span>
</div><div>
<h2>{t.company}</h2>{
t.companyFields.map((field) => <p class="placeholder">{field}</p>)
}
</div><div>
<h2>{t.legal}</h2>{
t.legalNames.map((name, i) => (
<a href={`/${lang}/policies/${policies[i]}/`}>
{name}
<span aria-hidden="true">↗</span>
</a>
))
}
</div><div>
<h2>{t.contact}</h2><p class="placeholder">{t.support}</p><div
class="footer-locales"
>
<a
href="/en/"
lang="en"
aria-current={lang === 'en' ? 'page' : undefined}>English</a
><a
href="/ru/"
lang="ru"
aria-current={lang === 'ru' ? 'page' : undefined}>Русский</a>
</div>
</div>
</div><div class="footer-bottom">
<p>© {new Date().getFullYear()} SXCHNGE</p><p>{t.draft}</p><p>
{t.rights}
</p>
</div>
</div>
</footer>
+293
View File
@@ -0,0 +1,293 @@
export type Lang = 'en' | 'ru';
export const languages: Lang[] = ['en', 'ru'];
export const copy = {
en: {
title: 'SXCHNGE — Crypto out. Life on.',
description:
'Explore a simpler way from crypto to fiat. Preview an exchange, learn about verification, and get ready to create your account.',
nav: ['How it works', 'Why SXCHNGE', 'FAQ'],
login: 'Log in',
register: 'Create an account',
skip: 'Skip to content',
eyebrow: 'CRYPTO → FIAT. SIMPLY.',
headline: ['Crypto out.', 'Life on.'],
intro:
'From digital assets to your next big thing. Make the move from crypto to everyday money.',
subcta: 'See how it works',
heroNote: 'Your crypto. Your next move.',
preview: 'Exchange preview',
demo: 'DEMO',
send: 'You send',
receive: 'You receive',
crypto: 'Cryptocurrency',
fiat: 'Payout currency',
sampleRate: 'Illustrative rate',
fee: 'Example fee (1%)',
payout: 'Payout method',
bank: 'Bank transfer · example',
exchange: 'Register to exchange',
disclaimer:
'Demo calculation, not a live quote. Currencies, fees and payout methods are illustrative.',
invalid: 'Enter an amount greater than 0, up to 1,000,000.',
quoteLabel: 'Estimated amount after example fee',
strip: ['Less friction.', 'More possibility.', 'Your next move.'],
howLabel: '01 / THE PROCESS',
howTitle: 'Three steps.\nA fresh start.',
howIntro: 'A clear path from your crypto balance to what comes next.',
steps: [
['Make it yours', 'Create your account to start your exchange journey.'],
[
'Verify your identity',
'Complete the required checks before your first exchange.',
],
[
'Make your move',
'Review your quote, send crypto and receive your fiat payout.',
],
],
whyLabel: '02 / MADE FOR YOUR NEXT MOVE',
whyTitle: 'Big on clarity.\nLight on complexity.',
whyIntro: 'The details that matter, before you exchange.',
features: [
[
'A quote you can read.',
'See the conversion, applicable fees and estimated payout together before confirming.',
],
[
'Your destination, your choice.',
'Explore crypto-to-fiat pairs. Available currencies and payout methods will depend on your region.',
],
[
'A little preparation. A clearer path.',
'Understand identity verification before you start. Read how KYC and AML checks fit into an exchange.',
],
],
pairNote: 'Illustrative currencies',
verification: 'VERIFICATION, EXPLAINED',
kyc: 'About KYC',
aml: 'AML policy',
faqLabel: '03 / GOOD QUESTIONS',
faqTitle: 'Let’s clear\na few things up.',
faqs: [
[
'Which currencies can I exchange?',
'The preview demonstrates BTC, ETH and USDT conversion into EUR or USD. These are examples, not a confirmed list of supported assets. The available pairs will be shown in the exchange application.',
],
[
'Do I need to verify my identity?',
'Identity verification is part of the planned exchange process. Required documents, eligibility and checks will be described in the final KYC policy before the service launches.',
],
[
'What are the fees?',
'The calculator uses sample rates and a 1% example fee to demonstrate the experience. It is not a live quote. Actual fees and the final amount must be presented in the application before you confirm an exchange.',
],
[
'How long will a payout take?',
'Timing depends on the asset network, verification checks and payout provider. The application should show an estimated timeframe for your selected route before confirmation.',
],
[
'Where is the service available?',
'Supported countries and any regional restrictions are still to be confirmed. Check the published terms and eligibility information when the service launches.',
],
],
closingLabel: 'YOUR NEXT CHAPTER STARTS HERE',
closing: 'Put your crypto\nin motion.',
closingNote: 'One account. Your next move.',
company: 'Company',
legal: 'Policies',
contact: 'Contact',
companyFields: [
'[Legal company name]',
'[Registration number]',
'[Registered address]',
'[Regulatory status, if applicable]',
],
support: '[Support email]',
legalNames: [
'Terms of service',
'Privacy policy',
'AML policy',
'KYC policy',
],
draft: 'Design preview · Company information and policies pending.',
rights: 'Crypto → fiat. Forward.',
authTitle: 'Your next move is coming.',
authBody:
'This is a preview of the SXCHNGE website. Registration and login will open in the exchange application once it is connected. No account is created here.',
close: 'Back to the preview',
dialogClose: 'Close dialog',
policyDraft: 'DRAFT PLACEHOLDER',
policyIntro:
'This page reserves a place for the final policy. It is not an active policy or a legal statement. The company must supply and approve its content before launch.',
policyBack: 'Back to SXCHNGE',
policyNeeded: 'Content to be supplied',
policyItems: [
[
'Service provider and jurisdiction',
'Eligibility, supported regions and service terms',
'Fees, cancellations, complaints and dispute resolution',
],
[
'Data controller and contact information',
'Data collected, purposes and legal bases',
'Retention, processors, international transfers and individual rights',
],
[
'Responsible legal entity and compliance contacts',
'Risk assessment, screening and transaction monitoring',
'Escalation, reporting and record-keeping procedures',
],
[
'Eligibility and identity verification requirements',
'Required documents and enhanced checks',
'Review process, retention and customer support',
],
],
},
ru: {
title: 'SXCHNGE — Из крипто в жизнь.',
description:
'Простой путь от криптовалюты к фиатным деньгам. Рассчитайте пример обмена, узнайте о проверке личности и подготовьтесь к регистрации.',
nav: ['Как это работает', 'Почему SXCHNGE', 'Вопросы'],
login: 'Войти',
register: 'Создать аккаунт',
skip: 'Перейти к содержанию',
eyebrow: 'КРИПТО → ФИАТ. ПРОСТО.',
headline: ['Из крипто.', 'В жизнь.'],
intro:
'От цифровых активов — к вашим планам. Переведите криптовалюту в деньги для повседневной жизни.',
subcta: 'Как это работает',
heroNote: 'Ваша крипто. Ваш следующий шаг.',
preview: 'Пример обмена',
demo: 'ДЕМО',
send: 'Вы отправляете',
receive: 'Вы получаете',
crypto: 'Криптовалюта',
fiat: 'Валюта выплаты',
sampleRate: 'Пример курса',
fee: 'Пример комиссии (1%)',
payout: 'Способ выплаты',
bank: 'Банковский перевод · пример',
exchange: 'Регистрация для обмена',
disclaimer:
'Демонстрационный расчёт, не действующая котировка. Валюты, комиссии и способы выплаты приведены для примера.',
invalid: 'Введите сумму больше 0, но не больше 1 000 000.',
quoteLabel: 'Примерная сумма после условной комиссии',
strip: ['Меньше сложностей.', 'Больше возможностей.', 'Ваш следующий шаг.'],
howLabel: '01 / КАК ЭТО РАБОТАЕТ',
howTitle: 'Три шага.\nНовые планы.',
howIntro: 'Понятный путь от криптобаланса к вашим следующим целям.',
steps: [
[
'Создайте аккаунт',
'Зарегистрируйтесь, чтобы начать свой путь к обмену.',
],
[
'Подтвердите личность',
'Пройдите необходимые проверки перед первым обменом.',
],
[
'Сделайте следующий шаг',
'Проверьте условия, отправьте криптовалюту и получите выплату в фиатной валюте.',
],
],
whyLabel: '02 / ДЛЯ ВАШИХ ПЛАНОВ',
whyTitle: 'Больше ясности.\nМеньше сложностей.',
whyIntro: 'Всё, что важно знать до обмена.',
features: [
[
'Понятные условия.',
'Курс, применимые комиссии и ожидаемая выплата — вместе, до подтверждения обмена.',
],
[
'Выбирайте направление.',
'Изучите пары крипто → фиат. Доступные валюты и способы выплаты будут зависеть от вашего региона.',
],
[
'Подготовьтесь заранее.',
'Узнайте о проверке личности до начала обмена. Прочитайте, как работают процедуры KYC и AML.',
],
],
pairNote: 'Валюты для примера',
verification: 'О ПРОВЕРКЕ ЛИЧНОСТИ',
kyc: 'О процедуре KYC',
aml: 'Политика AML',
faqLabel: '03 / ВАШИ ВОПРОСЫ',
faqTitle: 'Разберёмся\nв деталях.',
faqs: [
[
'Какие валюты можно обменять?',
'В примере показан обмен BTC, ETH и USDT на EUR или USD. Это демонстрация, а не подтверждённый список активов. Доступные пары будут указаны в приложении обмена.',
],
[
'Нужно ли подтверждать личность?',
'Проверка личности предусмотрена в процессе обмена. Требования к документам, условия доступа и порядок проверок будут описаны в окончательной политике KYC до запуска сервиса.',
],
[
'Какие комиссии за обмен?',
'Калькулятор использует условные курсы и комиссию 1% для демонстрации. Это не действующая котировка. Фактические комиссии и итоговая сумма должны быть показаны в приложении до подтверждения обмена.',
],
[
'Сколько времени занимает выплата?',
'Срок зависит от сети актива, проверок и платёжного провайдера. Приложение должно показать ожидаемое время для выбранного направления до подтверждения.',
],
[
'В каких странах работает сервис?',
'Список поддерживаемых стран и региональных ограничений пока не утверждён. После запуска ознакомьтесь с опубликованными условиями и требованиями к клиентам.',
],
],
closingLabel: 'НОВАЯ ГЛАВА НАЧИНАЕТСЯ ЗДЕСЬ',
closing: 'Крипто в дело.\nПланы в жизнь.',
closingNote: 'Один аккаунт. Ваш следующий шаг.',
company: 'Компания',
legal: 'Документы',
contact: 'Контакты',
companyFields: [
'[Юридическое наименование]',
'[Регистрационный номер]',
'[Юридический адрес]',
'[Регуляторный статус, если применимо]',
],
support: '[Email поддержки]',
legalNames: [
'Условия обслуживания',
'Политика конфиденциальности',
'Политика AML',
'Политика KYC',
],
draft: 'Макет сайта · Данные компании и политики ещё не предоставлены.',
rights: 'Крипто → фиат. Вперёд.',
authTitle: 'Скоро — ваш следующий шаг.',
authBody:
'Это предварительная версия сайта SXCHNGE. Регистрация и вход будут доступны в приложении обмена после его подключения. Здесь аккаунт не создаётся.',
close: 'Вернуться к просмотру',
dialogClose: 'Закрыть окно',
policyDraft: 'МЕСТО ДЛЯ БУДУЩЕГО ДОКУМЕНТА',
policyIntro:
'Эта страница предназначена для окончательного текста политики. Она не является действующим документом или юридическим заявлением. Компания должна предоставить и утвердить текст до запуска.',
policyBack: 'Вернуться к SXCHNGE',
policyNeeded: 'Что необходимо предоставить',
policyItems: [
[
'Поставщик услуги и юрисдикция',
'Требования к клиентам, регионы и условия обслуживания',
'Комиссии, отмена, жалобы и разрешение споров',
],
[
'Оператор данных и контактная информация',
'Состав данных, цели и правовые основания обработки',
'Сроки хранения, обработчики, международная передача и права клиентов',
],
[
'Ответственное юридическое лицо и контакты комплаенс-службы',
'Оценка рисков, скрининг и мониторинг транзакций',
'Процедуры эскалации, отчётности и хранения записей',
],
[
'Требования к клиентам и проверке личности',
'Необходимые документы и дополнительные проверки',
'Порядок рассмотрения, хранение данных и поддержка',
],
],
},
};
+38
View File
@@ -0,0 +1,38 @@
---
import '../styles/global.css';
import { copy, type Lang } from '../i18n/copy';
interface Props {
lang: Lang;
title?: string;
path?: string;
}
const { lang, title, path = '' } = Astro.props;
const t = copy[lang];
const origin =
Astro.site || new URL('https://sxchnge.s-deshevykh.chatgpt.site');
---
<!doctype html>
<html lang={lang}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#c4f9ce" />
<meta name="description" content={t.description} />
<meta name="robots" content="noindex,nofollow" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="canonical" href={new URL(`/${lang}/${path}`, origin)} />
<link rel="alternate" hreflang="en" href={new URL(`/en/${path}`, origin)} />
<link rel="alternate" hreflang="ru" href={new URL(`/ru/${path}`, origin)} />
<link
rel="alternate"
hreflang="x-default"
href={new URL(`/en/${path}`, origin)}
/>
<title>{title || t.title}</title>
</head>
<body>
<a class="skip-link" href="#main">{t.skip}</a>
<slot />
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
// Full Flutter destinations are intentionally unset until the real app exists.
function validatedUrl(value: string | undefined) {
if (!value) return undefined;
const url = new URL(value);
if (!['https:', 'http:'].includes(url.protocol))
throw new Error('Flutter URLs must be HTTP(S) URLs');
return url.toString();
}
export const appLinks = {
register: validatedUrl(import.meta.env.PUBLIC_FLUTTER_REGISTER_URL),
login: validatedUrl(import.meta.env.PUBLIC_FLUTTER_LOGIN_URL),
};
+30
View File
@@ -0,0 +1,30 @@
export const cryptoCurrencies = ['BTC', 'ETH', 'USDT'] as const;
export const fiatCurrencies = ['EUR', 'USD'] as const;
export type Crypto = (typeof cryptoCurrencies)[number];
export type Fiat = (typeof fiatCurrencies)[number];
// Fixed, deliberately illustrative fixtures. Never use for an actual exchange.
export const demoRates: Record<Crypto, Record<Fiat, number>> = {
BTC: { EUR: 57500, USD: 62500 },
ETH: { EUR: 2300, USD: 2500 },
USDT: { EUR: 0.92, USD: 1 },
};
export function parseAmount(raw: string): number {
const normalized = raw.trim().replace(',', '.');
if (!/^\d+(?:\.\d{0,8})?$/.test(normalized)) return NaN;
return Number(normalized);
}
export function calculateQuote(amount: number, crypto: Crypto, fiat: Fiat) {
if (!Number.isFinite(amount) || amount <= 0 || amount > 1_000_000)
throw new RangeError('Invalid amount');
if (!cryptoCurrencies.includes(crypto) || !fiatCurrencies.includes(fiat))
throw new RangeError('Invalid currency');
const rate = demoRates[crypto][fiat];
const grossCents = Math.round(amount * rate * 100);
const feeCents = Math.round(grossCents * 0.01);
return {
rate,
fee: feeCents / 100,
receive: (grossCents - feeCents) / 100,
demo: true,
};
}
+296
View File
@@ -0,0 +1,296 @@
---
import Layout from '../../layouts/Layout.astro';
import Footer from '../../components/Footer.astro';
import AccountDialog from '../../components/AccountDialog.astro';
import { appLinks } from '../../lib/app-links';
import { copy, languages, type Lang } from '../../i18n/copy';
export function getStaticPaths() {
return languages.map((lang) => ({ params: { lang } }));
}
const lang = Astro.params.lang as Lang;
const t = copy[lang];
---
<Layout lang={lang}>
<div
class="hero-shell"
data-register-url={appLinks.register}
data-login-url={appLinks.login}
>
<header class="site-header wrap">
<a class="wordmark" href={`/${lang}/`} aria-label="SXCHNGE"
><span class="brand-icon" aria-hidden="true">↗</span> sxchnge<span
class="brand-dot">®</span
></a
>
<nav
class="desktop-nav"
aria-label={lang === 'en' ? 'Main navigation' : 'Основная навигация'}
>
{
t.nav.map((label, i) => (
<a href={['#how', '#why', '#faq'][i]}>{label}</a>
))
}
</nav>
<div class="header-actions">
<div
class="language-switch"
aria-label={lang === 'en' ? 'Language' : 'Язык'}
>
<a
href="/en/"
lang="en"
aria-current={lang === 'en' ? 'page' : undefined}>EN</a
><span>/</span><a
href="/ru/"
lang="ru"
aria-current={lang === 'ru' ? 'page' : undefined}>RU</a>
</div><button class="login-button" data-auth="login"
>{t.login}<span aria-hidden="true">↗</span></button>
</div>
</header>
<main id="main">
<noscript
><p class="no-script">
{
lang === 'en'
? 'Enable JavaScript to use the demo calculator and account preview. All information and policies remain available below.'
: 'Включите JavaScript для демо-калькулятора и просмотра регистрации. Вся информация и документы доступны ниже.'
}
</p></noscript
>
<section class="hero wrap" aria-labelledby="hero-title">
<div class="hero-copy">
<p class="eyebrow"><span class="status-dot"></span>{t.eyebrow}</p><h1
id="hero-title"
>
{t.headline[0]}<br /><span>{t.headline[1]}</span><span
class="headline-star"
aria-hidden="true">✳</span>
</h1><p class="hero-intro">{t.intro}</p><div class="hero-actions">
<button class="button button-dark" data-auth="register"
>{t.register}<span aria-hidden="true">↗</span></button
><a class="text-link" href="#how"
>{t.subcta}<span aria-hidden="true">↓</span></a>
</div><div class="hero-signoff">
<span class="mini-line" aria-hidden="true"></span>{t.heroNote}
</div>
</div>
<div class="exchange-stage">
<div class="orbit" aria-hidden="true"></div><div
class="stage-caption"
aria-hidden="true"
>
MAKE YOUR MOVE ↗
</div><section class="exchange-card" aria-labelledby="exchange-title">
<div class="exchange-heading">
<h2 id="exchange-title">{t.preview}</h2><span class="demo-badge"
>{t.demo}</span>
</div><div class="amount-box">
<label for="send-amount">{t.send}</label><div class="amount-row">
<input
id="send-amount"
type="text"
inputmode="decimal"
value="1"
autocomplete="off"
aria-describedby="amount-error quote-disclaimer"
/><div class="currency-picker">
<span class="coin coin-btc" id="coin-icon" aria-hidden="true"
>₿</span
><select id="crypto" aria-label={t.crypto}
><option value="BTC">BTC</option><option value="ETH"
>ETH</option
><option value="USDT">USDT</option></select>
</div>
</div>
</div><div class="direction-divider" aria-hidden="true">
<span>↓</span>
</div><div class="amount-box receive-box">
<label for="receive-amount">{t.receive}</label><div
class="amount-row"
>
<output
id="receive-amount"
aria-label={t.quoteLabel}
aria-live="polite">56,925.00</output
><div class="currency-picker">
<span class="coin coin-fiat" id="fiat-icon" aria-hidden="true"
>€</span
><select id="fiat" aria-label={t.fiat}
><option value="EUR">EUR</option><option value="USD"
>USD</option
></select>
</div>
</div>
</div><p id="amount-error" class="amount-error" hidden>
{t.invalid}
</p><dl class="quote-details">
<div>
<dt>{t.sampleRate}</dt><dd id="quote-rate">
1 BTC ≈ 57,500 EUR
</dd>
</div><div>
<dt>{t.fee}</dt><dd id="quote-fee">575.00 EUR</dd>
</div><div><dt>{t.payout}</dt><dd>{t.bank}</dd></div>
</dl><button
class="button button-mint exchange-submit"
data-auth="register"
>{t.exchange}<span aria-hidden="true">↗</span></button
><p class="quote-disclaimer" id="quote-disclaimer">
{t.disclaimer}
</p>
</section><div class="stage-bottom" aria-hidden="true">
<span>₿</span><span>→</span><span>€</span><div class="stage-line">
</div><span>01 — ∞</span>
</div>
</div>
</section>
<div class="ticker" aria-hidden="true">
<div>
{
[...t.strip, ...t.strip].map((s) => (
<span>
{s}
<b>✳</b>
</span>
))
}
</div>
</div>
<section class="section how-section" id="how" aria-labelledby="how-title">
<div class="wrap">
<div class="section-heading">
<div>
<p class="eyebrow">{t.howLabel}</p><h2 id="how-title">
{t.howTitle}
</h2>
</div><p>{t.howIntro}</p>
</div><div class="steps">
{
t.steps.map(([title, body], i) => (
<article class="step">
<>
<div class="step-top">
<>
<span class="step-number">0{i + 1}</span>
<span class="step-arrow" aria-hidden="true">
{i === 2 ? '↗' : '→'}
</span>
</>
</div>
<h3>{title}</h3>
<p>{body}</p>
</>
</article>
))
}
</div>
</div>
</section>
<section class="section why-section" id="why" aria-labelledby="why-title">
<div class="wrap">
<div class="section-heading">
<div>
<p class="eyebrow">{t.whyLabel}</p><h2 id="why-title">
{t.whyTitle}
</h2>
</div><p>{t.whyIntro}</p>
</div><div class="feature-grid">
<article class="feature-card clarity-card">
<div class="feature-top">
<span class="feature-symbol" aria-hidden="true">↗</span><span
class="feature-index">/ 01</span>
</div><div class="clarity-visual" aria-hidden="true">
<div>
<span>BTC</span><span class="visual-line"></span><span
>EUR</span>
</div><div class="clarity-flow">
<span>₿</span><span>⟶</span><span>€</span>
</div><div class="visual-ruler">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i
></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i
></i><i></i><i></i><i></i>
</div>
</div><h3>{t.features[0][0]}</h3><p>{t.features[0][1]}</p>
</article><article class="feature-card choice-card">
<div class="feature-top">
<span class="feature-symbol" aria-hidden="true">↔</span><span
class="feature-index">/ 02</span>
</div><div class="currency-visual" aria-hidden="true">
<span>₿</span><span>Ξ</span><span>₮</span><span>€</span><span
>$</span>
</div><span class="pair-note">{t.pairNote}</span><h3>
{t.features[1][0]}
</h3><p>{t.features[1][1]}</p>
</article><article class="verification-card">
<div class="verification-mark" aria-hidden="true">
<svg viewBox="0 0 64 64" fill="none"
><path
d="M32 6 52 14v17c0 13-20 25-20 25S12 44 12 31V14L32 6Z"
stroke="currentColor"
stroke-width="2"></path><path
d="m23 30 6 6 13-14"
stroke="currentColor"
stroke-width="2"></path></svg>
</div><div class="verification-copy">
<p class="eyebrow">{t.verification}</p><h3>
{t.features[2][0]}
</h3><p>{t.features[2][1]}</p>
</div><div class="verification-links">
<a href={`/${lang}/policies/kyc/`}
>{t.kyc}<span aria-hidden="true">↗</span></a
><a href={`/${lang}/policies/aml/`}
>{t.aml}<span aria-hidden="true">↗</span></a>
</div>
</article>
</div>
</div>
</section>
<section class="section faq-section" id="faq" aria-labelledby="faq-title">
<div class="wrap faq-grid">
<div>
<p class="eyebrow">{t.faqLabel}</p><h2 id="faq-title">
{t.faqTitle}
</h2><span class="faq-asterisk" aria-hidden="true">✳</span>
</div><div class="faq-list">
{
t.faqs.map(([q, a]) => (
<details name="faq">
<>
<summary>
{q}
<span aria-hidden="true">+</span>
</summary>
<p>{a}</p>
</>
</details>
))
}
</div>
</div>
</section>
<section class="closing-section" aria-labelledby="closing-title">
<div class="wrap closing-content">
<p class="eyebrow">{t.closingLabel}</p><h2 id="closing-title">
{t.closing}<span aria-hidden="true">↗</span>
</h2><div class="closing-actions">
<button class="button button-dark" data-auth="register"
>{t.register}<span aria-hidden="true">↗</span></button
><button class="closing-login" data-auth="login"
>{t.login} ↗</button>
</div><p class="closing-note">{t.closingNote}</p><span
class="closing-star"
aria-hidden="true">✳</span>
</div>
</section>
</main>
<Footer lang={lang} />
<AccountDialog lang={lang} />
</div>
<script>
import '../../scripts/home';
</script>
</Layout>
@@ -0,0 +1,52 @@
---
import Layout from '../../../layouts/Layout.astro';
import Footer from '../../../components/Footer.astro';
import { copy, languages, type Lang } from '../../../i18n/copy';
const policySlugs = ['terms', 'privacy', 'aml', 'kyc'];
export function getStaticPaths() {
return languages.flatMap((lang) =>
['terms', 'privacy', 'aml', 'kyc'].map((policy) => ({
params: { lang, policy },
})),
);
}
const lang = Astro.params.lang as Lang;
const policy = Astro.params.policy!;
const t = copy[lang];
const index = policySlugs.indexOf(policy);
---
<Layout
lang={lang}
title={`${t.legalNames[index]} — SXCHNGE`}
path={`policies/${policy}/`}
>
<header class="site-header wrap">
<a class="wordmark" href={`/${lang}/`}
><span class="brand-icon" aria-hidden="true">↗</span> sxchnge</a
><div class="language-switch">
<a
href={`/en/policies/${policy}/`}
lang="en"
aria-current={lang === 'en' ? 'page' : undefined}>EN</a
><span>/</span><a
href={`/ru/policies/${policy}/`}
lang="ru"
aria-current={lang === 'ru' ? 'page' : undefined}>RU</a>
</div>
</header>
<main class="policy-content wrap" id="main">
<a class="text-link" href={`/${lang}/`}>← {t.policyBack}</a><p
class="eyebrow policy-label"
>
{t.policyDraft}
</p><h1>{t.legalNames[index]}</h1><p class="policy-intro">
{t.policyIntro}
</p><section class="policy-placeholder">
<h2>{t.policyNeeded}</h2><ul>
{t.policyItems[index].map((item) => <li>{item}</li>)}
</ul>
</section><p class="policy-company">{t.companyFields[0]} · {t.support}</p>
</main>
<Footer lang={lang} />
</Layout>
+3
View File
@@ -0,0 +1,3 @@
---
return Astro.redirect('/en/');
---
+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;
}),
);
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
/* Generated by npm run tokens. Edit design/tokens.json. */
:root {
--ink: #14221d;
--mint: #c4f9ce;
--lime: #d7ff5f;
--paper: #f9faf6;
--muted: #637168;
--line: #d9e0d8;
--forest: #396a4b;
--focus: #168850;
--dark-field: #25372e;
--dark-receive: #203d2d;
--sage-surface: #eaf0e6;
--blue-surface: #edf0f9;
--radius: 24px;
--font-family: 'Onest', Arial, sans-serif;
}