Пейволл больших файлов: 99₽/файл или подписка 299₽/мес (Фаза 1)
- _finalize_submit держит большой файл (awaiting_payment) без активной подписки; account из cookie-сессии (Yandex/VK) или tg user_id - Оплата purpose=file (снимает задание с холда) / sub (+30 дней, снимает холд) - Фронт: карточка пейволла (99₽/подписка), обработка возврата, single+batch - Старый auth-баннер больших файлов заменён пейволлом - Включается флагом PAYWALL_ENABLED (в .env) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1011,8 +1011,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth banner -->
|
||||
<div id="auth-banner">
|
||||
<!-- 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">
|
||||
@@ -1020,6 +1020,20 @@
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -1240,6 +1254,13 @@
|
||||
pay_error: 'Не удалось создать платёж',
|
||||
pay_thanks: '✅ Спасибо за поддержку! Оплата прошла.',
|
||||
pay_canceled: 'Платёж отменён',
|
||||
paywall_title: 'Файл больше бесплатного лимита',
|
||||
paywall_desc: 'Бесплатно — до 50 МБ и 100 страниц. Для файлов больше — разовая оплата или подписка.',
|
||||
paywall_pay_file: 'Оплатить файл',
|
||||
paywall_pay_sub: 'Подписка',
|
||||
paywall_month: 'мес',
|
||||
paywall_cancel: 'Отмена',
|
||||
paywall_need_login: 'Для подписки войдите в аккаунт',
|
||||
seo_title: 'Как работает pdf2scan',
|
||||
seo_step1_title: 'Загрузите файл',
|
||||
seo_step1_desc: 'PDF или Word — перетащите или выберите с устройства',
|
||||
@@ -1348,6 +1369,13 @@
|
||||
pay_error: 'Failed to create payment',
|
||||
pay_thanks: '✅ Thank you for your support! Payment received.',
|
||||
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_pay_file: 'Pay for file',
|
||||
paywall_pay_sub: 'Subscription',
|
||||
paywall_month: 'mo',
|
||||
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',
|
||||
@@ -1456,6 +1484,13 @@
|
||||
pay_error: 'Zahlung konnte nicht erstellt werden',
|
||||
pay_thanks: '✅ Danke für die Unterstützung! Zahlung erhalten.',
|
||||
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_pay_file: 'Datei bezahlen',
|
||||
paywall_pay_sub: 'Abo',
|
||||
paywall_month: 'Mon',
|
||||
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',
|
||||
@@ -1564,6 +1599,13 @@
|
||||
pay_error: 'Échec de création du paiement',
|
||||
pay_thanks: '✅ Merci pour votre soutien ! Paiement reçu.',
|
||||
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_pay_file: 'Payer le fichier',
|
||||
paywall_pay_sub: 'Abonnement',
|
||||
paywall_month: 'mois',
|
||||
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',
|
||||
@@ -1672,6 +1714,13 @@
|
||||
pay_error: 'Impossibile creare il pagamento',
|
||||
pay_thanks: '✅ Grazie per il supporto! Pagamento ricevuto.',
|
||||
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_pay_file: 'Paga file',
|
||||
paywall_pay_sub: 'Abbonamento',
|
||||
paywall_month: 'mese',
|
||||
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',
|
||||
@@ -1780,6 +1829,13 @@
|
||||
pay_error: 'No se pudo crear el pago',
|
||||
pay_thanks: '✅ ¡Gracias por tu apoyo! Pago recibido.',
|
||||
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_pay_file: 'Pagar archivo',
|
||||
paywall_pay_sub: 'Suscripción',
|
||||
paywall_month: 'mes',
|
||||
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',
|
||||
@@ -1888,6 +1944,13 @@
|
||||
pay_error: 'Falha ao criar o pagamento',
|
||||
pay_thanks: '✅ Obrigado pelo apoio! Pagamento recebido.',
|
||||
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_pay_file: 'Pagar arquivo',
|
||||
paywall_pay_sub: 'Assinatura',
|
||||
paywall_month: 'mês',
|
||||
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',
|
||||
@@ -2021,12 +2084,52 @@
|
||||
} catch (e) { msg.textContent = t('pay_error'); btn.disabled = false; }
|
||||
}
|
||||
|
||||
// Возврат с ЮKassa: ?paid=... → опрашиваем статус и показываем результат
|
||||
// ── Пейволл больших файлов ────────────────────────────────────────────────
|
||||
let paywallJob = null, paywallFilePrice = 99, paywallSubPrice = 299;
|
||||
function showPaywall(jobId, filePrice, subPrice) {
|
||||
paywallJob = jobId;
|
||||
paywallFilePrice = filePrice || 99;
|
||||
paywallSubPrice = subPrice || 299;
|
||||
document.getElementById('paywall-file-price').textContent = paywallFilePrice;
|
||||
document.getElementById('paywall-sub-price').textContent = paywallSubPrice;
|
||||
const emailEl = document.getElementById('paywall-email');
|
||||
if (sessionUser && sessionUser.email && !emailEl.value) emailEl.value = sessionUser.email;
|
||||
document.getElementById('progress-card').style.display = 'none';
|
||||
document.getElementById('result-card').style.display = 'none';
|
||||
submitBtn.style.display = 'none';
|
||||
document.getElementById('paywall-card').style.display = 'block';
|
||||
}
|
||||
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 body = { purpose, job_id: paywallJob, email };
|
||||
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
|
||||
const fb = document.getElementById('paywall-file-btn'), sb = document.getElementById('paywall-sub-btn');
|
||||
fb.disabled = true; sb.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(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');
|
||||
}
|
||||
|
||||
// Возврат с Ю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');
|
||||
// чистим URL, чтобы не срабатывало при обновлении
|
||||
const payJob = localStorage.getItem('pdf2scan_pay_job');
|
||||
try { history.replaceState({}, '', location.pathname); } catch (_) {}
|
||||
if (!pid) return;
|
||||
let tries = 0;
|
||||
@@ -2037,11 +2140,21 @@
|
||||
const d = await r.json();
|
||||
if (d.status === 'succeeded') {
|
||||
localStorage.removeItem('pdf2scan_pay_id');
|
||||
showInfo(t('pay_thanks')); return;
|
||||
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();
|
||||
}
|
||||
showInfo(t('pay_thanks'));
|
||||
return;
|
||||
}
|
||||
if (d.status === 'canceled') {
|
||||
localStorage.removeItem('pdf2scan_pay_id'); localStorage.removeItem('pdf2scan_pay_job');
|
||||
showError(t('pay_canceled')); return;
|
||||
}
|
||||
if (d.status === 'canceled') { localStorage.removeItem('pdf2scan_pay_id'); showError(t('pay_canceled')); return; }
|
||||
} catch (_) {}
|
||||
if (tries <= 20) setTimeout(check, 2000);
|
||||
if (tries <= 25) setTimeout(check, 2000);
|
||||
};
|
||||
check();
|
||||
})();
|
||||
@@ -2442,11 +2555,18 @@
|
||||
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;
|
||||
@@ -2477,23 +2597,18 @@
|
||||
}
|
||||
|
||||
function renderAuthBanner() {
|
||||
// Старый баннер авторизации больше не используется — гейт больших файлов
|
||||
// теперь на сервере (пейволл: оплата 99₽ или подписка после отправки).
|
||||
const banner = document.getElementById('auth-banner');
|
||||
if (authRequired && !tgUser) {
|
||||
banner.style.display = 'flex';
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
if (banner) banner.style.display = 'none';
|
||||
}
|
||||
|
||||
function updateSubmitBtn() {
|
||||
renderAuthBanner(); // держим баннер и кнопку в одном состоянии (одинаковое условие)
|
||||
renderAuthBanner();
|
||||
const hasSelection = batchMode ? batchJobs.length > 0 : !!selectedFile;
|
||||
if (!hasSelection) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t('btn_select_file');
|
||||
} else if (authRequired && !tgUser) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t('btn_auth_required');
|
||||
} else {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = batchMode ? t('btn_process_n')(batchJobs.length) : t('btn_process');
|
||||
@@ -2581,8 +2696,7 @@
|
||||
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));
|
||||
const data = await fr.json();
|
||||
return data.job_id;
|
||||
return await fr.json(); // {job_id, requires_payment?, file_price?, sub_price?}
|
||||
}
|
||||
|
||||
async function submitBatch() {
|
||||
@@ -2619,8 +2733,14 @@
|
||||
async function uploadOne(j, s) {
|
||||
j.status = 'uploading'; j.error = null; updateRow(j);
|
||||
try {
|
||||
j.jobId = await uploadFileChunked(j.file, s, null);
|
||||
j.status = 'queued'; j.error = null; updateRow(j);
|
||||
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');
|
||||
@@ -2773,6 +2893,7 @@
|
||||
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) {
|
||||
@@ -2809,13 +2930,15 @@
|
||||
document.getElementById('progress-status').textContent = t('progress_loading');
|
||||
document.getElementById('progress-speed').textContent = '';
|
||||
try {
|
||||
currentJobId = await uploadFileChunked(selectedFile, collectSettings(), (up, tot) => {
|
||||
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);
|
||||
@@ -2827,7 +2950,7 @@
|
||||
function onUploadError(msg) {
|
||||
showError('⚠ ' + msg);
|
||||
document.getElementById('progress-card').style.display = 'none';
|
||||
submitBtn.disabled = !selectedFile || (authRequired && !tgUser);
|
||||
submitBtn.disabled = !selectedFile;
|
||||
submitBtn.textContent = t('btn_process');
|
||||
}
|
||||
|
||||
@@ -2881,6 +3004,10 @@
|
||||
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();
|
||||
@@ -2919,7 +3046,7 @@
|
||||
submitBtn.textContent = t('btn_process');
|
||||
showDonateCard(filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10);
|
||||
} else {
|
||||
submitBtn.disabled = !selectedFile || (authRequired && !tgUser);
|
||||
submitBtn.disabled = !selectedFile;
|
||||
submitBtn.textContent = selectedFile ? t('btn_process') : t('btn_select_file');
|
||||
progressCard.style.display = 'none';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user