- /admin: дашборд (активные подписки, платежи, заходы, документы), доступ по ADMIN_TOKEN - Уникальные посетители (cookie p2s_vid) + экспорт документов в CSV - Индикатор активной подписки: бейдж в шапке + карточка на главной (/api/subscription) - Автопродление ЮKassa: save_payment_method + рекуррентные списания + фоновый цикл - Кнопка входа де-брендирована (нейтральная иконка, «Войти / Регистрация») Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3558 lines
168 KiB
HTML
3558 lines
168 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" data-prelang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<!--SEO_HEAD-->
|
||
<title>pdf2scan — сделать скан из PDF онлайн бесплатно</title>
|
||
<meta name="description" content="Сделайте скан из PDF онлайн — бесплатно, без регистрации. Добавьте эффект сканирования, сшивку (нитка, ленточка, пружинка, уголок, дырокол), нумерацию страниц и лист «прошито-пронумеровано».">
|
||
<meta name="keywords" content="сделать скан из pdf онлайн, pdf в скан онлайн, сканирование pdf бесплатно, pdf с эффектом сканирования, прошить документ онлайн, сшивка документов онлайн, растровый pdf онлайн, pdf2scan">
|
||
<link rel="canonical" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="ru" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="en" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="de" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="fr" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="it" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="es" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="pt" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="x-default" href="https://pdf2scan.online/">
|
||
<meta property="og:title" content="pdf2scan — сделать скан из PDF онлайн бесплатно">
|
||
<meta property="og:description" content="Сделайте скан из PDF онлайн — бесплатно, без регистрации. Сшивка, нумерация, эффект сканирования в один клик.">
|
||
<meta property="og:url" content="https://pdf2scan.online/">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:image" content="https://pdf2scan.online/static/logo.png">
|
||
<meta property="og:image:width" content="640">
|
||
<meta property="og:image:height" content="640">
|
||
<meta name="twitter:card" content="summary">
|
||
<meta name="twitter:title" content="pdf2scan — сделать скан из PDF онлайн бесплатно">
|
||
<meta name="twitter:description" content="Сделайте скан из PDF онлайн — бесплатно, без регистрации. Сшивка, нумерация, эффект сканирования в один клик.">
|
||
<meta name="twitter:image" content="https://pdf2scan.online/static/logo.png">
|
||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||
<link rel="shortcut icon" href="/favicon.ico">
|
||
<script type="application/ld+json">
|
||
{
|
||
"@context": "https://schema.org",
|
||
"@type": "WebApplication",
|
||
"name": "pdf2scan",
|
||
"url": "https://pdf2scan.online",
|
||
"description": "Бесплатный онлайн-инструмент для создания PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Нумерация страниц, титульный лист, PDF/A.",
|
||
"applicationCategory": "UtilitiesApplication",
|
||
"operatingSystem": "Any",
|
||
"browserRequirements": "Requires JavaScript",
|
||
"inLanguage": ["ru", "en", "de", "fr", "it", "es", "pt"],
|
||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "RUB" },
|
||
"featureList": [
|
||
"Эффект сканирования PDF онлайн",
|
||
"Сшивка нитками, ленточкой, уголком, пружинкой, дыроколом",
|
||
"Нумерация страниц",
|
||
"Титульный лист",
|
||
"Лист прошито и пронумеровано",
|
||
"Формат PDF/A",
|
||
"Цветной и чёрно-белый режим",
|
||
"Конвертация Word в PDF со сканированием"
|
||
],
|
||
"creator": { "@type": "Organization", "name": "pdf2scan", "url": "https://pdf2scan.online" }
|
||
}
|
||
</script>
|
||
<!--/SEO_HEAD-->
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--blue: #007AFF;
|
||
--blue-hover: #0066D6;
|
||
--blue-light: #E8F2FF;
|
||
--gray: #8E8E93;
|
||
--gray-light: #F2F2F7;
|
||
--border: #D1D1D6;
|
||
--text: #1C1C1E;
|
||
--text-secondary: #636366;
|
||
--radius: 14px;
|
||
--radius-sm: 10px;
|
||
--shadow: 0 4px 24px rgba(0,0,0,0.08);
|
||
--shadow-sm: 0 2px 8px rgba(0,0,0,0.06);
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
|
||
background: #F2F2F7;
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
/* ── Header ── */
|
||
header {
|
||
width: 100%;
|
||
background: rgba(255,255,255,0.85);
|
||
backdrop-filter: saturate(180%) blur(20px);
|
||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||
border-bottom: 1px solid var(--border);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
padding: 8px 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
/* ── Language switcher ── */
|
||
#lang-switcher {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#lang-btn {
|
||
background: none;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 5px 8px;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
#lang-btn:hover { background: var(--gray-light); }
|
||
|
||
#lang-btn .lang-arrow {
|
||
font-size: 9px;
|
||
color: var(--gray);
|
||
line-height: 1;
|
||
}
|
||
|
||
.flag-img {
|
||
width: 20px;
|
||
height: 15px;
|
||
border-radius: 2px;
|
||
display: block;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.lang-option .flag-img {
|
||
width: 22px;
|
||
height: 16px;
|
||
}
|
||
|
||
#lang-dropdown {
|
||
display: none;
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
left: 0;
|
||
background: white;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
min-width: 150px;
|
||
z-index: 200;
|
||
}
|
||
|
||
#lang-dropdown.open { display: block; }
|
||
|
||
.lang-option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 9px 14px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
transition: background 0.1s;
|
||
border: none;
|
||
background: none;
|
||
width: 100%;
|
||
text-align: left;
|
||
font-family: inherit;
|
||
color: var(--text);
|
||
}
|
||
|
||
.lang-option:hover { background: var(--gray-light); }
|
||
.lang-option.active { background: var(--blue-light); color: var(--blue); font-weight: 600; }
|
||
|
||
.lang-option .lang-flag { font-size: 18px; }
|
||
|
||
.logo-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.logo-text {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--blue);
|
||
letter-spacing: -0.3px;
|
||
}
|
||
|
||
.logo-subtitle {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
margin-top: 1px;
|
||
}
|
||
|
||
/* ── Telegram auth in header ── */
|
||
#header-auth {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#tg-user-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 14px;
|
||
color: var(--text);
|
||
}
|
||
|
||
#tg-user-info img {
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
#tg-logout-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 12px;
|
||
color: var(--gray);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
padding: 0;
|
||
}
|
||
|
||
#tg-login-header-btn {
|
||
padding: 7px 14px;
|
||
background: var(--blue);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
#tg-login-header-btn:hover { background: var(--blue-hover); }
|
||
|
||
/* ── Main card ── */
|
||
main {
|
||
width: 100%;
|
||
max-width: 640px;
|
||
padding: 14px 16px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.card {
|
||
background: white;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-sm);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 9px 20px 7px;
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--text-secondary);
|
||
letter-spacing: 0.2px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
/* ── Drop zone ── */
|
||
.drop-zone {
|
||
border: 2px dashed var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 18px 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s, background 0.2s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6px;
|
||
position: relative;
|
||
}
|
||
|
||
.drop-zone:hover, .drop-zone.drag-over {
|
||
border-color: var(--blue);
|
||
background: var(--blue-light);
|
||
}
|
||
|
||
.drop-zone input[type=file] {
|
||
display: none;
|
||
}
|
||
|
||
.drop-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
background: var(--blue-light);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.drop-icon svg { fill: var(--blue); }
|
||
|
||
.drop-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.drop-hint {
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* ── Batch file list ── */
|
||
#batch-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.batch-row {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||
padding: 10px 12px;
|
||
background: var(--blue-light);
|
||
border-radius: 10px;
|
||
font-size: 14px;
|
||
}
|
||
.batch-name {
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
flex: 1; min-width: 0; font-weight: 500;
|
||
}
|
||
.batch-status { flex-shrink: 0; }
|
||
.bs {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
font-size: 13px; font-weight: 600; white-space: nowrap;
|
||
padding: 3px 10px; border-radius: 8px; text-decoration: none;
|
||
}
|
||
.bs-wait { color: var(--text-secondary); background: rgba(120,120,128,0.12); }
|
||
.bs-proc { color: #FF9500; background: #FFF8EC; }
|
||
.bs-done { color: #fff; background: var(--blue); }
|
||
.bs-done:hover { filter: brightness(0.95); }
|
||
.bs-err { color: #fff; background: #FF3B30; cursor: help; }
|
||
#batch-actions { display: flex; gap: 10px; flex-wrap: wrap; padding: 0 12px 12px; }
|
||
#zip-btn, #batch-another-btn {
|
||
flex: 1; min-width: 160px; padding: 12px 18px;
|
||
border: none; border-radius: 12px; font-size: 15px; font-weight: 600;
|
||
cursor: pointer; font-family: inherit;
|
||
}
|
||
#zip-btn { background: var(--blue); color: #fff; }
|
||
#zip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
#batch-another-btn { background: rgba(120,120,128,0.14); color: var(--text); }
|
||
|
||
.file-badge {
|
||
background: var(--blue-light);
|
||
color: var(--blue);
|
||
border-radius: 8px;
|
||
padding: 6px 14px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
display: none;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ── Settings ── */
|
||
.setting-row {
|
||
padding: 9px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.setting-row:last-child { border-bottom: none; }
|
||
|
||
.setting-label {
|
||
font-size: 15px;
|
||
flex: 1;
|
||
}
|
||
|
||
/* Segmented control */
|
||
.seg {
|
||
display: flex;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.seg input[type=radio] { display: none; }
|
||
|
||
.seg label {
|
||
padding: 6px 12px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
color: var(--text-secondary);
|
||
transition: background 0.15s, color 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.seg label:not(:last-of-type) { border-right: 1px solid var(--border); }
|
||
|
||
.seg input[type=radio]:checked + label {
|
||
background: var(--blue);
|
||
color: white;
|
||
}
|
||
|
||
/* Stitch grid */
|
||
.stitch-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 6px;
|
||
padding: 8px 14px;
|
||
}
|
||
|
||
.stitch-grid input[type=radio] { display: none; }
|
||
|
||
.stitch-btn {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: 6px 4px;
|
||
border: 2px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, background 0.15s;
|
||
font-size: 13px;
|
||
text-align: center;
|
||
}
|
||
|
||
.stitch-btn .icon { font-size: 20px; }
|
||
|
||
.stitch-grid input[type=radio]:checked + .stitch-btn {
|
||
border-color: var(--blue);
|
||
background: var(--blue-light);
|
||
color: var(--blue);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Toggle switch */
|
||
.toggle {
|
||
position: relative;
|
||
width: 51px;
|
||
height: 31px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toggle input { display: none; }
|
||
|
||
.toggle-track {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 100px;
|
||
background: var(--border);
|
||
transition: background 0.2s;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.toggle-track::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 27px;
|
||
height: 27px;
|
||
border-radius: 50%;
|
||
background: white;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.toggle input:checked + .toggle-track {
|
||
background: var(--blue);
|
||
}
|
||
|
||
.toggle input:checked + .toggle-track::after {
|
||
transform: translateX(20px);
|
||
}
|
||
|
||
/* ── Auth banner ── */
|
||
#auth-banner {
|
||
display: none;
|
||
background: linear-gradient(135deg, var(--blue) 0%, #5AC8FA 100%);
|
||
color: white;
|
||
border-radius: var(--radius);
|
||
padding: 24px 20px;
|
||
text-align: center;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
|
||
#auth-banner h3 { font-size: 18px; font-weight: 700; }
|
||
#auth-banner p { font-size: 14px; opacity: 0.9; line-height: 1.4; }
|
||
|
||
/* ── Submit button ── */
|
||
#submit-btn {
|
||
width: 100%;
|
||
padding: 16px;
|
||
background: var(--blue);
|
||
color: white;
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background 0.15s, transform 0.1s;
|
||
font-family: inherit;
|
||
}
|
||
|
||
#submit-btn:hover { background: var(--blue-hover); }
|
||
#submit-btn:active { transform: scale(0.98); }
|
||
#submit-btn:disabled {
|
||
background: var(--gray);
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
/* ── Progress ── */
|
||
#progress-card { display: none; }
|
||
|
||
.progress-label {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 16px 20px 6px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.progress-label span:last-child { color: var(--blue); font-weight: 600; }
|
||
|
||
.progress-bar-track {
|
||
height: 8px;
|
||
background: var(--gray-light);
|
||
border-radius: 100px;
|
||
margin: 0 20px 6px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-bar-fill {
|
||
height: 100%;
|
||
background: var(--blue);
|
||
border-radius: 100px;
|
||
width: 0%;
|
||
transition: width 0.4s ease;
|
||
}
|
||
|
||
.progress-speed {
|
||
padding: 0 20px 14px;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
min-height: 18px;
|
||
}
|
||
|
||
/* ── Result ── */
|
||
#result-card { display: none; }
|
||
|
||
.result-content {
|
||
padding: 14px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
.success-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
background: #34C759;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.success-icon svg { fill: white; }
|
||
|
||
.result-title { font-size: 20px; font-weight: 700; }
|
||
.result-sub { font-size: 14px; color: var(--text-secondary); }
|
||
|
||
#download-btn {
|
||
padding: 14px 40px;
|
||
background: #34C759;
|
||
color: white;
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: opacity 0.15s;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
}
|
||
|
||
#download-btn:hover { opacity: 0.85; }
|
||
|
||
#another-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--blue);
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
|
||
/* ── Error ── */
|
||
.error-msg {
|
||
background: #FFF0F0;
|
||
border: 1px solid #FFD6D6;
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px 16px;
|
||
color: #C00;
|
||
font-size: 14px;
|
||
display: none;
|
||
}
|
||
|
||
/* ── Info ── */
|
||
.info-msg {
|
||
background: var(--blue-light);
|
||
border: 1px solid #B3D4FF;
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px 16px;
|
||
color: var(--blue);
|
||
font-size: 14px;
|
||
display: none;
|
||
}
|
||
|
||
/* ── Donate card ── */
|
||
#donate-card {
|
||
display: none;
|
||
background: linear-gradient(135deg, #fffbf0 0%, #fff8e6 100%);
|
||
border: 1px solid #ffe0a0;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.donate-inner {
|
||
padding: 16px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18px;
|
||
}
|
||
|
||
.donate-text { flex: 1; min-width: 0; }
|
||
|
||
.donate-icon { font-size: 22px; margin-bottom: 2px; }
|
||
|
||
.donate-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #b45309;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.donate-desc {
|
||
font-size: 13px;
|
||
color: #92400e;
|
||
line-height: 1.4;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.donate-buttons {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.donate-btn {
|
||
padding: 7px 14px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.donate-btn:hover { opacity: 0.85; }
|
||
|
||
.donate-btn-sbp {
|
||
background: #1a56db;
|
||
color: white;
|
||
}
|
||
|
||
.donate-qr {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.donate-qr img {
|
||
width: 100px;
|
||
height: 100px;
|
||
border-radius: 8px;
|
||
border: 1px solid #ffe0a0;
|
||
background: white;
|
||
padding: 4px;
|
||
display: block;
|
||
}
|
||
|
||
.donate-qr-hint {
|
||
font-size: 10px;
|
||
color: #92400e;
|
||
text-align: center;
|
||
max-width: 100px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.pay-amt {
|
||
padding: 7px 12px;
|
||
border: 1px solid var(--border, #d0d7e2);
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
color: #92400e;
|
||
}
|
||
.pay-amt.active {
|
||
background: #f59e0b;
|
||
border-color: #f59e0b;
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
@media (max-width: 420px) {
|
||
.donate-inner { flex-direction: column; }
|
||
.donate-qr { flex-direction: row; align-items: center; gap: 12px; width: 100%; }
|
||
.donate-qr img { width: 80px; height: 80px; }
|
||
.donate-qr-hint { max-width: none; }
|
||
/* Батч-список на узких экранах */
|
||
#batch-actions { flex-direction: column; }
|
||
#zip-btn, #batch-another-btn { min-width: 0; width: 100%; }
|
||
.batch-row { padding: 9px 10px; font-size: 13px; }
|
||
.bs { font-size: 12px; padding: 3px 8px; }
|
||
}
|
||
|
||
/* ── SEO section ── */
|
||
#seo-section {
|
||
width: 100%;
|
||
max-width: 640px;
|
||
padding: 0 16px 8px;
|
||
}
|
||
|
||
.seo-heading {
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin-bottom: 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
.seo-steps {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.seo-step {
|
||
background: white;
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px 10px;
|
||
text-align: center;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.seo-step-num {
|
||
width: 28px;
|
||
height: 28px;
|
||
background: var(--blue);
|
||
color: white;
|
||
border-radius: 50%;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0 auto 6px;
|
||
}
|
||
|
||
.seo-step-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.seo-step-desc {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.seo-about {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.55;
|
||
text-align: center;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
/* ── Footer ── */
|
||
footer {
|
||
padding: 10px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<!-- Language switcher -->
|
||
<div id="lang-switcher">
|
||
<button id="lang-btn" onclick="toggleLangDropdown(event)" aria-label="Select language">
|
||
<img id="lang-flag-img" class="flag-img" src="/static/flags/ru.png" alt="RU">
|
||
<span class="lang-arrow">▼</span>
|
||
</button>
|
||
<div id="lang-dropdown">
|
||
<button class="lang-option" onclick="setLang('ru')"><img class="flag-img" src="/static/flags/ru.png" alt="RU"> Русский</button>
|
||
<button class="lang-option" onclick="setLang('en')"><img class="flag-img" src="/static/flags/gb.png" alt="GB"> English</button>
|
||
<button class="lang-option" onclick="setLang('de')"><img class="flag-img" src="/static/flags/de.png" alt="DE"> Deutsch</button>
|
||
<button class="lang-option" onclick="setLang('fr')"><img class="flag-img" src="/static/flags/fr.png" alt="FR"> Français</button>
|
||
<button class="lang-option" onclick="setLang('it')"><img class="flag-img" src="/static/flags/it.png" alt="IT"> Italiano</button>
|
||
<button class="lang-option" onclick="setLang('es')"><img class="flag-img" src="/static/flags/es.png" alt="ES"> Español</button>
|
||
<button class="lang-option" onclick="setLang('pt')"><img class="flag-img" src="/static/flags/pt.png" alt="PT"> Português</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="logo-icon">
|
||
<img src="/static/logo.png" width="40" height="40" alt="pdf2scan logo" style="display:block;">
|
||
</div>
|
||
<div>
|
||
<div class="logo-text">pdf2scan</div>
|
||
<div class="logo-subtitle" data-i18n="logo_subtitle">Создание растровых pdf с эффектом сканирования из электронных pdf документов</div>
|
||
</div>
|
||
|
||
<div id="header-auth" style="position:relative;">
|
||
<button id="tg-login-header-btn" onclick="toggleLoginMenu(event)" style="display:none;">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
|
||
<span data-i18n="login_btn">Войти / Регистрация</span>
|
||
</button>
|
||
<div id="login-menu" style="display:none;position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid var(--border,#d0d7e2);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.12);padding:6px;z-index:50;min-width:190px;">
|
||
<button onclick="startTgAuth();closeLoginMenu();" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#2AABEE"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L8.32 13.617l-2.96-.924c-.643-.204-.657-.643.136-.953l11.57-4.461c.537-.194 1.006.131.828.942z"/></svg>
|
||
<span data-i18n="login_via_tg">Через Telegram</span>
|
||
</button>
|
||
<button onclick="location.href='/api/auth/yandex/start';" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
|
||
<span style="display:inline-flex;width:18px;height:18px;background:#FC3F1D;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:12px;">Я</span>
|
||
<span data-i18n="login_via_yandex">Через Яндекс</span>
|
||
</button>
|
||
<button onclick="location.href='/api/auth/vk/start';" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
|
||
<span style="display:inline-flex;width:22px;height:18px;background:#07f;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:11px;">VK</span>
|
||
<span data-i18n="login_via_vk">Через VK</span>
|
||
</button>
|
||
<button onclick="closeLoginMenu();openEmailLogin();" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
|
||
<span style="display:inline-flex;width:22px;height:18px;background:#64748b;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:12px;">✉</span>
|
||
<span data-i18n="login_via_email">Через почту</span>
|
||
</button>
|
||
</div>
|
||
<div id="tg-user-info" style="display:none;">
|
||
<img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'">
|
||
<span id="tg-user-name"></span>
|
||
<a id="sub-badge" href="javascript:void(0)" onclick="scrollToSubCard()" title=""
|
||
style="display:none;align-items:center;gap:5px;background:rgba(52,199,89,.16);color:#1f9d3f;font-size:12.5px;font-weight:700;padding:4px 10px;border-radius:20px;text-decoration:none;white-space:nowrap;">
|
||
<span style="font-size:13px;line-height:1">★</span><span id="sub-badge-text"></span>
|
||
</a>
|
||
<button id="tg-logout-btn" onclick="doLogout()" data-i18n="logout_btn">Выйти</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Модалка входа по почте -->
|
||
<div id="email-login-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:100;align-items:center;justify-content:center;padding:16px;">
|
||
<div style="background:#fff;border-radius:14px;max-width:340px;width:100%;padding:22px;box-shadow:0 10px 40px rgba(0,0,0,.25);">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||
<strong style="font-size:17px;color:#1c2b45" data-i18n="email_login_title">Вход по почте</strong>
|
||
<button onclick="closeEmailLogin()" style="background:none;border:none;font-size:22px;line-height:1;cursor:pointer;color:#94a3b8">×</button>
|
||
</div>
|
||
<div id="email-step-1">
|
||
<input type="email" id="email-login-input" data-i18n-ph="email_ph" placeholder="ваш email" style="width:100%;padding:10px 12px;border:1px solid #d0d7e2;border-radius:8px;font-size:15px;font-family:inherit;margin-bottom:10px">
|
||
<button id="email-send-btn" onclick="emailSendCode()" style="width:100%;padding:11px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="email_send_code">Получить код</button>
|
||
</div>
|
||
<div id="email-step-2" style="display:none">
|
||
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 10px" data-i18n="email_code_sent">Код отправлен на почту. Введите его:</p>
|
||
<input type="text" id="email-code-input" inputmode="numeric" maxlength="6" placeholder="000000" style="width:100%;padding:10px 12px;border:1px solid #d0d7e2;border-radius:8px;font-size:20px;letter-spacing:6px;text-align:center;font-family:inherit;margin-bottom:10px">
|
||
<button id="email-verify-btn" onclick="emailVerifyCode()" style="width:100%;padding:11px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="email_login_btn">Войти</button>
|
||
</div>
|
||
<div id="email-login-msg" style="font-size:12px;color:#e05555;margin-top:10px;min-height:16px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модалка «оплата прошла» -->
|
||
<div id="pay-success-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:110;align-items:center;justify-content:center;padding:16px;">
|
||
<div style="background:#fff;border-radius:16px;max-width:340px;width:100%;padding:28px 24px;box-shadow:0 12px 44px rgba(0,0,0,.28);text-align:center;">
|
||
<div style="font-size:52px;line-height:1;margin-bottom:10px">✅</div>
|
||
<div id="pay-success-msg" style="font-size:17px;font-weight:600;color:#1c2b45;margin-bottom:16px"></div>
|
||
<button onclick="document.getElementById('pay-success-overlay').style.display='none'" style="padding:11px 28px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="pay_ok_btn">Отлично</button>
|
||
</div>
|
||
</div>
|
||
|
||
<main>
|
||
|
||
<!-- Статус активной подписки -->
|
||
<div class="card" id="sub-card" style="display:none;border:1px solid rgba(52,199,89,.4);background:linear-gradient(180deg,rgba(52,199,89,.09),rgba(52,199,89,.02));">
|
||
<div style="padding:16px;display:flex;align-items:center;gap:14px;">
|
||
<div style="width:46px;height:46px;flex:none;border-radius:50%;background:rgba(52,199,89,.18);display:flex;align-items:center;justify-content:center;font-size:23px;color:#1f9d3f;">★</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:16px;font-weight:700;color:var(--text-primary,#1c2b45)" data-i18n="sub_active_title">Подписка активна</div>
|
||
<div id="sub-card-until" style="font-size:13.5px;color:var(--text-secondary)"></div>
|
||
</div>
|
||
<button id="sub-renew-btn" type="button" onclick="renewSub()" style="flex:none;background:#34c759;color:#fff;border:none;border-radius:10px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="sub_renew">Продлить</button>
|
||
</div>
|
||
<div style="padding:0 16px 14px;display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--text-secondary)">
|
||
<span>✓ <span data-i18n="sub_perk_files">Большие файлы без лимита</span></span>
|
||
<span>✓ <span data-i18n="sub_perk_batch">Пакетная обработка</span></span>
|
||
<span>✓ <span data-i18n="sub_perk_priority">Приоритетная очередь</span></span>
|
||
</div>
|
||
<div id="sub-autorenew" style="padding:10px 16px;border-top:0.5px solid rgba(52,199,89,.25);font-size:12.5px;color:var(--text-secondary);display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<span id="sub-autorenew-label"></span>
|
||
<a id="sub-autorenew-toggle" href="javascript:void(0)" onclick="toggleAutorenew()" style="color:#007AFF;text-decoration:underline;cursor:pointer;display:none"></a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
<p data-i18n="page_description" style="font-size:13px; line-height:1.45; color:var(--text-secondary); padding: 0 4px;">
|
||
🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.
|
||
</p>
|
||
|
||
<!-- Upload -->
|
||
<div class="card">
|
||
<div class="card-title" data-i18n="card_file">Файл</div>
|
||
<div style="padding: 12px;">
|
||
<div class="drop-zone" id="drop-zone">
|
||
<input type="file" id="file-input" accept=".pdf,.doc,.docx,.zip" multiple style="display:none" />
|
||
<input type="file" id="folder-input" webkitdirectory directory multiple style="display:none" />
|
||
<div class="drop-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||
</svg>
|
||
</div>
|
||
<div class="drop-title" data-i18n="drop_title">Выберите PDF или Word</div>
|
||
<div class="drop-hint" data-i18n="drop_hint">или перетащите файлы сюда (до 20)</div>
|
||
<div class="file-badge" id="file-badge">—</div>
|
||
</div>
|
||
<div style="text-align:center;margin-top:8px">
|
||
<button type="button" id="folder-btn" style="background:none;border:1px solid var(--border,#2b3a52);color:var(--text-secondary);border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer;font-family:inherit" data-i18n="folder_pick">📁 Папкой</button>
|
||
</div>
|
||
<div style="text-align:center;margin-top:10px;font-size:12px;color:var(--text-secondary);line-height:1.4" data-i18n="free_limit_note">Бесплатно до 50 МБ и 100 страниц. Больше — 99 ₽ за файл или подписка 299 ₽/мес.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Batch list (несколько файлов) -->
|
||
<div class="card" id="batch-card" style="display:none">
|
||
<div class="card-title"><span data-i18n="batch_files">Файлы</span> <span id="batch-count"></span></div>
|
||
<div style="padding: 12px;">
|
||
<div id="batch-list"></div>
|
||
</div>
|
||
<div id="batch-actions" style="display:none">
|
||
<button id="zip-btn" type="button" data-i18n="download_all_zip">⬇ Скачать всё архивом</button>
|
||
<button id="batch-another-btn" type="button" onclick="resetApp()" data-i18n="another_btn_batch">Обработать ещё файлы</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stitching -->
|
||
<div class="card">
|
||
<div class="card-title" data-i18n="card_stitch">Тип сшивки</div>
|
||
<div class="stitch-grid">
|
||
|
||
<input type="radio" name="stitching" id="s-thread" value="нитка">
|
||
<label class="stitch-btn" for="s-thread"><span class="icon">🧵</span><span data-i18n="stitch_thread">Нитка</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-ribbon" value="ленточка">
|
||
<label class="stitch-btn" for="s-ribbon"><span class="icon">🎀</span><span data-i18n="stitch_ribbon">Ленточка</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-corner" value="уголок">
|
||
<label class="stitch-btn" for="s-corner"><span class="icon">📐</span><span data-i18n="stitch_corner">Уголок</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-spiral" value="пружинка">
|
||
<label class="stitch-btn" for="s-spiral"><span class="icon">🌀</span><span data-i18n="stitch_spiral">Пружинка</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-punch" value="дырокол">
|
||
<label class="stitch-btn" for="s-punch"><span class="icon">⭕</span><span data-i18n="stitch_punch">Дырокол</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-none" value="без сшивки" checked>
|
||
<label class="stitch-btn" for="s-none"><span class="icon">📄</span><span data-i18n="stitch_none">Без сшивки</span></label>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Extra settings -->
|
||
<div class="card">
|
||
<div class="card-title" data-i18n="card_extra">Дополнительно</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="color_mode">Режим цвета</span>
|
||
<div class="seg">
|
||
<input type="radio" name="color" id="c-color" value="color">
|
||
<label for="c-color" data-i18n="color_color">Цветной</label>
|
||
<input type="radio" name="color" id="c-bw" value="bw" checked>
|
||
<label for="c-bw" data-i18n="color_bw">Ч/Б</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="quality">Качество</span>
|
||
<div class="seg">
|
||
<input type="radio" name="quality" id="q-high" value="high">
|
||
<label for="q-high" data-i18n="quality_high">Высокое</label>
|
||
<input type="radio" name="quality" id="q-medium" value="medium" checked>
|
||
<label for="q-medium" data-i18n="quality_medium">Среднее</label>
|
||
<input type="radio" name="quality" id="q-low" value="low">
|
||
<label for="q-low" data-i18n="quality_low">Низкое</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="title_page">Титульный лист</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="title-check">
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="page_numbers">Нумерация страниц</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="num-check">
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="certified_page">Лист «прошито-пронумеровано»</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="flip-check">
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="pdfa_format">Формат PDF/A</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="pdfa-check" checked>
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label">
|
||
<span data-i18n="ocr_label">Распознать текст (OCR)</span>
|
||
<span style="display:block;font-size:11px;color:var(--text-secondary);margin-top:2px" data-i18n="ocr_hint">Скан станет искомым: текст можно копировать и находить поиском</span>
|
||
</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="ocr-check">
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Auth banner (не используется — заменён пейволлом) -->
|
||
<div id="auth-banner" style="display:none">
|
||
<h3 data-i18n="auth_required">🔐 Требуется авторизация</h3>
|
||
<p data-i18n="auth_required_desc">Для файлов больше 100 страниц или 50 МБ необходим вход через Telegram.</p>
|
||
<button onclick="startTgAuth()" style="padding:10px 24px;background:white;color:#007AFF;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;" data-i18n="login_tg">
|
||
Войти через Telegram
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Paywall: большой файл (>50 МБ / >100 стр.) -->
|
||
<div id="paywall-card" style="display:none;background:#fff;border:1px solid var(--border,#e2e8f0);border-radius:var(--radius);box-shadow:var(--shadow-sm);padding:18px 20px;margin-bottom:12px;text-align:center;">
|
||
<div style="font-size:32px;margin-bottom:4px">💳</div>
|
||
<h3 style="margin:0 0 6px;font-size:18px" data-i18n="paywall_title">Файл больше бесплатного лимита</h3>
|
||
<p style="margin:0 0 14px;font-size:14px;color:var(--text-secondary)" data-i18n="paywall_desc">Бесплатно — до 50 МБ и 100 страниц. Для файлов больше — разовая оплата или подписка.</p>
|
||
<input type="email" id="paywall-email" data-i18n-ph="pay_email_ph" placeholder="email для чека" style="width:100%;max-width:280px;padding:9px 12px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-size:14px;margin-bottom:12px;font-family:inherit">
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center">
|
||
<button id="paywall-file-btn" onclick="payForFile()" style="flex:1;min-width:150px;padding:12px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit"><span data-i18n="paywall_pay_file">Оплатить файл</span> · <span id="paywall-file-price">99</span> ₽</button>
|
||
<button id="paywall-sub-btn" onclick="payForSub()" style="flex:1;min-width:150px;padding:12px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit"><span data-i18n="paywall_pay_sub">Подписка</span> · <span id="paywall-sub-price">299</span> ₽/<span data-i18n="paywall_month">мес</span></button>
|
||
</div>
|
||
<label id="paywall-autorenew-row" style="display:flex;align-items:center;justify-content:center;gap:7px;margin-top:12px;font-size:12.5px;color:var(--text-secondary);cursor:pointer">
|
||
<input type="checkbox" id="paywall-autorenew" checked style="width:16px;height:16px;cursor:pointer">
|
||
<span data-i18n="paywall_autorenew">Продлевать автоматически каждый месяц</span>
|
||
</label>
|
||
<div id="paywall-msg" style="font-size:12px;color:var(--text-secondary);margin-top:10px"></div>
|
||
<button onclick="resetApp()" style="margin-top:8px;background:none;border:none;color:var(--text-secondary);font-size:13px;cursor:pointer;text-decoration:underline;font-family:inherit" data-i18n="paywall_cancel">Отмена</button>
|
||
</div>
|
||
|
||
<!-- Info -->
|
||
<div class="info-msg" id="info-msg"></div>
|
||
|
||
<!-- Error -->
|
||
<div class="error-msg" id="error-msg"></div>
|
||
|
||
<!-- Submit -->
|
||
<button id="submit-btn" disabled data-i18n="btn_select_file">Выберите файл</button>
|
||
|
||
<!-- Progress -->
|
||
<div class="card" id="progress-card">
|
||
<div class="progress-label">
|
||
<span id="progress-status" data-i18n="progress_processing">Обработка...</span>
|
||
<span id="progress-pct"></span>
|
||
</div>
|
||
<div class="progress-bar-track">
|
||
<div class="progress-bar-fill" id="progress-fill"></div>
|
||
</div>
|
||
<div class="progress-speed" id="progress-speed"></div>
|
||
</div>
|
||
|
||
<!-- Result -->
|
||
<div class="card" id="result-card">
|
||
<div class="result-content">
|
||
<div class="success-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
|
||
<polyline points="20 6 9 17 4 12" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||
</svg>
|
||
</div>
|
||
<div class="result-title" data-i18n="result_title">Готово!</div>
|
||
<div class="result-sub" id="result-sub" data-i18n="result_sub">Ваш PDF обработан и готов к скачиванию.</div>
|
||
<div style="font-size:13px;color:#FF9500;background:#FFF8EC;border-radius:8px;padding:8px 14px;" data-i18n="result_timer">
|
||
⏱ Ссылка активна 6 часов — скачайте файл сейчас
|
||
</div>
|
||
<a id="download-btn" href="#" download data-i18n="download_btn">⬇ Скачать результат</a>
|
||
<button id="another-btn" onclick="resetApp()" data-i18n="another_btn">Обработать ещё один файл</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Donate -->
|
||
<div id="donate-card">
|
||
<div class="donate-inner">
|
||
<div class="donate-text">
|
||
<div class="donate-icon">🍩</div>
|
||
<div class="donate-title" data-i18n="donate_title">Поддержите проект</div>
|
||
<div class="donate-desc" data-i18n="donate_desc">Если сервис полезен — поддержите его работу донатом. Это помогает оплачивать сервер и развивать проект.</div>
|
||
<div class="donate-buttons">
|
||
<a class="donate-btn donate-btn-sbp" href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019cbe28-6b54-7eb8-8a52-189633e608ea" target="_blank" rel="noopener" data-i18n="donate_sbp">СБП / Ozon Pay</a>
|
||
</div>
|
||
<!-- Оплата картой через ЮKassa -->
|
||
<div class="donate-pay" style="margin-top:14px">
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
|
||
<button type="button" class="pay-amt" onclick="setPayAmt(100,this)">100 ₽</button>
|
||
<button type="button" class="pay-amt active" onclick="setPayAmt(300,this)">300 ₽</button>
|
||
<button type="button" class="pay-amt" onclick="setPayAmt(500,this)">500 ₽</button>
|
||
<input type="number" id="pay-amt-custom" min="10" max="100000" placeholder="₽" oninput="onCustomAmt()"
|
||
style="width:76px;padding:7px 8px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-family:inherit;font-size:14px">
|
||
</div>
|
||
<input type="email" id="pay-email" data-i18n-ph="pay_email_ph" placeholder="email для чека"
|
||
style="width:100%;max-width:260px;padding:8px 10px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-family:inherit;font-size:14px;margin-bottom:8px">
|
||
<div>
|
||
<button type="button" id="pay-card-btn" onclick="payDonate()" class="donate-btn" data-i18n="pay_card_btn">💳 Оплатить картой</button>
|
||
</div>
|
||
<div id="pay-msg" style="font-size:12px;color:var(--text-secondary);margin-top:6px"></div>
|
||
</div>
|
||
</div>
|
||
<div class="donate-qr">
|
||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https%3A%2F%2Ffinance.ozon.ru%2Fapps%2Fsbp%2Fozonbankpay%2F019cbe28-6b54-7eb8-8a52-189633e608ea" alt="QR donate" loading="lazy">
|
||
<div class="donate-qr-hint" data-i18n="donate_qr_hint">Сканируйте для оплаты</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Личный кабинет: Мои документы (видно после входа) -->
|
||
<div class="card" id="cabinet-card" style="display:none">
|
||
<div class="card-title" data-i18n="cabinet_title">Мои документы</div>
|
||
<div style="padding: 12px;">
|
||
<div id="cabinet-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<section id="seo-section">
|
||
<h2 class="seo-heading" data-i18n="seo_title">Как работает pdf2scan</h2>
|
||
<div class="seo-steps">
|
||
<div class="seo-step">
|
||
<div class="seo-step-num">1</div>
|
||
<div class="seo-step-title" data-i18n="seo_step1_title">Загрузите файл</div>
|
||
<div class="seo-step-desc" data-i18n="seo_step1_desc">PDF или Word — перетащите или выберите с устройства</div>
|
||
</div>
|
||
<div class="seo-step">
|
||
<div class="seo-step-num">2</div>
|
||
<div class="seo-step-title" data-i18n="seo_step2_title">Настройте параметры</div>
|
||
<div class="seo-step-desc" data-i18n="seo_step2_desc">Тип сшивки, цвет, качество, нумерация и другие опции</div>
|
||
</div>
|
||
<div class="seo-step">
|
||
<div class="seo-step-num">3</div>
|
||
<div class="seo-step-title" data-i18n="seo_step3_title">Скачайте результат</div>
|
||
<div class="seo-step-desc" data-i18n="seo_step3_desc">Готовый PDF со сканом доступен для скачивания 6 часов</div>
|
||
</div>
|
||
</div>
|
||
<p class="seo-about" data-i18n="seo_about">pdf2scan — бесплатный онлайн-инструмент для создания растрового PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Идеально для подготовки документов к сдаче в архив, нотариусу или государственные органы. Без регистрации, без установки программ.</p>
|
||
</section>
|
||
|
||
<footer>
|
||
pdf2scan — <span data-i18n="footer_text">превращаем PDF в скан</span> | <a href="https://t.me/pdf2scan_bot" style="color:var(--blue); text-decoration:none;" data-i18n="footer_tg">Telegram-бот</a> | <a href="/developers" style="color:var(--blue); text-decoration:none;" data-i18n="footer_api">API для бизнеса</a> | <a href="#" onclick="showDonateCard(false);document.getElementById('donate-card').scrollIntoView({behavior:'smooth'});return false;" style="color:var(--blue); text-decoration:none;" data-i18n="footer_donate">🍩 Поддержать</a>
|
||
</footer>
|
||
|
||
<script>
|
||
// ── Translations ──────────────────────────────────────────────────────────
|
||
const LANGS = {
|
||
ru: { img: '/static/flags/ru.png', alt: 'RU', name: 'Русский' },
|
||
en: { img: '/static/flags/gb.png', alt: 'GB', name: 'English' },
|
||
de: { img: '/static/flags/de.png', alt: 'DE', name: 'Deutsch' },
|
||
fr: { img: '/static/flags/fr.png', alt: 'FR', name: 'Français' },
|
||
it: { img: '/static/flags/it.png', alt: 'IT', name: 'Italiano' },
|
||
es: { img: '/static/flags/es.png', alt: 'ES', name: 'Español' },
|
||
pt: { img: '/static/flags/pt.png', alt: 'PT', name: 'Português' },
|
||
};
|
||
|
||
const TR = {
|
||
ru: {
|
||
page_title: 'pdf2scan — онлайн сканирование PDF',
|
||
logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов',
|
||
login_btn: 'Войти / Регистрация',
|
||
login_via_tg: 'Через Telegram',
|
||
login_via_yandex: 'Через Яндекс',
|
||
login_via_vk: 'Через VK',
|
||
login_via_email: 'Через почту',
|
||
email_login_title: 'Вход по почте',
|
||
email_ph: 'ваш email',
|
||
email_send_code: 'Получить код',
|
||
email_code_sent: 'Код отправлен на почту. Введите его:',
|
||
email_login_btn: 'Войти',
|
||
email_sending: 'Отправляем код…',
|
||
email_bad_code: 'Неверный код',
|
||
err_login: 'Не удалось войти. Попробуйте снова.',
|
||
logout_btn: 'Выйти',
|
||
page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.',
|
||
card_file: 'Файл',
|
||
drop_title: 'Выберите PDF или Word',
|
||
drop_hint: 'или перетащите файлы сюда (до 20)',
|
||
free_limit_note: 'Бесплатно до 50 МБ и 100 страниц. Больше — 99 ₽ за файл или подписка 299 ₽/мес.',
|
||
file_selected: 'Файл выбран',
|
||
batch_files: 'Файлы',
|
||
files_selected: (n) => `Выбрано ${n} ${pluralizeRu(n, 'файл', 'файла', 'файлов')}`,
|
||
btn_process_n: (n) => `Обработать ${n} ${pluralizeRu(n, 'файл', 'файла', 'файлов')}`,
|
||
err_too_many: (max) => `Слишком много файлов — максимум ${max}. Лишние не добавлены.`,
|
||
st_uploading: 'Загрузка…',
|
||
st_queued: 'В очереди',
|
||
st_processing: 'Обработка…',
|
||
st_done: 'Готово',
|
||
st_error: 'Ошибка',
|
||
download_all_zip: '⬇ Скачать всё архивом',
|
||
another_btn_batch: 'Обработать ещё файлы',
|
||
zip_preparing: 'Архивируем…',
|
||
err_zip: 'Не удалось создать архив',
|
||
card_stitch: 'Тип сшивки',
|
||
stitch_thread: 'Нитка',
|
||
stitch_ribbon: 'Ленточка',
|
||
stitch_corner: 'Уголок',
|
||
stitch_spiral: 'Пружинка',
|
||
stitch_punch: 'Дырокол',
|
||
stitch_none: 'Без сшивки',
|
||
card_extra: 'Дополнительно',
|
||
color_mode: 'Режим цвета',
|
||
color_color: 'Цветной',
|
||
color_bw: 'Ч/Б',
|
||
quality: 'Качество',
|
||
quality_high: 'Высокое',
|
||
quality_medium: 'Среднее',
|
||
quality_low: 'Низкое',
|
||
title_page: 'Титульный лист',
|
||
page_numbers: 'Нумерация страниц',
|
||
certified_page: 'Лист «прошито-пронумеровано»',
|
||
pdfa_format: 'Формат PDF/A',
|
||
ocr_label: 'Распознать текст (OCR)',
|
||
ocr_hint: 'Скан станет искомым: текст можно копировать и находить поиском',
|
||
cabinet_title: 'Мои документы',
|
||
cabinet_empty: 'Пока нет обработанных документов',
|
||
cabinet_dl: '⬇ Скачать',
|
||
cabinet_expired: 'истёк',
|
||
cabinet_processing: 'обработка',
|
||
folder_pick: '📁 Папкой',
|
||
auth_required: '🔐 Требуется авторизация',
|
||
auth_required_desc:'Для файлов больше 100 страниц или 50 МБ необходим вход через Telegram.',
|
||
login_tg: 'Войти через Telegram',
|
||
btn_select_file: 'Выберите файл',
|
||
btn_process: 'Обработать PDF',
|
||
btn_sending: 'Отправка...',
|
||
btn_processing: 'Обработка...',
|
||
btn_auth_required: 'Необходима авторизация',
|
||
progress_loading: 'Загрузка файла...',
|
||
progress_uploaded: 'Файл загружен, ожидание обработки...',
|
||
progress_processing:'Обработка...',
|
||
result_title: 'Готово!',
|
||
result_sub: 'Ваш PDF обработан и готов к скачиванию.',
|
||
result_timer: '⏱ Ссылка активна 6 часов — скачайте файл сейчас',
|
||
download_btn: '⬇ Скачать результат',
|
||
another_btn: 'Обработать ещё один файл',
|
||
footer_text: 'превращаем PDF в скан',
|
||
footer_tg: 'Telegram-бот',
|
||
footer_api: 'API для бизнеса',
|
||
footer_donate: '🍩 Поддержать',
|
||
err_no_file: 'Файл не выбран',
|
||
err_task_not_found:'Задача не найдена. Попробуйте отправить файл снова.',
|
||
err_network: 'Ошибка сети при загрузке файла',
|
||
err_timeout: 'Превышено время ожидания',
|
||
err_auth_start: 'Не удалось начать авторизацию.',
|
||
err_auth_expired: 'Время авторизации истекло. Попробуйте снова.',
|
||
err_auth_launch: 'Ошибка при запуске авторизации.',
|
||
err_processing: 'Произошла ошибка обработки.',
|
||
info_auth_waiting: 'Откройте Telegram-бота и нажмите Start — авторизация произойдёт автоматически...',
|
||
queue_text: (pos) => `В очереди (${pos} ${pluralizeRu(pos, 'задание', 'задания', 'заданий')})`,
|
||
upload_speed: (loaded, total, speed) => `${loaded} / ${total} МБ · ${speed} МБ/с`,
|
||
err_server: (code) => `Ошибка ${code}`,
|
||
donate_title: 'Поддержите проект',
|
||
donate_desc: 'Если сервис полезен — поддержите его работу донатом. Это помогает оплачивать сервер и развивать проект.',
|
||
donate_desc_heavy: '⚠️ Большие файлы сильно нагружают сервер. Если сервис полезен — поддержите проект донатом.',
|
||
donate_sbp: 'СБП / Ozon Pay',
|
||
donate_qr_hint: 'Сканируйте для оплаты',
|
||
pay_card_btn: '💳 Оплатить картой',
|
||
pay_email_ph: 'email для чека',
|
||
pay_need_email: 'Укажите корректный email для чека',
|
||
pay_bad_amount: 'Некорректная сумма',
|
||
pay_creating: 'Создаём платёж…',
|
||
pay_error: 'Не удалось создать платёж',
|
||
pay_thanks: '✅ Спасибо за поддержку! Оплата прошла.',
|
||
pay_thanks_file: 'Оплата прошла! Файл обрабатывается…',
|
||
pay_ok_btn: 'Отлично',
|
||
pay_canceled: 'Платёж отменён',
|
||
paywall_title: 'Файл больше бесплатного лимита',
|
||
paywall_desc: 'Бесплатно — до 50 МБ и 100 страниц. Для файлов больше — разовая оплата или подписка.',
|
||
paywall_batch_title: 'Пакетная обработка — по подписке',
|
||
paywall_batch_desc: 'Обработка нескольких файлов сразу — по подписке. Оформите подписку, затем загрузите файлы.',
|
||
pay_uploading: 'Загружаем файл…',
|
||
paywall_pay_file: 'Оплатить файл',
|
||
paywall_pay_sub: 'Подписка',
|
||
paywall_month: 'мес',
|
||
paywall_renew_title: 'Продлить подписку',
|
||
paywall_renew_desc: 'Продлите доступ к большим файлам и пакетной обработке.',
|
||
sub_active_title: 'Подписка активна',
|
||
sub_until_label: 'до',
|
||
sub_renew: 'Продлить',
|
||
sub_perk_files: 'Большие файлы без лимита',
|
||
sub_perk_batch: 'Пакетная обработка',
|
||
sub_perk_priority: 'Приоритетная очередь',
|
||
paywall_autorenew: 'Продлевать автоматически каждый месяц',
|
||
sub_autorenew_on: 'Автопродление включено',
|
||
sub_autorenew_off: 'Автопродление выключено',
|
||
sub_autorenew_disable: 'Отключить',
|
||
sub_autorenew_enable: 'Включить',
|
||
paywall_cancel: 'Отмена',
|
||
paywall_need_login: 'Для подписки войдите в аккаунт',
|
||
seo_title: 'Как работает pdf2scan',
|
||
seo_step1_title: 'Загрузите файл',
|
||
seo_step1_desc: 'PDF или Word — перетащите или выберите с устройства',
|
||
seo_step2_title: 'Настройте параметры',
|
||
seo_step2_desc: 'Тип сшивки, цвет, качество, нумерация и другие опции',
|
||
seo_step3_title: 'Скачайте результат',
|
||
seo_step3_desc: 'Готовый PDF со сканом доступен для скачивания 6 часов',
|
||
seo_about: 'pdf2scan — онлайн-инструмент для создания растрового PDF с эффектом сканирования. Файлы до 50 МБ и 100 страниц — бесплатно, без регистрации. Для больших файлов — 99 ₽ за файл или подписка 299 ₽/мес. Сшивка нитками, ленточкой, уголком, пружинкой и дыроколом; нумерация, титульный лист, PDF/A. Идеально для сдачи в архив, нотариусу или госорганы.',
|
||
},
|
||
en: {
|
||
page_title: 'pdf2scan — online PDF scan',
|
||
logo_subtitle: 'Convert PDF to scanned document online',
|
||
login_btn: 'Log in / Sign up',
|
||
login_via_tg: 'Via Telegram',
|
||
login_via_yandex: 'Via Yandex',
|
||
login_via_vk: 'Via VK',
|
||
login_via_email: 'Via email',
|
||
email_login_title: 'Email login',
|
||
email_ph: 'your email',
|
||
email_send_code: 'Get code',
|
||
email_code_sent: 'Code sent to your email. Enter it:',
|
||
email_login_btn: 'Log in',
|
||
email_sending: 'Sending code…',
|
||
email_bad_code: 'Invalid code',
|
||
err_login: 'Login failed. Please try again.',
|
||
logout_btn: 'Logout',
|
||
page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.',
|
||
card_file: 'File',
|
||
drop_title: 'Select PDF or Word',
|
||
drop_hint: 'or drag and drop files here (up to 20)',
|
||
free_limit_note: 'Free up to 50 MB and 100 pages. Larger — 99 ₽ per file or 299 ₽/mo subscription.',
|
||
file_selected: 'File selected',
|
||
batch_files: 'Files',
|
||
files_selected: (n) => `${n} ${n === 1 ? 'file' : 'files'} selected`,
|
||
btn_process_n: (n) => `Process ${n} ${n === 1 ? 'file' : 'files'}`,
|
||
err_too_many: (max) => `Too many files — maximum ${max}. Extra files were not added.`,
|
||
st_uploading: 'Uploading…',
|
||
st_queued: 'Queued',
|
||
st_processing: 'Processing…',
|
||
st_done: 'Ready',
|
||
st_error: 'Error',
|
||
download_all_zip: '⬇ Download all as ZIP',
|
||
another_btn_batch: 'Process more files',
|
||
zip_preparing: 'Zipping…',
|
||
err_zip: 'Failed to create archive',
|
||
card_stitch: 'Binding type',
|
||
stitch_thread: 'Thread',
|
||
stitch_ribbon: 'Ribbon',
|
||
stitch_corner: 'Corner',
|
||
stitch_spiral: 'Spiral',
|
||
stitch_punch: 'Hole punch',
|
||
stitch_none: 'No binding',
|
||
card_extra: 'Options',
|
||
color_mode: 'Color mode',
|
||
color_color: 'Color',
|
||
color_bw: 'B&W',
|
||
quality: 'Quality',
|
||
quality_high: 'High',
|
||
quality_medium: 'Medium',
|
||
quality_low: 'Low',
|
||
title_page: 'Title page',
|
||
page_numbers: 'Page numbers',
|
||
certified_page: 'Certification page',
|
||
pdfa_format: 'PDF/A format',
|
||
ocr_label: 'Recognize text (OCR)',
|
||
ocr_hint: 'The scan becomes searchable — copy and find text',
|
||
cabinet_title: 'My documents',
|
||
cabinet_empty: 'No processed documents yet',
|
||
cabinet_dl: '⬇ Download',
|
||
cabinet_expired: 'expired',
|
||
cabinet_processing: 'processing',
|
||
folder_pick: '📁 Folder',
|
||
auth_required: '🔐 Authorization required',
|
||
auth_required_desc:'Files with more than 100 pages or 50 MB require Telegram login.',
|
||
login_tg: 'Login with Telegram',
|
||
btn_select_file: 'Select file',
|
||
btn_process: 'Process PDF',
|
||
btn_sending: 'Uploading...',
|
||
btn_processing: 'Processing...',
|
||
btn_auth_required: 'Authorization required',
|
||
progress_loading: 'Uploading file...',
|
||
progress_uploaded: 'File uploaded, waiting for processing...',
|
||
progress_processing:'Processing...',
|
||
result_title: 'Done!',
|
||
result_sub: 'Your PDF has been processed and is ready to download.',
|
||
result_timer: '⏱ Link active for 6 hours — download now',
|
||
download_btn: '⬇ Download result',
|
||
another_btn: 'Process another file',
|
||
footer_text: 'convert PDF to scan',
|
||
footer_tg: 'Telegram bot',
|
||
footer_api: 'API for business',
|
||
footer_donate: '🍩 Support',
|
||
err_no_file: 'No file selected',
|
||
err_task_not_found:'Task not found. Please try submitting the file again.',
|
||
err_network: 'Network error while uploading',
|
||
err_timeout: 'Request timed out',
|
||
err_auth_start: 'Failed to start authorization.',
|
||
err_auth_expired: 'Authorization expired. Please try again.',
|
||
err_auth_launch: 'Error launching authorization.',
|
||
err_processing: 'An error occurred during processing.',
|
||
info_auth_waiting: 'Open the Telegram bot and press Start — authorization will happen automatically...',
|
||
queue_text: (pos) => `In queue (position ${pos})`,
|
||
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
|
||
err_server: (code) => `Error ${code}`,
|
||
donate_title: 'Support the project',
|
||
donate_desc: 'If this service is useful to you, please consider supporting it with a donation.',
|
||
donate_desc_heavy: '⚠️ Large files put heavy load on the server. If the service is useful, please support the project.',
|
||
donate_sbp: 'SBP / Ozon Pay',
|
||
donate_yoo: 'YooMoney',
|
||
donate_qr_hint: 'Scan to pay',
|
||
pay_card_btn: '💳 Pay by card',
|
||
pay_email_ph: 'email for receipt',
|
||
pay_need_email: 'Enter a valid email for the receipt',
|
||
pay_bad_amount: 'Invalid amount',
|
||
pay_creating: 'Creating payment…',
|
||
pay_error: 'Failed to create payment',
|
||
pay_thanks: '✅ Thank you for your support! Payment received.',
|
||
pay_thanks_file: 'Payment received! Your file is processing…',
|
||
pay_ok_btn: 'Great',
|
||
pay_canceled: 'Payment canceled',
|
||
paywall_title: 'File exceeds the free limit',
|
||
paywall_desc: 'Free up to 50 MB and 100 pages. Larger files — one-time payment or subscription.',
|
||
paywall_batch_title: 'Batch processing — subscription',
|
||
paywall_batch_desc: 'Processing multiple files at once requires a subscription. Subscribe, then upload your files.',
|
||
pay_uploading: 'Uploading file…',
|
||
paywall_pay_file: 'Pay for file',
|
||
paywall_pay_sub: 'Subscription',
|
||
paywall_month: 'mo',
|
||
paywall_renew_title: 'Renew subscription',
|
||
paywall_renew_desc: 'Extend access to large files and batch processing.',
|
||
sub_active_title: 'Subscription active',
|
||
sub_until_label: 'until',
|
||
sub_renew: 'Renew',
|
||
sub_perk_files: 'Large files, no limit',
|
||
sub_perk_batch: 'Batch processing',
|
||
sub_perk_priority: 'Priority queue',
|
||
paywall_autorenew: 'Renew automatically every month',
|
||
sub_autorenew_on: 'Auto-renewal on',
|
||
sub_autorenew_off: 'Auto-renewal off',
|
||
sub_autorenew_disable: 'Turn off',
|
||
sub_autorenew_enable: 'Turn on',
|
||
paywall_cancel: 'Cancel',
|
||
paywall_need_login: 'Log in to subscribe',
|
||
seo_title: 'How pdf2scan works',
|
||
seo_step1_title: 'Upload your file',
|
||
seo_step1_desc: 'PDF or Word — drag & drop or choose from device',
|
||
seo_step2_title: 'Configure options',
|
||
seo_step2_desc: 'Binding type, color, quality, page numbers and more',
|
||
seo_step3_title: 'Download the result',
|
||
seo_step3_desc: 'Your scanned PDF is ready to download for 6 hours',
|
||
seo_about: 'pdf2scan is an online tool to create raster PDFs with a scan effect. Files up to 50 MB and 100 pages are free, no signup. Larger files: 99 ₽ per file or a 299 ₽/mo subscription. Thread, ribbon, corner, spiral and hole-punch binding; page numbering, title page, PDF/A.',
|
||
},
|
||
de: {
|
||
page_title: 'pdf2scan — Online-PDF-Scan',
|
||
logo_subtitle: 'PDF online in gescannte Dokumente umwandeln',
|
||
login_btn: 'Anmelden / Registrieren',
|
||
login_via_tg: 'Über Telegram',
|
||
login_via_yandex: 'Über Yandex',
|
||
login_via_vk: 'Über VK',
|
||
login_via_email: 'Per E-Mail',
|
||
email_login_title: 'E-Mail-Anmeldung',
|
||
email_ph: 'Ihre E-Mail',
|
||
email_send_code: 'Code erhalten',
|
||
email_code_sent: 'Code an Ihre E-Mail gesendet. Eingeben:',
|
||
email_login_btn: 'Anmelden',
|
||
email_sending: 'Code wird gesendet…',
|
||
email_bad_code: 'Ungültiger Code',
|
||
err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
|
||
logout_btn: 'Abmelden',
|
||
page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.',
|
||
card_file: 'Datei',
|
||
drop_title: 'PDF oder Word auswählen',
|
||
drop_hint: 'oder Dateien hier ablegen (bis zu 20)',
|
||
free_limit_note: 'Kostenlos bis 50 MB und 100 Seiten. Größer — 99 ₽ pro Datei oder Abo 299 ₽/Mon.',
|
||
file_selected: 'Datei ausgewählt',
|
||
batch_files: 'Dateien',
|
||
files_selected: (n) => `${n} ${n === 1 ? 'Datei' : 'Dateien'} ausgewählt`,
|
||
btn_process_n: (n) => `${n} ${n === 1 ? 'Datei' : 'Dateien'} verarbeiten`,
|
||
err_too_many: (max) => `Zu viele Dateien — maximal ${max}. Überzählige wurden nicht hinzugefügt.`,
|
||
st_uploading: 'Hochladen…',
|
||
st_queued: 'In Warteschlange',
|
||
st_processing: 'Verarbeitung…',
|
||
st_done: 'Fertig',
|
||
st_error: 'Fehler',
|
||
download_all_zip: '⬇ Alle als ZIP herunterladen',
|
||
another_btn_batch: 'Weitere Dateien verarbeiten',
|
||
zip_preparing: 'Archivierung…',
|
||
err_zip: 'Archiv konnte nicht erstellt werden',
|
||
card_stitch: 'Bindungstyp',
|
||
stitch_thread: 'Faden',
|
||
stitch_ribbon: 'Band',
|
||
stitch_corner: 'Ecke',
|
||
stitch_spiral: 'Spirale',
|
||
stitch_punch: 'Lochstanzer',
|
||
stitch_none: 'Ohne Bindung',
|
||
card_extra: 'Optionen',
|
||
color_mode: 'Farbmodus',
|
||
color_color: 'Farbe',
|
||
color_bw: 'S/W',
|
||
quality: 'Qualität',
|
||
quality_high: 'Hoch',
|
||
quality_medium: 'Mittel',
|
||
quality_low: 'Niedrig',
|
||
title_page: 'Titelseite',
|
||
page_numbers: 'Seitennummerierung',
|
||
certified_page: 'Zertifizierungsseite',
|
||
pdfa_format: 'PDF/A-Format',
|
||
ocr_label: 'Text erkennen (OCR)',
|
||
ocr_hint: 'Der Scan wird durchsuchbar — Text kopieren und finden',
|
||
cabinet_title: 'Meine Dokumente',
|
||
cabinet_empty: 'Noch keine verarbeiteten Dokumente',
|
||
cabinet_dl: '⬇ Herunterladen',
|
||
cabinet_expired: 'abgelaufen',
|
||
cabinet_processing: 'Verarbeitung',
|
||
folder_pick: '📁 Ordner',
|
||
auth_required: '🔐 Anmeldung erforderlich',
|
||
auth_required_desc:'Dateien mit mehr als 100 Seiten oder 50 MB erfordern eine Telegram-Anmeldung.',
|
||
login_tg: 'Mit Telegram anmelden',
|
||
btn_select_file: 'Datei auswählen',
|
||
btn_process: 'PDF verarbeiten',
|
||
btn_sending: 'Hochladen...',
|
||
btn_processing: 'Verarbeitung...',
|
||
btn_auth_required: 'Anmeldung erforderlich',
|
||
progress_loading: 'Datei wird hochgeladen...',
|
||
progress_uploaded: 'Datei hochgeladen, warte auf Verarbeitung...',
|
||
progress_processing:'Verarbeitung...',
|
||
result_title: 'Fertig!',
|
||
result_sub: 'Ihr PDF wurde verarbeitet und ist zum Download bereit.',
|
||
result_timer: '⏱ Link 6 Stunden aktiv — jetzt herunterladen',
|
||
download_btn: '⬇ Ergebnis herunterladen',
|
||
another_btn: 'Eine weitere Datei verarbeiten',
|
||
footer_text: 'PDF in Scan umwandeln',
|
||
footer_tg: 'Telegram-Bot',
|
||
footer_api: 'API für Unternehmen',
|
||
footer_donate: '🍩 Unterstützen',
|
||
err_no_file: 'Keine Datei ausgewählt',
|
||
err_task_not_found:'Aufgabe nicht gefunden. Bitte versuchen Sie, die Datei erneut zu senden.',
|
||
err_network: 'Netzwerkfehler beim Hochladen',
|
||
err_timeout: 'Zeitüberschreitung',
|
||
err_auth_start: 'Autorisierung konnte nicht gestartet werden.',
|
||
err_auth_expired: 'Autorisierung abgelaufen. Bitte erneut versuchen.',
|
||
err_auth_launch: 'Fehler beim Starten der Autorisierung.',
|
||
err_processing: 'Bei der Verarbeitung ist ein Fehler aufgetreten.',
|
||
info_auth_waiting: 'Öffnen Sie den Telegram-Bot und drücken Sie Start — die Autorisierung erfolgt automatisch...',
|
||
queue_text: (pos) => `In der Warteschlange (Position ${pos})`,
|
||
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
|
||
err_server: (code) => `Fehler ${code}`,
|
||
donate_title: 'Projekt unterstützen',
|
||
donate_desc: 'Wenn dieser Dienst nützlich ist, unterstützen Sie ihn bitte mit einer Spende.',
|
||
donate_desc_heavy: '⚠️ Große Dateien belasten den Server stark. Wenn der Dienst nützlich ist, unterstützen Sie das Projekt.',
|
||
donate_sbp: 'SBP / Ozon Pay',
|
||
donate_yoo: 'YooMoney',
|
||
donate_qr_hint: 'Zum Bezahlen scannen',
|
||
pay_card_btn: '💳 Mit Karte zahlen',
|
||
pay_email_ph: 'E-Mail für Beleg',
|
||
pay_need_email: 'Gültige E-Mail für den Beleg angeben',
|
||
pay_bad_amount: 'Ungültiger Betrag',
|
||
pay_creating: 'Zahlung wird erstellt…',
|
||
pay_error: 'Zahlung konnte nicht erstellt werden',
|
||
pay_thanks: '✅ Danke für die Unterstützung! Zahlung erhalten.',
|
||
pay_thanks_file: 'Zahlung erhalten! Datei wird verarbeitet…',
|
||
pay_ok_btn: 'Super',
|
||
pay_canceled: 'Zahlung storniert',
|
||
paywall_title: 'Datei über dem Gratis-Limit',
|
||
paywall_desc: 'Kostenlos bis 50 MB und 100 Seiten. Größere Dateien — Einmalzahlung oder Abo.',
|
||
paywall_batch_title: 'Stapelverarbeitung — im Abo',
|
||
paywall_batch_desc: 'Mehrere Dateien gleichzeitig erfordern ein Abo. Abonnieren Sie und laden Sie dann Ihre Dateien hoch.',
|
||
pay_uploading: 'Datei wird hochgeladen…',
|
||
paywall_pay_file: 'Datei bezahlen',
|
||
paywall_pay_sub: 'Abo',
|
||
paywall_month: 'Mon',
|
||
paywall_renew_title: 'Abo verlängern',
|
||
paywall_renew_desc: 'Verlängern Sie den Zugriff auf große Dateien und Stapelverarbeitung.',
|
||
sub_active_title: 'Abo aktiv',
|
||
sub_until_label: 'bis',
|
||
sub_renew: 'Verlängern',
|
||
sub_perk_files: 'Große Dateien ohne Limit',
|
||
sub_perk_batch: 'Stapelverarbeitung',
|
||
sub_perk_priority: 'Prioritäts-Warteschlange',
|
||
paywall_autorenew: 'Automatisch monatlich verlängern',
|
||
sub_autorenew_on: 'Automatische Verlängerung an',
|
||
sub_autorenew_off: 'Automatische Verlängerung aus',
|
||
sub_autorenew_disable: 'Ausschalten',
|
||
sub_autorenew_enable: 'Einschalten',
|
||
paywall_cancel: 'Abbrechen',
|
||
paywall_need_login: 'Zum Abonnieren anmelden',
|
||
seo_title: 'So funktioniert pdf2scan',
|
||
seo_step1_title: 'Datei hochladen',
|
||
seo_step1_desc: 'PDF oder Word — per Drag & Drop oder vom Gerät',
|
||
seo_step2_title: 'Optionen einstellen',
|
||
seo_step2_desc: 'Bindungstyp, Farbe, Qualität, Seitennummern und mehr',
|
||
seo_step3_title: 'Ergebnis herunterladen',
|
||
seo_step3_desc: 'Ihr gescanntes PDF steht 6 Stunden zum Download bereit',
|
||
seo_about: 'pdf2scan ist ein Online-Tool für Raster-PDFs mit Scan-Effekt. Dateien bis 50 MB und 100 Seiten sind kostenlos, ohne Anmeldung. Größere Dateien: 99 ₽ pro Datei oder Abo 299 ₽/Mon. Faden-, Band-, Ecken-, Spiral- und Lochbindung; Nummerierung, Titelseite, PDF/A.',
|
||
},
|
||
fr: {
|
||
page_title: 'pdf2scan — scan PDF en ligne',
|
||
logo_subtitle: 'Convertir des PDF en documents scannés en ligne',
|
||
login_btn: 'Connexion / Inscription',
|
||
login_via_tg: 'Via Telegram',
|
||
login_via_yandex: 'Via Yandex',
|
||
login_via_vk: 'Via VK',
|
||
login_via_email: 'Par e-mail',
|
||
email_login_title: 'Connexion par e-mail',
|
||
email_ph: 'votre e-mail',
|
||
email_send_code: 'Recevoir le code',
|
||
email_code_sent: 'Code envoyé par e-mail. Saisissez-le :',
|
||
email_login_btn: 'Se connecter',
|
||
email_sending: 'Envoi du code…',
|
||
email_bad_code: 'Code invalide',
|
||
err_login: 'Échec de connexion. Réessayez.',
|
||
logout_btn: 'Déconnexion',
|
||
page_description: '🧷 Sélectionnez le type de reliure et les paramètres optionnels, puis téléchargez votre fichier — obtenez un scan prêt à l\'emploi.',
|
||
card_file: 'Fichier',
|
||
drop_title: 'Sélectionner PDF ou Word',
|
||
drop_hint: 'ou déposez les fichiers ici (max. 20)',
|
||
free_limit_note: 'Gratuit : 50 Mo et 100 pages max. Plus grand — 99 ₽ par fichier ou abonnement 299 ₽/mois.',
|
||
file_selected: 'Fichier sélectionné',
|
||
batch_files: 'Fichiers',
|
||
files_selected: (n) => `${n} fichier${n === 1 ? '' : 's'} sélectionné${n === 1 ? '' : 's'}`,
|
||
btn_process_n: (n) => `Traiter ${n} fichier${n === 1 ? '' : 's'}`,
|
||
err_too_many: (max) => `Trop de fichiers — maximum ${max}. Les fichiers en trop n’ont pas été ajoutés.`,
|
||
st_uploading: 'Téléversement…',
|
||
st_queued: 'En file',
|
||
st_processing: 'Traitement…',
|
||
st_done: 'Prêt',
|
||
st_error: 'Erreur',
|
||
download_all_zip: '⬇ Tout télécharger en ZIP',
|
||
another_btn_batch: 'Traiter d’autres fichiers',
|
||
zip_preparing: 'Archivage…',
|
||
err_zip: 'Échec de création de l’archive',
|
||
card_stitch: 'Type de reliure',
|
||
stitch_thread: 'Fil',
|
||
stitch_ribbon: 'Ruban',
|
||
stitch_corner: 'Coin',
|
||
stitch_spiral: 'Spirale',
|
||
stitch_punch: 'Perforateur',
|
||
stitch_none: 'Sans reliure',
|
||
card_extra: 'Options',
|
||
color_mode: 'Mode couleur',
|
||
color_color: 'Couleur',
|
||
color_bw: 'N/B',
|
||
quality: 'Qualité',
|
||
quality_high: 'Haute',
|
||
quality_medium: 'Moyenne',
|
||
quality_low: 'Basse',
|
||
title_page: 'Page de titre',
|
||
page_numbers: 'Numérotation des pages',
|
||
certified_page: 'Page de certification',
|
||
pdfa_format: 'Format PDF/A',
|
||
ocr_label: 'Reconnaître le texte (OCR)',
|
||
ocr_hint: 'Le scan devient consultable — copier et rechercher le texte',
|
||
cabinet_title: 'Mes documents',
|
||
cabinet_empty: 'Aucun document traité',
|
||
cabinet_dl: '⬇ Télécharger',
|
||
cabinet_expired: 'expiré',
|
||
cabinet_processing: 'traitement',
|
||
folder_pick: '📁 Dossier',
|
||
auth_required: '🔐 Autorisation requise',
|
||
auth_required_desc:'Les fichiers de plus de 100 pages ou 50 Mo nécessitent une connexion Telegram.',
|
||
login_tg: 'Se connecter via Telegram',
|
||
btn_select_file: 'Sélectionner un fichier',
|
||
btn_process: 'Traiter le PDF',
|
||
btn_sending: 'Envoi...',
|
||
btn_processing: 'Traitement...',
|
||
btn_auth_required: 'Autorisation requise',
|
||
progress_loading: 'Téléchargement du fichier...',
|
||
progress_uploaded: 'Fichier téléchargé, en attente de traitement...',
|
||
progress_processing:'Traitement...',
|
||
result_title: 'Terminé!',
|
||
result_sub: 'Votre PDF a été traité et est prêt à être téléchargé.',
|
||
result_timer: '⏱ Lien actif pendant 6 heures — téléchargez maintenant',
|
||
download_btn: '⬇ Télécharger le résultat',
|
||
another_btn: 'Traiter un autre fichier',
|
||
footer_text: 'convertir PDF en scan',
|
||
footer_tg: 'Bot Telegram',
|
||
footer_api: 'API pour entreprises',
|
||
footer_donate: '🍩 Soutenir',
|
||
err_no_file: 'Aucun fichier sélectionné',
|
||
err_task_not_found:'Tâche introuvable. Veuillez soumettre le fichier à nouveau.',
|
||
err_network: 'Erreur réseau lors du téléchargement',
|
||
err_timeout: 'Délai d\'attente dépassé',
|
||
err_auth_start: 'Impossible de démarrer l\'autorisation.',
|
||
err_auth_expired: 'Autorisation expirée. Veuillez réessayer.',
|
||
err_auth_launch: 'Erreur lors du lancement de l\'autorisation.',
|
||
err_processing: 'Une erreur s\'est produite lors du traitement.',
|
||
info_auth_waiting: 'Ouvrez le bot Telegram et appuyez sur Démarrer — l\'autorisation se fera automatiquement...',
|
||
queue_text: (pos) => `En attente (position ${pos})`,
|
||
upload_speed: (loaded, total, speed) => `${loaded} / ${total} Mo · ${speed} Mo/s`,
|
||
err_server: (code) => `Erreur ${code}`,
|
||
donate_title: 'Soutenir le projet',
|
||
donate_desc: 'Si ce service vous est utile, soutenez-le avec un don.',
|
||
donate_desc_heavy: '⚠️ Les gros fichiers sollicitent fortement le serveur. Si le service est utile, soutenez le projet.',
|
||
donate_sbp: 'SBP / Ozon Pay',
|
||
donate_yoo: 'YooMoney',
|
||
donate_qr_hint: 'Scannez pour payer',
|
||
pay_card_btn: '💳 Payer par carte',
|
||
pay_email_ph: 'email pour le reçu',
|
||
pay_need_email: 'Saisissez un email valide pour le reçu',
|
||
pay_bad_amount: 'Montant invalide',
|
||
pay_creating: 'Création du paiement…',
|
||
pay_error: 'Échec de création du paiement',
|
||
pay_thanks: '✅ Merci pour votre soutien ! Paiement reçu.',
|
||
pay_thanks_file: 'Paiement reçu ! Le fichier est en traitement…',
|
||
pay_ok_btn: 'Parfait',
|
||
pay_canceled: 'Paiement annulé',
|
||
paywall_title: 'Fichier au-delà de la limite gratuite',
|
||
paywall_desc: 'Gratuit : 50 Mo et 100 pages max. Au-delà — paiement unique ou abonnement.',
|
||
paywall_batch_title: 'Traitement par lots — sur abonnement',
|
||
paywall_batch_desc: 'Traiter plusieurs fichiers à la fois nécessite un abonnement. Abonnez-vous, puis chargez vos fichiers.',
|
||
pay_uploading: 'Téléversement du fichier…',
|
||
paywall_pay_file: 'Payer le fichier',
|
||
paywall_pay_sub: 'Abonnement',
|
||
paywall_month: 'mois',
|
||
paywall_renew_title: 'Renouveler votre abonnement',
|
||
paywall_renew_desc: 'Prolongez votre accès aux gros fichiers et au traitement par lots.',
|
||
sub_active_title: 'Abonnement actif',
|
||
sub_until_label: 'expire le',
|
||
sub_renew: 'Renouveler',
|
||
sub_perk_files: 'Gros fichiers sans limite',
|
||
sub_perk_batch: 'Traitement par lots',
|
||
sub_perk_priority: 'File prioritaire',
|
||
paywall_autorenew: 'Renouveler automatiquement chaque mois',
|
||
sub_autorenew_on: 'Renouvellement automatique activé',
|
||
sub_autorenew_off: 'Renouvellement automatique désactivé',
|
||
sub_autorenew_disable: 'Désactiver',
|
||
sub_autorenew_enable: 'Activer',
|
||
paywall_cancel: 'Annuler',
|
||
paywall_need_login: 'Connectez-vous pour vous abonner',
|
||
seo_title: 'Comment fonctionne pdf2scan',
|
||
seo_step1_title: 'Téléchargez votre fichier',
|
||
seo_step1_desc: 'PDF ou Word — glisser-déposer ou choisir depuis l\'appareil',
|
||
seo_step2_title: 'Configurez les options',
|
||
seo_step2_desc: 'Type de reliure, couleur, qualité, numérotation et plus',
|
||
seo_step3_title: 'Téléchargez le résultat',
|
||
seo_step3_desc: 'Votre PDF scanné est disponible 6 heures au téléchargement',
|
||
seo_about: 'pdf2scan est un outil en ligne pour PDF raster avec effet scan. Fichiers de 50 Mo et 100 pages max : gratuits, sans inscription. Fichiers plus grands : 99 ₽ par fichier ou abonnement 299 ₽/mois. Reliure fil, ruban, coin, spirale, perforateur ; numérotation, page de titre, PDF/A.',
|
||
},
|
||
it: {
|
||
page_title: 'pdf2scan — scansione PDF online',
|
||
logo_subtitle: 'Converti PDF in documenti scansionati online',
|
||
login_btn: 'Accedi / Registrati',
|
||
login_via_tg: 'Via Telegram',
|
||
login_via_yandex: 'Via Yandex',
|
||
login_via_vk: 'Via VK',
|
||
login_via_email: 'Via email',
|
||
email_login_title: 'Accesso via email',
|
||
email_ph: 'la tua email',
|
||
email_send_code: 'Ricevi codice',
|
||
email_code_sent: 'Codice inviato via email. Inseriscilo:',
|
||
email_login_btn: 'Accedi',
|
||
email_sending: 'Invio codice…',
|
||
email_bad_code: 'Codice non valido',
|
||
err_login: 'Accesso non riuscito. Riprova.',
|
||
logout_btn: 'Esci',
|
||
page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.',
|
||
card_file: 'File',
|
||
drop_title: 'Seleziona PDF o Word',
|
||
drop_hint: 'o trascina i file qui (fino a 20)',
|
||
free_limit_note: 'Gratis fino a 50 MB e 100 pagine. Oltre — 99 ₽ a file o abbonamento 299 ₽/mese.',
|
||
file_selected: 'File selezionato',
|
||
batch_files: 'File',
|
||
files_selected: (n) => `${n} file selezionat${n === 1 ? 'o' : 'i'}`,
|
||
btn_process_n: (n) => `Elabora ${n} file`,
|
||
err_too_many: (max) => `Troppi file — massimo ${max}. I file in eccesso non sono stati aggiunti.`,
|
||
st_uploading: 'Caricamento…',
|
||
st_queued: 'In coda',
|
||
st_processing: 'Elaborazione…',
|
||
st_done: 'Pronto',
|
||
st_error: 'Errore',
|
||
download_all_zip: '⬇ Scarica tutto in ZIP',
|
||
another_btn_batch: 'Elabora altri file',
|
||
zip_preparing: 'Compressione…',
|
||
err_zip: 'Impossibile creare l’archivio',
|
||
card_stitch: 'Tipo di rilegatura',
|
||
stitch_thread: 'Filo',
|
||
stitch_ribbon: 'Nastro',
|
||
stitch_corner: 'Angolo',
|
||
stitch_spiral: 'Spirale',
|
||
stitch_punch: 'Perforatore',
|
||
stitch_none: 'Senza rilegatura',
|
||
card_extra: 'Opzioni',
|
||
color_mode: 'Modalità colore',
|
||
color_color: 'Colore',
|
||
color_bw: 'B/N',
|
||
quality: 'Qualità',
|
||
quality_high: 'Alta',
|
||
quality_medium: 'Media',
|
||
quality_low: 'Bassa',
|
||
title_page: 'Pagina del titolo',
|
||
page_numbers: 'Numerazione pagine',
|
||
certified_page: 'Pagina di certificazione',
|
||
pdfa_format: 'Formato PDF/A',
|
||
ocr_label: 'Riconosci testo (OCR)',
|
||
ocr_hint: 'La scansione diventa ricercabile — copia e cerca il testo',
|
||
cabinet_title: 'I miei documenti',
|
||
cabinet_empty: 'Nessun documento elaborato',
|
||
cabinet_dl: '⬇ Scarica',
|
||
cabinet_expired: 'scaduto',
|
||
cabinet_processing: 'elaborazione',
|
||
folder_pick: '📁 Cartella',
|
||
auth_required: '🔐 Autorizzazione richiesta',
|
||
auth_required_desc:'I file con più di 100 pagine o 50 MB richiedono il login Telegram.',
|
||
login_tg: 'Accedi con Telegram',
|
||
btn_select_file: 'Seleziona file',
|
||
btn_process: 'Elabora PDF',
|
||
btn_sending: 'Caricamento...',
|
||
btn_processing: 'Elaborazione...',
|
||
btn_auth_required: 'Autorizzazione richiesta',
|
||
progress_loading: 'Caricamento file...',
|
||
progress_uploaded: 'File caricato, in attesa di elaborazione...',
|
||
progress_processing:'Elaborazione...',
|
||
result_title: 'Pronto!',
|
||
result_sub: 'Il tuo PDF è stato elaborato ed è pronto per il download.',
|
||
result_timer: '⏱ Link attivo per 6 ore — scarica ora',
|
||
download_btn: '⬇ Scarica il risultato',
|
||
another_btn: 'Elabora un altro file',
|
||
footer_text: 'converti PDF in scansione',
|
||
footer_tg: 'Bot Telegram',
|
||
footer_api: 'API per aziende',
|
||
footer_donate: '🍩 Sostieni',
|
||
err_no_file: 'Nessun file selezionato',
|
||
err_task_not_found:'Attività non trovata. Prova a inviare nuovamente il file.',
|
||
err_network: 'Errore di rete durante il caricamento',
|
||
err_timeout: 'Timeout della richiesta',
|
||
err_auth_start: 'Impossibile avviare l\'autorizzazione.',
|
||
err_auth_expired: 'Autorizzazione scaduta. Riprova.',
|
||
err_auth_launch: 'Errore nell\'avvio dell\'autorizzazione.',
|
||
err_processing: 'Si è verificato un errore durante l\'elaborazione.',
|
||
info_auth_waiting: 'Apri il bot Telegram e premi Start — l\'autorizzazione avverrà automaticamente...',
|
||
queue_text: (pos) => `In coda (posizione ${pos})`,
|
||
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
|
||
err_server: (code) => `Errore ${code}`,
|
||
donate_title: 'Sostieni il progetto',
|
||
donate_desc: 'Se questo servizio ti è utile, considera di supportarlo con una donazione.',
|
||
donate_desc_heavy: '⚠️ I file grandi caricano molto il server. Se il servizio è utile, sostieni il progetto.',
|
||
donate_sbp: 'SBP / Ozon Pay',
|
||
donate_yoo: 'YooMoney',
|
||
donate_qr_hint: 'Scansiona per pagare',
|
||
pay_card_btn: '💳 Paga con carta',
|
||
pay_email_ph: 'email per ricevuta',
|
||
pay_need_email: 'Inserisci un email valido per la ricevuta',
|
||
pay_bad_amount: 'Importo non valido',
|
||
pay_creating: 'Creazione pagamento…',
|
||
pay_error: 'Impossibile creare il pagamento',
|
||
pay_thanks: '✅ Grazie per il supporto! Pagamento ricevuto.',
|
||
pay_thanks_file: 'Pagamento ricevuto! Il file è in elaborazione…',
|
||
pay_ok_btn: 'Ottimo',
|
||
pay_canceled: 'Pagamento annullato',
|
||
paywall_title: 'File oltre il limite gratuito',
|
||
paywall_desc: 'Gratis fino a 50 MB e 100 pagine. File più grandi — pagamento singolo o abbonamento.',
|
||
paywall_batch_title: 'Elaborazione multipla — in abbonamento',
|
||
paywall_batch_desc: 'Elaborare più file insieme richiede un abbonamento. Abbonati e poi carica i file.',
|
||
pay_uploading: 'Caricamento file…',
|
||
paywall_pay_file: 'Paga file',
|
||
paywall_pay_sub: 'Abbonamento',
|
||
paywall_month: 'mese',
|
||
paywall_renew_title: 'Rinnova abbonamento',
|
||
paywall_renew_desc: 'Estendi accesso ai file grandi e alla elaborazione multipla.',
|
||
sub_active_title: 'Abbonamento attivo',
|
||
sub_until_label: 'fino al',
|
||
sub_renew: 'Rinnova',
|
||
sub_perk_files: 'File grandi senza limiti',
|
||
sub_perk_batch: 'Elaborazione multipla',
|
||
sub_perk_priority: 'Coda prioritaria',
|
||
paywall_autorenew: 'Rinnova automaticamente ogni mese',
|
||
sub_autorenew_on: 'Rinnovo automatico attivo',
|
||
sub_autorenew_off: 'Rinnovo automatico disattivato',
|
||
sub_autorenew_disable: 'Disattiva',
|
||
sub_autorenew_enable: 'Attiva',
|
||
paywall_cancel: 'Annulla',
|
||
paywall_need_login: 'Accedi per abbonarti',
|
||
seo_title: 'Come funziona pdf2scan',
|
||
seo_step1_title: 'Carica il file',
|
||
seo_step1_desc: 'PDF o Word — trascina o scegli dal dispositivo',
|
||
seo_step2_title: 'Configura le opzioni',
|
||
seo_step2_desc: 'Tipo di rilegatura, colore, qualità, numerazione e altro',
|
||
seo_step3_title: 'Scarica il risultato',
|
||
seo_step3_desc: 'Il tuo PDF scansionato è disponibile per 6 ore',
|
||
seo_about: 'pdf2scan è uno strumento online per PDF raster con effetto scansione. File fino a 50 MB e 100 pagine gratis, senza registrazione. File più grandi: 99 ₽ a file o abbonamento 299 ₽/mese. Rilegatura filo, nastro, angolo, spirale, perforatore; numerazione, pagina del titolo, PDF/A.',
|
||
},
|
||
es: {
|
||
page_title: 'pdf2scan — escaneo PDF en línea',
|
||
logo_subtitle: 'Convierte PDF en documentos escaneados en línea',
|
||
login_btn: 'Entrar / Registro',
|
||
login_via_tg: 'Con Telegram',
|
||
login_via_yandex: 'Con Yandex',
|
||
login_via_vk: 'Con VK',
|
||
login_via_email: 'Por correo',
|
||
email_login_title: 'Acceso por correo',
|
||
email_ph: 'tu correo',
|
||
email_send_code: 'Obtener código',
|
||
email_code_sent: 'Código enviado a tu correo. Ingrésalo:',
|
||
email_login_btn: 'Entrar',
|
||
email_sending: 'Enviando código…',
|
||
email_bad_code: 'Código inválido',
|
||
err_login: 'Error de inicio de sesión. Inténtalo de nuevo.',
|
||
logout_btn: 'Cerrar sesión',
|
||
page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.',
|
||
card_file: 'Archivo',
|
||
drop_title: 'Seleccionar PDF o Word',
|
||
drop_hint: 'o arrastra los archivos aquí (hasta 20)',
|
||
free_limit_note: 'Gratis hasta 50 MB y 100 páginas. Más — 99 ₽ por archivo o suscripción 299 ₽/mes.',
|
||
file_selected: 'Archivo seleccionado',
|
||
batch_files: 'Archivos',
|
||
files_selected: (n) => `${n} archivo${n === 1 ? '' : 's'} seleccionado${n === 1 ? '' : 's'}`,
|
||
btn_process_n: (n) => `Procesar ${n} archivo${n === 1 ? '' : 's'}`,
|
||
err_too_many: (max) => `Demasiados archivos — máximo ${max}. Los archivos sobrantes no se añadieron.`,
|
||
st_uploading: 'Subiendo…',
|
||
st_queued: 'En cola',
|
||
st_processing: 'Procesando…',
|
||
st_done: 'Listo',
|
||
st_error: 'Error',
|
||
download_all_zip: '⬇ Descargar todo en ZIP',
|
||
another_btn_batch: 'Procesar más archivos',
|
||
zip_preparing: 'Comprimiendo…',
|
||
err_zip: 'No se pudo crear el archivo',
|
||
card_stitch: 'Tipo de encuadernación',
|
||
stitch_thread: 'Hilo',
|
||
stitch_ribbon: 'Cinta',
|
||
stitch_corner: 'Esquina',
|
||
stitch_spiral: 'Espiral',
|
||
stitch_punch: 'Perforadora',
|
||
stitch_none: 'Sin encuadernación',
|
||
card_extra: 'Opciones',
|
||
color_mode: 'Modo de color',
|
||
color_color: 'Color',
|
||
color_bw: 'B/N',
|
||
quality: 'Calidad',
|
||
quality_high: 'Alta',
|
||
quality_medium: 'Media',
|
||
quality_low: 'Baja',
|
||
title_page: 'Página de título',
|
||
page_numbers: 'Numeración de páginas',
|
||
certified_page: 'Página de certificación',
|
||
pdfa_format: 'Formato PDF/A',
|
||
ocr_label: 'Reconocer texto (OCR)',
|
||
ocr_hint: 'El escaneo se vuelve buscable — copia y busca texto',
|
||
cabinet_title: 'Mis documentos',
|
||
cabinet_empty: 'Aún no hay documentos procesados',
|
||
cabinet_dl: '⬇ Descargar',
|
||
cabinet_expired: 'expirado',
|
||
cabinet_processing: 'procesando',
|
||
folder_pick: '📁 Carpeta',
|
||
auth_required: '🔐 Autorización requerida',
|
||
auth_required_desc:'Los archivos con más de 100 páginas o 50 MB requieren inicio de sesión en Telegram.',
|
||
login_tg: 'Iniciar sesión con Telegram',
|
||
btn_select_file: 'Seleccionar archivo',
|
||
btn_process: 'Procesar PDF',
|
||
btn_sending: 'Enviando...',
|
||
btn_processing: 'Procesando...',
|
||
btn_auth_required: 'Autorización requerida',
|
||
progress_loading: 'Subiendo archivo...',
|
||
progress_uploaded: 'Archivo subido, esperando procesamiento...',
|
||
progress_processing:'Procesando...',
|
||
result_title: '¡Listo!',
|
||
result_sub: 'Tu PDF ha sido procesado y está listo para descargar.',
|
||
result_timer: '⏱ Enlace activo por 6 horas — descarga ahora',
|
||
download_btn: '⬇ Descargar resultado',
|
||
another_btn: 'Procesar otro archivo',
|
||
footer_text: 'convierte PDF en escaneo',
|
||
footer_tg: 'Bot de Telegram',
|
||
footer_api: 'API para empresas',
|
||
footer_donate: '🍩 Apoyar',
|
||
err_no_file: 'Ningún archivo seleccionado',
|
||
err_task_not_found:'Tarea no encontrada. Intenta enviar el archivo de nuevo.',
|
||
err_network: 'Error de red al subir',
|
||
err_timeout: 'Tiempo de espera agotado',
|
||
err_auth_start: 'No se pudo iniciar la autorización.',
|
||
err_auth_expired: 'Autorización expirada. Inténtalo de nuevo.',
|
||
err_auth_launch: 'Error al iniciar la autorización.',
|
||
err_processing: 'Se produjo un error durante el procesamiento.',
|
||
info_auth_waiting: 'Abre el bot de Telegram y pulsa Iniciar — la autorización ocurrirá automáticamente...',
|
||
queue_text: (pos) => `En cola (posición ${pos})`,
|
||
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
|
||
err_server: (code) => `Error ${code}`,
|
||
donate_title: 'Apoya el proyecto',
|
||
donate_desc: 'Si este servicio te es útil, considera apoyarlo con una donación.',
|
||
donate_desc_heavy: '⚠️ Los archivos grandes sobrecargan el servidor. Si el servicio es útil, apoya el proyecto.',
|
||
donate_sbp: 'SBP / Ozon Pay',
|
||
donate_yoo: 'YooMoney',
|
||
donate_qr_hint: 'Escanea para pagar',
|
||
pay_card_btn: '💳 Pagar con tarjeta',
|
||
pay_email_ph: 'email para recibo',
|
||
pay_need_email: 'Ingresa un email válido para el recibo',
|
||
pay_bad_amount: 'Importe inválido',
|
||
pay_creating: 'Creando pago…',
|
||
pay_error: 'No se pudo crear el pago',
|
||
pay_thanks: '✅ ¡Gracias por tu apoyo! Pago recibido.',
|
||
pay_thanks_file: 'Pago recibido! El archivo se está procesando…',
|
||
pay_ok_btn: 'Genial',
|
||
pay_canceled: 'Pago cancelado',
|
||
paywall_title: 'Archivo supera el límite gratis',
|
||
paywall_desc: 'Gratis hasta 50 MB y 100 páginas. Archivos mayores — pago único o suscripción.',
|
||
paywall_batch_title: 'Procesamiento por lotes — con suscripción',
|
||
paywall_batch_desc: 'Procesar varios archivos a la vez requiere suscripción. Suscríbete y luego sube tus archivos.',
|
||
pay_uploading: 'Subiendo archivo…',
|
||
paywall_pay_file: 'Pagar archivo',
|
||
paywall_pay_sub: 'Suscripción',
|
||
paywall_month: 'mes',
|
||
paywall_renew_title: 'Renovar suscripción',
|
||
paywall_renew_desc: 'Amplía el acceso a archivos grandes y al procesamiento por lotes.',
|
||
sub_active_title: 'Suscripción activa',
|
||
sub_until_label: 'hasta el',
|
||
sub_renew: 'Renovar',
|
||
sub_perk_files: 'Archivos grandes sin límite',
|
||
sub_perk_batch: 'Procesamiento por lotes',
|
||
sub_perk_priority: 'Cola prioritaria',
|
||
paywall_autorenew: 'Renovar automáticamente cada mes',
|
||
sub_autorenew_on: 'Renovación automática activada',
|
||
sub_autorenew_off: 'Renovación automática desactivada',
|
||
sub_autorenew_disable: 'Desactivar',
|
||
sub_autorenew_enable: 'Activar',
|
||
paywall_cancel: 'Cancelar',
|
||
paywall_need_login: 'Inicia sesión para suscribirte',
|
||
seo_title: 'Cómo funciona pdf2scan',
|
||
seo_step1_title: 'Sube tu archivo',
|
||
seo_step1_desc: 'PDF o Word — arrastra o elige desde el dispositivo',
|
||
seo_step2_title: 'Configura las opciones',
|
||
seo_step2_desc: 'Tipo de encuadernación, color, calidad, numeración y más',
|
||
seo_step3_title: 'Descarga el resultado',
|
||
seo_step3_desc: 'Tu PDF escaneado está disponible 6 horas para descargar',
|
||
seo_about: 'pdf2scan es una herramienta online para PDF rasterizados con efecto de escaneo. Archivos de hasta 50 MB y 100 páginas gratis, sin registro. Archivos mayores: 99 ₽ por archivo o suscripción 299 ₽/mes. Encuadernación hilo, cinta, esquina, espiral, perforadora; numeración, página de título, PDF/A.',
|
||
},
|
||
pt: {
|
||
page_title: 'pdf2scan — digitalização PDF online',
|
||
logo_subtitle: 'Converta PDF em documentos digitalizados online',
|
||
login_btn: 'Entrar / Registrar',
|
||
login_via_tg: 'Via Telegram',
|
||
login_via_yandex: 'Via Yandex',
|
||
login_via_vk: 'Via VK',
|
||
login_via_email: 'Por e-mail',
|
||
email_login_title: 'Entrar por e-mail',
|
||
email_ph: 'seu e-mail',
|
||
email_send_code: 'Receber código',
|
||
email_code_sent: 'Código enviado ao seu e-mail. Digite:',
|
||
email_login_btn: 'Entrar',
|
||
email_sending: 'Enviando código…',
|
||
email_bad_code: 'Código inválido',
|
||
err_login: 'Falha no login. Tente novamente.',
|
||
logout_btn: 'Sair',
|
||
page_description: '🧷 Selecione o tipo de encadernação e configurações opcionais, depois faça upload do arquivo — obtenha uma digitalização pronta.',
|
||
card_file: 'Arquivo',
|
||
drop_title: 'Selecionar PDF ou Word',
|
||
drop_hint: 'ou arraste os arquivos aqui (até 20)',
|
||
free_limit_note: 'Grátis até 50 MB e 100 páginas. Maior — 99 ₽ por arquivo ou assinatura 299 ₽/mês.',
|
||
file_selected: 'Arquivo selecionado',
|
||
batch_files: 'Arquivos',
|
||
files_selected: (n) => `${n} arquivo${n === 1 ? '' : 's'} selecionado${n === 1 ? '' : 's'}`,
|
||
btn_process_n: (n) => `Processar ${n} arquivo${n === 1 ? '' : 's'}`,
|
||
err_too_many: (max) => `Arquivos demais — máximo ${max}. Os arquivos extras não foram adicionados.`,
|
||
st_uploading: 'Enviando…',
|
||
st_queued: 'Na fila',
|
||
st_processing: 'Processando…',
|
||
st_done: 'Pronto',
|
||
st_error: 'Erro',
|
||
download_all_zip: '⬇ Baixar tudo em ZIP',
|
||
another_btn_batch: 'Processar mais arquivos',
|
||
zip_preparing: 'Compactando…',
|
||
err_zip: 'Não foi possível criar o arquivo',
|
||
card_stitch: 'Tipo de encadernação',
|
||
stitch_thread: 'Fio',
|
||
stitch_ribbon: 'Fita',
|
||
stitch_corner: 'Canto',
|
||
stitch_spiral: 'Espiral',
|
||
stitch_punch: 'Furador',
|
||
stitch_none: 'Sem encadernação',
|
||
card_extra: 'Opções',
|
||
color_mode: 'Modo de cor',
|
||
color_color: 'Colorido',
|
||
color_bw: 'P/B',
|
||
quality: 'Qualidade',
|
||
quality_high: 'Alta',
|
||
quality_medium: 'Média',
|
||
quality_low: 'Baixa',
|
||
title_page: 'Página de título',
|
||
page_numbers: 'Numeração de páginas',
|
||
certified_page: 'Página de certificação',
|
||
pdfa_format: 'Formato PDF/A',
|
||
ocr_label: 'Reconhecer texto (OCR)',
|
||
ocr_hint: 'A digitalização fica pesquisável — copie e encontre texto',
|
||
cabinet_title: 'Meus documentos',
|
||
cabinet_empty: 'Ainda não há documentos processados',
|
||
cabinet_dl: '⬇ Baixar',
|
||
cabinet_expired: 'expirado',
|
||
cabinet_processing: 'processando',
|
||
folder_pick: '📁 Pasta',
|
||
auth_required: '🔐 Autorização necessária',
|
||
auth_required_desc:'Arquivos com mais de 100 páginas ou 50 MB requerem login no Telegram.',
|
||
login_tg: 'Entrar com Telegram',
|
||
btn_select_file: 'Selecionar arquivo',
|
||
btn_process: 'Processar PDF',
|
||
btn_sending: 'Enviando...',
|
||
btn_processing: 'Processando...',
|
||
btn_auth_required: 'Autorização necessária',
|
||
progress_loading: 'Fazendo upload...',
|
||
progress_uploaded: 'Arquivo enviado, aguardando processamento...',
|
||
progress_processing:'Processando...',
|
||
result_title: 'Pronto!',
|
||
result_sub: 'Seu PDF foi processado e está pronto para download.',
|
||
result_timer: '⏱ Link ativo por 6 horas — baixe agora',
|
||
download_btn: '⬇ Baixar resultado',
|
||
another_btn: 'Processar outro arquivo',
|
||
footer_text: 'converta PDF em digitalização',
|
||
footer_tg: 'Bot do Telegram',
|
||
footer_api: 'API para empresas',
|
||
footer_donate: '🍩 Apoiar',
|
||
err_no_file: 'Nenhum arquivo selecionado',
|
||
err_task_not_found:'Tarefa não encontrada. Tente enviar o arquivo novamente.',
|
||
err_network: 'Erro de rede ao fazer upload',
|
||
err_timeout: 'Tempo limite esgotado',
|
||
err_auth_start: 'Não foi possível iniciar a autorização.',
|
||
err_auth_expired: 'Autorização expirada. Tente novamente.',
|
||
err_auth_launch: 'Erro ao iniciar a autorização.',
|
||
err_processing: 'Ocorreu um erro durante o processamento.',
|
||
info_auth_waiting: 'Abra o bot do Telegram e pressione Iniciar — a autorização ocorrerá automaticamente...',
|
||
queue_text: (pos) => `Na fila (posição ${pos})`,
|
||
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
|
||
err_server: (code) => `Erro ${code}`,
|
||
donate_title: 'Apoie o projeto',
|
||
donate_desc: 'Se este serviço é útil para você, considere apoiá-lo com uma doação.',
|
||
donate_desc_heavy: '⚠️ Arquivos grandes sobrecarregam o servidor. Se o serviço é útil, apoie o projeto.',
|
||
donate_sbp: 'SBP / Ozon Pay',
|
||
donate_yoo: 'YooMoney',
|
||
donate_qr_hint: 'Escaneie para pagar',
|
||
pay_card_btn: '💳 Pagar com cartão',
|
||
pay_email_ph: 'email para recibo',
|
||
pay_need_email: 'Informe um email válido para o recibo',
|
||
pay_bad_amount: 'Valor inválido',
|
||
pay_creating: 'Criando pagamento…',
|
||
pay_error: 'Falha ao criar o pagamento',
|
||
pay_thanks: '✅ Obrigado pelo apoio! Pagamento recebido.',
|
||
pay_thanks_file: 'Pagamento recebido! O arquivo está sendo processado…',
|
||
pay_ok_btn: 'Ótimo',
|
||
pay_canceled: 'Pagamento cancelado',
|
||
paywall_title: 'Arquivo acima do limite grátis',
|
||
paywall_desc: 'Grátis até 50 MB e 100 páginas. Arquivos maiores — pagamento único ou assinatura.',
|
||
paywall_batch_title: 'Processamento em lote — por assinatura',
|
||
paywall_batch_desc: 'Processar vários arquivos de uma vez requer assinatura. Assine e depois envie seus arquivos.',
|
||
pay_uploading: 'Enviando arquivo…',
|
||
paywall_pay_file: 'Pagar arquivo',
|
||
paywall_pay_sub: 'Assinatura',
|
||
paywall_month: 'mês',
|
||
paywall_renew_title: 'Renovar assinatura',
|
||
paywall_renew_desc: 'Amplie o acesso a arquivos grandes e ao processamento em lote.',
|
||
sub_active_title: 'Assinatura ativa',
|
||
sub_until_label: 'até',
|
||
sub_renew: 'Renovar',
|
||
sub_perk_files: 'Arquivos grandes sem limite',
|
||
sub_perk_batch: 'Processamento em lote',
|
||
sub_perk_priority: 'Fila prioritária',
|
||
paywall_autorenew: 'Renovar automaticamente todo mês',
|
||
sub_autorenew_on: 'Renovação automática ativada',
|
||
sub_autorenew_off: 'Renovação automática desativada',
|
||
sub_autorenew_disable: 'Desativar',
|
||
sub_autorenew_enable: 'Ativar',
|
||
paywall_cancel: 'Cancelar',
|
||
paywall_need_login: 'Entre para assinar',
|
||
seo_title: 'Como funciona o pdf2scan',
|
||
seo_step1_title: 'Envie seu arquivo',
|
||
seo_step1_desc: 'PDF ou Word — arraste ou escolha do dispositivo',
|
||
seo_step2_title: 'Configure as opções',
|
||
seo_step2_desc: 'Tipo de encadernação, cor, qualidade, numeração e mais',
|
||
seo_step3_title: 'Baixe o resultado',
|
||
seo_step3_desc: 'Seu PDF digitalizado fica disponível por 6 horas',
|
||
seo_about: 'pdf2scan é uma ferramenta online para PDF rasterizados com efeito de digitalização. Arquivos até 50 MB e 100 páginas grátis, sem cadastro. Arquivos maiores: 99 ₽ por arquivo ou assinatura 299 ₽/mês. Encadernação fio, fita, canto, espiral, furador; numeração, página de título, PDF/A.',
|
||
},
|
||
};
|
||
|
||
function pluralizeRu(n, f1, f2, f5) {
|
||
const n10 = n % 10, n100 = n % 100;
|
||
if (n10 === 1 && n100 !== 11) return f1;
|
||
if (n10 >= 2 && n10 <= 4 && (n100 < 10 || n100 >= 20)) return f2;
|
||
return f5;
|
||
}
|
||
|
||
// ── Language management ───────────────────────────────────────────────────
|
||
// Язык из URL (серверный data-prelang для /en/, /de/… — важно для SEO) имеет
|
||
// приоритет над сохранённым выбором, чтобы языковая страница показывалась на своём языке.
|
||
const PRELANG = document.documentElement.getAttribute('data-prelang');
|
||
let currentLang = (PRELANG && TR[PRELANG])
|
||
? PRELANG
|
||
: (localStorage.getItem('pdf2scan_lang') || detectLang());
|
||
|
||
function detectLang() {
|
||
const nav = navigator.language || navigator.userLanguage || 'ru';
|
||
const code = nav.slice(0, 2).toLowerCase();
|
||
return TR[code] ? code : 'ru';
|
||
}
|
||
|
||
function t(key) {
|
||
const tr = TR[currentLang] || TR['ru'];
|
||
return tr[key] !== undefined ? tr[key] : (TR['ru'][key] || key);
|
||
}
|
||
|
||
function applyTranslations() {
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n');
|
||
const val = t(key);
|
||
if (typeof val === 'string') el.textContent = val;
|
||
});
|
||
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
||
const val = t(el.getAttribute('data-i18n-ph'));
|
||
if (typeof val === 'string') el.setAttribute('placeholder', val);
|
||
});
|
||
document.title = t('page_title');
|
||
document.documentElement.lang = currentLang;
|
||
// Update flag button
|
||
const flagEl = document.getElementById('lang-flag-img');
|
||
flagEl.src = LANGS[currentLang].img;
|
||
flagEl.alt = LANGS[currentLang].alt;
|
||
// Re-render donate desc if visible
|
||
const donateCard = document.getElementById('donate-card');
|
||
if (donateCard && donateCard.style.display !== 'none') {
|
||
const isHeavy = filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10;
|
||
donateCard.querySelector('.donate-desc').textContent = t(isHeavy ? 'donate_desc_heavy' : 'donate_desc');
|
||
}
|
||
// Highlight active language in dropdown
|
||
document.querySelectorAll('.lang-option').forEach(btn => {
|
||
const lang = btn.getAttribute('onclick').match(/setLang\('(\w+)'\)/)[1];
|
||
btn.classList.toggle('active', lang === currentLang);
|
||
});
|
||
// Re-render dynamic button state
|
||
updateSubmitBtn();
|
||
// Re-render subscription badge/card in the new language
|
||
if (subUntilTs) renderSubStatus({ active: subUntilTs > (Date.now() / 1000), until: subUntilTs });
|
||
}
|
||
|
||
function setLang(lang) {
|
||
if (!TR[lang]) return;
|
||
currentLang = lang;
|
||
localStorage.setItem('pdf2scan_lang', lang);
|
||
closeLangDropdown();
|
||
applyTranslations();
|
||
}
|
||
|
||
function toggleLangDropdown(e) {
|
||
e.stopPropagation();
|
||
document.getElementById('lang-dropdown').classList.toggle('open');
|
||
}
|
||
|
||
function closeLangDropdown() {
|
||
document.getElementById('lang-dropdown').classList.remove('open');
|
||
}
|
||
|
||
document.addEventListener('click', closeLangDropdown);
|
||
|
||
// ── Donate ────────────────────────────────────────────────────────────────
|
||
const DONATE_PAGE_THRESHOLD = 20;
|
||
|
||
function showDonateCard(heavy) {
|
||
const card = document.getElementById('donate-card');
|
||
const desc = card.querySelector('.donate-desc');
|
||
desc.textContent = t(heavy ? 'donate_desc_heavy' : 'donate_desc');
|
||
card.style.display = 'block';
|
||
}
|
||
|
||
function hideDonateCard() {
|
||
document.getElementById('donate-card').style.display = 'none';
|
||
}
|
||
|
||
// ── Оплата картой (ЮKassa) ────────────────────────────────────────────────
|
||
let payAmount = 300;
|
||
function setPayAmt(v, el) {
|
||
payAmount = v;
|
||
const c = document.getElementById('pay-amt-custom'); if (c) c.value = '';
|
||
document.querySelectorAll('.pay-amt').forEach(b => b.classList.remove('active'));
|
||
if (el) el.classList.add('active');
|
||
}
|
||
function onCustomAmt() {
|
||
const v = parseFloat(document.getElementById('pay-amt-custom').value);
|
||
if (v > 0) { payAmount = v; document.querySelectorAll('.pay-amt').forEach(b => b.classList.remove('active')); }
|
||
}
|
||
async function payDonate() {
|
||
const email = (document.getElementById('pay-email').value || '').trim();
|
||
const msg = document.getElementById('pay-msg');
|
||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; }
|
||
if (!payAmount || payAmount < 10) { msg.textContent = t('pay_bad_amount'); return; }
|
||
const btn = document.getElementById('pay-card-btn'); btn.disabled = true; msg.textContent = t('pay_creating');
|
||
try {
|
||
const r = await fetch('/api/pay/create', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ amount: payAmount, purpose: 'donate', email })
|
||
});
|
||
const d = await r.json();
|
||
if (d.confirmation_url) {
|
||
localStorage.setItem('pdf2scan_pay_id', d.payment_id);
|
||
location.href = d.confirmation_url;
|
||
} else { msg.textContent = d.detail || t('pay_error'); btn.disabled = false; }
|
||
} catch (e) { msg.textContent = t('pay_error'); btn.disabled = false; }
|
||
}
|
||
|
||
// ── Пейволл больших файлов / батча ────────────────────────────────────────
|
||
let paywallJob = null, paywallFilePrice = 99, paywallSubPrice = 299, paywallMode = 'file';
|
||
|
||
// Предпроверка на сервере БЕЗ загрузки файла
|
||
async function checkPaywall(sizeMb, pages) {
|
||
try {
|
||
const body = { size_mb: sizeMb, pages: pages };
|
||
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
|
||
const r = await fetch('/api/paywall/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||
return await r.json();
|
||
} catch (e) { return { requires_payment: false, sub_active: false, paywall_enabled: false, file_price: 99, sub_price: 299 }; }
|
||
}
|
||
|
||
function showPaywall(jobId, filePrice, subPrice, mode) {
|
||
paywallMode = mode || 'file';
|
||
paywallJob = jobId;
|
||
paywallFilePrice = filePrice || 99;
|
||
paywallSubPrice = subPrice || 299;
|
||
const subOnly = (paywallMode === 'batch' || paywallMode === 'renew');
|
||
document.getElementById('paywall-file-price').textContent = paywallFilePrice;
|
||
document.getElementById('paywall-sub-price').textContent = paywallSubPrice;
|
||
document.getElementById('paywall-file-btn').style.display = subOnly ? 'none' : '';
|
||
const titleEl = document.querySelector('#paywall-card [data-i18n="paywall_title"]');
|
||
const descEl = document.querySelector('#paywall-card [data-i18n="paywall_desc"]');
|
||
const tKey = paywallMode === 'renew' ? 'paywall_renew_title' : (paywallMode === 'batch' ? 'paywall_batch_title' : 'paywall_title');
|
||
const dKey = paywallMode === 'renew' ? 'paywall_renew_desc' : (paywallMode === 'batch' ? 'paywall_batch_desc' : 'paywall_desc');
|
||
if (titleEl) titleEl.textContent = t(tKey);
|
||
if (descEl) descEl.textContent = t(dKey);
|
||
const emailEl = document.getElementById('paywall-email');
|
||
if (sessionUser && sessionUser.email && !emailEl.value) emailEl.value = sessionUser.email;
|
||
document.getElementById('paywall-file-btn').disabled = false;
|
||
document.getElementById('paywall-sub-btn').disabled = false;
|
||
document.getElementById('paywall-msg').textContent = '';
|
||
document.getElementById('progress-card').style.display = 'none';
|
||
document.getElementById('result-card').style.display = 'none';
|
||
document.getElementById('batch-card').style.display = 'none';
|
||
submitBtn.style.display = 'none';
|
||
document.getElementById('paywall-card').style.display = 'block';
|
||
document.getElementById('paywall-card').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
|
||
async function _payViaKassa(purpose) {
|
||
const email = (document.getElementById('paywall-email').value || '').trim();
|
||
const msg = document.getElementById('paywall-msg');
|
||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; }
|
||
const fb = document.getElementById('paywall-file-btn'), sb = document.getElementById('paywall-sub-btn');
|
||
fb.disabled = true; sb.disabled = true;
|
||
// Если файл ещё не загружен (пейволл показан ДО загрузки) — грузим сейчас
|
||
if (!paywallJob && paywallMode === 'file' && selectedFile) {
|
||
msg.textContent = t('pay_uploading');
|
||
try {
|
||
const data = await uploadFileChunked(selectedFile, collectSettings(), null);
|
||
paywallJob = data.job_id;
|
||
} catch (e) { msg.textContent = (e && e.message) ? e.message : t('err_network'); fb.disabled = false; sb.disabled = false; return; }
|
||
}
|
||
const body = { purpose, email };
|
||
if (purpose === 'sub') { const ar = document.getElementById('paywall-autorenew'); body.autorenew = ar ? ar.checked : false; }
|
||
if (paywallJob) body.job_id = paywallJob;
|
||
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
|
||
msg.textContent = t('pay_creating');
|
||
try {
|
||
const r = await fetch('/api/pay/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||
const d = await r.json();
|
||
if (d.confirmation_url) {
|
||
localStorage.setItem('pdf2scan_pay_id', d.payment_id);
|
||
localStorage.setItem('pdf2scan_pay_job', paywallJob || '');
|
||
location.href = d.confirmation_url;
|
||
} else { msg.textContent = d.detail || t('pay_error'); fb.disabled = false; sb.disabled = false; }
|
||
} catch (e) { msg.textContent = t('pay_error'); fb.disabled = false; sb.disabled = false; }
|
||
}
|
||
function payForFile() { _payViaKassa('file'); }
|
||
function payForSub() {
|
||
if (!tgUser && !sessionUser) { document.getElementById('paywall-msg').textContent = t('paywall_need_login'); return; }
|
||
_payViaKassa('sub');
|
||
}
|
||
function showPaySuccess(msg) {
|
||
document.getElementById('pay-success-msg').textContent = msg;
|
||
document.getElementById('pay-success-overlay').style.display = 'flex';
|
||
}
|
||
|
||
// Возврат с ЮKassa: ?paid=... → опрашиваем статус, для file/sub запускаем обработку
|
||
(function handlePayReturn() {
|
||
const params = new URLSearchParams(location.search);
|
||
if (!params.has('paid')) return;
|
||
const paid = params.get('paid');
|
||
const pid = localStorage.getItem('pdf2scan_pay_id');
|
||
const payJob = localStorage.getItem('pdf2scan_pay_job');
|
||
try { history.replaceState({}, '', location.pathname); } catch (_) {}
|
||
if (!pid) return;
|
||
let tries = 0;
|
||
const check = async () => {
|
||
tries++;
|
||
try {
|
||
const r = await fetch('/api/pay/status/' + encodeURIComponent(pid));
|
||
const d = await r.json();
|
||
if (d.status === 'succeeded') {
|
||
localStorage.removeItem('pdf2scan_pay_id');
|
||
localStorage.removeItem('pdf2scan_pay_job');
|
||
if ((paid === 'file' || paid === 'sub') && payJob) {
|
||
const pc = document.getElementById('paywall-card'); if (pc) pc.style.display = 'none';
|
||
currentJobId = payJob; batchMode = false;
|
||
setUiState('processing'); startPolling();
|
||
showPaySuccess(t('pay_thanks_file'));
|
||
} else {
|
||
showPaySuccess(t('pay_thanks'));
|
||
}
|
||
return;
|
||
}
|
||
if (d.status === 'canceled') {
|
||
localStorage.removeItem('pdf2scan_pay_id'); localStorage.removeItem('pdf2scan_pay_job');
|
||
showError(t('pay_canceled')); return;
|
||
}
|
||
} catch (_) {}
|
||
if (tries <= 25) setTimeout(check, 2000);
|
||
};
|
||
check();
|
||
})();
|
||
|
||
// ── App state ─────────────────────────────────────────────────────────────
|
||
const TG_AUTH_KEY = 'pdf2scan_tg_auth';
|
||
|
||
let selectedFile = null;
|
||
let currentJobId = null;
|
||
let pollInterval = null;
|
||
let progressInterval = null;
|
||
let tgUser = null;
|
||
let authRequired = false;
|
||
let processingStartTime = null;
|
||
let filePages = 0;
|
||
let fileSizeMb = 0;
|
||
|
||
// ── Batch state (несколько файлов) ────────────────────────────────────────
|
||
const MAX_BATCH = 20;
|
||
const AUTH_MAX_PAGES = 100; // зеркало серверных AUTH_REQUIRED_PAGES (сервер — источник истины)
|
||
const AUTH_MAX_MB = 50; // зеркало серверных AUTH_REQUIRED_MB
|
||
const CHUNK_SIZE = 6 * 1024 * 1024; // 6 МБ — туннель глотает такие куски быстро (>~15 МБ схлопывается)
|
||
let batchMode = false;
|
||
let batchJobs = []; // [{file,name,idx,sizeMb,pages,jobId,status,error,queuePos}]
|
||
let batchPollInterval = null;
|
||
let batchTicker = null;
|
||
|
||
// ── Подсчёт страниц PDF на клиенте ───────────────────────────────────────
|
||
async function countPdfPages(file) {
|
||
try {
|
||
const buf = await file.arrayBuffer();
|
||
const text = new TextDecoder('latin1').decode(new Uint8Array(buf));
|
||
const matches = text.match(/\/Type\s*\/Page[^s]/g);
|
||
return matches ? matches.length : 0;
|
||
} catch(e) { return 0; }
|
||
}
|
||
|
||
// ── localStorage сессия ───────────────────────────────────────────────────
|
||
function loadSavedAuth() {
|
||
try {
|
||
const raw = localStorage.getItem(TG_AUTH_KEY);
|
||
if (!raw) return null;
|
||
const data = JSON.parse(raw);
|
||
if (Date.now() / 1000 - (data.saved_at || 0) > 86400) {
|
||
localStorage.removeItem(TG_AUTH_KEY);
|
||
return null;
|
||
}
|
||
return data;
|
||
} catch(e) { return null; }
|
||
}
|
||
|
||
function saveAuth(user) {
|
||
try {
|
||
localStorage.setItem(TG_AUTH_KEY, JSON.stringify({ ...user, saved_at: Date.now() / 1000 }));
|
||
} catch(e) {}
|
||
}
|
||
|
||
function logoutTg() {
|
||
localStorage.removeItem(TG_AUTH_KEY);
|
||
tgUser = null;
|
||
renderHeaderAuth();
|
||
updateSubmitBtn();
|
||
renderAuthBanner();
|
||
const cab = document.getElementById('cabinet-card');
|
||
if (cab) cab.style.display = 'none';
|
||
}
|
||
|
||
// ── Личный кабинет: «Мои документы» ───────────────────────────────────────
|
||
async function loadHistory() {
|
||
const uid = tgUser && (tgUser.user_id || tgUser.id);
|
||
const card = document.getElementById('cabinet-card');
|
||
if (!uid || !card) return;
|
||
try {
|
||
const r = await fetch('/api/history/' + uid);
|
||
if (!r.ok) { card.style.display = 'none'; return; }
|
||
const data = await r.json();
|
||
const items = data.items || [];
|
||
const list = document.getElementById('cabinet-list');
|
||
if (!items.length) {
|
||
list.innerHTML = '<div style="padding:8px;color:var(--text-secondary);font-size:14px">' + t('cabinet_empty') + '</div>';
|
||
card.style.display = 'block';
|
||
return;
|
||
}
|
||
list.innerHTML = items.map(function(it) {
|
||
var right;
|
||
if (it.status === 'available') {
|
||
right = '<a href="/api/download/' + encodeURIComponent(it.job_id) + '" style="color:var(--blue);text-decoration:none;font-size:13px;white-space:nowrap">' + t('cabinet_dl') + '</a>';
|
||
} else if (it.status === 'processing') {
|
||
right = '<span style="color:var(--text-secondary);font-size:13px">' + t('cabinet_processing') + '</span>';
|
||
} else if (it.status === 'error') {
|
||
right = '<span style="color:#e05555;font-size:13px">' + t('st_error') + '</span>';
|
||
} else {
|
||
right = '<span style="color:var(--text-secondary);font-size:13px;opacity:.7">' + t('cabinet_expired') + '</span>';
|
||
}
|
||
return '<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border,#22314a)">' +
|
||
'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13.5px" title="' + escapeHtml(it.name) + '">' + escapeHtml(it.name) + '</span>' +
|
||
'<span style="display:flex;gap:8px;align-items:center"><span style="color:var(--text-secondary);font-size:11px;white-space:nowrap">' + escapeHtml((it.date||'') + ' ' + (it.time||'').slice(0,5)) + '</span>' + right + '</span>' +
|
||
'</div>';
|
||
}).join('');
|
||
card.style.display = 'block';
|
||
} catch (e) { /* тихо */ }
|
||
}
|
||
|
||
let sessionUser = null; // из /api/me (вход через Яндекс/VK)
|
||
|
||
function renderHeaderAuth() {
|
||
const loginBtn = document.getElementById('tg-login-header-btn');
|
||
const userInfo = document.getElementById('tg-user-info');
|
||
const name = tgUser
|
||
? (tgUser.first_name + (tgUser.last_name ? ' ' + tgUser.last_name : ''))
|
||
: (sessionUser ? sessionUser.name : null);
|
||
if (name) {
|
||
loginBtn.style.display = 'none';
|
||
userInfo.style.display = 'flex';
|
||
document.getElementById('tg-user-name').textContent = name;
|
||
document.getElementById('tg-user-photo').style.display = 'none';
|
||
} else {
|
||
loginBtn.style.display = 'flex';
|
||
userInfo.style.display = 'none';
|
||
}
|
||
loadSubscription(); // обновляем бейдж/карточку подписки под текущий вход
|
||
}
|
||
|
||
// ── Статус подписки (бейдж в шапке + карточка на главной) ─────────────────
|
||
let subUntilTs = 0, subAutorenew = false, subHasMethod = false;
|
||
const _DATE_LOC = { ru: 'ru-RU', en: 'en-US', de: 'de-DE', fr: 'fr-FR', it: 'it-IT', es: 'es-ES', pt: 'pt-PT' };
|
||
function _subDate(ts, opts) {
|
||
try { return new Date(ts * 1000).toLocaleDateString(_DATE_LOC[currentLang] || undefined, opts); }
|
||
catch (_) { return new Date(ts * 1000).toLocaleDateString(); }
|
||
}
|
||
async function loadSubscription() {
|
||
const badge = document.getElementById('sub-badge');
|
||
const card = document.getElementById('sub-card');
|
||
if (!tgUser && !sessionUser) { renderSubStatus({ active: false }); return; }
|
||
try {
|
||
const uid = tgUser ? (tgUser.user_id || tgUser.id || 0) : 0;
|
||
const r = await fetch('/api/subscription' + (uid ? ('?user_id=' + encodeURIComponent(uid)) : ''));
|
||
renderSubStatus(await r.json());
|
||
} catch (_) { renderSubStatus({ active: false }); }
|
||
}
|
||
function renderSubStatus(d) {
|
||
const badge = document.getElementById('sub-badge');
|
||
const card = document.getElementById('sub-card');
|
||
subUntilTs = (d && d.active) ? (d.until || 0) : 0;
|
||
if (d && d.price) paywallSubPrice = d.price;
|
||
if (d && d.active && d.until) {
|
||
const shortD = _subDate(d.until, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
const longD = _subDate(d.until, { day: '2-digit', month: 'long', year: 'numeric' });
|
||
if (badge) {
|
||
document.getElementById('sub-badge-text').textContent = t('sub_until_label') + ' ' + shortD;
|
||
badge.title = t('sub_active_title') + ' — ' + t('sub_until_label') + ' ' + longD;
|
||
badge.style.display = 'inline-flex';
|
||
}
|
||
if (card) {
|
||
document.getElementById('sub-card-until').textContent = t('sub_until_label') + ' ' + longD;
|
||
card.style.display = 'block';
|
||
}
|
||
// автопродление
|
||
subAutorenew = !!(d && d.autorenew);
|
||
subHasMethod = !!(d && d.has_method);
|
||
const arLabel = document.getElementById('sub-autorenew-label');
|
||
const arToggle = document.getElementById('sub-autorenew-toggle');
|
||
if (arLabel && arToggle) {
|
||
if (subAutorenew) {
|
||
arLabel.textContent = t('sub_autorenew_on');
|
||
arToggle.textContent = t('sub_autorenew_disable');
|
||
arToggle.style.display = 'inline';
|
||
} else {
|
||
arLabel.textContent = t('sub_autorenew_off');
|
||
if (subHasMethod) { arToggle.textContent = t('sub_autorenew_enable'); arToggle.style.display = 'inline'; }
|
||
else { arToggle.style.display = 'none'; }
|
||
}
|
||
}
|
||
} else {
|
||
if (badge) badge.style.display = 'none';
|
||
if (card) card.style.display = 'none';
|
||
}
|
||
}
|
||
async function toggleAutorenew() {
|
||
if (!tgUser && !sessionUser) return;
|
||
const body = { enable: !subAutorenew };
|
||
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
|
||
const arToggle = document.getElementById('sub-autorenew-toggle');
|
||
if (arToggle) arToggle.style.pointerEvents = 'none';
|
||
try {
|
||
const r = await fetch('/api/subscription/autorenew', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||
const d = await r.json();
|
||
subAutorenew = !!d.autorenew;
|
||
} catch (e) {}
|
||
if (arToggle) arToggle.style.pointerEvents = '';
|
||
loadSubscription();
|
||
}
|
||
function scrollToSubCard() {
|
||
const card = document.getElementById('sub-card');
|
||
if (card && card.style.display !== 'none') card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
function renewSub() {
|
||
if (!tgUser && !sessionUser) return;
|
||
showPaywall(null, paywallFilePrice, paywallSubPrice, 'renew');
|
||
}
|
||
|
||
// ── Меню входа (Telegram / Яндекс) ────────────────────────────────────────
|
||
function toggleLoginMenu(e) {
|
||
e.stopPropagation();
|
||
const m = document.getElementById('login-menu');
|
||
m.style.display = (m.style.display === 'none' || !m.style.display) ? 'block' : 'none';
|
||
}
|
||
function closeLoginMenu() {
|
||
const m = document.getElementById('login-menu');
|
||
if (m) m.style.display = 'none';
|
||
}
|
||
document.addEventListener('click', closeLoginMenu);
|
||
|
||
async function doLogout() {
|
||
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) {}
|
||
sessionUser = null;
|
||
logoutTg(); // чистит localStorage TG + перерисовывает шапку
|
||
}
|
||
|
||
async function loadMe() {
|
||
try {
|
||
const r = await fetch('/api/me');
|
||
const d = await r.json();
|
||
sessionUser = d.logged_in ? d : null;
|
||
} catch (e) { sessionUser = null; }
|
||
renderHeaderAuth();
|
||
if (sessionUser) loadHistory();
|
||
}
|
||
|
||
// ── Вход по почте (код) ───────────────────────────────────────────────────
|
||
let _emailForCode = '';
|
||
function openEmailLogin() {
|
||
document.getElementById('email-step-1').style.display = 'block';
|
||
document.getElementById('email-step-2').style.display = 'none';
|
||
document.getElementById('email-login-msg').textContent = '';
|
||
document.getElementById('email-login-input').value = '';
|
||
document.getElementById('email-code-input').value = '';
|
||
document.getElementById('email-login-overlay').style.display = 'flex';
|
||
}
|
||
function closeEmailLogin() { document.getElementById('email-login-overlay').style.display = 'none'; }
|
||
async function emailSendCode() {
|
||
const email = (document.getElementById('email-login-input').value || '').trim();
|
||
const msg = document.getElementById('email-login-msg');
|
||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; }
|
||
const btn = document.getElementById('email-send-btn'); btn.disabled = true; msg.textContent = t('email_sending');
|
||
try {
|
||
const r = await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
|
||
const d = await r.json();
|
||
if (r.ok) {
|
||
_emailForCode = email;
|
||
document.getElementById('email-step-1').style.display = 'none';
|
||
document.getElementById('email-step-2').style.display = 'block';
|
||
msg.textContent = ''; document.getElementById('email-code-input').focus();
|
||
} else { msg.textContent = d.detail || t('err_login'); btn.disabled = false; }
|
||
} catch (e) { msg.textContent = t('err_login'); btn.disabled = false; }
|
||
}
|
||
async function emailVerifyCode() {
|
||
const code = (document.getElementById('email-code-input').value || '').trim();
|
||
const msg = document.getElementById('email-login-msg');
|
||
if (!/^\d{6}$/.test(code)) { msg.textContent = t('email_bad_code'); return; }
|
||
const btn = document.getElementById('email-verify-btn'); btn.disabled = true; msg.textContent = '';
|
||
try {
|
||
const r = await fetch('/api/auth/email/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: _emailForCode, code }) });
|
||
const d = await r.json();
|
||
if (r.ok) { closeEmailLogin(); loadMe(); }
|
||
else { msg.textContent = d.detail || t('email_bad_code'); btn.disabled = false; }
|
||
} catch (e) { msg.textContent = t('err_login'); btn.disabled = false; }
|
||
}
|
||
|
||
// ── Авторизация через бота ────────────────────────────────────────────────
|
||
let _authPollInterval = null;
|
||
let _authToken = null;
|
||
let _authWindow = null;
|
||
|
||
async function startTgAuth() {
|
||
try {
|
||
const r = await fetch('/api/auth/token', { method: 'POST' });
|
||
if (!r.ok) { showError(t('err_auth_start')); return; }
|
||
const data = await r.json();
|
||
_authToken = data.token;
|
||
_authWindow = window.open(data.url, '_blank');
|
||
showInfo(t('info_auth_waiting'));
|
||
if (_authPollInterval) clearInterval(_authPollInterval);
|
||
_authPollInterval = setInterval(_pollAuthToken, 2000);
|
||
} catch(e) {
|
||
showError(t('err_auth_launch'));
|
||
}
|
||
}
|
||
|
||
async function _pollAuthToken() {
|
||
if (!_authToken) return;
|
||
try {
|
||
const r = await fetch(`/api/auth/poll/${_authToken}`);
|
||
if (!r.ok) { _stopAuthPoll(); return; }
|
||
const data = await r.json();
|
||
if (data.status === 'verified') {
|
||
_stopAuthPoll();
|
||
tgUser = { user_id: data.user_id, first_name: data.first_name, last_name: data.last_name, username: data.username };
|
||
saveAuth(tgUser);
|
||
renderHeaderAuth();
|
||
renderAuthBanner();
|
||
updateSubmitBtn();
|
||
hideInfo();
|
||
loadHistory();
|
||
if (_authWindow) { try { _authWindow.close(); } catch(_) {} }
|
||
} else if (data.status === 'expired') {
|
||
_stopAuthPoll();
|
||
showError(t('err_auth_expired'));
|
||
}
|
||
} catch(e) { /* сетевая ошибка, попробуем снова */ }
|
||
}
|
||
|
||
function _stopAuthPoll() {
|
||
if (_authPollInterval) { clearInterval(_authPollInterval); _authPollInterval = null; }
|
||
_authToken = null;
|
||
}
|
||
|
||
// ── Инициализация ─────────────────────────────────────────────────────────
|
||
tgUser = loadSavedAuth();
|
||
renderHeaderAuth();
|
||
if (tgUser) loadHistory();
|
||
if (!tgUser) loadMe(); // проверяем cookie-сессию (вход через Яндекс/VK)
|
||
// Маячок посещения (раз в сессию браузера) — считаем реальные заходы для админки
|
||
try {
|
||
if (!sessionStorage.getItem('p2s_hit')) {
|
||
sessionStorage.setItem('p2s_hit', '1');
|
||
fetch('/api/hit', { method: 'POST', keepalive: true }).catch(function () {});
|
||
}
|
||
} catch (_) {}
|
||
// Возврат после OAuth: чистим ?login и показываем ошибку при неудаче
|
||
(function () {
|
||
const p = new URLSearchParams(location.search);
|
||
if (p.has('login')) {
|
||
const v = p.get('login');
|
||
try { history.replaceState({}, '', location.pathname); } catch (_) {}
|
||
if (v === 'error') showError(t('err_login'));
|
||
}
|
||
})();
|
||
|
||
// ── Drop zone ─────────────────────────────────────────────────────────────
|
||
const dropZone = document.getElementById('drop-zone');
|
||
const fileInput = document.getElementById('file-input');
|
||
const fileBadge = document.getElementById('file-badge');
|
||
const submitBtn = document.getElementById('submit-btn');
|
||
|
||
applyTranslations(); // после объявления submitBtn — иначе TDZ ReferenceError
|
||
|
||
dropZone.addEventListener('click', () => fileInput.click());
|
||
dropZone.addEventListener('dragenter', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
||
dropZone.addEventListener('dragover', e => { e.preventDefault(); });
|
||
dropZone.addEventListener('dragleave', e => { if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove('drag-over'); });
|
||
const _ALLOWED_MIME = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
|
||
function gatherFiles(fileList) {
|
||
return Array.from(fileList || []).filter(
|
||
f => _ALLOWED_MIME.includes(f.type) || /\.(pdf|doc|docx|zip)$/i.test(f.name)
|
||
);
|
||
}
|
||
|
||
dropZone.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
dropZone.classList.remove('drag-over');
|
||
acceptFiles(gatherFiles(e.dataTransfer.files));
|
||
});
|
||
|
||
fileInput.addEventListener('change', () => {
|
||
acceptFiles(gatherFiles(fileInput.files));
|
||
});
|
||
|
||
// Загрузка папкой: webkitdirectory отдаёт все файлы каталога, фильтруем нужные.
|
||
const folderInput = document.getElementById('folder-input');
|
||
const folderBtn = document.getElementById('folder-btn');
|
||
if (folderBtn) folderBtn.addEventListener('click', (e) => { e.stopPropagation(); folderInput.click(); });
|
||
if (folderInput) folderInput.addEventListener('change', () => {
|
||
acceptFiles(gatherFiles(folderInput.files));
|
||
});
|
||
|
||
// Пакетная обработка (2+ файла / папка / ZIP) — только по подписке.
|
||
function requireSubForBatch(proceed) {
|
||
checkPaywall(0, 0).then(chk => {
|
||
if (chk.paywall_enabled && !chk.sub_active) {
|
||
showPaywall(null, chk.file_price, chk.sub_price, 'batch');
|
||
} else { proceed(); }
|
||
});
|
||
}
|
||
|
||
// Один файл → классический одиночный поток; несколько → батч со списком и ZIP.
|
||
function acceptFiles(arr) {
|
||
if (!arr || !arr.length) return;
|
||
hideError(); hideInfo(); hideDonateCard();
|
||
submitBtn.style.display = 'block'; // мог быть скрыт после прошлого батча
|
||
// ZIP-архив (форма батча) — по подписке.
|
||
const zips = arr.filter(f => /\.zip$/i.test(f.name));
|
||
if (zips.length) { requireSubForBatch(() => handleZip(zips[0])); return; }
|
||
let files = arr;
|
||
if (files.length > MAX_BATCH) {
|
||
showError(t('err_too_many')(MAX_BATCH));
|
||
files = files.slice(0, MAX_BATCH);
|
||
}
|
||
if (files.length === 1) {
|
||
batchMode = false;
|
||
document.getElementById('batch-card').style.display = 'none';
|
||
handleFile(files[0]);
|
||
} else {
|
||
// Пакет из нескольких файлов — гейт по подписке
|
||
requireSubForBatch(() => {
|
||
batchMode = true;
|
||
selectedFile = null;
|
||
fileBadge.style.display = 'none';
|
||
document.getElementById('result-card').style.display = 'none';
|
||
document.getElementById('progress-card').style.display = 'none';
|
||
handleFiles(files);
|
||
});
|
||
}
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, c => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||
));
|
||
}
|
||
|
||
// ── Загрузка ZIP: грузим архив кусками, сервер распаковывает и ставит в очередь ─
|
||
async function uploadZipChunked(file, s, onProgress) {
|
||
const uploadId = randId();
|
||
const total = file.size;
|
||
let offset = 0;
|
||
do {
|
||
const end = Math.min(offset + CHUNK_SIZE, total);
|
||
const fd = new FormData();
|
||
fd.append('upload_id', uploadId);
|
||
fd.append('offset', offset);
|
||
fd.append('chunk', file.slice(offset, end), 'chunk');
|
||
const r = await fetchRetry('/api/upload-chunk', { method: 'POST', body: fd }, 3);
|
||
if (!r.ok) throw new Error(await _detailOf(r));
|
||
offset = end;
|
||
if (onProgress) onProgress(offset, total);
|
||
} while (offset < total);
|
||
|
||
const ff = new FormData();
|
||
ff.append('upload_id', uploadId);
|
||
ff.append('total_size', total);
|
||
ff.append('stitching', s.stitching);
|
||
ff.append('color', s.color);
|
||
ff.append('quality', s.quality);
|
||
ff.append('title', s.title);
|
||
ff.append('numeration', s.numeration);
|
||
ff.append('flip_side', s.flip_side);
|
||
ff.append('pdfa', s.pdfa);
|
||
ff.append('ocr', s.ocr || 0);
|
||
if (tgUser) ff.append('user_id', tgUser.user_id || tgUser.id || 0);
|
||
const fr = await fetchRetry('/api/submit-zip-chunked', { method: 'POST', body: ff }, 3);
|
||
if (!fr.ok) throw new Error(await _detailOf(fr));
|
||
return (await fr.json()).jobs || [];
|
||
}
|
||
|
||
async function handleZip(file) {
|
||
batchMode = true; selectedFile = null;
|
||
fileBadge.style.display = 'none';
|
||
document.getElementById('result-card').style.display = 'none';
|
||
document.getElementById('progress-card').style.display = 'none';
|
||
document.querySelector('.drop-title').textContent = file.name;
|
||
document.getElementById('batch-count').textContent = '';
|
||
document.getElementById('batch-list').innerHTML =
|
||
'<div style="padding:8px;color:var(--text-secondary)">' + t('zip_preparing') + '</div>';
|
||
document.getElementById('batch-actions').style.display = 'none';
|
||
document.getElementById('batch-card').style.display = 'block';
|
||
submitBtn.style.display = 'none';
|
||
showDonateCard(true);
|
||
try {
|
||
const settings = collectSettings();
|
||
const jobs = await uploadZipChunked(file, settings, null);
|
||
if (!jobs.length) { showError(t('err_zip')); return; }
|
||
batchJobs = jobs.map((jb, i) => ({
|
||
file: null, name: jb.name, idx: i, sizeMb: 0, pages: 0,
|
||
jobId: jb.job_id, status: 'queued', error: null, queuePos: null,
|
||
}));
|
||
document.getElementById('batch-count').textContent = '(' + batchJobs.length + ')';
|
||
renderBatchList();
|
||
startBatchPolling();
|
||
} catch (e) {
|
||
showError((e && e.message) ? e.message : t('err_zip'));
|
||
document.getElementById('batch-list').innerHTML = '';
|
||
}
|
||
}
|
||
|
||
async function handleFiles(files) {
|
||
batchJobs = files.map((f, i) => ({
|
||
file: f, name: f.name, idx: i,
|
||
sizeMb: f.size / (1024 * 1024), pages: 0,
|
||
jobId: null, status: 'pending', error: null, queuePos: null,
|
||
}));
|
||
document.querySelector('.drop-title').textContent = t('files_selected')(batchJobs.length);
|
||
document.getElementById('batch-count').textContent = '(' + batchJobs.length + ')';
|
||
renderBatchList();
|
||
document.getElementById('batch-actions').style.display = 'none';
|
||
document.getElementById('batch-card').style.display = 'block';
|
||
// Подсчёт страниц на клиенте и определение необходимости авторизации
|
||
await Promise.all(batchJobs.map(async j => { j.pages = await countPdfPages(j.file); }));
|
||
authRequired = batchJobs.some(j => j.pages > AUTH_MAX_PAGES || j.sizeMb > AUTH_MAX_MB);
|
||
renderAuthBanner();
|
||
updateSubmitBtn();
|
||
}
|
||
|
||
function renderBatchList() {
|
||
const list = document.getElementById('batch-list');
|
||
list.innerHTML = '';
|
||
batchJobs.forEach(j => {
|
||
const row = document.createElement('div');
|
||
row.className = 'batch-row';
|
||
row.innerHTML =
|
||
`<span class="batch-name" title="${escapeHtml(j.name)}">${escapeHtml(j.name)}</span>` +
|
||
`<span class="batch-status" id="batch-status-${j.idx}"></span>`;
|
||
list.appendChild(row);
|
||
updateRow(j);
|
||
});
|
||
}
|
||
|
||
// Примерный прогресс обработки (асимптотика к 90%, как в одиночном режиме)
|
||
function procPct(j) {
|
||
const started = j.procStart || Date.now();
|
||
const elapsed = (Date.now() - started) / 1000;
|
||
return Math.round(90 * (1 - Math.exp(-elapsed / 45)));
|
||
}
|
||
|
||
function updateRow(j) {
|
||
const el = document.getElementById('batch-status-' + j.idx);
|
||
if (!el) return;
|
||
if (j.status === 'done') {
|
||
el.innerHTML = `<a class="bs bs-done" href="/api/download/${encodeURIComponent(j.jobId)}" download>${t('st_done')} ⬇</a>`;
|
||
} else if (j.status === 'error') {
|
||
el.innerHTML = `<span class="bs bs-err" title="${escapeHtml(j.error || '')}">${t('st_error')}</span>`;
|
||
} else if (j.status === 'processing') {
|
||
const label = t('st_processing').replace(/[…\.]+$/, '');
|
||
el.innerHTML = `<span class="bs bs-proc">${label} ${procPct(j)}%</span>`;
|
||
} else if (j.status === 'queued') {
|
||
el.innerHTML = `<span class="bs bs-wait">${t('st_queued')}${j.queuePos ? ' · ' + j.queuePos : ''}</span>`;
|
||
} else if (j.status === 'uploading') {
|
||
el.innerHTML = `<span class="bs bs-wait">${t('st_uploading')}</span>`;
|
||
} else if (j.status === 'awaiting_payment') {
|
||
el.innerHTML = `<a class="bs bs-err" style="cursor:pointer;text-decoration:none" onclick="payBatchRow(${j.idx})">💳 ${(j.filePrice||99)} ₽</a>`;
|
||
} else {
|
||
el.innerHTML = `<span class="bs bs-wait">—</span>`;
|
||
}
|
||
}
|
||
|
||
function payBatchRow(idx) {
|
||
const j = batchJobs.find(x => x.idx === idx);
|
||
if (j && j.jobId) showPaywall(j.jobId, j.filePrice, j.subPrice);
|
||
}
|
||
|
||
async function handleFile(f) {
|
||
selectedFile = f;
|
||
fileBadge.textContent = f.name;
|
||
fileBadge.style.display = 'block';
|
||
document.querySelector('.drop-title').textContent = t('file_selected');
|
||
hideError();
|
||
hideDonateCard();
|
||
|
||
fileSizeMb = f.size / (1024 * 1024);
|
||
filePages = await countPdfPages(f);
|
||
await checkAuth(filePages, fileSizeMb);
|
||
}
|
||
|
||
async function checkAuth(pages, sizeMb) {
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('pages', pages);
|
||
fd.append('size_mb', sizeMb);
|
||
const r = await fetch('/api/check', { method: 'POST', body: fd });
|
||
const data = await r.json();
|
||
authRequired = data.requires_auth;
|
||
renderAuthBanner();
|
||
} catch (e) {
|
||
authRequired = false;
|
||
renderAuthBanner();
|
||
}
|
||
updateSubmitBtn();
|
||
}
|
||
|
||
function renderAuthBanner() {
|
||
// Старый баннер авторизации больше не используется — гейт больших файлов
|
||
// теперь на сервере (пейволл: оплата 99₽ или подписка после отправки).
|
||
const banner = document.getElementById('auth-banner');
|
||
if (banner) banner.style.display = 'none';
|
||
}
|
||
|
||
function updateSubmitBtn() {
|
||
renderAuthBanner();
|
||
const hasSelection = batchMode ? batchJobs.length > 0 : !!selectedFile;
|
||
if (!hasSelection) {
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = t('btn_select_file');
|
||
} else {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = batchMode ? t('btn_process_n')(batchJobs.length) : t('btn_process');
|
||
}
|
||
}
|
||
|
||
// ── Submit ────────────────────────────────────────────────────────────────
|
||
submitBtn.addEventListener('click', () => { if (batchMode) submitBatch(); else submitJob(); });
|
||
|
||
function collectSettings() {
|
||
return {
|
||
stitching: getRadio('stitching'),
|
||
color: getRadio('color'),
|
||
quality: getRadio('quality'),
|
||
title: document.getElementById('title-check').checked ? 1 : 0,
|
||
numeration: document.getElementById('num-check').checked ? 1 : 0,
|
||
flip_side: document.getElementById('flip-check').checked ? 1 : 0,
|
||
pdfa: document.getElementById('pdfa-check').checked ? 1 : 0,
|
||
ocr: document.getElementById('ocr-check').checked ? 1 : 0,
|
||
};
|
||
}
|
||
|
||
// ── Batch submit / poll / zip ─────────────────────────────────────────────
|
||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||
|
||
// ── Чанковый загрузчик (обход потолка туннеля на крупных файлах) ───────────
|
||
function randId() {
|
||
const a = new Uint8Array(16);
|
||
if (window.crypto && crypto.getRandomValues) crypto.getRandomValues(a);
|
||
else for (let i = 0; i < a.length; i++) a[i] = Math.floor(Math.random() * 256);
|
||
return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
async function fetchRetry(url, opts, tries = 3) {
|
||
let lastErr = t('err_network');
|
||
for (let attempt = 1; attempt <= tries; attempt++) {
|
||
try {
|
||
const r = await fetch(url, opts);
|
||
if (r.ok) return r;
|
||
if (r.status >= 400 && r.status < 500) return r; // детерминированный ответ — отдаём как есть
|
||
lastErr = t('err_server')(r.status); // 5xx — пробуем ещё
|
||
} catch (e) {
|
||
lastErr = t('err_network'); // сетевой сброс — пробуем ещё
|
||
}
|
||
if (attempt < tries) await sleep(attempt * 1500);
|
||
}
|
||
throw new Error(lastErr);
|
||
}
|
||
|
||
async function _detailOf(r) {
|
||
let msg = t('err_server')(r.status);
|
||
try { const d = await r.json(); if (typeof d.detail === 'string') msg = d.detail; } catch (_) {}
|
||
return msg;
|
||
}
|
||
|
||
// Грузит файл кусками ≤CHUNK_SIZE, затем финализирует. onProgress(uploaded,total) опц. Возвращает job_id.
|
||
async function uploadFileChunked(file, s, onProgress) {
|
||
const uploadId = randId();
|
||
const total = file.size;
|
||
let offset = 0;
|
||
do {
|
||
const end = Math.min(offset + CHUNK_SIZE, total);
|
||
const fd = new FormData();
|
||
fd.append('upload_id', uploadId);
|
||
fd.append('offset', offset);
|
||
fd.append('chunk', file.slice(offset, end), 'chunk');
|
||
const r = await fetchRetry('/api/upload-chunk', { method: 'POST', body: fd }, 3);
|
||
if (!r.ok) throw new Error(await _detailOf(r));
|
||
offset = end;
|
||
if (onProgress) onProgress(offset, total);
|
||
} while (offset < total);
|
||
|
||
const ff = new FormData();
|
||
ff.append('upload_id', uploadId);
|
||
ff.append('filename', file.name);
|
||
ff.append('total_size', total);
|
||
ff.append('stitching', s.stitching);
|
||
ff.append('color', s.color);
|
||
ff.append('quality', s.quality);
|
||
ff.append('title', s.title);
|
||
ff.append('numeration', s.numeration);
|
||
ff.append('flip_side', s.flip_side);
|
||
ff.append('pdfa', s.pdfa);
|
||
ff.append('ocr', s.ocr || 0);
|
||
if (tgUser) ff.append('user_id', tgUser.user_id || tgUser.id || 0);
|
||
const fr = await fetchRetry('/api/submit-chunked', { method: 'POST', body: ff }, 3);
|
||
if (!fr.ok) throw new Error(await _detailOf(fr));
|
||
return await fr.json(); // {job_id, requires_payment?, file_price?, sub_price?}
|
||
}
|
||
|
||
async function submitBatch() {
|
||
hideError(); hideInfo();
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = t('btn_sending');
|
||
document.getElementById('batch-actions').style.display = 'none';
|
||
|
||
const settings = collectSettings();
|
||
batchJobs.forEach(j => { j.status = 'pending'; j.error = null; updateRow(j); });
|
||
|
||
submitBtn.textContent = t('btn_processing');
|
||
showDonateCard(true);
|
||
// Поллинг статусов идёт параллельно загрузке — ранние файлы показывают прогресс,
|
||
// пока грузятся остальные.
|
||
startBatchPolling();
|
||
// Туннель до бэкенда тонкий и не тянет несколько крупных загрузок разом —
|
||
// грузим ПОСЛЕДОВАТЕЛЬНО (по одному) c авто-ретраями на сетевые сбои.
|
||
await runPool(batchJobs.slice(), 1, j => uploadOne(j, settings));
|
||
}
|
||
|
||
async function runPool(items, limit, fn) {
|
||
const queue = items.slice();
|
||
const workers = [];
|
||
const n = Math.min(limit, queue.length);
|
||
for (let i = 0; i < n; i++) {
|
||
workers.push((async () => {
|
||
while (queue.length) { await fn(queue.shift()); }
|
||
})());
|
||
}
|
||
await Promise.all(workers);
|
||
}
|
||
|
||
async function uploadOne(j, s) {
|
||
j.status = 'uploading'; j.error = null; updateRow(j);
|
||
try {
|
||
const data = await uploadFileChunked(j.file, s, null);
|
||
j.jobId = data.job_id;
|
||
if (data.requires_payment) {
|
||
j.status = 'awaiting_payment'; j.filePrice = data.file_price; j.subPrice = data.sub_price;
|
||
} else {
|
||
j.status = 'queued';
|
||
}
|
||
j.error = null; updateRow(j);
|
||
} catch (e) {
|
||
j.status = 'error';
|
||
j.error = (e && e.message) ? e.message : t('err_network');
|
||
updateRow(j);
|
||
}
|
||
}
|
||
|
||
function startBatchPolling() {
|
||
if (batchPollInterval) clearInterval(batchPollInterval);
|
||
batchPollInterval = setInterval(pollBatch, 2000);
|
||
// тикер плавно крутит % у файла в обработке между опросами
|
||
if (batchTicker) clearInterval(batchTicker);
|
||
batchTicker = setInterval(() => {
|
||
batchJobs.forEach(j => { if (j.status === 'processing') updateRow(j); });
|
||
}, 1000);
|
||
pollBatch();
|
||
}
|
||
|
||
async function pollBatch() {
|
||
const active = batchJobs.filter(j => j.jobId && (j.status === 'queued' || j.status === 'processing'));
|
||
await Promise.all(active.map(async j => {
|
||
try {
|
||
const r = await fetch(`/api/status/${encodeURIComponent(j.jobId)}`);
|
||
if (r.status === 404) { j.status = 'error'; j.error = t('err_task_not_found'); updateRow(j); return; }
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
if (d.status === 'done') { j.status = 'done'; updateRow(j); }
|
||
else if (d.status === 'error') { j.status = 'error'; j.error = d.error || t('err_processing'); updateRow(j); }
|
||
else if (d.status === 'queued') { j.status = 'queued'; j.queuePos = d.queue_pos || null; updateRow(j); }
|
||
else { if (!j.procStart) j.procStart = Date.now(); j.status = 'processing'; updateRow(j); }
|
||
} catch (e) { /* сетевой сбой — повторим на следующем тике */ }
|
||
}));
|
||
if (batchJobs.every(j => j.status === 'done' || j.status === 'error')) {
|
||
if (batchPollInterval) { clearInterval(batchPollInterval); batchPollInterval = null; }
|
||
onBatchComplete();
|
||
}
|
||
}
|
||
|
||
function onBatchComplete() {
|
||
if (batchTicker) { clearInterval(batchTicker); batchTicker = null; }
|
||
const doneCount = batchJobs.filter(j => j.status === 'done').length;
|
||
const actions = document.getElementById('batch-actions');
|
||
const zipBtn = document.getElementById('zip-btn');
|
||
zipBtn.style.display = doneCount > 0 ? 'block' : 'none';
|
||
zipBtn.disabled = doneCount === 0;
|
||
actions.style.display = 'flex';
|
||
submitBtn.style.display = 'none';
|
||
showDonateCard(true);
|
||
}
|
||
|
||
document.getElementById('zip-btn').addEventListener('click', downloadZip);
|
||
|
||
async function downloadZip() {
|
||
const ids = batchJobs.filter(j => j.status === 'done').map(j => j.jobId);
|
||
if (!ids.length) return;
|
||
const zipBtn = document.getElementById('zip-btn');
|
||
const orig = zipBtn.textContent;
|
||
zipBtn.disabled = true;
|
||
zipBtn.textContent = t('zip_preparing');
|
||
try {
|
||
// 1) старт фоновой сборки
|
||
const r = await fetch('/api/zip/prepare', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ job_ids: ids }),
|
||
});
|
||
if (!r.ok) throw new Error(await _detailOf(r));
|
||
const { zip_id, name } = await r.json();
|
||
// 2) ждём готовности (большой архив строится в фоне, без таймаута заголовков)
|
||
let ready = false;
|
||
for (let i = 0; i < 400; i++) {
|
||
await sleep(2000);
|
||
try {
|
||
const sr = await fetch('/api/zip/status/' + encodeURIComponent(zip_id));
|
||
if (!sr.ok) continue;
|
||
const s = await sr.json();
|
||
if (s.status === 'ready') { ready = true; break; }
|
||
if (s.status === 'error') throw new Error(t('err_zip'));
|
||
} catch (e) { if (e && e.message === t('err_zip')) throw e; /* сетевой блип — повторим */ }
|
||
}
|
||
if (!ready) throw new Error(t('err_zip'));
|
||
// 3) нативное скачивание готового файла (стримится на диск, не в память)
|
||
const a = document.createElement('a');
|
||
a.href = '/api/zip/file/' + encodeURIComponent(zip_id);
|
||
a.download = name || 'pdf2scan.zip';
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
} catch (e) {
|
||
showError((e && e.message) ? e.message : t('err_zip'));
|
||
} finally {
|
||
zipBtn.disabled = false;
|
||
zipBtn.textContent = orig;
|
||
}
|
||
}
|
||
|
||
async function submitJob() {
|
||
if (!selectedFile) { showError(t('err_no_file')); return; }
|
||
// Пейволл БЕЗ загрузки: только для потенциально больших файлов (мелкие не дёргают сервер)
|
||
if (fileSizeMb > AUTH_MAX_MB || filePages > AUTH_MAX_PAGES) {
|
||
submitBtn.disabled = true;
|
||
const chk = await checkPaywall(fileSizeMb, filePages);
|
||
submitBtn.disabled = false;
|
||
if (chk.requires_payment) { showPaywall(null, chk.file_price, chk.sub_price, 'file'); return; }
|
||
}
|
||
// Крупные файлы — через чанковую загрузку (туннель не тянет большое тело за раз)
|
||
if (selectedFile.size > CHUNK_SIZE) { submitJobChunked(); return; }
|
||
hideError();
|
||
hideInfo();
|
||
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = t('btn_sending');
|
||
|
||
document.getElementById('progress-card').style.display = 'block';
|
||
document.getElementById('result-card').style.display = 'none';
|
||
setProgressPct(0);
|
||
document.getElementById('progress-status').textContent = t('progress_loading');
|
||
document.getElementById('progress-speed').textContent = '';
|
||
|
||
const fd = new FormData();
|
||
fd.append('file', selectedFile);
|
||
fd.append('stitching', getRadio('stitching'));
|
||
fd.append('color', getRadio('color'));
|
||
fd.append('quality', getRadio('quality'));
|
||
fd.append('title', document.getElementById('title-check').checked ? 1 : 0);
|
||
fd.append('numeration', document.getElementById('num-check').checked ? 1 : 0);
|
||
fd.append('flip_side', document.getElementById('flip-check').checked ? 1 : 0);
|
||
fd.append('pdfa', document.getElementById('pdfa-check').checked ? 1 : 0);
|
||
fd.append('ocr', document.getElementById('ocr-check').checked ? 1 : 0);
|
||
if (tgUser) fd.append('user_id', tgUser.user_id || tgUser.id || 0);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
const uploadStart = Date.now();
|
||
let lastLoaded = 0, lastTime = uploadStart;
|
||
|
||
xhr.upload.onprogress = function(e) {
|
||
if (!e.lengthComputable) return;
|
||
const pct = Math.round(e.loaded / e.total * 100);
|
||
setProgressPct(pct);
|
||
|
||
const now = Date.now();
|
||
const dt = (now - lastTime) / 1000;
|
||
if (dt >= 0.3) {
|
||
const speedMb = (e.loaded - lastLoaded) / (1024 * 1024) / dt;
|
||
lastLoaded = e.loaded; lastTime = now;
|
||
const loaded = (e.loaded / (1024*1024)).toFixed(1);
|
||
const total = (e.total / (1024*1024)).toFixed(1);
|
||
const speedFn = (TR[currentLang] || TR['ru']).upload_speed;
|
||
document.getElementById('progress-speed').textContent =
|
||
speedFn(loaded, total, speedMb.toFixed(1));
|
||
}
|
||
document.getElementById('progress-status').textContent =
|
||
pct < 100 ? t('progress_loading') : t('progress_uploaded');
|
||
};
|
||
|
||
xhr.onload = function() {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
try {
|
||
const data = JSON.parse(xhr.responseText);
|
||
currentJobId = data.job_id;
|
||
document.getElementById('progress-speed').textContent = '';
|
||
if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; }
|
||
setUiState('processing');
|
||
startPolling();
|
||
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) {
|
||
showDonateCard(true);
|
||
}
|
||
} catch(e) {
|
||
onUploadError(t('err_server')('?'));
|
||
}
|
||
} else {
|
||
try {
|
||
const d = JSON.parse(xhr.responseText);
|
||
const detail = d.detail;
|
||
const msg = typeof detail === 'string' ? detail : t('err_server')(xhr.status);
|
||
onUploadError(msg);
|
||
} catch(_) { onUploadError(t('err_server')(xhr.status)); }
|
||
}
|
||
};
|
||
|
||
xhr.onerror = function() { onUploadError(t('err_network')); };
|
||
xhr.ontimeout = function() { onUploadError(t('err_timeout')); };
|
||
xhr.timeout = 300000;
|
||
|
||
xhr.open('POST', '/api/submit');
|
||
xhr.send(fd);
|
||
}
|
||
|
||
async function submitJobChunked() {
|
||
hideError(); hideInfo();
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = t('btn_sending');
|
||
document.getElementById('progress-card').style.display = 'block';
|
||
document.getElementById('result-card').style.display = 'none';
|
||
setProgressPct(0);
|
||
document.getElementById('progress-status').textContent = t('progress_loading');
|
||
document.getElementById('progress-speed').textContent = '';
|
||
try {
|
||
const data = await uploadFileChunked(selectedFile, collectSettings(), (up, tot) => {
|
||
const pct = Math.round(up / tot * 100);
|
||
setProgressPct(pct);
|
||
document.getElementById('progress-status').textContent =
|
||
pct < 100 ? t('progress_loading') : t('progress_uploaded');
|
||
});
|
||
currentJobId = data.job_id;
|
||
document.getElementById('progress-speed').textContent = '';
|
||
if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; }
|
||
setUiState('processing');
|
||
startPolling();
|
||
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) showDonateCard(true);
|
||
} catch (e) {
|
||
onUploadError((e && e.message) ? e.message : t('err_network'));
|
||
}
|
||
}
|
||
|
||
function onUploadError(msg) {
|
||
showError('⚠ ' + msg);
|
||
document.getElementById('progress-card').style.display = 'none';
|
||
submitBtn.disabled = !selectedFile;
|
||
submitBtn.textContent = t('btn_process');
|
||
}
|
||
|
||
function getRadio(name) {
|
||
const el = document.querySelector(`input[name="${name}"]:checked`);
|
||
return el ? el.value : '';
|
||
}
|
||
|
||
// ── Прогрессбар ───────────────────────────────────────────────────────────
|
||
function startProgressSim() {
|
||
processingStartTime = Date.now();
|
||
if (progressInterval) clearInterval(progressInterval);
|
||
progressInterval = setInterval(() => {
|
||
const elapsed = (Date.now() - processingStartTime) / 1000;
|
||
const pct = Math.round(90 * (1 - Math.exp(-elapsed / 45)));
|
||
setProgressPct(pct);
|
||
}, 500);
|
||
}
|
||
|
||
function stopProgressSim() {
|
||
if (progressInterval) { clearInterval(progressInterval); progressInterval = null; }
|
||
}
|
||
|
||
function setProgressPct(pct) {
|
||
document.getElementById('progress-fill').style.width = pct + '%';
|
||
document.getElementById('progress-pct').textContent = pct + '%';
|
||
}
|
||
|
||
// ── Polling ───────────────────────────────────────────────────────────────
|
||
function startPolling() {
|
||
pollInterval = setInterval(poll, 2000);
|
||
poll();
|
||
}
|
||
|
||
async function poll() {
|
||
if (!currentJobId) return;
|
||
try {
|
||
const r = await fetch(`/api/status/${currentJobId}`);
|
||
if (r.status === 404) {
|
||
clearInterval(pollInterval);
|
||
stopProgressSim();
|
||
showError(t('err_task_not_found'));
|
||
setUiState('idle');
|
||
return;
|
||
}
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
|
||
if (data.status === 'done') {
|
||
clearInterval(pollInterval);
|
||
stopProgressSim();
|
||
setProgressPct(100);
|
||
setTimeout(() => setUiState('done'), 300);
|
||
} else if (data.status === 'awaiting_payment') {
|
||
clearInterval(pollInterval);
|
||
stopProgressSim();
|
||
showPaywall(currentJobId, data.file_price, data.sub_price);
|
||
} else if (data.status === 'error') {
|
||
clearInterval(pollInterval);
|
||
stopProgressSim();
|
||
showError(data.error || t('err_processing'));
|
||
setUiState('idle');
|
||
} else if (data.status === 'queued') {
|
||
const pos = data.queue_pos || '...';
|
||
const queueFn = (TR[currentLang] || TR['ru']).queue_text;
|
||
document.getElementById('progress-status').textContent = queueFn(pos);
|
||
} else {
|
||
document.getElementById('progress-status').textContent = t('progress_processing');
|
||
}
|
||
} catch(e) { /* network glitch */ }
|
||
}
|
||
|
||
// ── UI state ──────────────────────────────────────────────────────────────
|
||
function setUiState(state) {
|
||
const progressCard = document.getElementById('progress-card');
|
||
const resultCard = document.getElementById('result-card');
|
||
|
||
if (state === 'processing') {
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = t('btn_processing');
|
||
progressCard.style.display = 'block';
|
||
resultCard.style.display = 'none';
|
||
document.getElementById('progress-status').textContent = t('progress_processing');
|
||
document.getElementById('progress-speed').textContent = '';
|
||
startProgressSim();
|
||
} else if (state === 'done') {
|
||
progressCard.style.display = 'none';
|
||
resultCard.style.display = 'block';
|
||
const btn = document.getElementById('download-btn');
|
||
btn.href = `/api/download/${currentJobId}`;
|
||
btn.download = (selectedFile ? selectedFile.name.replace(/\.pdf$/i,'') : 'document') + '_scan.pdf';
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = t('btn_process');
|
||
showDonateCard(filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10);
|
||
} else {
|
||
submitBtn.disabled = !selectedFile;
|
||
submitBtn.textContent = selectedFile ? t('btn_process') : t('btn_select_file');
|
||
progressCard.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function showError(msg) {
|
||
const el = document.getElementById('error-msg');
|
||
el.textContent = msg.startsWith('⚠') ? msg : '⚠ ' + msg;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
function hideError() {
|
||
document.getElementById('error-msg').style.display = 'none';
|
||
}
|
||
|
||
function showInfo(msg) {
|
||
const el = document.getElementById('info-msg');
|
||
el.textContent = msg;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
function hideInfo() {
|
||
document.getElementById('info-msg').style.display = 'none';
|
||
}
|
||
|
||
function resetApp() {
|
||
currentJobId = null;
|
||
selectedFile = null;
|
||
authRequired = false;
|
||
filePages = 0;
|
||
fileSizeMb = 0;
|
||
fileInput.value = '';
|
||
fileBadge.style.display = 'none';
|
||
// Сброс батча
|
||
batchMode = false;
|
||
batchJobs = [];
|
||
if (batchPollInterval) { clearInterval(batchPollInterval); batchPollInterval = null; }
|
||
if (batchTicker) { clearInterval(batchTicker); batchTicker = null; }
|
||
document.getElementById('batch-card').style.display = 'none';
|
||
document.getElementById('batch-actions').style.display = 'none';
|
||
document.getElementById('batch-count').textContent = '';
|
||
document.getElementById('batch-list').innerHTML = '';
|
||
document.querySelector('.drop-title').textContent = t('drop_title');
|
||
document.getElementById('result-card').style.display = 'none';
|
||
document.getElementById('progress-card').style.display = 'none';
|
||
document.getElementById('auth-banner').style.display = 'none';
|
||
hideDonateCard();
|
||
hideError();
|
||
submitBtn.style.display = 'block';
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = t('btn_select_file');
|
||
if (pollInterval) clearInterval(pollInterval);
|
||
stopProgressSim();
|
||
hideInfo();
|
||
document.getElementById('progress-speed').textContent = '';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|