From fca88c5e08b8a71de47900cf64ed45ff74cf287f Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 11 Oct 2025 04:39:13 +0300 Subject: [PATCH] feat: redesign autopay controls in miniapp --- miniapp/index.html | 791 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 788 insertions(+), 3 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 1023062b..6e53995b 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -2041,6 +2041,174 @@ border-radius: var(--radius-sm); } + .info-item--autopay { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .info-item--autopay:hover { + padding-left: 0; + padding-right: 0; + margin: 0; + background: none; + } + + .info-item--autopay .info-item-header { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .autopay-controls { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + } + + .autopay-controls.hidden { + display: none; + } + + .autopay-toggle { + display: flex; + gap: 8px; + } + + .autopay-toggle-btn { + flex: 1; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.25s ease; + } + + .autopay-toggle-btn:hover:not(:disabled) { + border-color: var(--primary); + color: var(--primary); + box-shadow: 0 2px 6px rgba(var(--primary-rgb), 0.1); + } + + .autopay-toggle-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .autopay-toggle-btn.active { + background: rgba(var(--primary-rgb), 0.12); + border-color: rgba(var(--primary-rgb), 0.6); + color: var(--primary); + } + + .autopay-slider { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 4px; + } + + .autopay-slider.hidden { + display: none; + } + + .autopay-slider-caption { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + } + + .autopay-slider-input { + width: 100%; + appearance: none; + height: 6px; + border-radius: 999px; + background: var(--border-color); + outline: none; + transition: background 0.3s ease; + } + + .autopay-slider-input::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary); + border: 2px solid var(--bg-primary); + cursor: pointer; + box-shadow: 0 2px 6px rgba(var(--primary-rgb), 0.3); + transition: transform 0.2s ease, background 0.3s ease; + } + + .autopay-slider-input::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary); + border: 2px solid var(--bg-primary); + cursor: pointer; + box-shadow: 0 2px 6px rgba(var(--primary-rgb), 0.3); + transition: transform 0.2s ease, background 0.3s ease; + } + + .autopay-slider-input:disabled::-webkit-slider-thumb, + .autopay-slider-input:disabled::-moz-range-thumb { + background: var(--border-color); + box-shadow: none; + cursor: not-allowed; + } + + .autopay-slider-input::-webkit-slider-thumb:hover, + .autopay-slider-input::-moz-range-thumb:hover { + transform: scale(1.05); + } + + .autopay-slider-marks { + display: flex; + justify-content: space-between; + align-items: flex-start; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + } + + .autopay-slider-mark { + flex: 1; + text-align: center; + position: relative; + padding-top: 6px; + user-select: none; + } + + .autopay-slider-mark::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--border-color); + transform: translateX(-50%); + transition: background 0.3s ease, transform 0.3s ease; + } + + .autopay-slider-mark.active { + color: var(--text-primary); + } + + .autopay-slider-mark.active::before { + background: var(--primary); + transform: translateX(-50%) scale(1.2); + } + .info-label { font-size: 14px; color: var(--text-secondary); @@ -4544,9 +4712,42 @@ Device Limit - -
- Auto-Pay - - +
+
+ Auto-Pay + - +
+
@@ -5134,6 +5335,7 @@ setupSubscriptionSettingsEvents(); setupSubscriptionPurchaseEvents(); setupSubscriptionRenewalEvents(); + setupAutopayControlsEvents(); const themeToggle = document.getElementById('themeToggle'); const THEME_STORAGE_KEY = 'remnawave-miniapp-theme'; @@ -5585,6 +5787,18 @@ 'trial.activation.error.already_active': 'You already have an active subscription.', 'autopay.enabled': 'Enabled', 'autopay.disabled': 'Disabled', + 'autopay.toggle.enable': 'Enable auto-pay', + 'autopay.toggle.disable': 'Disable auto-pay', + 'autopay.update_in_progress': 'Updating…', + 'autopay.slider.caption': 'Charge {count} days before renewal', + 'autopay.slider.caption.one': 'Charge {count} day before renewal', + 'autopay.slider.hint': 'Choose when to charge automatically.', + 'autopay.days.label': '{count} days', + 'autopay.days.label.one': '{count} day', + 'autopay.error.generic': 'Failed to update auto-pay settings. Please try again later.', + 'autopay.success.enabled': 'Auto-pay enabled.', + 'autopay.success.disabled': 'Auto-pay disabled.', + 'autopay.success.updated': 'Auto-pay schedule updated.', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'PC', @@ -5962,6 +6176,18 @@ 'trial.activation.error.already_active': 'У вас уже есть активная подписка.', 'autopay.enabled': 'Включен', 'autopay.disabled': 'Выключен', + 'autopay.toggle.enable': 'Включить автоплатеж', + 'autopay.toggle.disable': 'Отключить автоплатеж', + 'autopay.update_in_progress': 'Обновляем…', + 'autopay.slider.caption': 'Списывать за {count} дн. до окончания', + 'autopay.slider.caption.one': 'Списывать за {count} день до окончания', + 'autopay.slider.hint': 'Выберите, за сколько дней до окончания списывать оплату автоматически.', + 'autopay.days.label': '{count} дн.', + 'autopay.days.label.one': '{count} день', + 'autopay.error.generic': 'Не удалось обновить автоплатеж. Попробуйте позже.', + 'autopay.success.enabled': 'Автоплатеж включен.', + 'autopay.success.disabled': 'Автоплатеж отключен.', + 'autopay.success.updated': 'Настройки автоплатежа обновлены.', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'ПК', @@ -6117,6 +6343,15 @@ const activePaymentMonitors = new Map(); let paymentStatusPollTimer = null; let isPaymentStatusPolling = false; + const autopayState = { + enabled: false, + daysBefore: null, + options: [], + previewDays: null, + updating: false, + pendingAction: null, + available: false, + }; let subscriptionSettingsData = null; let subscriptionSettingsPromise = null; let subscriptionSettingsError = null; @@ -7254,6 +7489,7 @@ detectPlatform(); setActivePlatformButton(); + syncAutopayStateFromUserData(userData); refreshAfterLanguageChange(); animateCardsOnce(); @@ -7446,6 +7682,8 @@ : autopayLabel; } + renderAutopayControls(); + renderSubscriptionMissingCard(); renderSubscriptionPurchaseCard(); renderSubscriptionRenewalCard(); @@ -7463,6 +7701,553 @@ updateActionButtons(); } + function resolveAutopayString(key, fallback) { + const value = t(key); + if (value && value !== key) { + return value; + } + return fallback; + } + + function normalizeAutopaySourceValues(source) { + if (Array.isArray(source)) { + return source; + } + if (source === undefined || source === null) { + return []; + } + if (typeof source === 'number') { + return [source]; + } + if (typeof source === 'string') { + return source + .split(/[,;\s]+/) + .map(entry => entry.trim()) + .filter(Boolean); + } + if (typeof source === 'object') { + const collected = []; + if (Array.isArray(source.options)) { + collected.push(...source.options); + } + if (Array.isArray(source.values)) { + collected.push(...source.values); + } + if (Array.isArray(source.days)) { + collected.push(...source.days); + } + if (Array.isArray(source.list)) { + collected.push(...source.list); + } + ['min', 'max', 'default', 'value', 'days_before', 'daysBefore'].forEach(key => { + if (source[key] !== undefined && source[key] !== null) { + collected.push(source[key]); + } + }); + return collected; + } + return []; + } + + function resolveAutopayOptions(data = userData) { + const sources = [ + data?.autopay_days_options, + data?.autopayDaysOptions, + data?.autopay_warning_days, + data?.autopayWarningDays, + data?.autopay?.days_before_options, + data?.autopay?.daysBeforeOptions, + data?.autopay?.options, + data?.autopay?.values, + appsConfig?.autopay_days_options, + appsConfig?.autopayDaysOptions, + appsConfig?.autopay_warning_days, + appsConfig?.autopayWarningDays, + ]; + const values = new Set(); + sources.forEach(source => { + normalizeAutopaySourceValues(source).forEach(entry => { + const number = coercePositiveInt(entry, null); + if (number !== null && number > 0) { + values.add(number); + } + }); + }); + if (values.size === 0) { + return [1, 3]; + } + return Array.from(values).sort((a, b) => a - b); + } + + function extractAutopayDaysValue(data = userData) { + const candidates = [ + data?.autopay_days_before, + data?.autopayDaysBefore, + data?.autopay?.days_before, + data?.autopay?.daysBefore, + data?.user?.autopay_days_before, + data?.user?.autopayDaysBefore, + ]; + for (const candidate of candidates) { + const value = coercePositiveInt(candidate, null); + if (value !== null) { + return value; + } + } + return null; + } + + function syncAutopayStateFromUserData(data = userData) { + if (!data) { + autopayState.available = false; + autopayState.enabled = false; + autopayState.options = []; + autopayState.daysBefore = null; + autopayState.previewDays = null; + return; + } + + const subscriptionMissing = Boolean(data.subscription_missing ?? data.subscriptionMissing); + const hasSubscription = Boolean(data.subscription_id ?? data.subscriptionId); + const available = hasSubscription && !subscriptionMissing && hasPaidSubscription(); + autopayState.available = available; + + const enabled = Boolean(data.autopay_enabled ?? data.autopayEnabled); + autopayState.enabled = enabled; + + const options = resolveAutopayOptions(data); + const daysValue = extractAutopayDaysValue(data); + + const normalizedOptions = Array.isArray(options) ? options.slice() : []; + if (daysValue !== null && !normalizedOptions.includes(daysValue)) { + normalizedOptions.push(daysValue); + } + normalizedOptions.sort((a, b) => a - b); + autopayState.options = normalizedOptions; + + if (daysValue !== null) { + autopayState.daysBefore = daysValue; + } else if (!autopayState.daysBefore || !normalizedOptions.includes(autopayState.daysBefore)) { + autopayState.daysBefore = normalizedOptions.length + ? normalizedOptions[normalizedOptions.length - 1] + : null; + } + + if (!available) { + autopayState.previewDays = null; + } + } + + function getAutopayActiveDays() { + if (autopayState.previewDays !== null && autopayState.previewDays !== undefined) { + return autopayState.previewDays; + } + if (autopayState.daysBefore !== null && autopayState.daysBefore !== undefined) { + return autopayState.daysBefore; + } + if (autopayState.options.length) { + return autopayState.options[autopayState.options.length - 1]; + } + return null; + } + + function formatAutopayDaysLabel(days) { + const value = coercePositiveInt(days, null); + if (value === null) { + return ''; + } + const key = value === 1 ? 'autopay.days.label.one' : 'autopay.days.label'; + const template = t(key); + if (template && template !== key) { + return template.replace('{count}', String(value)); + } + return value === 1 ? `${value} day` : `${value} days`; + } + + function formatAutopayCaption(days) { + const value = coercePositiveInt(days, null); + if (value === null) { + return resolveAutopayString('autopay.slider.hint', 'Choose when to charge automatically.'); + } + const key = value === 1 ? 'autopay.slider.caption.one' : 'autopay.slider.caption'; + const template = t(key); + if (template && template !== key) { + return template.replace('{count}', String(value)); + } + return value === 1 + ? `Charge ${value} day before renewal` + : `Charge ${value} days before renewal`; + } + + function updateAutopaySliderTrack(slider, activeIndex) { + if (!slider) { + return; + } + const hasMultiple = autopayState.options.length > 1; + if (!autopayState.enabled || !hasMultiple) { + slider.style.background = `linear-gradient(90deg, var(--border-color) 0%, var(--border-color) 100%)`; + return; + } + const maxIndex = autopayState.options.length - 1; + const safeIndex = Math.max(0, Math.min(maxIndex, activeIndex || 0)); + const percentage = (safeIndex / maxIndex) * 100; + slider.style.background = `linear-gradient(90deg, rgba(var(--primary-rgb), 0.7) 0%, rgba(var(--primary-rgb), 0.7) ${percentage}%, var(--border-color) ${percentage}%, var(--border-color) 100%)`; + } + + function renderAutopayControls() { + const controls = document.getElementById('autopayControls'); + if (!controls) { + return; + } + + const shouldShowControls = autopayState.available; + controls.classList.toggle('hidden', !shouldShowControls); + if (!shouldShowControls) { + return; + } + + const enableBtn = document.getElementById('autopayEnableBtn'); + const disableBtn = document.getElementById('autopayDisableBtn'); + const sliderWrapper = document.getElementById('autopaySliderWrapper'); + const slider = document.getElementById('autopaySlider'); + const marksContainer = document.getElementById('autopaySliderMarks'); + const caption = document.getElementById('autopaySliderLabel'); + + const activeDays = getAutopayActiveDays(); + const options = autopayState.options; + const hasSlider = autopayState.enabled && options.length > 0; + + if (enableBtn) { + const key = autopayState.updating && autopayState.pendingAction === 'enable' + ? 'autopay.update_in_progress' + : 'autopay.toggle.enable'; + const fallback = autopayState.updating && autopayState.pendingAction === 'enable' + ? 'Updating…' + : 'Enable auto-pay'; + enableBtn.textContent = resolveAutopayString(key, fallback); + enableBtn.disabled = autopayState.updating || autopayState.enabled; + enableBtn.classList.toggle('active', autopayState.enabled); + enableBtn.setAttribute('aria-pressed', autopayState.enabled ? 'true' : 'false'); + } + + if (disableBtn) { + const key = autopayState.updating && autopayState.pendingAction === 'disable' + ? 'autopay.update_in_progress' + : 'autopay.toggle.disable'; + const fallback = autopayState.updating && autopayState.pendingAction === 'disable' + ? 'Updating…' + : 'Disable auto-pay'; + disableBtn.textContent = resolveAutopayString(key, fallback); + disableBtn.disabled = autopayState.updating || !autopayState.enabled; + disableBtn.classList.toggle('active', !autopayState.enabled); + disableBtn.setAttribute('aria-pressed', autopayState.enabled ? 'false' : 'true'); + } + + if (sliderWrapper) { + sliderWrapper.classList.toggle('hidden', !hasSlider); + } + + if (caption) { + caption.textContent = hasSlider && activeDays !== null + ? formatAutopayCaption(activeDays) + : resolveAutopayString('autopay.slider.hint', 'Choose when to charge automatically.'); + } + + if (slider) { + const maxIndex = Math.max(options.length - 1, 0); + slider.setAttribute('aria-valuemin', '0'); + slider.setAttribute('aria-valuemax', String(maxIndex)); + slider.disabled = autopayState.updating || !autopayState.enabled || options.length <= 1; + + if (options.length > 0) { + let activeIndex = options.findIndex(value => value === activeDays); + if (activeIndex < 0) { + activeIndex = options.length - 1; + } + slider.max = String(Math.max(options.length - 1, 0)); + slider.value = String(Math.max(activeIndex, 0)); + slider.setAttribute('aria-valuenow', activeDays !== null ? String(activeDays) : '0'); + slider.setAttribute('aria-label', formatAutopayCaption(activeDays)); + updateAutopaySliderTrack(slider, activeIndex); + } else { + slider.max = '0'; + slider.value = '0'; + slider.setAttribute('aria-valuenow', '0'); + slider.setAttribute('aria-label', resolveAutopayString('autopay.slider.hint', 'Choose when to charge automatically.')); + slider.style.background = `linear-gradient(90deg, var(--border-color) 0%, var(--border-color) 100%)`; + } + } + + if (marksContainer) { + marksContainer.innerHTML = ''; + if (hasSlider && options.length) { + const activeValue = activeDays; + options.forEach((value, index) => { + const mark = document.createElement('div'); + mark.className = 'autopay-slider-mark'; + if (value === activeValue) { + mark.classList.add('active'); + } + mark.textContent = formatAutopayDaysLabel(value); + if (!slider?.disabled) { + mark.style.cursor = 'pointer'; + mark.addEventListener('click', () => { + if (autopayState.updating || !autopayState.enabled) { + return; + } + const sliderElement = document.getElementById('autopaySlider'); + if (!sliderElement) { + return; + } + sliderElement.value = String(index); + sliderElement.dispatchEvent(new Event('input')); + sliderElement.dispatchEvent(new Event('change')); + }); + } + marksContainer.appendChild(mark); + }); + } + } + } + + function handleAutopayToggle(enabled) { + if (autopayState.updating || !autopayState.available) { + return; + } + if (autopayState.enabled === enabled) { + return; + } + + const previousEnabled = autopayState.enabled; + const previousDays = autopayState.daysBefore; + const targetDays = autopayState.daysBefore + ?? (autopayState.options.length ? autopayState.options[autopayState.options.length - 1] : null); + + autopayState.enabled = enabled; + autopayState.previewDays = null; + renderAutopayControls(); + + submitAutopayUpdate({ + enabled, + daysBefore: targetDays, + action: enabled ? 'enable' : 'disable', + }).catch(error => { + console.warn('Failed to toggle autopay:', error); + autopayState.enabled = previousEnabled; + autopayState.daysBefore = previousDays; + renderAutopayControls(); + }); + } + + function handleAutopaySliderInput(event) { + if (!autopayState.options.length) { + return; + } + const index = Math.round(Number(event.target.value) || 0); + const safeIndex = Math.max(0, Math.min(autopayState.options.length - 1, index)); + autopayState.previewDays = autopayState.options[safeIndex]; + renderAutopayControls(); + } + + function handleAutopaySliderChange(event) { + if (!autopayState.options.length) { + return; + } + const index = Math.round(Number(event.target.value) || 0); + const safeIndex = Math.max(0, Math.min(autopayState.options.length - 1, index)); + const selected = autopayState.options[safeIndex]; + autopayState.previewDays = null; + + if (selected === autopayState.daysBefore) { + renderAutopayControls(); + return; + } + + const previousDays = autopayState.daysBefore; + autopayState.daysBefore = selected; + renderAutopayControls(); + + if (!autopayState.enabled) { + return; + } + + submitAutopayUpdate({ + enabled: true, + daysBefore: selected, + action: 'days', + }).catch(error => { + console.warn('Failed to update autopay days:', error); + autopayState.daysBefore = previousDays; + renderAutopayControls(); + }); + } + + async function submitAutopayUpdate(options) { + const { enabled, daysBefore, action } = options || {}; + const initData = tg.initData || ''; + if (!initData) { + const error = createError('Authorization Error', t('subscription_settings.error.unauthorized')); + showPopup(error.message, resolveAutopayString('info.autopay', 'Auto-pay')); + throw error; + } + + const subscriptionId = userData?.subscription_id ?? userData?.subscriptionId ?? null; + if (!subscriptionId) { + const fallback = resolveAutopayString('autopay.error.generic', 'Failed to update auto-pay settings. Please try again later.'); + const error = createError('Autopay Error', fallback); + showPopup(error.message, resolveAutopayString('info.autopay', 'Auto-pay')); + throw error; + } + + const payload = { + initData, + subscription_id: subscriptionId, + subscriptionId, + enabled, + autopay_enabled: enabled, + autopayEnabled: enabled, + }; + + const daysValue = coercePositiveInt(daysBefore, null); + if (daysValue !== null) { + payload.days_before = daysValue; + payload.daysBefore = daysValue; + payload.autopay_days_before = daysValue; + payload.autopayDaysBefore = daysValue; + } + + autopayState.updating = true; + autopayState.pendingAction = action || (enabled ? 'enable' : 'disable'); + renderAutopayControls(); + + try { + const response = await fetch('/miniapp/subscription/autopay', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const body = await parseJsonSafe(response); + if (!response.ok || (body && body.success === false)) { + const message = extractAutopayError(body, response.status); + throw createError('Autopay update error', message, response.status); + } + + if (!userData) { + userData = {}; + } + userData.autopay_enabled = enabled; + userData.autopayEnabled = enabled; + if (daysValue !== null) { + userData.autopay_days_before = daysValue; + userData.autopayDaysBefore = daysValue; + } + + autopayState.enabled = enabled; + if (daysValue !== null) { + autopayState.daysBefore = daysValue; + } + autopayState.previewDays = null; + + const successKey = action === 'days' + ? 'autopay.success.updated' + : enabled + ? 'autopay.success.enabled' + : 'autopay.success.disabled'; + const successFallback = action === 'days' + ? 'Auto-pay schedule updated.' + : enabled + ? 'Auto-pay enabled.' + : 'Auto-pay disabled.'; + showPopup(resolveAutopayString(successKey, successFallback), resolveAutopayString('info.autopay', 'Auto-pay')); + + renderUserData(); + + try { + await refreshSubscriptionData({ silent: true }); + } catch (refreshError) { + console.warn('Failed to refresh subscription after autopay update:', refreshError); + } + + return true; + } catch (error) { + const message = resolveAutopayErrorMessage(error); + showPopup(message, resolveAutopayString('info.autopay', 'Auto-pay')); + throw error; + } finally { + autopayState.updating = false; + autopayState.pendingAction = null; + renderAutopayControls(); + } + } + + function extractAutopayError(payload, status) { + if (status === 401) { + return t('subscription_settings.error.unauthorized'); + } + if (!payload || typeof payload !== 'object') { + return resolveAutopayString('autopay.error.generic', 'Failed to update auto-pay settings. Please try again later.'); + } + if (typeof payload.detail === 'string') { + return payload.detail; + } + if (payload.detail && typeof payload.detail === 'object') { + if (typeof payload.detail.message === 'string') { + return payload.detail.message; + } + if (typeof payload.detail.error === 'string') { + return payload.detail.error; + } + } + if (typeof payload.message === 'string') { + return payload.message; + } + if (typeof payload.error === 'string') { + return payload.error; + } + return resolveAutopayString('autopay.error.generic', 'Failed to update auto-pay settings. Please try again later.'); + } + + function resolveAutopayErrorMessage(error) { + if (!error) { + return resolveAutopayString('autopay.error.generic', 'Failed to update auto-pay settings. Please try again later.'); + } + if (typeof error === 'string') { + return error; + } + if (typeof error.message === 'string' && error.message.trim()) { + return error.message; + } + if (error.detail) { + if (typeof error.detail === 'string' && error.detail.trim()) { + return error.detail; + } + if (typeof error.detail.message === 'string' && error.detail.message.trim()) { + return error.detail.message; + } + } + if (error.status === 401) { + const unauthorized = t('subscription_settings.error.unauthorized'); + if (unauthorized && unauthorized !== 'subscription_settings.error.unauthorized') { + return unauthorized; + } + } + return resolveAutopayString('autopay.error.generic', 'Failed to update auto-pay settings. Please try again later.'); + } + + function setupAutopayControlsEvents() { + document.getElementById('autopayEnableBtn')?.addEventListener('click', () => { + handleAutopayToggle(true); + }); + document.getElementById('autopayDisableBtn')?.addEventListener('click', () => { + handleAutopayToggle(false); + }); + const slider = document.getElementById('autopaySlider'); + if (slider) { + slider.addEventListener('input', handleAutopaySliderInput); + slider.addEventListener('change', handleAutopaySliderChange); + } + } + function renderSubscriptionMissingCard() { const card = document.getElementById('subscriptionMissingCard'); if (!card) {