mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-22 20:31:47 +00:00
Merge pull request #1065 from Fr1ngg/a0y28m-bedolaga/expand-api-for-subscription-purchase
Improve miniapp subscription configurator responsiveness
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -360,10 +360,13 @@ class MiniAppSubscriptionServerOption(BaseModel):
|
||||
name: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
original_price_kopeks: Optional[int] = None
|
||||
original_price_label: Optional[str] = None
|
||||
discount_percent: Optional[int] = None
|
||||
is_connected: bool = False
|
||||
is_available: bool = True
|
||||
disabled_reason: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrafficOption(BaseModel):
|
||||
@@ -371,8 +374,11 @@ class MiniAppSubscriptionTrafficOption(BaseModel):
|
||||
label: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
original_price_kopeks: Optional[int] = None
|
||||
original_price_label: Optional[str] = None
|
||||
is_current: bool = False
|
||||
is_available: bool = True
|
||||
is_default: bool = False
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@@ -381,6 +387,10 @@ class MiniAppSubscriptionDeviceOption(BaseModel):
|
||||
label: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
original_price_kopeks: Optional[int] = None
|
||||
original_price_label: Optional[str] = None
|
||||
included: Optional[int] = None
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class MiniAppSubscriptionCurrentSettings(BaseModel):
|
||||
@@ -411,6 +421,8 @@ class MiniAppSubscriptionDevicesSettings(BaseModel):
|
||||
max: int = 0
|
||||
step: int = 1
|
||||
current: int = 0
|
||||
default: Optional[int] = None
|
||||
included: Optional[int] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
|
||||
@@ -524,3 +536,188 @@ class MiniAppSubscriptionUpdateResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
def _merge_purchase_selection(values: Any) -> Any:
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
|
||||
alias_map = {
|
||||
"periodId": "period_id",
|
||||
"periodDays": "period_days",
|
||||
"periodMonths": "period_months",
|
||||
"trafficValue": "traffic_value",
|
||||
"trafficGb": "traffic_gb",
|
||||
"serverUuids": "server_uuids",
|
||||
"squadUuids": "squad_uuids",
|
||||
"deviceLimit": "device_limit",
|
||||
}
|
||||
|
||||
selection = values.get("selection")
|
||||
if isinstance(selection, dict):
|
||||
for alias, target in alias_map.items():
|
||||
if alias in selection and target not in values:
|
||||
values[target] = selection[alias]
|
||||
for key in ("servers", "server_uuids", "squads", "squad_uuids"):
|
||||
if key in selection and key not in values:
|
||||
values[key] = selection[key]
|
||||
if "devices" in selection and "devices" not in values:
|
||||
values["devices"] = selection["devices"]
|
||||
if "traffic" in selection and "traffic" not in values:
|
||||
values["traffic"] = selection["traffic"]
|
||||
|
||||
for alias, target in alias_map.items():
|
||||
if alias in values and target not in values:
|
||||
values[target] = values[alias]
|
||||
|
||||
return values
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchasePeriod(BaseModel):
|
||||
id: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
period_days: Optional[int] = Field(None, alias="periodDays")
|
||||
period_months: Optional[int] = Field(None, alias="periodMonths")
|
||||
months: Optional[int] = None
|
||||
label: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
original_price_kopeks: Optional[int] = None
|
||||
original_price_label: Optional[str] = None
|
||||
per_month_price_kopeks: Optional[int] = None
|
||||
per_month_price_label: Optional[str] = None
|
||||
discount_percent: Optional[int] = None
|
||||
is_available: bool = True
|
||||
promo_badges: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseTrafficConfig(BaseModel):
|
||||
selectable: bool = True
|
||||
mode: str = "selectable"
|
||||
options: List[MiniAppSubscriptionTrafficOption] = Field(default_factory=list)
|
||||
default: Optional[int] = None
|
||||
current: Optional[int] = None
|
||||
hint: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseServersConfig(BaseModel):
|
||||
selectable: bool = True
|
||||
min: int = 0
|
||||
max: int = 0
|
||||
options: List[MiniAppSubscriptionServerOption] = Field(default_factory=list)
|
||||
default: List[str] = Field(default_factory=list)
|
||||
selected: List[str] = Field(default_factory=list)
|
||||
hint: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseDevicesConfig(BaseModel):
|
||||
min: int = 1
|
||||
max: Optional[int] = None
|
||||
step: int = 1
|
||||
default: Optional[int] = None
|
||||
current: Optional[int] = None
|
||||
included: Optional[int] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
original_price_kopeks: Optional[int] = None
|
||||
original_price_label: Optional[str] = None
|
||||
hint: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseOptions(BaseModel):
|
||||
currency: str = "RUB"
|
||||
balance_kopeks: Optional[int] = None
|
||||
balance_label: Optional[str] = None
|
||||
subscription_id: Optional[int] = Field(None, alias="subscriptionId")
|
||||
periods: List[MiniAppSubscriptionPurchasePeriod] = Field(default_factory=list)
|
||||
traffic: MiniAppSubscriptionPurchaseTrafficConfig = Field(default_factory=MiniAppSubscriptionPurchaseTrafficConfig)
|
||||
servers: MiniAppSubscriptionPurchaseServersConfig = Field(default_factory=MiniAppSubscriptionPurchaseServersConfig)
|
||||
devices: MiniAppSubscriptionPurchaseDevicesConfig = Field(default_factory=MiniAppSubscriptionPurchaseDevicesConfig)
|
||||
selection: Dict[str, Any] = Field(default_factory=dict)
|
||||
promo: Optional[Dict[str, Any]] = None
|
||||
summary: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(None, alias="subscriptionId")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _normalize(cls, values: Any) -> Any:
|
||||
return _merge_purchase_selection(values)
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseOptionsResponse(BaseModel):
|
||||
success: bool = True
|
||||
data: MiniAppSubscriptionPurchaseOptions
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchasePreviewRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(None, alias="subscriptionId")
|
||||
selection: Optional[Dict[str, Any]] = None
|
||||
period_id: Optional[str] = Field(None, alias="periodId")
|
||||
period_days: Optional[int] = Field(None, alias="periodDays")
|
||||
period_months: Optional[int] = Field(None, alias="periodMonths")
|
||||
traffic_value: Optional[int] = Field(None, alias="trafficValue")
|
||||
traffic_gb: Optional[int] = Field(None, alias="trafficGb")
|
||||
traffic: Optional[int] = None
|
||||
servers: Optional[List[str]] = None
|
||||
server_uuids: Optional[List[str]] = Field(None, alias="serverUuids")
|
||||
squads: Optional[List[str]] = None
|
||||
squad_uuids: Optional[List[str]] = Field(None, alias="squadUuids")
|
||||
devices: Optional[int] = None
|
||||
device_limit: Optional[int] = Field(None, alias="deviceLimit")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _normalize(cls, values: Any) -> Any:
|
||||
return _merge_purchase_selection(values)
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchasePreviewItem(BaseModel):
|
||||
label: str
|
||||
value: str
|
||||
highlight: bool = False
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchasePreview(BaseModel):
|
||||
total_price_kopeks: Optional[int] = Field(None, alias="totalPriceKopeks")
|
||||
total_price_label: Optional[str] = Field(None, alias="totalPriceLabel")
|
||||
original_price_kopeks: Optional[int] = Field(None, alias="originalPriceKopeks")
|
||||
original_price_label: Optional[str] = Field(None, alias="originalPriceLabel")
|
||||
per_month_price_kopeks: Optional[int] = Field(None, alias="perMonthPriceKopeks")
|
||||
per_month_price_label: Optional[str] = Field(None, alias="perMonthPriceLabel")
|
||||
discount_percent: Optional[int] = None
|
||||
discount_label: Optional[str] = None
|
||||
discount_lines: List[str] = Field(default_factory=list)
|
||||
breakdown: List[MiniAppSubscriptionPurchasePreviewItem] = Field(default_factory=list)
|
||||
balance_kopeks: Optional[int] = None
|
||||
balance_label: Optional[str] = None
|
||||
missing_amount_kopeks: Optional[int] = None
|
||||
missing_amount_label: Optional[str] = None
|
||||
can_purchase: bool = True
|
||||
status_message: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchasePreviewResponse(BaseModel):
|
||||
success: bool = True
|
||||
preview: MiniAppSubscriptionPurchasePreview
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseSubmitRequest(MiniAppSubscriptionPurchasePreviewRequest):
|
||||
pass
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseSubmitResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
balance_kopeks: Optional[int] = None
|
||||
balance_label: Optional[str] = None
|
||||
|
||||
|
||||
@@ -4347,6 +4347,9 @@
|
||||
'subscription_purchase.servers.empty': 'No servers available',
|
||||
'subscription_purchase.servers.single': 'Included server: {name}',
|
||||
'subscription_purchase.servers.limit': 'Select up to {count}',
|
||||
'subscription_purchase.servers.limit_max': 'Select up to {count}',
|
||||
'subscription_purchase.servers.limit_exact': 'Select {count}',
|
||||
'subscription_purchase.servers.limit_min': 'Select at least {count}',
|
||||
'subscription_purchase.servers.selected': 'Selected: {count}',
|
||||
'subscription_purchase.devices.title': 'Devices',
|
||||
'subscription_purchase.devices.subtitle': 'Simultaneous connections.',
|
||||
@@ -4673,6 +4676,9 @@
|
||||
'subscription_purchase.servers.empty': 'Нет доступных серверов',
|
||||
'subscription_purchase.servers.single': 'Включён сервер: {name}',
|
||||
'subscription_purchase.servers.limit': 'Можно выбрать до {count}',
|
||||
'subscription_purchase.servers.limit_max': 'Можно выбрать до {count}',
|
||||
'subscription_purchase.servers.limit_exact': 'Выберите {count}',
|
||||
'subscription_purchase.servers.limit_min': 'Нужно выбрать минимум {count}',
|
||||
'subscription_purchase.servers.selected': 'Выбрано: {count}',
|
||||
'subscription_purchase.devices.title': 'Устройства',
|
||||
'subscription_purchase.devices.subtitle': 'Одновременные подключения.',
|
||||
@@ -5054,6 +5060,8 @@
|
||||
let subscriptionPurchaseFeatureEnabled = false;
|
||||
let subscriptionPurchaseModalOpen = false;
|
||||
let subscriptionPurchasePreviewUpdateHandle = null;
|
||||
let subscriptionPurchasePreviewRefreshQueued = false;
|
||||
let subscriptionPurchasePreviewRefreshPromise = null;
|
||||
const subscriptionPurchaseSelections = {
|
||||
periodId: null,
|
||||
trafficValue: null,
|
||||
@@ -11228,43 +11236,65 @@
|
||||
const currency = (subscriptionPurchaseData?.currency || userData?.balance_currency || 'RUB').toString().toUpperCase();
|
||||
const options = ensureArray(config?.options || config?.available || []);
|
||||
const normalizedOptions = options.map(option => normalizePurchaseServerOption(option, currency)).filter(Boolean);
|
||||
const availableUuids = normalizedOptions.map(option => option.uuid).filter(Boolean);
|
||||
const availableOptions = normalizedOptions.filter(option => option.isAvailable && option.uuid);
|
||||
const availableUuids = availableOptions.map(option => String(option.uuid));
|
||||
const minSelectable = coercePositiveInt(config?.min ?? config?.min_selectable ?? config?.minRequired, 0) || 0;
|
||||
const maxSelectable = coercePositiveInt(config?.max ?? config?.max_selectable ?? config?.maxAllowed, 0) || 0;
|
||||
const selection = subscriptionPurchaseSelections.servers instanceof Set
|
||||
? new Set(subscriptionPurchaseSelections.servers)
|
||||
: new Set();
|
||||
const selectable = config?.selectable !== false && availableUuids.length > 1;
|
||||
|
||||
Array.from(selection).forEach(value => {
|
||||
if (!availableUuids.includes(String(value))) {
|
||||
selection.delete(value);
|
||||
const selectionValues = [];
|
||||
const selection = subscriptionPurchaseSelections.servers instanceof Set
|
||||
? Array.from(subscriptionPurchaseSelections.servers)
|
||||
: [];
|
||||
|
||||
selection.map(value => String(value)).forEach(value => {
|
||||
if (availableUuids.includes(value) && !selectionValues.includes(value)) {
|
||||
selectionValues.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
const selectable = config?.selectable !== false && availableUuids.length > 1 && (maxSelectable === 0 || maxSelectable > 1 || minSelectable === 0);
|
||||
if (!selectable) {
|
||||
selection.clear();
|
||||
selectionValues.length = 0;
|
||||
if (availableUuids[0]) {
|
||||
selection.add(availableUuids[0]);
|
||||
selectionValues.push(availableUuids[0]);
|
||||
}
|
||||
subscriptionPurchaseSelections.servers = selection;
|
||||
subscriptionPurchaseSelections.servers = new Set(selectionValues);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection.size) {
|
||||
if (!selectionValues.length) {
|
||||
const defaults = ensureArray(config?.selected || config?.default || config?.current || config?.preselected || []);
|
||||
defaults.map(String).forEach(uuid => {
|
||||
if (availableUuids.includes(uuid)) {
|
||||
selection.add(uuid);
|
||||
defaults.map(value => String(value)).forEach(uuid => {
|
||||
if (availableUuids.includes(uuid) && !selectionValues.includes(uuid)) {
|
||||
selectionValues.push(uuid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!selection.size && minSelectable > 0) {
|
||||
availableUuids.slice(0, minSelectable).forEach(uuid => selection.add(uuid));
|
||||
if (!selectionValues.length && minSelectable > 0) {
|
||||
availableUuids.slice(0, minSelectable).forEach(uuid => {
|
||||
if (!selectionValues.includes(uuid)) {
|
||||
selectionValues.push(uuid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscriptionPurchaseSelections.servers = selection;
|
||||
if (maxSelectable && selectionValues.length > maxSelectable) {
|
||||
selectionValues.length = maxSelectable;
|
||||
}
|
||||
|
||||
if (minSelectable && selectionValues.length < minSelectable) {
|
||||
availableUuids.forEach(uuid => {
|
||||
if (selectionValues.length >= minSelectable) {
|
||||
return;
|
||||
}
|
||||
if (!selectionValues.includes(uuid)) {
|
||||
selectionValues.push(uuid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscriptionPurchaseSelections.servers = new Set(selectionValues);
|
||||
}
|
||||
|
||||
function ensurePurchaseDevicesSelection(config) {
|
||||
@@ -12069,7 +12099,10 @@
|
||||
}
|
||||
|
||||
const config = getSubscriptionPurchaseServersConfig(period);
|
||||
const currency = getSubscriptionPurchaseCurrency();
|
||||
const options = ensureArray(config?.options || config?.available || []);
|
||||
const normalizedOptions = options.map(option => normalizePurchaseServerOption(option, currency)).filter(Boolean);
|
||||
const availableOptions = normalizedOptions.filter(option => option.isAvailable);
|
||||
const selection = subscriptionPurchaseSelections.servers instanceof Set
|
||||
? new Set(subscriptionPurchaseSelections.servers)
|
||||
: new Set();
|
||||
@@ -12078,10 +12111,7 @@
|
||||
|
||||
const minSelectable = coercePositiveInt(config?.min ?? config?.min_selectable ?? config?.minRequired, 0) || 0;
|
||||
const maxSelectable = coercePositiveInt(config?.max ?? config?.max_selectable ?? config?.maxAllowed, 0) || 0;
|
||||
const selectable = config?.selectable !== false && options.length > 1;
|
||||
|
||||
const currency = getSubscriptionPurchaseCurrency();
|
||||
const normalizedOptions = options.map(option => normalizePurchaseServerOption(option, currency)).filter(Boolean);
|
||||
const selectable = config?.selectable !== false && availableOptions.length > 1;
|
||||
|
||||
if (!normalizedOptions.length) {
|
||||
emptyElement?.classList.remove('hidden');
|
||||
@@ -12099,7 +12129,9 @@
|
||||
emptyElement?.classList.add('hidden');
|
||||
|
||||
if (!selectable) {
|
||||
const selected = normalizedOptions.find(option => selection.has(option.uuid)) || normalizedOptions[0];
|
||||
const selected = normalizedOptions.find(option => selection.has(option.uuid))
|
||||
|| availableOptions[0]
|
||||
|| normalizedOptions[0];
|
||||
selection.clear();
|
||||
if (selected?.uuid) {
|
||||
selection.add(selected.uuid);
|
||||
@@ -12192,8 +12224,10 @@
|
||||
|
||||
if (metaElement) {
|
||||
let metaText = '';
|
||||
if (maxSelectable && maxSelectable !== minSelectable) {
|
||||
metaText = t('subscription_purchase.servers.limit').replace('{count}', String(maxSelectable));
|
||||
if (maxSelectable && minSelectable && maxSelectable === minSelectable) {
|
||||
metaText = t('subscription_purchase.servers.limit_exact').replace('{count}', String(maxSelectable));
|
||||
} else if (maxSelectable) {
|
||||
metaText = t('subscription_purchase.servers.limit_max').replace('{count}', String(maxSelectable));
|
||||
} else if (selectedCount) {
|
||||
metaText = t('subscription_purchase.servers.selected').replace('{count}', String(selectedCount));
|
||||
}
|
||||
@@ -12206,7 +12240,7 @@
|
||||
hintParts.push(config.hint);
|
||||
}
|
||||
if (!config?.hint && minSelectable > 0) {
|
||||
hintParts.push(t('subscription_purchase.servers.limit').replace('{count}', String(minSelectable)));
|
||||
hintParts.push(t('subscription_purchase.servers.limit_min').replace('{count}', String(minSelectable)));
|
||||
}
|
||||
const hintText = hintParts.join(' ');
|
||||
hintElement.textContent = hintText;
|
||||
@@ -12392,6 +12426,7 @@
|
||||
|
||||
function requestSubscriptionPurchasePreviewUpdate(options = {}) {
|
||||
const { delay = 250 } = options;
|
||||
subscriptionPurchasePreviewRefreshQueued = true;
|
||||
if (subscriptionPurchasePreviewUpdateHandle) {
|
||||
clearTimeout(subscriptionPurchasePreviewUpdateHandle);
|
||||
}
|
||||
@@ -12414,30 +12449,46 @@
|
||||
}
|
||||
|
||||
if (!shouldShowPurchaseConfigurator()) {
|
||||
subscriptionPurchasePreviewRefreshQueued = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!subscriptionPurchaseData) {
|
||||
subscriptionPurchasePreviewRefreshQueued = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const initData = tg.initData || '';
|
||||
if (!initData) {
|
||||
subscriptionPurchasePreviewRefreshQueued = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscriptionPurchasePreviewLoading && !force) {
|
||||
return subscriptionPurchasePreviewPromise || Promise.resolve(subscriptionPurchasePreview);
|
||||
if (!subscriptionPurchasePreviewRefreshPromise) {
|
||||
const pending = subscriptionPurchasePreviewPromise || Promise.resolve(subscriptionPurchasePreview);
|
||||
subscriptionPurchasePreviewRefreshPromise = pending.finally(() => {
|
||||
subscriptionPurchasePreviewRefreshPromise = null;
|
||||
if (subscriptionPurchasePreviewRefreshQueued) {
|
||||
updateSubscriptionPurchasePreview({ immediate: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
return subscriptionPurchasePreviewPromise || subscriptionPurchasePreviewRefreshPromise;
|
||||
}
|
||||
|
||||
subscriptionPurchasePreviewRefreshQueued = false;
|
||||
|
||||
const period = getSelectedSubscriptionPurchasePeriod();
|
||||
if (!period) {
|
||||
subscriptionPurchasePreviewRefreshQueued = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
ensureSubscriptionPurchaseSelectionsValidForPeriod(period);
|
||||
const selection = buildSubscriptionPurchaseSelectionPayload(period);
|
||||
if (!selection.period_id && !selection.periodId) {
|
||||
subscriptionPurchasePreviewRefreshQueued = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user