feat: redesign autopay controls in miniapp

This commit is contained in:
Egor
2025-10-11 04:39:13 +03:00
parent 55dcb61798
commit fca88c5e08

View File

@@ -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 @@
<span class="info-label" data-i18n="info.device_limit">Device Limit</span>
<span class="info-value" id="deviceLimit">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.autopay">Auto-Pay</span>
<span class="info-value" id="autopayStatus">-</span>
<div class="info-item info-item--autopay" id="autopayInfoItem">
<div class="info-item-header">
<span class="info-label" data-i18n="info.autopay">Auto-Pay</span>
<span class="info-value" id="autopayStatus">-</span>
</div>
<div class="autopay-controls hidden" id="autopayControls">
<div class="autopay-toggle">
<button
class="autopay-toggle-btn"
type="button"
id="autopayEnableBtn"
data-i18n="autopay.toggle.enable"
>Enable auto-pay</button>
<button
class="autopay-toggle-btn"
type="button"
id="autopayDisableBtn"
data-i18n="autopay.toggle.disable"
>Disable auto-pay</button>
</div>
<div class="autopay-slider hidden" id="autopaySliderWrapper">
<div class="autopay-slider-caption" id="autopaySliderLabel"></div>
<input
class="autopay-slider-input"
id="autopaySlider"
type="range"
min="0"
max="0"
step="1"
value="0"
aria-valuemin="0"
aria-valuemax="0"
/>
<div class="autopay-slider-marks" id="autopaySliderMarks"></div>
</div>
</div>
</div>
</div>
</div>
@@ -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) {