Build bilingual Astro exchange landing page and brand reference
This commit is contained in:
commit
671a6aea96
34 files changed
+8213
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=
|
||||||
+24
@@ -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/
|
||||||
@@ -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" } }]
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://sxchnge.muddy-nova-7708.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.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>
|
||||||
@@ -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