Handle missing period selections in miniapp preview

This commit is contained in:
Egor
2025-10-10 09:52:50 +03:00
parent 390874565e
commit 851f4dc58c
3 changed files with 1630 additions and 8 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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,194 @@ 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",
"period": "period_id",
"period_key": "period_id",
"periodKey": "period_id",
"code": "period_id",
"durationDays": "period_days",
"duration_days": "period_days",
"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

View File

@@ -4347,7 +4347,10 @@
'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.minimum': 'Select at least {count}',
'subscription_purchase.servers.selected': 'Selected: {count}',
'subscription_purchase.servers.selected_of': 'Selected: {selected}/{total}',
'subscription_purchase.servers.exact': 'Select {count}',
'subscription_purchase.devices.title': 'Devices',
'subscription_purchase.devices.subtitle': 'Simultaneous connections.',
'subscription_purchase.devices.unlimited': 'Unlimited devices',
@@ -4673,7 +4676,10 @@
'subscription_purchase.servers.empty': 'Нет доступных серверов',
'subscription_purchase.servers.single': 'Включён сервер: {name}',
'subscription_purchase.servers.limit': 'Можно выбрать до {count}',
'subscription_purchase.servers.minimum': 'Нужно выбрать минимум {count}',
'subscription_purchase.servers.selected': 'Выбрано: {count}',
'subscription_purchase.servers.selected_of': 'Выбрано: {selected} из {total}',
'subscription_purchase.servers.exact': 'Нужно выбрать {count}',
'subscription_purchase.devices.title': 'Устройства',
'subscription_purchase.devices.subtitle': 'Одновременные подключения.',
'subscription_purchase.devices.unlimited': 'Безлимитное число устройств',
@@ -11264,6 +11270,16 @@
availableUuids.slice(0, minSelectable).forEach(uuid => selection.add(uuid));
}
if (maxSelectable && maxSelectable > 0 && selection.size > maxSelectable) {
const prioritized = availableUuids.filter(uuid => selection.has(uuid));
selection.clear();
prioritized.slice(0, maxSelectable).forEach(uuid => selection.add(uuid));
}
if (minSelectable > 0 && selection.size < minSelectable) {
availableUuids.slice(0, minSelectable).forEach(uuid => selection.add(uuid));
}
subscriptionPurchaseSelections.servers = selection;
}
@@ -12192,8 +12208,16 @@
if (metaElement) {
let metaText = '';
if (maxSelectable && maxSelectable !== minSelectable) {
metaText = t('subscription_purchase.servers.limit').replace('{count}', String(maxSelectable));
if (maxSelectable && maxSelectable > 0 && maxSelectable !== minSelectable) {
if (selectedCount) {
metaText = t('subscription_purchase.servers.selected_of')
.replace('{selected}', String(selectedCount))
.replace('{total}', String(maxSelectable));
} else {
metaText = t('subscription_purchase.servers.limit').replace('{count}', String(maxSelectable));
}
} else if (maxSelectable && maxSelectable > 0 && maxSelectable === minSelectable) {
metaText = t('subscription_purchase.servers.exact').replace('{count}', String(maxSelectable));
} else if (selectedCount) {
metaText = t('subscription_purchase.servers.selected').replace('{count}', String(selectedCount));
}
@@ -12205,8 +12229,11 @@
if (config?.hint) {
hintParts.push(config.hint);
}
if (!config?.hint && minSelectable > 0) {
hintParts.push(t('subscription_purchase.servers.limit').replace('{count}', String(minSelectable)));
if (minSelectable > 0) {
const key = maxSelectable && maxSelectable > 0 && maxSelectable === minSelectable
? 'subscription_purchase.servers.exact'
: 'subscription_purchase.servers.minimum';
hintParts.push(t(key).replace('{count}', String(minSelectable)));
}
const hintText = hintParts.join(' ');
hintElement.textContent = hintText;
@@ -12636,17 +12663,36 @@
if (!uuid) {
return;
}
const period = getSelectedSubscriptionPurchasePeriod();
const config = getSubscriptionPurchaseServersConfig(period);
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();
if (selection.has(uuid)) {
if (minSelectable && selection.size <= minSelectable) {
return;
}
selection.delete(uuid);
} else {
if (maxSelectable && maxSelectable > 0 && selection.size >= maxSelectable) {
if (maxSelectable === 1) {
selection.clear();
} else {
const ordered = Array.from(selection);
while (selection.size >= maxSelectable && ordered.length) {
const oldest = ordered.shift();
if (oldest === undefined) {
break;
}
selection.delete(oldest);
}
}
}
selection.add(uuid);
}
subscriptionPurchaseSelections.servers = selection;
const period = getSelectedSubscriptionPurchasePeriod();
const config = getSubscriptionPurchaseServersConfig(period);
ensurePurchaseServersSelection(config);
renderSubscriptionPurchaseCard();
requestSubscriptionPurchasePreviewUpdate();