mirror of
https://github.com/artemium428/428th-website-2026.git
synced 2026-09-15 17:06:20 +00:00
Add Telegram contact API with JSONL backup and real form errors.
Wire the anonymous form to a VPS Python endpoint that notifies Telegram, persists submissions locally, and stops faking success on failure. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
228aba8af4
commit
793d093c86
16 changed files with 454 additions and 13 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,6 @@
|
||||||
.tools/
|
.tools/
|
||||||
*.zip
|
*.zip
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
|
api/.env
|
||||||
|
api/messages.jsonl
|
||||||
|
|
|
||||||
12
api/.env.example
Normal file
12
api/.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Copy to api/.env on the VPS and fill in real values.
|
||||||
|
# Never commit .env.
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN=1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
|
||||||
|
# Bind address (default: loopback only)
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=8787
|
||||||
|
|
||||||
|
# Local JSONL backup of submissions (relative to api/ or absolute path)
|
||||||
|
MESSAGES_FILE=messages.jsonl
|
||||||
109
api/README.md
Normal file
109
api/README.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Contact API → Telegram
|
||||||
|
|
||||||
|
Small Python 3 (stdlib only) service that accepts `POST /api/contact` and forwards the message to Telegram.
|
||||||
|
|
||||||
|
## Setup (once)
|
||||||
|
|
||||||
|
1. Create a bot with [@BotFather](https://t.me/BotFather) → copy the token.
|
||||||
|
2. Open a chat with the bot and press **Start**.
|
||||||
|
3. Get your numeric chat id (`@userinfobot`, or call `getUpdates` after messaging the bot).
|
||||||
|
4. On the VPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/site/api
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env — set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
cp .env.example .env # fill tokens
|
||||||
|
python3 contact_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST http://127.0.0.1:8787/api/contact \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"Test","contact":"@you","message":"hello"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Health check: `GET /api/health` → `{"ok":true,"configured":true}`.
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
`/etc/systemd/system/428th-contact.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=428th contact form → Telegram
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/var/www/428th.com/api
|
||||||
|
EnvironmentFile=/var/www/428th.com/api/.env
|
||||||
|
ExecStart=/usr/bin/python3 /var/www/428th.com/api/contact_server.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now 428th-contact
|
||||||
|
sudo systemctl status 428th-contact
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust `WorkingDirectory`, `EnvironmentFile`, `User`, and paths to match your VPS layout.
|
||||||
|
|
||||||
|
## nginx
|
||||||
|
|
||||||
|
Inside the HTTPS server block for `428th.com`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/contact {
|
||||||
|
proxy_pass http://127.0.0.1:8787/api/contact;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 16k;
|
||||||
|
}
|
||||||
|
|
||||||
|
# optional
|
||||||
|
location /api/health {
|
||||||
|
proxy_pass http://127.0.0.1:8787/api/health;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy updated static files (`common.js`, built HTML) as usual so the form shows real errors and includes the honeypot field.
|
||||||
|
|
||||||
|
## Message backup
|
||||||
|
|
||||||
|
Every valid submission is appended to `messages.jsonl` (override with `MESSAGES_FILE` in `.env`), one JSON object per line:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ts":"2026-07-26T16:40:00Z","name":"…","contact":"…","message":"…","ip":"1.2.3.4","telegram_ok":true,"telegram_error":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
Backup is written even if Telegram fails (`telegram_ok: false`), so you still have the text on disk. Honeypot hits are not stored. Keep this file private (same permissions as `.env`); it is gitignored.
|
||||||
|
|
||||||
|
## Behaviour notes
|
||||||
|
|
||||||
|
- Honeypot field `website`: if filled, returns `200` without notifying Telegram or writing a backup.
|
||||||
|
- In-memory rate limit: 5 POSTs per IP per 60 seconds → `429`.
|
||||||
|
- Field limits: name/contact ≤ 200 chars, message ≤ 4000.
|
||||||
268
api/contact_server.py
Normal file
268
api/contact_server.py
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal /api/contact endpoint — forwards form submissions to Telegram."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------- config ----------
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
MAX_NAME = 200
|
||||||
|
MAX_CONTACT = 200
|
||||||
|
MAX_MESSAGE = 4000
|
||||||
|
MAX_BODY = 16_384
|
||||||
|
RATE_LIMIT = 5 # requests
|
||||||
|
RATE_WINDOW = 60 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv(path: Path) -> None:
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip("'").strip('"')
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv(ROOT / ".env")
|
||||||
|
|
||||||
|
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
|
||||||
|
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "").strip()
|
||||||
|
HOST = os.environ.get("HOST", "127.0.0.1").strip() or "127.0.0.1"
|
||||||
|
PORT = int(os.environ.get("PORT", "8787"))
|
||||||
|
_backup_raw = os.environ.get("MESSAGES_FILE", "messages.jsonl").strip() or "messages.jsonl"
|
||||||
|
MESSAGES_FILE = Path(_backup_raw)
|
||||||
|
if not MESSAGES_FILE.is_absolute():
|
||||||
|
MESSAGES_FILE = ROOT / MESSAGES_FILE
|
||||||
|
|
||||||
|
_rate_lock = threading.Lock()
|
||||||
|
_file_lock = threading.Lock()
|
||||||
|
_rate_hits: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(handler: BaseHTTPRequestHandler) -> str:
|
||||||
|
forwarded = handler.headers.get("X-Real-IP") or handler.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return handler.client_address[0]
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limited(ip: str) -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
with _rate_lock:
|
||||||
|
q = _rate_hits[ip]
|
||||||
|
while q and now - q[0] > RATE_WINDOW:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= RATE_LIMIT:
|
||||||
|
return True
|
||||||
|
q.append(now)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram(text: str) -> None:
|
||||||
|
if not BOT_TOKEN or not CHAT_ID:
|
||||||
|
raise RuntimeError("Telegram is not configured")
|
||||||
|
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||||
|
payload = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"chat_id": CHAT_ID,
|
||||||
|
"text": text,
|
||||||
|
"disable_web_page_preview": "1",
|
||||||
|
}
|
||||||
|
).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
body = json.loads(resp.read().decode("utf-8"))
|
||||||
|
if not body.get("ok"):
|
||||||
|
raise RuntimeError("Telegram API rejected the message")
|
||||||
|
|
||||||
|
|
||||||
|
def append_message(record: dict) -> None:
|
||||||
|
"""Append one JSON line to the backup file (best-effort durable log)."""
|
||||||
|
line = json.dumps(record, ensure_ascii=False) + "\n"
|
||||||
|
with _file_lock:
|
||||||
|
MESSAGES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with MESSAGES_FILE.open("a", encoding="utf-8") as f:
|
||||||
|
f.write(line)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_fields(data: dict) -> tuple[str, str, str] | str:
|
||||||
|
"""Return (name, contact, message) or an error code string."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return "invalid"
|
||||||
|
|
||||||
|
# Honeypot — treat as success upstream; caller checks separately.
|
||||||
|
website = data.get("website", "")
|
||||||
|
if isinstance(website, str) and website.strip():
|
||||||
|
return "honeypot"
|
||||||
|
|
||||||
|
def field(key: str, max_len: int) -> str | None:
|
||||||
|
raw = data.get(key, "")
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
value = raw.strip()
|
||||||
|
if not value or len(value) > max_len:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
name = field("name", MAX_NAME)
|
||||||
|
contact = field("contact", MAX_CONTACT)
|
||||||
|
message = field("message", MAX_MESSAGE)
|
||||||
|
if name is None or contact is None or message is None:
|
||||||
|
return "invalid"
|
||||||
|
return name, contact, message
|
||||||
|
|
||||||
|
|
||||||
|
class ContactHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "428thContact/1.0"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args) -> None:
|
||||||
|
# Avoid logging bodies; keep a short access line.
|
||||||
|
sys_stderr = __import__("sys").stderr
|
||||||
|
print(
|
||||||
|
f"{client_ip(self)} - [{self.log_date_time_string()}] {fmt % args}",
|
||||||
|
file=sys_stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _json(self, status: int, payload: dict) -> None:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_OPTIONS(self) -> None:
|
||||||
|
# Same-origin from the site; allow preflight just in case.
|
||||||
|
if self.path.rstrip("/") != "/api/contact":
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
if self.path.rstrip("/") != "/api/contact":
|
||||||
|
self._json(404, {"ok": False, "error": "not_found"})
|
||||||
|
return
|
||||||
|
|
||||||
|
ip = client_ip(self)
|
||||||
|
if rate_limited(ip):
|
||||||
|
self._json(429, {"ok": False, "error": "rate_limited"})
|
||||||
|
return
|
||||||
|
|
||||||
|
length_raw = self.headers.get("Content-Length", "0")
|
||||||
|
try:
|
||||||
|
length = int(length_raw)
|
||||||
|
except ValueError:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid"})
|
||||||
|
return
|
||||||
|
if length < 0 or length > MAX_BODY:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid"})
|
||||||
|
return
|
||||||
|
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
self._json(400, {"ok": False, "error": "invalid"})
|
||||||
|
return
|
||||||
|
|
||||||
|
result = validate_fields(data)
|
||||||
|
if result == "honeypot":
|
||||||
|
self._json(200, {"ok": True})
|
||||||
|
return
|
||||||
|
if isinstance(result, str):
|
||||||
|
self._json(400, {"ok": False, "error": "invalid"})
|
||||||
|
return
|
||||||
|
|
||||||
|
name, contact, message = result
|
||||||
|
text = (
|
||||||
|
"New contact form\n"
|
||||||
|
f"Name: {name}\n"
|
||||||
|
f"Contact: {contact}\n"
|
||||||
|
f"Message:\n{message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
telegram_ok = False
|
||||||
|
telegram_error = None
|
||||||
|
try:
|
||||||
|
send_telegram(text)
|
||||||
|
telegram_ok = True
|
||||||
|
except (urllib.error.URLError, TimeoutError, RuntimeError, OSError) as err:
|
||||||
|
telegram_error = type(err).__name__
|
||||||
|
|
||||||
|
try:
|
||||||
|
append_message(
|
||||||
|
{
|
||||||
|
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"name": name,
|
||||||
|
"contact": contact,
|
||||||
|
"message": message,
|
||||||
|
"ip": ip,
|
||||||
|
"telegram_ok": telegram_ok,
|
||||||
|
"telegram_error": telegram_error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
# Disk failure after Telegram: still report based on Telegram.
|
||||||
|
if not telegram_ok:
|
||||||
|
self._json(502, {"ok": False, "error": "upstream"})
|
||||||
|
return
|
||||||
|
# Telegram delivered; backup failed — accept but log.
|
||||||
|
print(f"WARNING: failed to append backup for {ip}", flush=True)
|
||||||
|
|
||||||
|
if not telegram_ok:
|
||||||
|
self._json(502, {"ok": False, "error": "upstream"})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._json(200, {"ok": True})
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path.rstrip("/") == "/api/health":
|
||||||
|
configured = bool(BOT_TOKEN and CHAT_ID)
|
||||||
|
self._json(200, {"ok": True, "configured": configured})
|
||||||
|
return
|
||||||
|
self._json(404, {"ok": False, "error": "not_found"})
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if not BOT_TOKEN or not CHAT_ID:
|
||||||
|
print(
|
||||||
|
"WARNING: TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — "
|
||||||
|
"POSTs will fail with 502 until configured.",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
print(f"message backup file: {MESSAGES_FILE}", flush=True)
|
||||||
|
httpd = ThreadingHTTPServer((HOST, PORT), ContactHandler)
|
||||||
|
print(f"contact API listening on http://{HOST}:{PORT}/api/contact", flush=True)
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -449,19 +449,17 @@ function initContactForm(){
|
||||||
const data = Object.fromEntries(new FormData(form).entries());
|
const data = Object.fromEntries(new FormData(form).entries());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/contact', { // placeholder — заменит программист
|
const res = await fetch('/api/contact', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error('request_failed');
|
||||||
status.textContent = form.dataset.statusSuccess || '✓';
|
status.textContent = form.dataset.statusSuccess || '✓';
|
||||||
form.reset();
|
form.reset();
|
||||||
setTimeout(() => modal.classList.remove('open'), 1200);
|
setTimeout(() => modal.classList.remove('open'), 1200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent = form.dataset.statusSuccess || '✓'; // без реального сервера считаем успехом
|
status.textContent = form.dataset.statusError || 'Error';
|
||||||
form.reset();
|
|
||||||
setTimeout(() => modal.classList.remove('open'), 1200);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,12 @@
|
||||||
<button class="modal-close" id="closeContactForm">×</button>
|
<button class="modal-close" id="closeContactForm">×</button>
|
||||||
<h3>Contact form</h3>
|
<h3>Contact form</h3>
|
||||||
|
|
||||||
<form id="contactForm" data-status-loading="..." data-status-success="Sent ✓">
|
<form id="contactForm" data-status-loading="..." data-status-success="Sent ✓" data-status-error="Could not send. Please try again.">
|
||||||
|
<label class="hp-field" aria-hidden="true">
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<input type="text" name="name" required="">
|
<input type="text" name="name" required="">
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,12 @@
|
||||||
<button class="modal-close" id="closeContactForm">×</button>
|
<button class="modal-close" id="closeContactForm">×</button>
|
||||||
<h3>Contact form</h3>
|
<h3>Contact form</h3>
|
||||||
|
|
||||||
<form id="contactForm" data-status-loading="..." data-status-success="Sent ✓">
|
<form id="contactForm" data-status-loading="..." data-status-success="Sent ✓" data-status-error="Could not send. Please try again.">
|
||||||
|
<label class="hp-field" aria-hidden="true">
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<input type="text" name="name" required="">
|
<input type="text" name="name" required="">
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,12 @@
|
||||||
<button class="modal-close" id="closeContactForm">×</button>
|
<button class="modal-close" id="closeContactForm">×</button>
|
||||||
<h3>Форма для связи</h3>
|
<h3>Форма для связи</h3>
|
||||||
|
|
||||||
<form id="contactForm" data-status-loading="..." data-status-success="Отправлено ✓">
|
<form id="contactForm" data-status-loading="..." data-status-success="Отправлено ✓" data-status-error="Не удалось отправить. Попробуйте ещё раз.">
|
||||||
|
<label class="hp-field" aria-hidden="true">
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Имя</span>
|
<span>Имя</span>
|
||||||
<input type="text" name="name" required="">
|
<input type="text" name="name" required="">
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,10 @@ def build_page(locale: dict, page: dict, translations: dict, template: str) -> s
|
||||||
"{{FORM_STATUS_SUCCESS}}",
|
"{{FORM_STATUS_SUCCESS}}",
|
||||||
esc_attr(t(translations, locale["dict"], "form.statusSuccess")),
|
esc_attr(t(translations, locale["dict"], "form.statusSuccess")),
|
||||||
)
|
)
|
||||||
|
html = html.replace(
|
||||||
|
"{{FORM_STATUS_ERROR}}",
|
||||||
|
esc_attr(t(translations, locale["dict"], "form.statusError")),
|
||||||
|
)
|
||||||
|
|
||||||
html = fill_i18n(html, translations, locale["dict"])
|
html = fill_i18n(html, translations, locale["dict"])
|
||||||
html = localize_urls(html, prefix)
|
html = localize_urls(html, prefix)
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,12 @@
|
||||||
<button class="modal-close" id="closeContactForm">×</button>
|
<button class="modal-close" id="closeContactForm">×</button>
|
||||||
<h3 data-i18n="form.title"></h3>
|
<h3 data-i18n="form.title"></h3>
|
||||||
|
|
||||||
<form id="contactForm" data-status-loading="{{FORM_STATUS_LOADING}}" data-status-success="{{FORM_STATUS_SUCCESS}}">
|
<form id="contactForm" data-status-loading="{{FORM_STATUS_LOADING}}" data-status-success="{{FORM_STATUS_SUCCESS}}" data-status-error="{{FORM_STATUS_ERROR}}">
|
||||||
|
<label class="hp-field" aria-hidden="true">
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span data-i18n="form.nameLabel"></span>
|
<span data-i18n="form.nameLabel"></span>
|
||||||
<input type="text" name="name" required="">
|
<input type="text" name="name" required="">
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,12 @@
|
||||||
<button class="modal-close" id="closeContactForm">×</button>
|
<button class="modal-close" id="closeContactForm">×</button>
|
||||||
<h3 data-i18n="form.title"></h3>
|
<h3 data-i18n="form.title"></h3>
|
||||||
|
|
||||||
<form id="contactForm" data-status-loading="{{FORM_STATUS_LOADING}}" data-status-success="{{FORM_STATUS_SUCCESS}}">
|
<form id="contactForm" data-status-loading="{{FORM_STATUS_LOADING}}" data-status-success="{{FORM_STATUS_SUCCESS}}" data-status-error="{{FORM_STATUS_ERROR}}">
|
||||||
|
<label class="hp-field" aria-hidden="true">
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span data-i18n="form.nameLabel"></span>
|
<span data-i18n="form.nameLabel"></span>
|
||||||
<input type="text" name="name" required="">
|
<input type="text" name="name" required="">
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@
|
||||||
"form.submit": "Отправить",
|
"form.submit": "Отправить",
|
||||||
"form.statusLoading": "...",
|
"form.statusLoading": "...",
|
||||||
"form.statusSuccess": "Отправлено ✓",
|
"form.statusSuccess": "Отправлено ✓",
|
||||||
|
"form.statusError": "Не удалось отправить. Попробуйте ещё раз.",
|
||||||
"a11y.toggleTheme": "Переключить тему",
|
"a11y.toggleTheme": "Переключить тему",
|
||||||
"a11y.sections": "Разделы",
|
"a11y.sections": "Разделы",
|
||||||
"terminal.meta.title": "428th Terminal — автотрейдинг по TradingView сигналам",
|
"terminal.meta.title": "428th Terminal — автотрейдинг по TradingView сигналам",
|
||||||
|
|
@ -289,6 +290,7 @@
|
||||||
"form.submit": "Надіслати",
|
"form.submit": "Надіслати",
|
||||||
"form.statusLoading": "...",
|
"form.statusLoading": "...",
|
||||||
"form.statusSuccess": "Надіслано ✓",
|
"form.statusSuccess": "Надіслано ✓",
|
||||||
|
"form.statusError": "Не вдалося надіслати. Спробуйте ще раз.",
|
||||||
"a11y.toggleTheme": "Перемкнути тему",
|
"a11y.toggleTheme": "Перемкнути тему",
|
||||||
"a11y.sections": "Розділи",
|
"a11y.sections": "Розділи",
|
||||||
"terminal.meta.title": "428th Terminal — автотрейдинг за сигналами TradingView",
|
"terminal.meta.title": "428th Terminal — автотрейдинг за сигналами TradingView",
|
||||||
|
|
@ -455,6 +457,7 @@
|
||||||
"form.submit": "Send",
|
"form.submit": "Send",
|
||||||
"form.statusLoading": "...",
|
"form.statusLoading": "...",
|
||||||
"form.statusSuccess": "Sent ✓",
|
"form.statusSuccess": "Sent ✓",
|
||||||
|
"form.statusError": "Could not send. Please try again.",
|
||||||
"a11y.toggleTheme": "Toggle theme",
|
"a11y.toggleTheme": "Toggle theme",
|
||||||
"a11y.sections": "Sections",
|
"a11y.sections": "Sections",
|
||||||
"terminal.meta.title": "428th Terminal — auto trading via TradingView signals",
|
"terminal.meta.title": "428th Terminal — auto trading via TradingView signals",
|
||||||
|
|
|
||||||
|
|
@ -440,6 +440,10 @@ a:hover{opacity:0.7;}
|
||||||
font-size:22px; cursor:pointer; color:var(--text);
|
font-size:22px; cursor:pointer; color:var(--text);
|
||||||
}
|
}
|
||||||
#contactForm label{display:block; margin-bottom:20px; font-size:13px; color:var(--text);}
|
#contactForm label{display:block; margin-bottom:20px; font-size:13px; color:var(--text);}
|
||||||
|
#contactForm .hp-field{
|
||||||
|
position:absolute; left:-10000px; top:auto; width:1px; height:1px;
|
||||||
|
overflow:hidden; opacity:0; pointer-events:none;
|
||||||
|
}
|
||||||
#contactForm input, #contactForm textarea{
|
#contactForm input, #contactForm textarea{
|
||||||
width:100%; margin-top:6px; padding:10px; border:1px solid var(--border);
|
width:100%; margin-top:6px; padding:10px; border:1px solid var(--border);
|
||||||
border-radius:4px; background:transparent; color:var(--heading); font-family:inherit;
|
border-radius:4px; background:transparent; color:var(--heading); font-family:inherit;
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,12 @@
|
||||||
<button class="modal-close" id="closeContactForm">×</button>
|
<button class="modal-close" id="closeContactForm">×</button>
|
||||||
<h3>Форма для связи</h3>
|
<h3>Форма для связи</h3>
|
||||||
|
|
||||||
<form id="contactForm" data-status-loading="..." data-status-success="Отправлено ✓">
|
<form id="contactForm" data-status-loading="..." data-status-success="Отправлено ✓" data-status-error="Не удалось отправить. Попробуйте ещё раз.">
|
||||||
|
<label class="hp-field" aria-hidden="true">
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Имя</span>
|
<span>Имя</span>
|
||||||
<input type="text" name="name" required="">
|
<input type="text" name="name" required="">
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,12 @@
|
||||||
<button class="modal-close" id="closeContactForm">×</button>
|
<button class="modal-close" id="closeContactForm">×</button>
|
||||||
<h3>Форма для зв'язку</h3>
|
<h3>Форма для зв'язку</h3>
|
||||||
|
|
||||||
<form id="contactForm" data-status-loading="..." data-status-success="Надіслано ✓">
|
<form id="contactForm" data-status-loading="..." data-status-success="Надіслано ✓" data-status-error="Не вдалося надіслати. Спробуйте ще раз.">
|
||||||
|
<label class="hp-field" aria-hidden="true">
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Ім'я</span>
|
<span>Ім'я</span>
|
||||||
<input type="text" name="name" required="">
|
<input type="text" name="name" required="">
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,12 @@
|
||||||
<button class="modal-close" id="closeContactForm">×</button>
|
<button class="modal-close" id="closeContactForm">×</button>
|
||||||
<h3>Форма для зв'язку</h3>
|
<h3>Форма для зв'язку</h3>
|
||||||
|
|
||||||
<form id="contactForm" data-status-loading="..." data-status-success="Надіслано ✓">
|
<form id="contactForm" data-status-loading="..." data-status-success="Надіслано ✓" data-status-error="Не вдалося надіслати. Спробуйте ще раз.">
|
||||||
|
<label class="hp-field" aria-hidden="true">
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Ім'я</span>
|
<span>Ім'я</span>
|
||||||
<input type="text" name="name" required="">
|
<input type="text" name="name" required="">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue