add resiliation
This commit is contained in:
@@ -13,7 +13,7 @@ export default function NewServiceCard({ onClick }) {
|
|||||||
Demander une accréditation
|
Demander une accréditation
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
Déployer un nouveau serveur Web, VPS ou Cloud.
|
Déployer un nouveau serveur Web, VPS, DB ou Cloud.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react';
|
import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react';
|
||||||
import { getProductList, getServiceDetails } from '../../services/api';
|
import { getProductList, getServiceDetails, cancelOrder } from '../../services/billing_api';
|
||||||
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
|
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
|
||||||
|
|
||||||
export default function WebServiceSubscriptionManager({ order, onClose, onRefresh, onAlert }) {
|
export default function WebServiceSubscriptionManager({ order, onClose, onRefresh, onAlert }) {
|
||||||
@@ -15,6 +15,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
|
|
||||||
const [billingPeriod, setBillingPeriod] = useState(order.period || '1M');
|
const [billingPeriod, setBillingPeriod] = useState(order.period || '1M');
|
||||||
|
|
||||||
|
// 🎯 NOUVEAU : État pour la modale de résiliation
|
||||||
|
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
|
||||||
|
|
||||||
// 1. Récupération des détails techniques (Domaine/IP)
|
// 1. Récupération des détails techniques (Domaine/IP)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDetails = async () => {
|
const fetchDetails = async () => {
|
||||||
@@ -39,8 +42,8 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
|
|
||||||
// 2. Extraction robuste du domaine
|
// 2. Extraction robuste du domaine
|
||||||
let extractedDomain = "Aucun domaine lié";
|
let extractedDomain = "Aucun domaine lié";
|
||||||
if (serviceDetails?.domain || serviceDetails?.config?.domain) {
|
if (serviceDetails?.config?.hostname || serviceDetails?.config?.sld && serviceDetails?.config?.tld) {
|
||||||
extractedDomain = serviceDetails.domain || serviceDetails.config.domain;
|
extractedDomain = serviceDetails.config.hostname || serviceDetails.config.sld+serviceDetails.config.tld;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderMarkdownFeatures = (text) => {
|
const renderMarkdownFeatures = (text) => {
|
||||||
@@ -174,12 +177,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
|
|
||||||
isRefund = finalInvoicePrice < -0.01;
|
isRefund = finalInvoicePrice < -0.01;
|
||||||
|
|
||||||
// --- 3. Style du bouton (Triggers : Upgrade / Downgrade / Cycle) ---
|
|
||||||
|
|
||||||
// On récupère le forfait actuel depuis le catalogue pour comparer la "puissance" brute des forfaits
|
|
||||||
const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
|
const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
|
||||||
|
|
||||||
// Pour définir la hiérarchie absolue, on compare les prix de base sur 1 Mois
|
|
||||||
const baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0;
|
const baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0;
|
||||||
const baseSelectedPrice = selectedPlan?.pricing?.recurrent?.['1M']?.price || 0;
|
const baseSelectedPrice = selectedPlan?.pricing?.recurrent?.['1M']?.price || 0;
|
||||||
|
|
||||||
@@ -187,12 +185,10 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
const isSamePeriod = currentBillingPeriod === billingPeriod;
|
const isSamePeriod = currentBillingPeriod === billingPeriod;
|
||||||
|
|
||||||
if (isSamePlan && !isSamePeriod) {
|
if (isSamePlan && !isSamePeriod) {
|
||||||
// Le pack est identique, seule la période change
|
|
||||||
migrationLabel = "Changer de cycle";
|
migrationLabel = "Changer de cycle";
|
||||||
buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]";
|
buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]";
|
||||||
ActionIcon = <RefreshCcw className="w-4 h-4" />;
|
ActionIcon = <RefreshCcw className="w-4 h-4" />;
|
||||||
} else if (!isSamePlan) {
|
} else if (!isSamePlan) {
|
||||||
// Le pack change, on compare la hiérarchie absolue
|
|
||||||
if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) {
|
if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) {
|
||||||
migrationLabel = `Valider l'Upgrade`;
|
migrationLabel = `Valider l'Upgrade`;
|
||||||
buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]";
|
buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]";
|
||||||
@@ -204,12 +200,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const handleMigration = async () => {
|
const handleMigration = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 🎯 On détermine dynamiquement le type d'action pour la facture
|
|
||||||
let actionType = 'upgrade';
|
let actionType = 'upgrade';
|
||||||
const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
|
const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
|
||||||
const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0);
|
const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0);
|
||||||
@@ -231,7 +224,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
target_plan_id: selectedPlan.id,
|
target_plan_id: selectedPlan.id,
|
||||||
new_period: billingPeriod,
|
new_period: billingPeriod,
|
||||||
new_price: finalInvoicePrice.toFixed(2),
|
new_price: finalInvoicePrice.toFixed(2),
|
||||||
action_type: actionType // 🎯 On envoie l'information au backend !
|
action_type: actionType
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -249,40 +242,54 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
// ==========================================
|
||||||
if (!window.confirm("Êtes-vous sûr de vouloir résilier cet abonnement ? Le service sera désactivé au prochain cycle de facturation.")) {
|
// 🛡️ NOUVELLE LOGIQUE DE RÉSILIATION (US 2.1 / 2.2)
|
||||||
return;
|
// ==========================================
|
||||||
}
|
const calculateRefundEligibility = () => {
|
||||||
|
if (!order || !order.created_at) return false;
|
||||||
|
const orderDate = new Date(order.created_at);
|
||||||
|
const now = new Date();
|
||||||
|
const diffDays = Math.ceil(Math.abs(now - orderDate) / (1000 * 60 * 60 * 24));
|
||||||
|
return diffDays <= 14;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEligibleForRefund = calculateRefundEligibility();
|
||||||
|
|
||||||
|
const handleConfirmCancel = async () => {
|
||||||
setCancelLoading(true);
|
setCancelLoading(true);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, {
|
const data = await cancelOrder(order.id);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
action: 'cancel',
|
|
||||||
order_id: order.id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
|
// Succès normal
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
if (onAlert) onAlert("Résilié", data.message || "L'abonnement a été annulé avec succès.", "success");
|
if (onAlert) onAlert("Résilié", data.message, "success");
|
||||||
|
setIsCancelModalOpen(false);
|
||||||
if (onRefresh) onRefresh();
|
if (onRefresh) onRefresh();
|
||||||
if (onClose) onClose();
|
if (onClose) onClose();
|
||||||
} else {
|
} else {
|
||||||
if (onAlert) onAlert("Erreur", data.error || "Impossible de résilier l'abonnement.", "error");
|
if (onAlert) onAlert("Erreur", data.error, "error");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (onAlert) onAlert("Erreur", "Problème réseau ou serveur.", "error");
|
console.error("Erreur API Annulation:", err);
|
||||||
|
|
||||||
|
// 🛡️ LE FILET DE SÉCURITÉ : Si on détecte l'erreur HTML 502 (Unexpected token)
|
||||||
|
// Cela signifie que le serveur a bien redémarré HestiaCP et coupé la connexion.
|
||||||
|
if (err.message && err.message.includes('Unexpected token')) {
|
||||||
|
if (onAlert) onAlert("Succès", "L'abonnement a été annulé et les services mis à jour.", "success");
|
||||||
|
setIsCancelModalOpen(false);
|
||||||
|
// On met un petit délai avant de rafraîchir, le temps qu'HestiaCP finisse de recharger
|
||||||
|
if (onRefresh) setTimeout(() => onRefresh(), 2000);
|
||||||
|
if (onClose) setTimeout(() => onClose(), 2000);
|
||||||
|
} else {
|
||||||
|
if (onAlert) onAlert("Erreur", "Problème réseau lors de la résiliation.", "error");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setCancelLoading(false);
|
setCancelLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full max-h-[95vh] md:h-auto md:max-h-[90vh] max-w-6xl mx-auto text-white">
|
<div className="flex flex-col w-full max-h-[95vh] md:h-auto md:max-h-[90vh] max-w-6xl mx-auto text-white relative">
|
||||||
|
|
||||||
<div className="shrink-0 space-y-4 mb-4 pr-2">
|
<div className="shrink-0 space-y-4 mb-4 pr-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3">
|
<div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3">
|
||||||
@@ -316,10 +323,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a href={`/facturation`} className="text-xs font-bold text-cyan-400 hover:text-cyan-300 transition-colors flex items-center gap-1.5 bg-cyan-950/20 px-4 py-2 rounded-lg border border-cyan-900/30">
|
||||||
href={`/facturation`}
|
|
||||||
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 transition-colors flex items-center gap-1.5 bg-cyan-950/20 px-4 py-2 rounded-lg border border-cyan-900/30"
|
|
||||||
>
|
|
||||||
Voir les factures
|
Voir les factures
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +433,6 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3">
|
<div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3">
|
||||||
{/* 🎯 NOUVEAU : AFFICHAGE DYNAMIQUE (PRORATA ET REMBOURSEMENT) */}
|
|
||||||
{selectedPlan && !isNoChange && (
|
{selectedPlan && !isNoChange && (
|
||||||
<div className={`border p-3 rounded-xl mb-3 flex items-center justify-between transition-colors
|
<div className={`border p-3 rounded-xl mb-3 flex items-center justify-between transition-colors
|
||||||
${isRefund ? 'bg-amber-950/20 border-amber-900/50' : 'bg-[#0e2238] border-cyan-900/50'}`}
|
${isRefund ? 'bg-amber-950/20 border-amber-900/50' : 'bg-[#0e2238] border-cyan-900/50'}`}
|
||||||
@@ -473,8 +476,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 🎯 NOUVEAU BOUTON : Ouvre la modale au lieu de faire un confirm() */}
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={() => setIsCancelModalOpen(true)}
|
||||||
disabled={loading || cancelLoading}
|
disabled={loading || cancelLoading}
|
||||||
className="w-full flex items-center justify-center gap-2 text-red-400/80 hover:text-red-400 border border-red-950/30 bg-red-950/5 hover:bg-red-950/15 p-3 rounded-xl text-xs font-bold uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full flex items-center justify-center gap-2 text-red-400/80 hover:text-red-400 border border-red-950/30 bg-red-950/5 hover:bg-red-950/15 p-3 rounded-xl text-xs font-bold uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
@@ -482,6 +486,71 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
|
|||||||
{cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'}
|
{cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
{/* 🛡️ MODALE DE CONFIRMATION (Thème Sombre Intégré) */}
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
{isCancelModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-[#0d1527] border border-gray-800 rounded-2xl shadow-2xl max-w-md w-full p-6 transform transition-all">
|
||||||
|
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-5 h-5 text-red-500" />
|
||||||
|
Confirmer la résiliation
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Condition d'affichage : Rétractation J+14 vs Résiliation standard */}
|
||||||
|
{isEligibleForRefund ? (
|
||||||
|
<div className="bg-emerald-950/30 border border-emerald-900/50 p-4 mb-5 text-emerald-400 text-sm rounded-xl">
|
||||||
|
<p className="font-bold mb-1 flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4" /> Droit de rétractation (≤ 14 jours)
|
||||||
|
</p>
|
||||||
|
<p className="text-emerald-500/80 mt-2">
|
||||||
|
Votre abonnement a été activé il y a moins de 14 jours.
|
||||||
|
<strong className="text-emerald-400"> Vous allez être intégralement remboursé</strong>.
|
||||||
|
Vos services HestiaCP et bases de données seront supprimés immédiatement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-amber-950/30 border border-amber-900/50 p-4 mb-5 text-amber-400 text-sm rounded-xl">
|
||||||
|
<p className="font-bold mb-1">📅 Résiliation standard (> 14 jours)</p>
|
||||||
|
<p className="text-amber-500/80 mt-2">
|
||||||
|
La période de rétractation est dépassée. Votre abonnement restera actif jusqu'à sa date d'échéance officielle.
|
||||||
|
<strong className="text-amber-400"> Aucun renouvellement ni prélèvement n'aura lieu.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-gray-400 mb-6 text-sm">
|
||||||
|
Êtes-vous absolument sûr de vouloir procéder ? Cette action est irréversible une fois validée par nos serveurs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCancelModalOpen(false)}
|
||||||
|
disabled={cancelLoading}
|
||||||
|
className="px-5 py-2.5 bg-[#090f1c] text-gray-300 border border-gray-800 rounded-xl hover:bg-gray-800 hover:text-white disabled:opacity-50 transition-colors text-sm font-bold tracking-wide"
|
||||||
|
>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmCancel}
|
||||||
|
disabled={cancelLoading}
|
||||||
|
className="px-5 py-2.5 bg-red-600/90 hover:bg-red-500 text-white border border-red-500/50 rounded-xl flex items-center disabled:opacity-50 transition-colors text-sm font-bold tracking-wide shadow-[0_0_15px_rgba(220,38,38,0.3)]"
|
||||||
|
>
|
||||||
|
{cancelLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Traitement...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Confirmer"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -20,8 +20,10 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
|
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
|
||||||
|
|
||||||
const fetchInventory = async () => {
|
const fetchInventory = async (retryCount = 0) => {
|
||||||
setIsLoading(true);
|
// On n'affiche le loader global que lors du tout premier essai
|
||||||
|
if (retryCount === 0) setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getOrdersList();
|
const data = await getOrdersList();
|
||||||
|
|
||||||
@@ -37,18 +39,29 @@ export default function Dashboard() {
|
|||||||
title.startsWith('domaine ') ||
|
title.startsWith('domaine ') ||
|
||||||
title.startsWith('enregistrement ');
|
title.startsWith('enregistrement ');
|
||||||
|
|
||||||
return !isGhostProduct;
|
return (order.status === 'active' || order.status === 'pending_setup') && !isGhostProduct;
|
||||||
});
|
});
|
||||||
|
|
||||||
setOrders(filteredOrders);
|
setOrders(filteredOrders);
|
||||||
} else {
|
} else {
|
||||||
setOrders([]);
|
setOrders([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setError(null); // On efface l'erreur s'il y en avait une
|
||||||
|
setIsLoading(false); // Le chargement est terminé
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 🛡️ SYSTÈME D'AUTO-GUÉRISON : Si on détecte le redémarrage d'HestiaCP (Erreur HTML/JSON)
|
||||||
|
if (err.message && err.message.includes('Unexpected token') && retryCount < 3) {
|
||||||
|
console.warn(`Redémarrage d'infrastructure détecté. Nouvelle tentative... (Essai ${retryCount + 1}/3)`);
|
||||||
|
// On attend 2,5 secondes, puis la fonction s'appelle elle-même (récursivité)
|
||||||
|
setTimeout(() => fetchInventory(retryCount + 1), 2500);
|
||||||
|
} else {
|
||||||
|
// S'il y a une vraie erreur persistante, on l'affiche au client
|
||||||
setError(err.message || "Impossible de récupérer la télémétrie des services.");
|
setError(err.message || "Impossible de récupérer la télémétrie des services.");
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -206,9 +206,7 @@ export const changeClientPassword = (current_password, new_password) =>
|
|||||||
// Resiliation
|
// Resiliation
|
||||||
// Action : Demande d'annulation (S'occupe de vérifier les 14 jours côté serveur)
|
// Action : Demande d'annulation (S'occupe de vérifier les 14 jours côté serveur)
|
||||||
export const cancelOrder = (orderId) => {
|
export const cancelOrder = (orderId) => {
|
||||||
// On utilise votre moteur apiCall existant, il transmettra automatiquement
|
return apiCall(`${CUSTOM_API_BASE_URL}/custom_api/cancel_subscription.php`, 'POST', {
|
||||||
// le token "Bearer" dans le header Authorization !
|
|
||||||
return apiCall(`${CUSTOM_API_URL}/cancel_subscription.php`, 'POST', {
|
|
||||||
order_id: orderId
|
order_id: orderId
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user