Build bilingual Astro exchange landing page and brand reference

This commit is contained in:
Stephan D committed 2026-09-07 21:50:08 +02:00
commit 671a6aea96
34 files changed
+8213

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Set the actual Flutter app URLs to replace the honest preview dialog.
# Both must be full HTTPS URLs. The website appends lang=en or lang=ru.
PUBLIC_FLUTTER_REGISTER_URL=
PUBLIC_FLUTTER_LOGIN_URL=
+24
View File
@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
+4
View File
@@ -0,0 +1,4 @@
{
"project_id": "appgprj_6a9f125af1808191b31ba0801413927b",
"static": { "directory": "dist" }
}
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.astro/
.openai/
package-lock.json
src/styles/tokens.css
flutter/brand_palette.dart
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": ["prettier-plugin-astro"],
"singleQuote": true,
"overrides": [{ "files": "*.astro", "options": { "parser": "astro" } }]
}
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+28
View File
@@ -0,0 +1,28 @@
# SXCHNGE
Astro public website for a crypto-to-fiat exchange. English and Russian, responsive layouts, an illustrative exchange calculator, and explicit company / policy placeholders.
## Development
Node >=22.12.0 (Node >=22.18 for the native TypeScript test runner), npm.
```sh
npm ci
npm run dev -- --background --host 127.0.0.1
npm run test
npm run build
```
Use `npm exec astro dev stop` to stop the background preview. Static output is `dist/`. `/` redirects to `/en/`; localized content lives at `/en/` and `/ru/`. Legal placeholders have separate localized routes.
## Content and launch configuration
- Translation source: `src/i18n/copy.ts`.
- Brand reference: `design/tokens.json`, documented in `design/README.md`.
- Optional Flutter palette reference: `flutter/brand_palette.dart`. Compatibility does not require identical application styling.
- Full account URLs: set `PUBLIC_FLUTTER_REGISTER_URL` and `PUBLIC_FLUTTER_LOGIN_URL` at build time, as shown in `.env.example`. Locale is appended as `lang`. Until configured, actions open a clear preview dialog; no credentials are collected.
- Quote fixtures: `src/lib/quote.ts`. Demo only; no rates are fetched, orders placed, or funds handled.
- Replace all company placeholders, supply approved legal policies, and confirm supported assets, payout methods, countries, fees and timelines before launch.
- The preview explicitly uses `noindex,nofollow`. Remove only when launch content is complete, and set the real domain in `astro.config.mjs`.
Fonts are self-hosted with their license. No analytics, third-party trackers, client storage, or authentication backend is included. The website uses Astro's own TypeScript script support; React is unnecessary for the current interactions.
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://sxchnge.muddy-nova-7708.chatgpt.site',
output: 'static',
trailingSlash: 'always',
});
+25
View File
@@ -0,0 +1,25 @@
# SXCHNGE brand compatibility
The website and Flutter application should feel related, not identical.
`tokens.json` is the brand reference. `npm run tokens` generates the website's CSS variables and an **optional** Flutter palette (`flutter/brand_palette.dart`). Neither file prescribes a Flutter ThemeData or component system.
## Shared identity
- Ink `#14221D`: primary text and strong surfaces.
- Mint `#C4F9CE`: brand accent, selected surfaces, and key actions on dark backgrounds.
- Lime `#D7FF5F`: expressive website campaign accent. Use sparingly, or omit it, in the app.
- Paper `#F9FAF6`: neutral base. Forest `#396A4B`: secondary brand tone.
- Onest: Latin and Cyrillic variable font bundled in `public/fonts/`, with its OFL license. Flutter can bundle the same font or use a compatible readable sans serif.
## Deliberately website-specific
Oversized display typography, the slightly tilted desktop quote card, animated asterisk, marquee, decorative paths, and large campaign panels belong to the public site. They are **not** requirements for Flutter. Website radii, spacing, and motion tokens are explicitly nested under `website`.
The Flutter app can use Material 3, denser layouts, its own spacing, more restrained accents, and flatter cards. Use ink on mint/lime, not light text. Define separate semantic success, error, warning, and information colors; the marketing lime is not a transaction status.
## Flutter handoff
Copy the optional `brand_palette.dart` constants into the app and map only the desired brand roles to its own `ColorScheme` or theme extension. Avoid blindly feeding the entire palette into `ColorScheme.fromSeed`, which may change the brand colors. Choose app contrast and dark-mode roles independently.
To reuse Onest, copy `Onest-Variable.ttf` plus `OFL.txt` into the Flutter assets and declare the font family as `Onest` in pubspec.yaml. Web CSS and Flutter widgets are separate implementations. The Dart file is a reference and has not been compiled inside a Flutter project.
+24
View File
@@ -0,0 +1,24 @@
{
"name": "SXCHNGE",
"version": 1,
"colors": {
"ink": "#14221d",
"mint": "#c4f9ce",
"lime": "#d7ff5f",
"paper": "#f9faf6",
"muted": "#637168",
"line": "#d9e0d8",
"forest": "#396a4b",
"focus": "#168850",
"darkField": "#25372e",
"darkReceive": "#203d2d",
"sageSurface": "#eaf0e6",
"blueSurface": "#edf0f9"
},
"typography": { "family": "Onest", "bodySize": 16, "bodyLineHeight": 1.65 },
"website": {
"radius": { "card": 24, "input": 14, "pill": 999 },
"spacing": [4, 8, 12, 16, 24, 32, 48, 64, 96],
"motionMs": 200
}
}
+19
View File
@@ -0,0 +1,19 @@
// Generated brand palette reference. Optional for the Flutter application.
// This is not an application theme: choose component styling independently.
import 'dart:ui';
abstract final class SxchngeBrand {
static const ink = Color(0xFF14221D);
static const mint = Color(0xFFC4F9CE);
static const lime = Color(0xFFD7FF5F);
static const paper = Color(0xFFF9FAF6);
static const muted = Color(0xFF637168);
static const line = Color(0xFFD9E0D8);
static const forest = Color(0xFF396A4B);
static const focus = Color(0xFF168850);
static const darkField = Color(0xFF25372E);
static const darkReceive = Color(0xFF203D2D);
static const sageSurface = Color(0xFFEAF0E6);
static const blueSurface = Color(0xFFEDF0F9);
static const fontFamily = 'Onest';
}
+5241
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "sxchnge",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "npm run tokens && astro build",
"preview": "astro preview",
"astro": "astro",
"tokens": "node scripts/generate-tokens.mjs",
"test": "node --test scripts/quote.test.mjs",
"check": "astro check",
"format": "prettier --write ."
},
"dependencies": {
"astro": "^7.3.1"
},
"allowScripts": {
"esbuild": true
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"prettier": "^3.9.6",
"prettier-plugin-astro": "^0.14.1",
"typescript": "^6.0.3"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#14221d"/><path d="M19 45 45 19M20 19h25v25" fill="none" stroke="#c4f9ce" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 249 B

+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Onest Project Authors (https://github.com/googlefonts/onest)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from 'node:fs';
const tokens = JSON.parse(
readFileSync(new URL('../design/tokens.json', import.meta.url), 'utf8'),
);
const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
const colors = Object.entries(tokens.colors);
writeFileSync(
new URL('../src/styles/tokens.css', import.meta.url),
`/* Generated by npm run tokens. Edit design/tokens.json. */\n:root {\n${colors.map(([k, v]) => ` --${kebab(k)}: ${v};`).join('\n')}\n --radius: ${tokens.website.radius.card}px;\n --font-family: '${tokens.typography.family}', Arial, sans-serif;\n}\n`,
);
writeFileSync(
new URL('../flutter/brand_palette.dart', import.meta.url),
`// Generated brand palette reference. Optional for the Flutter application.\n// This is not an application theme: choose component styling independently.\nimport 'dart:ui';\n\nabstract final class SxchngeBrand {\n${colors.map(([k, v]) => ` static const ${k} = Color(0xFF${v.slice(1).toUpperCase()});`).join('\n')}\n static const fontFamily = '${tokens.typography.family}';\n}\n`,
);
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calculateQuote, parseAmount } from '../src/lib/quote.ts';
test('demo calculation deducts the example fee in currency cents', () => {
assert.deepEqual(calculateQuote(1, 'BTC', 'EUR'), {
rate: 57500,
fee: 575,
receive: 56925,
demo: true,
});
assert.deepEqual(calculateQuote(2, 'ETH', 'USD'), {
rate: 2500,
fee: 50,
receive: 4950,
demo: true,
});
const q = calculateQuote(123.45, 'USDT', 'EUR');
assert.equal(q.fee, 1.14);
assert.equal(q.receive, 112.43);
});
test('accepts decimal comma and eight crypto decimal places', () => {
assert.equal(parseAmount(' 0,25 '), 0.25);
assert.equal(parseAmount('0.00000001'), 0.00000001);
assert.equal(
calculateQuote(parseAmount('0,25'), 'BTC', 'EUR').receive,
14231.25,
);
});
test('rejects ambiguous, malformed, excessive or invalid input', () => {
for (const value of [
'',
'-1',
'1e3',
'1,2,3',
'1.2.3',
'<script>',
'0.000000001',
'1 000',
])
assert.ok(Number.isNaN(parseAmount(value)));
for (const amount of [0, -1, NaN, Infinity, 1000001])
assert.throws(() => calculateQuote(amount, 'BTC', 'EUR'), RangeError);
assert.throws(() => calculateQuote(1, 'BOGUS', 'EUR'), RangeError);
assert.throws(() => calculateQuote(1, 'BTC', 'BOGUS'), RangeError);
});
+21
View File
@@ -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.muddy-nova-7708.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>
+52
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}