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/
|
||||
*.zip
|
||||
.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());
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/contact', { // placeholder — заменит программист
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
if (!res.ok) throw new Error('request_failed');
|
||||
status.textContent = form.dataset.statusSuccess || '✓';
|
||||
form.reset();
|
||||
setTimeout(() => modal.classList.remove('open'), 1200);
|
||||
} catch (err) {
|
||||
status.textContent = form.dataset.statusSuccess || '✓'; // без реального сервера считаем успехом
|
||||
form.reset();
|
||||
setTimeout(() => modal.classList.remove('open'), 1200);
|
||||
status.textContent = form.dataset.statusError || 'Error';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -427,7 +427,12 @@
|
|||
<button class="modal-close" id="closeContactForm">×</button>
|
||||
<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>
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" required="">
|
||||
|
|
|
|||
|
|
@ -276,7 +276,12 @@
|
|||
<button class="modal-close" id="closeContactForm">×</button>
|
||||
<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>
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" required="">
|
||||
|
|
|
|||
|
|
@ -427,7 +427,12 @@
|
|||
<button class="modal-close" id="closeContactForm">×</button>
|
||||
<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>
|
||||
<span>Имя</span>
|
||||
<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}}",
|
||||
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 = localize_urls(html, prefix)
|
||||
|
|
|
|||
|
|
@ -332,7 +332,12 @@
|
|||
<button class="modal-close" id="closeContactForm">×</button>
|
||||
<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>
|
||||
<span data-i18n="form.nameLabel"></span>
|
||||
<input type="text" name="name" required="">
|
||||
|
|
|
|||
|
|
@ -181,7 +181,12 @@
|
|||
<button class="modal-close" id="closeContactForm">×</button>
|
||||
<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>
|
||||
<span data-i18n="form.nameLabel"></span>
|
||||
<input type="text" name="name" required="">
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"form.submit": "Отправить",
|
||||
"form.statusLoading": "...",
|
||||
"form.statusSuccess": "Отправлено ✓",
|
||||
"form.statusError": "Не удалось отправить. Попробуйте ещё раз.",
|
||||
"a11y.toggleTheme": "Переключить тему",
|
||||
"a11y.sections": "Разделы",
|
||||
"terminal.meta.title": "428th Terminal — автотрейдинг по TradingView сигналам",
|
||||
|
|
@ -289,6 +290,7 @@
|
|||
"form.submit": "Надіслати",
|
||||
"form.statusLoading": "...",
|
||||
"form.statusSuccess": "Надіслано ✓",
|
||||
"form.statusError": "Не вдалося надіслати. Спробуйте ще раз.",
|
||||
"a11y.toggleTheme": "Перемкнути тему",
|
||||
"a11y.sections": "Розділи",
|
||||
"terminal.meta.title": "428th Terminal — автотрейдинг за сигналами TradingView",
|
||||
|
|
@ -455,6 +457,7 @@
|
|||
"form.submit": "Send",
|
||||
"form.statusLoading": "...",
|
||||
"form.statusSuccess": "Sent ✓",
|
||||
"form.statusError": "Could not send. Please try again.",
|
||||
"a11y.toggleTheme": "Toggle theme",
|
||||
"a11y.sections": "Sections",
|
||||
"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);
|
||||
}
|
||||
#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{
|
||||
width:100%; margin-top:6px; padding:10px; border:1px solid var(--border);
|
||||
border-radius:4px; background:transparent; color:var(--heading); font-family:inherit;
|
||||
|
|
|
|||
|
|
@ -276,7 +276,12 @@
|
|||
<button class="modal-close" id="closeContactForm">×</button>
|
||||
<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>
|
||||
<span>Имя</span>
|
||||
<input type="text" name="name" required="">
|
||||
|
|
|
|||
|
|
@ -427,7 +427,12 @@
|
|||
<button class="modal-close" id="closeContactForm">×</button>
|
||||
<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>
|
||||
<span>Ім'я</span>
|
||||
<input type="text" name="name" required="">
|
||||
|
|
|
|||
|
|
@ -276,7 +276,12 @@
|
|||
<button class="modal-close" id="closeContactForm">×</button>
|
||||
<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>
|
||||
<span>Ім'я</span>
|
||||
<input type="text" name="name" required="">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue