diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 37c131b..82ecb53 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -7,7 +7,8 @@ import os import re import time import uuid -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta +import secrets from pathlib import Path from typing import Any from fastapi import FastAPI, Request, Response @@ -144,6 +145,8 @@ async def _dispatch_post(request: Request): "assembly_list": _handle_assembly_list, "assembly_detail": _handle_assembly_detail, "assembly_set_kitchen_price": _handle_assembly_set_kitchen_price, + "sign_request_create": _handle_sign_request_create, + "sign_request_submit": _handle_sign_request_submit, "proposal_brief": proposals_mod.handle_brief, "proposal_create": proposals_mod.handle_create, "proposal_upsert_variant": proposals_mod.handle_upsert_variant, @@ -2220,8 +2223,11 @@ def _assembly_columns() -> list[str]: "started_at", "completed_at", # Фото-отчёт: списки имён файлов через запятую (внутри PHOTOS_DIR//) "photos_before", "photos_in_progress", "photos_after", - # Приёмка ППР: подпись клиента (PNG dataURL → файл) + ФИО/дата - "signature_file", "signed_by_name", "signed_at", + # Приёмка / подпись (SignRequest) + "sign_token", "sign_token_expires_at", + "signed_via", # canvas | code | proxy | absent + "signed_by_name", "signed_by_tg_id", "signed_by_phone", + "signature_file", "signed_at", # Google Calendar "gcal_event_id", "gcal_event_url", # Прочее @@ -2455,12 +2461,17 @@ def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]: "photos_in_progress": _list(row.get("photos_in_progress", "")), "photos_after": _list(row.get("photos_after", "")), "signature_file": row.get("signature_file", ""), + "signed_via": row.get("signed_via", ""), "signed_by_name": row.get("signed_by_name", ""), + "signed_by_tg_id": row.get("signed_by_tg_id", ""), + "signed_by_phone": row.get("signed_by_phone", ""), "signed_at": row.get("signed_at", ""), + "sign_token_expires_at": row.get("sign_token_expires_at", ""), "gcal_event_id": row.get("gcal_event_id", ""), "gcal_event_url": row.get("gcal_event_url", ""), "manager_note": row.get("manager_note", ""), "kitchen_price": row.get("kitchen_price", ""), + "client_tg_id": row.get("client_tg_id", ""), } @@ -2508,6 +2519,193 @@ def _handle_assembly_set_kitchen_price(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "kitchen_price": kitchen_price, "assembly_price": assembly_price} +# ================================================================= +# SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП) +# ================================================================= + +def _handle_sign_request_create(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер/сборщик инициирует подпись акта. + body: {initData, initDataUnsafe, assembly_id, mode: canvas|code|proxy|absent} + Для code-режима: генерирует OTP и отправляет клиенту через бот.""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = str(auth["user"]["id"]) + user = sheets.find_user(tg_id) + if not user: + return {"error": "user_not_found"} + if not (sheets.has_role(user, "manager") or sheets.has_role(user, "admin") + or sheets.has_role(user, "assembler") or sheets.has_role(user, "measurer")): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + mode = (body.get("mode") or "canvas").strip() + if mode not in ("canvas", "code", "proxy", "absent"): + return {"error": "invalid_mode"} + + _ensure_assemblies_sheet() + row = sheets.find_row("Assemblies", "id", assembly_id) + if not row: + return {"error": "assembly_not_found"} + + is_owner = (str(row.get("manager_tg_id")) == tg_id + or str(row.get("assigned_to_tg_id")) == tg_id) + if not is_owner: + return {"error": "forbidden"} + + now = datetime.now(timezone.utc) + expires_at = (now + timedelta(hours=72)).isoformat() + # 6-значный OTP (надёжнее строковым генератором) + otp = str(secrets.randbelow(900000) + 100000) + + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "sign_token", otp) + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "sign_token_expires_at", expires_at) + + client_tg_id = row.get("client_tg_id", "") + client_sent = False + if mode == "code" and client_tg_id: + msg = ( + "🔐 Код подтверждения акта сборки\n\n" + f"Адрес: {row.get('address', '—')}\n\n" + f"Ваш код: {otp}\n\n" + "Сообщите код мастеру или введите в приложении. " + "Код действителен 72 часа." + ) + client_sent = tg.send_message(int(client_tg_id), msg) + + sheets.log_event("sign_request_created", tg_id, + {"assembly_id": assembly_id, "mode": mode}) + return { + "ok": True, + "sign_token": otp, + "expires_at": expires_at, + "mode": mode, + "client_tg_id": client_tg_id, + "client_name": row.get("client_name", ""), + "client_sent": client_sent, + } + + +def _handle_sign_request_submit(body: dict[str, Any]) -> dict[str, Any]: + """Фиксирует подпись. Режимы: + canvas — {initData, assembly_id, mode, signature_data(base64 PNG), signed_by_name, signed_by_phone?} + code — {assembly_id, mode, code, signed_by_name, signed_by_phone?, initData?} + proxy — {initData, assembly_id, mode, signed_by_name, signed_by_phone?} + absent — {initData, assembly_id, mode, absent_reason?} + """ + cfg = get_config() + mode = (body.get("mode") or "canvas").strip() + if mode not in ("canvas", "code", "proxy", "absent"): + return {"error": "invalid_mode"} + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + row = sheets.find_row("Assemblies", "id", assembly_id) + if not row: + return {"error": "assembly_not_found"} + + now_iso = _now_iso() + signed_by_name = (body.get("signed_by_name") or "").strip() + signed_by_phone = (body.get("signed_by_phone") or "").strip() + signed_by_tg_id = "" + signature_file = "" + + if mode == "code": + code = (body.get("code") or "").strip() + stored = (row.get("sign_token") or "").strip() + expires_str = (row.get("sign_token_expires_at") or "").strip() + if not stored: + return {"error": "no_sign_token"} + if not code: + return {"error": "missing_code"} + if code != stored: + return {"error": "invalid_code"} + if expires_str: + try: + # Парсим ISO без dateutil + exp = datetime.fromisoformat(expires_str.replace("Z", "+00:00")) + if datetime.now(timezone.utc) > exp: + return {"error": "code_expired"} + except Exception: + pass # не блокируем если парсинг упал + # Берём tg_id из initData если есть (клиент авторизован в боте) + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if auth and auth.get("user"): + signed_by_tg_id = str(auth["user"]["id"]) + + elif mode in ("canvas", "proxy", "absent"): + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = str(auth["user"]["id"]) + is_owner = (str(row.get("manager_tg_id")) == tg_id + or str(row.get("assigned_to_tg_id")) == tg_id) + if not is_owner: + return {"error": "forbidden"} + signed_by_tg_id = tg_id + + if mode == "canvas": + sig_data = (body.get("signature_data") or "").strip() + if not sig_data: + return {"error": "missing_signature_data"} + # data:image/png;base64, → bytes + raw = sig_data.split(",", 1)[-1] + try: + sig_bytes = base64.b64decode(raw) + except Exception: + return {"error": "invalid_signature_data"} + sig_dir = PHOTOS_DIR / assembly_id + sig_dir.mkdir(parents=True, exist_ok=True) + sig_filename = f"sign_{int(time.time())}.png" + (sig_dir / sig_filename).write_bytes(sig_bytes) + signature_file = sig_filename + + elif mode == "absent": + reason = (body.get("absent_reason") or "Клиент отсутствовал").strip() + signed_by_name = reason + + # Пишем все поля за один проход (каждый update_cell — отдельный запрос к Sheets) + updates = { + "signed_via": mode, + "signed_by_name": signed_by_name, + "signed_by_phone": signed_by_phone, + "signed_by_tg_id": signed_by_tg_id, + "signed_at": now_iso, + "signature_file": signature_file, + } + for col, val in updates.items(): + sheets.update_cell_by_key("Assemblies", "id", assembly_id, col, val) + + sheets.log_event("assembly_signed", signed_by_tg_id or "anon", + {"assembly_id": assembly_id, "mode": mode, "by": signed_by_name}) + return {"ok": True, "signed_at": now_iso, "mode": mode, "signed_by_name": signed_by_name} + + +@app.post("/api/sign_request_create") +async def api_sign_request_create(request: Request): + body = await _safe_json(request) + return JSONResponse(_handle_sign_request_create(body)) + + +@app.post("/api/sign_request_submit") +async def api_sign_request_submit(request: Request): + body = await _safe_json(request) + return JSONResponse(_handle_sign_request_submit(body)) + + def _normalize_phone(raw: str) -> tuple[str, bool]: """Нормализует RU-телефон в формат +7XXXXXXXXXX. Возвращает (нормализованный, валиден ли).""" diff --git a/miniapp/assets/assembly_detail.js b/miniapp/assets/assembly_detail.js index 96e3a3b..91ffa2e 100644 --- a/miniapp/assets/assembly_detail.js +++ b/miniapp/assets/assembly_detail.js @@ -141,16 +141,25 @@ const AssemblyDetailScreen = (function () { ` : ""; // Подпись + const VIA_LABELS = { + canvas: "✍️ Подпись пальцем", + code: "📱 Код подтверждения", + proxy: "👤 Представитель", + absent: "🚫 Без подписи", + }; const signBlock = data.signed_by_name ? `
-
-
Принято клиентом
-
${escHtml(data.signed_by_name)}
+ border:1px solid var(--border);border-radius:12px;"> +
+ ${escHtml(VIA_LABELS[data.signed_via] || "Принято")}
-
${escHtml(fmtDate(data.signed_at) || "")}
-
` : ""; +
+
${escHtml(data.signed_by_name)}
+
${escHtml(fmtDate(data.signed_at) || "")}
+
+ ${data.signed_by_phone ? `
${escHtml(data.signed_by_phone)}
` : ""} +
` : `
`; // Кнопка Google Calendar const calBtn = data.gcal_event_url ? ` @@ -167,6 +176,31 @@ const AssemblyDetailScreen = (function () { screen.innerHTML = statusBanner + mainBlock + noteBlock + photosBlock + signBlock + calBtn + `
`; + // Кнопка «Подписать акт» — только если ещё не подписано + if (!data.signed_by_name) { + const btnWrap = screen.querySelector("#sr-sign-btn-wrap"); + if (btnWrap) { + const signBtn = document.createElement("button"); + signBtn.className = "btn-primary"; + signBtn.style.cssText = "width:100%;font-size:15px;padding:13px;"; + signBtn.textContent = "✍️ Подписать акт приёмки"; + signBtn.addEventListener("click", () => { + haptic && haptic("impact"); + if (typeof SignRequest !== "undefined") { + SignRequest.open(data.id, { + clientName: data.client_name || "", + clientTgId: data.client_tg_id || "", + onSuccess: () => { + // Перерисовываем экран после подписания + mount(container, assemblyId); + }, + }); + } + }); + btnWrap.appendChild(signBtn); + } + } + } catch (e) { screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; } diff --git a/miniapp/assets/signrequest.js b/miniapp/assets/signrequest.js new file mode 100644 index 0000000..95772f5 --- /dev/null +++ b/miniapp/assets/signrequest.js @@ -0,0 +1,412 @@ +/* ============================================================ + SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП) + Два метода: canvas (палец) и code (OTP через Telegram/SMS). + Дополнительно: proxy (представитель) и absent (клиент отсутствовал). + ============================================================ */ + +const SignRequest = (function () { + "use strict"; + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData: Platform.initData, + initDataUnsafe: Platform.initDataUnsafe, + ...body, + }), + }); + if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Сервер не отвечает"); + throw e; + } finally { clearTimeout(t); } + } + + /* ── Canvas signature ─────────────────────────────────────── */ + + function initCanvas(canvas) { + const ctx = canvas.getContext("2d"); + let drawing = false; + let hasStrokes = false; + + function resize() { + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + ctx.strokeStyle = "#111"; + ctx.lineWidth = 2.5; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + } + resize(); + + function pos(e) { + const rect = canvas.getBoundingClientRect(); + const src = e.touches ? e.touches[0] : e; + return { x: src.clientX - rect.left, y: src.clientY - rect.top }; + } + + function start(e) { + e.preventDefault(); + drawing = true; + const { x, y } = pos(e); + ctx.beginPath(); + ctx.moveTo(x, y); + } + function move(e) { + e.preventDefault(); + if (!drawing) return; + hasStrokes = true; + const { x, y } = pos(e); + ctx.lineTo(x, y); + ctx.stroke(); + } + function end(e) { + e.preventDefault(); + drawing = false; + } + + canvas.addEventListener("mousedown", start, { passive: false }); + canvas.addEventListener("mousemove", move, { passive: false }); + canvas.addEventListener("mouseup", end, { passive: false }); + canvas.addEventListener("touchstart", start, { passive: false }); + canvas.addEventListener("touchmove", move, { passive: false }); + canvas.addEventListener("touchend", end, { passive: false }); + + return { + clear() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + hasStrokes = false; + }, + isEmpty() { return !hasStrokes; }, + toDataURL() { return canvas.toDataURL("image/png"); }, + }; + } + + /* ── Overlay builder ──────────────────────────────────────── */ + + function open(assemblyId, opts = {}) { + const clientName = opts.clientName || ""; + const clientTgId = opts.clientTgId || ""; + const onSuccess = opts.onSuccess || null; + + // Удалить предыдущий если есть + document.getElementById("signreq-overlay")?.remove(); + + const overlay = document.createElement("div"); + overlay.id = "signreq-overlay"; + overlay.className = "signreq-overlay"; + + overlay.innerHTML = ` +
+
+
Подписание акта
+ +
+ + +
+ + + + +
+ + +
+
+ Клиент подписывает пальцем на экране +
+
+ +
Подпись здесь
+
+
+ +
+
+ + +
+
+ +
+
+
+ + + + + + + + + +
+ `; + + document.body.appendChild(overlay); + + // Закрытие + overlay.querySelector(".signreq-close").addEventListener("click", close); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) close(); + }); + + // Tabs + const tabs = overlay.querySelectorAll(".signreq-tab"); + tabs.forEach(tab => { + tab.addEventListener("click", () => { + tabs.forEach(t => t.classList.remove("active")); + tab.classList.add("active"); + overlay.querySelectorAll(".signreq-panel").forEach(p => p.style.display = "none"); + overlay.querySelector(`#sr-panel-${tab.dataset.tab}`).style.display = ""; + }); + }); + + // Canvas setup + const canvas = overlay.querySelector("#sr-canvas"); + const canvasCtrl = initCanvas(canvas); + + overlay.querySelector(".signreq-clear").addEventListener("click", () => { + canvasCtrl.clear(); + Platform.haptic("impact"); + }); + + // ── Отправить код ──────────────────────────────────────────── + let codeSent = false; + overlay.querySelector(".signreq-send-code").addEventListener("click", async () => { + const btn = overlay.querySelector(".signreq-send-code"); + const errEl = overlay.querySelector("#sr-code-err"); + errEl.textContent = ""; + btn.disabled = true; + btn.textContent = "Отправляем..."; + try { + const res = await _api("sign_request_create", { assembly_id: assemblyId, mode: "code" }); + if (res.error) throw new Error(res.error); + codeSent = true; + overlay.querySelector("#sr-code-send-block").style.display = "none"; + overlay.querySelector("#sr-code-input-block").style.display = ""; + Platform.haptic("success"); + } catch (e) { + btn.disabled = false; + btn.textContent = "📤 Отправить код клиенту"; + errEl.textContent = "Ошибка: " + e.message; + } + }); + + // ── Submit: canvas ─────────────────────────────────────────── + overlay.querySelector(".signreq-submit-canvas").addEventListener("click", async () => { + const errEl = overlay.querySelector("#sr-canvas-err"); + errEl.textContent = ""; + if (canvasCtrl.isEmpty()) { + errEl.textContent = "Нарисуйте подпись на поле выше"; + return; + } + const name = overlay.querySelector("#sr-canvas-name").value.trim(); + if (!name) { + errEl.textContent = "Укажите ФИО подписанта"; + return; + } + await _submitSigned({ + mode: "canvas", + signature_data: canvasCtrl.toDataURL(), + signed_by_name: name, + signed_by_phone: overlay.querySelector("#sr-canvas-phone").value.trim(), + errEl, + onSuccess, + }); + }); + + // ── Submit: code ───────────────────────────────────────────── + overlay.querySelector(".signreq-submit-code").addEventListener("click", async () => { + const errEl = overlay.querySelector("#sr-code-err"); + errEl.textContent = ""; + const code = overlay.querySelector("#sr-code-input").value.trim(); + if (!/^\d{6}$/.test(code)) { + errEl.textContent = "Введите 6-значный код"; + return; + } + const name = overlay.querySelector("#sr-code-name").value.trim(); + if (!name) { + errEl.textContent = "Укажите ФИО подписанта"; + return; + } + await _submitSigned({ + mode: "code", + code, + signed_by_name: name, + signed_by_phone: overlay.querySelector("#sr-code-phone").value.trim(), + errEl, + onSuccess, + }, assemblyId); + }); + + // ── Submit: proxy ───────────────────────────────────────────── + overlay.querySelector(".signreq-submit-proxy").addEventListener("click", async () => { + const errEl = overlay.querySelector("#sr-proxy-err"); + errEl.textContent = ""; + const name = overlay.querySelector("#sr-proxy-name").value.trim(); + if (!name) { + errEl.textContent = "Укажите ФИО представителя"; + return; + } + await _submitSigned({ + mode: "proxy", + signed_by_name: name, + signed_by_phone: overlay.querySelector("#sr-proxy-phone").value.trim(), + errEl, + onSuccess, + }, assemblyId); + }); + + // ── Submit: absent ──────────────────────────────────────────── + overlay.querySelector(".signreq-submit-absent").addEventListener("click", async () => { + const errEl = overlay.querySelector("#sr-absent-err"); + errEl.textContent = ""; + const reason = overlay.querySelector("#sr-absent-reason").value; + const note = overlay.querySelector("#sr-absent-note").value.trim(); + await _submitSigned({ + mode: "absent", + absent_reason: note ? `${reason} · ${note}` : reason, + errEl, + onSuccess, + }, assemblyId); + }); + + // --- helper ------------------------------------------------------- + async function _submitSigned(params, asmId = assemblyId) { + const { errEl, onSuccess: cb, ...apiParams } = params; + const btn = overlay.querySelector(`.signreq-submit-${apiParams.mode}`); + if (btn) { btn.disabled = true; btn.textContent = "Сохраняем..."; } + try { + const res = await _api("sign_request_submit", { + assembly_id: asmId, + ...apiParams, + }); + if (res.error) throw new Error(res.error); + Platform.haptic("success"); + close(); + if (typeof cb === "function") cb(res); + } catch (e) { + if (btn) { btn.disabled = false; btn.textContent = _btnLabel(apiParams.mode); } + errEl.textContent = _errMsg(e.message); + } + } + } + + function _btnLabel(mode) { + return { + canvas: "Подписать", + code: "Подтвердить", + proxy: "Зафиксировать подпись", + absent: "Отметить как «без подписи»", + }[mode] || "Подтвердить"; + } + + function _errMsg(raw) { + return ({ + invalid_code: "Неверный код — проверьте и попробуйте снова", + code_expired: "Код устарел (72 ч) — отправьте новый", + no_sign_token: "Сначала отправьте код клиенту", + missing_code: "Введите 6-значный код", + forbidden: "Нет прав на подпись этой сборки", + invalid_init_data: "Ошибка авторизации — перезапустите приложение", + })[raw] || "Ошибка: " + raw; + } + + function close() { + document.getElementById("signreq-overlay")?.remove(); + } + + return { open, close }; +})(); diff --git a/miniapp/assets/styles.css b/miniapp/assets/styles.css index f9d59b4..5bffad6 100644 --- a/miniapp/assets/styles.css +++ b/miniapp/assets/styles.css @@ -1568,4 +1568,69 @@ html[data-variant="d"] .section-head .label { font-size: 13px; padding: 7px 14px; } +/* ── SignRequest overlay ─────────────────────────────────────── */ +.signreq-overlay { + position: fixed; inset: 0; z-index: 1000; + background: rgba(0,0,0,.55); + display: flex; align-items: flex-end; + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); +} +.signreq-sheet { + width: 100%; max-height: 92dvh; overflow-y: auto; + background: var(--surface, #fff); + border-radius: 20px 20px 0 0; + padding: 0 0 env(safe-area-inset-bottom, 16px); +} +.signreq-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 16px 8px; + position: sticky; top: 0; + background: var(--surface, #fff); + border-bottom: 1px solid var(--border, #eee); + z-index: 1; +} +.signreq-title { font-size: 16px; font-weight: 700; color: var(--ink); } +.signreq-close { + background: none; border: none; cursor: pointer; + font-size: 18px; color: var(--muted); padding: 4px 8px; +} +.signreq-tabs { + display: flex; gap: 4px; padding: 10px 12px 0; flex-wrap: wrap; +} +.signreq-tab { + background: var(--surface-2, var(--surface)); + border: 1px solid var(--border); border-radius: 20px; + font-size: 12px; font-weight: 600; color: var(--muted); + padding: 6px 12px; cursor: pointer; + transition: background .15s, color .15s; +} +.signreq-tab.active { + background: var(--accent, #003E7E); color: #fff; border-color: transparent; +} +.signreq-panel { padding: 14px 16px 8px; } +.signreq-canvas-wrap { + position: relative; width: 100%; height: 160px; + border: 2px solid var(--border); border-radius: 10px; + background: var(--bg, #f9f9f9); overflow: hidden; + cursor: crosshair; touch-action: none; +} +.signreq-canvas-wrap canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.signreq-canvas-hint { + position: absolute; inset: 0; display: flex; + align-items: center; justify-content: center; + font-size: 13px; color: var(--muted); pointer-events: none; + opacity: .6; +} +.signreq-name-row { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } +.signreq-cta { margin-top: 12px; } +.signreq-cta .btn-primary { width: 100%; } +.signreq-err { + color: #C0392B; font-size: 12px; margin-top: 6px; min-height: 16px; +} +.signreq-code-info { + font-size: 13px; color: var(--ink); margin-bottom: 12px; + padding: 10px 12px; background: var(--surface-2, var(--surface)); + border-radius: 8px; border: 1px solid var(--border); +} /* ─────────────────────────────────────────────────────────────── */ diff --git a/miniapp/index.html b/miniapp/index.html index 22b3711..8cfe2e0 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,7 +12,7 @@ - + @@ -51,7 +51,8 @@ - + +