Lot F : bouton du portail Stripe et diviseur de questions mesuré
Portail de facturation : le bouton manquait, et l'écran n'affichait rien du tout hors essai — if (isTrialActive) enveloppait le seul bouton, donc un client payant arrivait sur un écran sans action. Désormais Checkout pendant l'essai, portail après ; les deux ne coexistent jamais. Le 409 a son propre message : les instances venues de Mongo n'ont pas de StripeCustomerId, « réessayez plus tard » les ferait attendre quelque chose qui n'arrivera jamais. Diviseur de questions : guide_ia_screen lit aiTokensPerQuestion du serveur au lieu du /1000 codé en dur, par appel http direct — le motif déjà établi dans cet écran pour knowledge et insights. Si l'appel échoue, la ligne « ~N questions » disparaît plutôt que d'afficher un repli : sur une jauge qui sert à décider d'un achat, pas de chiffre vaut mieux qu'un chiffre faux, ce que le /1000 faisait depuis le début à un facteur 10 près. manager_api_new étendu à la main, générateur non relancé. 4 clés i18n FR/EN/NL. flutter analyze lib sans erreur, flutter build web OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3429153135
commit
cf60c098c4
@ -1,6 +1,7 @@
|
|||||||
import 'dart:html' as html;
|
import 'dart:html' as html;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:manager_api_new/api.dart';
|
||||||
import 'package:manager_app/Components/common_loader.dart';
|
import 'package:manager_app/Components/common_loader.dart';
|
||||||
import 'package:manager_app/Components/message_notification.dart';
|
import 'package:manager_app/Components/message_notification.dart';
|
||||||
import 'package:manager_app/Components/rounded_button.dart';
|
import 'package:manager_app/Components/rounded_button.dart';
|
||||||
@ -46,6 +47,31 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Portail Stripe : changer de carte, relire ses factures, résilier. Stripe héberge la page.
|
||||||
|
///
|
||||||
|
/// ⚠️ Le 409 n'est pas une erreur à afficher comme telle : il dit que l'instance n'a pas de
|
||||||
|
/// client Stripe — le cas de **tous les clients venus de la migration Mongo**, qui n'ont
|
||||||
|
/// jamais souscrit en ligne. Leur montrer « réessayez plus tard » les ferait attendre
|
||||||
|
/// quelque chose qui n'arrivera pas ; le message dit que leur facturation passe par nous.
|
||||||
|
Future<void> _openBillingPortal(ManagerAppContext ctx) async {
|
||||||
|
setState(() => isRedirecting = true);
|
||||||
|
try {
|
||||||
|
final url = await ctx.clientAPI!.onboardingApi!.onboardingCreateBillingPortalSession();
|
||||||
|
html.window.open(url, '_blank');
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final message = e.code == 409 ? l.subscriptionPortalNoCustomer : l.subscriptionPortalError;
|
||||||
|
showNotification(kError, kWhite, message, context, null);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
showNotification(kError, kWhite, AppLocalizations.of(context)!.subscriptionPortalError, context, null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => isRedirecting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
String _formatDate(DateTime date) {
|
||||||
final d = date.toLocal();
|
final d = date.toLocal();
|
||||||
return "${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}";
|
return "${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}";
|
||||||
@ -121,17 +147,29 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
if (isTrialActive) ...[
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
if (isRedirecting)
|
||||||
isRedirecting
|
const CommonLoader(iconSize: 32)
|
||||||
? const CommonLoader(iconSize: 32)
|
else if (isTrialActive)
|
||||||
: RoundedButton(
|
RoundedButton(
|
||||||
text: l.subscriptionUpgradeBtn,
|
text: l.subscriptionUpgradeBtn,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
vertical: 14,
|
vertical: 14,
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
press: () => _startCheckout(managerCtx),
|
press: () => _startCheckout(managerCtx),
|
||||||
),
|
)
|
||||||
|
else ...[
|
||||||
|
// Hors essai, l'abonnement est en cours : ce qu'on peut encore vouloir faire ici,
|
||||||
|
// c'est corriger une carte ou relire une facture — pas souscrire une seconde fois.
|
||||||
|
Text(l.subscriptionManageDesc, style: TextStyle(fontSize: 13, color: Colors.grey[700])),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
RoundedButton(
|
||||||
|
text: l.subscriptionManageBtn,
|
||||||
|
fontSize: 15,
|
||||||
|
vertical: 14,
|
||||||
|
horizontal: 24,
|
||||||
|
press: () => _openBillingPortal(managerCtx),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(l.subscriptionAddonsTitle, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
Text(l.subscriptionAddonsTitle, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
|||||||
@ -43,6 +43,9 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
|
|||||||
|
|
||||||
Map<String, dynamic>? _knowledge;
|
Map<String, dynamic>? _knowledge;
|
||||||
|
|
||||||
|
/// Nul tant que le serveur n'a pas répondu : la ligne « ~N questions » ne s'affiche pas.
|
||||||
|
int? _tokensPerQuestion;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -98,6 +101,7 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
|
|||||||
});
|
});
|
||||||
_loadKnowledge();
|
_loadKnowledge();
|
||||||
_loadInsights();
|
_loadInsights();
|
||||||
|
_loadTokensPerQuestion();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
@ -138,6 +142,30 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Même appel direct que les deux ci-dessus. On ne lit qu'une chose : combien de jetons
|
||||||
|
/// coûte une question **chez ce client**, mesuré par le serveur sur ses propres questions.
|
||||||
|
///
|
||||||
|
/// ⚠️ Un échec laisse `_tokensPerQuestion` nul et la ligne « ~N questions » disparaît.
|
||||||
|
/// C'est délibéré : le diviseur codé en dur qui vivait ici valait 1 000, alors que la grille
|
||||||
|
/// vend 20 M de jetons pour ~2 000 questions — soit 10 000. Le gestionnaire lisait un crédit
|
||||||
|
/// **dix fois trop généreux**. Pas de chiffre vaut mieux qu'un chiffre faux sur une jauge
|
||||||
|
/// qui sert à décider d'un achat.
|
||||||
|
Future<void> _loadTokensPerQuestion() async {
|
||||||
|
final ctx = _managerContext;
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ctx.host}/api/Instance/${ctx.instanceId}/quota'),
|
||||||
|
headers: {'Authorization': 'Bearer ${ctx.accessToken}'},
|
||||||
|
);
|
||||||
|
if (!mounted || response.statusCode != 200) return;
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final value = (decoded['aiTokensPerQuestion'] as num?)?.toInt() ?? 0;
|
||||||
|
if (value > 0) setState(() => _tokensPerQuestion = value);
|
||||||
|
} catch (_) {
|
||||||
|
// La ligne « ~N questions » reste masquée.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final instance = _instance;
|
final instance = _instance;
|
||||||
if (instance == null) return;
|
if (instance == null) return;
|
||||||
@ -424,8 +452,11 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
|
|||||||
Widget _usageCard(AppLocalizations l) {
|
Widget _usageCard(AppLocalizations l) {
|
||||||
final used = _instance?.aiTokensThisMonth ?? 0;
|
final used = _instance?.aiTokensThisMonth ?? 0;
|
||||||
final quota = _instance?.aiTokensPerMonth ?? 0;
|
final quota = _instance?.aiTokensPerMonth ?? 0;
|
||||||
// Le gestionnaire raisonne en questions, pas en jetons de traitement.
|
// Le gestionnaire raisonne en questions, pas en jetons de traitement. Le diviseur vient
|
||||||
final questions = (used / 1000).round();
|
// du serveur (`aiTokensPerQuestion`), qui le mesure sur les questions réellement posées
|
||||||
|
// par cette instance — il n'est pas codé ici.
|
||||||
|
final tokensPerQuestion = _tokensPerQuestion;
|
||||||
|
final questions = tokensPerQuestion != null ? (used / tokensPerQuestion).round() : null;
|
||||||
final ratio = quota > 0 ? (used / quota).clamp(0.0, 1.0) : 0.0;
|
final ratio = quota > 0 ? (used / quota).clamp(0.0, 1.0) : 0.0;
|
||||||
|
|
||||||
return _card(
|
return _card(
|
||||||
@ -447,7 +478,8 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(l.guideIaUsageQuestions(questions), style: TextStyle(fontSize: 13, color: kBodyTextColor)),
|
if (questions != null)
|
||||||
|
Text(l.guideIaUsageQuestions(questions), style: TextStyle(fontSize: 13, color: kBodyTextColor)),
|
||||||
] else
|
] else
|
||||||
Text(l.guideIaUsageNoQuota, style: TextStyle(fontSize: 13, color: kBodyTextColor)),
|
Text(l.guideIaUsageNoQuota, style: TextStyle(fontSize: 13, color: kBodyTextColor)),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -45,6 +45,10 @@
|
|||||||
"subscriptionIncludedTitle": "What's included",
|
"subscriptionIncludedTitle": "What's included",
|
||||||
"subscriptionUpgradeBtn": "Upgrade to a paid subscription",
|
"subscriptionUpgradeBtn": "Upgrade to a paid subscription",
|
||||||
"subscriptionCheckoutError": "Could not start checkout, please try again later.",
|
"subscriptionCheckoutError": "Could not start checkout, please try again later.",
|
||||||
|
"subscriptionManageBtn": "Manage my subscription",
|
||||||
|
"subscriptionManageDesc": "Update your card, view invoices or cancel — on Stripe's secure pages.",
|
||||||
|
"subscriptionPortalError": "Could not open the billing portal, please try again later.",
|
||||||
|
"subscriptionPortalNoCustomer": "This instance has no online subscription. Your billing is handled directly with us.",
|
||||||
"subscriptionAddonsTitle": "Add-ons",
|
"subscriptionAddonsTitle": "Add-ons",
|
||||||
"subscriptionAddonsComingSoon": "Coming soon: extra AI requests and other options.",
|
"subscriptionAddonsComingSoon": "Coming soon: extra AI requests and other options.",
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,10 @@
|
|||||||
"subscriptionIncludedTitle": "Ce qui est inclus",
|
"subscriptionIncludedTitle": "Ce qui est inclus",
|
||||||
"subscriptionUpgradeBtn": "Passer à un abonnement payant",
|
"subscriptionUpgradeBtn": "Passer à un abonnement payant",
|
||||||
"subscriptionCheckoutError": "Impossible de démarrer le paiement, réessayez plus tard.",
|
"subscriptionCheckoutError": "Impossible de démarrer le paiement, réessayez plus tard.",
|
||||||
|
"subscriptionManageBtn": "Gérer mon abonnement",
|
||||||
|
"subscriptionManageDesc": "Changer de carte, consulter vos factures ou résilier — sur les pages sécurisées de Stripe.",
|
||||||
|
"subscriptionPortalError": "Impossible d'ouvrir le portail de facturation, réessayez plus tard.",
|
||||||
|
"subscriptionPortalNoCustomer": "Aucun abonnement en ligne n'est rattaché à cette instance. Votre facturation est gérée directement avec nous.",
|
||||||
"subscriptionAddonsTitle": "Add-ons",
|
"subscriptionAddonsTitle": "Add-ons",
|
||||||
"subscriptionAddonsComingSoon": "Bientôt disponible : requêtes IA supplémentaires et autres options.",
|
"subscriptionAddonsComingSoon": "Bientôt disponible : requêtes IA supplémentaires et autres options.",
|
||||||
|
|
||||||
|
|||||||
@ -346,6 +346,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'Impossible de démarrer le paiement, réessayez plus tard.'**
|
/// **'Impossible de démarrer le paiement, réessayez plus tard.'**
|
||||||
String get subscriptionCheckoutError;
|
String get subscriptionCheckoutError;
|
||||||
|
|
||||||
|
/// No description provided for @subscriptionManageBtn.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Gérer mon abonnement'**
|
||||||
|
String get subscriptionManageBtn;
|
||||||
|
|
||||||
|
/// No description provided for @subscriptionManageDesc.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Changer de carte, consulter vos factures ou résilier — sur les pages sécurisées de Stripe.'**
|
||||||
|
String get subscriptionManageDesc;
|
||||||
|
|
||||||
|
/// No description provided for @subscriptionPortalError.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Impossible d\'ouvrir le portail de facturation, réessayez plus tard.'**
|
||||||
|
String get subscriptionPortalError;
|
||||||
|
|
||||||
|
/// No description provided for @subscriptionPortalNoCustomer.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Aucun abonnement en ligne n\'est rattaché à cette instance. Votre facturation est gérée directement avec nous.'**
|
||||||
|
String get subscriptionPortalNoCustomer;
|
||||||
|
|
||||||
/// No description provided for @subscriptionAddonsTitle.
|
/// No description provided for @subscriptionAddonsTitle.
|
||||||
///
|
///
|
||||||
/// In fr, this message translates to:
|
/// In fr, this message translates to:
|
||||||
|
|||||||
@ -138,6 +138,21 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get subscriptionCheckoutError =>
|
String get subscriptionCheckoutError =>
|
||||||
'Could not start checkout, please try again later.';
|
'Could not start checkout, please try again later.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionManageBtn => 'Manage my subscription';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionManageDesc =>
|
||||||
|
'Update your card, view invoices or cancel — on Stripe\'s secure pages.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionPortalError =>
|
||||||
|
'Could not open the billing portal, please try again later.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionPortalNoCustomer =>
|
||||||
|
'This instance has no online subscription. Your billing is handled directly with us.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get subscriptionAddonsTitle => 'Add-ons';
|
String get subscriptionAddonsTitle => 'Add-ons';
|
||||||
|
|
||||||
|
|||||||
@ -141,6 +141,21 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get subscriptionCheckoutError =>
|
String get subscriptionCheckoutError =>
|
||||||
'Impossible de démarrer le paiement, réessayez plus tard.';
|
'Impossible de démarrer le paiement, réessayez plus tard.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionManageBtn => 'Gérer mon abonnement';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionManageDesc =>
|
||||||
|
'Changer de carte, consulter vos factures ou résilier — sur les pages sécurisées de Stripe.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionPortalError =>
|
||||||
|
'Impossible d\'ouvrir le portail de facturation, réessayez plus tard.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionPortalNoCustomer =>
|
||||||
|
'Aucun abonnement en ligne n\'est rattaché à cette instance. Votre facturation est gérée directement avec nous.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get subscriptionAddonsTitle => 'Add-ons';
|
String get subscriptionAddonsTitle => 'Add-ons';
|
||||||
|
|
||||||
|
|||||||
@ -141,6 +141,21 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get subscriptionCheckoutError =>
|
String get subscriptionCheckoutError =>
|
||||||
'Kon de betaling niet starten, probeer het later opnieuw.';
|
'Kon de betaling niet starten, probeer het later opnieuw.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionManageBtn => 'Mijn abonnement beheren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionManageDesc =>
|
||||||
|
'Kaart wijzigen, facturen bekijken of opzeggen — op de beveiligde pagina\'s van Stripe.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionPortalError =>
|
||||||
|
'Kon het factuurportaal niet openen, probeer het later opnieuw.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subscriptionPortalNoCustomer =>
|
||||||
|
'Deze instantie heeft geen online abonnement. Uw facturatie verloopt rechtstreeks via ons.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get subscriptionAddonsTitle => 'Add-ons';
|
String get subscriptionAddonsTitle => 'Add-ons';
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,10 @@
|
|||||||
"subscriptionIncludedTitle": "Wat is inbegrepen",
|
"subscriptionIncludedTitle": "Wat is inbegrepen",
|
||||||
"subscriptionUpgradeBtn": "Overstappen naar een betaald abonnement",
|
"subscriptionUpgradeBtn": "Overstappen naar een betaald abonnement",
|
||||||
"subscriptionCheckoutError": "Kon de betaling niet starten, probeer het later opnieuw.",
|
"subscriptionCheckoutError": "Kon de betaling niet starten, probeer het later opnieuw.",
|
||||||
|
"subscriptionManageBtn": "Mijn abonnement beheren",
|
||||||
|
"subscriptionManageDesc": "Kaart wijzigen, facturen bekijken of opzeggen — op de beveiligde pagina's van Stripe.",
|
||||||
|
"subscriptionPortalError": "Kon het factuurportaal niet openen, probeer het later opnieuw.",
|
||||||
|
"subscriptionPortalNoCustomer": "Deze instantie heeft geen online abonnement. Uw facturatie verloopt rechtstreeks via ons.",
|
||||||
"subscriptionAddonsTitle": "Add-ons",
|
"subscriptionAddonsTitle": "Add-ons",
|
||||||
"subscriptionAddonsComingSoon": "Binnenkort beschikbaar: extra AI-verzoeken en andere opties.",
|
"subscriptionAddonsComingSoon": "Binnenkort beschikbaar: extra AI-verzoeken en andere opties.",
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
//
|
//
|
||||||
// Manually added (not generated) — mirrors OnboardingController.CreateCheckoutSession.
|
// Manually added (not generated) — mirrors OnboardingController.CreateCheckoutSession
|
||||||
|
// and CreateBillingPortalSession.
|
||||||
// Do not run the OpenAPI generator on this file, it would be overwritten.
|
// Do not run the OpenAPI generator on this file, it would be overwritten.
|
||||||
//
|
//
|
||||||
// @dart=2.18
|
// @dart=2.18
|
||||||
@ -43,4 +44,41 @@ class OnboardingApi {
|
|||||||
final decoded = jsonDecode(body) as Map<String, dynamic>;
|
final decoded = jsonDecode(body) as Map<String, dynamic>;
|
||||||
return decoded['url'] as String;
|
return decoded['url'] as String;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'POST /api/onboarding/billing-portal' operation and returns the [Response].
|
||||||
|
Future<Response> onboardingCreateBillingPortalSessionWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/api/onboarding/billing-portal';
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'POST',
|
||||||
|
queryParams,
|
||||||
|
null,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the Stripe Billing Portal URL to redirect the user to.
|
||||||
|
///
|
||||||
|
/// Throws an [ApiException] with status 409 when the instance has no Stripe customer —
|
||||||
|
/// the case of every instance that came from the Mongo migration rather than from
|
||||||
|
/// self-service sign-up. The caller must tell those two apart.
|
||||||
|
Future<String> onboardingCreateBillingPortalSession() async {
|
||||||
|
final response = await onboardingCreateBillingPortalSessionWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
final body = await _decodeBodyBytes(response);
|
||||||
|
final decoded = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
return decoded['url'] as String;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user