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,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=
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"project_id": "appgprj_6a9f125af1808191b31ba0801413927b",
|
||||
"static": { "directory": "dist" }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.openai/
|
||||
package-lock.json
|
||||
src/styles/tokens.css
|
||||
flutter/brand_palette.dart
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-astro"],
|
||||
"singleQuote": true,
|
||||
"overrides": [{ "files": "*.astro", "options": { "parser": "astro" } }]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
## Development
|
||||
|
||||
The public Astro site is in `website/`. Run its npm and Astro commands from that folder. `frontend/` and `backend/` are reserved for the new consumer application and BFF.
|
||||
|
||||
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/)
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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.
|
||||
|
||||
Run these commands from `website/` (use `cd website` from the repository root).
|
||||
|
||||
```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.
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
export default defineConfig({
|
||||
site: 'https://sxchnge.s-deshevykh.chatgpt.site',
|
||||
output: 'static',
|
||||
trailingSlash: 'always',
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Generated
+5241
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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.
@@ -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`,
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in new issue
Block a user