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:
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>
|
||||
@@ -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>
|
||||
@@ -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: [
|
||||
[
|
||||
'Поставщик услуги и юрисдикция',
|
||||
'Требования к клиентам, регионы и условия обслуживания',
|
||||
'Комиссии, отмена, жалобы и разрешение споров',
|
||||
],
|
||||
[
|
||||
'Оператор данных и контактная информация',
|
||||
'Состав данных, цели и правовые основания обработки',
|
||||
'Сроки хранения, обработчики, международная передача и права клиентов',
|
||||
],
|
||||
[
|
||||
'Ответственное юридическое лицо и контакты комплаенс-службы',
|
||||
'Оценка рисков, скрининг и мониторинг транзакций',
|
||||
'Процедуры эскалации, отчётности и хранения записей',
|
||||
],
|
||||
[
|
||||
'Требования к клиентам и проверке личности',
|
||||
'Необходимые документы и дополнительные проверки',
|
||||
'Порядок рассмотрения, хранение данных и поддержка',
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
return Astro.redirect('/en/');
|
||||
---
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user