Complete Implemenation of tenancy
This commit is contained in:
264
frontend/src/components/billing/PendingPaymentBanner.tsx
Normal file
264
frontend/src/components/billing/PendingPaymentBanner.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Pending Payment Banner
|
||||
* Shows alert banner when account status is 'pending_payment'
|
||||
* Displays invoice details and provides link to payment confirmation
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AlertCircle, CreditCard, X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Button from '../ui/button/Button';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { API_BASE_URL } from '../../services/api';
|
||||
import PaymentConfirmationModal from './PaymentConfirmationModal';
|
||||
|
||||
interface Invoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
total_amount: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
due_date?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface PendingPaymentBannerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PendingPaymentBanner({ className = '' }: PendingPaymentBannerProps) {
|
||||
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const { user, refreshUser } = useAuthStore();
|
||||
|
||||
const accountStatus = user?.account?.status;
|
||||
const isPendingPayment = accountStatus === 'pending_payment';
|
||||
|
||||
useEffect(() => {
|
||||
if (isPendingPayment && !dismissed) {
|
||||
loadPendingInvoice();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isPendingPayment, dismissed]);
|
||||
|
||||
const loadPendingInvoice = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
// Fetch pending invoices for this account
|
||||
const response = await fetch(`${API_BASE_URL}/v1/billing/invoices/?status=pending&limit=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success && data.results?.length > 0) {
|
||||
setInvoice(data.results[0]);
|
||||
|
||||
// Load payment method if available
|
||||
const country = (user?.account as any)?.billing_country || 'US';
|
||||
const pmResponse = await fetch(`${API_BASE_URL}/v1/billing/admin/payment-methods/?country=${country}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const pmData = await pmResponse.json();
|
||||
if (pmResponse.ok && pmData.success && pmData.results?.length > 0) {
|
||||
setPaymentMethod(pmData.results[0]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load pending invoice:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true);
|
||||
// Store dismissal in sessionStorage to persist during session
|
||||
sessionStorage.setItem('payment-banner-dismissed', 'true');
|
||||
};
|
||||
|
||||
const handlePaymentSuccess = async () => {
|
||||
setShowPaymentModal(false);
|
||||
// Refresh user data to update account status
|
||||
await refreshUser();
|
||||
};
|
||||
|
||||
// Don't show if not pending payment, loading, or dismissed
|
||||
if (!isPendingPayment || loading || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if already dismissed in this session
|
||||
if (sessionStorage.getItem('payment-banner-dismissed') === 'true') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If no invoice found, show simplified banner
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className={`relative border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-900/20 ${className}`}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertCircle className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-amber-900 dark:text-amber-100">
|
||||
Payment Required
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">
|
||||
Your account is pending payment. Please complete your payment to activate your subscription.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Link to="/account/plans">
|
||||
<Button variant="primary" size="sm">
|
||||
View Billing Details
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-1 hover:bg-amber-100 dark:hover:bg-amber-800/40 rounded transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format due date
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const isDueSoon = invoice.due_date && new Date(invoice.due_date) <= new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
|
||||
const isOverdue = invoice.due_date && new Date(invoice.due_date) < new Date();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative border-l-4 ${isOverdue ? 'border-red-500 bg-red-50 dark:bg-red-900/20' : 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'} ${className}`}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertCircle
|
||||
className={`w-6 h-6 flex-shrink-0 mt-0.5 ${isOverdue ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400'}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-semibold ${isOverdue ? 'text-red-900 dark:text-red-100' : 'text-amber-900 dark:text-amber-100'}`}>
|
||||
{isOverdue ? 'Payment Overdue' : 'Payment Required'}
|
||||
</h3>
|
||||
{isDueSoon && !isOverdue && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-amber-200 text-amber-900 dark:bg-amber-700 dark:text-amber-100">
|
||||
Due Soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={`mt-1 text-sm ${isOverdue ? 'text-red-800 dark:text-red-200' : 'text-amber-800 dark:text-amber-200'}`}>
|
||||
Your subscription is pending payment confirmation. Complete your payment to activate your account and unlock all features.
|
||||
</p>
|
||||
|
||||
{/* Invoice Details */}
|
||||
<div className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<span className={`block font-medium ${isOverdue ? 'text-red-700 dark:text-red-300' : 'text-amber-700 dark:text-amber-300'}`}>
|
||||
Invoice
|
||||
</span>
|
||||
<span className={`${isOverdue ? 'text-red-900 dark:text-red-100' : 'text-amber-900 dark:text-amber-100'}`}>
|
||||
#{invoice.invoice_number}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`block font-medium ${isOverdue ? 'text-red-700 dark:text-red-300' : 'text-amber-700 dark:text-amber-300'}`}>
|
||||
Amount
|
||||
</span>
|
||||
<span className={`${isOverdue ? 'text-red-900 dark:text-red-100' : 'text-amber-900 dark:text-amber-100'}`}>
|
||||
{invoice.currency} {invoice.total_amount}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`block font-medium ${isOverdue ? 'text-red-700 dark:text-red-300' : 'text-amber-700 dark:text-amber-300'}`}>
|
||||
Status
|
||||
</span>
|
||||
<span className={`${isOverdue ? 'text-red-900 dark:text-red-100' : 'text-amber-900 dark:text-amber-100'} capitalize`}>
|
||||
{invoice.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`block font-medium ${isOverdue ? 'text-red-700 dark:text-red-300' : 'text-amber-700 dark:text-amber-300'}`}>
|
||||
{isOverdue ? 'Was Due' : 'Due Date'}
|
||||
</span>
|
||||
<span className={`${isOverdue ? 'text-red-900 dark:text-red-100' : 'text-amber-900 dark:text-amber-100'}`}>
|
||||
{formatDate(invoice.due_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
startIcon={<CreditCard className="w-4 h-4" />}
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
>
|
||||
Confirm Payment
|
||||
</Button>
|
||||
<Link to="/account/plans">
|
||||
<Button variant="outline" size="sm">
|
||||
View Billing Details
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss Button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isOverdue
|
||||
? 'hover:bg-red-100 dark:hover:bg-red-800/40 text-red-600 dark:text-red-400'
|
||||
: 'hover:bg-amber-100 dark:hover:bg-amber-800/40 text-amber-600 dark:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Confirmation Modal */}
|
||||
{showPaymentModal && invoice && paymentMethod && (
|
||||
<PaymentConfirmationModal
|
||||
isOpen={showPaymentModal}
|
||||
onClose={() => setShowPaymentModal(false)}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
invoice={invoice}
|
||||
paymentMethod={paymentMethod}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user